Pricing
Learn how a variant's price is resolved per channel and currency, how sale windows are evaluated at read time, and what decimal precision the schema uses.
A price is resolved from VariantPrice, which is unique per variant, channel and currency.
There is no single price column on a variant. A store selling in two markets or two currencies carries a row for each combination, keyed by @@unique([variantId, channelId, currency]), where a null channel means default or direct pricing.
That shape is what makes the price a lookup rather than a calculation. A channel price is not a percentage applied to a base price at display time — it is its own row, with its own list price, sale price and cost price.
Resolution
lib/pricing/core.ts resolves the price for a variant in a given channel and currency.
getCurrentVariantPrice is the single source of truth for that calculation. It takes the variant's price records, a target currency and channel, and a point in time to evaluate — which defaults to now, but can be set to any date, so a future-dated sale can be previewed without waiting for it.
It returns the effective price, the list price it came from, whether a sale is currently active, and the savings.
The fallback chain
When there is no exact row for the channel and currency asked for, resolution narrows in a fixed 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.
A row found in a currency other than the one requested is converted through the configured conversion rather than failing the read.
The bias here is deliberate. A storefront that cannot find an exact price row should show a converted price, not an error page or a zero. Only when a variant has no price rows at all does resolution return zeros.
Strict resolution
Leniency is right for display and wrong for anything that has to agree with a charge.
getCurrentVariantPriceStrict requires an exact currency match. It still falls back from a channel-specific price to the direct price within the same currency, but never across currencies, except through one explicit path: when the target currency has no rows at all, it will narrow the base-currency rows by channel and convert those.
If that does not produce a price in the target currency, it throws PriceResolutionError rather than returning a number that came from somewhere else.
getLowestVariantPrice is built on the strict form. It is the storefront source of truth for "from" pricing on listing and product pages, and it skips a variant whose price cannot be resolved strictly instead of letting a loosely converted number become the advertised floor.
Sale windows
Sale prices are not a stored flag. A sale is a date window on the price row, evaluated at read time.
isSalePriceActive returns true when a positive sale price is set and asOf falls inside [startsAt, endsAt]. Either bound may be null: a sale price with no window at all is simply always active.
That is why a scheduled sale needs no job to start or stop it, and why the storefront and the admin cannot disagree about whether a sale is live. They are both asking the same question of the same row at the moment they render.
Queries that cannot run the function per row use activeSalePriceWhere(now), a Prisma predicate that mirrors the same rule in the database — a sale price is set, and now is within the window.
That mirroring is the point. Two implementations of "is this on sale" that could drift are exactly how a product ends up listed under On Sale at its full price.
Money precision
Money is not one type across the schema. The real precisions are:
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 split between the first two is about range, not about accuracy. A unit price and an order total need the same two decimal places, but an order total has to hold a number a single unit price never will.
Decimal(5, 4) exists for rates that four places actually change — a tax rate stored as 0.1900 for 19%.
Arithmetic goes through utils/money.ts, which wraps decimal.js-light rather than using JavaScript numbers.
A currency is required alongside an amount. There is no implicit store currency at the arithmetic layer, so a currency mismatch is a compile error rather than a silent runtime surprise, and a display default cannot stand in for a real domain currency by accident.
Conversion between major and minor units reads the currency's real decimalPlaces instead of multiplying by 100, so a zero-decimal currency such as JPY passes through undivided rather than being quietly inflated by a factor of a hundred.
Totals are constrained in the database
order_amounts_nonnegative and order_amounts_refunded_not_over_total are CHECK
constraints in the baseline migration, and money comparisons there carry a
one-cent tolerance. See Guarantees.
Currency rates
functions/cron.currency-rates.ts refreshes rates on a daily schedule.
Three modes are available to an operator:
- manual rates
- daily ECB rates
- ECB with per-currency overrides
In manual mode — the default — the cron is a no-op. It is also skipped for a single-currency store, where there is nothing to convert into.
Rates come from the European Central Bank's daily reference rates behind a free, keyless API, so a self-hosted store gets multi-currency display without signing up for anything.
The schedule follows the source rather than the clock. The ECB publishes once per working day, in the afternoon, and the fetch runs after that publication, so each run picks up that day's rates instead of yesterday's. A daily fetch against a daily source is exactly as fresh as the source can be.
The job fetches the full base-relative table rather than filtering to the currencies the store has enabled. An enabled currency the ECB does not quote then degrades to a missing rate downstream, instead of failing the entire request and leaving every rate stale.
Two settings keys are written by that cron:
external_exchange_rates
exchange_rates_updated_atBoth are excluded from the settings form, because they are machine-written and not operator-editable. A scheduled refresh cannot be clobbered by a save. Without that exclusion, an operator opening the settings page an hour after a refresh and pressing Save would write back the values their browser loaded, silently reverting the rates.
exchange_rates_updated_at is the visibility half of the same design. It is stamped on every successful fetch, so a stale rate is a readable fact rather than a guess, and a fetch that exhausts its retries lands in the dead-letter listener rather than disappearing.
Where a new price rule goes
If the rule changes what a customer is charged for a variant in a particular market, it is a row on VariantPrice, not a modifier applied on top of one.
If it is a time-bounded change, it is a window on that row. Nothing needs to run at the start or the end of it.
If it has to agree with a charge, resolve through the strict path and let a missing price throw, rather than accepting a converted number that the checkout quote will not reproduce.
The useful question is not "what price should this show?" It is:
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.
Payments
Learn how Stripe is wired as the single provider, how the append-only transaction ledger is enforced by Postgres, and why replayed webhooks are idempotent.
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.