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.

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.

Tax core data model Tax_transactions references the time-bounded tax_rates row in force on the invoice date, gated by tax_registrations, and is strictly insert-only. tax_rates effective_from/to append-only tax_registrations gates charging tax_transactions insert-only audit location_evidence JSONB invoice one per line rate ref
A determination pins the exact rate version in force on the invoice date — append-only history makes every past invoice reproducible.

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.

Tax design patterns Async determination prevents checkout outages, treatment-not-discount prevents misreporting, tax-as-ledger-lines prevents liability drift, and idempotent determination prevents double tax. Async + fallback prevents checkout outage timeout + cache Treatment field prevents misreporting not a discount Tax as ledger prevents liability drift derivable owed Idempotent prevents double tax on re-finalize
Four patterns, four prevented failures — treatment-as-a-field and tax-as-ledger-lines are the two auditors care about most.

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.

Compliance regimes EU VAT OSS and reverse charge, US economic nexus, GST regimes, ASC 606 revenue recognition, and GDPR on location evidence are the regimes to satisfy. EU VAT (OSS + RC) EUR 10k threshold · VIES US nexus (Wayfair) $100k / 200 txns per state GST (AU/IN/CA) destination-based ASC 606 / IFRS 15 recognition independent of tax PCI-DSS / GDPR location evidence = personal data
Registration is the boundary that catches teams — the tax_registrations table gates whether you charge at all.

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.

Tax scale failures Synchronous tax calls need a circuit breaker and fallback, return aggregation needs a read replica and incremental sums, and stale rates need a pre-run in-force assertion. Sync tax calls slow region stalls the whole close → breaker + cached fallback Return aggregation quarter-close scan data-volume problem → replica + incremental Stale rates expired rate applied thousands mis-taxed → pre-run in-force assert
Three scale failures, three containments — the pre-run in-force assertion is the cheapest insurance against a silent mis-tax.

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.

Tax operations Monitor tax-engine latency and fallback rate, reconcile tax_transactions against vat_payable daily, run a weekly threshold job, and snapshot returns at quarter close. Engine health alert error rate > 2% / 5 min Reverse-charge guard RC without validated VAT = 0 Daily reconcile tax_transactions = vat_payable Threshold watch alert at 80% of nexus/OSS
Four operational signals — the threshold watch is what triggers registration before you are obligated to collect.

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.