---
title: Invoice lifecycle
path: concepts/invoice-lifecycle
status: published
---

# Invoice lifecycle

Invoices in ScaiControl follow a strict state machine designed to satisfy EU invoicing rules: once finalised, an invoice is immutable. Corrections happen via credit notes, not edits.

## States

```
draft       Editable. No legal force. Buyer/seller not snapshotted yet.
finalized   Locked. Snapshots applied. PDF generated, archived in S3.
sent        Emailed to the buyer via ScaiSend.
past_due    A scheduled payment failed; still owed.
paid        Customer paid in full.
void        Cancelled by the operator (rare; usually credit note instead).
```

Allowed transitions:

```mermaid
stateDiagram-v2
    [*] --> draft
    draft --> finalized
    draft --> void
    finalized --> sent
    finalized --> void
    sent --> past_due
    sent --> paid
    sent --> void
    past_due --> paid
    past_due --> void
    paid --> [*]
    void --> [*]
```

## Numbering

Invoice numbers follow a gap-free sequence per fiscal year:

```
SCAI-YYYY-NNNNNN
```

The next number comes from `invoice_sequence(fiscal_year, next_number)` under a `SELECT … FOR UPDATE` lock. This guarantees no gaps and no duplicates even under concurrent finalisations. Credit notes use a parallel sequence with their own prefix:

```
SCAI-CN-YYYY-NNNNNN
```

## Lifecycle endpoints

| Operation | Endpoint | What happens |
|---|---|---|
| Create draft | `POST /admin/invoices` | New row, `status=draft`, no number yet |
| Edit draft | `PATCH /admin/invoices/{id}` | Mutates notes, dates, period, and the full line items list (replaces) |
| Reorder lines | `PUT /admin/invoices/{id}/line-items/order` | Renumbers `order_index` 0..N |
| Sort by date | `POST /admin/invoices/{id}/line-items/sort-by-date` | Parses leading `DD-MM-YYYY` from each description, sorts |
| Finalize | `POST /admin/invoices/{id}/finalize` | Allocates number; snapshots buyer/seller; applies VAT; generates PDF; uploads to S3; `→ finalized` |
| Regenerate PDF | `POST /admin/invoices/{id}/regenerate-pdf` | Re-renders the PDF in-place (e.g. after template edit). Keeps the original number/snapshots; only the PDF bytes change. |
| Send by email | `POST /admin/invoices/{id}/send` | Renders the email template, attaches the PDF (and optional e-invoice XML), POSTs to ScaiSend, `→ sent` |
| Credit note | `POST /admin/invoices/{id}/credit-note` | Creates a new invoice with `document_type=credit_note`, `referenced_invoice_id` set, line items negated |
| Refund | `POST /admin/invoices/{id}/refund` | (When invoice paid) refund via the original payment provider |

See [Admin — billing & invoices](../reference/api/admin-billing) for the full request/response shapes.

## Finalisation: what happens inside

The `services/billing/lifecycle.py:finalize_invoice` flow:

1. **Validate** — must be in `draft`, must have at least one line item, buyer billing profile must exist.
2. **Snapshot buyer** — copies `tenant_billing_profiles` row into `invoices.buyer_snapshot`.
3. **Snapshot seller** — copies relevant fields from `partner_configuration` into `invoices.seller_snapshot`.
4. **Determine VAT** — calls `services/billing/vat.py:determine_vat` with seller country, buyer country, buyer VAT number, and `is_business`. Sets per-invoice `tax_rate`, `reverse_charge`, and per-line `tax_category` + `tax_cents`. Aggregates into `invoices.vat_details.entries[]`.
5. **Allocate number** — `services/billing/numbering.py:get_next_invoice_number` under row lock.
6. **Generate PDF** — `services/billing/template_renderer.py:render_pdf` with the resolved designer template (tenant → partner fallback, language-matched) plus Blogger Sans embedded as base64 data URLs. WeasyPrint with a custom URL fetcher that strips `width="100%" height="100%"` from inner SVGs.
7. **Upload to S3** — `s3://<bucket>/uploads/invoices/<number>.pdf`. Stores the s3:// URL on `invoices.pdf_url`.
8. **Set state** — `status = 'finalized'`, `finalized_at = now`.
9. **Emit event** — `subscription.changed.v1` doesn't fire here (invoice events are not in the launch catalog); future addition.

After finalisation the invoice is legally an invoice and is immutable. Trying to PATCH it returns 400.

## Sending

`POST /admin/invoices/{id}/send` does:

1. Resolve recipient (buyer snapshot's contact_email → tenant billing profile's contact_email).
2. Resolve buyer's preferred language from the snapshot or live profile.
3. Resolve the email template via the same chain as the PDF (tenant → partner → built-in).
4. Render subject + HTML + plain-text from the template against a context with the same shape as the PDF context (seller, buyer, document, line items, total, vat_breakdown).
5. Re-render the PDF using the *current* designer template (so template fixes since finalisation are picked up) and upload to S3, overwriting the previous PDF. This is intentional: the operator is responsible for getting the template right before sending; once sent, do not edit the template.
6. Attach the PDF (and optionally an e-invoice XML).
7. POST to ScaiSend.
8. `→ sent`, record `send_history` in `metadata`.

## Credit notes

A credit note (`document_type='credit_note'`) is itself an invoice — same numbering scheme but separate sequence, same template pipeline (but uses the `credit_note_default.html` template or its designer override). Line items are negated copies of the original; references the original via `referenced_invoice_id`.

Two flavours:

- **Full credit** — every line of the original is negated.
- **Partial credit** — selected items only, with explicit amounts.

Once issued, a credit note is finalised and emitted immediately (no separate draft → finalize step for credit notes). The original invoice's `status` stays as-is (`paid`, `sent`, `past_due` — whatever it was); the credit note offsets it on the customer's ledger.

## VAT details

`invoices.vat_details` is a JSON column:

```json
{
  "entries": [
    {
      "category": "S",           // UNCL 5305: S/AE/G/E/Z/O/K
      "rate": "21.00",
      "label": "VAT 21.00%",     // localised at render time
      "taxable_cents": 15000,
      "amount_cents": 3150,
      "legal_note": "Reverse charge - Art. 196 EU VAT Directive"  // when applicable
    }
  ]
}
```

The label and legal note are stored in English (canonical, for audit) but localised at render time via `services/billing/vat.py:localize_legal_note` / `localize_tax_label` to match the buyer's preferred_language.

## E-invoicing formats

Beyond the human-readable PDF, ScaiControl can attach a structured XML alongside:

| Format | Schema | Used by |
|---|---|---|
| `ubl` | UBL 2.1 invoice | Generic Peppol-compatible, OASIS |
| `xrechnung` | UBL profile (CIUS-XR) | German public sector |
| `cii` | UN/CEFACT Cross Industry Invoice | French Factur-X non-hybrid |
| `zugferd` | Hybrid PDF/A-3 with CII embedded | German B2B |
| `facturx` | Same hybrid as ZUGFeRD | French label for the same standard |

The buyer's `preferred_einvoice_format` drives which one ships. If null, only a plain PDF is attached.

## Snapshots, immutability, and what "edit" really means

What you can do to a finalised invoice:

- **Re-render its PDF.** The template is server-rendered; the snapshot data is frozen, so re-rendering produces a visually-equivalent PDF with whatever template changes you've made since. Use this when the template was wrong but the underlying data is right.
- **Issue a credit note**, then a fresh invoice if the math was wrong.
- **Mark void** in operational cases (e.g. duplicate). Rare; prefer credit notes.

What you cannot do:

- Edit line items.
- Change the buyer or seller snapshots.
- Renumber.
- Change `invoice_date` or `due_date`.
- Mutate `vat_details`.

This is a hard line — it's enforced at the service layer with a `BadRequestError`, and the admin UI hides edit controls for non-draft invoices.
