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#
1 2 3 4 5 6 | |
Allowed transitions:
Numbering#
Invoice numbers follow a gap-free sequence per fiscal year:
1 | |
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:
1 | |
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 for the full request/response shapes.
Finalisation: what happens inside#
The services/billing/lifecycle.py:finalize_invoice flow:
- Validate — must be in
draft, must have at least one line item, buyer billing profile must exist. - Snapshot buyer — copies
tenant_billing_profilesrow intoinvoices.buyer_snapshot. - Snapshot seller — copies relevant fields from
partner_configurationintoinvoices.seller_snapshot. - Determine VAT — calls
services/billing/vat.py:determine_vatwith seller country, buyer country, buyer VAT number, andis_business. Sets per-invoicetax_rate,reverse_charge, and per-linetax_category+tax_cents. Aggregates intoinvoices.vat_details.entries[]. - Allocate number —
services/billing/numbering.py:get_next_invoice_numberunder row lock. - Generate PDF —
services/billing/template_renderer.py:render_pdfwith the resolved designer template (tenant → partner fallback, language-matched) plus Blogger Sans embedded as base64 data URLs. WeasyPrint with a custom URL fetcher that stripswidth="100%" height="100%"from inner SVGs. - Upload to S3 —
s3://<bucket>/uploads/invoices/<number>.pdf. Stores the s3:// URL oninvoices.pdf_url. - Set state —
status = 'finalized',finalized_at = now. - Emit event —
subscription.changed.v1doesn'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:
- Resolve recipient (buyer snapshot's contact_email → tenant billing profile's contact_email).
- Resolve buyer's preferred language from the snapshot or live profile.
- Resolve the email template via the same chain as the PDF (tenant → partner → built-in).
- 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).
- 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.
- Attach the PDF (and optionally an e-invoice XML).
- POST to ScaiSend.
→ sent, recordsend_historyinmetadata.
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:
1 2 3 4 5 6 7 8 9 10 11 12 | |
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_dateordue_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.