Pricing
Understand how Litestore picks the right price for each market and currency, and how sales and exchange rates work.
Litestore decides what a variant costs in each sales channel and currency, when a sale applies, and how amounts are stored and converted. Read this page before you change how prices are shown or charged.
Litestore reads prices from VariantPrice. Each variant has at most one row per channel and currency.
A variant has no single price column. If you sell in two markets or two currencies, the store keeps one row for each combination. The constraint @@unique([variantId, channelId, currency]) enforces this. A row with no channel holds the default, or direct, price.
Because of this, Litestore looks a price up instead of calculating it. A channel price is not a percentage added to a base price when the page renders. It is a row of its own, with its own list price, sale price and cost price.
Product in each market
Resolution
lib/pricing/core.ts resolves the price for a variant in a given channel and currency.
getCurrentVariantPrice is the only function that does this calculation. You pass it the variant's price rows, a target currency and channel, and a point in time. The time defaults to now, but you can pass any date to preview a future sale without waiting for it.
It returns the price the customer pays, the list price it started from, whether a sale is active and how much the customer saves.
The fallback chain
When no row matches the requested channel and currency exactly, Litestore tries these options in order:
- Exact channel and exact currency.
- Default channel (null) and exact currency.
- Exact channel in the base currency.
- Default channel in the base currency.
- Exact channel in any currency.
- Default channel in any currency.
- The first available price row.
If the row it finds is in a different currency, Litestore converts the amount using the configured conversion instead of failing.
This leniency is on purpose. When the storefront can't find an exact price row, it should show a converted price, not an error page or a zero. Litestore returns zeros only when a variant has no price rows at all.
Strict resolution
Being lenient is fine for showing a price, but not for anything that must match what the customer is charged.
getCurrentVariantPriceStrict requires the currency to match exactly. It still falls back from a channel's price to the direct price in the same currency. It never falls back to another currency, with one exception. When the target currency has no rows at all, it picks from the base-currency rows by channel and converts that price.
If that still gives no price in the target currency, it throws PriceResolutionError. It never returns a number that came from somewhere else.
getLowestVariantPrice uses the strict version. The storefront uses it for the "from" price on listing and product pages. If a variant's price can't be resolved strictly, the function skips that variant, so a loosely converted number never becomes the advertised lowest price.
Sale windows
Litestore does not store an "on sale" flag. A sale is a date window on the price row, and Litestore checks it every time it reads the price.
isSalePriceActive returns true when the row has a sale price above zero and asOf falls inside [startsAt, endsAt]. Either date can be empty. A sale price with no dates at all is always active.
So a scheduled sale needs no job to start or stop it. The storefront and the admin also can't disagree about whether a sale is live, because both check the same row at the moment they render.
Some queries can't call the function for each row. They use activeSalePriceWhere(now), a Prisma filter that applies the same rule in the database: a sale price is set, and now falls inside the window.
The two versions must stay identical. If they drifted apart, a product could appear under On Sale while showing its full price.
Money precision
The schema does not use one type for all money. It uses these precisions:
Decimal(10, 2) 21 fields — unit prices
Decimal(12, 2) 32 fields — order and campaign totals
Decimal(5, 2) percentages
Decimal(5, 4) rates needing four placesThe first two differ in size, not accuracy. A unit price and an order total both need two decimal places, but an order total must hold much larger numbers than any single unit price.
Decimal(5, 4) is for rates where the extra places matter, such as a tax rate of 19% stored as 0.1900.
All arithmetic goes through utils/money.ts, which wraps decimal.js-light instead of using JavaScript numbers.
Every amount must come with a currency. The arithmetic code never assumes a store currency. If two currencies don't match, the compiler catches it, so you never get a silent surprise at runtime. A display default also can't be used by accident where the real currency belongs.
When Litestore converts between major and minor units, such as dollars and cents, it reads the currency's real decimalPlaces instead of multiplying by 100. A currency without decimals, such as JPY, passes through unchanged instead of being inflated a hundredfold.
Totals are constrained in the database
order_amounts_nonnegative and order_amounts_refunded_not_over_total are CHECK
constraints in the baseline migration. Their money comparisons allow a one-cent
tolerance. See Guarantees.
Currency rates
functions/cron.currency-rates.ts refreshes exchange rates once a day.
An operator can choose between three modes: manual rates, daily ECB rates, or ECB rates with overrides for individual currencies.
Manual mode is the default, and in that mode the job does nothing. The job also does nothing on a store with a single currency, because there is nothing to convert into.
The rates are the European Central Bank's daily reference rates, served by a free API that needs no key. A self-hosted store can show prices in several currencies without signing up for anything.
The job runs on the ECB's timetable. The ECB publishes once per working day in the afternoon, and the job runs after that, so each run gets that day's rates instead of yesterday's. Fetching once a day from a source that updates once a day keeps the rates as fresh as they can be.
The job fetches the whole table of rates against the base currency, not just the currencies the store has turned on. If the ECB doesn't quote one of your currencies, only that rate goes missing. The rest of the request still succeeds, and the other rates stay current.
The job writes two settings keys:
external_exchange_rates
exchange_rates_updated_atThe settings form leaves both keys out, because the job writes them and operators should not edit them. Saving the settings form can never overwrite a refresh. Without this, an operator could open the settings page, wait an hour while a refresh ran, and press Save. The form would write back the old values from their browser and quietly undo the refresh.
exchange_rates_updated_at lets you see when rates were last updated. Litestore sets it after every successful fetch, so you can read how old the rates are instead of guessing. If a fetch runs out of retries, the dead-letter listener records it, so the failure doesn't disappear.
Where a new price rule goes
If your rule changes what a customer pays for a variant in a market, add a row to VariantPrice. Don't apply a modifier on top of an existing row.
If the change only lasts for a while, give the row a date window. Nothing has to run when the window starts or ends.
If the price must match what the customer is charged, use the strict functions and let a missing price throw. Don't accept a converted number that the checkout quote won't reproduce.
Don't ask only what price the page should show. Ask this:
Which row is this number coming from, and would the charge resolve the same row?
Related
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.
Schema
Learn the core Prisma models in schema.prisma, the conventions every model follows, and how to change the schema safely.
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.