Litestore · Developer docs
Commerce

Tax

Configure tax for a market and connect a real tax engine to Litestore.

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 need to connect a tax engine.

Product in each market

Linen shirt$48.00
USDEnglish
Each market can have its own tax rate and its own setting for whether prices include tax.

The TaxProvider interface separates who calculates the tax from everything else. A real tax engine plugs in 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.

Providers

modules/checkout/tax/provider.ts contains three providers.

ProvidernameBehaviour
Flat channel ratechannelThe market's configured rate. No channel means no tax. The schema default.
No taxnoneAlways zero, for tax-free stores
Stripe Taxstripe_taxReal 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, and a provider that keeps no state simply leaves them out.

Provider selection

ENABLE_TAXES

This is an environment feature flag in env.ts. When it is off, getTaxProvider returns the no-tax provider, even if rates are configured. The flag is off by default.

This 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 and doesn't add tax to orders.

Channel setting

getTaxProvider then reads channel.taxProvider.

ValueWhat Litestore does
manualIt selects the flat channel rate, so a default store behaves the same as before.
stripe_taxIt selects real tax for the shopper's jurisdiction, calculated by Stripe.
Any other valueIt falls back to the flat channel rate and logs a loud warning. This covers the name of an old provider left in the column.
No setting at allThe channel gets the flat provider, which returns zero tax when there is no channel.

The fallback for unknown values 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, and 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, and 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)

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.

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 fallback

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.

modules/checkout/tax/provider.ts
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 failure is logged as an error in the server logs.

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. Payment, order recording and refunds therefore 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.

Adding tax engines

The interface is designed so that adding a real tax engine means writing a new implementation, not restructuring code.

TaxProvider.resolve

Use the engine's calculation API.

commit

Use it for reporting, and call it in the order-creation path once the order is paid.

reverse

Use it for returns, and call it in the refund path next to the existing amountTax proration.

getTaxProvider

Base the choice on a store setting.

The maintainers deliberately didn't set up steps 2 to 4 in advance. The existing providers keep no state and have no commit or reverse, and 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.

On this page