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.

Tax determination decision flow Customer location, B2B or B2C status, and product type feed a tax engine that resolves jurisdiction and rate, then writes tax lines to the ledger. Location evidence B2B / B2C + VAT number Product tax category Tax engine jurisdiction + treatment Resolved rate e.g. 21% VAT or reverse charge Double-entry ledger net line + tax line + tax-liability credit
Every taxable line resolves to a jurisdiction and treatment before it is posted as immutable ledger lines.

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.

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

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.

Compliance & Regulatory Boundaries

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.

Scalability & Failure Modes

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.

Operational Runbook

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.

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.