Platform
ScaiWave ScaiGrid ScaiCore ScaiBot ScaiDrive ScaiKey Models Tools & Services
Solutions
Organisations Developers Internet Service Providers Managed Service Providers AI-in-a-Box
Resources
Support Documentation Blog Downloads
Company
About Research Careers Investment Opportunities Contact
Log in

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#

scdoc
1
2
3
4
5
6
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:

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:

text
1
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:

text
1
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 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 numberservices/billing/numbering.py:get_next_invoice_number under row lock.
  6. Generate PDFservices/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 S3s3://<bucket>/uploads/invoices/<number>.pdf. Stores the s3:// URL on invoices.pdf_url.
  8. Set statestatus = 'finalized', finalized_at = now.
  9. Emit eventsubscription.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
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "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.

Updated 2026-05-18 01:48:39 View source (.md) rev 2