@blamejs/blamejs-shop 0.3.67 → 0.3.69

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.3.x
10
10
 
11
+ - v0.3.69 (2026-06-05) — **Discount codes on the cart page, and the full shipment timeline on the order page.** Shoppers can now redeem a discount code: an automatic-discount rule may carry an unlock code, leaving it dormant until a shopper enters that code on the cart page — the code persists on the cart, survives signing in, and flows through the exact same evaluation, quoting, and charge path as every other discount rule, so there is one discount system, not two. The cart shows the applied code with a remove control, totals re-render with the savings, and an unknown code gets one uniform message. Separately, the customer order page now shows the full shipment tracking timeline — every recorded carrier event with its status, location, and time, newest first — instead of only the latest event. Two schema migrations apply on deploy. **Added:** *Code-unlocked discount rules with cart-page redemption* — An automatic-discount rule can now carry an unlock code (one active rule per code). The rule stays dormant until a shopper enters its code in the new entry form on the cart page; the code is stored on the cart (capped, idempotent, carried across sign-in by the cart merge), totals re-render with the rule applied, and checkout's quote and confirm honor it on every payment path. The applied code renders as a chip with a remove control; unknown, malformed, or inactive codes all get the same message, so the endpoint reveals nothing about which codes exist. The redemption form is CSRF-tokened like the other cart configuration forms, and the cart page's edge-cached anonymous shell is unaffected — code state renders only on the container. · *Full shipment tracking timeline for customers* — The order page's tracking card previously showed the carrier, tracking number, status, and only the most recent event. It now renders the full event timeline newest-first — status, optional carrier location, optional note, and the event time — bounded to the newest twenty events. Orders without shipment events render exactly as before.
12
+
13
+ - v0.3.68 (2026-06-05) — **Delivery estimates: "Get it by" dates on the product and cart pages.** The product page and cart can now promise a delivery date. Operators configure the inputs at /admin/delivery-estimates — carrier transit times, warehouse cutoff hours, shipping holidays, and postal-prefix zones — and name the origin warehouse via the SHOP_ESTIMATE_ORIGIN environment variable or the shop.estimate_origin config row. A signed-in customer with a saved shipping address then sees "Get it by <date>" computed from the cutoff clock, transit days, and the holiday calendar. Anonymous visitors served from the edge cache deliberately see no date: an estimate is destination-specific, and a date baked into a shared cached page would show one visitor's delivery promise to everyone while going stale in the cache. The product and cart markup builders are byte-identical across the edge and container renderers with a deterministic date formatter, locked by a parity test. A store that has not configured estimates renders exactly what it does today — nothing — and a configuration gap can never produce a customer-facing error. **Added:** *Configurable delivery-date estimates* — The delivery-estimate primitive is wired end to end: an /admin/delivery-estimates console manages carrier transit times, order-cutoff hours per warehouse, shipping holidays, and postal-prefix zone mappings, with each mutation audited; the product and cart pages render "Get it by <date>" for signed-in customers with a saved shipping address once an origin warehouse is configured. Estimates render only where the destination is actually known — never on shared edge-cached pages — and every estimate read is fail-quiet: missing configuration, an unmatched destination, or a computation error renders no estimate rather than an error. Operators enable it by setting the origin (SHOP_ESTIMATE_ORIGIN or the shop.estimate_origin config row) and authoring at least one cutoff, transit time, and postal zone.
14
+
11
15
  - v0.3.67 (2026-06-05) — **Digital orders no longer require a shipping address, and search ranking shrugs off malformed weights.** A cart containing only digital goods completes checkout since 0.3.63, but the shipping form still demanded a street address and city the order would never use. For an all-digital cart, the form now marks the address fields optional with an honest note — email stays required for the receipt and country for tax — and the backend enforces exactly the same relaxed set, while any value the shopper does supply is still format-validated. Carts with any shippable item keep the full requirements. Separately, the search ranker now skips a non-numeric weight in a hand-edited weight set instead of letting it poison every score into NaN and garble the result order; the edge and container rankers apply the identical filter, keeping their orders locked together even on malformed configuration. **Fixed:** *Digital-only carts check out without a street address* — When no line in the cart requires shipping, the checkout form renders the address block as optional — the required markers move off street and city, a note explains that a digital order ships nothing and country is kept for tax, and the backend requires exactly email, name, and country. Values the shopper supplies anyway are still format-validated, including the per-field error rendering. A cart with any physical item keeps the full address requirements, and a missing variant record counts as physical, so the relaxation can never trigger on incomplete data. · *Search ranking ignores non-numeric weights instead of corrupting the order* — A weight set carrying a non-numeric value — possible through a hand-edited database row — multiplied into every product's score as NaN, making the result order effectively random on the container while the edge filtered the bad weight and produced a different order. Both rankers now skip non-numeric weights identically, so a malformed entry degrades to "that signal contributes nothing" with edge and container orders staying byte-identical.
12
16
 
13
17
  - v0.3.66 (2026-06-05) — **Production server errors are readable from the admin console and the CLI.** When a server-side failure was scrubbed to a clean customer-facing page — a payment-processor rejection at checkout, a public-API failure, an admin-action error — the underlying detail was only visible in the hosting dashboard's container log viewer. The error-log primitive now captures those failures into the database: route, method, status, a length-bounded message, and a timestamp, ring-buffered to the newest five hundred entries, written fire-and-forget so a logging failure can never affect a customer request, and carrying no customer or session data. A new Errors screen in the admin console lists them newest-first, and the same route answers a bearer-token request with JSON, so operators and tooling can read production errors with one curl. A schema migration adds the message column and applies on deploy. **Added:** *Server-error capture with an admin Errors screen and JSON API* — Scrubbed 500-class failures — the checkout confirm step, the public catalog API, and admin console actions — now also record route, method, status, and a truncated error message into the error-log table (newest five hundred kept; writes are drop-silent and never awaited in the request path; no customer, session, or header data is stored). GET /admin/errors renders the newest entries in the console with every cell escaped, and the same path with an Authorization: Bearer admin token returns JSON for command-line and tooling access. Migration 0208 adds the message column plus an index and applies with the normal deploy.
package/README.md CHANGED
@@ -66,6 +66,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
66
66
  | **`lib/pricing.js`** | Pure-function money math — `lineTotal`, `subtotal`, `totals`, `format`. Multi-currency refused, banker's-style rounding, locale-aware via `Intl.NumberFormat`. |
67
67
  | **`lib/tax.js`** | Operator-table adapter. Country / state / postal_prefix → rate_bps. Most-specific-first match, banker's rounding. Pluggable adapter shape for future Stripe Tax / TaxJar / Avalara. |
68
68
  | **`lib/shipping.js`** | Operator-table adapter. Services with zones (flat or per-gram + base + min/max), free-over-threshold, `digital_only` flag. |
69
+ | **`lib/delivery-estimate.js`** | "Get it by <date>" promises. Operators define carrier transit times, warehouse cutoffs, holidays, and postal-prefix zones at `/admin/delivery-estimates`; the product and cart pages then show a signed-in customer a delivery date computed against their saved shipping address and the configured origin (`SHOP_ESTIMATE_ORIGIN` or the `shop.estimate_origin` config row). Anonymous, edge-cached pages deliberately render no date — estimates are destination-specific and never bake into the shared cache. Unconfigured stores render nothing, never an error. |
69
70
  | **`lib/payment.js`** | Payment adapters — **Stripe** (verify webhook HMAC-SHA256 via upstream `b.webhook.verify`, create / retrieve / confirm / cancel PaymentIntent, refund, register / list payment-method domains for Apple/Google Pay) and **PayPal** (`adapter: "paypal"` — OAuth2 client-credentials token, create / capture / get / refund Orders v2, webhook verify via PayPal's verify-webhook-signature API). No `stripe` / `paypal` npm dep — outbound through `b.httpClient` (SSRF-gated, retried, circuit-broken). |
70
71
  | **`lib/order.js`** | FSM-driven post-checkout record via upstream `b.fsm`. States: pending → paid → fulfilling → shipped → delivered (+ refunded / cancelled). Every transition appends to `order_transitions`. |
71
72
  | **`lib/checkout.js`** | Orchestrator. `quote()` returns priced quote; `confirm()` validates the ship-to address (real ISO 3166-1 country; US/CA state + ZIP/postal formats; lenient elsewhere) and the customer email shape, then creates a Stripe PaymentIntent + persists order pending; `handleStripeEvent()` verifies webhook + fires the FSM transition. PayPal path: `createPaypalOrder()` opens a PayPal order + persists pending, `capturePaypalOrder()` captures → paid, `handlePaypalEvent()` is the webhook backstop. All idempotent on re-delivery. |
@@ -96,7 +97,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
96
97
  | **`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). |
97
98
  | **`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. |
98
99
  | **`lib/newsletter.js`** | Operator-collected email broadcast list — `signup({ email, source })` composes `b.guardEmail` for shape validation, `b.crypto.namespaceHash` for the dedup key, and `INSERT OR IGNORE` for idempotency. Storefront POST `/newsletter` route renders a designed thank-you card with separate copy for the `new` vs `dedup` branches. |
99
- | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, and Errors links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
100
+ | **`lib/admin.js`** | Bearer-token-gated CRUD over catalog + orders + refunds + bulk CSV import + subscription plans + review moderation + return moderation. Token compared via `b.crypto.timingSafeEqual`. Errors as RFC 9457 problem documents via `b.problemDetails`. Audit emission on every mutation. Also serves a **browser admin console**: sign in at `/admin` by pasting the API key (sealed `shop_admin` session cookie, SameSite=Strict, /admin-scoped), with a persistent nav across every signed-in page. A guided **setup wizard** at `/admin/setup` writes shop identity to config; **Products** (`/admin/products`) browses the catalog and creates / archives / restores, and each product opens a management screen that edits its fields, adds / edits / removes variants, sets a variant's price and shows its price history, and attaches / uploads / removes images — the full path to a sellable product; **Inventory** (`/admin/inventory`) lists stock per SKU (on-hand / held / available) with a low-stock filter, restocks, sets per-SKU thresholds, and tracks new SKUs; **Orders** (`/admin/orders`) lists recent orders with status filters, opens an order's items, totals, and shipping address, and drives the lifecycle (mark paid → fulfil → ship → deliver, cancel — Refund goes through the payment provider) through the order FSM, and attaches a shipment (carrier + tracking number) with recorded shipment events that surface a public tracking link to the customer; **Customers** (`/admin/customers`) is a read-only roster, newest first — display name, short id, join date, sign-in method (passkey count + linked OAuth providers), and order count, with the count and sign-in methods resolved by bounded aggregate queries so a page of customers costs no per-row trips (email addresses aren't stored in the clear, so they're not shown); **Returns** (`/admin/returns`) is the RMA moderation queue — filter by status, open a request's items and reason, and approve (with refund amount) → mark received → refund, or reject with a reason, over the return FSM; **Reviews** (`/admin/reviews`) is the review moderation queue — filter by status and publish, reject (with a reason), or take down each submission inline; **Q&A** (`/admin/questions`) is the question moderation queue — filter by status, open a question to its full answer thread, approve / reject the question, post the seller answer, and approve / reject / pin individual answers; **Subscriptions** (`/admin/subscription-plans`) is the recurring-offer catalog — filter active / archived, create a plan (Stripe price id, interval, amount, trial), and archive one, with archiving terminal because the mirrored Stripe price can go stale; **Collections** (`/admin/collections`) manages manual + smart product collections — filter active / archived, create a collection (manual or smart with a starter rule), and per collection edit title / description / sort strategy, manage manual members (add by product id, remove, reorder) or edit a smart collection's rule set with a live preview of the products the rules currently match, and archive; **Gift cards** (`/admin/gift-cards`) is the gift-card ledger — list issued cards (masked code, original + remaining balance, status, issued date) filtered by lifecycle status, issue a new card (the bearer code shown once, right after creation), open a card to see its full credit / debit / expire ledger, and void an active card through a confirmation step; **Webhooks** (`/admin/webhooks`) registers outbound endpoints (https:// only) with a one-time signing-secret reveal, enables / disables / deletes them, and opens an endpoint's delivery feed to retry a failed delivery — the signing secret is shown once on create and never in the list, and order transitions fan out signed deliveries to subscribed endpoints. **Tax** (`/admin/tax-rates`), **Shipping** (`/admin/shipping`), and **Discounts** (`/admin/discounts`) configure tax rates per jurisdiction, shipping zones + rates, and automatic-discount rules — including code-unlocked rules a shopper redeems with a discount code on the cart page — + coupon-stacking policies — create / edit / archive each. **Audit** (`/admin/audit`) is a read-only activity log of every privileged action — filtered by outcome (success / failure / denied) and paginated — composed on the framework's tamper-evident `b.audit` chain; opening it is itself recorded as an `audit.read` event. **Errors** (`/admin/errors`) lists captured server-error detail — time, status, route, and a truncated message for scrubbed 500-class failures (checkout confirm, public API, admin actions) — newest-first, with the same path answering a bearer-token request with JSON so the log is one `curl` away. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, and Errors links appear only when those primitives are wired. Each console path content-negotiates: a bearer-token client still gets the JSON API unchanged, a signed-in browser gets HTML. Reachable by the cookie or the bearer token. The console's styling is an external, integrity-pinned stylesheet (`themes/default/assets/css/admin.css`) with the same self-hosted typeface — no inline styles and no third-party font host, so it renders correctly under the strict `style-src 'self'` / `font-src 'self'` CSP that governs the route. |
100
101
  | **`lib/catalog-import.js`** | Bulk CSV import — `POST /admin/catalog/import` accepts a `text/csv` body, parses via `b.csv`, content-safety-filters every cell through `b.guardCsv` (formula-injection / bidi / control / dangerous-function denylist), validates exact header order, de-dupes rows by `product_slug`, returns per-row errors without aborting. Default 1 MiB / 10000 rows caps. |
101
102
  | **`lib/theme.js`** | File-backed templates with fallback chain. Operators register a named theme under `<themesDir>/<name>/*.html` and the storefront dispatches every renderer through it. `assetUrl(path)` resolves to `/assets/themes/<name>/<path>`. The shipped `default` theme is the fallback. |
102
103
 
package/lib/admin.js CHANGED
@@ -573,7 +573,7 @@ function mount(router, deps) {
573
573
  // `reports` is always present in the nav (read-only sales summary needs no
574
574
  // extra dep); its route mounts unconditionally and renders an unconfigured
575
575
  // notice when the salesReports primitive isn't wired.
576
- var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog };
576
+ var navAvailable = { returns: !!returns, reviews: !!reviews, productQa: !!productQa, subscriptions: !!deps.subscriptions, preorder: !!deps.preorder, webhooks: !!deps.webhooks, collections: !!deps.collections, customers: !!deps.customers, customerSegments: !!customerSegments, giftcards: !!deps.giftcards, announcementBar: !!deps.announcementBar, blog: !!deps.blog, knowledgeBase: !!deps.knowledgeBase, customerSurveys: !!deps.customerSurveys, storefrontPages: !!deps.storefrontPages, businessHours: !!deps.businessHours, taxRates: !!deps.taxRates, shippingZones: !!deps.shippingZones, deliveryEstimate: !!deps.deliveryEstimate, autoDiscount: !!deps.autoDiscount, discountAllocation: !!deps.discountAllocation, quantityDiscounts: !!deps.quantityDiscounts, loyalty: !!deps.loyalty, pickLists: !!pickLists, salesTaxFilings: !!salesTaxFilings, shippingLabels: !!shippingLabels, supportTickets: !!supportTickets, complianceExport: !!complianceExport, orderExchanges: !!orderExchanges, orderRatings: !!orderRatings, clickAndCollect: !!clickAndCollect, giftOptions: !!giftOptions, searchRanking: !!searchRanking, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog };
577
577
 
578
578
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
579
579
 
@@ -8910,6 +8910,170 @@ function mount(router, deps) {
8910
8910
  ));
8911
8911
  }
8912
8912
 
8913
+ // ---- delivery estimates ---------------------------------------------
8914
+ //
8915
+ // The "Get it by <date>" tables on one console screen: carrier transits
8916
+ // (per from→to zone / carrier / service-level transit-day budget), origin
8917
+ // cutoffs (the daily local-time ship-by per origin location + IANA tz),
8918
+ // observed holidays (per region, dropped from the business-day count), and
8919
+ // postal-prefix → zone mappings (destination lookup). Each is create +
8920
+ // list + archive (transits + holidays archive by uuid; cutoffs + postal
8921
+ // zones are upserts with no archive — re-author to change). The only free-
8922
+ // text field is a holiday's `name`, escaped at render. Every date field
8923
+ // validates against the primitive's DATE_RE (it re-validates server-side,
8924
+ // so a bad date is a 400 notice, never a 500); carrier / service-level /
8925
+ // zone / postal-prefix are enum/regex-bounded by the primitive.
8926
+ var deliveryEstimate = deps.deliveryEstimate || null;
8927
+ if (deliveryEstimate) {
8928
+ // Re-read every table for the screen in one helper so create / archive
8929
+ // re-renders all share the same shape. Drop-silent per table: an
8930
+ // unmigrated table degrades to an empty list, never a 500.
8931
+ async function _deliveryEstimateModel() {
8932
+ var model = { transits: [], cutoffs: [], holidays: [], postal_zones: [] };
8933
+ try { model.transits = await deliveryEstimate.listTransits({}); } catch (_e) { model.transits = []; }
8934
+ try { model.cutoffs = await deliveryEstimate.listCutoffs(); } catch (_e) { model.cutoffs = []; }
8935
+ try { model.holidays = await deliveryEstimate.listHolidays({}); } catch (_e) { model.holidays = []; }
8936
+ try { model.postal_zones = await deliveryEstimate.listPostalZones({}); } catch (_e) { model.postal_zones = []; }
8937
+ return model;
8938
+ }
8939
+
8940
+ // One handler factory per create surface — each defines a row, then on
8941
+ // the HTML path re-renders the screen with a 400 notice on a TypeError
8942
+ // (the primitive's own validation throws carry operator-safe text) and
8943
+ // routes everything else through the shared classifier so a raw
8944
+ // constraint / unknown error can't reach the banner.
8945
+ function _deCreate(kind, auditAction, define, transform) {
8946
+ return _pageOrApi(false,
8947
+ W(auditAction, async function (req, res) {
8948
+ var row = await define(transform(req.body || {}));
8949
+ _json(res, 201, row);
8950
+ return { id: (row && (row.id || row.origin_location || row.postal_prefix)) || kind };
8951
+ }),
8952
+ async function (req, res) {
8953
+ try {
8954
+ await define(transform(req.body || {}));
8955
+ } catch (e) {
8956
+ var n = _safeNotice(e, auditAction);
8957
+ var noticeMsg = n.message.replace(/^deliveryEstimate[.:]\s*/, "");
8958
+ var model = await _deliveryEstimateModel();
8959
+ return _sendHtml(res, n.status, renderAdminDeliveryEstimates(Object.assign({
8960
+ shop_name: deps.shop_name, nav_available: navAvailable, notice: noticeMsg,
8961
+ }, model)));
8962
+ }
8963
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + auditAction, outcome: "success" });
8964
+ _redirect(res, "/admin/delivery-estimates?created=" + kind);
8965
+ },
8966
+ );
8967
+ }
8968
+
8969
+ router.post("/admin/delivery-estimates/transits", _deCreate(
8970
+ "transit", "delivery_transit.create",
8971
+ function (input) { return deliveryEstimate.defineCarrierTransit(input); },
8972
+ function (body) {
8973
+ return {
8974
+ from_zone: typeof body.from_zone === "string" ? body.from_zone.trim() : body.from_zone,
8975
+ to_zone: typeof body.to_zone === "string" ? body.to_zone.trim() : body.to_zone,
8976
+ carrier: typeof body.carrier === "string" ? body.carrier.trim() : body.carrier,
8977
+ service_level: typeof body.service_level === "string" ? body.service_level.trim() : body.service_level,
8978
+ transit_days: _strictMinorInt(body.transit_days, "deliveryEstimate", "transit_days"),
8979
+ };
8980
+ },
8981
+ ));
8982
+
8983
+ router.post("/admin/delivery-estimates/cutoffs", _deCreate(
8984
+ "cutoff", "delivery_cutoff.create",
8985
+ function (input) { return deliveryEstimate.defineCutoff(input); },
8986
+ function (body) {
8987
+ return {
8988
+ origin_location: typeof body.origin_location === "string" ? body.origin_location.trim() : body.origin_location,
8989
+ daily_cutoff_local_time: typeof body.daily_cutoff_local_time === "string" ? body.daily_cutoff_local_time.trim() : body.daily_cutoff_local_time,
8990
+ timezone: typeof body.timezone === "string" ? body.timezone.trim() : body.timezone,
8991
+ };
8992
+ },
8993
+ ));
8994
+
8995
+ router.post("/admin/delivery-estimates/holidays", _deCreate(
8996
+ "holiday", "delivery_holiday.create",
8997
+ function (input) { return deliveryEstimate.defineHoliday(input); },
8998
+ function (body) {
8999
+ return {
9000
+ region: typeof body.region === "string" ? body.region.trim() : body.region,
9001
+ date: typeof body.date === "string" ? body.date.trim() : body.date,
9002
+ name: body.name,
9003
+ };
9004
+ },
9005
+ ));
9006
+
9007
+ router.post("/admin/delivery-estimates/postal-zones", _deCreate(
9008
+ "postal_zone", "delivery_postal_zone.create",
9009
+ function (input) { return deliveryEstimate.definePostalZone(input); },
9010
+ function (body) {
9011
+ return {
9012
+ country: typeof body.country === "string" ? body.country.trim().toUpperCase() : body.country,
9013
+ postal_prefix: typeof body.postal_prefix === "string" ? body.postal_prefix.trim() : body.postal_prefix,
9014
+ zone: typeof body.zone === "string" ? body.zone.trim() : body.zone,
9015
+ };
9016
+ },
9017
+ ));
9018
+
9019
+ router.get("/admin/delivery-estimates", _pageOrApi(true,
9020
+ R(async function (req, res) {
9021
+ _json(res, 200, await _deliveryEstimateModel());
9022
+ }),
9023
+ async function (req, res) {
9024
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
9025
+ var model = await _deliveryEstimateModel();
9026
+ _sendHtml(res, 200, renderAdminDeliveryEstimates(Object.assign({
9027
+ shop_name: deps.shop_name, nav_available: navAvailable,
9028
+ created: url && url.searchParams.get("created"),
9029
+ archived: url && url.searchParams.get("archived"),
9030
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
9031
+ }, model)));
9032
+ },
9033
+ ));
9034
+
9035
+ // Archive a transit / holiday by its uuid. Both archive surfaces share
9036
+ // the same shape: a TypeError (bad uuid) is a 400; a null result (no
9037
+ // such row) re-renders with a notice; success redirects with ?archived.
9038
+ router.post("/admin/delivery-estimates/transits/:id/archive", _pageOrApi(false,
9039
+ W("delivery_transit.archive", async function (req, res) {
9040
+ var row;
9041
+ try { row = await deliveryEstimate.archiveTransit(req.params.id); }
9042
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
9043
+ if (!row) return _problem(res, 404, "transit-not-found");
9044
+ _json(res, 200, row);
9045
+ return { id: row.id };
9046
+ }),
9047
+ async function (req, res) {
9048
+ var row = null;
9049
+ try { row = await deliveryEstimate.archiveTransit(req.params.id); }
9050
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
9051
+ if (!row) return _redirect(res, "/admin/delivery-estimates?err=1");
9052
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".delivery_transit.archive", outcome: "success", metadata: { id: req.params.id } });
9053
+ _redirect(res, "/admin/delivery-estimates?archived=transit");
9054
+ },
9055
+ ));
9056
+
9057
+ router.post("/admin/delivery-estimates/holidays/:id/archive", _pageOrApi(false,
9058
+ W("delivery_holiday.archive", async function (req, res) {
9059
+ var row;
9060
+ try { row = await deliveryEstimate.archiveHoliday(req.params.id); }
9061
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
9062
+ if (!row) return _problem(res, 404, "holiday-not-found");
9063
+ _json(res, 200, row);
9064
+ return { id: row.id };
9065
+ }),
9066
+ async function (req, res) {
9067
+ var row = null;
9068
+ try { row = await deliveryEstimate.archiveHoliday(req.params.id); }
9069
+ catch (e) { if (!(e instanceof TypeError)) throw e; }
9070
+ if (!row) return _redirect(res, "/admin/delivery-estimates?err=1");
9071
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".delivery_holiday.archive", outcome: "success", metadata: { id: req.params.id } });
9072
+ _redirect(res, "/admin/delivery-estimates?archived=holiday");
9073
+ },
9074
+ ));
9075
+ }
9076
+
8913
9077
  // Translate the create form / JSON body into a defineZone input. The
8914
9078
  // console form is the single-country, single-flat-rate shape; the
8915
9079
  // bearer JSON path can pass the full regions[]/rates[] arrays directly,
@@ -10710,6 +10874,7 @@ var ADMIN_NAV_ITEMS = [
10710
10874
  { key: "tax", href: "/admin/tax-rates", label: "Tax", requires: "taxRates" },
10711
10875
  { key: "tax-filings", href: "/admin/tax-filings", label: "Tax filings", requires: "salesTaxFilings" },
10712
10876
  { key: "shipping", href: "/admin/shipping", label: "Shipping", requires: "shippingZones" },
10877
+ { key: "delivery-estimates", href: "/admin/delivery-estimates", label: "Delivery estimates", requires: "deliveryEstimate" },
10713
10878
  { key: "shipping-labels", href: "/admin/shipping-labels", label: "Shipping labels", requires: "shippingLabels" },
10714
10879
  { key: "pick-lists", href: "/admin/pick-lists", label: "Pick lists", requires: "pickLists" },
10715
10880
  { key: "announcements", href: "/admin/announcements", label: "Announcements", requires: "announcementBar" },
@@ -14211,6 +14376,138 @@ function renderAdminShippingZone(opts) {
14211
14376
  return _renderAdminShell(opts.shop_name, "Zone " + z.slug, body, "shipping", opts.nav_available);
14212
14377
  }
14213
14378
 
14379
+ // Delivery-estimate console — the four "Get it by <date>" tables (carrier
14380
+ // transits, origin cutoffs, observed holidays, postal-prefix → zone) each
14381
+ // with a list + a create form, transits + holidays carrying an archive
14382
+ // button. The only operator free-text field is a holiday's `name`, escaped
14383
+ // on render. Carrier is a fixed <select> (the primitive's CARRIERS enum);
14384
+ // every other field is regex-bounded server-side, so a bad value is a 400
14385
+ // notice, never a 500.
14386
+ function renderAdminDeliveryEstimates(opts) {
14387
+ opts = opts || {};
14388
+ var transits = opts.transits || [];
14389
+ var cutoffs = opts.cutoffs || [];
14390
+ var holidays = opts.holidays || [];
14391
+ var zones = opts.postal_zones || [];
14392
+
14393
+ var created = opts.created ? "<div class=\"banner banner--ok\">Entry created.</div>" : "";
14394
+ var archived = opts.archived ? "<div class=\"banner banner--ok\">Entry archived.</div>" : "";
14395
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
14396
+
14397
+ var CARRIERS = ["ups", "fedex", "usps", "dhl", "flat_rate", "custom"];
14398
+ var carrierOpts = CARRIERS.map(function (c) {
14399
+ return "<option value=\"" + _htmlEscape(c) + "\">" + _htmlEscape(c) + "</option>";
14400
+ }).join("");
14401
+
14402
+ // ---- carrier transits ----
14403
+ var transitRows = transits.map(function (t) {
14404
+ return "<tr>" +
14405
+ "<td><code class=\"order-id\">" + _htmlEscape(t.from_zone) + "</code> &rarr; <code class=\"order-id\">" + _htmlEscape(t.to_zone) + "</code></td>" +
14406
+ "<td>" + _htmlEscape(t.carrier) + "</td>" +
14407
+ "<td>" + _htmlEscape(t.service_level) + "</td>" +
14408
+ "<td class=\"num\">" + _htmlEscape(String(t.transit_days)) + "</td>" +
14409
+ "<td><form method=\"post\" action=\"/admin/delivery-estimates/transits/" + _htmlEscape(encodeURIComponent(t.id)) + "/archive\" class=\"form-inline\">" +
14410
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form></td>" +
14411
+ "</tr>";
14412
+ }).join("");
14413
+ var transitTable = transits.length
14414
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Zone pair</th><th scope=\"col\">Carrier</th><th scope=\"col\">Service</th><th scope=\"col\" class=\"num\">Transit days</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + transitRows + "</tbody></table>") + "</div>"
14415
+ : "<p class=\"empty\">No carrier transits yet.</p>";
14416
+ var transitForm =
14417
+ "<div class=\"panel mt mw-40\">" +
14418
+ "<h3 class=\"subhead\">Add a carrier transit</h3>" +
14419
+ "<p class=\"meta\">The carrier-declared business-day budget for one zone pair and service level.</p>" +
14420
+ "<form method=\"post\" action=\"/admin/delivery-estimates/transits\">" +
14421
+ _setupField("From zone", "from_zone", "", "text", "Origin zone slug, e.g. dc-east.", " maxlength=\"64\" class=\"input-code\" required") +
14422
+ _setupField("To zone", "to_zone", "", "text", "Destination zone slug, e.g. us-west.", " maxlength=\"64\" class=\"input-code\" required") +
14423
+ "<label class=\"form-field\"><span>Carrier</span><select name=\"carrier\" required>" + carrierOpts + "</select></label>" +
14424
+ _setupField("Service level", "service_level", "", "text", "e.g. GROUND, TWO_DAY.", " maxlength=\"64\" class=\"input-code\" required") +
14425
+ _setupField("Transit days", "transit_days", "", "number", "Business days in transit, 0–365.", " min=\"0\" max=\"365\" required") +
14426
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add transit</button></div>" +
14427
+ "</form>" +
14428
+ "</div>";
14429
+
14430
+ // ---- origin cutoffs ----
14431
+ var cutoffRows = cutoffs.map(function (c) {
14432
+ return "<tr>" +
14433
+ "<td><code class=\"order-id\">" + _htmlEscape(c.origin_location) + "</code></td>" +
14434
+ "<td>" + _htmlEscape(c.daily_cutoff_local_time) + "</td>" +
14435
+ "<td>" + _htmlEscape(c.timezone) + "</td>" +
14436
+ "</tr>";
14437
+ }).join("");
14438
+ var cutoffTable = cutoffs.length
14439
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Origin</th><th scope=\"col\">Cutoff (local)</th><th scope=\"col\">Timezone</th></tr></thead><tbody>" + cutoffRows + "</tbody></table>") + "</div>"
14440
+ : "<p class=\"empty\">No origin cutoffs yet.</p>";
14441
+ var cutoffForm =
14442
+ "<div class=\"panel mt mw-40\">" +
14443
+ "<h3 class=\"subhead\">Add an origin cutoff</h3>" +
14444
+ "<p class=\"meta\">The daily local-time cutoff for an origin. Orders after it ship the next business day. Re-add to change.</p>" +
14445
+ "<form method=\"post\" action=\"/admin/delivery-estimates/cutoffs\">" +
14446
+ _setupField("Origin location", "origin_location", "", "text", "Origin slug, e.g. dc-east.", " maxlength=\"80\" class=\"input-code\" required") +
14447
+ _setupField("Daily cutoff (HH:MM)", "daily_cutoff_local_time", "", "text", "24-hour local time, e.g. 14:00.", " maxlength=\"5\" class=\"input-code\" required") +
14448
+ _setupField("Timezone", "timezone", "", "text", "IANA name, e.g. America/New_York.", " maxlength=\"64\" required") +
14449
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add cutoff</button></div>" +
14450
+ "</form>" +
14451
+ "</div>";
14452
+
14453
+ // ---- observed holidays ----
14454
+ var holidayRows = holidays.map(function (h) {
14455
+ return "<tr>" +
14456
+ "<td>" + _htmlEscape(h.region) + "</td>" +
14457
+ "<td>" + _htmlEscape(h.date) + "</td>" +
14458
+ "<td>" + _htmlEscape(h.name) + "</td>" +
14459
+ "<td><form method=\"post\" action=\"/admin/delivery-estimates/holidays/" + _htmlEscape(encodeURIComponent(h.id)) + "/archive\" class=\"form-inline\">" +
14460
+ "<button class=\"btn btn--danger\" type=\"submit\">Archive</button></form></td>" +
14461
+ "</tr>";
14462
+ }).join("");
14463
+ var holidayTable = holidays.length
14464
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Region</th><th scope=\"col\">Date</th><th scope=\"col\">Name</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + holidayRows + "</tbody></table>") + "</div>"
14465
+ : "<p class=\"empty\">No holidays yet.</p>";
14466
+ var holidayForm =
14467
+ "<div class=\"panel mt mw-40\">" +
14468
+ "<h3 class=\"subhead\">Add an observed holiday</h3>" +
14469
+ "<p class=\"meta\">A non-shipping date dropped from a region's business-day count.</p>" +
14470
+ "<form method=\"post\" action=\"/admin/delivery-estimates/holidays\">" +
14471
+ _setupField("Region", "region", "", "text", "Operator region slug, e.g. us or us-ca.", " maxlength=\"16\" class=\"input-code\" required") +
14472
+ _setupField("Date", "date", "", "text", "YYYY-MM-DD.", " maxlength=\"10\" class=\"input-code\" required") +
14473
+ _setupField("Name", "name", "", "text", "e.g. Independence Day.", " maxlength=\"200\" required") +
14474
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add holiday</button></div>" +
14475
+ "</form>" +
14476
+ "</div>";
14477
+
14478
+ // ---- postal-prefix → zone ----
14479
+ var zoneRows = zones.map(function (z) {
14480
+ return "<tr>" +
14481
+ "<td>" + _htmlEscape(z.country) + "</td>" +
14482
+ "<td><code class=\"order-id\">" + _htmlEscape(z.postal_prefix) + "</code></td>" +
14483
+ "<td><code class=\"order-id\">" + _htmlEscape(z.zone) + "</code></td>" +
14484
+ "</tr>";
14485
+ }).join("");
14486
+ var zoneTable = zones.length
14487
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Country</th><th scope=\"col\">Postal prefix</th><th scope=\"col\">Zone</th></tr></thead><tbody>" + zoneRows + "</tbody></table>") + "</div>"
14488
+ : "<p class=\"empty\">No postal zones yet.</p>";
14489
+ var zoneForm =
14490
+ "<div class=\"panel mt mw-40\">" +
14491
+ "<h3 class=\"subhead\">Add a postal-prefix zone</h3>" +
14492
+ "<p class=\"meta\">Map a destination postal-code prefix to a transit zone. Longest matching prefix wins. Re-add to change.</p>" +
14493
+ "<form method=\"post\" action=\"/admin/delivery-estimates/postal-zones\">" +
14494
+ _setupField("Country", "country", "", "text", "ISO 3166-1 alpha-2, e.g. US.", " maxlength=\"2\" class=\"input-code\" required") +
14495
+ _setupField("Postal prefix", "postal_prefix", "", "text", "e.g. 902 for US 90xxx.", " maxlength=\"16\" class=\"input-code\" required") +
14496
+ _setupField("Zone", "zone", "", "text", "Destination zone slug, e.g. us-west.", " maxlength=\"64\" class=\"input-code\" required") +
14497
+ "<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add postal zone</button></div>" +
14498
+ "</form>" +
14499
+ "</div>";
14500
+
14501
+ var body = "<section><h2>Delivery estimates</h2>" + created + archived + notice +
14502
+ "<p class=\"meta\">These four tables compose the storefront's “Get it by &lt;date&gt;” window. A customer sees a date only once an origin cutoff is configured for the origin their order ships from.</p>" +
14503
+ "<h3 class=\"subhead subhead--sp-lg\">Carrier transits</h3>" + transitTable + transitForm +
14504
+ "<h3 class=\"subhead subhead--sp-lg\">Origin cutoffs</h3>" + cutoffTable + cutoffForm +
14505
+ "<h3 class=\"subhead subhead--sp-lg\">Observed holidays</h3>" + holidayTable + holidayForm +
14506
+ "<h3 class=\"subhead subhead--sp-lg\">Postal-prefix zones</h3>" + zoneTable + zoneForm +
14507
+ "</section>";
14508
+ return _renderAdminShell(opts.shop_name, "Delivery estimates", body, "delivery-estimates", opts.nav_available);
14509
+ }
14510
+
14214
14511
  // Human one-liner for a rule's trigger / value JSON.
14215
14512
  function _fmtTrigger(t) {
14216
14513
  if (!t || !t.kind) return "—";
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.3.67",
2
+ "version": "0.3.69",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
6
6
  "fingerprinted": "css/admin.44eb97700c660798.css"
7
7
  },
8
8
  "css/main.css": {
9
- "integrity": "sha384-fzlpYGSv30HHY+ir9ym4VxwwgwzXQ9Sn153QMXLVKOPJ02eDNO/cI6Ww3rPluQ8R",
10
- "fingerprinted": "css/main.15dfd64f4536314d.css"
9
+ "integrity": "sha384-kjpDvd8TisWdb45G5S2FyHOo4plk5DiOMO0aTZWxKCJhAhBEo55yABnv5BbUpzS4",
10
+ "fingerprinted": "css/main.337a7be6244cd7c0.css"
11
11
  },
12
12
  "js/announcement.js": {
13
13
  "integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
@@ -172,9 +172,15 @@ var MAX_LIST_LIMIT = 500;
172
172
  var MAX_BASIS_POINTS = 10000;
173
173
  var MAX_MINOR_VALUE = 1e12;
174
174
  var MAX_BOGO_QTY = 1000;
175
+ var MAX_UNLOCK_CODE_LEN = 64;
175
176
 
176
177
  // Slug shape — alnum + dot + hyphen + underscore, alnum leading.
177
178
  var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
179
+ // Unlock-code shape — the string a shopper types on the cart page to
180
+ // unlock a code-gated rule. Same alnum + dot + hyphen + underscore family
181
+ // the couponStacking primitive accepts so the two surfaces agree on what a
182
+ // "code" is; refuses whitespace + control bytes.
183
+ var UNLOCK_CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
178
184
  // Sku shape — same family the catalog primitive uses (alnum + dot +
179
185
  // hyphen + underscore + slash + colon), tight enough to refuse
180
186
  // whitespace + control bytes.
@@ -214,6 +220,7 @@ var ALLOWED_PATCH_COLUMNS = Object.freeze([
214
220
  "max_redemptions_total",
215
221
  "max_redemptions_per_customer",
216
222
  "active",
223
+ "unlock_code",
217
224
  ]);
218
225
 
219
226
  var b = require("./vendor/blamejs");
@@ -227,6 +234,17 @@ function _slug(s) {
227
234
  return s;
228
235
  }
229
236
 
237
+ // Optional shopper-typed code that gates a rule. null/"" clears it (the
238
+ // rule reverts to a pure automatic). A non-empty value must match the
239
+ // code shape; stored as authored, matched case-insensitively at evaluate.
240
+ function _unlockCode(s) {
241
+ if (s == null || s === "") return null;
242
+ if (typeof s !== "string" || !UNLOCK_CODE_RE.test(s)) {
243
+ throw new TypeError("autoDiscount: unlock_code must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_UNLOCK_CODE_LEN + " chars)");
244
+ }
245
+ return s;
246
+ }
247
+
230
248
  function _title(s) {
231
249
  if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
232
250
  throw new TypeError("autoDiscount: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
@@ -493,6 +511,7 @@ function _hydrateRow(r) {
493
511
  active: r.active === 1 || r.active === true,
494
512
  archived_at: r.archived_at == null ? null : Number(r.archived_at),
495
513
  redemptions_used: Number(r.redemptions_used) || 0,
514
+ unlock_code: r.unlock_code == null ? null : String(r.unlock_code),
496
515
  created_at: Number(r.created_at),
497
516
  updated_at: Number(r.updated_at),
498
517
  };
@@ -617,6 +636,7 @@ function create(opts) {
617
636
  }
618
637
  var maxTotal = input.max_redemptions_total == null ? null : _nonNegInt(input.max_redemptions_total, "max_redemptions_total");
619
638
  var maxPerCustomer = input.max_redemptions_per_customer == null ? null : _nonNegInt(input.max_redemptions_per_customer, "max_redemptions_per_customer");
639
+ var unlockCode = _unlockCode(input.unlock_code);
620
640
 
621
641
  var existing = (await query(
622
642
  "SELECT slug FROM auto_discount_rules WHERE slug = ?1 LIMIT 1",
@@ -625,14 +645,28 @@ function create(opts) {
625
645
  if (existing) {
626
646
  throw new TypeError("autoDiscount.defineRule: slug " + JSON.stringify(slug) + " already exists -- use updateRule");
627
647
  }
648
+ // Reject a duplicate active code up front so the caller gets a clean
649
+ // TypeError rather than a raw UNIQUE-constraint bubble. The partial
650
+ // unique index is the authoritative guard; this is the friendly check.
651
+ if (unlockCode != null) {
652
+ var clash = (await query(
653
+ "SELECT slug FROM auto_discount_rules " +
654
+ "WHERE lower(unlock_code) = lower(?1) AND archived_at IS NULL LIMIT 1",
655
+ [unlockCode],
656
+ )).rows[0];
657
+ if (clash) {
658
+ throw new TypeError("autoDiscount.defineRule: unlock_code " + JSON.stringify(unlockCode) +
659
+ " already claimed by an active rule");
660
+ }
661
+ }
628
662
 
629
663
  var ts = _now();
630
664
  await query(
631
665
  "INSERT INTO auto_discount_rules (slug, title, trigger_json, value_json, applies_to_json, " +
632
666
  "customer_segment_in_json, exclusions_json, priority, starts_at, expires_at, " +
633
667
  "max_redemptions_total, max_redemptions_per_customer, active, archived_at, " +
634
- "redemptions_used, created_at, updated_at) " +
635
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1, NULL, 0, ?13, ?13)",
668
+ "redemptions_used, unlock_code, created_at, updated_at) " +
669
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1, NULL, 0, ?13, ?14, ?14)",
636
670
  [
637
671
  slug,
638
672
  title,
@@ -646,6 +680,7 @@ function create(opts) {
646
680
  expiresAt,
647
681
  maxTotal,
648
682
  maxPerCustomer,
683
+ unlockCode,
649
684
  ts,
650
685
  ],
651
686
  );
@@ -746,6 +781,24 @@ function create(opts) {
746
781
  } else if (col === "max_redemptions_per_customer") {
747
782
  sets.push("max_redemptions_per_customer = ?" + idx);
748
783
  params.push(patch[col] == null ? null : _nonNegInt(patch[col], "max_redemptions_per_customer"));
784
+ } else if (col === "unlock_code") {
785
+ var nextCode = _unlockCode(patch[col]);
786
+ if (nextCode != null) {
787
+ // Don't let an update steal another active rule's code. A
788
+ // re-assert of THIS rule's own code is fine (the clash row is
789
+ // itself).
790
+ var codeClash = (await query(
791
+ "SELECT slug FROM auto_discount_rules " +
792
+ "WHERE lower(unlock_code) = lower(?1) AND archived_at IS NULL AND slug != ?2 LIMIT 1",
793
+ [nextCode, slug],
794
+ )).rows[0];
795
+ if (codeClash) {
796
+ throw new TypeError("autoDiscount.updateRule: unlock_code " + JSON.stringify(nextCode) +
797
+ " already claimed by an active rule");
798
+ }
799
+ }
800
+ sets.push("unlock_code = ?" + idx);
801
+ params.push(nextCode);
749
802
  } else /* active */ {
750
803
  sets.push("active = ?" + idx);
751
804
  params.push(_bool(patch[col], "active") ? 1 : 0);
@@ -789,6 +842,30 @@ function create(opts) {
789
842
  return await getRule(slug);
790
843
  }
791
844
 
845
+ // ---- ruleForCode ---------------------------------------------------
846
+
847
+ // Resolve a shopper-typed code to its active, code-gated rule (the row
848
+ // whose unlock_code matches case-insensitively, not archived). Returns
849
+ // the hydrated rule or null when no active rule claims the code. The
850
+ // cart-page coupon entry calls this to validate a typed code before
851
+ // persisting it — a non-empty result means "this is a real code"; null
852
+ // is the uniform "unknown code" answer. A malformed code (whitespace /
853
+ // control bytes / over-length) returns null rather than throwing, so the
854
+ // cart route gives one message for "doesn't match a rule" regardless of
855
+ // whether the input was garbage or simply unknown (no code-shape oracle).
856
+ async function ruleForCode(code) {
857
+ if (typeof code !== "string" || !UNLOCK_CODE_RE.test(code)) return null;
858
+ var r = (await query(
859
+ "SELECT * FROM auto_discount_rules " +
860
+ "WHERE lower(unlock_code) = lower(?1) AND active = 1 AND archived_at IS NULL " +
861
+ " AND (starts_at IS NULL OR starts_at <= ?2) " +
862
+ " AND (expires_at IS NULL OR expires_at > ?2) " +
863
+ "LIMIT 1",
864
+ [code, _now()],
865
+ )).rows[0];
866
+ return _hydrateRow(r);
867
+ }
868
+
792
869
  // ---- evaluate ------------------------------------------------------
793
870
 
794
871
  async function evaluate(input) {
@@ -800,6 +877,21 @@ function create(opts) {
800
877
  if (customerId != null && (typeof customerId !== "string" || !customerId.length)) {
801
878
  throw new TypeError("autoDiscount.evaluate: customer_id must be a non-empty string when provided");
802
879
  }
880
+ // Shopper-presented codes that unlock code-gated rules. A defensive
881
+ // request-shape reader: any non-string / malformed entry is dropped
882
+ // (never throws on the buy path), each kept entry is lower-cased into a
883
+ // lookup set so the code gate is a case-insensitive membership test. A
884
+ // pure-automatic rule (unlock_code IS NULL) ignores this set entirely.
885
+ var presentedCodes = Object.create(null);
886
+ if (input.codes != null) {
887
+ if (!Array.isArray(input.codes)) {
888
+ throw new TypeError("autoDiscount.evaluate: codes must be an array when provided");
889
+ }
890
+ for (var pc = 0; pc < input.codes.length; pc += 1) {
891
+ var raw = input.codes[pc];
892
+ if (typeof raw === "string" && raw.length) presentedCodes[raw.toLowerCase()] = true;
893
+ }
894
+ }
803
895
 
804
896
  var ts = _now();
805
897
  var rows = (await query(
@@ -839,6 +931,15 @@ function create(opts) {
839
931
  for (var ri = 0; ri < rows.length; ri += 1) {
840
932
  var rule = _hydrateRow(rows[ri]);
841
933
 
934
+ // 0. Code gate — a rule with an unlock_code is dormant until the
935
+ // shopper presents that code (case-insensitive). A pure-automatic
936
+ // rule (unlock_code IS NULL) skips this gate and fires on shape alone,
937
+ // so the legacy code-less behaviour is unchanged.
938
+ if (rule.unlock_code != null && !presentedCodes[rule.unlock_code.toLowerCase()]) {
939
+ skipped.push({ rule_slug: rule.slug, reason: "unlock_code_not_presented" });
940
+ continue;
941
+ }
942
+
842
943
  // 1. Total-redemption cap
843
944
  if (rule.max_redemptions_total != null && rule.redemptions_used >= rule.max_redemptions_total) {
844
945
  skipped.push({ rule_slug: rule.slug, reason: "max_redemptions_total_reached" });
@@ -1118,6 +1219,7 @@ function create(opts) {
1118
1219
  listRules: listRules,
1119
1220
  updateRule: updateRule,
1120
1221
  archiveRule: archiveRule,
1222
+ ruleForCode: ruleForCode,
1121
1223
  evaluate: evaluate,
1122
1224
  recordApplication: recordApplication,
1123
1225
  metricsForRule: metricsForRule,