Tax
Learn how the tax provider seam resolves a rate, why Stripe Tax falls back to the flat channel rate, and what is deliberately left unwired.
The app owns tax. It computes the number, charges it as an explicit line item, records it on the order and refunds it — independently of the payment provider.
The TaxProvider interface decouples who computes the number from that machinery, so a real tax engine can be dropped in without touching checkout charging, order recording, reconciliation or refunds.
That ownership is what keeps the checkout quote complete. Because tax is a line item the application computed, the OrderIntent already contains the number the customer will pay, and the payment webhook promotes that quote instead of discovering the breakdown from the processor.
The three providers
services/tax/provider.ts implements three.
| Provider | name | Behaviour |
|---|---|---|
| Flat channel rate | channel | The market's configured rate. No channel means no tax. The schema default. |
| No tax | none | Always zero, for tax-free stores |
| Stripe Tax | stripe_tax | Real jurisdiction tax computed by Stripe, opt-in per channel |
All three implement the same resolve, which takes a channel, a taxable base in cents that is already post-discount, a currency and an optional destination address, and returns the tax to charge in cents plus whether the behaviour is inclusive or exclusive.
The interface also declares optional commit and reverse methods. A stateless provider simply does not have them.
Choosing a provider
getTaxProvider applies two gates, in order.
ENABLE_TAXES, the environment feature flag the taxes settings page advertises. When it is off,getTaxProviderreturns the zero provider even where rates are configured.- The channel's own configured authority,
channel.taxProvider.
The first gate exists because the flag used to be decorative — tax was charged regardless of it. Now the charged behaviour matches what the admin is told when the flag is off, which is that taxes are not applied to orders. The flag defaults to off.
The second gate resolves like this:
manualselects the flat channel rate, which is the unchanged behaviour for a default storestripe_taxselects real Stripe jurisdiction tax- any other stored value — a legacy provider name left in the column — falls back to the flat channel rate and logs a loud warning
A channel with no provider context at all gets the flat provider, which itself returns zero tax without a channel.
That third branch is worth dwelling on. The column is free-form, so a value naming an engine that was never wired can still be sitting in the database. The choice is between silently pretending it works and degrading honestly. The order still carries a complete, charged tax quote from the flat rate, and the operator is told that the engine configured is not the engine running.
Inclusive and exclusive
A channel stores a rate at Decimal(5, 4) — 0.1900 for 19% — and a behaviour that defaults to exclusive.
The two behaviours are different arithmetic, not different presentation:
inclusive the price already contains the tax (EU/AU style)
exclusive the tax is added on top (US style)Inclusive rounds the extracted subtotal first and takes the tax as the remainder, so subtotal + tax equals the base exactly. Exclusive rounds the tax and adds it.
Every path that touches tax routes through that one function — the charged calculation, the web display, and the tax engine. Splitting it caused a one-cent drift between the charged and the displayed tax, which is the kind of bug that is invisible in every test that only checks one of the two.
Stripe Tax is fail-safe
stripeTaxProvider.resolve falls back to the flat channel rate on a missing destination country, a non-positive base, or any Stripe error.
if (!destination?.country || subtotalCents <= 0) {
return flatChannelTaxProvider.resolve(input)
}
// …
} catch (error) {
logger.error("Stripe Tax calculation failed — falling back to the flat channel rate", { … })
return flatChannelTaxProvider.resolve(input)
}The intent is stated in the source. A taxable order can never silently drop to zero tax, and checkout can never break because Stripe Tax is momentarily unavailable. The operator learns about it from the tax-health dashboard instead.
Both branches of the fallback answer the same question:
If the engine cannot produce a number, is it better to charge nothing or to charge the rate the store already configured?
Charging nothing would be a silent liability. Failing the checkout would be a lost order for a problem the shopper had no part in. The flat rate is the answer that keeps the quote complete.
Note that the app still owns the number even when Stripe computes it. Stripe's calculation is read and then charged as the application's own explicit line item, exactly like the flat provider — so the payment, recording and refund machinery is unchanged by the choice of engine. The calculation's id comes back as an opaque providerRef for a later commit or reverse.
commit and reverse are deliberately not wired
These are Stripe's own tax reporting and filing, distinct from the customer
refund the app already prorates from the recorded amountTax. Wiring them needs
the calculation id persisted on the order — a schema change. The source calls
calculation-only a complete, chargeable integration.
Adding an engine
The seam is written so that a real tax engine is an implementation, not a refactor.
- Implement
TaxProvider.resolveagainst the engine's calculation API. - Implement
commitfor the reporting side, and wire it in the order-creation path once the order is paid. - Implement
reversefor returns, and wire it in the refund path alongside the existingamountTaxproration. - Switch
getTaxProvideron a store setting.
Steps 2 to 4 are intentionally not pre-wired. The stateless providers have no commit or reverse, so wiring no-op calls into the money paths today would add risk for no benefit.
The compute seam is the part every order uses. That is the part that exists, and the part a new engine has to satisfy first.
Related
Shipping
Learn how shipping rates are scoped without a zone model, which carriers get tracking links, and where arrival estimates come from.
Checkout
Learn how a cart becomes a Stripe Checkout Session, how long stock is reserved, and why the reservation window must outlive the payment window.
Providers
Learn which external services sit behind a swappable interface, which are integrated directly, and which environment variables the app refuses to boot without.