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

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.

Implementation Walkthrough

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

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

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

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

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

Edge Cases & Failure Modes

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

Performance & Scale

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.

Testing Strategy

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.