VAT & GST Tax Calculation

VAT and GST calculation is the determination layer of the billing pipeline — given a customer, a product, and an amount, it answers which jurisdiction taxes this, at what rate, and who accounts for it. For the broader regulatory picture and how this fits with the ledger and revenue recognition, see Tax, Compliance & Revenue Recognition. This page is the implementation reference: place-of-supply rules, the B2B/B2C split, choosing between Stripe Tax, Avalara, and a custom engine, time-bounded rate lookup, and the tax-inclusive versus tax-exclusive arithmetic that quietly causes off-by-one-cent reporting bugs.

The mistake teams make is treating tax as a percentage multiply. The rate is the last and easiest step. The hard part is determining the place of supply from evidence you must collect, deciding whether the transaction is B2B (and validating the VAT number that flips it to reverse charge), and selecting the rate that was in force on the invoice date rather than today. Get those three right and the arithmetic is trivial.

Prerequisites

Determination is a decision, not a multiply, and it depends on four inputs being available and trustworthy: time-bounded rates, registration state, location evidence, and a VAT-validation path. The stack shows the foundations before the checklist.

Tax determination prerequisites Time-bounded rate tables, registration records, two location-evidence sources, a VIES validation path, and an immutable audit table underpin determination. Tax determination Rate tables time-bounded Registrations gate charging Location proof two sources VIES path cached Audit table immutable
Four foundations plus an audit record — the rate table and registration state are what make a determination legally defensible.

The tax_rates table is the asset that decays fastest. Standard rates move — Ireland cut its 23% rate to 21% for a pandemic window and reverted it, Luxembourg dropped from 17% to 16% for calendar 2023, and the UK hospitality rate whipsawed between 20%, 5%, and 12.5% inside eighteen months. Each of those is a new time-bounded row, never an UPDATE of an existing one, because an invoice finalized during the old window must still resolve to the old rate when it is reprinted or credited two years later. Store the rate in basis points as an integer (rate_bps = 2100 for 21%) rather than a float; a NUMERIC(5,4) column drifts under repeated arithmetic and makes the net + tax == gross invariant harder to hold exactly. Give each row a stable tax_rate_id and reference that id from every tax_transaction, so the determination is pinned to the exact row that produced it even after later corrections add newer rows for the same jurisdiction.

The tax_registrations table is what gates whether determination runs at all. A row should carry the jurisdiction, the registration number you file under, the effective_from date collection began, and the scheme (domestic VAT, EU OSS, a US state sales-tax permit). Determination reads this table first: if there is no active registration covering the resolved place of supply on the invoice date, the correct output is no tax charged, not the standard rate. Charging 21% in a country where you hold no registration number produces a liability you legally cannot remit — the money sits on your balance sheet as a payable to a tax authority that has no account for you, and refunding it after the fact means reissuing every affected invoice. Registration state also changes the meaning of a threshold crossing: the day US economic nexus trips in a new state, determination for that state must flip from zero to the destination rate on a specific date, which is again a time-bounded read, not a config flag.

Two pieces of location evidence is a legal minimum for EU digital services, not a design preference. The evidence must be non-contradictory and it must be retained — Article 24f of the VAT Implementing Regulation expects you to keep the proofs for ten years, so the location_evidence you fold into each tax_transaction is itself part of the audit record, not a transient input you can discard after determination. Billing-address country, IP geolocation, and the payment instrument’s BIN issuer country are the three cheapest independent sources; SIM country and fixed-line telephone country matter more for telecoms than for SaaS. The point of demanding two is that any single source is spoofable or wrong: a VPN moves the IP, a corporate card is issued in the parent company’s country, and customers mistype their own address.

Architecture & Data Flow

The calculator takes three inputs — resolved customer location, business status, and product tax category — and produces a treatment plus a tax amount, which is persisted immutably. The diagram shows the place-of-supply branch that dominates the logic.

VAT and GST determination flow A taxable line branches on B2B versus B2C and on domestic versus cross-border to select standard rate, reverse charge, or destination rate. Taxable line + evidence B2B or B2C? VAT no. valid? B2C destination charge customer- country rate Domestic B2B charge standard domestic rate Cross-border B2B reverse charge 0% + legal note
Business status and the validated VAT number decide whether a cross-border B2B line is reverse charged.

Inputs are the customer’s resolved jurisdiction (from evidence), their is_business flag plus any VAT number, and the product’s tax_category. Processing resolves place of supply, then branches. Outputs are a TaxDecision (treatment, rate, optional legal note) and a tax amount in cents, both persisted to tax_transactions.

The single most important architectural decision is where in the invoice lifecycle determination runs. Compute tax at the moment the invoice is finalized, not when the subscription is created and not when the price is quoted. A subscription created in March and first billed in April must use April’s rate and April’s evidence; if the customer moved countries or a rate changed in between, quote-time tax is simply wrong. This is why the TaxDecision is keyed to invoice_id and not to subscription_id — the subscription is a long-lived intent, but each invoice is a discrete taxable event with its own date, its own resolved jurisdiction, and its own immutable determination. Recomputing tax on a draft invoice as it is edited is fine; recomputing it after finalization is forbidden, because the finalized number has already been reported to a tax authority.

The tax_category input deserves more weight than teams give it. A flat 21% applied to everything is the assumption that breaks first when the product line grows. Physical shipments, e-books, professional services, and pure SaaS can all attract different rates in the same country — several EU states tax e-books at a reduced rate while taxing streaming access at the standard rate, and the boundary between “electronically supplied service” and “consultancy delivered by email” is a real classification the category encodes. Model tax_category as an explicit enum stored on the product or plan, resolve it into the rate lookup as a first-class key, and never infer it from the amount. When a jurisdiction has no reduced rate for a category, the lookup simply falls back to the standard-rate row for that same (country, region, category) tuple, so the calculator needs no special-casing.

Determination should be synchronous and in-process on the checkout path and batched and asynchronous at cycle close. A single interactive checkout can afford one cached rate read and, at worst, one VIES call behind a tight timeout. A monthly renewal run finalizing fifty thousand invoices cannot afford fifty thousand external calls, so it resolves rates from a preloaded snapshot, reuses per-customer VAT validations cached from the last cycle, and emits the results through the outbox rather than blocking on any downstream tax-reporting system. The same TaxDecision function serves both paths; only the caching and the I/O around it differ.

Implementation Walkthrough

The five steps run in a fixed order because each gates the next: resolve place of supply, decide B2B versus B2C, look up the in-force rate, apply the arithmetic, then persist immutably. The hard steps are the first three — the arithmetic is trivial once the treatment is decided. The sequence shows the ordering.

Tax determination steps Resolve place of supply, determine B2B versus B2C, look up the in-force rate, apply inclusive or exclusive arithmetic, then persist an immutable transaction. 1 Place of supply 2 B2B/B2C validate VAT 3 Rate in force 4 Arithmetic round cents 5 Persist immutable
The first three steps decide the treatment; the arithmetic and the persist are the easy tail.

1. Resolve place of supply from evidence

Place of supply for electronically supplied services is the customer’s location. Collect at least two non-contradictory pieces of evidence and resolve a single country; contradictions get flagged rather than guessed.

from dataclasses import dataclass

@dataclass
class LocationEvidence:
    billing_country: str | None
    ip_country: str | None
    bin_country: str | None        # payment instrument issuer country

def resolve_place_of_supply(ev: LocationEvidence) -> str:
    proofs = [c for c in (ev.billing_country, ev.ip_country, ev.bin_country) if c]
    if len(set(proofs)) == 1 and len(proofs) >= 2:
        return proofs[0]                       # ✅ two agreeing proofs
    if ev.billing_country and ev.billing_country in proofs[1:]:
        return ev.billing_country              # ✅ billing backed by one other
    raise ContradictoryEvidence(proofs)        # ✗ flag for manual review

The resolver above is deliberately strict: it refuses to guess when the proofs disagree. In production the ContradictoryEvidence path should not hard-fail a checkout — it should route the line to a conservative default (usually the billing-address country, which the customer explicitly asserted) while writing a review flag so a human can confirm before the VAT return is filed. The asymmetry matters: under-charging a B2C customer means you eat the tax out of margin, while over-charging means a refund and a corrected invoice, so when forced to guess, guess the jurisdiction the customer typed rather than the one an IP database inferred. Store all three raw proofs in location_evidence, not just the resolved winner, because an auditor reconstructing a determination three years later needs to see what you knew at the time, not the single value you collapsed to.

2. Determine B2B vs B2C

A transaction is B2B only when the customer supplies a VAT/GST number that validates. An unvalidated number is treated as B2C — you cannot take the customer’s word for it.

def is_b2b(customer_vat_number: str | None, validated: bool) -> bool:
    return bool(customer_vat_number) and validated   # validation gates B2B

The validated flag hides a subtlety: VIES validation confirms only that a number is currently assigned to some entity, not that it belongs to the customer in front of you or that it is valid on the supply date. Capture the validation result together with the timestamp and the consultation number VIES returns, and store all three on the tax_transaction. That consultation number is your evidence, in a later audit, that you exercised due diligence at the moment of supply — without it, a number that has since been deregistered will make a historically correct reverse-charge determination look negligent. Also handle the case where the number validates but the returned country prefix disagrees with the resolved place of supply: a DE VAT number on a customer whose evidence resolves to France is a signal to review, not to silently trust one over the other. A B2B line that reverse-charges also flips who remits the tax, so getting is_business wrong is not a rounding error — it moves an entire liability from your books onto the customer’s, and correcting it after filing means restating a return.

3. Look up the rate in force on the invoice date

Rates are time-bounded. Always pass the invoice date so corrections and back-dated invoices use the historically correct rate.

SELECT tax_rate_id, rate_bps
FROM tax_rates
WHERE country_code = :country
  AND (region_code = :region OR (region_code IS NULL AND :region IS NULL))
  AND tax_category = :category
  AND effective_from <= :invoice_date
  AND (effective_to IS NULL OR effective_to > :invoice_date)
ORDER BY effective_from DESC
LIMIT 1;  -- the rate in force on the invoice date

Two details in that query earn their place. The region_code IS NULL AND :region IS NULL clause handles countries that levy VAT at the national level (most of the EU) alongside those where a sub-national region matters (Canadian provincial GST/HST/PST, Indian state GST) without forking the query. And the ORDER BY effective_from DESC LIMIT 1 guards against overlapping rows: if a data-entry error ever leaves two rows covering the same instant, you deterministically take the most recently effective one rather than letting the database return an arbitrary match. The effective_to > :invoice_date comparison must be strict > and the effective_from <= :invoice_date must be <=, so a rate that ends at midnight on the same day a new one begins produces exactly one match at the boundary rather than zero or two. When the query returns no row at all, that is a hard error, not a fallback to zero — it means you are billing a jurisdiction whose rates you have never loaded, and the safe response is to fail the finalization loudly so the missing rate is added before any invoice ships.

4. Apply tax-inclusive or tax-exclusive arithmetic

Tax-exclusive (common in the US and B2B) adds tax on top. Tax-inclusive (common for EU B2C display prices) extracts tax from a gross figure. Round once, in minor units.

def tax_exclusive(net_cents: int, rate_bps: int) -> int:
    # round half-up on the final cent
    return (net_cents * rate_bps + 5000) // 10000

def tax_inclusive(gross_cents: int, rate_bps: int) -> tuple[int, int]:
    net = round(gross_cents * 10000 / (10000 + rate_bps))
    return net, gross_cents - net   # (net, tax) — they always sum to gross

The reason tax_inclusive derives tax as gross - net instead of computing it directly is the whole point of the function. If you compute net by division and tax by multiplication independently, rounding each to the nearest cent, the two results will disagree with the gross by a cent on a meaningful fraction of amounts — for a gross of 1000 cents at 2100 bps, net rounds to 826 and an independently rounded tax would be 174, but at other amounts the two roundings pull in opposite directions and net + tax lands at gross ± 1. Deriving one from the other makes the invariant hold by construction. The rounding mode itself is not free to choose: most tax authorities specify round-half-up or round-half-away-from-zero at the line level, and a few require rounding on the invoice total rather than per line, which changes results when an invoice has many small lines. Decide line-level versus total-level rounding explicitly and encode it once — mixing the two across a codebase produces invoices whose printed total does not equal the sum of the printed lines, which fails validation at the customer’s accounts-payable system before you ever hear about it. Multi-line rounding also interacts with discounts: apply the discount to the net base before computing tax, never to the gross, or the discounted line will not reconcile against its tax_transaction.

5. Persist the determination immutably

Record inputs, treatment, and amounts so any invoice’s tax is reproducible during an audit. Make the write idempotent on invoice_id so a replayed finalization returns the stored record rather than recomputing.

INSERT INTO tax_transactions
  (invoice_id, customer_id, jurisdiction, treatment, tax_rate_id,
   taxable_base_cents, tax_amount_cents, location_evidence, customer_vat_number)
VALUES
  (:invoice_id, :customer_id, :jurisdiction, :treatment, :tax_rate_id,
   :base_cents, :tax_cents, :evidence_jsonb, :vat_number)
ON CONFLICT (invoice_id) DO NOTHING;  -- idempotent on replay

The ON CONFLICT (invoice_id) DO NOTHING is doing more than deduplication — it is enforcing that a finalized invoice’s tax can be written exactly once. A billing run that retries after a transient failure, an outbox consumer that redelivers, or two workers that race on the same invoice all converge on a single stored determination. Pair the insert with a read-back of the existing row so the caller always operates on the persisted values rather than the ones it just recomputed; if its freshly computed tax disagrees with what is already stored, that disagreement is a bug worth alerting on, because it means the rate table or the evidence changed underneath a supposedly immutable invoice. The location_evidence column should be jsonb holding all raw proofs plus the resolved country, and customer_vat_number should store the number exactly as validated including its country prefix — reconstructing a determination during an audit means reading only this row, with no dependency on tables that may have moved on since. Never expose an UPDATE path on this table; corrections happen by issuing a credit note against the original invoice and finalizing a new one, each with its own immutable tax_transaction.

Edge Cases & Failure Modes

The determination edge cases split by which input goes wrong: the evidence contradicts, the date is mishandled, the business status is misjudged, or the rate is stale. The map sorts them so the defense is obvious.

Determination edge cases Contradictory evidence needs two agreeing proofs, back-dated invoices need invoice-date lookup, unvalidated VAT numbers stay B2C, and stale rates need a pre-run assertion. Evidence contradicts → two agreeing Date back-dated → invoice-date lookup Status unvalidated VAT → treat as B2C Rate stale / unregistered → in-force assert + gate
Four inputs, four defenses — the unvalidated-VAT and stale-rate cases are the two that cause audit findings.
Scenario Failure Mitigation
Contradictory location evidence Wrong jurisdiction taxed Require two agreeing proofs; flag conflicts for review
Back-dated invoice / correction Today’s rate applied to old supply Always look up by invoice date, not now()
Unvalidated VAT number B2B reverse charge applied incorrectly Treat as B2C until VIES validates
Tax-inclusive rounding Net + tax ≠ gross by 1 cent Compute net once, derive tax as gross − net
Rate table stale Expired rate applied across a run Pre-run assertion that every used rate is in force
Not registered in jurisdiction Collecting tax you cannot remit Gate determination on tax_registrations
External engine timeout Checkout stalls Aggressive timeout + cached fallback off the critical path

Two rows in that table deserve elaboration because they are the ones that surface in audits rather than in bug trackers. The unvalidated-VAT-number case is dangerous precisely because it looks correct in testing: a developer enters a real, valid number, VIES is up, the reverse charge applies, and the flow passes. It breaks in production when VIES is down — the EU service has scheduled and unscheduled outages measured in hours — and a naive implementation either blocks checkout or, worse, treats “could not validate” as “valid” and applies the reverse charge anyway. The correct behavior is to treat any non-affirmative validation result as B2C, charge the destination-rate VAT, and let the customer reclaim it through their own return; charging VAT you should not have is recoverable, but issuing a zero-rated reverse-charge invoice to a customer whose number never validated leaves you liable for the tax you failed to collect. Cache only affirmative results, never failures, so an outage does not poison a customer’s status until the next cycle.

The stale-rate row is the other audit magnet. It fails silently: a rate expired last quarter, nobody loaded the successor row, and the lookup keeps returning the old rate because effective_to was left NULL on the superseded row. The defense is a pre-run assertion that walks every (country, region, category) you are about to bill and confirms exactly one row is in force on the run date, with no gaps and no overlaps. Run it as a gate before the finalization loop starts, not inside it, so a missing rate stops the whole run before a single wrong invoice ships rather than producing tens of thousands you must later credit and reissue. A related trap is currency: the rate is dimensionless basis points, but the taxable_base_cents is denominated in the invoice currency, and a determination that quietly assumes euros while the invoice is in Swedish kronor will store a tax amount that is arithmetically right but economically nonsense.

Threshold crossings and retroactive registration

The nastiest edge case is temporal rather than per-invoice: the day you cross a registration threshold. US economic-nexus thresholds (commonly 200 transactions or 100,000 dollars in a state over a rolling period) and EU distance-selling limits do not trip at a clean cycle boundary — they trip mid-month, mid-invoice-run. Determination must flip that jurisdiction from zero to the destination rate on the exact date the obligation begins, which is why registration state is a time-bounded read and not a boolean flag. Worse, some authorities expect you to account for tax from the transaction that caused the crossing, not from the day you noticed, so the accurate design tracks running transaction and revenue counts per jurisdiction and can answer “were we obligated on this invoice’s date” retroactively. Getting this wrong in either direction is costly: charge too early and you collect tax with no registration to remit it against; charge too late and you owe the authority tax you never collected from the customer, out of your own margin.

Performance & Scale

Determination has two cacheable dependencies with very different volatility: rate rows change rarely (cache for hours, invalidate on write), while VAT-number validations are per-customer and slow (cache per billing period). The diagram shows the caching layers that keep determination off the slow path.

Determination caching An in-memory rate cache keyed by country-region-category fronts the database index, and a VIES validation cache keeps VAT lookups to once per billing period. Rate cache hours TTL, on-write invalidate VIES cache once per billing period Determination fast path DB index cold-path fallback
Two caches with different volatility keep determination fast — rates for hours, VAT validations per period.

Rate lookups should be served from an in-memory cache keyed by (country, region, category) with the time-bounded rows preloaded; the database index (country_code, region_code, tax_category, effective_from DESC) is the cold-path fallback. Cache TTL can be hours because rate changes are announced in advance — invalidate on rate-table writes rather than expiring aggressively. For batch finalization at cycle close, determine tax in batches off the critical path and publish via the outbox pattern for billing events. VAT-number validation is the slow external dependency; cache validated numbers (see the VIES caching strategy in the reverse-charge guide) so a B2B customer’s number is validated once per billing period, not once per invoice.

The rate cache is small enough to hold entirely in process — a few thousand rows covering every jurisdiction and category you bill fits in single-digit megabytes, so there is no reason to hit the database on the hot path at all once the snapshot is warm. Load it at worker startup and refresh it on a rate-table write event rather than on a timer, because a timer-based refresh either lags a rate change or hammers the database needlessly. The one number worth watching is p99 determination latency on the checkout path: it should be dominated by the VIES call for first-time B2B customers and be effectively free for everyone else. If p99 creeps up for B2C traffic, the cause is almost always an accidental database read where a cache hit was expected — instrument the cache hit rate per key type and alert when the rate-cache miss ratio climbs above a fraction of a percent, because a cold cache silently pushes every determination onto the DB index and turns a sub-millisecond step into a network round trip under load.

Testing Strategy

Determination is pure logic, so it tests cleanly: freeze the clock for date correctness, drive a case table for treatment coverage, property-test the inclusive arithmetic, and assert idempotent persistence. The panel lists them before the property test.

Determination tests Frozen-clock date correctness, a jurisdiction case table, an inclusive net-plus-tax-equals-gross property, and idempotent persistence. Frozen clock back-dated historic rate Case table jurisdiction × type treatment + rate Property inclusive math net+tax=gross Idempotent replay invoice one tax_transaction
Pure determination tests cleanly — the inclusive-arithmetic property test catches the sneaky one-cent drift.

Tax logic is pure determination, so test it deterministically. Freeze the clock and assert that a back-dated invoice picks the historically correct rate. Drive a table of (jurisdiction, B2B/B2C, product category) cases through the calculator and assert the treatment and rate. For tax-inclusive arithmetic, property-test that net + tax == gross for thousands of random gross amounts and rates. Replay the same invoice_id twice and assert exactly one tax_transaction row. Mock the tax engine to return a timeout and assert the fallback path produces a flagged, conservative determination rather than throwing on the checkout path.

def test_tax_inclusive_always_sums_to_gross():
    for gross in range(1, 100_000):
        for rate in (700, 1900, 2100, 2500):
            net, tax = tax_inclusive(gross, rate)
            assert net + tax == gross        # invariant under every rounding

Frequently Asked Questions

Should I use Stripe Tax, Avalara, or build my own engine? Buy the rate tables and registration tracking — that is the maintenance burden that never ends. Stripe Tax is the lowest-effort choice if you are already on Stripe; Avalara suits multi-processor or complex US sales-tax footprints; a custom engine only makes sense when your jurisdictions are few and stable. Keep the orchestration, fallback, and audit record in-house regardless.

Where is the place of supply for SaaS? For electronically supplied services it is the customer’s location, established from two non-contradictory pieces of evidence. This is why handling EU VAT OSS/MOSS for digital goods centers on evidence collection rather than rate math.

Tax-inclusive or tax-exclusive pricing? EU B2C convention is tax-inclusive display prices; US and most B2B is tax-exclusive. Pick per market and store which one each price uses, then compute net once and derive tax to avoid rounding drift.

How do I handle a customer who claims to be a business? Do not trust the claim — validate the VAT number against VIES. Until it validates, treat the sale as B2C. See reverse charge B2B VAT validation with VIES.

Do I charge tax everywhere I have customers? No — only where you are registered. Charging an unregistered jurisdiction means collecting tax you cannot legally remit. Cross the threshold, register, then charge.

What happens to a determination when I later issue a credit note? Nothing — the original tax_transaction is immutable and stays exactly as it was. The credit note is a new taxable event with its own determination, its own invoice date, and its own row, carrying the tax back out at the rate that was in force on the original supply, not today’s rate. Never edit the original determination to “reverse” it; a credit note that references invoice_id and reverses the stored tax_amount_cents keeps both the original and the reversal auditable side by side.

Should the rate be stored on the invoice line or looked up at read time? Store it. The tax_rate_id and the resolved tax_amount_cents are pinned onto the tax_transaction at finalization, and every later read — reprinting the invoice, filing the return, reconciling the ledger — uses the stored value. Looking the rate up again at read time reintroduces exactly the bug the time-bounded table exists to prevent, because a rate row added or corrected after finalization would silently change a historic invoice’s tax.