Invoicing & Credit Notes

A finalized invoice is a legal document, not a UI view of a database row — once it carries a sequential number it must never change, and in many jurisdictions the only lawful way to undo it is to issue a credit note. This page is part of Tax, Compliance & Revenue Recognition and covers how a billing system generates invoices and credit notes that survive a tax audit: the draft-to-finalized lifecycle, gap-free sequential numbering, the legal fields different tax authorities require, deterministic PDF generation, and the immutability guarantees that make a finalized document trustworthy. The recurring mistake is treating invoices as editable records; the recurring fix is modeling finalization as a one-way door and corrections as new, linked documents.

Invoicing sits downstream of the billing engine and upstream of revenue recognition. The billing engine decides what a customer owes and computes tax; invoicing turns that into a numbered, immutable artifact; and that artifact then drives both the customer-facing PDF and the accounting entries that feed revenue recognition and the ledger. Because the document is legal evidence, the engineering priorities are correctness and immutability over flexibility — you optimize for “this number can never be reused or edited,” not for “this row is easy to update.”

Prerequisites

Architecture & Data Flow

An invoice begins life as a mutable draft that you can freely recompute as line items, proration, and tax settle. Finalization is the irreversible transition: it assigns the next sequential number, freezes every field, and renders the PDF. From that point the document is read-only. A correction — a refund, a price dispute, a tax error — never reopens the invoice; it produces a credit note that references the original and carries its own number in its own sequence.

Invoice lifecycle A draft invoice is finalized into a numbered immutable document that can be paid, while corrections are issued as a separate credit note rather than edits. Draft (editable) Finalized numbered, immutable Paid settled Credit note new document finalize pay correct
Finalization is a one-way door: paid or unpaid, a numbered invoice is corrected only by issuing a separate credit note.

The inputs are billing line items and customer legal data; the processing is finalization plus numbering plus rendering; the outputs are an immutable database record, a stored PDF, and accounting events. The boundary that matters is the draft → finalized edge — everything before it is malleable, everything after it is evidence.

Implementation Walkthrough

1. Model the invoice and its states

Separate the mutable working fields from the frozen snapshot. Finalization populates the snapshot columns and flips the status.

CREATE TABLE invoices (
    invoice_id        UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    legal_entity_id   UUID NOT NULL,
    customer_id       UUID NOT NULL,
    status            TEXT NOT NULL DEFAULT 'draft',  -- draft | finalized | void
    invoice_number    TEXT,                            -- assigned at finalization
    currency          TEXT NOT NULL,
    subtotal          BIGINT NOT NULL DEFAULT 0,       -- cents
    tax_total         BIGINT NOT NULL DEFAULT 0,       -- cents
    total             BIGINT NOT NULL DEFAULT 0,       -- cents
    -- frozen legal snapshot, captured at finalization
    seller_legal_json JSONB,
    buyer_legal_json  JSONB,
    finalized_at      TIMESTAMPTZ,
    pdf_object_key    TEXT,
    created_at        TIMESTAMPTZ NOT NULL DEFAULT now(),
    -- a number is unique within its legal entity
    UNIQUE (legal_entity_id, invoice_number)
);

2. Assign a gap-free sequential number atomically

Many jurisdictions require invoice numbers with no gaps, which rules out database SEQUENCE objects (they leak numbers on rollback). Use a per-legal-entity counter row taken under a row lock so concurrent finalizations serialize. The dedicated walkthrough in Generating Compliant Sequential Invoice Numbers covers concurrency and year resets in depth.

-- inside the finalization transaction
UPDATE invoice_sequences
   SET last_value = last_value + 1
 WHERE legal_entity_id = $1 AND period_year = $2
RETURNING last_value;
-- format e.g. 'INV-2026-000042'; the row lock blocks concurrent finalizers

3. Finalize: freeze content and number in one transaction

Finalization must be all-or-nothing: number assigned, snapshot frozen, status flipped, or none of it.

def finalize_invoice(invoice_id: str) -> str:
    with db.transaction():
        inv = load_invoice_for_update(invoice_id)
        if inv.status != "draft":
            return inv.invoice_number  # ✅ idempotent: already finalized

        number = next_invoice_number(inv.legal_entity_id, year=now().year)
        freeze_legal_snapshot(inv)        # copy seller + buyer details as-of-now
        set_finalized(invoice_id, number) # status -> finalized, finalized_at = now()
    enqueue_pdf_render(invoice_id)         # ⚠️ render after commit, idempotent by id
    return number

Rendering the PDF after commit keeps the transaction short; because the render job is keyed by invoice_id, a retry overwrites the same object rather than producing a second document.

4. Render an immutable PDF snapshot

The PDF must reflect the frozen data, not live database values, so a later customer-name edit can never alter an already-issued document. Render from the snapshot columns and store under a content-addressed key.

def render_invoice_pdf(invoice_id: str) -> str:
    inv = load_invoice(invoice_id)
    assert inv.status == "finalized"           # never render a draft as final
    html = template.render(
        number=inv.invoice_number,
        seller=inv.seller_legal_json,          # frozen snapshot
        buyer=inv.buyer_legal_json,
        lines=load_lines(invoice_id),
        tax_total=inv.tax_total, total=inv.total,
    )
    pdf = html_to_pdf(html)
    key = f"invoices/{inv.legal_entity_id}/{inv.invoice_number}.pdf"
    object_store.put(key, pdf, if_not_exists=False)  # overwrite-safe by stable key
    set_pdf_key(invoice_id, key)
    return key

5. Issue a credit note for corrections

A finalized invoice is never edited. To refund or correct, create a credit note that references the original, carries its own sequential number, and posts the offsetting accounting entry.

def issue_credit_note(invoice_id: str, amount_cents: int, reason: str) -> str:
    with db.transaction():
        inv = load_invoice(invoice_id)
        assert inv.status == "finalized"
        number = next_credit_note_number(inv.legal_entity_id, year=now().year)
        cn_id = create_credit_note(
            references_invoice=invoice_id,
            amount=amount_cents, reason=reason, number=number,
        )
        post_ledger_pair(debit="revenue", credit="accounts_receivable", amount=amount_cents)
    enqueue_pdf_render_credit_note(cn_id)
    return number

Edge Cases & Failure Modes

Scenario Failure if mishandled Mitigation
Concurrent finalization Two invoices share a number Row-lock the per-entity counter inside the transaction
Crash after number, before commit Gap in the sequence Assign the number in the same transaction; it rolls back atomically
Editing a finalized invoice Tampered legal document Block updates on finalized rows; correct via credit note
Customer changes their address later Old PDF silently restates Render from the frozen snapshot, never from live customer data
PDF render retried Duplicate documents Stable object key keyed by invoice number; overwrite, do not append
Wrong tax rate discovered post-issue Restating a closed invoice Issue a credit note plus a corrected new invoice

Performance & Scale

Finalization is the contended path because numbering serializes per legal entity — every finalizer for the same entity briefly queues on one counter row. For most SaaS volumes that is fine; a single entity finalizing thousands of invoices a minute is rare. If you hit it, shard numbering by sub-sequence only where the jurisdiction allows a prefix, or batch-finalize a month of invoices in one job that holds the lock once and assigns a contiguous block. PDF rendering is the expensive-but-parallel path: push it onto a queue, render off the request thread, and cache the stored object so re-downloads never re-render. Index invoices (legal_entity_id, status, finalized_at) so finance’s “all invoices issued in March” query is a range scan, and partition the invoice-lines table by month if line volume dominates.

Testing Strategy

The tests that matter prove two properties: numbers are gap-free under concurrency, and finalized documents are immutable. Simulate concurrency by finalizing many drafts in parallel and asserting the resulting numbers form a contiguous run with no duplicates and no gaps.

def test_concurrent_finalization_has_no_gaps_or_dupes():
    ids = [create_draft(LEGAL_ENTITY) for _ in range(200)]
    numbers = run_in_parallel(finalize_invoice, ids, workers=16)
    ints = sorted(int(n.split("-")[-1]) for n in numbers)
    assert len(set(ints)) == len(ints)                 # no duplicates
    assert ints == list(range(ints[0], ints[0] + 200)) # gap-free run

def test_finalized_invoice_is_immutable():
    inv = finalize(create_draft(LEGAL_ENTITY))
    with pytest.raises(ImmutableInvoiceError):
        update_invoice_total(inv, 99999)               # finalized rows reject edits

Add a snapshot test asserting the rendered PDF reflects the frozen seller_legal_json even after the live customer record is mutated, and an idempotency test asserting that finalizing an already-finalized invoice returns the same number without consuming a new one.

Frequently Asked Questions

Why can’t I just use a database SEQUENCE for invoice numbers? PostgreSQL sequences are designed to be fast and non-blocking, which means they intentionally leak values: a rolled-back transaction does not return its number, so you get gaps. Many tax authorities require gap-free numbering, so you need a counter row updated inside the same transaction as finalization, where a rollback truly un-assigns the number.

Can I ever edit a finalized invoice? No. Once an invoice is finalized and numbered it is a legal record. Any correction — wrong amount, wrong tax, wrong customer — is handled by voiding via a credit note and, if needed, issuing a fresh corrected invoice. Editing the original would break the audit trail and the immutability the document depends on.

What legal fields are actually required on an invoice? It varies by jurisdiction, but a common core is: a unique sequential number, issue date, seller legal name and tax/VAT ID, buyer name (and VAT ID for B2B reverse-charge), a description of goods or services, the net amount, the tax rate and amount per rate, and the total. EU VAT invoices add specifics like a reverse-charge note; capture these from the customer’s jurisdiction at finalization.

Should credit notes share the invoice numbering sequence? Usually no — credit notes get their own sequential series (often a distinct prefix like CN-) so each document type has its own gap-free run. The credit note must reference the original invoice number so the correction is traceable both ways.