@blamejs/blamejs-shop 0.1.35 → 0.1.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.1.x
10
10
 
11
+ - v0.1.37 (2026-05-26) — **Localized storefront — the UI chrome renders in the visitor's language, with a locale switcher and right-to-left support.** The storefront's interface chrome — the navigation, search controls, newsletter band, and footer — now renders in the visitor's language. The locale is resolved the same way at the edge and in the container: an explicit `?lang=` choice first, then a saved preference cookie, then the browser's Accept-Language header, then the operator's default. A locale switcher in the footer lets the visitor change it; the choice persists in a first-party cookie. Right-to-left languages (Arabic, Hebrew, Persian, …) flip the document direction automatically. Strings come from the operator's locale policy and translation rows, and any key without a translation falls back to the English baseline rather than showing a raw placeholder — so a partially translated catalog never renders broken text. The default locale renders at the edge for cookie-less visitors (so cached pages stay fast); a non-default choice is served by the container. Everything works with JavaScript off, and the localized markup is byte-identical across the edge and the container. **Added:** *Localized UI chrome + footer locale switcher* — Navigation, search labels, the newsletter band, and the footer render in the visitor's locale. A locale switcher in the footer lists the languages the operator has enabled (shown by their own names — "Deutsch", "Français"); selecting one persists the choice in a first-party cookie and returns the visitor to the page they were on. The locale resolves identically at the edge and in the container — `?lang=`, then the cookie, then `Accept-Language`, then the operator default — and a right-to-left language sets the document's `dir` so the layout mirrors correctly. The switcher and selection work without JavaScript. · *Operator locale policy with English-baseline fallback* — Enable languages by setting a locale policy (a default locale plus the supported list) and supplying translation rows for the chrome strings; the storefront resolves each string against the active policy, layered over the operator's translations, and falls back to the built-in English baseline for any key still missing — never a raw key or empty label. With no policy seeded the storefront renders the English baseline and shows no switcher, so the feature is opt-in and a fresh install is unaffected. `SHOP_DEFAULT_LOCALE` sets the default the edge serves to cookie-less visitors; set `SHOP_LOCALES` (the supported list) so the edge forwards a first-time visitor whose `Accept-Language` prefers a supported non-default locale to the container instead of caching the default. An explicit cookie or `?lang=` choice is always served by the container so edge caching stays simple.
12
+
13
+ - v0.1.36 (2026-05-26) — **Multi-currency display — shoppers browse prices in their own currency while you still charge in your base currency.** Shoppers can now pick a display currency from a switcher in the footer; every price on the storefront — product pages, the grid, search, and the cart — re-renders in that currency using the foreign-exchange rates you set, formatted with the right symbol, grouping, and decimal places for the locale. Conversion is display-only: the cart line and the eventual charge stay in your base currency, and a "Prices shown in EUR; you'll be charged in USD" disclosure makes that explicit whenever a conversion is in effect. The choice rides in a sealed first-party cookie and survives navigation; a per-currency rounding rule can snap converted prices to a tidy increment (for example Swiss 0.05 Rappenrundung). Every failure mode falls back to the base currency rather than showing a broken price: a currency with no rate on file, a rate that has expired, a currency you've removed from the allow-list, or a tampered cookie all render in your base currency. The switcher and all conversions render identically from the edge and the container, and the whole flow works with JavaScript off. **Added:** *Currency switcher + on-page price conversion* — A currency switcher in the footer lists the currencies you've enabled; choosing one `POST`s to `/currency`, stores the choice in a sealed cookie, and redirects back to the page the shopper was on. From then on product pages, the product grid, search results, and the cart display prices converted into that currency, each formatted for its locale (symbol, thousands separator, decimal places). When a conversion is active a "Prices shown in <display>; you'll be charged in <base>" note appears so the shopper always knows the charge currency. The switcher submits without JavaScript — the small enhancement script only auto-submits on change. · *Operator-set FX rates, display rounding, and a base-currency guarantee* — Set your base currency and the enabled list with `SHOP_BASE_CURRENCY` / `SHOP_CURRENCIES` (or the `shop.base_currency` / `shop.currencies` config keys), and supply foreign-exchange rates yourself from whatever source you trust — the framework never reaches out to a rate feed. Rates are stored as integer basis points and conversion uses banker's rounding; an optional per-currency rounding rule snaps the result to a display increment (e.g. nearest 0.05). Conversion is strictly for display: the cart line's currency and amount, and therefore the amount charged at checkout, always stay in the base currency. Any currency without a usable rate — missing, expired, de-listed, or a tampered cookie — falls back to base-currency display instead of a broken or `NaN` price, so a stale or absent rate feed never breaks a priced page.
14
+
11
15
  - v0.1.35 (2026-05-26) — **Cookie-consent banner — GDPR/ePrivacy consent with category opt-in, an audit ledger, and a preferences page.** Every page now carries a cookie-consent banner until the visitor decides. They can accept all, reject everything non-essential, or open preferences to toggle categories; the choice is stored in a sealed first-party cookie and recorded in a consent ledger for the audit trail. Strictly-necessary cookies (session, CSRF, the consent choice itself) always work; analytics and marketing are opt-in and default-deny, and a Do-Not-Track or Global-Privacy-Control signal collapses them to denied regardless of any stored opt-in. A "Manage cookies" footer link reopens the preferences. The banner and its choice form are server-rendered and work with JavaScript off — the small enhancement script only hides the banner once a choice exists — and the banner renders identically from the edge and the container. A consent-gating hook lets future analytics/marketing scripts render only when their category is granted. **Added:** *Consent banner + preferences page* — A banner shows on every page until a choice is made: Accept all, Reject non-essential, or Manage preferences. `POST /consent` records the decision (validated; safe-redirect back to where the visitor was), and `GET /cookies` is a preferences page to review and change categories later, linked from the footer. The authoritative choice is a sealed, HttpOnly first-party cookie; a tampered or missing cookie simply reshows the banner rather than erroring. Every decision is also written to the consent ledger for the GDPR audit trail, best-effort so a ledger hiccup never blocks the choice from taking effect. · *Category opt-in with DNT / GPC honored* — Cookies are grouped into strictly-necessary (always on) plus toggleable categories (analytics, marketing, …) that are opt-in and default-deny. A `DNT: 1` or `Sec-GPC: 1` request header forces the non-essential categories to denied regardless of any stored opt-in. A server-side gating hook renders a category's script only when that category is granted, so future analytics/marketing islands are consent-gated by construction. · *Consent is anchored to a policy version* — Each stored decision records the cookie-policy version it was captured under. When an operator bumps the active version (the cookie policy materially changed), decisions taken under the old version stop being honored — the gate falls back to default-deny for the optional categories and the banner re-prompts so consent is re-collected, rather than coasting on opt-ins given under a superseded policy. The banner and preferences page keep working with JavaScript off; the enhancement script only suppresses the banner while the stored version still matches the active one.
12
16
 
13
17
  - v0.1.34 (2026-05-26) — **Vendored blamejs refreshed to v0.13.0.** Refreshes the vendored blamejs stack from v0.12.11 to v0.13.0 — the single source of every security, crypto, and primitive the shop composes. No application API changes; the refresh keeps the shop current with upstream fixes and broadens the primitive surface the shop builds on. The vendored tree is verified against the release tag by the vendor-integrity gate, and the in-image smoke gate runs against the refreshed tree before any image ships. **Changed:** *Vendored blamejs v0.12.11 → v0.13.0* — The committed copy under `lib/vendor/blamejs/` is updated to the v0.13.0 release tag via the vendor-refresh entry point. Composed primitives are unchanged at the call sites the shop already uses; the refresh carries upstream maintenance and expands the available primitive surface for future shop features.
package/README.md CHANGED
@@ -81,6 +81,8 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
81
81
  | **`lib/search-facets.js`** | Filterable search. A search result page renders facet groups — collection, price range, in-stock — as server-rendered controls; selecting one narrows the results and rides the URL query string (`?q=…&collection=…&in_stock=1`), counts beside each option reflect the current result set, facets combine across groups, and active filters show as removable chips with a clear-all path that survives result pagination. All filtering is server-side from the query string (no client JS); unknown facet keys, out-of-range prices, and garbage values are ignored rather than erroring; an empty filtered result shows a clear-filters state. Runs identically at the edge worker and the container — the edge reads the catalog and facet registry straight from D1, missing-table-resilient. |
82
82
  | **`lib/search-synonyms.js`** | Typo-tolerant, synonym-aware query matching. Before the catalog is searched, the query is expanded through an operator-curated vocabulary — synonym groups (so "tee" matches "t-shirt"), common typo corrections, and stopword removal — and the page shows a "Showing results for" note when the query was corrected or expanded. A query that still matches nothing falls back to the raw terms so search never silently empties on an unknown word. Shared rewrite instance; runs identically at the edge and the container. |
83
83
  | **`lib/cookie-consent.js`** | GDPR/ePrivacy cookie consent. Every page carries a banner until the visitor decides — accept all, reject non-essential, or manage categories — with the choice in a sealed first-party cookie and recorded in a consent ledger for the audit trail. Strictly-necessary cookies are always on; analytics/marketing are opt-in and default-deny, and a `DNT`/`Sec-GPC` header forces non-essential to denied regardless of stored opt-in. `POST /consent` sets the choice (safe-redirect back); `GET /cookies` is the preferences page (footer-linked). A server-side gating hook renders a category's script only when granted. Banner + form are server-rendered (work with JS off; the island only hides the banner once decided) and identical at the edge and container. |
84
+ | **`lib/currency-display.js`** | Multi-currency display. Shoppers pick a display currency from the footer switcher (`POST /currency`, sealed cookie, 303 back); product pages, the grid, search, and the cart then show prices converted into it via operator-set FX rates (stored as integer basis points, banker's rounding), formatted per the currency's locale. Conversion is display-only — the cart line and the charged amount stay in the base currency, and a "Prices shown in <display>; you'll be charged in <base>" disclosure appears whenever converting. An optional per-currency rounding rule (composed with `lib/currency-rounding.js`) snaps converted prices to a display increment (e.g. CHF 0.05). Every failure mode — no rate on file, an expired rate, a de-listed currency, a tampered cookie — falls back to base-currency display, never a broken / `NaN` price. Base + enabled list come from `SHOP_BASE_CURRENCY` / `SHOP_CURRENCIES` (or `shop.base_currency` / `shop.currencies` config). Switcher + conversions are server-rendered (work with JS off) and identical at the edge and container. |
85
+ | **`lib/translations.js`** | Storefront localization. The UI chrome (nav, search, newsletter band, footer) renders in the visitor's locale, resolved identically at the edge and container: `?lang=`, then a first-party cookie, then `Accept-Language`, then the operator default. A footer locale switcher (languages shown by their autonyms) persists the choice and 303s back; `GET /locale` sets the cookie. Right-to-left languages set the document `dir`. Strings layer the operator's translation rows over a built-in English baseline — a missing key falls back to English, never a raw placeholder. Enable it by seeding a locale policy (default + supported locales) via `localeRouter`; with none seeded the storefront renders the English baseline and shows no switcher. `SHOP_DEFAULT_LOCALE` sets the edge default and `SHOP_LOCALES` (the supported list) lets the edge forward an `Accept-Language`-preferred non-default locale to the container instead of caching the default; an explicit cookie/`?lang=` choice is always container-served. Server-rendered (works with JS off), byte-identical edge/container. |
84
86
  | **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. Customers view + cancel their own subscriptions at `/account/subscriptions` (ownership-checked; cancel mounts when the payment handle is wired). |
85
87
  | **`lib/giftcards.js`** | Prepaid bearer gift cards. `issue({ amount_minor, currency })` generates a 16-char code (32-glyph alphabet, no ambiguous letters) via `b.crypto.generateBytes`, stores only its `namespaceHash` digest + a 4-char hint, and returns the plaintext code once. `balance(code)` / `lookup(code)` resolve a code to its live balance (constant-time hash compare); `redeem({ code, order_id, amount_minor })` decrements the balance with an atomic `balance >= amount` SQL guard so concurrent spends can't overdraw. Redeemed at checkout as a credit against the order grand total: the amount due drops by the applied balance (never below zero), the order still records the full total it owed, and the debit is recorded once per order — a card that fully covers the order is marked paid with no Stripe charge. Customers check a balance at `GET /gift-cards`; the page is not a code-existence oracle (unknown / malformed / expired all return the same generic not-found). |
86
88
  | **`lib/gift-card-ledger.js`** | Append-only credit / debit / expire history per gift card, with a denormalized `balance_after_minor` snapshot for O(1) balance reads. `credit` / `debit` / `expire` write one row each; `history(id)` paginates a card's transactions; `transactionsForOrder(id)` lists a card's movements for one order. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer. |
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.1.35",
2
+ "version": "0.1.37",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-rvjL1QySBgNJGEUDusbbMCkL3fsjmzcwUvTp9pljo/0Nn/lErxq2AbY8EYvDF9Ch",
6
6
  "fingerprinted": "css/admin.4dc78dfc45d0736d.css"
7
7
  },
8
8
  "css/main.css": {
9
- "integrity": "sha384-eNBBXiZ+B0S2drwiWoHzw7FzZRNGRO79o9VIwsaujSHTxQLz2zJabp0x74kFxAxq",
10
- "fingerprinted": "css/main.0ef22690caec76ea.css"
9
+ "integrity": "sha384-oCNOJjwj7/EHXCrMnkhOTDY/5iFDeIvpWFmMyeX1g73CE4usg4i4q/dGYx4Ko258",
10
+ "fingerprinted": "css/main.0cbb632d5418155e.css"
11
11
  },
12
12
  "js/consent.js": {
13
13
  "integrity": "sha384-KKMQ0og8HPOykRRPpUyxX7dMhTvKySfVtpGX/jGWzZwNaN/c4OykvRvXpqBHcQST",
@@ -53,6 +53,7 @@
53
53
 
54
54
  var b = require("./vendor/blamejs");
55
55
  var C = b.constants;
56
+ var currencyRounding = require("./currency-rounding");
56
57
 
57
58
  var CURRENCY_RE = /^[A-Z]{3}$/;
58
59
  var DEFAULT_TTL_MS = C.TIME.days(1);
@@ -409,10 +410,38 @@ function create(opts) {
409
410
  return { written: written, fetched_at: fetchedAt };
410
411
  }
411
412
 
413
+ // Display-side read: the rate to use when rendering `base` prices in
414
+ // `quote`, or null when there's nothing usable. Returns null (rather
415
+ // than the stale-flagged row `getRate` returns) for a missing row, an
416
+ // expired row, OR a quote outside the operator's allow-list — the three
417
+ // cases the storefront treats identically: fall back to the base
418
+ // currency. Missing-table-resilient: a query that throws (the
419
+ // `fx_rates` table not migrated on this deploy) reads as "no rate", so
420
+ // a storefront without the table degrades to base display rather than
421
+ // 500-ing every priced page. The `at` epoch-ms parameter overrides
422
+ // "now" for the freshness check (tests, deterministic replay).
423
+ async function getRateForDisplay(base, quote, at) {
424
+ _assertCurrency(base, "base");
425
+ _assertCurrency(quote, "quote");
426
+ if (base === quote) return { rate_bps: BPS_SCALE, stale: false };
427
+ if (supported.indexOf(quote) === -1) return null;
428
+ var now = at == null ? _now() : at;
429
+ var row;
430
+ try {
431
+ row = await getRate(base, quote);
432
+ } catch (_e) {
433
+ // fx_rates table absent / query failure — degrade to base display.
434
+ return null;
435
+ }
436
+ if (!row || row.expires_at < now) return null;
437
+ return { rate_bps: row.rate_bps, stale: false, source: row.source, fetched_at: row.fetched_at };
438
+ }
439
+
412
440
  return {
413
441
  supportedCurrencies: supported.slice(),
414
442
  setRate: setRate,
415
443
  getRate: getRate,
444
+ getRateForDisplay: getRateForDisplay,
416
445
  convert: convert,
417
446
  format: format,
418
447
  convertAndFormat: convertAndFormat,
@@ -421,9 +450,177 @@ function create(opts) {
421
450
  };
422
451
  }
423
452
 
453
+ // ---- display presenter (request-scoped) ---------------------------------
454
+ //
455
+ // The storefront renders a price string at dozens of call sites (PDP,
456
+ // catalog grids, search cards, cart lines + totals). Those renderers are
457
+ // synchronous; the FX-rate + rounding-rule lookups are async D1 reads.
458
+ // The presenter resolves the rate + rule ONCE per request (`load`), then
459
+ // hands the renderers a synchronous `format(amount_minor, currency)` that
460
+ // converts the catalog's base-currency price into the visitor's chosen
461
+ // display currency and formats it for that currency's locale.
462
+ //
463
+ // This is DISPLAY-ONLY. The cart / order / payment currency is unchanged
464
+ // — `pricing.format` + the stored order totals remain authoritative for
465
+ // what the customer is charged. The presenter only transforms the string
466
+ // the customer reads. When the display currency is the base currency (or
467
+ // unset / unknown / has no usable FX rate), `format` is exactly
468
+ // `b.money.fromMinorUnits(...).format(locale)` over the base amount.
469
+
470
+ // Default BCP 47 locale per currency so a EUR price renders "27,59 €"
471
+ // and a JPY price renders "¥3,200" without the operator wiring a locale
472
+ // table. The visitor's Accept-Language can override via `opts.locale`.
473
+ var DEFAULT_LOCALE_BY_CURRENCY = {
474
+ USD: "en-US", CAD: "en-CA", AUD: "en-AU", NZD: "en-NZ", SGD: "en-SG",
475
+ HKD: "en-HK", INR: "en-IN", GBP: "en-GB", EUR: "de-DE", CHF: "de-CH",
476
+ SEK: "sv-SE", NOK: "nb-NO", DKK: "da-DK", JPY: "ja-JP", KRW: "ko-KR",
477
+ BRL: "pt-BR", MXN: "es-MX",
478
+ };
479
+
480
+ function _localeFor(currency, fallback) {
481
+ return DEFAULT_LOCALE_BY_CURRENCY[currency] || fallback || "en-US";
482
+ }
483
+
484
+ // Pure base→display minor-unit conversion. Composes b.money.convert (the
485
+ // framework's banker's-rounding FX step, ISO 4217 exponent-aware) for the
486
+ // currency change, then the pure currency-rounding step for the display
487
+ // increment rule (CHF 0.05, SEK 0.10, …). No I/O, no audit-log write —
488
+ // the audit log is for committed checkout roundings, not the read-side
489
+ // display render. `rateBps` is the integer basis-points rate
490
+ // (rate * 10000); `rule` is the shaped rounding rule (or null for none).
491
+ function _convertMinorForDisplay(baseMinor, baseCurrency, displayCurrency, rateBps, rule) {
492
+ var convertedMinor;
493
+ if (baseCurrency === displayCurrency || rateBps === BPS_SCALE) {
494
+ convertedMinor = baseMinor;
495
+ } else {
496
+ var money = b.money.fromMinorUnits(BigInt(baseMinor), baseCurrency);
497
+ var rateStr = _bpsToDecimal(rateBps);
498
+ var provider = { rate: function (_from, _to) { return rateStr; } };
499
+ convertedMinor = Number(b.money.convert(money, displayCurrency, provider).toMinorUnits());
500
+ }
501
+ // Display-increment rounding — only when an active rule covers the
502
+ // display-side render. The rule's `applies_to` is honoured: a rule
503
+ // scoped to `cart_total` only does not touch a PDP price.
504
+ if (rule && rule.increment_minor > 1 &&
505
+ (rule.applies_to === "all" || rule.applies_to === "display_only")) {
506
+ convertedMinor = currencyRounding.round(convertedMinor, rule.increment_minor, rule.mode);
507
+ }
508
+ return convertedMinor;
509
+ }
510
+
511
+ // Build a presenter for ONE request. `displayCurrency` is the visitor's
512
+ // chosen currency (from the sealed cookie); `baseCurrency` is the
513
+ // catalog's settlement currency (USD by default). Resolves the FX rate +
514
+ // rounding rule up front so the returned `format` is synchronous.
515
+ //
516
+ // opts.fx — a currencyDisplay.create() instance (FX reads)
517
+ // opts.rounding — a currencyRounding.create() instance (optional)
518
+ // opts.displayCurrency — the visitor's choice (may be null / unknown)
519
+ // opts.baseCurrency — catalog settlement currency (default "USD")
520
+ // opts.locale — override the per-currency default locale
521
+ // opts.at — epoch-ms freshness override (tests)
522
+ //
523
+ // When the display currency equals the base, is unset, is unsupported, or
524
+ // has no usable FX rate, the presenter resolves to an INACTIVE presenter
525
+ // that formats in the base currency — never a broken / NaN price.
526
+ async function loadPresenter(opts) {
527
+ opts = opts || {};
528
+ var baseCurrency = opts.baseCurrency == null ? "USD" : opts.baseCurrency;
529
+ _assertCurrency(baseCurrency, "baseCurrency");
530
+ var requested = opts.displayCurrency;
531
+
532
+ // No / garbage choice, or the base currency itself → base presenter.
533
+ if (typeof requested !== "string" || !CURRENCY_RE.test(requested) || requested === baseCurrency) {
534
+ return _basePresenter(baseCurrency, opts.locale);
535
+ }
536
+ if (!opts.fx || typeof opts.fx.getRateForDisplay !== "function") {
537
+ return _basePresenter(baseCurrency, opts.locale);
538
+ }
539
+ // Outside the operator's allow-list → base presenter (and the switcher
540
+ // never lists it, so this is the tampered-cookie path).
541
+ if (opts.fx.supportedCurrencies && opts.fx.supportedCurrencies.indexOf(requested) === -1) {
542
+ return _basePresenter(baseCurrency, opts.locale);
543
+ }
544
+
545
+ var rate = await opts.fx.getRateForDisplay(baseCurrency, requested, opts.at);
546
+ if (!rate) {
547
+ // No usable FX rate (missing / stale / table absent) → base display.
548
+ return _basePresenter(baseCurrency, opts.locale);
549
+ }
550
+
551
+ var rule = null;
552
+ if (opts.rounding && typeof opts.rounding.getRule === "function") {
553
+ try {
554
+ rule = await opts.rounding.getRule(requested);
555
+ if (rule && !rule.active) rule = null;
556
+ } catch (_e) {
557
+ // currency_rounding_rules table absent / read failure — convert
558
+ // without the display-increment step. Banker's rounding from the
559
+ // FX step still applies, so the price is never broken.
560
+ rule = null;
561
+ }
562
+ }
563
+
564
+ var locale = opts.locale || _localeFor(requested);
565
+ var rateBps = rate.rate_bps;
566
+
567
+ return {
568
+ active: true,
569
+ baseCurrency: baseCurrency,
570
+ displayCurrency: requested,
571
+ locale: locale,
572
+ note: _disclosure(requested, baseCurrency),
573
+ // Synchronous price renderer. `currency` is the price's stored
574
+ // currency; when it matches the base we convert to the display
575
+ // currency, otherwise we format it as-is (a price already quoted in
576
+ // a non-base currency isn't re-converted — we have no base→that rate
577
+ // resolved this request, and re-quoting it would be wrong).
578
+ format: function (amountMinor, currency) {
579
+ var cur = currency == null ? baseCurrency : currency;
580
+ if (cur !== baseCurrency) {
581
+ return b.money.fromMinorUnits(BigInt(amountMinor), cur).format(_localeFor(cur, "en-US"));
582
+ }
583
+ var displayMinor = _convertMinorForDisplay(amountMinor, baseCurrency, requested, rateBps, rule);
584
+ return b.money.fromMinorUnits(BigInt(displayMinor), requested).format(locale);
585
+ },
586
+ };
587
+ }
588
+
589
+ // The pass-through presenter — `format` is byte-for-byte `pricing.format`
590
+ // (b.money over the stored amount + currency). Used whenever no display
591
+ // conversion applies, so every call site can use `presenter.format`
592
+ // unconditionally.
593
+ function _basePresenter(baseCurrency, localeOverride) {
594
+ return {
595
+ active: false,
596
+ baseCurrency: baseCurrency,
597
+ displayCurrency: baseCurrency,
598
+ locale: localeOverride || _localeFor(baseCurrency),
599
+ note: null,
600
+ format: function (amountMinor, currency) {
601
+ var cur = currency == null ? baseCurrency : currency;
602
+ var loc = localeOverride || _localeFor(cur, "en-US");
603
+ return b.money.fromMinorUnits(BigInt(amountMinor), cur).format(loc);
604
+ },
605
+ };
606
+ }
607
+
608
+ // The customer-facing disclosure shown near converted prices: the charge
609
+ // stays in the settlement currency even though the display is converted.
610
+ function _disclosure(displayCurrency, baseCurrency) {
611
+ return "Prices shown in " + displayCurrency + "; you'll be charged in " + baseCurrency + ".";
612
+ }
613
+
424
614
  module.exports = {
425
- create: create,
426
- DEFAULT_TTL_MS: DEFAULT_TTL_MS,
427
- DEFAULT_SUPPORTED: DEFAULT_SUPPORTED,
428
- BPS_SCALE: BPS_SCALE,
615
+ create: create,
616
+ loadPresenter: loadPresenter,
617
+ DEFAULT_TTL_MS: DEFAULT_TTL_MS,
618
+ DEFAULT_SUPPORTED: DEFAULT_SUPPORTED,
619
+ BPS_SCALE: BPS_SCALE,
620
+ DEFAULT_LOCALE_BY_CURRENCY: DEFAULT_LOCALE_BY_CURRENCY,
621
+ // Pure conversion exposed so the edge worker computes byte-identical
622
+ // display prices from the same rate + rule inputs.
623
+ convertMinorForDisplay: _convertMinorForDisplay,
624
+ localeFor: _localeFor,
625
+ disclosure: _disclosure,
429
626
  };