@blamejs/blamejs-shop 0.3.75 → 0.4.0

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
@@ -6,8 +6,14 @@ Pre-1.0 the surface is intentionally evolving — every release may
6
6
  change something operators depend on. Read each entry before
7
7
  upgrading across more than a few patches at a time.
8
8
 
9
+ ## v0.4.x
10
+
11
+ - v0.4.0 (2026-06-05) — **Inventory is enforced at the point of sale, completing the transactions, checkout, and analytics surface.** Stock levels were previously display-only: the product page showed honest availability, but nothing reserved inventory at checkout or debited it on a sale, so concurrent buyers could oversell a SKU and stock counts never moved. Checkout now places an atomic per-SKU hold before any charge — a sold-out line re-renders the form with a friendly message instead of charging — and the order lifecycle settles the hold: payment converts it to a real stock decrement (idempotent across webhook re-deliveries), cancellation releases it, and refunds deliberately leave restocking to the operator's judgment. Untracked SKUs remain unlimited, and pre-order campaigns keep their own reservation flow. The README previously described an oversell-prevention mechanism that was not actually wired; it now describes the real one. This minor release caps the transactions, checkout, and analytics arc: server-validated addresses and digital-cart checkout, discount codes with console authoring, gift cards, loyalty, store pickup, a dark-themed Stripe payment surface with express wallets and verified 3-D Secure, shipment tracking timelines, consent-gated funnel analytics with an admin dashboard, abandoned-cart visibility with honest recovery codes, operator error logs, and confirmation resends. Known, documented deferrals: customer receipt downloads (the confirmation page and signed email serve as the receipt), partial refunds from the browser console (the JSON API supports them), and a real-time new-order notification (the dashboard and outbound webhooks cover arrival today). **Added:** *Point-of-sale inventory enforcement* — Checkout reserves stock with an atomic conditional hold per shippable line before charging — insufficient stock re-renders the checkout with a clear message and charges nothing, and two concurrent buyers of the last unit resolve to exactly one sale. Payment converts holds into stock decrements, idempotently across webhook re-deliveries; cancelling a pending order releases its holds precisely, even when other shoppers hold the same SKU. Refunds do not auto-restock — returned goods re-enter stock through the operator's existing restock action, by judgment. SKUs without an inventory row remain available without limit, and pre-order campaigns are unaffected. The inventory primitive gains the hold and decrement operations its documentation previously described, and the README now reflects the actual oversell-prevention mechanism. **Changed:** *The 0.3 series rolls up* — This release follows nineteen 0.3.x patches that built out the commerce surface: server-side address validation with accessible per-field errors, digital-cart checkout without an address, delivery-date estimates, discount unlock codes with cart redemption and console authoring, full shipment-tracking timelines, consent-gated funnel analytics and an Analytics console screen, abandoned-cart visibility with single-use recovery codes, an operator-readable error log with a JSON API, order-confirmation resends, segment CSV exports, payment-processor TLS fixes verified by live payment, a dark-themed payment surface with express wallets, and a vendored-framework refresh. See the changelog for the full sequence.
12
+
9
13
  ## v0.3.x
10
14
 
15
+ - v0.3.76 (2026-06-05) — **Abandoned-cart visibility in the admin console, with honest recovery.** The admin console gains a Carts screen showing abandoned carts: active carts with items whose last activity is older than a tunable window (twenty-four hours by default), listed freshest-first with line counts, subtotals, guest or signed-in attribution, and a value-at-risk summary. Recovery is built around what the privacy stance actually permits: buyer email addresses are stored only as one-way hashes, so a recovery email is impossible by design and the screen says so plainly. Instead, a per-cart action mints a single-use, code-gated percent-off discount — composing the existing unlock-code rules — and shows the code once for the operator to share through whatever channel they have. Fresh, empty, and converted carts are excluded; queries are window- and limit-bounded; the screen and its JSON shape are bearer-gated like every other console surface. **Added:** *Abandoned-carts screen with single-use recovery codes* — GET /admin/carts lists abandoned carts — active, containing items, idle past the window (tunable via the hours parameter, clamped to sane bounds) — with per-cart line counts, subtotal, last activity, and the customer link for signed-in carts, plus a count and value-at-risk summary. The per-cart Issue-code action creates a single-use unlock-code discount rule at the operator's chosen percentage and reveals the code once; the action is audited and appears only when the discounts primitive is wired. Session identifiers never appear in the JSON shape, and the screen states why no email recovery exists: addresses are one-way hashed.
16
+
11
17
  - v0.3.75 (2026-06-05) — **The analytics funnel records real shopper events — only with analytics consent.** The admin Analytics screen's browse-to-buy funnel, top search terms, and most-viewed products read an event stream that nothing previously wrote, so those tables stayed empty. The storefront now records five events — product view, search, add to cart, checkout started, order completed — strictly gated on the visitor's analytics consent: no consent decision means no recording, Do Not Track and Global Privacy Control suppress recording even after an accept-all, session identifiers are stored only as keyed hashes, and no name or email ever rides an event. Recording is fire-and-forget and can never affect a request. Edge-cached anonymous pages are deliberately not recorded — the cache stays shared and fast — so the funnel reflects consented, container-served traffic, and the dashboard's own copy now says exactly that. **Added:** *Consent-gated shopper event recording* — Product views, searches, cart adds, checkout starts, and completed orders record into the analytics event stream when — and only when — the session's consent grants the analytics category. The gate is the consent primitive's own server-side read: default-deny without a decision, policy-version-aware, and honoring Do Not Track and Sec-GPC over a stored acceptance. Events carry a hashed session key and bounded payloads, never personal data. The Analytics screen's funnel now states that its counts cover consented, container-served traffic; anonymous edge-cached page views are not tracked by design.
12
18
 
13
19
  - v0.3.74 (2026-06-05) — **The payment form matches the shop's dark theme.** The Stripe Payment Element and the express wallet buttons on the pay page previously rendered in Stripe's default light style — a white card floating on the shop's near-black page. The elements now use Stripe's night appearance recolored with the shop's own design tokens: violet accent on focus and selected tabs, the shop's charcoal input surfaces, soft ink text, and matching corner radii. The Apple Pay, Google Pay, and PayPal express buttons switch to their white and outline variants, which keep each brand's contrast rules legible on the dark card. No payment behavior changes; this is purely the appearance configuration on the existing elements. **Changed:** *Dark-themed payment elements* — The pay page's Stripe elements adopt the night appearance with the shop's design tokens — violet primary, charcoal surfaces, soft ink, ten-pixel radii, violet focus rings — and the express wallet buttons render in their white and outline variants sized to the page's controls. Inside Stripe's cross-origin frame the typeface falls back to the system stack; everything else mirrors the storefront's stylesheet tokens.
package/README.md CHANGED
@@ -43,7 +43,7 @@ node test/smoke.js
43
43
 
44
44
  - **Cloudflare deploy topology** — `Dockerfile` (multi-stage Node LTS, non-root, tini PID 1, vendor refresh + smoke run as build stages), `wrangler.toml` (Container + Worker + D1 + R2 + KV + Durable Objects), `worker/index.js` (edge router: health, asset pass-through, Stripe webhook signature pre-verification, D1 service-binding bridge, container forward, cold-start retry).
45
45
  - **`b.externalDb` adapter for Cloudflare D1** (`lib/externaldb-d1.js`) — service-binding + REST-API modes, normalized result envelope, AbortController timeouts, jittered retry on transient errors.
46
- - **`InventoryLock` Durable Object** — per-SKU serialization point so concurrent checkouts across container replicas can't oversell.
46
+ - **Oversell-safe stock at checkout** — checkout reserves stock with an atomic conditional `UPDATE` (`held = held + qty WHERE on_hand - held >= qty`) before charging, converts the hold to a shelf debit when the order is paid, and releases it if the pending order is cancelled or expires; pre-order / backorder / digital lines are exempt. The conditional write is the serialization point, so two concurrent checkouts for the last unit can't both succeed — the loser gets a friendly out-of-stock re-prompt. An `InventoryLock` Durable Object ships as an optional per-SKU serialization aid for multi-replica deployments.
47
47
  - **`docs/deploy-cloudflare.md`** — operator deploy recipe end-to-end.
48
48
  - **Database backup & recovery** — D1 Time Travel gives 30 days of
49
49
  always-on point-in-time recovery; `npm run d1-export` (`scripts/d1-export.js`)
@@ -97,7 +97,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
97
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). |
98
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. |
99
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. |
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, with a rate-bounded resend of the order confirmation to an operator-supplied address (the buyer's email is stored only as a hash, so the operator types the recipient), 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); customer segments export their members as a streamed CSV — id, display name, join date, order count, deliberately no email column; **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. **Analytics** (`/admin/analytics`) is the pre-purchase view the sales report can't see — browse-to-buy funnel with conversion rate, top search terms, most-viewed products, units-ranked top SKUs, and a revenue-by-day sparkline — cross-linked with the Reports screen, read-only, every aggregate window- and limit-bounded. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, 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, with a rate-bounded resend of the order confirmation to an operator-supplied address (the buyer's email is stored only as a hash, so the operator types the recipient), 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); customer segments export their members as a streamed CSV — id, display name, join date, order count, deliberately no email column; **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. **Carts** (`/admin/carts`) lists abandoned carts — active, has items, idle past a tunable window (24h default) — with line counts, value at risk, and guest/signed-in attribution; a per-cart action mints a single-use, code-gated discount the operator shares through their own channel (recovery email is impossible by design: buyer addresses are stored only as hashes, and the screen says so). **Analytics** (`/admin/analytics`) is the pre-purchase view the sales report can't see — browse-to-buy funnel with conversion rate, top search terms, most-viewed products, units-ranked top SKUs, and a revenue-by-day sparkline — cross-linked with the Reports screen, read-only, every aggregate window- and limit-bounded. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, Carts, 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. |
101
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. |
102
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. |
103
103
 
package/lib/admin.js CHANGED
@@ -36,10 +36,19 @@ var quantityDiscountsModule = require("./quantity-discounts");
36
36
  var loyaltyEarnRulesModule = require("./loyalty-earn-rules");
37
37
  var loyaltyRedemptionModule = require("./loyalty-redemption");
38
38
  var trustBadgesModule = require("./trust-badges");
39
+ var cartModule = require("./cart"); // ABANDONED_* window/limit constants for the /admin/carts console
39
40
  var textGuard = require("./text-guard");
40
41
  var { AsyncLocalStorage } = require("node:async_hooks"); // allow:non-shop-require — Node-core per-request context (no npm dep); the framework itself composes it in db-role-context / log. No b.* request-context primitive exists to wrap it.
41
42
 
42
43
  var b = require("./vendor/blamejs");
44
+ // Framework duration constants (C.TIME.hours(n) etc.). The index entry
45
+ // point exposes `framework` before the require cascade, so resolving this
46
+ // at module-eval is safe — same pattern lib/cart.js uses.
47
+ var C = b.constants;
48
+
49
+ // One hour in ms, named once so the hours<->ms conversions on the
50
+ // abandoned-cart window read in named units rather than a bare literal.
51
+ var MS_PER_HOUR = C.TIME.hours(1);
43
52
 
44
53
  var AUDIT_NAMESPACE = "shop_admin";
45
54
 
@@ -523,6 +532,7 @@ function mount(router, deps) {
523
532
  }
524
533
  var catalog = deps.catalog;
525
534
  var order = deps.order;
535
+ var cart = deps.cart || null; // abandoned-cart visibility console (/admin/carts) disabled when absent
526
536
  var payment = deps.payment || null; // refund endpoints disabled when absent
527
537
  var mailer = deps.mailer || null; // transactional email factory (lib/email.js) — resend-confirmation disabled when absent
528
538
  var _checkout = deps.checkout || null; // reserved — future webhook handler wiring
@@ -574,7 +584,7 @@ function mount(router, deps) {
574
584
  // `reports` is always present in the nav (read-only sales summary needs no
575
585
  // extra dep); its route mounts unconditionally and renders an unconfigured
576
586
  // notice when the salesReports primitive isn't wired.
577
- var navAvailable = { analytics: !!deps.analytics, 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 };
587
+ var navAvailable = { analytics: !!deps.analytics, 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, carts: !!cart };
578
588
 
579
589
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
580
590
 
@@ -9431,6 +9441,154 @@ function mount(router, deps) {
9431
9441
  return patch;
9432
9442
  }
9433
9443
 
9444
+ // ---- abandoned carts (visibility + honest recovery) -----------------
9445
+ //
9446
+ // Operators have zero abandoned-cart visibility without this screen — no
9447
+ // list, no value-at-risk total, no recovery surface. This reads the live
9448
+ // `carts` table directly (active cart + has lines + untouched for >N
9449
+ // hours), so it works WITHOUT the cart-abandonment scanner cron having
9450
+ // ever run. The window is operator-tunable via `?hours=` (default 24).
9451
+ //
9452
+ // Recovery, honest version: a buyer's email is one-way hashed at every
9453
+ // layer (the `carts` row carries only a sealed-cookie `session_id`, the
9454
+ // customer record stores only an `email_hash`), so the framework cannot
9455
+ // send a recovery EMAIL to ANY cart — guest or signed-in — on its own.
9456
+ // The screen states this plainly. What it CAN do is compose the existing
9457
+ // `autoDiscount` unlock-code surface: the "issue recovery code" action
9458
+ // mints a single-use, code-gated percent-off rule (`max_redemptions_total
9459
+ // = 1`) and shows the code ONCE for the operator to share through their
9460
+ // own channel (the order-confirmation reply, an SMS, a printed receipt).
9461
+ // No email is claimed where none is possible. When `autoDiscount` isn't
9462
+ // wired the action is absent and the screen documents it as the next step.
9463
+
9464
+ // The recovery-code action composes deps.autoDiscount. Read it through a
9465
+ // helper rather than the `autoDiscount` local (which is declared further
9466
+ // down, inside the discounts block) so this block doesn't depend on
9467
+ // var-hoist ordering.
9468
+ function autoDiscountForCarts() { return deps.autoDiscount || null; }
9469
+
9470
+ // Defensive request-shape readers for the abandoned-cart window. The
9471
+ // console accepts `?hours=` (whole hours) + `?limit=`; the cart primitive
9472
+ // clamps the ms window + limit again, but converting here keeps the
9473
+ // operator-facing unit (hours) at the route boundary. Garbage falls back
9474
+ // to the default rather than 500-ing the screen.
9475
+ function _cartHoursToMs(hoursRaw) {
9476
+ if (hoursRaw == null || hoursRaw === "") return cartModule.ABANDONED_DEFAULT_IDLE_MS;
9477
+ var h = Number(hoursRaw);
9478
+ if (!isFinite(h) || h <= 0) return cartModule.ABANDONED_DEFAULT_IDLE_MS;
9479
+ return Math.floor(h * MS_PER_HOUR);
9480
+ }
9481
+ function _cartListLimit(limitRaw) {
9482
+ if (limitRaw == null || limitRaw === "") return undefined;
9483
+ var n = parseInt(limitRaw, 10);
9484
+ if (!isFinite(n) || n <= 0) return undefined;
9485
+ return n;
9486
+ }
9487
+
9488
+ // Mint a single-use, code-gated percent-off rule for one abandoned cart.
9489
+ // Returns `{ rule_slug, unlock_code, percent_off }` on success, or null
9490
+ // when the cart id doesn't resolve to an abandonable cart (so the route
9491
+ // 404s rather than minting a code for nothing). Throws TypeError on bad
9492
+ // input (a non-1..99 percent, a bad cart id) → clean 400 at the route.
9493
+ async function _issueCartRecoveryCode(cartId, body) {
9494
+ var ad = autoDiscountForCarts();
9495
+ if (!ad) return null; // defensive — the route only mounts when wired
9496
+ // Resolve the cart so we don't mint a code for a non-existent / already
9497
+ // converted cart. cart.get validates the id shape (throws TypeError on
9498
+ // garbage → 400).
9499
+ var row = await cart.get(cartId);
9500
+ if (!row || row.status !== "active") return null;
9501
+ // Percent off — operator-chosen, 1..99. Default 10% when blank.
9502
+ var pctRaw = body.percent_off;
9503
+ var pct = (pctRaw == null || pctRaw === "") ? 10 : Number(pctRaw);
9504
+ if (!isFinite(pct) || !Number.isInteger(pct) || pct < 1 || pct > 99) {
9505
+ throw new TypeError("percent_off must be a whole number between 1 and 99");
9506
+ }
9507
+ // Mint a short, human-shareable code. The unlock_code grammar is
9508
+ // alnum + . _ - ; an uppercased hex slice of random bytes keeps it
9509
+ // typeable. generateToken(n) returns n random bytes as 2n hex chars.
9510
+ var code = "SAVE" + b.crypto.generateToken(4).slice(0, 6).toUpperCase();
9511
+ var slug = "cart-recovery-" + String(cartId).slice(0, 8).toLowerCase() + "-" + Date.now().toString(36);
9512
+ // Single-use, code-gated, no automatic trigger (cart_total_min: 0 fires
9513
+ // on any cart once the code is presented). The lib rejects a code clash
9514
+ // with another active rule (→ TypeError → 400), so a duplicate is safe.
9515
+ await ad.defineRule({
9516
+ slug: slug,
9517
+ title: "Cart recovery — " + pct + "% off",
9518
+ trigger: { kind: "cart_total_min", min_minor: 0 },
9519
+ value: { kind: "percent_off", basis_points: pct * 100 },
9520
+ unlock_code: code,
9521
+ max_redemptions_total: 1,
9522
+ });
9523
+ return { rule_slug: slug, unlock_code: code, percent_off: pct };
9524
+ }
9525
+
9526
+ if (cart) {
9527
+ var cartRecoveryCodeEnabled = !!autoDiscountForCarts();
9528
+
9529
+ router.get("/admin/carts", _pageOrApi(true,
9530
+ R(async function (req, res) {
9531
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
9532
+ var hours = url && url.searchParams.get("hours");
9533
+ var idleMs = _cartHoursToMs(hours);
9534
+ var limit = _cartListLimit(url && url.searchParams.get("limit"));
9535
+ var carts = await cart.listAbandoned({ idle_threshold_ms: idleMs, limit: limit });
9536
+ var summary = await cart.abandonedSummary({ idle_threshold_ms: idleMs });
9537
+ _json(res, 200, { carts: carts, summary: summary });
9538
+ }),
9539
+ async function (req, res) {
9540
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
9541
+ var hoursRaw = url && url.searchParams.get("hours");
9542
+ var idleMs = _cartHoursToMs(hoursRaw);
9543
+ var hours = Math.round(idleMs / MS_PER_HOUR);
9544
+ var carts = await cart.listAbandoned({ idle_threshold_ms: idleMs, limit: cartModule.ABANDONED_MAX_LIMIT });
9545
+ var summary = await cart.abandonedSummary({ idle_threshold_ms: idleMs });
9546
+ _sendHtml(res, 200, renderAdminCarts({
9547
+ shop_name: deps.shop_name, nav_available: navAvailable,
9548
+ carts: carts, summary: summary, hours: hours,
9549
+ recovery_enabled: cartRecoveryCodeEnabled,
9550
+ issued: url && url.searchParams.get("issued"),
9551
+ issued_code: url && url.searchParams.get("code"),
9552
+ issued_cart: url && url.searchParams.get("cart"),
9553
+ notice: (url && url.searchParams.get("err")) ? "That action couldn't be completed." : null,
9554
+ }));
9555
+ },
9556
+ ));
9557
+
9558
+ // Issue a single-use recovery code for one cart. Composes the existing
9559
+ // autoDiscount unlock-code surface — NO new primitive. Only mounted
9560
+ // when autoDiscount is wired; absent it, the screen never renders the
9561
+ // form (so this route stays an honest 404 for the unwired deploy).
9562
+ if (cartRecoveryCodeEnabled) {
9563
+ router.post("/admin/carts/:id/recovery-code", _pageOrApi(false,
9564
+ W("cart_recovery_code.issue", async function (req, res) {
9565
+ var out;
9566
+ try { out = await _issueCartRecoveryCode(req.params.id, req.body || {}); }
9567
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
9568
+ if (!out) return _problem(res, 404, "cart-not-found");
9569
+ _json(res, 201, out);
9570
+ return { id: out.rule_slug };
9571
+ }),
9572
+ async function (req, res) {
9573
+ var out;
9574
+ try { out = await _issueCartRecoveryCode(req.params.id, req.body || {}); }
9575
+ catch (e) {
9576
+ if (!(e instanceof TypeError)) throw e;
9577
+ return _redirect(res, "/admin/carts?err=1");
9578
+ }
9579
+ if (!out) return _redirect(res, "/admin/carts?err=1");
9580
+ b.audit.safeEmit({
9581
+ action: AUDIT_NAMESPACE + ".cart_recovery_code.issue",
9582
+ outcome: "success",
9583
+ metadata: { rule_slug: out.rule_slug },
9584
+ });
9585
+ _redirect(res, "/admin/carts?issued=1&code=" + encodeURIComponent(out.unlock_code) +
9586
+ "&cart=" + encodeURIComponent(String(req.params.id).slice(0, 8)));
9587
+ },
9588
+ ));
9589
+ }
9590
+ }
9591
+
9434
9592
  // ---- automatic discounts + stacking policies ------------------------
9435
9593
  //
9436
9594
  // Two related concerns on one console screen. `autoDiscount` rules are
@@ -11334,6 +11492,7 @@ var ADMIN_NAV_ITEMS = [
11334
11492
  { key: "products", href: "/admin/products", label: "Products" },
11335
11493
  { key: "inventory", href: "/admin/inventory", label: "Inventory" },
11336
11494
  { key: "orders", href: "/admin/orders", label: "Orders" },
11495
+ { key: "carts", href: "/admin/carts", label: "Abandoned carts", requires: "carts" },
11337
11496
  { key: "reports", href: "/admin/reports", label: "Reports" },
11338
11497
  { key: "analytics", href: "/admin/analytics", label: "Analytics", requires: "analytics" },
11339
11498
  { key: "audit", href: "/admin/audit", label: "Audit", requires: "auditLog" },
@@ -11655,6 +11814,138 @@ function renderAdminOrders(opts) {
11655
11814
  return _renderAdminShell(opts.shop_name, "Orders", body, "orders", opts.nav_available);
11656
11815
  }
11657
11816
 
11817
+ // Format an idle duration (ms) as a coarse human string for the abandoned-
11818
+ // cart "last activity" column: "3h", "2d", "<1h". Coarse on purpose — the
11819
+ // operator wants the order of magnitude, not a precise stopwatch.
11820
+ function _fmtIdle(ms) {
11821
+ if (typeof ms !== "number" || !isFinite(ms) || ms < 0) return "—";
11822
+ var hours = ms / MS_PER_HOUR;
11823
+ if (hours < 1) return "<1h";
11824
+ if (hours < 48) return Math.floor(hours) + "h";
11825
+ return Math.floor(hours / 24) + "d";
11826
+ }
11827
+
11828
+ // Abandoned-cart visibility. Lists active carts with at least one line that
11829
+ // haven't been touched for the selected window (default 24h, tunable via the
11830
+ // `?hours=` filter), freshest-abandonment first, with a value-at-risk summary.
11831
+ //
11832
+ // Recovery is honest about the framework's hash-only stance: buyer emails are
11833
+ // one-way hashed everywhere (the cart row carries only a sealed-cookie
11834
+ // session_id; the customer record stores only an email_hash), so NO recovery
11835
+ // EMAIL is possible — for guest OR signed-in carts. The screen says so. When
11836
+ // `autoDiscount` is wired it offers the one recovery that IS possible: minting
11837
+ // a single-use code-gated discount whose code the operator shares through
11838
+ // their own channel.
11839
+ //
11840
+ // XSS note: customer_id + cart id are framework-issued UUIDs, but every value
11841
+ // still flows through _htmlEscape (escape-by-default) — the discipline holds
11842
+ // regardless of the field's provenance.
11843
+ function renderAdminCarts(opts) {
11844
+ opts = opts || {};
11845
+ var carts = opts.carts || [];
11846
+ var summary = opts.summary || { cart_count: 0, by_currency: [] };
11847
+ var hours = opts.hours || 24;
11848
+ var recoveryEnabled = !!opts.recovery_enabled;
11849
+
11850
+ var notice = opts.notice ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.notice) + "</div>" : "";
11851
+
11852
+ // PRG success banner after issuing a recovery code — shows the code ONCE
11853
+ // (the operator copies it to share through their own channel; it isn't
11854
+ // re-displayed on reload).
11855
+ var issued = "";
11856
+ if (opts.issued && opts.issued_code) {
11857
+ issued = "<div class=\"banner banner--ok\">" +
11858
+ "Recovery code <code class=\"order-id\">" + _htmlEscape(opts.issued_code) + "</code> issued" +
11859
+ (opts.issued_cart ? " for cart " + _htmlEscape(opts.issued_cart) : "") + ". " +
11860
+ "Share it through your own channel — it's single-use and won't be shown again." +
11861
+ "</div>";
11862
+ }
11863
+
11864
+ // Value-at-risk summary line(s) — one per currency present in the window.
11865
+ var valueLine;
11866
+ if (summary.cart_count === 0) {
11867
+ valueLine = "<span class=\"meta\">No carts have been idle longer than " + _htmlEscape(String(hours)) + "h.</span>";
11868
+ } else {
11869
+ var perCur = (summary.by_currency || []).map(function (c) {
11870
+ return _htmlEscape(pricing.format(c.value_minor, c.currency)) +
11871
+ " across " + _htmlEscape(String(c.cart_count)) + " cart" + (c.cart_count === 1 ? "" : "s");
11872
+ }).join("; ");
11873
+ valueLine = "<strong>" + _htmlEscape(String(summary.cart_count)) + "</strong> abandoned cart" +
11874
+ (summary.cart_count === 1 ? "" : "s") + " — " + perCur + " at risk.";
11875
+ }
11876
+
11877
+ // Window filter chips — common windows plus the current one highlighted.
11878
+ var WINDOWS = [1, 6, 24, 72, 168];
11879
+ var chips = "<div class=\"order-filters\">" +
11880
+ WINDOWS.map(function (h) {
11881
+ var label = h < 24 ? h + "h" : (h / 24) + "d";
11882
+ return "<a class=\"chip" + (hours === h ? " chip--on" : "") +
11883
+ "\" href=\"/admin/carts?hours=" + h + "\">&gt; " + _htmlEscape(label) + "</a>";
11884
+ }).join("") +
11885
+ "</div>";
11886
+
11887
+ var rows = carts.map(function (c) {
11888
+ var isGuest = !c.customer_id;
11889
+ var who = isGuest
11890
+ ? "<span class=\"chip\">Guest</span>"
11891
+ : "<a href=\"/admin/customers/" + _htmlEscape(encodeURIComponent(c.customer_id)) +
11892
+ "\"><span class=\"chip\">Signed in</span></a>";
11893
+ // The recovery-code form (only when autoDiscount is wired). A guest cart
11894
+ // gets the same code-mint affordance — the code is shared out-of-band, so
11895
+ // it doesn't depend on a deliverable address either way.
11896
+ var action = recoveryEnabled
11897
+ ? "<form method=\"post\" action=\"/admin/carts/" + _htmlEscape(encodeURIComponent(c.id)) +
11898
+ "/recovery-code\" class=\"form-inline\">" +
11899
+ "<input type=\"number\" name=\"percent_off\" min=\"1\" max=\"99\" step=\"1\" value=\"10\" " +
11900
+ "class=\"input-code\" aria-label=\"Percent off\"> " +
11901
+ "<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Issue code</button>" +
11902
+ "</form>"
11903
+ : "<span class=\"meta\">—</span>";
11904
+ return "<tr>" +
11905
+ "<td><code class=\"order-id\">" + _htmlEscape(String(c.id).slice(0, 8)) + "</code></td>" +
11906
+ "<td class=\"num\">" + _htmlEscape(String(c.line_count)) + "</td>" +
11907
+ "<td class=\"num\">" + _htmlEscape(pricing.format(c.subtotal_minor, c.currency)) + "</td>" +
11908
+ "<td>" + _htmlEscape(_fmtIdle(c.idle_ms)) + " ago <span class=\"meta\">(" + _htmlEscape(_fmtDate(c.updated_at)) + ")</span></td>" +
11909
+ "<td>" + who + "</td>" +
11910
+ "<td>" + action + "</td>" +
11911
+ "</tr>";
11912
+ }).join("");
11913
+
11914
+ var table = carts.length
11915
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr>" +
11916
+ "<th scope=\"col\">Cart</th>" +
11917
+ "<th scope=\"col\" class=\"num\">Items</th>" +
11918
+ "<th scope=\"col\" class=\"num\">Subtotal</th>" +
11919
+ "<th scope=\"col\">Last activity</th>" +
11920
+ "<th scope=\"col\">Shopper</th>" +
11921
+ "<th scope=\"col\">" + (recoveryEnabled ? "Recovery" : "<span class=\"sr-only\">Recovery</span>") + "</th>" +
11922
+ "</tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
11923
+ : "<p class=\"empty\">No carts have been abandoned longer than " + _htmlEscape(String(hours)) + "h.</p>";
11924
+
11925
+ // The honest-recovery explainer. Always shown — it's the load-bearing
11926
+ // caveat that distinguishes this screen from a store that emails idle
11927
+ // shoppers (which this framework structurally cannot do).
11928
+ var recoveryNote = recoveryEnabled
11929
+ ? "<div class=\"panel mt\"><h3 class=\"subhead\">Recovering a cart</h3>" +
11930
+ "<p class=\"meta\">Buyer emails are one-way hashed everywhere in this shop, so the " +
11931
+ "framework can't send an abandoned-cart email — for guest or signed-in carts. " +
11932
+ "Instead, issue a single-use discount code for a cart and share it through your own " +
11933
+ "channel (an order-confirmation reply, SMS, or printed receipt). Each code works once " +
11934
+ "and appears on your <a href=\"/admin/discounts\">discounts</a> list.</p></div>"
11935
+ : "<div class=\"panel mt\"><h3 class=\"subhead\">Recovering a cart</h3>" +
11936
+ "<p class=\"meta\">Buyer emails are one-way hashed everywhere in this shop, so the " +
11937
+ "framework can't send an abandoned-cart email. The recovery path here is to issue a " +
11938
+ "single-use discount code per cart and share it through your own channel — wire the " +
11939
+ "discounts feature (the <code>autoDiscount</code> dependency) to enable it.</p></div>";
11940
+
11941
+ var body = "<section><h2>Abandoned carts</h2>" + notice + issued +
11942
+ "<p class=\"meta\">Active carts with items that haven't been touched in a while. " +
11943
+ "These are reads of live carts — no scanner or cron required.</p>" +
11944
+ "<p class=\"meta\">" + valueLine + "</p>" +
11945
+ chips + table + recoveryNote + "</section>";
11946
+ return _renderAdminShell(opts.shop_name, "Abandoned carts", body, "carts", opts.nav_available);
11947
+ }
11948
+
11658
11949
  // The outcomes an operator can filter the audit log by — drives the chips.
11659
11950
  var AUDIT_OUTCOME_FILTERS = ["success", "failure", "denied"];
11660
11951
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.3.75",
2
+ "version": "0.4.0",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
package/lib/cart.js CHANGED
@@ -42,6 +42,18 @@ var CURRENCY_RE = /^[A-Z]{3}$/;
42
42
  var DISCOUNT_CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
43
43
  var MAX_CODES_PER_CART = 16;
44
44
 
45
+ // Abandoned-cart visibility defaults. A cart counts as "abandoned" for the
46
+ // operator dashboard when it is still `active`, carries at least one line,
47
+ // and has not been touched (no `updated_at` bump) for at least
48
+ // `idle_threshold_ms`. This reads the live `carts` table directly — it does
49
+ // NOT require the cart-abandonment scanner's detection rows to exist, so an
50
+ // operator who never wired the recovery cron still sees their idle carts.
51
+ var ABANDONED_DEFAULT_IDLE_MS = C.TIME.hours(24);
52
+ var ABANDONED_MIN_IDLE_MS = C.TIME.minutes(1); // floor so a 0h window can't list live carts
53
+ var ABANDONED_MAX_IDLE_MS = C.TIME.days(90); // ceiling — past this the TTL has abandoned it anyway
54
+ var ABANDONED_DEFAULT_LIMIT = 50;
55
+ var ABANDONED_MAX_LIMIT = 200;
56
+
45
57
  // ---- validators ---------------------------------------------------------
46
58
 
47
59
  function _uuid(s, label) {
@@ -77,6 +89,31 @@ function _discountCode(s) {
77
89
 
78
90
  function _now() { return Date.now(); }
79
91
 
92
+ // Defensive request-shape readers for the abandoned-cart dashboard window.
93
+ // A garbage / out-of-range value (a hand-typed `?hours=` query param) clamps
94
+ // to a sane default rather than throwing — the console must never 500 on a
95
+ // fat-fingered filter. The bounds also stop a caller widening the scan past
96
+ // the ceiling (idle floor keeps live carts out; limit ceiling keeps the
97
+ // payload bounded).
98
+ function _abandonedIdleMs(v) {
99
+ if (v == null) return ABANDONED_DEFAULT_IDLE_MS;
100
+ var n = typeof v === "string" ? Number(v) : v;
101
+ if (typeof n !== "number" || !isFinite(n)) return ABANDONED_DEFAULT_IDLE_MS;
102
+ n = Math.floor(n);
103
+ if (n < ABANDONED_MIN_IDLE_MS) return ABANDONED_MIN_IDLE_MS;
104
+ if (n > ABANDONED_MAX_IDLE_MS) return ABANDONED_MAX_IDLE_MS;
105
+ return n;
106
+ }
107
+ function _abandonedLimit(v) {
108
+ if (v == null) return ABANDONED_DEFAULT_LIMIT;
109
+ var n = typeof v === "string" ? Number(v) : v;
110
+ if (typeof n !== "number" || !isFinite(n)) return ABANDONED_DEFAULT_LIMIT;
111
+ n = Math.floor(n);
112
+ if (n < 1) return ABANDONED_DEFAULT_LIMIT;
113
+ if (n > ABANDONED_MAX_LIMIT) return ABANDONED_MAX_LIMIT;
114
+ return n;
115
+ }
116
+
80
117
  // ---- factory ------------------------------------------------------------
81
118
 
82
119
  function create(opts) {
@@ -317,6 +354,107 @@ function create(opts) {
317
354
  return (await query("SELECT * FROM carts WHERE id = ?1", [cartId])).rows[0];
318
355
  },
319
356
 
357
+ // ---- abandoned-cart visibility (operator dashboard) ---------------
358
+ //
359
+ // A cart is "abandoned" for dashboard purposes when it is still
360
+ // `active`, has at least one line, and hasn't been touched for
361
+ // `idle_threshold_ms` (default 24h, operator-tunable). This reads the
362
+ // live `carts` / `cart_lines` tables — it needs NO scanner detection
363
+ // rows and NO schema change, so an operator who never wired the
364
+ // recovery cron still sees their idle carts.
365
+ //
366
+ // Defensive request-shape reader tier: a missing / garbage option
367
+ // falls back to a sane default rather than throwing, so a hand-typed
368
+ // `?hours=` query param can't 500 the console. The window is clamped
369
+ // to [1min, 90d] and the limit to [1, 200] — a caller can't widen the
370
+ // scan past the bounded ceiling.
371
+
372
+ // List abandoned carts, most-recent-activity-first, each enriched with
373
+ // its line count + subtotal-in-minor-units + age. Bounded by `limit`
374
+ // (default 50, max 200). The `idle_threshold_ms` option sets the
375
+ // untouched-for window; carts updated more recently than that are still
376
+ // "live" and excluded. Carts with zero lines are excluded (an empty
377
+ // cart has nothing to recover).
378
+ listAbandoned: async function (listOpts) {
379
+ listOpts = listOpts || {};
380
+ var idleMs = _abandonedIdleMs(listOpts.idle_threshold_ms);
381
+ var limit = _abandonedLimit(listOpts.limit);
382
+ var now = listOpts.now == null ? _now() : listOpts.now;
383
+ var cutoff = now - idleMs;
384
+ // INNER JOIN onto an aggregate of cart_lines so the zero-line carts
385
+ // drop out (a cart with no lines produces no group row) and we read
386
+ // the line count + subtotal in one pass. ORDER BY updated_at DESC =
387
+ // freshest-abandonment first (the operator's most-actionable carts).
388
+ var rows = (await query(
389
+ "SELECT c.id AS id, c.session_id AS session_id, c.customer_id AS customer_id, " +
390
+ " c.currency AS currency, c.created_at AS created_at, c.updated_at AS updated_at, " +
391
+ " agg.line_count AS line_count, agg.subtotal_minor AS subtotal_minor " +
392
+ "FROM carts c " +
393
+ "JOIN (SELECT cart_id, COUNT(*) AS line_count, " +
394
+ " SUM(qty * unit_amount_minor) AS subtotal_minor " +
395
+ " FROM cart_lines GROUP BY cart_id) agg ON agg.cart_id = c.id " +
396
+ "WHERE c.status = 'active' AND c.updated_at <= ?1 " +
397
+ "ORDER BY c.updated_at DESC, c.id DESC LIMIT ?2",
398
+ [cutoff, limit],
399
+ )).rows;
400
+ var out = [];
401
+ for (var i = 0; i < rows.length; i += 1) {
402
+ var r = rows[i];
403
+ out.push({
404
+ id: r.id,
405
+ // session_id is sealed-cookie material — never surface it raw to
406
+ // the operator screen; a short opaque tag of the cart id is the
407
+ // dashboard handle. customer_id passes through so the console can
408
+ // link signed-in carts to /admin/customers/<id>.
409
+ customer_id: r.customer_id || null,
410
+ currency: r.currency,
411
+ line_count: Number(r.line_count || 0),
412
+ subtotal_minor: Number(r.subtotal_minor || 0),
413
+ created_at: Number(r.created_at),
414
+ updated_at: Number(r.updated_at),
415
+ idle_ms: Math.max(0, now - Number(r.updated_at)),
416
+ });
417
+ }
418
+ return out;
419
+ },
420
+
421
+ // Summary stats for the same abandoned-cart window: how many carts are
422
+ // at risk + the total value (sum of subtotals) across them, per
423
+ // currency. The console renders this as the headline "N carts, X at
424
+ // risk" line. Unbounded COUNT/SUM (cheap aggregate, no row payload).
425
+ abandonedSummary: async function (sumOpts) {
426
+ sumOpts = sumOpts || {};
427
+ var idleMs = _abandonedIdleMs(sumOpts.idle_threshold_ms);
428
+ var now = sumOpts.now == null ? _now() : sumOpts.now;
429
+ var cutoff = now - idleMs;
430
+ var rows = (await query(
431
+ "SELECT c.currency AS currency, COUNT(*) AS cart_count, " +
432
+ " SUM(agg.subtotal_minor) AS value_minor " +
433
+ "FROM carts c " +
434
+ "JOIN (SELECT cart_id, SUM(qty * unit_amount_minor) AS subtotal_minor " +
435
+ " FROM cart_lines GROUP BY cart_id) agg ON agg.cart_id = c.id " +
436
+ "WHERE c.status = 'active' AND c.updated_at <= ?1 " +
437
+ "GROUP BY c.currency",
438
+ [cutoff],
439
+ )).rows;
440
+ var byCurrency = [];
441
+ var totalCarts = 0;
442
+ for (var i = 0; i < rows.length; i += 1) {
443
+ var n = Number(rows[i].cart_count || 0);
444
+ totalCarts += n;
445
+ byCurrency.push({
446
+ currency: rows[i].currency,
447
+ cart_count: n,
448
+ value_minor: Number(rows[i].value_minor || 0),
449
+ });
450
+ }
451
+ return {
452
+ idle_threshold_ms: idleMs,
453
+ cart_count: totalCarts,
454
+ by_currency: byCurrency,
455
+ };
456
+ },
457
+
320
458
  // ---- applied discount codes ---------------------------------------
321
459
  //
322
460
  // A cart carries the discount codes a shopper applied on the cart page.
@@ -391,8 +529,10 @@ function create(opts) {
391
529
  }
392
530
 
393
531
  module.exports = {
394
- create: create,
395
- DEFAULT_TTL_MS: DEFAULT_TTL_MS,
396
- MAX_QTY: MAX_QTY,
397
- CART_STATUSES: CART_STATUSES,
532
+ create: create,
533
+ DEFAULT_TTL_MS: DEFAULT_TTL_MS,
534
+ MAX_QTY: MAX_QTY,
535
+ CART_STATUSES: CART_STATUSES,
536
+ ABANDONED_DEFAULT_IDLE_MS: ABANDONED_DEFAULT_IDLE_MS,
537
+ ABANDONED_MAX_LIMIT: ABANDONED_MAX_LIMIT,
398
538
  };
package/lib/catalog.js CHANGED
@@ -10,7 +10,8 @@
10
10
  * - `products` — create, get, bySlug, list, update, archive, restore
11
11
  * - `variants` — create, get, listForProduct, update, delete
12
12
  * - `prices` — set (versioned), current, history
13
- * - `inventory` — create, get, decrement, release, restock
13
+ * - `inventory` — create, get, list, hold, decrement, release,
14
+ * restock, setThreshold, checkLowStock
14
15
  * - `media` — attach, get, listForProduct, listForVariant, delete,
15
16
  * reorder, setPrimary
16
17
  *
@@ -679,10 +680,16 @@ function _inventoryModule(query, opts) {
679
680
  return { rows: (await query(sql, [limit])).rows };
680
681
  },
681
682
 
682
- // Hot-path decrement happens via the Worker's InventoryLock
683
- // Durable Object this primitive is for admin restock / release
684
- // operations that don't need the DO serialization. Concurrent
685
- // decrements MUST go through the DO (see worker/index.js).
683
+ // `hold`, `decrement`, and `release` are the checkout-time stock
684
+ // primitives; `restock` and `setThreshold` are admin operations.
685
+ // Concurrency on the buy path is enforced by the conditional-UPDATE
686
+ // guards in `hold` / `decrement` (the WHERE clause refuses the write
687
+ // when stock is insufficient), so two racing confirms against the
688
+ // same SKU can never both succeed beyond the shelf. The container
689
+ // runs a single replica today; the guards are written to hold under
690
+ // N replicas without change — D1 applies each UPDATE atomically and
691
+ // the WHERE clause re-reads the row inside the same statement, so the
692
+ // check-and-set is a single SQL operation, not a read-then-write race.
686
693
  restock: async function (sku, qty) {
687
694
  _sku(sku);
688
695
  _positiveInt(qty, "qty");
@@ -714,6 +721,71 @@ function _inventoryModule(query, opts) {
714
721
  return await this.get(sku);
715
722
  },
716
723
 
724
+ // Place an atomic hold against available stock (on_hand − held).
725
+ // Called at checkout-confirm time, BEFORE the buyer is charged, so
726
+ // two concurrent confirms for the last unit can't both proceed. The
727
+ // conditional WHERE is the serialization point: D1 evaluates the
728
+ // `(stock_on_hand - stock_held) >= qty` predicate and applies the
729
+ // `stock_held = stock_held + qty` write in one atomic statement, so
730
+ // the second racer's predicate sees the first racer's increment and
731
+ // the UPDATE matches zero rows.
732
+ //
733
+ // Returns `{ held: true, sku, qty }` when the hold lands;
734
+ // `{ held: false, sku, qty }` when there's a row but it lacks the
735
+ // available stock (the caller renders the friendly out-of-stock
736
+ // message); `null` when the SKU has NO inventory row at all — an
737
+ // un-tracked SKU is treated as unlimited everywhere in the storefront
738
+ // (PDP, facets, edge all read a missing row as available), so a hold
739
+ // simply doesn't apply and the caller lets the line through.
740
+ hold: async function (sku, qty) {
741
+ _sku(sku);
742
+ _positiveInt(qty, "qty");
743
+ var ts = _now();
744
+ var r = await query(
745
+ "UPDATE inventory SET stock_held = stock_held + ?1, updated_at = ?2 " +
746
+ "WHERE sku = ?3 AND (stock_on_hand - stock_held) >= ?1",
747
+ [qty, ts, sku],
748
+ );
749
+ if (r.rowCount === 1) {
750
+ // Hold lowers available; a low-stock threshold may now trip.
751
+ await _afterMutation(sku);
752
+ return { held: true, sku: sku, qty: qty };
753
+ }
754
+ // Zero rows: either the SKU has no row, or it has a row but
755
+ // insufficient available stock. Distinguish so the caller can let
756
+ // an un-tracked SKU through while refusing a tracked-but-empty one.
757
+ var existing = await this.get(sku);
758
+ if (!existing) return null;
759
+ return { held: false, sku: sku, qty: qty };
760
+ },
761
+
762
+ // Convert a hold into a sale: debit on_hand and clear the matching
763
+ // held units in one atomic statement. Called on the order's paid
764
+ // transition. The `stock_held >= qty AND stock_on_hand >= qty` guard
765
+ // makes this a no-op for any SKU that was never held (an exempt or
766
+ // un-tracked line) and for a re-delivered paid event whose hold was
767
+ // already consumed — so it is idempotent: a second call against an
768
+ // already-debited SKU matches zero rows and returns `{ decremented:
769
+ // false }`. Both columns stay non-negative (the schema CHECKs would
770
+ // reject otherwise), so a bookkeeping drift can never write a
771
+ // negative shelf.
772
+ decrement: async function (sku, qty) {
773
+ _sku(sku);
774
+ _positiveInt(qty, "qty");
775
+ var ts = _now();
776
+ var r = await query(
777
+ "UPDATE inventory SET stock_on_hand = stock_on_hand - ?1, " +
778
+ "stock_held = stock_held - ?1, updated_at = ?2 " +
779
+ "WHERE sku = ?3 AND stock_held >= ?1 AND stock_on_hand >= ?1",
780
+ [qty, ts, sku],
781
+ );
782
+ if (r.rowCount === 1) {
783
+ await _afterMutation(sku);
784
+ return { decremented: true, sku: sku, qty: qty };
785
+ }
786
+ return { decremented: false, sku: sku, qty: qty };
787
+ },
788
+
717
789
  // Set / clear the per-SKU low-stock threshold. `threshold = null`
718
790
  // disables alerts for the SKU. Operators set this via the admin
719
791
  // API (`PATCH /admin/inventory/:sku/threshold`).
package/lib/checkout.js CHANGED
@@ -234,6 +234,17 @@ function create(deps) {
234
234
  // Disabled when absent — the buy path is byte-identical to the un-wired
235
235
  // flow.
236
236
  var discountAllocation = deps.discountAllocation || null;
237
+ // Optional backorder + preorder handles. When wired, a line whose SKU
238
+ // is actively backorderable (`backorder.availabilityFor` →
239
+ // `backorderable`) or has an open pre-order campaign
240
+ // (`preorder.openCampaignForSku` → a row) is EXEMPT from the
241
+ // confirm-time stock hold: those flows deliberately sell beyond the
242
+ // shelf (the operator commits to ship later / the unit isn't released
243
+ // yet). Absent — no SKU is exempted on these grounds, which matches
244
+ // production today (neither primitive is mounted on the buy path);
245
+ // a SKU with no inventory row is still treated as unlimited regardless.
246
+ var backorder = deps.backorder || null;
247
+ var preorder = deps.preorder || null;
237
248
 
238
249
  // Reprice a list of cart lines through the quantity-discount engine.
239
250
  // Returns a shallow copy with `unit_amount_minor` overwritten by the
@@ -587,6 +598,80 @@ function create(deps) {
587
598
  }
588
599
  }
589
600
 
601
+ // Is this SKU exempt from the confirm-time stock hold? Pre-order and
602
+ // backorder lines deliberately sell beyond the shelf, so they pass
603
+ // through without a hold. Both lookups are optional (the handles may
604
+ // be unwired) and defensive — any read failure falls back to "not
605
+ // exempt" so a flaky lookup tightens (never loosens) enforcement.
606
+ async function _holdExempt(sku) {
607
+ if (preorder && typeof preorder.openCampaignForSku === "function") {
608
+ try {
609
+ if (await preorder.openCampaignForSku(sku)) return true;
610
+ } catch (_e) { /* not exempt on lookup failure */ }
611
+ }
612
+ if (backorder && typeof backorder.availabilityFor === "function") {
613
+ try {
614
+ var a = await backorder.availabilityFor(sku);
615
+ if (a && a.status === "backorderable") return true;
616
+ } catch (_e) { /* not exempt on lookup failure */ }
617
+ }
618
+ return false;
619
+ }
620
+
621
+ // Reserve stock for every shippable, non-exempt line BEFORE any charge.
622
+ // Each hold is an atomic conditional UPDATE (`catalog.inventory.hold`):
623
+ // the SKU's available stock (on_hand − held) must cover the line qty or
624
+ // the write matches zero rows. On the FIRST insufficient line we release
625
+ // every hold already placed in this pass and throw a coded
626
+ // INSUFFICIENT_STOCK error carrying a friendly, per-line message — the
627
+ // storefront re-renders the checkout form inline with that message, the
628
+ // same recoverable UX a rejected gift-card / loyalty code gets. Nothing
629
+ // is charged and no order row is created, so a refused checkout leaves
630
+ // zero side effects beyond the already-rolled-back holds.
631
+ //
632
+ // Digital lines (requires_shipping false) never debit stock. An
633
+ // un-tracked SKU (no inventory row) is unlimited (hold → null) and
634
+ // passes through. Each line that DID hold is annotated with `_held_qty`
635
+ // so the order line records the exact reserved quantity (the paid
636
+ // decrement + cancel release act on it). Returns the list of placed
637
+ // { sku, qty } holds so the caller can release them if a LATER step
638
+ // (gift-card burn, order create) throws before the order is committed.
639
+ async function _placeStockHolds(quoteLines) {
640
+ var placed = [];
641
+ for (var i = 0; i < quoteLines.length; i += 1) {
642
+ var l = quoteLines[i];
643
+ l._held_qty = 0; // default: held nothing
644
+ if (!l.requires_shipping) continue; // digital — no shelf
645
+ if (await _holdExempt(l.sku)) continue; // preorder / backorder
646
+ var res = await catalog.inventory.hold(l.sku, l.qty);
647
+ if (res == null) continue; // un-tracked SKU — unlimited
648
+ if (res.held) { l._held_qty = l.qty; placed.push({ sku: l.sku, qty: l.qty }); continue; }
649
+ // Insufficient stock for this line — roll back the pass and refuse.
650
+ await _releaseStockHolds(placed);
651
+ var title = l.title || l.sku;
652
+ var refused = new Error("checkout: " + title + " — only a limited quantity is in stock. " +
653
+ "Lower the quantity or remove it to continue.");
654
+ refused.code = "INSUFFICIENT_STOCK";
655
+ refused.sku = l.sku;
656
+ throw refused;
657
+ }
658
+ return placed;
659
+ }
660
+
661
+ // Best-effort release of a set of placed holds (the rollback path).
662
+ // Drop-silent per hold: a release failure must not mask the original
663
+ // error that triggered the rollback, and the TTL-free holds here are
664
+ // only ever consumed by the paid decrement or cleared by a cancel, so
665
+ // a missed release self-heals when the abandoned pending order is later
666
+ // cancelled / expired.
667
+ async function _releaseStockHolds(holds) {
668
+ if (!Array.isArray(holds)) return;
669
+ for (var i = 0; i < holds.length; i += 1) {
670
+ try { await catalog.inventory.release(holds[i].sku, holds[i].qty); }
671
+ catch (_e) { /* drop-silent — rollback best-effort */ }
672
+ }
673
+ }
674
+
590
675
  // Compose a quote from a cart + ship-to + (optional) selected
591
676
  // shipping service. Pure read — no DB writes.
592
677
  async function _buildQuote(input) {
@@ -726,10 +811,39 @@ function create(deps) {
726
811
  throw new TypeError("checkout.confirm: grand_total_minor must be > 0 (zero-total orders use a separate freebie flow)");
727
812
  }
728
813
 
729
- // Resolve an optional gift-card credit BEFORE any charge so a
730
- // bad code fails the checkout without touching Stripe. The
731
- // credit reduces the amount due; the order still records the
732
- // full grand total it owed.
814
+ // Reserve stock for every shippable, non-exempt line BEFORE any
815
+ // charge. Insufficient stock throws a coded INSUFFICIENT_STOCK error
816
+ // here (no PaymentIntent, no order) which the storefront re-renders
817
+ // inline. The holds placed here ride into the pending order and are
818
+ // converted to a real shelf debit on the paid transition (or released
819
+ // when the order is cancelled / expires). Everything AFTER this point
820
+ // runs inside a guard that releases the holds if a later step throws
821
+ // before the order is committed, so a refused gift-card / payment
822
+ // error never strands held stock.
823
+ var stockHolds = await _placeStockHolds(quote.lines);
824
+ try {
825
+ return await this._confirmAfterHolds(input, quote, email, stockHolds);
826
+ } catch (e) {
827
+ await _releaseStockHolds(stockHolds);
828
+ throw e;
829
+ }
830
+ },
831
+
832
+ // The body of confirm() once stock is held. Split out so the holds
833
+ // placed in confirm() release on any throw from here down via the
834
+ // single try/catch above — the two return paths (fully-credited and
835
+ // Stripe-intent) both live inside that guard. Internal — invoked only
836
+ // through confirm() above (hence the `_` prefix); `stockHolds` is the
837
+ // list of placed holds, which both terminal paths leave in place (the
838
+ // pending order owns them until paid / cancelled).
839
+ _confirmAfterHolds: async function (input, quote, email, stockHolds) {
840
+ // Already validated in confirm() above; re-read here since the PI
841
+ // creation moved into this split-out body.
842
+ var idempotencyKey = input.idempotency_key;
843
+ // Resolve an optional gift-card credit BEFORE any charge so a bad
844
+ // code fails the checkout without touching Stripe. The credit
845
+ // reduces the amount due; the order still records the full grand
846
+ // total it owed.
733
847
  var gc = await _resolveGiftCard(input.gift_card_code, quote);
734
848
  // Loyalty points credit stacks on top of any gift-card credit —
735
849
  // both reduce the same amount due. The loyalty credit is capped
@@ -760,6 +874,9 @@ function create(deps) {
760
874
  qty: l.qty,
761
875
  unit_amount_minor: l.unit_amount_minor,
762
876
  unit_currency: l.unit_currency,
877
+ // Units this line reserved at confirm (0 unless a hold landed) so
878
+ // the order's paid/cancel transitions settle the exact hold.
879
+ stock_held_qty: l._held_qty || 0,
763
880
  };
764
881
  });
765
882
  // Reuse the cart row already fetched for the loyalty
@@ -968,6 +1085,13 @@ function create(deps) {
968
1085
  if (quote.totals.grand_total_minor <= 0) {
969
1086
  throw new TypeError("checkout.createPaypalOrder: grand_total_minor must be > 0");
970
1087
  }
1088
+ // Reserve stock before opening the PayPal order — same atomic holds
1089
+ // the Stripe confirm places, so the two payment paths can't oversell
1090
+ // against each other. Insufficient stock throws INSUFFICIENT_STOCK
1091
+ // here (no PayPal order created). Released on any throw before the
1092
+ // local order row commits.
1093
+ var ppHolds = await _placeStockHolds(quote.lines);
1094
+ try {
971
1095
  // Resolve an optional gift-card credit before opening the PayPal
972
1096
  // order so a bad code fails without a remote round-trip.
973
1097
  var gc = await _resolveGiftCard(input.gift_card_code, quote);
@@ -975,7 +1099,7 @@ function create(deps) {
975
1099
  var cartRow = await cart.get(quote.cart_id);
976
1100
  var emailHash = customers ? customers.hashEmail(email) : null;
977
1101
  var ppLines = quote.lines.map(function (l) {
978
- return { variant_id: l.variant_id, sku: l.sku, qty: l.qty, unit_amount_minor: l.unit_amount_minor, unit_currency: l.unit_currency };
1102
+ return { variant_id: l.variant_id, sku: l.sku, qty: l.qty, unit_amount_minor: l.unit_amount_minor, unit_currency: l.unit_currency, stock_held_qty: l._held_qty || 0 };
979
1103
  });
980
1104
 
981
1105
  // Gift card fully covers the order — no PayPal order (PayPal
@@ -1028,6 +1152,12 @@ function create(deps) {
1028
1152
  if (gc) await _redeemGiftCard(gc, createdOrder.id);
1029
1153
  await cart.setStatus(quote.cart_id, "converted");
1030
1154
  return { order: createdOrder, paypal_order_id: ppOrder.id, status: ppOrder.status, gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: amountDue } : null };
1155
+ } catch (e) {
1156
+ // Any throw before the order row commits (PayPal open failure,
1157
+ // gift-card error) releases the holds so PayPal can't strand stock.
1158
+ await _releaseStockHolds(ppHolds);
1159
+ throw e;
1160
+ }
1031
1161
  },
1032
1162
 
1033
1163
  // Capture an approved PayPal order, then advance the local order to paid.
package/lib/order.js CHANGED
@@ -128,6 +128,34 @@ function _shipTo(s) {
128
128
 
129
129
  function _now() { return Date.now(); }
130
130
 
131
+ // Read the per-SKU stock-hold map an order recorded at creation time. It
132
+ // lives in the `__init__` transition's metadata (`{ stock_holds: { sku:
133
+ // qty } }`), written by createFromCart when the checkout reserved shelf
134
+ // units. Returns `{}` for an order that held nothing (digital-only, an
135
+ // inventory-less deploy, or an order created before this was wired) so the
136
+ // settlement loop is a clean no-op. Defensive against a missing /
137
+ // malformed metadata blob — a parse failure yields no holds rather than a
138
+ // throw that would break a legitimate state transition.
139
+ function _stockHoldMap(order) {
140
+ if (!order || !Array.isArray(order.transitions)) return {};
141
+ var init = null;
142
+ for (var i = 0; i < order.transitions.length; i += 1) {
143
+ if (order.transitions[i].from_state === "__init__") { init = order.transitions[i]; break; }
144
+ }
145
+ if (!init || !init.metadata_json) return {};
146
+ var parsed;
147
+ try { parsed = JSON.parse(init.metadata_json); }
148
+ catch (_e) { return {}; }
149
+ if (!parsed || typeof parsed.stock_holds !== "object" || !parsed.stock_holds) return {};
150
+ var out = {};
151
+ var skus = Object.keys(parsed.stock_holds);
152
+ for (var s = 0; s < skus.length; s += 1) {
153
+ var q = Number(parsed.stock_holds[skus[s]]);
154
+ if (Number.isInteger(q) && q > 0) out[skus[s]] = q;
155
+ }
156
+ return out;
157
+ }
158
+
131
159
  // ---- factory ------------------------------------------------------------
132
160
 
133
161
  function create(opts) {
@@ -162,6 +190,25 @@ function create(opts) {
162
190
  // fire-and-forget on the same detached-promise discipline as the
163
191
  // loyalty fan-out: the transition has already persisted.
164
192
  var referrals = opts.referrals || null;
193
+ // Optional inventory handle — when present, the order FSM converts the
194
+ // confirm-time stock holds into real shelf movements as the order
195
+ // changes state. On `mark_paid` (pending → paid) each shippable line's
196
+ // held units are debited from on_hand via `inventory.decrement`; on
197
+ // `cancel` FROM PENDING (pending → cancelled — a never-paid order that
198
+ // timed out or the buyer abandoned) each line's hold is released via
199
+ // `inventory.release`. Both run SYNCHRONOUSLY inside the transition,
200
+ // before the fire-and-forget fan-outs, because stock truth is not
201
+ // best-effort: a paid order MUST debit the shelf and a cancelled
202
+ // pending order MUST free its hold. Both verbs are idempotent and
203
+ // self-targeting — `decrement` matches only lines that still hold the
204
+ // qty (so a re-delivered mark_paid is a no-op and an exempt / un-tracked
205
+ // line is skipped), and `release` only clears an active hold. Refund
206
+ // (paid → refunded) and cancel-after-paid (paid → cancelled) DO NOT
207
+ // touch inventory: a returned item is not automatically back on the
208
+ // shelf — the operator restocks via the admin inventory console once
209
+ // the physical return is inspected. Opt-in like the other handles so
210
+ // tests and an inventory-less deploy run unchanged.
211
+ var inventory = opts.inventory || null;
165
212
  // Pagination cursors for listForCustomer are HMAC-tagged via
166
213
  // b.pagination so an operator can't hand-craft one to skip past a
167
214
  // hidden order or replay across deployments. The secret defaults
@@ -215,10 +262,22 @@ function create(opts) {
215
262
  input.customer_email_hash || null, ts,
216
263
  ],
217
264
  );
265
+ // Accumulate the per-SKU stock-hold map as we write the lines. The
266
+ // checkout passes `stock_held_qty` on every line that reserved shelf
267
+ // units at confirm; lines that held nothing (digital / preorder /
268
+ // backorder / un-tracked SKU) contribute 0. The map is stamped onto
269
+ // the init-transition metadata below so the paid-time decrement and
270
+ // the cancel-time release settle exactly the units THIS order held —
271
+ // no schema column needed, and a cancel can never release stock that
272
+ // belongs to another shopper's hold on the same SKU.
273
+ var heldBySku = {};
218
274
  for (var i = 0; i < input.lines.length; i += 1) {
219
275
  var l = input.lines[i];
220
276
  _positiveInt(l.qty, "lines[" + i + "].qty");
221
277
  _nonNegInt(l.unit_amount_minor, "lines[" + i + "].unit_amount_minor");
278
+ var heldQty = l.stock_held_qty == null ? 0 : l.stock_held_qty;
279
+ _nonNegInt(heldQty, "lines[" + i + "].stock_held_qty");
280
+ if (heldQty > 0) heldBySku[l.sku] = (heldBySku[l.sku] || 0) + heldQty;
222
281
  await query(
223
282
  "INSERT INTO order_lines (id, order_id, variant_id, sku, qty, " +
224
283
  "unit_amount_minor, unit_currency, line_total_minor) " +
@@ -230,11 +289,14 @@ function create(opts) {
230
289
  ],
231
290
  );
232
291
  }
233
- // Initial transition row — from no-prior-state into pending.
292
+ // Initial transition row — from no-prior-state into pending. Its
293
+ // metadata carries the stock-hold map (`{ stock_holds: { sku: qty } }`)
294
+ // so the FSM can settle the holds without a dedicated column.
295
+ var initMeta = Object.keys(heldBySku).length ? JSON.stringify({ stock_holds: heldBySku }) : "{}";
234
296
  await query(
235
297
  "INSERT INTO order_transitions (id, order_id, from_state, to_state, on_event, reason, metadata_json, occurred_at) " +
236
- "VALUES (?1, ?2, '__init__', 'pending', 'create', ?3, '{}', ?4)",
237
- [b.uuid.v7(), id, input.reason || null, ts],
298
+ "VALUES (?1, ?2, '__init__', 'pending', 'create', ?3, ?5, ?4)",
299
+ [b.uuid.v7(), id, input.reason || null, ts, initMeta],
238
300
  );
239
301
  return await this.get(id);
240
302
  },
@@ -301,6 +363,41 @@ function create(opts) {
301
363
  ],
302
364
  );
303
365
  var refreshed = await this.get(orderId);
366
+ // Inventory settlement — SYNCHRONOUS, before the fire-and-forget
367
+ // fan-outs below. Stock truth is not best-effort: a paid order debits
368
+ // the shelf, a cancelled-while-pending order frees its hold. The
369
+ // edge (result.from → result.to) is authoritative, so this runs once
370
+ // per real state change — a re-delivered webhook is collapsed to a
371
+ // no-op transition upstream (the FSM refuses a second mark_paid from
372
+ // `paid`), and the underlying verbs are idempotent regardless. Only
373
+ // the two edges that own a hold act; refund and cancel-after-paid are
374
+ // deliberately inert (the operator restocks a physical return by hand).
375
+ if (inventory) {
376
+ var holdMap = _stockHoldMap(refreshed);
377
+ var holdSkus = Object.keys(holdMap);
378
+ if (holdSkus.length) {
379
+ if (result.from === "pending" && result.to === "paid"
380
+ && typeof inventory.decrement === "function") {
381
+ // Convert each held SKU's reservation into a real shelf debit,
382
+ // scoped to the exact units this order held. decrement's own
383
+ // guard (stock_held >= qty) makes a re-delivered mark_paid a
384
+ // no-op, so this is idempotent across webhook re-deliveries.
385
+ for (var di = 0; di < holdSkus.length; di += 1) {
386
+ await inventory.decrement(holdSkus[di], holdMap[holdSkus[di]]);
387
+ }
388
+ } else if (result.from === "pending" && result.to === "cancelled"
389
+ && typeof inventory.release === "function") {
390
+ // Free the holds of a pending order that never paid (timed out,
391
+ // payment_intent.canceled, or an explicit cancel), scoped to the
392
+ // exact units this order held so a cancel can never release stock
393
+ // another shopper holds on the same SKU. release floors at zero
394
+ // so a double-cancel can't underflow stock_held.
395
+ for (var ri = 0; ri < holdSkus.length; ri += 1) {
396
+ await inventory.release(holdSkus[ri], holdMap[holdSkus[ri]]);
397
+ }
398
+ }
399
+ }
400
+ }
304
401
  // Fan-out to merchant webhook subscribers is fire-and-forget. The
305
402
  // transition has already persisted; the request must not wait on
306
403
  // outbound HTTP, or a slow / unreachable endpoint would block the
package/lib/storefront.js CHANGED
@@ -12869,11 +12869,15 @@ function mount(router, deps) {
12869
12869
  // fat-fingered value re-prompts rather than 500-ing checkout.
12870
12870
  var code = (e && typeof e.code === "string") ? e.code : "";
12871
12871
  var msg = (e && e.message) || "checkout failed";
12872
- // A coded gift-card / loyalty error is something the shopper can
12873
- // fix in place — re-render the checkout form with the message
12874
- // inline (preserving the cart + their prefilled fields where
12872
+ // A coded gift-card / loyalty / out-of-stock error is something the
12873
+ // shopper can fix in place — re-render the checkout form with the
12874
+ // message inline (preserving the cart + their prefilled fields where
12875
12875
  // possible) rather than dead-ending on a separate page.
12876
- if (code.indexOf("GIFTCARD_") === 0 || code.indexOf("LOYALTY_") === 0) {
12876
+ // INSUFFICIENT_STOCK carries a friendly per-line message and means
12877
+ // the buyer must lower a quantity or drop a line; nothing was
12878
+ // charged and any holds placed mid-confirm were already released.
12879
+ if (code.indexOf("GIFTCARD_") === 0 || code.indexOf("LOYALTY_") === 0 ||
12880
+ code === "INSUFFICIENT_STOCK") {
12877
12881
  try {
12878
12882
  var coLines = await _repriceCartLines(await deps.cart.listLines(c.id));
12879
12883
  if (coLines.length) {
@@ -12999,7 +13003,14 @@ function mount(router, deps) {
12999
13003
  // The PayPal JS SDK's createOrder expects `{ id }`.
13000
13004
  return _json(200, { id: created.paypal_order_id, order_id: created.order.id });
13001
13005
  } catch (e) {
13002
- var gcErr = e && typeof e.code === "string" && e.code.indexOf("GIFTCARD_") === 0;
13006
+ var ecode = (e && typeof e.code === "string") ? e.code : "";
13007
+ var gcErr = ecode.indexOf("GIFTCARD_") === 0;
13008
+ // Out-of-stock is a 409 (conflict) carrying the friendly per-line
13009
+ // message so the PayPal button surfaces it; nothing was charged
13010
+ // and the mid-confirm holds were already released.
13011
+ if (ecode === "INSUFFICIENT_STOCK") {
13012
+ return _json(409, { error: (e && e.message) || "out-of-stock", code: ecode });
13013
+ }
13003
13014
  return _json((e instanceof TypeError || gcErr) ? 400 : 502, { error: (e && e.message) || "paypal-create-failed" });
13004
13015
  }
13005
13016
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.3.75",
3
+ "version": "0.4.0",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {