Pricing
Litestore picks a price for each market and currency, and applies sales and exchange rates on top of it.
Litestore determines a variant's price 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.
Product in each market
Litestore reads prices from VariantPrice, and 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.
Resolution
modules/products/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, and passing a future date previews a 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.
Fallback chain
When no row matches the requested channel and currency exactly, Litestore tries these options in order.
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.
Use getCurrentVariantPrice. It walks the whole fallback chain and converts a price from another currency when it has to.
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.
A scheduled sale therefore needs no job to start or stop it. The storefront and the admin also always show the same sale status, because both check the same row at the moment they render.
Some queries can't call the function for each row. They use productHasActiveSaleWhere(now), a Prisma filter built on the same file's activeSalePriceWhere, which 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:
| Type | Used for |
|---|---|
Decimal(10, 2) | 20 fields that store unit prices |
Decimal(12, 2) | 22 fields that store order and campaign totals |
Decimal(5, 2) | Percentages |
Decimal(5, 4) | Rates that need four places |
The 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 platform/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 a currency mismatch never slips through silently 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
jobs/cron.currency-rates.ts refreshes exchange rates once a day. An operator can choose between three modes.
| Mode | What the job does | Default |
|---|---|---|
| Manual rates | Nothing. The operator enters the rates. | Yes |
| Daily ECB rates | Fetches the ECB reference rates every day. | No |
| ECB rates with overrides | Fetches the ECB rates and accepts overrides for individual currencies. | No |
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 Frankfurter (api.frankfurter.dev), 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 the store's currencies, only that rate goes missing. The rest of the request still succeeds, and the other rates stay current.
The job writes one settings key, external_exchange_rates, and keeps only the currencies Litestore supports.
That setting is marked machine: true in modules/settings/schema.ts, so the settings form leaves it out: the job writes it and operators should not edit it. 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.
If an enabled currency has no ECB rate, the job logs a warning and conversion falls back. If a fetch runs out of retries, the dead-letter listener records it, so the failure doesn't disappear.
Adding price rules
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
A cart becomes a Stripe Checkout Session, Litestore reserves stock for a fixed time, and the reservation window must outlast the payment window.
Schema
The core Prisma models in schema.prisma follow shared conventions, and the schema changes safely when you follow a few rules.
Tax
The tax provider seam resolves a rate, Stripe Tax falls back to the flat channel rate, and Litestore deliberately leaves some tax features unwired.