Tax
This page shows you how Litestore works out the tax on an order and how you can plug in a real tax engine.
Litestore calculates tax itself, adds it to the order as its own line, records it and refunds it, whichever payment provider you use. Read this page when you configure tax for a market or want to connect a tax engine.
The TaxProvider interface separates who calculates the tax from everything else. You can plug in a real tax engine without touching how checkout charges, how orders are recorded, reconciliation or refunds.
Because Litestore calculates tax itself, the checkout quote is always complete. The OrderIntent already contains the exact amount the customer will pay. The payment webhook turns that quote into an order and never has to ask the payment processor for the tax breakdown.
The three providers
services/tax/provider.ts contains three providers.
| 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 have the same resolve method. You pass it a channel, the taxable amount in cents after discounts, a currency and, optionally, a destination address. It returns the tax to charge in cents and whether that tax is inclusive or exclusive.
The interface also has optional commit and reverse methods. A provider that keeps no state simply leaves them out.
How Litestore picks a provider
getTaxProvider makes two checks, in this order:
- It checks
ENABLE_TAXES. This is the environment feature flag that the taxes settings page tells operators about. When it is off,getTaxProviderreturns the no-tax provider, even if rates are configured. - It reads the channel's setting,
channel.taxProvider.
The first check exists because the flag used to do nothing, and Litestore charged tax whether it was on or not. Now, when the flag is off, Litestore does what the admin says: it doesn't add tax to orders. The flag is off by default.
For the second check, Litestore reads the value like this:
manualselects the flat channel rate. A default store behaves the same as before.stripe_taxselects real tax for the shopper's jurisdiction, calculated by Stripe.- Any other value, such as the name of an old provider left in the column, falls back to the flat channel rate and logs a loud warning.
If there is no provider setting at all, the channel gets the flat provider. That provider returns zero tax when there is no channel.
The third case needs some explanation. The column accepts any text, so it can still contain the name of an engine that was never connected. Litestore could either pretend that engine works or fall back openly. It falls back openly. The order still gets a complete tax amount from the flat rate, and Litestore tells the operator that the configured engine isn't the one running.
Inclusive and exclusive tax
Each channel stores a rate as Decimal(5, 4), for example 0.1900 for 19%. It also stores whether tax is inclusive or exclusive, with exclusive as the default.
The two settings calculate tax differently. They don't just display it differently:
inclusive the price already contains the tax (EU/AU style)
exclusive the tax is added on top (US style)With inclusive tax, Litestore first takes the subtotal out of the price and rounds it, and the tax is what is left over. This way subtotal + tax always equals the original price exactly. With exclusive tax, Litestore rounds the tax and adds it on top.
Every piece of code that deals with tax uses this one function. That includes the amount Litestore charges, the amount the web pages display, and the tax engine. When this calculation was split into two places, the charged tax and the displayed tax drifted apart by one cent. A bug like that passes every test that only checks one of the two.
Stripe Tax falls back safely
stripeTaxProvider.resolve uses the flat channel rate instead when the destination country is missing, when the taxable amount is zero or less, or when Stripe returns any 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 source code states the goal. A taxable order must never quietly drop to zero tax, and checkout must never break because Stripe Tax is briefly unavailable. Instead, the operator finds out from the tax-health dashboard.
Both fallbacks answer the same question:
If the engine can't produce a number, is it better to charge no tax or to charge the rate the store already set up?
Charging nothing would quietly leave the store owing tax. Failing the checkout would lose an order over a problem the shopper didn't cause. Charging the flat rate keeps the quote complete.
Even when Stripe calculates the tax, Litestore is still in charge of the amount. It reads Stripe's result and charges it as its own line item, just as it does with the flat provider. So payment, order recording and refunds work the same whichever engine you choose. Stripe's calculation id comes back as an opaque providerRef, which a later commit or reverse call could use.
commit and reverse are deliberately not wired
These methods would handle Stripe's own tax reporting and filing. That is
separate from the customer refund, where Litestore already refunds a share of
the recorded amountTax. Connecting them would mean saving the calculation id
on the order, which is a schema change. The source code says a
calculation-only integration is complete and can be charged.
Adding a tax engine
The interface is designed so that adding a real tax engine means writing a new implementation, not restructuring code.
- Implement
TaxProvider.resolveusing the engine's calculation API. - Implement
commitfor reporting, and call it in the order-creation path once the order is paid. - Implement
reversefor returns, and call it in the refund path next to the existingamountTaxproration. - Make
getTaxProviderchoose your engine based on a store setting.
Steps 2 to 4 are not set up in advance on purpose. The existing providers keep no state and have no commit or reverse. Adding calls that do nothing to the code that handles money would add risk without any benefit.
Every order uses the calculation step. That step is already in place, and it is the first thing a new engine has to support.
Related
Shipping
See how Litestore decides which shipping rates a shopper sees, how it builds tracking links, and how it estimates arrival dates.
Checkout
See how Litestore turns a cart into a Stripe payment and holds stock for the shopper while they pay.
Providers
See which external services you can swap, which are wired in directly, and which environment variables the app needs to start.
Inventory
Learn how stock is summed from locations on read, how a stock movement is constrained by the database, and how concurrent checkouts are serialised.
Shipping
This page shows you how Litestore decides which shipping rates a shopper sees, how it builds tracking links, and how it estimates arrival dates.