Tax, Compliance & Revenue Recognition
Tax and compliance is the highest regulatory-risk gap in most billing stacks, and it is almost always the part teams build last and understand least. A miscalculated subscription proration costs a customer a few cents and a support ticket; a mishandled tax obligation accrues silently across thousands of invoices until a tax authority assesses back taxes, penalties, and interest on revenue you already recognized and spent. Unlike a payment decline, a tax error is invisible at runtime — the charge succeeds, the customer is happy, and the liability compounds in the dark. This guide covers the four interlocking subsystems that close that gap: VAT & GST tax calculation at the point of sale, a reconciliation double-entry ledger that makes every cent auditable, revenue recognition under ASC 606 that separates cash from earned revenue, and invoicing & credit notes that produce the legally compliant documents tax authorities and auditors demand.
The reason this domain is uniquely hard is that it sits at the intersection of three moving targets: the customer’s tax jurisdiction (which depends on location evidence you must collect and store), the nature of the transaction (B2B versus B2C, goods versus electronically supplied services), and a regulatory surface that changes per country, per quarter, and per registration threshold you cross. A billing engine that charges the right amount but applies the wrong tax treatment is not a billing engine — it is a future audit finding. The pages below treat tax as a first-class part of the financial data model rather than a line item bolted onto an invoice at print time.
A second reason this is hard is temporal. Tax is computed at the point of sale, but the obligations it creates stretch for years: a rate that changes mid-quarter, an invoice that is corrected eighteen months later, a customer who deregisters their VAT number while still on a recurring plan, an auditor who asks in 2030 why a 2026 invoice was reverse charged. Every one of those situations requires that you reconstruct the decision you made then, with the inputs that were true then and the rate that was in force then — not what is true today. That is why this domain insists on immutable, append-only records and time-bounded rate tables. You are not just calculating a number; you are recording an auditable judgment that must survive replay, correction, and inspection long after the cash has moved.
Architecture Overview
Tax determination is a decision pipeline, not a lookup. The inputs are the customer’s location evidence, their business status (and validated VAT number if B2B), and the product’s tax category. Those flow into a tax engine that resolves the correct jurisdiction, rate, and treatment, which then become immutable ledger lines on the invoice. The diagram below shows the decision flow that every taxable line item passes through.
The runtime data flow spans several services. The billing engine assembles the invoice draft and asks the tax service to determine tax for each line; the tax service consults rate tables and registration state, returns a treatment, and the billing engine commits the result. The ledger then records net revenue, the tax collected as a liability, and (for reverse charge) a zero-tax line with a legal note. A webhook bus propagates the finalized invoice to downstream consumers — the revenue-recognition scheduler, the reporting warehouse, and the email service.
The critical architectural decision in this flow is the boundary between determination and posting. Determination is the tax service’s job: given the line and the customer, produce a treatment, a jurisdiction, and an amount. Posting is the ledger’s job: take that determination and write balanced, immutable entries. Keeping these separate matters because they fail differently and scale differently. Determination depends on an external rate source that can be slow or wrong; posting depends only on your own database and must be transactional and exact. If you collapse them — computing tax inline as you write ledger rows — a slow tax engine becomes a database-lock problem, and a tax bug corrupts the ledger directly. Separated, the tax service can time out, fall back, and retry without ever holding a ledger transaction open, and the ledger only ever sees a finished, validated determination.
The other property this boundary buys you is reproducibility as a query. Because determination writes an immutable tax_transaction that pins the exact rate row it used, re-deriving any past invoice’s tax is a join against history, not a re-run of live logic that may have changed. This matters more than it first appears: tax engines, rate sources, and even your own determination rules evolve, so “what would we charge this customer today?” and “what did we charge them in 2026?” are different questions with different answers, and only the second one is defensible in an audit. Designing every determination as an as-of computation — resolve inputs as-of the supply date, record the resolution — is what lets the two questions coexist without one corrupting the other. A system that can only answer “today’s rate” has quietly thrown away its own history.
| Service | Responsibility | Reads | Writes |
|---|---|---|---|
| Billing engine | Assemble invoice draft, request tax, finalize | subscription_id, price_id |
invoice_id, draft lines |
| Tax service | Determine jurisdiction, rate, treatment | tax_rates, tax_registrations |
tax_transactions |
| Ledger | Post net, tax-collected, tax-liability lines | finalized invoice | ledger_entries |
| Revenue scheduler | Split cash into earned vs deferred | tax_transactions, invoice |
recognition schedule |
Core Data Model
Three tables form the backbone of tax handling, and their defining property is that two of them are append-only history. The tax_rates table is a slowly-changing dimension with effective-date ranges; tax_registrations gates whether you charge at all; and tax_transactions is the immutable audit record of every determination. The relationships below show how a determination pins the exact rate it used.
Three tables form the backbone of tax handling. The tax_rates table is a slowly-changing dimension — rates have effective date ranges because authorities change them and you must charge the rate that was in force on the invoice date, not today’s rate. The tax_registrations table records where you are registered to collect tax (and your registration number), which drives whether you charge tax at all in a given jurisdiction. The tax_transactions table is the immutable audit record of every determination you made: it captures the inputs, the engine’s decision, and the resolved amounts so you can reconstruct any invoice’s tax treatment years later during an audit.
-- Rates are time-bounded: charge the rate in force on the invoice date.
CREATE TABLE tax_rates (
tax_rate_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
country_code CHAR(2) NOT NULL, -- ISO 3166-1 alpha-2
region_code TEXT, -- US state, CA province; NULL for VAT
tax_category TEXT NOT NULL, -- 'standard','digital_services','reduced'
rate_bps INTEGER NOT NULL, -- basis points: 2100 = 21.00%
effective_from DATE NOT NULL,
effective_to DATE, -- NULL = currently in force
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_tax_rates_lookup
ON tax_rates (country_code, region_code, tax_category, effective_from DESC);
-- Where we are registered to collect; absence means do not charge tax there.
CREATE TABLE tax_registrations (
tax_registration_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
country_code CHAR(2) NOT NULL,
scheme TEXT NOT NULL, -- 'oss','domestic_vat','us_nexus','gst'
registration_number TEXT NOT NULL, -- our VAT/GST/sales-tax id
nexus_established_at DATE,
active BOOLEAN NOT NULL DEFAULT true,
UNIQUE (country_code, scheme)
);
-- Immutable record of every determination — the audit trail.
CREATE TABLE tax_transactions (
tax_transaction_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invoice_id UUID NOT NULL,
customer_id UUID NOT NULL,
jurisdiction TEXT NOT NULL, -- resolved, e.g. 'DE'
treatment TEXT NOT NULL, -- 'standard','reverse_charge','exempt','zero_rated'
tax_rate_id UUID REFERENCES tax_rates(tax_rate_id),
taxable_base_cents BIGINT NOT NULL, -- minor units, never float
tax_amount_cents BIGINT NOT NULL,
location_evidence JSONB NOT NULL, -- the proofs that justified jurisdiction
customer_vat_number TEXT,
determined_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_tax_tx_invoice ON tax_transactions (invoice_id);
Money is always stored as integer minor units (BIGINT cents), never floating point — a 0.5-cent rounding error multiplied across an OSS return is a real reporting discrepancy. The location_evidence JSONB column is deliberately denormalized: tax law requires you to retain the proofs (billing-address country, IP geolocation country, payment-instrument BIN country) that justified the jurisdiction, not just the conclusion.
Two modelling choices in this schema are easy to get wrong and expensive to fix later. The first is making tax_rates an append-only history rather than a mutable current-rate table. When a country changes its VAT rate, you do not update the existing row — you close it off with an effective_to date and insert a new row with the new effective_from. This is what lets a back-dated correction issued today still pick the rate that was legally in force on the original supply date. A mutable rate table silently rewrites history and makes every prior invoice unreproducible. The second is treating tax_transactions as strictly insert-only: there is no UPDATE path. A correction is a new transaction (typically a credit note’s reversal plus a fresh determination), never an edit of the original. Together these two append-only disciplines are what make the entire tax subsystem auditable — the record of what you decided, and the rate you decided it against, can never be quietly mutated out from under an auditor.
Key Design Patterns
Four patterns carry the tax subsystem: sync-versus-async determination, reverse charge modeled as a treatment, tax posted as ledger lines, and idempotent determination. The matrix maps each to the failure it prevents.
Sync vs async tax calculation
For interactive checkout you compute tax synchronously so the customer sees the gross total before they pay. For invoice finalization at cycle close you can batch asynchronously. The trap is calling an external tax engine synchronously on the checkout critical path — a slow tax API becomes a checkout outage. Always set an aggressive timeout and have a fallback.
// ✅ success path: tax resolved within budget
// ⚠️ retry path: transient engine error, retry with backoff off the critical path
// ✗ hard failure: fall back to a conservative cached rate, flag for review
async function determineTax(line: InvoiceLine): Promise<TaxResult> {
try {
return await taxEngine.calc(line, { timeoutMs: 800 });
} catch (err) {
return fallbackRate(line); // never block checkout on the tax engine
}
}
Use synchronous calls only when the user is waiting; everything else belongs in the outbox pattern for billing events so the tax determination is published reliably alongside the invoice.
Reverse charge as a treatment, not a discount
For B2B cross-border supplies within the EU, the supplier charges 0% VAT and the customer self-accounts under the reverse-charge mechanism. This is not “no tax” — it is a specific treatment that must be recorded with the validated customer VAT number and a legal note on the invoice. Model it as a first-class treatment value so reporting can distinguish reverse charge from genuinely exempt or zero-rated supplies.
from dataclasses import dataclass
@dataclass
class TaxDecision:
treatment: str # 'standard' | 'reverse_charge' | 'exempt' | 'zero_rated'
rate_bps: int
note: str | None # invoice legal wording when reverse_charge
def decide_eu_b2b(supplier_country: str, customer_country: str,
vat_validated: bool) -> TaxDecision:
if customer_country != supplier_country and vat_validated:
return TaxDecision("reverse_charge", 0,
"VAT reverse charged — Article 196 Directive 2006/112/EC")
return TaxDecision("standard", lookup_rate(customer_country), None)
Tax as ledger lines
Collected tax is not revenue — it is a liability you owe the authority. The cleanest model posts every taxable charge as separate ledger lines so your tax liability is always derivable from the ledger, never recomputed from invoices. This is why tax integrates directly with the double-entry ledger posting: the invoice gross splits into a revenue credit (net) and a tax-liability credit, balanced by a receivable debit.
-- One taxable charge → three balanced ledger lines (amounts in cents).
INSERT INTO ledger_entries (ledger_entry_id, invoice_id, account, amount_cents) VALUES
(gen_random_uuid(), :invoice_id, 'accounts_receivable', 12100), -- debit gross
(gen_random_uuid(), :invoice_id, 'revenue', -10000), -- credit net
(gen_random_uuid(), :invoice_id, 'vat_payable', -2100); -- credit tax liability
-- Sum of amount_cents = 0 → double-entry invariant holds.
Idempotent determination
Re-finalizing an invoice (a retried webhook, a manual re-run) must not double-charge tax. Key each determination on invoice_id plus a content hash; an idempotent webhook consumer pattern ensures replays return the stored tax_transaction rather than computing a second one.
The content hash in that key is doing real work: it must cover every input that could change the treatment — the line amount, the customer’s country and business status, the validated VAT number, and the product tax category — so that a genuine change (the customer corrected their address before finalization) produces a new determination while a mere replay of the identical inputs returns the stored one. Hash too little and a corrected address silently reuses the old, wrong treatment; hash too much (including a timestamp, say) and every replay looks new and double-taxes. This is the same discipline as idempotency keys elsewhere in the stack, applied to a decision rather than a charge: the key identifies the decision inputs, not the request, so the system converges on exactly one determination per distinct set of facts no matter how many times finalization is retried.
Compliance & Regulatory Boundaries
The regulatory surface spans several regimes, each with its own trigger. The map below groups them so you can see which apply to a given sale — EU VAT for digital services, US nexus post-Wayfair, GST regimes, and the cross-cutting ASC 606 and GDPR obligations.
The regulatory surface this domain must satisfy is broad, and each regime has its own triggering conditions:
- EU VAT (OSS/MOSS). Electronically supplied services to EU consumers are taxed at the customer’s rate. Since 2021 the One-Stop-Shop replaced the old MOSS scheme and removed the per-country distance-selling thresholds in favour of a single EUR 10,000 pan-EU threshold for micro-businesses. You must retain two non-contradictory pieces of location evidence per sale. See handling EU VAT OSS/MOSS for digital goods.
- EU B2B reverse charge. Validate the customer VAT number against VIES, charge 0%, and add the legal note. A bad validation here shifts the liability back to you. See reverse charge B2B VAT validation with VIES.
- US sales-tax nexus. Post-Wayfair, economic nexus is triggered by revenue or transaction-count thresholds per state (commonly $100k or 200 transactions). You must register before collecting, and rates vary by state, county, and city.
- GST (e.g. Australia, India, Canada). Destination-based consumption taxes with their own registration thresholds and invoice requirements; Canada layers federal GST with provincial PST/HST.
- ASC 606 / IFRS 15. Revenue recognition is independent of tax — cash received for an annual plan is recognized over the service period, not at collection. See revenue recognition under ASC 606.
- PCI-DSS / GDPR. Location evidence often includes IP addresses (personal data under GDPR); retain only what tax law requires and document the lawful basis.
The boundary that catches teams is registration: you must not charge a tax you are not registered to collect, and you must charge once you cross a threshold. The tax_registrations table is the authority for both.
These regimes also interact in ways that are non-obvious. A single EU sale can be OSS (B2C, destination rate), reverse charge (validated B2B), or domestic standard-rate depending entirely on the customer’s business status and location — three different treatments from the same product and price. A US customer in a registered state is sales-taxed at a combined state-county-city rate that the EU regimes never touch. A customer who is exempt (a non-profit with a valid exemption certificate) gets yet another treatment. The job of the determination layer is to collapse this matrix to exactly one treatment per line and record why. The compliance failure that recurs in audits is not usually a wrong rate — it is a wrong treatment: charging VAT on a sale that should have been reverse charge, or applying OSS to a sale that should have been domestic. Because treatment is a first-class field on tax_transactions, you can audit treatment distribution as a monitoring signal and catch a misrouting bug before it spans a full quarter.
The location-evidence requirement is where compliance and privacy collide most sharply, and the resolution is not obvious. EU rules demand two non-contradictory pieces of evidence for the customer’s location (billing-address country, IP-geolocation country, and payment-instrument BIN country are the usual three), retained for years to defend the jurisdiction you taxed under. But an IP address is personal data under GDPR, so you are simultaneously required to keep it and required to minimize it. The workable posture is to store the derived conclusion (the country each source indicated) plus a cryptographic commitment to the raw evidence, rather than the raw IP indefinitely — enough to prove two sources agreed on a country without warehousing the underlying personal data longer than necessary. When the two evidence sources contradict each other — a German billing address but an Italian IP — the determination must record the conflict and the tie-breaking rule it applied, because “which country did you tax and why” is exactly the question an auditor asks about the messy cases, and “we picked one” is not an answer that survives.
Exemptions add a final wrinkle that the treatment field alone does not capture: an exempt sale (a non-profit, a resale, a government buyer) is exempt only because a certificate says so, and that certificate has an issuer, an expiry, and a validity you are expected to have checked. Model the exemption certificate as its own record linked from the tax_transaction, storing the certificate reference, the jurisdiction that issued it, and its expiry, so that an exempt determination can always be traced to the document that authorized it. An exemption with no certificate on file is not a tax saving — it is an unbudgeted liability waiting for an audit, because when the certificate cannot be produced, the tax authority treats the sale as though it should have been taxed all along, and the assessment lands on you rather than the customer who claimed the exemption.
A final principle ties the whole domain together: in tax, the record is more important than the calculation. It is tempting to pour engineering effort into getting the rate exactly right and treat storage as an afterthought, but auditors do not re-run your calculation — they inspect your evidence. A determination that charged the correct rate but cannot produce the location evidence, the registration it collected under, or the rate version it applied is, from an auditor’s chair, indistinguishable from a guess. Conversely, a determination that reaches a defensible-but-arguable treatment survives scrutiny if it shows its work: here were the two location proofs, here was the VIES validation result on the supply date, here was the registration in force, here was the rate row. This is why every pattern in this domain — append-only rates, insert-only transactions, stored location evidence, dated registrations — optimizes for reconstructing a past decision rather than for computing a current one. Build the tax subsystem as an evidence-production system that happens to also compute a number, not a calculator that happens to log, and the audits that would otherwise consume weeks become a matter of running a query.
The build-versus-buy calculus follows directly from that principle. Buying a tax engine (Stripe Tax, Avalara, or similar) outsources the part that is genuinely a maintenance treadmill — accurate, time-bounded rate tables across thousands of jurisdictions — but it does not outsource the evidence obligation, which remains yours. Even with a bought engine, you must still store the determination it returned, the inputs you fed it, and the registration you collected under, in your own immutable tax_transactions record, because the vendor’s internal logs are not your audit trail and a vendor relationship can end. The right division of labor is: let the vendor own the rates and the calculation, and own the orchestration, the fallback, and above all the durable record yourself. A team that treats a bought tax engine as “tax is handled now” and skips its own audit record has bought a calculator and mistaken it for compliance.
Scalability & Failure Modes
The failure modes scale with volume: at 10k invoices synchronous tax calls stall the close, at 100k the return aggregation becomes a data-volume problem, and a stale rate table can silently mis-tax a whole run. The map pairs each danger with its containment.
At 10k invoices a billing run, synchronous external tax calls are the first thing to fall over — a single slow tax-engine region stalls the entire close. Decouple determination from the critical path with a queue and apply circuit-breaker semantics: if the engine error rate crosses a threshold, trip the breaker and fall back to cached rates for affected jurisdictions, flagging those transactions for review rather than blocking finalization.
At 100k subscriptions the OSS/sales-tax return aggregation becomes a data-volume problem. Aggregating tax_transactions per jurisdiction at quarter close should run off a read replica and be incremental — never a full table scan during the close window. The cascade failure to fear is a rate-table staleness bug: an expired rate silently applied across a run produces thousands of subtly wrong invoices. Guard against it with a pre-run assertion that every tax_rate used is in force on the invoice date.
The other systemic failure is reconciliation drift between what you collected and what the ledger says you owe. Because tax is posted as a liability line, drift is detectable as a non-zero variance between summed vat_payable and summed tax_amount_cents — alert on it.
Rounding is the quiet scale failure that never trips an alert until a return is filed. Tax computed per line and rounded per line does not necessarily equal tax computed on the invoice total and rounded once — the difference is a fraction of a cent per line, but across a quarter’s OSS return it aggregates into a real discrepancy against what the authority expects. Pick one rounding rule (most EU guidance favors rounding the total tax per rate, per invoice), apply it consistently in one place, and store the rounded result rather than recomputing it downstream where a different rounding might creep in. The same applies to currency conversion on the return: if you collect in local currencies but file in one, the FX rate and rounding used for the return must be the documented, reproducible ones, not whatever a live rate feed returns at filing time — a subtlety shared with handling multi-currency FX gain/loss in the ledger.
Operational Runbook
The runbook has three legs: real-time monitoring signals, scheduled reconciliation and threshold jobs, and a reproducible quarter-close. The panel summarizes the signals and their thresholds before the detail.
Monitoring signals. Track tax-engine p99 latency and error rate (alert at >2% over five minutes), the fallback-rate-applied counter (any sustained value means the engine is degraded), and the count of tax_transactions with treatment = 'reverse_charge' lacking a validated VAT number (should be zero — alert immediately).
Reconciliation jobs. Run a daily job asserting SUM(tax_amount_cents) in tax_transactions equals SUM(amount_cents) on vat_payable ledger lines for the same period; a non-zero delta indicates a posting bug. Run a weekly registration-threshold job that sums taxable revenue per jurisdiction and alerts when you approach 80% of a nexus or OSS threshold, so registration happens before you are obligated to collect.
Quarter close. Freeze the rate table, run the OSS/GST/sales-tax return aggregation off a replica, and snapshot the tax_transactions set that backs each filed return so the filing is reproducible.
Treatment-distribution monitoring. Beyond the point-in-time signals, watch the ratio of treatments over time per jurisdiction. In a stable customer base the split between standard-rated, reverse-charge, and exempt determinations should move slowly; a sudden jump in reverse-charge share usually means a VIES validation path started silently failing open (treating unvalidated numbers as valid), and a sudden jump in standard-rated share in the EU can mean the opposite — validation failing closed and charging VAT it should have reverse-charged. Neither shows up as an error; both are visible only as a distribution shift. Chart the daily treatment mix per jurisdiction and alert on a deviation beyond a few standard deviations from the trailing mean, because a misrouting bug that spans a full quarter is a filing you have to amend, while one caught in a day is a code fix.
Registration lifecycle. Registration is not a one-time event but a state each jurisdiction moves through — approaching-threshold, registered-and-collecting, and occasionally deregistering — and the runbook needs an owner for each transition. When the weekly threshold job fires its 80% alert, someone must actually start the registration paperwork, which can take weeks in some jurisdictions; the alert is a lead-time buffer, not a notification. When a registration completes, its tax_registrations row must go active on the correct legal date, not the date you happened to deploy, or you either under-collect before the row activates or over-collect after deregistration. Treat every registration change as a dated, audited event exactly like a rate change, so the determination layer always reflects the registrations that were legally in force on any past supply date.
Frequently Asked Questions
Should we build our own tax engine or use Stripe Tax / Avalara? For most SaaS the build-versus-buy answer is buy, because the liability is maintaining accurate, time-bounded rate tables for thousands of jurisdictions and tracking your own registration obligations. Build only the determination orchestration (which engine to call, fallback, ledger posting) and the audit record. The VAT & GST tax calculation page compares the options with concrete trade-offs.
Is collected VAT revenue?
No. Collected tax is a liability you hold on behalf of the authority until you remit it. Posting it as a separate vat_payable ledger line keeps revenue clean and makes your remittance obligation derivable directly from the ledger.
When do we have to start charging tax in a new country?
When you cross that jurisdiction’s registration threshold (the EUR 10,000 pan-EU OSS threshold, or US economic-nexus thresholds such as $100k/200 transactions per state) and complete registration. Charging before registering is also wrong — the tax_registrations table should gate determination.
How is tax different from revenue recognition? Tax is when and how much you collect and owe at the point of sale. Revenue recognition is when you have earned the cash you collected, spread over the service period under ASC 606 revenue recognition. An annual plan collects and remits tax up front but recognizes revenue monthly.
What do we keep for an audit?
The immutable tax_transactions record — inputs, location evidence, the resolved treatment and rate, and the registration under which you collected. Combined with compliant sequential invoices, this lets you reproduce any tax decision years later.
What happens to tax on a refund or credit note?
A credit note reverses the original treatment proportionally, including the tax line, and posts a balancing entry against vat_payable. Never reverse the tax separately from the net — model the credit note as a signed mirror of the original determination.
A customer’s VAT number was valid at sale but is invalid now — what do we owe?
Nothing changes for the original sale. You validated against VIES on the supply date, recorded the validation result in the tax_transaction, and reverse-charged correctly based on the facts that were true then. A number lapsing later does not retroactively invalidate a determination made in good faith with contemporaneous evidence — which is exactly why the evidence is stored on the immutable transaction. What does change is the next renewal: re-validate on each supply, and if the number no longer validates, the treatment for the new invoice flips to standard-rated. This is the append-only, as-of discipline paying off — each invoice stands on the evidence captured at its own supply date.
How do we handle a rate change that lands mid-billing-cycle?
The rate that applies is the one in force on the supply date (usually the invoice date), which the time-bounded tax_rates table resolves automatically. Close the old rate row with an effective_to and insert the new one with the matching effective_from; determinations before the boundary pick the old row, after it the new one, with no code change. The trap is a rate table that was updated in place — that silently re-taxes every historical invoice at the new rate the next time anything re-derives them, which is why the table must be append-only rather than mutable.