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.”
The mental shift that trips up most teams is that an invoice is not a projection of current state; it is a fact recorded at a point in time. A subscription’s price can change tomorrow, the customer can rename their company next quarter, your tax engine can be upgraded to a new rate table next year — none of those events may retroactively alter what invoice INV-2026-000042 said when it was issued. This is the opposite of how application developers usually think about data, where the row is the truth and history is an afterthought stored in an audit log. For invoicing, the issued document is the truth and the mutable draft is the afterthought. Once you internalize that inversion, the rest of the design — snapshot columns, gap-free counters, credit notes instead of edits — stops feeling like ceremony and starts feeling like the only honest way to model the domain.
There is also a practical reason immutability is non-negotiable beyond regulatory compliance: an invoice is frequently the primary evidence in a payment dispute, a chargeback, or a collections case. If a customer disputes a 1999-cent charge and your system can no longer reproduce the exact document they received — the same line items, the same tax breakdown, the same total — you have lost the argument before it starts. A finalized invoice that renders identically every time it is fetched, byte-for-byte from a stored snapshot, is what lets you answer “here is precisely what we billed and when” with confidence months or years after the fact.
Prerequisites
Compliant invoicing rests on a few structural commitments: a gap-free numbering sequence, a draft-versus-finalized status model, a frozen legal snapshot, and object storage for immutable PDFs. The stack lists them before the checklist.
Each of these commitments carries a hidden requirement that is easy to overlook until it fails in production. The per-legal-entity sequence, for example, must be scoped to the entity that legally issues the invoice, not to your SaaS tenant or your internal customer_id. If your company operates through a US Inc. and a German GmbH, each files its own tax returns and each needs its own uninterrupted number line; sharing one counter across both would produce a sequence that neither tax authority can reconcile against its filings. Similarly, the frozen legal snapshot is not merely the customer’s name — it is the seller’s registered address and tax registration number as they stood on the issue date, because those change too when you register in a new country or restructure an entity.
The object storage requirement deserves a specific note: the bucket holding finalized PDFs should be configured write-once, and ideally with object-lock or a retention policy, because the whole point of storing the rendered document is that it is the canonical copy a customer or auditor can retrieve unchanged. Treat the PDF store as append-only infrastructure, not as a cache you can freely purge. A pdf_object_key that resolves to a 404 three years after issue is a compliance gap, not just a broken download link. Retention windows in the EU commonly run to ten years for VAT records, so size and budget the store for long horizons from day one rather than assuming you can prune it like application logs.
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.
What lives in the draft, and what triggers finalization
The draft phase is where all the messy, recomputable work happens. As usage accrues, as a proration is recalculated because the customer upgraded mid-cycle, or as a tax determination is refined once the shipping address is confirmed, the draft’s subtotal, tax_total, and total are rewritten in place with no consequence. A draft can be deleted outright; it has no number, so deleting it leaves no gap and no trace that regulators care about. This is deliberate: you want the largest possible window in which mistakes are cheap. Finalization should happen at the last responsible moment — typically when the billing period closes and payment is about to be attempted, or when a customer explicitly requests the document — because every field you can still recompute is a field you have not yet frozen into evidence.
The event that triggers finalization is worth designing explicitly rather than letting it happen implicitly. A common pattern is that the billing engine emits a billing_period_closed event carrying the subscription_id and the settled line items; a consumer then finalizes the draft. Making finalization an explicit, idempotent operation keyed on that event — rather than a side effect of, say, rendering the customer’s billing page — prevents the classic bug where a document gets a number the first time someone opens a URL. Numbers are scarce and ordered; they should only ever be consumed by a deliberate, transactional action.
The two number lines and how they relate
Invoices and credit notes travel on separate sequential lines, but they are bound together by reference. A credit note stores the invoice_id it corrects and, on its face, the human-readable invoice number, so an auditor tracing CN-2026-000007 can immediately find INV-2026-000042 and vice versa. This bidirectional link is what turns a pile of documents into an auditable chain: every euro that was invoiced and later reversed can be followed from the original charge to its correction and into the offsetting ledger entries. The architecture therefore has two write-once artifacts per correction — the credit note record and its PDF — plus one pair of ledger postings, all created in the same transaction so a partial correction can never exist.
Implementation Walkthrough
The five steps build toward one irreversible edge: model the states, assign a gap-free number atomically, finalize by freezing everything in one transaction, render the PDF from the snapshot, and issue credit notes for corrections. The draft → finalized transition is the one-way door — the diagram highlights it.
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.
Two subtleties in this function repay attention. The first is the early return when status != "draft": this is what makes finalization safe to call twice. Delivery systems retry, users double-click, and message queues occasionally deliver the same billing_period_closed event more than once. Because the guard reads the row under the same lock that finalization would take (load_invoice_for_update), a second concurrent call blocks until the first commits, then sees finalized and returns the number already assigned without consuming a new one. Without that guard, a retry would either burn a second invoice number — creating a gap the first document can no longer fill — or, worse, produce a duplicate document for the same billing period. The second subtlety is ordering: the number is drawn and the snapshot frozen inside the transaction, but the PDF render is enqueued only after commit. If you rendered inside the transaction you would hold the counter lock for the entire duration of HTML-to-PDF conversion, which can run into hundreds of milliseconds, serializing every finalizer behind a slow rendering step. Keeping the lock hold time to a few fast row writes is what lets the serial path stay fast enough that its serialization is invisible in practice.
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.
The reason the template pulls seller and buyer from seller_legal_json and buyer_legal_json rather than joining to the live customers table cannot be overstated: it is the single most common place immutability leaks. A well-meaning engineer refactoring the invoice template to “reuse the customer address component” quietly reintroduces a live join, and six months later a customer who moved offices finds that every historical invoice now shows their new address — including ones already filed with a tax authority at the old one. Rendering strictly from the snapshot columns is the defense, and it is worth adding a lint rule or a code-review checklist item that forbids the invoice renderer from importing the live customer repository at all. The renderer’s only inputs should be the frozen row and its immutable line items.
Determinism is the other property this step must guarantee. Rendering the same finalized invoice twice must yield the same bytes, which means the template cannot embed anything that varies at render time: no “generated on” timestamp reflecting now(), no locale drawn from the current request, no font that might be substituted differently on a different worker. Pin the fonts into the image, derive every displayed date from stored columns like finalized_at, and format money by dividing the stored integer minor units by the currency’s exponent rather than trusting a runtime locale. A deterministic renderer is what makes the stable-key overwrite safe — re-running the job produces an identical object, so a retry after a crash is a no-op rather than a subtle content drift.
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.
Credit notes come in two flavors that a robust system distinguishes. A full credit note reverses the entire original invoice — used when a charge was issued in error or a subscription is cancelled with a full refund — and its amount_cents equals the original total. A partial credit note reverses only some of the value, for example crediting one disputed line item while leaving the rest of the invoice standing. The partial case is where teams get the accounting wrong: it is not enough to record a single lump amount; if the credit corresponds to a taxable line, the credit note must itself carry a tax breakdown so that the net and tax reversals land in the correct ledger accounts and flow correctly into the VAT return. Reversing 1000 cents of net plus 200 cents of tax is a different posting from reversing 1200 cents of net, even though both reduce the customer’s balance by 1200.
There is a sequencing consideration too. A credit note can only reference an invoice that has itself been finalized — you cannot correct a document that was never issued. The assert inv.status == "finalized" guard enforces that invariant, but the more interesting constraint is temporal: the credit note’s own issue date should fall in the tax period in which the correction is recognized, which may be a later period than the original invoice. This is why credit notes get their own date and their own place in the sequence rather than inheriting the original’s; the reversal is an event that happens when it happens, and back-dating it into a closed tax period would misstate a return that has already been filed.
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
The failure modes cluster around numbering (concurrency and rollback gaps), immutability (edits and stale PDFs), and correction routing. The map groups them so each defense — a row lock, a snapshot render, or a credit note — is obvious.
| 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 |
The gap that is allowed, and the gap that is not
It helps to be precise about what “gap-free” actually forbids, because engineers sometimes over-engineer against gaps that regulators do not care about. What tax authorities require is that within a single issued sequence, no number is skipped: if INV-2026-000041 and INV-2026-000043 exist, INV-2026-000042 must also exist and be accounted for. What they do not forbid is that a draft you were preparing was deleted before it ever received a number — an abandoned draft is invisible to the sequence precisely because numbering happens at finalization, not at creation. The design that satisfies both facts is the one already described: creation is free and numberless, finalization is transactional and number-consuming. The trap to avoid is assigning the number too early, at draft creation, because then every abandoned or test draft burns a real number and you are left explaining phantom gaps to an auditor. Assign late, assign inside the transaction, and the gap problem largely disappears.
Voiding versus crediting
A related routing decision is whether a mistaken invoice should be voided or credited. Some jurisdictions permit voiding an invoice that was finalized but never sent to the customer and never posted to the books, effectively marking it cancelled while keeping its number reserved so the sequence stays intact. Once an invoice has been delivered or has hit the ledger, however, voiding is no longer honest — the customer has evidence of a charge, and the correct instrument is a credit note that visibly reverses it. A useful rule of thumb: void only within the same transaction-visible window before anything external has observed the document; after that, always credit. Encode this as a hard state-machine constraint rather than a convention, because the temptation to “just void it and redo” grows exactly when someone is under pressure to fix a billing mistake quickly, which is precisely when the audit trail matters most.
Performance & Scale
Invoicing has one contended path (finalization, which serializes on the per-entity counter) and one parallel path (PDF rendering, which fans out on a queue). Keeping them separate means numbering stays gap-free while rendering scales. The diagram contrasts the two.
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.
To put concrete numbers on the contention: a single row-lock acquisition, increment, and release inside a fast transaction takes on the order of a millisecond or two when the counter row is in the buffer cache, so a per-entity line can sustain a few hundred finalizations per second before the lock becomes the bottleneck. That headroom covers the overwhelming majority of SaaS businesses, where finalization is bursty — concentrated at month-end when billing periods close — rather than uniformly high. The batch-finalize approach exists precisely for that month-end burst: instead of firing ten thousand independent finalize calls that each contend for the same lock, one job takes the lock once, reserves a contiguous block of ten thousand numbers with a single UPDATE ... SET last_value = last_value + 10000, and then assigns them to the drafts in memory. This turns ten thousand lock round-trips into one, and because the block is contiguous it stays gap-free by construction as long as the whole batch commits atomically.
The rendering path scales differently and is bounded by CPU and memory rather than lock contention, because HTML-to-PDF conversion spins up a headless rendering engine that is heavy per document. Size the render queue’s concurrency to your worker pool and expect each render to cost tens to low hundreds of milliseconds of CPU. The saving grace is that a rendered invoice never needs to be produced twice: once the object lands under its stable key, every subsequent request — the customer downloading it, finance re-exporting it, an auditor retrieving it years later — is a cheap object-store read. Treat the first render as the only expensive operation in a document’s entire life and design the download endpoint to serve the stored bytes directly, never regenerating on demand. A useful monitoring signal is the lag between finalized_at and the moment pdf_object_key is populated; if that lag grows, your render queue is falling behind and customers are seeing “invoice is being prepared” states longer than they should.
Testing Strategy
Two properties dominate the test suite: gap-free numbering under concurrency and finalized-document immutability. Around them sit a snapshot-render test and an idempotent-finalization test. The panel lists them before the code.
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.
The concurrency test deserves to be run against a real database rather than a mock, because the property it verifies — that a row lock actually serializes finalizers — is a property of the database’s locking behavior, not of your Python. A mock that returns incrementing integers will pass the test while proving nothing; only a genuine transaction against PostgreSQL, with sixteen workers racing to increment the same counter row, exercises the code path that matters. Run it enough times, or with enough workers relative to your connection pool, that any missing FOR UPDATE would reliably surface as a duplicate. It is the kind of bug that never appears in single-threaded development and appears immediately in production under load, so paying for a realistic concurrency test is cheap insurance.
Testing the correction path and the money math
Beyond numbering and immutability, the credit-note path needs its own coverage because it is where the accounting can silently go wrong. Write a test that issues a partial credit note against a multi-line invoice and asserts three things at once: the credit note carries its own number on the CN- line, the referenced invoice_id round-trips so the link is traceable, and the offsetting ledger postings sum to exactly the credited amount including its tax component. A good adversarial case is crediting 1200 cents where 1000 is net and 200 is tax, then asserting the revenue account moved by 1000 and the tax-payable account by 200 — not that a single 1200 landed in one account. Add a property-style test that credits an invoice in full and asserts the customer’s net position across the invoice and its credit note is exactly zero, which catches sign errors and rounding drift in one shot. Because every amount in these tests is an integer count of minor units, assertions are exact; there is no floating-point tolerance to reason about, and any test that needs a tolerance is a signal that money slipped into a float somewhere upstream.
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.
When exactly should a draft be finalized — at period close, or on demand?
The safe default is to finalize at the last responsible moment, which is usually when the billing period closes and payment is about to be attempted. Finalizing earlier freezes fields you might still need to recompute — a late proration, a corrected tax determination — into a document you can then only fix with a credit note. Finalizing on demand when a customer clicks “download invoice” is also fine, provided the underlying amounts are settled, but never let a passive page view assign a number. Number assignment should always be a deliberate, transactional action keyed to a real business event carrying the subscription_id, not a side effect of rendering a UI.
How should I store money amounts on the invoice?
As integer minor units — cents, pence, or the currency’s smallest unit — in BIGINT columns, never as floating point or a decimal shoehorned into a float at any layer. Store subtotal, tax_total, and total separately rather than recomputing the total on read, so the finalized document records the exact figures that were presented. When you display an amount, divide by the currency’s exponent (100 for most, 1 for zero-decimal currencies like JPY) at the very edge. Keeping money as integers end to end is what makes the credit-note reconciliation assertions exact and eliminates the rounding drift that plagues float-based billing.
What happens if the PDF render job fails after the invoice is finalized?
Nothing about the invoice’s validity changes — it is finalized, numbered, and immutable the moment the transaction commits; the PDF is a derived artifact, not the source of truth. The render job is enqueued keyed by invoice_id and is safe to retry, and because the renderer is deterministic and writes to a stable object key, a retry produces byte-identical output rather than a second document. Monitor the lag between finalized_at and a populated pdf_object_key; a growing lag means the render queue is behind, but it never means a customer was under- or over-billed. Serve downloads from the stored object and regenerate only if the key is genuinely missing.