Choosing Between a Tax Engine and In-House Calculation
Transaction tax looks like a lookup and behaves like a subscription to a moving target. Rates change, thresholds change, the rules about which digital services are taxable where change, and none of it is announced in a format your code can consume. This guide sits under VAT / GST Tax Calculation and covers the decision most billing teams face once they sell beyond one country: buy a tax engine, build the calculation, or split the difference.
The honest framing is that the calculation is the easy part and the maintenance is the product. Computing 21% of an amount is trivial; knowing that this customer, buying this service, from this entity, on this date, owes 21% is the work — and knowing it again next quarter after three jurisdictions changed their rules is the ongoing cost.
The second framing that helps is separating three distinct problems: calculating tax on a transaction, tracking where you are registered and where you are approaching a threshold, and actually filing returns. They are frequently sold together and they can be solved separately.
Trade-offs
| Dimension | In-house | Tax engine | Provider’s built-in tax |
|---|---|---|---|
| Rate maintenance | Yours, forever | Included | Included |
| Jurisdiction coverage | What you build | Broad | Broad, provider-scoped |
| Registration tracking | Build it | Usually included | Partial |
| Filing | Manual or a separate service | Often an add-on | Often an add-on |
| Latency on the critical path | None | Network call | Network call |
| Cost | Engineering time | Per transaction or seat | Percentage or per transaction |
| Auditability | Yours to design | Vendor’s records plus yours | Provider’s records |
The middle box is where in-house builds most often fail. Calculating a rate for a country you know you are registered in is straightforward; noticing that you have crossed a registration threshold in a US state you have never thought about is not, and the consequence of missing it is back taxes and penalties rather than a wrong invoice — see handling US sales tax nexus and registration thresholds.
Step-by-Step Implementation
1. Enumerate where you actually sell
-- Revenue by customer country over the last twelve months, with counts.
SELECT billing_country,
count(DISTINCT account_id) AS customers,
sum(amount_minor) AS revenue_minor
FROM invoices
WHERE issued_at > now() - INTERVAL '12 months' AND status = 'paid'
GROUP BY 1
ORDER BY revenue_minor DESC;
The output is usually surprising: a long list of countries with one or two customers each, and most revenue concentrated in a handful. That shape argues for buying — the maintenance burden scales with the number of jurisdictions, not with revenue.
2. Define the interface before choosing the implementation
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class TaxRequest:
line_amount_minor: int
currency: str
product_tax_code: str # your own catalogue's code
seller_entity: str
customer_country: str
customer_region: str | None
customer_tax_id: str | None # for reverse charge
transaction_date: str
@dataclass(frozen=True)
class TaxResult:
tax_minor: int
rate_bps: int
jurisdiction: str
reason: str # 'standard' | 'reverse_charge' | 'exempt' | 'not_registered'
provider_ref: str | None
Building this seam first means the choice is reversible and testable, and it forces the questions that actually matter — chiefly what the product tax code is, since digital services are classified differently across jurisdictions.
3. Design the failure mode before choosing
Whichever branch you choose, mark the resulting invoice line so a later reconciliation can find every transaction taxed by a fallback rather than a live calculation.
4. Record the decision, not just the number
ALTER TABLE invoice_lines
ADD COLUMN tax_minor BIGINT NOT NULL DEFAULT 0,
ADD COLUMN tax_rate_bps INT,
ADD COLUMN tax_jurisdiction TEXT,
ADD COLUMN tax_reason TEXT, -- standard | reverse_charge | exempt | fallback
ADD COLUMN tax_provider_ref TEXT;
An auditor’s question is never “what was the tax?” — it is “why was that the tax?”. The reason and the jurisdiction answer it; the amount alone does not.
5. Reconcile calculated tax against filed tax
Whatever computes the number, the amounts you file must reconcile to the amounts on your invoices, by jurisdiction and period. Build that report early: it is the control that catches a misconfigured product tax code before it becomes a year of incorrectly taxed invoices.
The Hybrid Most Teams End Up With
The decision is rarely all-or-nothing in practice, and the arrangement that tends to survive is a hybrid with clear boundaries.
Buy the calculation for everything outside your home jurisdiction. The breadth of coverage and the rate maintenance are precisely what a vendor is good at, and the per-transaction cost is small relative to the engineering time it displaces. This is also where the vendor’s records add audit value, since they can attest to the rate applied on a date.
Keep your own rate table for the one or two jurisdictions that dominate your revenue, as a fallback rather than a primary. That gives the cached-rate failure mode above real substance for the majority of transactions, and it makes the system resilient to a vendor outage on your busiest day.
Own the tax decision record entirely. The vendor computes; your invoice line stores the amount, the rate, the jurisdiction, the reason, and the vendor’s reference. That way a change of vendor is a change of implementation rather than a loss of history, and the seam discipline that keeps a payment provider replaceable applies equally here.
Outsource filing unless it is genuinely a small number of returns. Filing is periodic, jurisdiction-specific, deadline-driven work with penalties for error, and it is not a differentiator for any software business.
Revisit the decision when the jurisdiction count changes materially, not on a schedule. Selling into three countries and selling into thirty are different problems, and the in-house build that was correct at three becomes a liability long before anyone proposes replacing it.
Evaluating a Vendor Beyond the Rate Table
Vendor selection conversations tend to focus on jurisdiction coverage, which is table stakes. The questions that actually differentiate are elsewhere.
Ask about product tax codes for software and digital services specifically. Coverage of physical goods is broad everywhere; the classification of a subscription to a hosted application varies by jurisdiction and is where a generic vendor’s model can be thin. Ask to see the codes you would use and how they map to the countries in your revenue list.
Ask about latency and availability commitments, and what happens when they are not met. A tax call on the checkout path inherits the vendor’s availability, and a vendor without a published objective is one whose slow days become your outages.
Ask how corrections work. Every implementation eventually mis-taxes something, and the vendor’s model for adjusting a previously reported transaction — and whether that flows into their filing product — determines how painful the fix is.
Ask what happens to your data when you leave. Transaction-level records held only by the vendor are a dependency you may not have priced, and the answer shapes how much you store yourself.
Finally, run your fixture suite against a trial account before committing. A dozen representative transactions with known correct answers will tell you more in an afternoon than any amount of feature comparison, and the ones that disagree are the conversation worth having with the vendor’s solutions team.
Verification & Testing
Build a fixture suite of representative transactions — domestic consumer, domestic business, EU cross-border consumer, EU cross-border business with a valid VAT identifier, a US state with local rates, and a jurisdiction where you are not registered — and assert the amount, the rate, the jurisdiction, and the reason for each. Run it against whichever implementation is live, so a vendor change or a configuration drift fails the build.
Test the reverse-charge path specifically, including a customer supplying an identifier that fails validation. The correct behaviour is to charge tax, not to trust the customer’s claim, and getting that backwards is expensive at scale — the mechanics are covered in reverse-charge B2B VAT validation.
Test the fallback path by making the tax service unreachable and asserting the chosen behaviour occurs, that the line is flagged, and that a reconciliation query finds it afterwards. A fallback nobody has exercised is a fallback that will surprise you.
Gotchas & Production Pitfalls
- Product tax codes left at a default. Every jurisdiction classifies digital services slightly differently, and a default code produces plausible, wrong rates everywhere.
- Registration thresholds untracked. The expensive failure is not a wrong rate; it is discovering you should have been registered in a state for two years.
- Tax recalculated on a historical invoice. Reissuing a document with today’s rate invalidates a filed return. Rates on issued invoices are immutable.
- The tax call on the synchronous checkout path with no timeout. A vendor slowdown becomes a checkout outage.
- Only the amount stored. Without the rate, jurisdiction, and reason, an audit becomes a reconstruction exercise.
Frequently Asked Questions
When is in-house genuinely reasonable? When you sell into a small, stable set of jurisdictions with simple rules, and you have someone who owns keeping the rates current. Below roughly a handful of countries the arithmetic is manageable; beyond that the maintenance dominates.
Does using the payment provider’s tax feature lock me in? Somewhat, because the tax records live with the provider. Mitigate it by storing the full tax decision on your own invoice lines, which is worth doing regardless.
What about historical corrections? Never mutate an issued invoice. Issue a credit note and a corrected invoice, so the filed period and the correction are both traceable — see issuing credit notes.
How much latency should a tax call add? Keep it off the interactive checkout path where possible by calculating at quote time and confirming at invoice time. Where it must be synchronous, set a tight timeout and a defined fallback rather than letting the call block indefinitely.