@blamejs/blamejs-shop 0.3.76 → 0.4.1
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 +6 -0
- package/README.md +2 -2
- package/lib/admin.js +314 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/catalog.js +77 -5
- package/lib/checkout.js +135 -5
- package/lib/order.js +100 -3
- package/lib/storefront.js +16 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,12 @@ 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.1 (2026-06-05) — **Order notes and a customer activity timeline in the admin console.** Two operator surfaces land in the admin console. Every order detail screen gains a customer-service notes thread: operators record internal notes or customer-visible ones, pin the important thread to the top, and mark issues resolved or reopen them — with note bodies length-bounded, control-character-rejected, and escaped at render. Every customer detail screen gains a read-only activity timeline aggregating what that customer has done across the store — orders placed and their lifecycle transitions, loyalty points earned, wishlist additions, reviews submitted, and support tickets opened — read directly from the tables those features already populate, newest first. Both panels appear only when their backing modules are wired, and every note mutation is ownership-scoped to its order and audited. **Added:** *Customer-service notes on order detail* — The admin order screen shows a notes thread with pinned notes floated first and the rest newest-first. Operators add notes as internal (the default) or customer-visible, pin and unpin them, and resolve or reopen them with a short resolution summary; resolving a customer-visible note is refused so customer-facing context is never silently closed out. Bodies are validated server-side (8000-character cap, control characters rejected) and HTML-escaped at render. Each mutation verifies the note belongs to the order in the URL before acting, returns clean errors for unknown or malformed identifiers, and emits an audit event. The same surface is available as JSON under the admin bearer token. · *Customer activity timeline on customer detail* — The admin customer screen shows an aggregated, newest-first activity feed: order placements and status transitions, loyalty point movements, wishlist additions, review submissions, and support tickets. The timeline is a read-only view over the tables those features already write — no new tracking or recording was added anywhere in the request path. The panel shows the most recent fifty events, links each event to its admin screen where one exists, and renders an explicit empty state for customers with no history. The same feed is available as JSON under the admin bearer token.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
9
15
|
## v0.3.x
|
|
10
16
|
|
|
11
17
|
- 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.
|
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
|
-
-
|
|
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. **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. |
|
|
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, plus a customer-service notes thread per order (internal or customer-visible, pinnable, resolvable); **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); each customer opens to an aggregated activity timeline (orders, loyalty, wishlist, reviews, support) read from the tables those primitives already populate; 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
|
@@ -52,6 +52,12 @@ var MS_PER_HOUR = C.TIME.hours(1);
|
|
|
52
52
|
|
|
53
53
|
var AUDIT_NAMESPACE = "shop_admin";
|
|
54
54
|
|
|
55
|
+
// Newest-N bound on the order-detail customer-service notes panel + the
|
|
56
|
+
// customer-detail activity timeline. Both are bounded reads — the console
|
|
57
|
+
// renders the most recent slice rather than paginating an unbounded list.
|
|
58
|
+
var NOTES_PANEL_LIMIT = 50;
|
|
59
|
+
var ACTIVITY_PANEL_LIMIT = 50;
|
|
60
|
+
|
|
55
61
|
// Operator-readable error-log sink (lib/error-log.js), set by mount()
|
|
56
62
|
// when the deployment wires `deps.errorLog`. `_safeNotice` records the
|
|
57
63
|
// scrubbed message of a genuine 5xx here so the failure is reachable
|
|
@@ -547,6 +553,8 @@ function mount(router, deps) {
|
|
|
547
553
|
var storeCredit = deps.storeCredit || null; // per-customer store-credit panel + grant/deduct disabled when absent
|
|
548
554
|
var customerNotes = deps.customerNotes || null; // per-customer CRM notes panel disabled when absent
|
|
549
555
|
var customerSegments = deps.customerSegments || null; // per-customer segment-membership panel disabled when absent
|
|
556
|
+
var customerActivity = deps.customerActivity || null; // per-customer chronological activity-timeline panel disabled when absent
|
|
557
|
+
var orderNotes = deps.orderNotes || null; // per-order customer-service notes panel + add/lifecycle disabled when absent
|
|
550
558
|
var orderTracking = deps.orderTracking || null; // shipment/tracking panel disabled when absent
|
|
551
559
|
var salesReports = deps.salesReports || null; // /admin/reports degrades to an unconfigured notice when absent
|
|
552
560
|
var orderExport = deps.orderExport || null; // /admin/exports (date-range CSV/NDJSON dump + scheduled-export queue) disabled when absent
|
|
@@ -1777,6 +1785,16 @@ function mount(router, deps) {
|
|
|
1777
1785
|
try { splitPlans = await splitShipments.splitsForOrder(o.id); }
|
|
1778
1786
|
catch (_se) { splitPlans = []; }
|
|
1779
1787
|
}
|
|
1788
|
+
// Customer-service notes attached to the order — both visibility tiers
|
|
1789
|
+
// (internal + customer-visible), pinned-first then newest-first, bounded
|
|
1790
|
+
// to the most recent NOTES_PANEL_LIMIT. Best-effort: an unmigrated
|
|
1791
|
+
// order_notes table degrades to "no notes" rather than 500-ing the page.
|
|
1792
|
+
var notes = [];
|
|
1793
|
+
if (orderNotes) {
|
|
1794
|
+
try {
|
|
1795
|
+
notes = (await orderNotes.listForOrder({ order_id: o.id, limit: NOTES_PANEL_LIMIT })).rows || [];
|
|
1796
|
+
} catch (_ne) { notes = []; }
|
|
1797
|
+
}
|
|
1780
1798
|
_sendHtml(res, 200, renderAdminOrder({
|
|
1781
1799
|
shop_name: deps.shop_name,
|
|
1782
1800
|
nav_available: navAvailable,
|
|
@@ -1816,11 +1834,173 @@ function mount(router, deps) {
|
|
|
1816
1834
|
ship_done: url && url.searchParams.get("ship"),
|
|
1817
1835
|
label_done: url && url.searchParams.get("label"),
|
|
1818
1836
|
split_done: url && url.searchParams.get("split"),
|
|
1837
|
+
// Customer-service notes panel — renders only when the order-notes
|
|
1838
|
+
// primitive is wired (its routes mount only then). `note_done` flags a
|
|
1839
|
+
// successful add / lifecycle action (PRG); `note_err` surfaces a clean
|
|
1840
|
+
// operator-facing notice from a refused write.
|
|
1841
|
+
can_notes: !!orderNotes,
|
|
1842
|
+
notes: notes,
|
|
1843
|
+
note_done: url && url.searchParams.get("note"),
|
|
1844
|
+
note_err: url && url.searchParams.get("note_err") ? url.searchParams.get("note_err") : null,
|
|
1845
|
+
note_authors: orderNotes ? orderNotes.ALLOWED_AUTHORS : null,
|
|
1846
|
+
note_visibility: orderNotes ? orderNotes.ALLOWED_VISIBILITY : null,
|
|
1819
1847
|
notice: url && url.searchParams.get("err") ? "That action couldn't be completed for this order." : null,
|
|
1820
1848
|
}));
|
|
1821
1849
|
},
|
|
1822
1850
|
));
|
|
1823
1851
|
|
|
1852
|
+
// ---- order notes (customer-service threaded notes) ------------------
|
|
1853
|
+
//
|
|
1854
|
+
// The operator's customer-service surface on the order-detail screen: a
|
|
1855
|
+
// list of notes attached to the order (both internal + customer-visible
|
|
1856
|
+
// tiers, pinned-first then newest-first) plus an add form and the lifecycle
|
|
1857
|
+
// the order-notes primitive offers — pin / unpin (float to the top) and
|
|
1858
|
+
// resolve / reopen (close out an internal thread with a short summary). The
|
|
1859
|
+
// note body is operator free text, escaped at render and length-capped by
|
|
1860
|
+
// the primitive's validator. Every mutation is audited; the panel renders
|
|
1861
|
+
// only when the order-notes primitive is wired.
|
|
1862
|
+
if (orderNotes) {
|
|
1863
|
+
// Resolve the :id to an order. A malformed id throws inside order.get's
|
|
1864
|
+
// defensive reader — treat that as "no such order" so the route refuses
|
|
1865
|
+
// cleanly (404) rather than 500-ing.
|
|
1866
|
+
async function _resolveOrder(id) {
|
|
1867
|
+
try { return await order.get(id); }
|
|
1868
|
+
catch (e) { if (e instanceof TypeError) return null; throw e; }
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
// A note belongs to an order. The primitive's pin / resolve / reopen move
|
|
1872
|
+
// a row by note id alone — they carry no notion of which order the
|
|
1873
|
+
// operator is acting on — so every per-note write route under :id MUST
|
|
1874
|
+
// first assert the note belongs to THAT order before mutating it, or an
|
|
1875
|
+
// operator on order A's screen could pin / resolve order B's note by
|
|
1876
|
+
// guessing its id (an IDOR). Mirrors the customer-notes
|
|
1877
|
+
// _noteBelongsToCustomer scoping. A malformed note id throws inside get's
|
|
1878
|
+
// UUID guard -> a clean 400; a well-formed unknown id (or one on a
|
|
1879
|
+
// different order) returns null -> a clean 404, with nothing written.
|
|
1880
|
+
async function _orderNoteBelongs(noteId, orderId) {
|
|
1881
|
+
var note;
|
|
1882
|
+
try { note = await orderNotes.get(noteId); }
|
|
1883
|
+
catch (e) { if (e instanceof TypeError) throw e; return false; }
|
|
1884
|
+
return (note && note.order_id === orderId) ? note : false;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// ---- add a note ----------------------------------------------------
|
|
1888
|
+
// Scoped to the :id order. Defaults author "operator" + visibility
|
|
1889
|
+
// "internal" (the console never writes a system note; an operator can opt
|
|
1890
|
+
// a note customer-visible via the form's visibility select). Bad input
|
|
1891
|
+
// (empty / over-long body, bad author / visibility) is a clean 4xx with
|
|
1892
|
+
// nothing written.
|
|
1893
|
+
router.post("/admin/orders/:id/notes", _pageOrApi(false,
|
|
1894
|
+
W("order.note.add", async function (req, res) {
|
|
1895
|
+
var o = await _resolveOrder(req.params.id);
|
|
1896
|
+
if (!o) return _problem(res, 404, "order-not-found");
|
|
1897
|
+
var body = req.body || {};
|
|
1898
|
+
var note;
|
|
1899
|
+
try {
|
|
1900
|
+
note = await orderNotes.add({
|
|
1901
|
+
order_id: o.id,
|
|
1902
|
+
author: (body.author === "customer" || body.author === "system") ? body.author : "operator",
|
|
1903
|
+
visibility: body.visibility === "customer_visible" ? "customer_visible" : "internal",
|
|
1904
|
+
body: body.body,
|
|
1905
|
+
});
|
|
1906
|
+
} catch (e) {
|
|
1907
|
+
if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message);
|
|
1908
|
+
throw e;
|
|
1909
|
+
}
|
|
1910
|
+
_json(res, 201, note);
|
|
1911
|
+
return { id: o.id };
|
|
1912
|
+
}),
|
|
1913
|
+
async function (req, res) {
|
|
1914
|
+
var o = await _resolveOrder(req.params.id);
|
|
1915
|
+
if (!o) return _sendHtml(res, 404, renderAdminOrders({
|
|
1916
|
+
shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
|
|
1917
|
+
}));
|
|
1918
|
+
var body = req.body || {};
|
|
1919
|
+
var enc = encodeURIComponent(o.id);
|
|
1920
|
+
try {
|
|
1921
|
+
await orderNotes.add({
|
|
1922
|
+
order_id: o.id,
|
|
1923
|
+
author: (body.author === "customer" || body.author === "system") ? body.author : "operator",
|
|
1924
|
+
visibility: body.visibility === "customer_visible" ? "customer_visible" : "internal",
|
|
1925
|
+
body: body.body,
|
|
1926
|
+
});
|
|
1927
|
+
} catch (e) {
|
|
1928
|
+
var n = _safeNotice(e, "order.note.add");
|
|
1929
|
+
return _redirect(res, "/admin/orders/" + enc +
|
|
1930
|
+
"?note_err=" + encodeURIComponent(n.message.replace(/^(?:admin|orderNotes)[.:]\s*/, "")));
|
|
1931
|
+
}
|
|
1932
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".order.note.add", outcome: "success", metadata: { id: o.id } });
|
|
1933
|
+
_redirect(res, "/admin/orders/" + enc + "?note=1");
|
|
1934
|
+
},
|
|
1935
|
+
));
|
|
1936
|
+
|
|
1937
|
+
// ---- per-note lifecycle (pin / unpin / resolve / reopen) -----------
|
|
1938
|
+
// Each route asserts note-belongs-to-order BEFORE mutating, then runs the
|
|
1939
|
+
// matching primitive call. `mutate(note, body)` returns the updated note
|
|
1940
|
+
// (bearer JSON) or throws a TypeError on bad input (-> a clean 400 /
|
|
1941
|
+
// note_err redirect). A missing / cross-order note is a clean 404 (bearer)
|
|
1942
|
+
// / note_err redirect (browser); nothing is mutated on any refusal.
|
|
1943
|
+
function _orderNoteWriteRoute(routePath, audit, mutate) {
|
|
1944
|
+
router.post(routePath, _pageOrApi(false,
|
|
1945
|
+
W(audit, async function (req, res) {
|
|
1946
|
+
var o = await _resolveOrder(req.params.id);
|
|
1947
|
+
if (!o) return _problem(res, 404, "order-not-found");
|
|
1948
|
+
var owned;
|
|
1949
|
+
try { owned = await _orderNoteBelongs(req.params.noteId, o.id); }
|
|
1950
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
1951
|
+
if (!owned) return _problem(res, 404, "note-not-found");
|
|
1952
|
+
var updated;
|
|
1953
|
+
try { updated = await mutate(owned, req.body || {}); }
|
|
1954
|
+
catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
|
|
1955
|
+
_json(res, 200, updated);
|
|
1956
|
+
return { id: o.id };
|
|
1957
|
+
}),
|
|
1958
|
+
async function (req, res) {
|
|
1959
|
+
var o = await _resolveOrder(req.params.id);
|
|
1960
|
+
if (!o) return _sendHtml(res, 404, renderAdminOrders({
|
|
1961
|
+
shop_name: deps.shop_name, nav_available: navAvailable, orders: [], notice: "Order not found.",
|
|
1962
|
+
}));
|
|
1963
|
+
var enc = encodeURIComponent(o.id);
|
|
1964
|
+
var owned;
|
|
1965
|
+
try { owned = await _orderNoteBelongs(req.params.noteId, o.id); }
|
|
1966
|
+
catch (e) {
|
|
1967
|
+
var nm = _safeNotice(e, audit);
|
|
1968
|
+
return _redirect(res, "/admin/orders/" + enc +
|
|
1969
|
+
"?note_err=" + encodeURIComponent(nm.message.replace(/^(?:admin|orderNotes)[.:]\s*/, "")));
|
|
1970
|
+
}
|
|
1971
|
+
if (!owned) {
|
|
1972
|
+
return _redirect(res, "/admin/orders/" + enc +
|
|
1973
|
+
"?note_err=" + encodeURIComponent("That note was not found on this order."));
|
|
1974
|
+
}
|
|
1975
|
+
try { await mutate(owned, req.body || {}); }
|
|
1976
|
+
catch (e) {
|
|
1977
|
+
var n = _safeNotice(e, audit);
|
|
1978
|
+
return _redirect(res, "/admin/orders/" + enc +
|
|
1979
|
+
"?note_err=" + encodeURIComponent(n.message.replace(/^(?:admin|orderNotes)[.:]\s*/, "")));
|
|
1980
|
+
}
|
|
1981
|
+
b.audit.safeEmit({ action: AUDIT_NAMESPACE + "." + audit, outcome: "success", metadata: { id: o.id } });
|
|
1982
|
+
_redirect(res, "/admin/orders/" + enc + "?note=1");
|
|
1983
|
+
},
|
|
1984
|
+
));
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// Pin / unpin — a pinned note floats to the top of the order's note list
|
|
1988
|
+
// (listForOrder orders pinned DESC). Idempotent at the primitive.
|
|
1989
|
+
_orderNoteWriteRoute("/admin/orders/:id/notes/:noteId/pin", "order.note.pin",
|
|
1990
|
+
function (owned) { return orderNotes.pin(owned.id); });
|
|
1991
|
+
_orderNoteWriteRoute("/admin/orders/:id/notes/:noteId/unpin", "order.note.unpin",
|
|
1992
|
+
function (owned) { return orderNotes.unpin(owned.id); });
|
|
1993
|
+
|
|
1994
|
+
// Resolve / reopen — close out an INTERNAL thread with a short (<=280-char)
|
|
1995
|
+
// operator summary, or reopen it. The primitive refuses to resolve a
|
|
1996
|
+
// customer-visible note (only internal threads carry a resolution) — that
|
|
1997
|
+
// refusal surfaces as a clean 4xx / note_err, never a 500.
|
|
1998
|
+
_orderNoteWriteRoute("/admin/orders/:id/notes/:noteId/resolve", "order.note.resolve",
|
|
1999
|
+
function (owned, body) { return orderNotes.resolve({ note_id: owned.id, resolution: body.resolution }); });
|
|
2000
|
+
_orderNoteWriteRoute("/admin/orders/:id/notes/:noteId/reopen", "order.note.reopen",
|
|
2001
|
+
function (owned) { return orderNotes.reopen(owned.id); });
|
|
2002
|
+
}
|
|
2003
|
+
|
|
1824
2004
|
// ---- audit / activity log (read-only) -------------------------------
|
|
1825
2005
|
//
|
|
1826
2006
|
// A read-only window onto the framework's tamper-evident audit chain —
|
|
@@ -3049,6 +3229,21 @@ function mount(router, deps) {
|
|
|
3049
3229
|
catch (_e) { segments = []; }
|
|
3050
3230
|
}
|
|
3051
3231
|
|
|
3232
|
+
// Chronological activity timeline — the read-only per-customer feed the
|
|
3233
|
+
// aggregator flattens from the wired source primitives (order
|
|
3234
|
+
// transitions, wishlist saves, loyalty ledger, support tickets,
|
|
3235
|
+
// reviews), newest-first, bounded to the most recent N. Best-effort: an
|
|
3236
|
+
// unmigrated source / cache table degrades the panel to "no activity"
|
|
3237
|
+
// rather than 500-ing the page.
|
|
3238
|
+
var activity = [];
|
|
3239
|
+
if (customerActivity) {
|
|
3240
|
+
try {
|
|
3241
|
+
activity = (await customerActivity.forCustomer({
|
|
3242
|
+
customer_id: customer.id, limit: ACTIVITY_PANEL_LIMIT,
|
|
3243
|
+
})).events || [];
|
|
3244
|
+
} catch (_e) { activity = []; }
|
|
3245
|
+
}
|
|
3246
|
+
|
|
3052
3247
|
return Object.assign({
|
|
3053
3248
|
shop_name: deps.shop_name,
|
|
3054
3249
|
nav_available: navAvailable,
|
|
@@ -3065,6 +3260,8 @@ function mount(router, deps) {
|
|
|
3065
3260
|
show_archived_notes: showArchivedNotes,
|
|
3066
3261
|
can_segments: !!customerSegments,
|
|
3067
3262
|
segments: segments,
|
|
3263
|
+
can_activity: !!customerActivity,
|
|
3264
|
+
activity: activity,
|
|
3068
3265
|
}, flags);
|
|
3069
3266
|
}
|
|
3070
3267
|
|
|
@@ -3096,6 +3293,7 @@ function mount(router, deps) {
|
|
|
3096
3293
|
loyalty: model.loyalty,
|
|
3097
3294
|
notes: model.notes,
|
|
3098
3295
|
segments: model.segments,
|
|
3296
|
+
activity: model.activity,
|
|
3099
3297
|
});
|
|
3100
3298
|
}),
|
|
3101
3299
|
async function (req, res) {
|
|
@@ -12664,6 +12862,18 @@ function renderAdminOrder(opts) {
|
|
|
12664
12862
|
splitPanel = _orderSplitPanel(o, opts.split_plans || []);
|
|
12665
12863
|
}
|
|
12666
12864
|
|
|
12865
|
+
// Customer-service notes panel — only when the order-notes primitive is
|
|
12866
|
+
// wired (its routes mount only then). The note body is operator free text,
|
|
12867
|
+
// escaped at render. `_orderNotesPanel` lists the notes newest-first with
|
|
12868
|
+
// their per-note lifecycle controls, then the add form.
|
|
12869
|
+
var notesPanel = "";
|
|
12870
|
+
if (opts.can_notes) {
|
|
12871
|
+
notesPanel = _orderNotesPanel(o.id, opts.notes || [],
|
|
12872
|
+
opts.note_done ? "<div class=\"banner banner--ok\">Note saved.</div>" : "",
|
|
12873
|
+
opts.note_err ? "<div class=\"banner banner--warn\">" + _htmlEscape(opts.note_err) + "</div>" : "",
|
|
12874
|
+
opts.note_authors || [], opts.note_visibility || []);
|
|
12875
|
+
}
|
|
12876
|
+
|
|
12667
12877
|
var body =
|
|
12668
12878
|
"<section class=\"mw-48\">" +
|
|
12669
12879
|
"<div class=\"actions-row\"><a class=\"btn btn--ghost\" href=\"/admin/orders\">← Orders</a></div>" +
|
|
@@ -12684,12 +12894,85 @@ function renderAdminOrder(opts) {
|
|
|
12684
12894
|
"</div>" +
|
|
12685
12895
|
documentsPanel +
|
|
12686
12896
|
resendPanel +
|
|
12897
|
+
notesPanel +
|
|
12687
12898
|
splitPanel +
|
|
12688
12899
|
trackingPanel +
|
|
12689
12900
|
"</section>";
|
|
12690
12901
|
return _renderAdminShell(opts.shop_name, "Order " + o.id.slice(0, 8), body, "orders", opts.nav_available);
|
|
12691
12902
|
}
|
|
12692
12903
|
|
|
12904
|
+
// The order-detail customer-service notes panel. Lists the order's notes
|
|
12905
|
+
// newest-first (pinned float to the top, as the primitive orders them) with
|
|
12906
|
+
// each note's tier (internal / customer-visible), author, timestamp, pins,
|
|
12907
|
+
// resolution summary, and the legal lifecycle controls — pin / unpin always,
|
|
12908
|
+
// resolve (on an active internal note) / reopen (on a resolved one). Then an
|
|
12909
|
+
// add form (body + author + visibility). The note body is operator free text,
|
|
12910
|
+
// escaped with _htmlEscape at every interpolation. Every form renders through
|
|
12911
|
+
// _renderAdminShell, which injects the _csrf field into each POST form, so no
|
|
12912
|
+
// per-form token handling is needed here.
|
|
12913
|
+
function _orderNotesPanel(orderId, notes, doneBanner, errBanner, authors, visibilities) {
|
|
12914
|
+
var enc = encodeURIComponent(orderId);
|
|
12915
|
+
var noteItems = notes.map(function (n) {
|
|
12916
|
+
var nenc = _htmlEscape(encodeURIComponent(n.id));
|
|
12917
|
+
var base = "/admin/orders/" + _htmlEscape(enc) + "/notes/" + nenc;
|
|
12918
|
+
var isPinned = Number(n.pinned) === 1;
|
|
12919
|
+
var isInternal = n.visibility === "internal";
|
|
12920
|
+
var isResolved = n.resolved_at != null;
|
|
12921
|
+
var pills =
|
|
12922
|
+
"<span class=\"status-pill " + (isInternal ? "pending" : "shipped") + "\">" +
|
|
12923
|
+
_htmlEscape(isInternal ? "Internal" : "Customer-visible") + "</span>" +
|
|
12924
|
+
(isPinned ? "<span class=\"status-pill paid\">Pinned</span>" : "") +
|
|
12925
|
+
(isResolved ? "<span class=\"status-pill refunded\">Resolved</span>" : "");
|
|
12926
|
+
var pinForm = "<form method=\"post\" action=\"" + base + (isPinned ? "/unpin" : "/pin") + "\" class=\"form-inline\">" +
|
|
12927
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">" + (isPinned ? "Unpin" : "Pin") + "</button></form>";
|
|
12928
|
+
// Resolve / reopen apply only to internal threads (the primitive refuses a
|
|
12929
|
+
// customer-visible resolve). An active internal note offers a resolve form
|
|
12930
|
+
// (with the required summary); a resolved one offers reopen.
|
|
12931
|
+
var resolveForm = "";
|
|
12932
|
+
if (isInternal) {
|
|
12933
|
+
resolveForm = isResolved
|
|
12934
|
+
? "<form method=\"post\" action=\"" + base + "/reopen\" class=\"form-inline\">" +
|
|
12935
|
+
"<button class=\"btn btn--ghost\" type=\"submit\">Reopen</button></form>"
|
|
12936
|
+
: "<form method=\"post\" action=\"" + base + "/resolve\" class=\"return-action\">" +
|
|
12937
|
+
"<input type=\"text\" name=\"resolution\" placeholder=\"Resolution summary\" maxlength=\"280\" aria-label=\"Resolution summary\" required>" +
|
|
12938
|
+
"<button class=\"btn\" type=\"submit\">Resolve</button></form>";
|
|
12939
|
+
}
|
|
12940
|
+
var resolutionLine = (isResolved && n.resolution)
|
|
12941
|
+
? "<div class=\"note-meta\">Resolution: " + _htmlEscape(String(n.resolution)) + "</div>"
|
|
12942
|
+
: "";
|
|
12943
|
+
return "<li class=\"note-item\">" +
|
|
12944
|
+
"<div class=\"note-meta\">" + _htmlEscape(String(n.author)) + " · " + _htmlEscape(_fmtDate(n.created_at)) +
|
|
12945
|
+
" " + pills + "</div>" +
|
|
12946
|
+
"<div class=\"note-body\">" + _htmlEscape(String(n.body)) + "</div>" +
|
|
12947
|
+
resolutionLine +
|
|
12948
|
+
"<div class=\"actions-row\">" + pinForm + resolveForm + "</div>" +
|
|
12949
|
+
"</li>";
|
|
12950
|
+
}).join("");
|
|
12951
|
+
var authorOpts = (authors || []).map(function (a) {
|
|
12952
|
+
return "<option value=\"" + _htmlEscape(a) + "\"" + (a === "operator" ? " selected" : "") + ">" + _htmlEscape(a) + "</option>";
|
|
12953
|
+
}).join("");
|
|
12954
|
+
var visibilityOpts = (visibilities || []).map(function (v) {
|
|
12955
|
+
return "<option value=\"" + _htmlEscape(v) + "\"" + (v === "internal" ? " selected" : "") + ">" +
|
|
12956
|
+
_htmlEscape(v === "customer_visible" ? "Customer-visible" : v) + "</option>";
|
|
12957
|
+
}).join("");
|
|
12958
|
+
return "<div class=\"panel mt\"><h3 class=\"subhead\">Customer-service notes</h3>" +
|
|
12959
|
+
doneBanner + errBanner +
|
|
12960
|
+
"<p class=\"meta\">Threaded notes attached to this order. Internal notes are operator-only; customer-visible notes surface on the customer's order page. Pin the notes every shift should see first; resolve an internal thread with a short summary once it's handled.</p>" +
|
|
12961
|
+
(notes.length
|
|
12962
|
+
? "<ul class=\"note-list\">" + noteItems + "</ul>"
|
|
12963
|
+
: "<p class=\"empty\">No notes on this order yet.</p>") +
|
|
12964
|
+
"<form method=\"post\" action=\"/admin/orders/" + _htmlEscape(enc) + "/notes\">" +
|
|
12965
|
+
"<label class=\"form-field\"><span>Note</span>" +
|
|
12966
|
+
"<textarea name=\"body\" required maxlength=\"8000\" rows=\"3\" placeholder=\"e.g. Buyer asked to delay shipment until Monday\"></textarea></label>" +
|
|
12967
|
+
"<div class=\"two-col\">" +
|
|
12968
|
+
"<label class=\"form-field\"><span>Author</span><select name=\"author\">" + authorOpts + "</select></label>" +
|
|
12969
|
+
"<label class=\"form-field\"><span>Visibility</span><select name=\"visibility\">" + visibilityOpts + "</select></label>" +
|
|
12970
|
+
"</div>" +
|
|
12971
|
+
"<div class=\"actions-row\"><button class=\"btn\" type=\"submit\">Add note</button></div>" +
|
|
12972
|
+
"</form>" +
|
|
12973
|
+
"</div>";
|
|
12974
|
+
}
|
|
12975
|
+
|
|
12693
12976
|
// Per-shipment carrier-label sub-panel for the order detail. Lists the
|
|
12694
12977
|
// recorded labels (tracking number, broker, cost, status pill) with a
|
|
12695
12978
|
// "Mark used" action on purchased ones, then a form to record a freshly-
|
|
@@ -13260,11 +13543,41 @@ function renderAdminCustomerDetail(opts) {
|
|
|
13260
13543
|
"</div>";
|
|
13261
13544
|
}
|
|
13262
13545
|
|
|
13546
|
+
// ---- activity timeline (read-only) ---------------------------------
|
|
13547
|
+
// The chronological per-customer feed the customerActivity aggregator
|
|
13548
|
+
// flattens from the wired source primitives, newest-first. Read-only — the
|
|
13549
|
+
// operator never writes an event here; every row originates in a source
|
|
13550
|
+
// primitive's own table. title / body / actor are escaped (title is a fixed
|
|
13551
|
+
// canonical string per kind, but body can carry source free text — a review
|
|
13552
|
+
// title, a ticket subject — so both pass through _htmlEscape).
|
|
13553
|
+
var activityPanel = "";
|
|
13554
|
+
if (opts.can_activity) {
|
|
13555
|
+
var activityRows = (opts.activity || []).map(function (e) {
|
|
13556
|
+
var link = (typeof e.link === "string" && e.link.charAt(0) === "/") ? e.link : null;
|
|
13557
|
+
var titleCell = link
|
|
13558
|
+
? "<a href=\"" + _htmlEscape(link) + "\">" + _htmlEscape(String(e.title || e.kind)) + "</a>"
|
|
13559
|
+
: _htmlEscape(String(e.title || e.kind));
|
|
13560
|
+
return "<li class=\"note-item\">" +
|
|
13561
|
+
"<div class=\"note-meta\">" + _htmlEscape(_fmtDate(e.occurred_at)) +
|
|
13562
|
+
" · <code class=\"order-id\">" + _htmlEscape(String(e.kind)) + "</code>" +
|
|
13563
|
+
(e.actor ? " · " + _htmlEscape(String(e.actor)) : "") + "</div>" +
|
|
13564
|
+
"<div class=\"note-body\">" + titleCell +
|
|
13565
|
+
(e.body ? " <span class=\"meta\">" + _htmlEscape(String(e.body)) + "</span>" : "") + "</div>" +
|
|
13566
|
+
"</li>";
|
|
13567
|
+
}).join("");
|
|
13568
|
+
activityPanel = "<div class=\"panel\"><h3 class=\"subhead\">Activity</h3>" +
|
|
13569
|
+
"<p class=\"meta\">A read-only chronological feed of this customer's recent events — orders, wishlist saves, loyalty changes, support tickets, and reviews — newest first. Each event lives in its source record; nothing is edited here.</p>" +
|
|
13570
|
+
((opts.activity || []).length
|
|
13571
|
+
? "<ul class=\"note-list\">" + activityRows + "</ul>"
|
|
13572
|
+
: "<p class=\"empty\">No recorded activity yet.</p>") +
|
|
13573
|
+
"</div>";
|
|
13574
|
+
}
|
|
13575
|
+
|
|
13263
13576
|
var body = "<section>" +
|
|
13264
13577
|
"<div class=\"actions-row\"><h2>" + _htmlEscape(c.display_name) + "</h2>" +
|
|
13265
13578
|
"<a class=\"btn btn--ghost\" href=\"/admin/customers\"><span aria-hidden=\"true\">←</span> All customers</a></div>" +
|
|
13266
13579
|
saved +
|
|
13267
|
-
identity + ordersPanel + creditPanel + loyaltyPanel + notesPanel + segmentsPanel +
|
|
13580
|
+
identity + ordersPanel + creditPanel + loyaltyPanel + notesPanel + segmentsPanel + activityPanel +
|
|
13268
13581
|
"</section>";
|
|
13269
13582
|
return _renderAdminShell(opts.shop_name, c.display_name || "Customer", body, "customers", opts.nav_available);
|
|
13270
13583
|
}
|
package/lib/asset-manifest.json
CHANGED
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,
|
|
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
|
-
//
|
|
683
|
-
//
|
|
684
|
-
//
|
|
685
|
-
//
|
|
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
|
-
//
|
|
730
|
-
//
|
|
731
|
-
//
|
|
732
|
-
//
|
|
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,
|
|
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
|
|
12873
|
-
// fix in place — re-render the checkout form with the
|
|
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
|
-
|
|
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
|
|
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