@codefusion-cc/exchange-rates
v0.1.0
Published
Approximate prices in other currencies for a shop that charges in its own: daily exchange rates from a source (the Polish central bank's table A first, any source behind one interface) kept in D1 with a guard against absurd jumps, a refresh for the app's
Maintainers
Readme
@codefusion-cc/exchange-rates
Approximate prices in other currencies for a shop that charges in its own. The shop shows "≈ €23.26" next to its price of 100 zł, from a rate updated once a day. Payment stays in the shop's currency: the other price is for display and is never an amount to charge. It is not commerce: any app with a D1 database can use it. No dependencies (React is an optional peer).
npm install @codefusion-cc/exchange-rates| Entry | Runs in | Holds |
| --- | --- | --- |
| @codefusion-cc/exchange-rates | Workers | refreshRates, latestRates, ratesResponse, nbpTableA and the source interface, approximate |
| @codefusion-cc/exchange-rates/browser | the page | createCurrencyChoice, createRatesClient, formatMoney, formatApproximate, approximate |
| @codefusion-cc/exchange-rates/react | the page | ExchangeRatesProvider, ApproxPrice, useApproxPrice, useCurrencyChoice |
| @codefusion-cc/exchange-rates/migrations | Node | syncMigrations, behind the codefusion-exchange-rates-migrations command |
| @codefusion-cc/exchange-rates/testing | tests | EXAMPLE_RATES, fakeSource, fakeFetch, nbpAnswer, failedTable |
What a shop does
Install and migrate.
"postinstall": "codefusion-exchange-rates-migrations"copies the package's migration intomigrations/0000_exchange_rates/(add it to.gitignore); the D1 binding needs"migrations_pattern": "migrations/**/*.sql", as for@codefusion-cc/mail. The deploy applies it. One table,exchange_rates.A cron trigger and the refresh. The NBP publishes around noon Warsaw time on business days; run twice a day so one failure does not cost a day:
"triggers": { "crons": ["15 11 * * *", "15 14 * * *"] } // UTCimport { nbpTableA, refreshRates } from '@codefusion-cc/exchange-rates' export const RATES = { base: 'PLN', source: nbpTableA(), currencies: ['EUR', 'USD', 'GBP', 'CZK'] } as const async scheduled(_event, env) { const result = await refreshRates(env.DB, { base: RATES.base, source: RATES.source, require: RATES.currencies }) if (result.status === 'failed') console.error('exchange rates', result.code, result.detail) // the last good rates stay }A route (
GET /api/rates):return ratesResponse(request, env.DB, { base: 'PLN', source: RATES.source, currencies: RATES.currencies }). The answer is{ base, date, stale, rates }, cacheable for an hour;nullwhen no rates are stored (cached for a minute); a database that fails is a 503 that is not cached. The route only reads the database: no visitor can make the Worker fetch or write.The page makes the choice and the client once and wraps the app:
const choice = createCurrencyChoice({ base: 'PLN', offered: ['EUR', 'USD', 'GBP'], languageToCurrency: { en: 'EUR', de: 'EUR', 'en-GB': 'GBP' } }) const rates = createRatesClient({ url: '/api/rates' }) <ExchangeRatesProvider choice={choice} rates={rates} locale={language}>…</ExchangeRatesProvider> <ApproxPrice amountMinor={product.priceGrosze} from="PLN" label={({ spoken }) => t('approx.spoken', { amount: spoken })} />useCurrencyChoice()returns[currency, set, choice]for a picker;choice.choicesis the list (the shop's own currency first: choosing it means "show no approximate price").
Where ApproxPrice goes
- A list or grid: under the price, in a line of fixed height, for every product card. One request serves all cards.
- A product page: next to the large price, and for each variant's price when it changes.
- The cart: next to each line's price and to the total.
- The checkout: next to the total, and this sentence, which the app writes in its languages, with the date
from
useApproxPrice(...).date(or the route'sdate): "You pay in zloty (PLN). The price in euro is approximate, by the rate of 9 October 2026." The payment button shows the amount in the shop's currency only. Whenstaleis true, either say the rate is old or hide the approximate price.
The page does not jump
ApproxPrice renders nothing for the shop's own currency, without rates or without a rate for the currency. While the
rates load it holds the line with an empty, aria-hidden box one line high (or your fallback, a skeleton as wide as
the price), so the rates arriving do not move what is below. A price that turns out to have no rate leaves a gap, and
the first paint of a visitor who chose another currency comes after hydration, so also give the container a one-line
min-height (min-height: 1lh) where a gap would show. Rates are fetched only when a visitor chose another currency, so most visitors cost no request.
On the server the choice is always the shop's currency, so the HTML never differs from the first client render.
Words and screen readers
The package holds no copy. The visible text is prefix (default "≈ ") plus the amount in the visitor's locale
(Intl.NumberFormat: "€12.40", "12,40 €"). That is aria-hidden; a visually hidden sentence from the app's label is
read instead, with the currency in words: label={({ spoken }) => about ${spoken}} reads "about 12.40 euros".
The calculation
approximate(10000, 'PLN', 'EUR', rates) // { ok: true, amountMinor: 2284, currency: 'EUR' }Amounts are in the smallest unit (grosze, cents, yen) as safe integers or BigInts; rates are decimal texts
("4.3775", at most 12+12 digits) and everything is BigInt arithmetic with one rounding at the end, half up to the
target's own minor unit (yen 0 digits, dinar 3, most 2; ISO 4217, not the browser's opinion). No float is ever in
between. Anything wrong is a result: unknown-currency, missing-rate, invalid-amount, overflow (the answer would
pass 2^53-1). A rate is "units of the base per one unit of the currency", the base is always 1.
The page shows no more precision than a daily mid rate knows: approximateFor (and so ApproxPrice) rounds the answer
to whole units, half up, for a currency whose unit is worth less than a tenth of the base (the forint, the rupee, the
hryvnia, the rupiah), where its cents are noise; the euro, the franc or the dinar keep their minor digits. NBP quotes
every currency per one unit (the forint as 0.011999, the yen as 0.024681), so there is no "per 100" to convert. A price
above zero that rounds to zero in the chosen currency is not shown (it would read as free); a price of zero shows zero.
Where rates come from, and what can go wrong
refreshRates(db, options) returns { status: 'updated' | 'unchanged' | 'failed' } and throws only for options that
are a mistake. unchanged: the day is stored (a weekend, when the source repeats Friday; a second run; a run that lost a
race), or older than the newest stored day. failed has a code and stores nothing, so the last good rates stay:
| code | meaning |
| --- | --- |
| network, timeout | no answer within timeoutMs (8 s); retried up to retries (2) with a doubling pause |
| http | a status other than 200; 429, 408 and 5xx are retried |
| malformed | no JSON, truncated, wrong type, an empty table, a duplicate code, a rate of 0, negative or NaN, a rate outside rateRange (0.0000001 to 10000000: what the jump guard cannot see on the very first table), a larger answer than 256 KB, a redirect (not followed), a day that is no day or more than a day ahead |
| incomplete | the table lacks a required currency, or the base |
| jump | a rate of a required currency (all, with no require) moved by more than maxJump (1.5) against the newest stored day, up or down; the whole table is refused. After a real revaluation, or an outage so long that a real move passed 1.5, run once with maxJump: Infinity |
| storage | D1 refused the read or the write |
latestRates(db, { base, source }) returns { base, date, rates, stale } | null. stale is true when the date is more
than staleAfterDays (4: a long weekend) calendar days (UTC) old. null means nothing is stored, or D1 failed: the shop
shows only its own price. The answer is kept in the isolate for cacheMs (60 s). Old days stay (keepDays 400, pruned
by the refresh itself, relative to the newest day), so a shop may show the date of the rate it used.
What the scheduled handler does with failed
The shop keeps its last good rates, so a failed run needs no reaction for the visitor. For staff, log it as an error
(console.error('exchange rates', result.code, result.detail)), where the console's log search finds it. network,
timeout and http are mostly transient: the second cron run of the day tries again. malformed, incomplete, jump
and storage are not: the same table will fail again, someone has to look. Nothing is stored for a refused table, so
rates then grow old and stale turns true after staleAfterDays; a shop that hides stale rates hides them then. A
long holiday stretch (Christmas, with a Friday table and no Monday) can pass 4 days: raise staleAfterDays for it, or
accept the mark.
Adding a source
A source is { id, fetchTable({ signal }) } returning { ok: true, table: { base, date, rates } } or a failure
(sourceFailure('http', …)), where rates[X] is the base currency's units per one X. fetchJson does the request, the
size limit and the failure codes. The ECB's daily table quotes "units of X per 1 EUR": a source for it is a parser that
passes the quotes to invertQuotes('EUR', date, quotes), which returns exactly that. refreshRates rebases any table to
the shop's base (rebase), so the ECB's EUR table serves a zloty shop without the caller changing; only source does.
Rows are kept by source.id, so two sources never mix.
Tests in an app
@codefusion-cc/exchange-rates/testing: EXAMPLE_RATES (a RateTable), fakeSource([…]) for refreshRates,
fakeFetch(json, status) for createRatesClient, nbpAnswer(date, mids) for a fake NBP.
