@blamejs/blamejs-shop 0.1.34 → 0.1.36

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.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.
12
+
13
+ - 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.
14
+
11
15
  - 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.
12
16
 
13
17
  - v0.1.33 (2026-05-26) — **Search filters and synonyms — narrow results by facet, and match what shoppers mean, not just what they typed.** Search gains filters and smarter matching. A search result page now shows facet groups — collection, price range, and in-stock — as filter controls; choosing one narrows the results, the counts beside each option reflect the current result set, and choices combine across groups. Active filters show with a one-click clear and carry through pagination rather than dropping on page two. All filtering is server-side from query parameters, so it works with no client JavaScript. Separately, the query is expanded through an operator-curated synonym and typo vocabulary before the catalog is searched, so a search for "tee" matches "t-shirt" and a near-miss spelling still finds the product, with a "Showing results for" note when the query was corrected. Filters and synonym matching work identically whether the page is served from the edge or the container. This release also moves the shipped theme's stylesheets and island scripts to content-fingerprinted filenames so an asset and the page that references it can never fall out of sync. **Added:** *Faceted search filters* — A search result page shows facet groups — collection, price range, and in-stock availability — as server-rendered filter controls. Selecting a facet narrows the results and is reflected in the URL (for example `/search?q=shirt&collection=summer&in_stock=1`), the count beside each option reflects how many results it would yield against the current query, and facets combine across groups. Active filters render as removable chips with a clear-all path, and the active filters carry through result pagination so page two keeps the filter set. All filtering is server-side from the query string — no client JavaScript — and unknown facet keys, out-of-range price values, and garbage filter values are ignored rather than erroring. With filters applied and no matches, the page shows an empty state with a clear-filters link. · *Synonym and typo-tolerant 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. When the query is corrected or expanded, the page shows a "Showing results for" note. A query that still matches nothing falls back to the raw terms so search never silently empties on an unknown word. **Changed:** *Search filters and synonyms render identically at the edge and the container* — The faceting and synonym logic runs in both the edge worker and the container search path, producing the same results and the same filter chrome whichever serves the page. The edge path reads the catalog and the facet/synonym vocabulary directly from the database, missing-table-resilient, so a shop without the optional facet configuration still searches normally. · *Theme stylesheets and scripts use content-fingerprinted filenames* — The shipped default theme's stylesheets and island scripts are now referenced by a content-hashed filename (`main.<hash>.css`) rather than a `?v=` query, emitted identically by the edge and container renderers and served from object storage under the fingerprinted key. Each URL maps to exactly one byte-content, so an asset and the page that references it can never disagree on a Subresource Integrity digest, and previously-served pages keep working across an asset change. The edge stylesheet link now also carries an integrity attribute, matching the container.
package/README.md CHANGED
@@ -80,6 +80,8 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
80
80
  | **`lib/category-navigation.js`** | Hierarchical category tree surfaced as public browse pages. `GET /categories` lists the active top-level categories as a card grid; `GET /categories/:slug` renders one category — its title and optional description, a breadcrumb chain from the catalog root down to the current category, an optional hero image, and a grid of the category's direct child sub-categories. Each page reads fresh against the active tree, so archived / unpublished categories drop out of every surface. Public, no sign-in; an unknown, archived, or malformed slug is a 404 (never a 500), and a category with no children renders a graceful empty state. Linked from the footer on every page. The tree itself (define / move / reorder / archive, with cycle defense bounded by `MAX_TREE_DEPTH`) is operator-managed through the primitive's write API. |
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
+ | **`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. |
83
85
  | **`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). |
84
86
  | **`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). |
85
87
  | **`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,17 @@
1
1
  {
2
- "version": "0.1.34",
2
+ "version": "0.1.36",
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-A2m9cYLHIlIrFvTluTOaZz9r8F90yWIKd3SHOlyNwQwtaOBrzlsn/NDR1vso6dSF",
10
- "fingerprinted": "css/main.91c0dfab0198569c.css"
9
+ "integrity": "sha384-oCNOJjwj7/EHXCrMnkhOTDY/5iFDeIvpWFmMyeX1g73CE4usg4i4q/dGYx4Ko258",
10
+ "fingerprinted": "css/main.0cbb632d5418155e.css"
11
+ },
12
+ "js/consent.js": {
13
+ "integrity": "sha384-KKMQ0og8HPOykRRPpUyxX7dMhTvKySfVtpGX/jGWzZwNaN/c4OykvRvXpqBHcQST",
14
+ "fingerprinted": "js/consent.517d6ad1fbf106a1.js"
11
15
  },
12
16
  "js/passkey-login.js": {
13
17
  "integrity": "sha384-65i1wVHIkiS2e0YiOV6oUOF9+tNR7s6QRvpnWaEne43P+B8UvKLTpDgp4MSKNGqX",
@@ -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
  };