Price in the reader's currency

You need: payments composed, and a rail that quotes in a browser.

Nothing here multiplies a price by a percentage

Designing your catalog is where prices are declared, selling with Paddle is the rail that handles tax as merchant of record, and i18n is what formats the number a reader sees.

Every amount is the store’s own figure. Pithy does not compute a discount, does not apply a rate, and does not convert a currency.

That is the whole design of this page: your screen’s job is to ask the right store the right question and to label the answer honestly.

One route, two independent facts

{
  "pricing": {
    "currency": "USD",
    "currentAmountMinor": 500,
    "listAmountMinor": 1000,
    "discountCode": "LAUNCH50",
    "discountEndsAt": "2026-09-01T00:00:00.000Z"
  },
  "quotedFrom": { "rail": "paddle", "providerAccountId": "ctm_…" }
}

Siblings rather than one nested in the other, and each is null on its own terms.

FieldWhat it is
currencyThe currency both amounts are in
currentAmountMinorWhat the next invoice comes to under any discount
listAmountMinorWhat it comes to once the discount ends
discountCodeThe code in force, or null at list price
discountEndsAtWhen the rate changes — or null, which is either no discount or one that runs forever. Read it beside the code to tell which

pricing is null when no rail can price a subscription this caller holds, including when they hold none.

quotedFrom is what stops a quote and a charge disagreeing

It names the store customer a quote and a charge must both resolve from — the caller’s own row, which is the same row a checkout hands the rail.

So the figure quoted and the figure charged cannot resolve location differently.

That customer id is an identifier, not a credential. It names a customer and authorizes nothing, and the store’s own preview API reads a price with it plus the publishable client token — the pair published for exactly this.

The route requires auth and answers only about its own caller, so nobody learns anybody else’s.

A browser reads three states, and they are not the same answer

What arrivesWhat it meansQuote from
The field is absentThe Worker is older than the bundle asking itThe IP, labeled an estimate
nullNo store holds a customer for this caller yet — the ordinary state of somebody who has not bought anythingA billing address you hold, else the IP
An objectThe store customer this caller is charged asThat customer

The rail is on the wire rather than assumed, because a reader that skipped the check would price one store’s customer id as another’s.

An address supersedes an IP, and the screen has to say which it got

A customer is charged from their billing address, not from where the browser happened to connect from.

SourceWhere it came fromIs it the charge?
customerThe store customer. The store prices from the address it holds, which is the address the checkout chargesYes. The only one that is not a guess
addressA billing address you hold and the store does notCloser than the network, still not proof — the buyer may enter another at the card form
ipNobody said. The country comes from the browser’s own connectionNo. Right for a marketing page a stranger is reading, and an estimate every time

A quote is provisional exactly when the source is the IP, and there is a second independent reason a quote may be an estimate — either one is enough to require the label.

So a signed-in visitor’s price refines. The first render quotes from the IP and says Estimated; the second supersedes it from the store customer.

That is the recalculation every checkout on the web performs. The only thing that would make it a broken promise is a first figure that did not admit what it was — and in the United States the gap between the two reaches 15%.

The divide-by-100 that is wrong

currentAmountMinor and listAmountMinor are not divisible by 100.

They are the store’s own integers in the currency’s smallest unit, and how many decimal places that unit has is a property of the currency rather than a constant.

500 is $5.00 in USD and ¥500 in JPY. Korean won and Chilean pesos are the same story.

Read them beside currency or not at all.

Two ways to do it right:

Where the store hands you a rendered total, pass it through byte for byte. It already carries the currency’s decimals, symbol and separators — and it is correct in markets you have never thought about.

Where you must work from the integers, take the scale from the currency rather than from a literal:

const digits = new Intl.NumberFormat(locale, { style: "currency", currency })
  .resolvedOptions().minimumFractionDigits;

A division by 100 in a consumer is wrong in every zero-decimal market it reaches, and wrong silently. Nobody reports it, because the people who would are looking at a price that is a hundred times too small and assuming they misread.

Formatting, and the locale that does not fall back

The formatting locale is handed straight to Intl without falling back — which is what lets a reader in Buenos Aires get Spanish sentences and Argentine number formatting from one translator, with nobody writing a regional catalog.

Nothing is bundled for this. The Workers runtime embeds full ICU. Never add a formatting polyfill or locale data, and never move the compatibility date for the sake of it.

Two locales, and only one of them falls back is the model.

What to check

  • A signed-out visitor sees a price labeled as an estimate
  • A signed-in customer’s price refines on the second render and drops the label
  • A JPY price is not divided by 100 anywhere
  • The quoted figure and the charged figure match at checkout
  • A discount that ends shows the date; one that runs forever does not pretend to
ESC