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.
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.