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.
| Field | What it is |
|---|---|
currency | The currency both amounts are in |
currentAmountMinor | What the next invoice comes to under any discount |
listAmountMinor | What it comes to once the discount ends |
discountCode | The code in force, or null at list price |
discountEndsAt | When 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 arrives | What it means | Quote from |
|---|---|---|
| The field is absent | The Worker is older than the bundle asking it | The IP, labeled an estimate |
null | No store holds a customer for this caller yet — the ordinary state of somebody who has not bought anything | A billing address you hold, else the IP |
| An object | The store customer this caller is charged as | That 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.
| Source | Where it came from | Is it the charge? |
|---|---|---|
customer | The store customer. The store prices from the address it holds, which is the address the checkout charges | Yes. The only one that is not a guess |
address | A billing address you hold and the store does not | Closer than the network, still not proof — the buyer may enter another at the card form |
ip | Nobody said. The country comes from the browser’s own connection | No. 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