@blamejs/blamejs-shop 0.4.14 → 0.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.15 (2026-06-06) — **Multi-location inventory: stock locations, receiving, transfers, and write-offs in the admin console.** The admin console gains a location-aware inventory back office. Define warehouse, retail, and virtual stock locations and see per-location levels; record reason-coded inbound stock against a location; move stock between locations through a dispatch-and-receive flow; and record reason-coded write-offs with a full audit trail. Single-location stores are untouched — no configuration is needed and existing stock behavior is unchanged. **Added:** *Stock locations with per-location levels* — `/admin/inventory/locations` defines stock locations (warehouse, retail, or virtual, with a priority order) and shows each SKU's quantity at each location. A store that never defines a location keeps working exactly as before — the default location stays implicit and nothing about existing stock semantics changes. · *Receive stock against a location* — `/admin/inventory/receive` records inbound stock — SKU, quantity, location, reason — crediting both the location's level and the sellable aggregate in one step, with a batched receipt history on the same screen. If the receipt record cannot be written the stock credit is reversed, so the ledger and the levels never diverge. · *Location-to-location transfers with a dispatch/receive lifecycle* — `/admin/inventory/transfers` moves stock between locations through a real state machine: a draft transfer dispatches (debiting the source) and is later received (crediting the destination) or cancelled (restoring the source). Stock movements use the same atomic guards as checkout holds — a transfer dispatch racing a checkout for the last unit has exactly one winner, and a level can never go negative. · *Reason-coded write-offs with an audit trail* — `/admin/inventory/writeoffs` records stock losses — damage, shrinkage, expiry — against a location, with every write-off in the tamper-evident audit chain. A write-off that would eat into stock already promised to paid orders is refused with a clear message rather than silently unbalancing the ledgers, and low-stock alerts fire when a write-off takes a SKU under its threshold.
12
+
11
13
  - v0.4.14 (2026-06-06) — **Gift wrap charges exactly the fee it displays, the per-order wrap cap is enforced, low-stock events reach subscribed webhooks, and the admin API accepts curl as documented.** Three storefront fixes and two admin-surface fixes. Gift wrap now charges the configured wrap fee rather than the wrap variant's catalog price, and a wrap's per-order cap is enforced at the cart with a clear message. The low-stock inventory event is now a registered webhook event, so endpoints subscribed to specific events receive it. The account profile explains plainly why an email address cannot be changed. And the admin JSON API now answers automation clients such as curl the way the documentation has always shown, with the bearer key as the deciding check. **Changed:** *The account profile explains why the email address cannot be changed* — The store keeps only a one-way hash of each account's email address, so an address can never be read back or rewritten — an email change is not possible by design. The profile screen now says this plainly where an email-change control would normally sit, and points to the alternatives: create a new account under the new address, or contact the store to reconcile order history. **Fixed:** *Gift wrap charges the configured fee, not the wrap variant's catalog price* — The gift-wrap selector displayed the wrap fee configured in the admin console, but checkout added the wrap to the order at the underlying catalog variant's price — when the two diverged, the customer was silently charged a different amount than the page showed. The cart now snapshots the configured fee onto the wrap line, so the displayed fee and the charged fee always agree. A regression test keeps the two amounts pinned together with deliberately different fee and catalog values. · *A gift wrap's per-order cap is enforced at the cart* — A wrap option's maximum-per-order value was validated, stored, and shown in the admin console but never enforced — any quantity could be applied. Applying a wrap beyond its cap is now rejected with a 422 and the cart page re-renders with a readable message; an application at the cap continues to succeed and the cart is left unchanged on rejection. · *The low-stock event is a registered webhook event* — Inventory low-stock alerts emitted `inventory.low_stock`, but the event was missing from the webhook event registry — endpoints subscribed to specific events never received it, and the admin endpoint form never offered it. The event is now registered, appears in the endpoint form's event checklist, and is delivered to endpoints that subscribe to it. Wildcard subscriptions, which already received it, are unchanged. · *Documented curl commands against the admin API work as written* — Every admin endpoint is bearer-key-gated JSON, and the documentation drives it with plain curl — but the bot guard's user-agent deny list refused automation clients on `/admin` before the key was ever checked, so the documented commands answered 403. The admin surface now runs the bot guard in tag mode: automation is recorded in the audit trail (`system.botguard.tag`) instead of refused, and the timing-safe bearer key remains the deciding check. A curl call with a valid key reaches the endpoint; one without a key is refused by the key check with a 401.
12
14
 
13
15
  - v0.4.13 (2026-06-06) — **Security: a passkey can no longer be added to an existing account from the public register page, and the email sign-in link no longer reveals which addresses have accounts.** Two fixes to the account sign-in surface. The public passkey-registration endpoint reused an existing customer record when the submitted email already had an account, which let anyone who knew a registered email enroll their own authenticator onto that account and sign in as its owner. Registration now creates new accounts only: it refuses any email that already has an account — whether it signs in by passkey, by Google or Apple, or is a guest order awaiting a sign-in link — and directs the visitor to sign in instead. Separately, the email sign-in-link request did its account lookup, session creation, and email send before replying, so a registered address took measurably longer to answer than an unregistered one — a timing signal that revealed which addresses have accounts despite the identical on-screen confirmation. The reply is now sent first and the link is prepared and mailed afterward, so both cases answer at the same speed. **Security:** *Passkey registration can no longer enroll a credential onto someone else's account* — The public passkey-registration ceremony reused an existing customer record whenever the submitted email already had an account, then bound the registration challenge to that account — so a visitor who knew a registered email could complete the ceremony on their own device and be issued a sign-in session for the account's owner, without any proof of controlling the email. The path now creates new accounts only: it refuses any email that already has an account and binds no challenge to it, returning a clear "this email already has an account — sign in instead" response. The refusal covers every existing account, not only those with a passkey — a Google or Apple account and a guest order awaiting a sign-in link both have no passkey yet a real owner, and an attacker enrolling onto either would take it over just the same. An owner adding a second passkey continues to use the signed-in add-a-passkey flow, which has always required an active session and matches the challenge to the signed-in account; an owner who has no passkey, or whose first attempt was interrupted, signs in with the email link. · *The email sign-in link no longer leaks which addresses have accounts* — The "email me a sign-in link" request answered with the same confirmation whether or not the address matched an account, but it performed the account lookup, session creation, and email dispatch before replying when the address did match — so a registered address answered measurably more slowly than an unregistered one, a timing side channel that distinguished real accounts from non-accounts. The confirmation is now sent before any of that work; when the address matches an account, the sign-in link is prepared and mailed afterward, off the response path. A matched and an unmatched address now take the same time to answer, and the link still arrives for real accounts.
package/README.md CHANGED
@@ -99,7 +99,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
99
99
  | **`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). |
100
100
  | **`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. |
101
101
  | **`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. |
102
- | **`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, tracks new SKUs, and opens the low-stock alert history (`/admin/inventory/alerts`) — each alert row is written when a checkout decrement crosses a SKU's threshold, alongside an `inventory.low_stock` webhook to subscribed endpoints; **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. **Search suggestions** (`/admin/search-suggestions`) curates the storefront autocomplete — pin a featured link to a typed prefix, set its priority / status / active window, edit or remove it inline — and surfaces a read-only popular-searches report (each term's 30-day count, zero-result share, and last-seen) so an unmatched term flags a stock or naming gap. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, Search suggestions, 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. |
102
+ | **`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, tracks new SKUs, and opens the low-stock alert history (`/admin/inventory/alerts`) — each alert row is written when a checkout decrement crosses a SKU's threshold, alongside an `inventory.low_stock` webhook to subscribed endpoints; **Stock locations** (`/admin/inventory/locations`) defines warehouse / retail / virtual locations with per-location stock levels (a single-location store needs no configuration — the default location stays implicit), **Receive stock** (`/admin/inventory/receive`) records reason-coded inbound stock against a location with a batched receipt history, **Transfers** (`/admin/inventory/transfers`) moves stock between locations through a dispatch → receive state machine — the source is debited at dispatch, the destination credited on receive, and a dispatch racing a checkout hold for the last unit has exactly one winner — and **Write-offs** (`/admin/inventory/writeoffs`) records reason-coded stock losses with an audit trail, refusing a write-off that would eat into stock already held for paid orders; **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. **Search suggestions** (`/admin/search-suggestions`) curates the storefront autocomplete — pin a featured link to a typed prefix, set its priority / status / active window, edit or remove it inline — and surfaces a read-only popular-searches report (each term's 30-day count, zero-result share, and last-seen) so an unmatched term flags a stock or naming gap. The Customers, Returns, Reviews, Q&A, Subscriptions, Collections, Gift cards, Webhooks, Tax, Shipping, Discounts, Delivery estimates, Analytics, Search suggestions, Carts, Errors, Stock locations, Receive stock, Transfers, and Write-offs 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. |
103
103
  | **`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. |
104
104
  | **`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. |
105
105
 
package/lib/admin.js CHANGED
@@ -37,6 +37,7 @@ var loyaltyEarnRulesModule = require("./loyalty-earn-rules");
37
37
  var loyaltyRedemptionModule = require("./loyalty-redemption");
38
38
  var trustBadgesModule = require("./trust-badges");
39
39
  var cartModule = require("./cart"); // ABANDONED_* window/limit constants for the /admin/carts console
40
+ var inventoryWriteoffsModule = require("./inventory-writeoffs"); // WRITEOFF_REASONS enum for the /admin/inventory/writeoffs console
40
41
  var textGuard = require("./text-guard");
41
42
  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.
42
43
 
@@ -574,6 +575,10 @@ function mount(router, deps) {
574
575
  var searchSuggestions = deps.searchSuggestions || null; // featured-suggestion curation + popular-searches view disabled when absent
575
576
  var trustBadges = deps.trustBadges || null; // trust-badge authoring console disabled when absent
576
577
  var preorder = deps.preorder || null; // pre-order campaign console (define/launch/close) disabled when absent
578
+ var inventoryLocations = deps.inventoryLocations || null; // stock-location CRUD + per-location levels console disabled when absent
579
+ var inventoryReceive = deps.inventoryReceive || null; // inbound-stock receive console disabled when absent
580
+ var stockTransfers = deps.stockTransfers || null; // location→location transfer console (dispatch/receive FSM) disabled when absent
581
+ var inventoryWriteoffs = deps.inventoryWriteoffs || null; // reason-coded write-off / shrinkage console disabled when absent
577
582
  // Read-only activity log at /admin/audit. Defaults ON — the framework
578
583
  // audit chain is always booted by createApp, so the screen always has a
579
584
  // data source (unlike the optional primitives above, which default off).
@@ -593,7 +598,7 @@ function mount(router, deps) {
593
598
  // `reports` is always present in the nav (read-only sales summary needs no
594
599
  // extra dep); its route mounts unconditionally and renders an unconfigured
595
600
  // notice when the salesReports primitive isn't wired.
596
- 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, promoBanners: !!deps.promoBanners, 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, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart };
601
+ 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, promoBanners: !!deps.promoBanners, 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, searchSuggestions: !!searchSuggestions, trustBadges: !!trustBadges, orderExport: !!orderExport, auditLog: auditLog, errorLog: !!errorLog, carts: !!cart, inventoryLocations: !!inventoryLocations, inventoryReceive: !!inventoryReceive, stockTransfers: !!stockTransfers, inventoryWriteoffs: !!inventoryWriteoffs };
597
602
 
598
603
  try { b.audit.registerNamespace(AUDIT_NAMESPACE); } catch (_e) { /* idempotent */ }
599
604
 
@@ -5273,6 +5278,406 @@ function mount(router, deps) {
5273
5278
  });
5274
5279
  }
5275
5280
 
5281
+ // ---- inventory-ops back-office --------------------------------------
5282
+ //
5283
+ // Per-location stock on top of the single-bucket catalog inventory. The
5284
+ // catalog `inventory.stock_on_hand` stays the storefront source of truth;
5285
+ // these screens keep it in step with the per-location detail so a
5286
+ // multi-location operator's warehouse breakdown never diverges from the
5287
+ // count the storefront sells against. A store that never defines a
5288
+ // location keeps using /admin/inventory unchanged — the default location
5289
+ // is implicit, zero config.
5290
+
5291
+ // Credit ALL THREE ledgers for an inbound receive of one (sku,
5292
+ // location) line:
5293
+ // 1. inventoryReceive.draft + apply — the batched-receipt record the
5294
+ // "Recent receipts" history reads, AND the storefront-aggregate
5295
+ // credit (apply composes catalog.inventory.restock, which fires the
5296
+ // low-stock observer). A unique reference is auto-generated so two
5297
+ // receives in the same millisecond don't collide on the UNIQUE
5298
+ // constraint.
5299
+ // 2. inventoryLocations.adjustStock(+qty) — the per-location detail +
5300
+ // its reason-coded inventory_adjustments audit row.
5301
+ //
5302
+ // The location credit lands first: it's the verb that refuses an unknown
5303
+ // / deactivated location (a credit never refuses on insufficiency), so a
5304
+ // bad location is rejected before any receipt row is written. If the
5305
+ // receipt record then fails (duplicate reference race, DB error) the
5306
+ // location credit is reversed so the per-location detail doesn't sit
5307
+ // ahead of the aggregate. Returns the location adjust result.
5308
+ async function _receiveToLocation(sku, locationCode, qty, reason) {
5309
+ var adj = await inventoryLocations.adjustStock({
5310
+ sku: sku, location_code: locationCode, delta: qty,
5311
+ reason: reason ? ("receive: " + reason) : "receive",
5312
+ });
5313
+ try {
5314
+ var ref = "RCV-" + Date.now().toString(36).toUpperCase() + "-" +
5315
+ b.uuid.v7().slice(0, 8);
5316
+ var draft = await inventoryReceive.draft({
5317
+ reference: ref,
5318
+ supplier: reason ? reason.slice(0, 256) : "",
5319
+ received_by: "admin-console",
5320
+ notes: locationCode ? ("location: " + locationCode) : "",
5321
+ lines: [{ sku: sku, qty_received: qty }],
5322
+ });
5323
+ // apply credits the storefront aggregate (restock → low-stock
5324
+ // observer). restock is a no-op for an un-tracked SKU (no inventory
5325
+ // row) — fine: an un-tracked SKU is unlimited in the storefront, so
5326
+ // the per-location detail is the only ledger that needed the credit.
5327
+ await inventoryReceive.apply(draft.id);
5328
+ } catch (e) {
5329
+ // Compensate the per-location credit so the detail doesn't disagree
5330
+ // with the aggregate, then surface the original failure.
5331
+ try {
5332
+ await inventoryLocations.adjustStock({
5333
+ sku: sku, location_code: locationCode, delta: -qty,
5334
+ reason: "receive: rollback",
5335
+ });
5336
+ } catch (_e2) { /* drop-silent — the original error is the operator's signal */ }
5337
+ throw e;
5338
+ }
5339
+ return adj;
5340
+ }
5341
+
5342
+ // Debit BOTH ledgers for a write-off: the per-location detail (via the
5343
+ // writeoffs primitive, which composes inventoryLocations.adjustStock and
5344
+ // owns the reason-coded audit row) AND the storefront aggregate (catalog
5345
+ // adjustOnHand, which fires the low-stock observer + refuses to eat into
5346
+ // held stock).
5347
+ //
5348
+ // The aggregate mirror is applied BEFORE the write-off is committed as a
5349
+ // real record so the two ledgers never silently diverge. adjustOnHand
5350
+ // returns `{ adjusted: false }` (not a throw) when the debit would eat
5351
+ // into stock already held for paid-but-unfulfilled orders — that's a real
5352
+ // operational conflict (you're writing off units promised to a paid
5353
+ // order), so we reverse the per-location debit and surface it as a
5354
+ // refusal rather than committing a write-off that leaves the storefront
5355
+ // count ahead of the physical shelf. A null result means the SKU is
5356
+ // un-tracked in the catalog aggregate (no inventory row) — the
5357
+ // per-location detail is then the only ledger, and the write-off stands.
5358
+ async function _writeoffFromLocation(input) {
5359
+ var row = await inventoryWriteoffs.recordWriteoff(input);
5360
+ if (input.location_code) {
5361
+ // The per-location debit already landed inside recordWriteoff. Mirror
5362
+ // it onto the storefront aggregate.
5363
+ var mirror = await catalog.inventory.adjustOnHand(input.sku, -input.quantity);
5364
+ if (mirror && mirror.adjusted === false) {
5365
+ // The aggregate refused — reverse the per-location debit AND the
5366
+ // write-off record so neither ledger carries a half-applied move,
5367
+ // then surface the conflict.
5368
+ try {
5369
+ await inventoryWriteoffs.reverseWriteoff({
5370
+ id: row.id,
5371
+ reason: "aggregate-conflict: would oversell held stock",
5372
+ });
5373
+ } catch (_e) { /* drop-silent — the thrown conflict is the operator's signal */ }
5374
+ throw new TypeError("admin.inventory.writeoff: " + input.quantity +
5375
+ " unit(s) of " + input.sku + " can't be written off — that would eat " +
5376
+ "into stock already held for paid orders. Fulfil or cancel those orders first.");
5377
+ }
5378
+ }
5379
+ return row;
5380
+ }
5381
+
5382
+ if (inventoryLocations) {
5383
+ // GET /admin/inventory/locations — location list with per-location
5384
+ // levels for an optional ?sku= drill-down. JSON for the bearer token.
5385
+ router.get("/admin/inventory/locations", _pageOrApi(true,
5386
+ R(async function (req, res) {
5387
+ var rows = await inventoryLocations.listLocations({});
5388
+ _json(res, 200, { rows: rows });
5389
+ }),
5390
+ async function (req, res) {
5391
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5392
+ var sku = url && url.searchParams.get("sku");
5393
+ var rows = await inventoryLocations.listLocations({});
5394
+ var levels = null;
5395
+ if (sku) {
5396
+ try { levels = await inventoryLocations.stockForSku(sku); }
5397
+ catch (e) { if (!(e instanceof TypeError)) throw e; levels = null; }
5398
+ }
5399
+ _sendHtml(res, 200, renderAdminInvLocations({
5400
+ shop_name: deps.shop_name, nav_available: navAvailable,
5401
+ locations: rows, sku: sku || "", levels: levels,
5402
+ saved: url && url.searchParams.get("saved"),
5403
+ notice: url && url.searchParams.get("err") ? "That location couldn't be saved — check the fields and try again." : null,
5404
+ }));
5405
+ },
5406
+ ));
5407
+
5408
+ // POST /admin/inventory/locations — defineLocation, or updateLocation
5409
+ // when the code already exists (operator-friendly upsert). A bad shape
5410
+ // is a plain TypeError → 400.
5411
+ router.post("/admin/inventory/locations", _pageOrApi(false,
5412
+ W("inventory.location.save", async function (req, res) {
5413
+ var loc;
5414
+ try { loc = await _saveInvLocationFromBody(req.body || {}); }
5415
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5416
+ _json(res, 201, loc);
5417
+ return loc;
5418
+ }),
5419
+ async function (req, res) {
5420
+ try { await _saveInvLocationFromBody(req.body || {}); }
5421
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/locations?err=1"); throw e; }
5422
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.location.save", outcome: "success", metadata: { code: (req.body || {}).code } });
5423
+ _redirect(res, "/admin/inventory/locations?saved=1");
5424
+ },
5425
+ ));
5426
+
5427
+ async function _saveInvLocationFromBody(body) {
5428
+ var input = {
5429
+ code: body.code,
5430
+ name: body.name,
5431
+ type: body.type,
5432
+ priority: body.priority == null || body.priority === "" ? undefined : (parseInt(body.priority, 10)),
5433
+ };
5434
+ if (input.priority !== undefined && !Number.isFinite(input.priority)) {
5435
+ throw new TypeError("admin.inventory.location.save: priority must be an integer");
5436
+ }
5437
+ var existing = await inventoryLocations.getLocation(body.code);
5438
+ if (existing) {
5439
+ var patch = { name: input.name, type: input.type };
5440
+ if (input.priority !== undefined) patch.priority = input.priority;
5441
+ return inventoryLocations.updateLocation(body.code, patch);
5442
+ }
5443
+ return inventoryLocations.defineLocation(input);
5444
+ }
5445
+
5446
+ // POST /admin/inventory/locations/:code/deactivate — soft-delete. The
5447
+ // row survives so historical adjustments still resolve their location.
5448
+ router.post("/admin/inventory/locations/:code/deactivate", _pageOrApi(false,
5449
+ W("inventory.location.deactivate", async function (req, res) {
5450
+ var code = req.params.code;
5451
+ var existing;
5452
+ try { existing = await inventoryLocations.getLocation(code); }
5453
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5454
+ if (!existing) return _problem(res, 404, "inventory-location-not-found");
5455
+ var row = await inventoryLocations.deactivateLocation(code);
5456
+ _json(res, 200, row);
5457
+ return row;
5458
+ }),
5459
+ async function (req, res) {
5460
+ var code = req.params.code;
5461
+ var existing;
5462
+ try { existing = await inventoryLocations.getLocation(code); }
5463
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/locations?err=1"); throw e; }
5464
+ if (!existing) return _redirect(res, "/admin/inventory/locations?err=1");
5465
+ await inventoryLocations.deactivateLocation(code);
5466
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.location.deactivate", outcome: "success", metadata: { code: code } });
5467
+ _redirect(res, "/admin/inventory/locations?saved=1");
5468
+ },
5469
+ ));
5470
+ }
5471
+
5472
+ if (inventoryReceive && inventoryLocations) {
5473
+ // GET /admin/inventory/receive — the receive form + recent receipt
5474
+ // history (the inventory-receive ledger). The form credits a single
5475
+ // (sku, location) line; the history reads the batched-receipt records.
5476
+ router.get("/admin/inventory/receive", _pageOrApi(true,
5477
+ R(async function (req, res) {
5478
+ var page = await inventoryReceive.list({ limit: 50 });
5479
+ _json(res, 200, { rows: page.rows, next_cursor: page.next_cursor });
5480
+ }),
5481
+ async function (req, res) {
5482
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5483
+ var locs = await inventoryLocations.listLocations({ active_only: true });
5484
+ var page = await inventoryReceive.list({ limit: 50 });
5485
+ _sendHtml(res, 200, renderAdminInvReceive({
5486
+ shop_name: deps.shop_name, nav_available: navAvailable,
5487
+ locations: locs, receipts: page.rows || [],
5488
+ saved: url && url.searchParams.get("saved"),
5489
+ notice: url && url.searchParams.get("err") ? "That receipt couldn't be recorded — check the SKU, location, and quantity." : null,
5490
+ }));
5491
+ },
5492
+ ));
5493
+
5494
+ // POST /admin/inventory/receive — credit a single (sku, location) line
5495
+ // to both ledgers. quantity must be a positive integer; the location
5496
+ // must exist (adjustStock refuses an unknown one).
5497
+ router.post("/admin/inventory/receive", _pageOrApi(false,
5498
+ W("inventory.receive", async function (req, res) {
5499
+ var body = req.body || {};
5500
+ var qty = parseInt(body.quantity, 10);
5501
+ if (!Number.isInteger(qty) || qty <= 0) return _problem(res, 400, "bad-request", "quantity must be a positive integer");
5502
+ var out;
5503
+ try { out = await _receiveToLocation(body.sku, body.location_code, qty, body.reason); }
5504
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5505
+ _json(res, 201, out);
5506
+ return out;
5507
+ }),
5508
+ async function (req, res) {
5509
+ var body = req.body || {};
5510
+ var qty = parseInt(body.quantity, 10);
5511
+ if (!Number.isInteger(qty) || qty <= 0) return _redirect(res, "/admin/inventory/receive?err=1");
5512
+ try { await _receiveToLocation(body.sku, body.location_code, qty, body.reason); }
5513
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/receive?err=1"); throw e; }
5514
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.receive", outcome: "success", metadata: { sku: body.sku, location_code: body.location_code, quantity: qty } });
5515
+ _redirect(res, "/admin/inventory/receive?saved=1");
5516
+ },
5517
+ ));
5518
+ }
5519
+
5520
+ if (stockTransfers && inventoryLocations) {
5521
+ // GET /admin/inventory/transfers — the open-transfer queue + the open
5522
+ // form. The queue shows non-terminal transfers with the FSM actions
5523
+ // legal from each status.
5524
+ router.get("/admin/inventory/transfers", _pageOrApi(true,
5525
+ R(async function (req, res) {
5526
+ var rows = await stockTransfers.listOpen({});
5527
+ _json(res, 200, { rows: rows });
5528
+ }),
5529
+ async function (req, res) {
5530
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5531
+ var locs = await inventoryLocations.listLocations({ active_only: true });
5532
+ var rows = await stockTransfers.listOpen({});
5533
+ _sendHtml(res, 200, renderAdminInvTransfers({
5534
+ shop_name: deps.shop_name, nav_available: navAvailable,
5535
+ locations: locs, transfers: rows,
5536
+ saved: url && url.searchParams.get("saved"),
5537
+ notice: url && url.searchParams.get("err") ? "That transfer action couldn't be completed — check stock at the source and the transfer state." : null,
5538
+ }));
5539
+ },
5540
+ ));
5541
+
5542
+ // POST /admin/inventory/transfers — openTransfer for one (sku, qty)
5543
+ // line. Debits the source immediately (stock leaves at dispatch).
5544
+ router.post("/admin/inventory/transfers", _pageOrApi(false,
5545
+ W("inventory.transfer.open", async function (req, res) {
5546
+ var body = req.body || {};
5547
+ var qty = parseInt(body.quantity, 10);
5548
+ if (!Number.isInteger(qty) || qty <= 0) return _problem(res, 400, "bad-request", "quantity must be a positive integer");
5549
+ var t;
5550
+ try {
5551
+ t = await stockTransfers.openTransfer({
5552
+ from_location: body.from_location, to_location: body.to_location,
5553
+ lines: [{ sku: body.sku, quantity: qty }], reason: body.reason,
5554
+ });
5555
+ } catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5556
+ _json(res, 201, t);
5557
+ return t;
5558
+ }),
5559
+ async function (req, res) {
5560
+ var body = req.body || {};
5561
+ var qty = parseInt(body.quantity, 10);
5562
+ if (!Number.isInteger(qty) || qty <= 0) return _redirect(res, "/admin/inventory/transfers?err=1");
5563
+ try {
5564
+ await stockTransfers.openTransfer({
5565
+ from_location: body.from_location, to_location: body.to_location,
5566
+ lines: [{ sku: body.sku, quantity: qty }], reason: body.reason,
5567
+ });
5568
+ } catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/transfers?err=1"); throw e; }
5569
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.transfer.open", outcome: "success", metadata: { sku: body.sku, from: body.from_location, to: body.to_location, quantity: qty } });
5570
+ _redirect(res, "/admin/inventory/transfers?saved=1");
5571
+ },
5572
+ ));
5573
+
5574
+ // The FSM-action routes: ship → in-transit → receive → reconcile, plus
5575
+ // exception. Each resolves the transfer first (unknown id → err) and
5576
+ // calls the matching primitive verb; a wrong-state transition is a
5577
+ // plain TypeError surfaced as an error redirect / 400.
5578
+ function _transferAction(action, verb) {
5579
+ router.post("/admin/inventory/transfers/:id/" + action, _pageOrApi(false,
5580
+ W("inventory.transfer." + action, async function (req, res) {
5581
+ var id = req.params.id;
5582
+ var out;
5583
+ try { out = await verb(id, req.body || {}); }
5584
+ catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5585
+ _json(res, 200, out);
5586
+ return out;
5587
+ }),
5588
+ async function (req, res) {
5589
+ var id = req.params.id;
5590
+ try { await verb(id, req.body || {}); }
5591
+ catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/transfers?err=1"); throw e; }
5592
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.transfer." + action, outcome: "success", metadata: { transfer_id: id } });
5593
+ _redirect(res, "/admin/inventory/transfers?saved=1");
5594
+ },
5595
+ ));
5596
+ }
5597
+ _transferAction("ship", function (id, body) {
5598
+ return stockTransfers.markShipped({ transfer_id: id, carrier: body.carrier || null, tracking_number: body.tracking_number || null });
5599
+ });
5600
+ _transferAction("in-transit", function (id, body) {
5601
+ return stockTransfers.markInTransit({ transfer_id: id, location: body.location || null });
5602
+ });
5603
+ _transferAction("receive", function (id, body) {
5604
+ // Single-line transfers from the open form: receive the full shipped
5605
+ // qty unless the operator overrides quantity_received. Resolve the
5606
+ // shipped line so a blank field receives everything (the happy path).
5607
+ return (async function () {
5608
+ var t = await stockTransfers.getTransfer(id);
5609
+ if (!t) throw new TypeError("transfer not found");
5610
+ var rx = t.lines.map(function (l) {
5611
+ var got = body["rx_" + l.sku];
5612
+ var q = got == null || got === "" ? l.quantity_shipped : parseInt(got, 10);
5613
+ if (!Number.isInteger(q) || q < 0) throw new TypeError("quantity_received must be a non-negative integer");
5614
+ return { sku: l.sku, quantity_received: q };
5615
+ });
5616
+ return stockTransfers.markReceived({ transfer_id: id, received_lines: rx });
5617
+ })();
5618
+ });
5619
+ _transferAction("reconcile", function (id) {
5620
+ return stockTransfers.reconcile({ transfer_id: id });
5621
+ });
5622
+ _transferAction("exception", function (id, body) {
5623
+ return stockTransfers.markException({ transfer_id: id, reason: body.reason });
5624
+ });
5625
+ }
5626
+
5627
+ if (inventoryWriteoffs && inventoryLocations) {
5628
+ // GET /admin/inventory/writeoffs — the write-off log + the record form.
5629
+ router.get("/admin/inventory/writeoffs", _pageOrApi(true,
5630
+ R(async function (req, res) {
5631
+ var page = await inventoryWriteoffs.listWriteoffs({ limit: 50 });
5632
+ _json(res, 200, { rows: page.rows, next_cursor: page.next_cursor });
5633
+ }),
5634
+ async function (req, res) {
5635
+ var url = req.url ? new URL(req.url, "http://localhost") : null;
5636
+ var locs = await inventoryLocations.listLocations({ active_only: true });
5637
+ var page = await inventoryWriteoffs.listWriteoffs({ limit: 50 });
5638
+ _sendHtml(res, 200, renderAdminInvWriteoffs({
5639
+ shop_name: deps.shop_name, nav_available: navAvailable,
5640
+ locations: locs, writeoffs: page.rows || [],
5641
+ reasons: inventoryWriteoffsModule.WRITEOFF_REASONS,
5642
+ saved: url && url.searchParams.get("saved"),
5643
+ notice: url && url.searchParams.get("err") ? "That write-off couldn't be recorded — check the SKU, location, quantity, and reason." : null,
5644
+ }));
5645
+ },
5646
+ ));
5647
+
5648
+ // POST /admin/inventory/writeoffs — recordWriteoff against a location +
5649
+ // mirror the debit onto the storefront aggregate.
5650
+ router.post("/admin/inventory/writeoffs", _pageOrApi(false,
5651
+ W("inventory.writeoff", async function (req, res) {
5652
+ var body = req.body || {};
5653
+ var qty = parseInt(body.quantity, 10);
5654
+ if (!Number.isInteger(qty) || qty <= 0) return _problem(res, 400, "bad-request", "quantity must be a positive integer");
5655
+ var row;
5656
+ try {
5657
+ row = await _writeoffFromLocation({
5658
+ sku: body.sku, location_code: body.location_code, quantity: qty,
5659
+ reason: body.reason, actor: body.actor || "admin-console", notes: body.notes || null,
5660
+ });
5661
+ } catch (e) { if (e instanceof TypeError) return _problem(res, 400, "bad-request", e.message); throw e; }
5662
+ _json(res, 201, row);
5663
+ return row;
5664
+ }),
5665
+ async function (req, res) {
5666
+ var body = req.body || {};
5667
+ var qty = parseInt(body.quantity, 10);
5668
+ if (!Number.isInteger(qty) || qty <= 0) return _redirect(res, "/admin/inventory/writeoffs?err=1");
5669
+ try {
5670
+ await _writeoffFromLocation({
5671
+ sku: body.sku, location_code: body.location_code, quantity: qty,
5672
+ reason: body.reason, actor: body.actor || "admin-console", notes: body.notes || null,
5673
+ });
5674
+ } catch (e) { if (e instanceof TypeError) return _redirect(res, "/admin/inventory/writeoffs?err=1"); throw e; }
5675
+ b.audit.safeEmit({ action: AUDIT_NAMESPACE + ".inventory.writeoff", outcome: "success", metadata: { sku: body.sku, location_code: body.location_code, quantity: qty, reason: body.reason } });
5676
+ _redirect(res, "/admin/inventory/writeoffs?saved=1");
5677
+ },
5678
+ ));
5679
+ }
5680
+
5276
5681
  // ---- gift wraps -----------------------------------------------------
5277
5682
  //
5278
5683
  // The operator-defined gift-wrap catalog: define / update / archive a wrap
@@ -11946,6 +12351,10 @@ var ADMIN_NAV_ITEMS = [
11946
12351
  { key: "dashboard", href: "/admin/dashboard", label: "Dashboard" },
11947
12352
  { key: "products", href: "/admin/products", label: "Products" },
11948
12353
  { key: "inventory", href: "/admin/inventory", label: "Inventory" },
12354
+ { key: "inventory-locations", href: "/admin/inventory/locations", label: "Stock locations", requires: "inventoryLocations" },
12355
+ { key: "inventory-receive", href: "/admin/inventory/receive", label: "Receive stock", requires: "inventoryReceive" },
12356
+ { key: "inventory-transfers", href: "/admin/inventory/transfers", label: "Transfers", requires: "stockTransfers" },
12357
+ { key: "inventory-writeoffs", href: "/admin/inventory/writeoffs", label: "Write-offs", requires: "inventoryWriteoffs" },
11949
12358
  { key: "orders", href: "/admin/orders", label: "Orders" },
11950
12359
  { key: "carts", href: "/admin/carts", label: "Abandoned carts", requires: "carts" },
11951
12360
  { key: "reports", href: "/admin/reports", label: "Reports" },
@@ -14687,6 +15096,236 @@ function renderAdminPickupLocations(opts) {
14687
15096
  return _renderAdminShell(opts.shop_name, "Pickup locations", body, "pickup-locations", opts.nav_available);
14688
15097
  }
14689
15098
 
15099
+ // Stock-location list + the define/update form + an optional per-SKU
15100
+ // levels drill-down. Location names and codes are operator free text —
15101
+ // escaped at the sink.
15102
+ function renderAdminInvLocations(opts) {
15103
+ opts = opts || {};
15104
+ var list = opts.locations || [];
15105
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Stock location saved.</div>" : "";
15106
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15107
+
15108
+ var rows = list.map(function (l) {
15109
+ return "<tr>" +
15110
+ "<td><code class=\"order-id\">" + _htmlEscape(l.code) + "</code></td>" +
15111
+ "<td>" + _htmlEscape(l.name) + "</td>" +
15112
+ "<td>" + _htmlEscape(l.type) + "</td>" +
15113
+ "<td>" + _htmlEscape(String(l.priority)) + "</td>" +
15114
+ "<td>" + (l.active ? "<span class=\"status-pill\">active</span>" : "<span class=\"meta\">inactive</span>") + "</td>" +
15115
+ "<td>" +
15116
+ (l.active
15117
+ ? "<form method=\"post\" action=\"/admin/inventory/locations/" + encodeURIComponent(l.code) + "/deactivate\" class=\"form-inline\">" +
15118
+ "<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Deactivate</button></form>"
15119
+ : "<span class=\"meta\">—</span>") +
15120
+ "</td>" +
15121
+ "</tr>";
15122
+ }).join("");
15123
+
15124
+ var table = list.length
15125
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">Code</th><th scope=\"col\">Name</th><th scope=\"col\">Type</th><th scope=\"col\">Priority</th><th scope=\"col\">Status</th><th scope=\"col\">Action</th></tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
15126
+ : "<p class=\"empty\">No stock locations yet. A store with one warehouse needs no location — the default bucket is implicit. Add a location below once you ship from more than one place.</p>";
15127
+
15128
+ // Per-SKU levels drill-down.
15129
+ var levelsBlock = "";
15130
+ if (opts.levels) {
15131
+ var lv = opts.levels;
15132
+ var lvRows = (lv.by_location || []).map(function (r) {
15133
+ return "<tr><td><code class=\"order-id\">" + _htmlEscape(r.code) + "</code></td><td>" + _htmlEscape(String(r.quantity)) + "</td></tr>";
15134
+ }).join("");
15135
+ levelsBlock =
15136
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Levels for " + _htmlEscape(opts.sku) + " — total " + _htmlEscape(String(lv.total)) + "</h3>" +
15137
+ (lv.by_location && lv.by_location.length
15138
+ ? _tableWrap("<table><thead><tr><th scope=\"col\">Location</th><th scope=\"col\">On hand</th></tr></thead><tbody>" + lvRows + "</tbody></table>")
15139
+ : "<p class=\"empty\">No per-location stock recorded for this SKU.</p>") +
15140
+ "</div>";
15141
+ }
15142
+ var lookup =
15143
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Per-location levels</h3>" +
15144
+ "<form method=\"get\" action=\"/admin/inventory/locations\" class=\"form-inline\">" +
15145
+ _setupField("SKU", "sku", opts.sku || "", "text", "Show the per-location breakdown for one SKU.", " maxlength=\"128\"") +
15146
+ "<button class=\"btn btn--ghost btn--sm\" type=\"submit\">Look up</button>" +
15147
+ "</form>" + levelsBlock +
15148
+ "</div>";
15149
+
15150
+ var form =
15151
+ "<div class=\"panel mt\"><h3 class=\"subhead\">Add / update a stock location</h3>" +
15152
+ "<form method=\"post\" action=\"/admin/inventory/locations\">" +
15153
+ _setupField("Code", "code", "", "text", "Stable identifier (alnum + . _ -). Re-using a code updates that location.", " maxlength=\"64\" required") +
15154
+ _setupField("Name", "name", "", "text", "Operator-facing label (e.g. East warehouse).", " maxlength=\"128\" required") +
15155
+ "<label class=\"form-field\"><span>Type</span>" +
15156
+ "<select name=\"type\" required>" +
15157
+ "<option value=\"warehouse\">warehouse</option>" +
15158
+ "<option value=\"retail\">retail</option>" +
15159
+ "<option value=\"dropship\">dropship</option>" +
15160
+ "</select>" +
15161
+ "<small>Drives default routing weight; override with priority.</small>" +
15162
+ "</label>" +
15163
+ _setupField("Priority", "priority", "", "number", "Lower picks first (default 100).", " min=\"0\"") +
15164
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Save location</button></div>" +
15165
+ "</form>" +
15166
+ "</div>";
15167
+
15168
+ var body = "<section><h2>Stock locations</h2>" + saved + notice + table + lookup + form + "</section>";
15169
+ return _renderAdminShell(opts.shop_name, "Stock locations", body, "inventory-locations", opts.nav_available);
15170
+ }
15171
+
15172
+ // Receive inbound stock against a location. Credits both the per-location
15173
+ // detail and the storefront aggregate. Recent receipts (the batched
15174
+ // inventory-receive ledger) render below the form. Reference / supplier
15175
+ // are operator free text — escaped at the sink.
15176
+ function renderAdminInvReceive(opts) {
15177
+ opts = opts || {};
15178
+ var locs = opts.locations || [];
15179
+ var receipts = opts.receipts || [];
15180
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Stock received.</div>" : "";
15181
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15182
+
15183
+ var locOptions = locs.map(function (l) {
15184
+ return "<option value=\"" + _htmlEscape(l.code) + "\">" + _htmlEscape(l.name) + " (" + _htmlEscape(l.code) + ")</option>";
15185
+ }).join("");
15186
+
15187
+ var form = locs.length
15188
+ ? "<div class=\"panel\"><h3 class=\"subhead\">Receive stock</h3>" +
15189
+ "<form method=\"post\" action=\"/admin/inventory/receive\">" +
15190
+ _setupField("SKU", "sku", "", "text", "", " maxlength=\"128\" required") +
15191
+ "<label class=\"form-field\"><span>Location</span><select name=\"location_code\" required>" + locOptions + "</select></label>" +
15192
+ _setupField("Quantity", "quantity", "", "number", "Units received (positive).", " min=\"1\" required") +
15193
+ _setupField("Reason / reference", "reason", "", "text", "Optional — PO number, supplier, note.", " maxlength=\"256\"") +
15194
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Receive</button></div>" +
15195
+ "</form></div>"
15196
+ : "<p class=\"empty\">Add a stock location first, then receive stock against it.</p>";
15197
+
15198
+ var recRows = receipts.map(function (r) {
15199
+ return "<tr>" +
15200
+ "<td>" + _htmlEscape(String(r.reference || "")) + "</td>" +
15201
+ "<td>" + _htmlEscape(String(r.supplier || "")) + "</td>" +
15202
+ "<td><span class=\"status-pill\">" + _htmlEscape(String(r.status)) + "</span></td>" +
15203
+ "<td>" + _htmlEscape(String(r.total_qty)) + "</td>" +
15204
+ "<td>" + _htmlEscape(_fmtDate(r.received_at)) + "</td>" +
15205
+ "</tr>";
15206
+ }).join("");
15207
+ var history = receipts.length
15208
+ ? "<div class=\"panel mt\"><h3 class=\"subhead\">Recent receipts</h3>" +
15209
+ _tableWrap("<table><thead><tr><th scope=\"col\">Reference</th><th scope=\"col\">Supplier</th><th scope=\"col\">Status</th><th scope=\"col\">Qty</th><th scope=\"col\">Received</th></tr></thead><tbody>" + recRows + "</tbody></table>") +
15210
+ "</div>"
15211
+ : "";
15212
+
15213
+ var body = "<section><h2>Receive stock</h2>" + saved + notice + form + history + "</section>";
15214
+ return _renderAdminShell(opts.shop_name, "Receive stock", body, "inventory-receive", opts.nav_available);
15215
+ }
15216
+
15217
+ // Location→location transfer console: the open form + the open-transfer
15218
+ // queue with the FSM action legal from each row's status. Reasons,
15219
+ // carriers, and tracking numbers are operator free text — escaped.
15220
+ function renderAdminInvTransfers(opts) {
15221
+ opts = opts || {};
15222
+ var locs = opts.locations || [];
15223
+ var transfers = opts.transfers || [];
15224
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Transfer updated.</div>" : "";
15225
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15226
+
15227
+ var locOptions = locs.map(function (l) {
15228
+ return "<option value=\"" + _htmlEscape(l.code) + "\">" + _htmlEscape(l.name) + " (" + _htmlEscape(l.code) + ")</option>";
15229
+ }).join("");
15230
+
15231
+ function _transferActions(t) {
15232
+ var blocks = [];
15233
+ var act = function (action, label, extra) {
15234
+ return "<form method=\"post\" action=\"/admin/inventory/transfers/" + encodeURIComponent(t.id) + "/" + action + "\" class=\"form-inline\">" +
15235
+ (extra || "") + "<button class=\"btn btn--sm\" type=\"submit\">" + _htmlEscape(label) + "</button></form>";
15236
+ };
15237
+ if (t.status === "open") blocks.push(act("ship", "Mark shipped"));
15238
+ if (t.status === "shipped" || t.status === "in_transit") {
15239
+ blocks.push(act("receive", "Mark received"));
15240
+ }
15241
+ if (t.status === "received") blocks.push(act("reconcile", "Reconcile"));
15242
+ if (t.status !== "reconciled" && t.status !== "exception") {
15243
+ blocks.push(act("exception", "Exception",
15244
+ "<input type=\"text\" name=\"reason\" placeholder=\"reason\" maxlength=\"280\" required>"));
15245
+ }
15246
+ return blocks.length ? blocks.join("") : "<span class=\"meta\">—</span>";
15247
+ }
15248
+
15249
+ var rows = transfers.map(function (t) {
15250
+ var lineSummary = (t.lines || []).map(function (l) {
15251
+ return _htmlEscape(l.sku) + "×" + _htmlEscape(String(l.quantity_shipped));
15252
+ }).join(", ");
15253
+ return "<tr>" +
15254
+ "<td><code class=\"order-id\">" + _htmlEscape(String(t.id).slice(0, 8)) + "</code></td>" +
15255
+ "<td>" + _htmlEscape(t.from_location) + " → " + _htmlEscape(t.to_location) + "</td>" +
15256
+ "<td>" + lineSummary + "</td>" +
15257
+ "<td><span class=\"status-pill\">" + _htmlEscape(t.status) + "</span></td>" +
15258
+ "<td>" + _transferActions(t) + "</td>" +
15259
+ "</tr>";
15260
+ }).join("");
15261
+
15262
+ var queue = transfers.length
15263
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">ID</th><th scope=\"col\">Route</th><th scope=\"col\">Lines</th><th scope=\"col\">Status</th><th scope=\"col\">Actions</th></tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
15264
+ : "<p class=\"empty\">No open transfers.</p>";
15265
+
15266
+ var form = locs.length >= 2
15267
+ ? "<div class=\"panel mt\"><h3 class=\"subhead\">Open a transfer</h3>" +
15268
+ "<form method=\"post\" action=\"/admin/inventory/transfers\">" +
15269
+ "<label class=\"form-field\"><span>From</span><select name=\"from_location\" required>" + locOptions + "</select></label>" +
15270
+ "<label class=\"form-field\"><span>To</span><select name=\"to_location\" required>" + locOptions + "</select></label>" +
15271
+ _setupField("SKU", "sku", "", "text", "", " maxlength=\"128\" required") +
15272
+ _setupField("Quantity", "quantity", "", "number", "Units to move (leaves the source immediately).", " min=\"1\" required") +
15273
+ _setupField("Reason", "reason", "", "text", "Optional note.", " maxlength=\"280\"") +
15274
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn\">Open transfer</button></div>" +
15275
+ "</form></div>"
15276
+ : "<p class=\"empty\">Define at least two stock locations to transfer between them.</p>";
15277
+
15278
+ var body = "<section><h2>Stock transfers</h2>" + saved + notice + queue + form + "</section>";
15279
+ return _renderAdminShell(opts.shop_name, "Transfers", body, "inventory-transfers", opts.nav_available);
15280
+ }
15281
+
15282
+ // Reason-coded write-off log + record form. SKU, reason, notes, and actor
15283
+ // are operator free text — escaped at the sink.
15284
+ function renderAdminInvWriteoffs(opts) {
15285
+ opts = opts || {};
15286
+ var locs = opts.locations || [];
15287
+ var list = opts.writeoffs || [];
15288
+ var reasons = opts.reasons || [];
15289
+ var saved = opts.saved ? "<div class=\"banner banner--ok\">Write-off recorded.</div>" : "";
15290
+ var notice = opts.notice ? "<div class=\"banner banner--err\">" + _htmlEscape(opts.notice) + "</div>" : "";
15291
+
15292
+ var locOptions = locs.map(function (l) {
15293
+ return "<option value=\"" + _htmlEscape(l.code) + "\">" + _htmlEscape(l.name) + " (" + _htmlEscape(l.code) + ")</option>";
15294
+ }).join("");
15295
+ var reasonOptions = reasons.map(function (r) {
15296
+ return "<option value=\"" + _htmlEscape(r) + "\">" + _htmlEscape(r) + "</option>";
15297
+ }).join("");
15298
+
15299
+ var rows = list.map(function (w) {
15300
+ return "<tr>" +
15301
+ "<td>" + _htmlEscape(String(w.sku)) + "</td>" +
15302
+ "<td>" + (w.location_code ? "<code class=\"order-id\">" + _htmlEscape(String(w.location_code)) + "</code>" : "<span class=\"meta\">—</span>") + "</td>" +
15303
+ "<td>" + _htmlEscape(String(w.quantity)) + "</td>" +
15304
+ "<td>" + _htmlEscape(String(w.reason)) + "</td>" +
15305
+ "<td><span class=\"status-pill\">" + _htmlEscape(String(w.status)) + "</span></td>" +
15306
+ "<td>" + _htmlEscape(_fmtDate(w.occurred_at)) + "</td>" +
15307
+ "</tr>";
15308
+ }).join("");
15309
+ var table = list.length
15310
+ ? "<div class=\"panel\">" + _tableWrap("<table><thead><tr><th scope=\"col\">SKU</th><th scope=\"col\">Location</th><th scope=\"col\">Qty</th><th scope=\"col\">Reason</th><th scope=\"col\">Status</th><th scope=\"col\">When</th></tr></thead><tbody>" + rows + "</tbody></table>") + "</div>"
15311
+ : "<p class=\"empty\">No write-offs recorded.</p>";
15312
+
15313
+ var form = locs.length
15314
+ ? "<div class=\"panel mt\"><h3 class=\"subhead\">Record a write-off</h3>" +
15315
+ "<form method=\"post\" action=\"/admin/inventory/writeoffs\">" +
15316
+ _setupField("SKU", "sku", "", "text", "", " maxlength=\"128\" required") +
15317
+ "<label class=\"form-field\"><span>Location</span><select name=\"location_code\" required>" + locOptions + "</select></label>" +
15318
+ _setupField("Quantity", "quantity", "", "number", "Units removed (positive).", " min=\"1\" required") +
15319
+ "<label class=\"form-field\"><span>Reason</span><select name=\"reason\" required>" + reasonOptions + "</select></label>" +
15320
+ _setupField("Notes", "notes", "", "text", "Optional.", " maxlength=\"4096\"") +
15321
+ "<div class=\"actions-row\"><button type=\"submit\" class=\"btn btn--danger\">Record write-off</button></div>" +
15322
+ "</form></div>"
15323
+ : "<p class=\"empty\">Add a stock location first, then record write-offs against it.</p>";
15324
+
15325
+ var body = "<section><h2>Write-offs</h2>" + saved + notice + table + form + "</section>";
15326
+ return _renderAdminShell(opts.shop_name, "Write-offs", body, "inventory-writeoffs", opts.nav_available);
15327
+ }
15328
+
14690
15329
  // Pickup queue for one location: a location selector + status chips, the
14691
15330
  // scheduled/ready rows, and the FSM action forms (ready / picked-up /
14692
15331
  // no-show) legal from each row's status. The no_show reason is operator-
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.14",
2
+ "version": "0.4.15",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
package/lib/catalog.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * - `variants` — create, get, listForProduct, update, delete
12
12
  * - `prices` — set (versioned), current, history
13
13
  * - `inventory` — create, get, list, hold, decrement, release,
14
- * restock, setThreshold, checkLowStock
14
+ * restock, adjustOnHand, setThreshold, checkLowStock
15
15
  * - `media` — attach, get, listForProduct, listForVariant, delete,
16
16
  * reorder, setPrimary
17
17
  *
@@ -706,6 +706,56 @@ function _inventoryModule(query, opts) {
706
706
  return await this.get(sku);
707
707
  },
708
708
 
709
+ // Signed-delta adjustment of the storefront aggregate, fired by the
710
+ // inventory-ops back-office when a per-location move changes the
711
+ // total a single-bucket storefront sells against (a receive credits
712
+ // the aggregate, a write-off / shrinkage debits it). `restock` only
713
+ // grows on-hand and `decrement` only consumes a matching hold;
714
+ // neither expresses "the warehouse count just dropped by 5 because a
715
+ // pallet was damaged." A positive delta is an unconditional credit.
716
+ // A negative delta is an atomic conditional UPDATE — the
717
+ // `stock_on_hand - stock_held >= need` guard refuses (zero rows →
718
+ // `{ adjusted: false }`) when the debit would eat into stock that is
719
+ // already held for in-flight orders, so a write-off can never
720
+ // oversell a paid-but-unfulfilled line. Returns `{ adjusted, sku,
721
+ // delta }`; `null` when the SKU has no inventory row at all
722
+ // (un-tracked SKUs are unlimited everywhere in the storefront, so an
723
+ // aggregate adjustment simply doesn't apply). Fires the low-stock
724
+ // observer on success so a debit that crosses the threshold alerts.
725
+ adjustOnHand: async function (sku, delta) {
726
+ _sku(sku);
727
+ if (!Number.isInteger(delta) || delta === 0) {
728
+ throw new TypeError("catalog.inventory.adjustOnHand: delta must be a non-zero integer");
729
+ }
730
+ var ts = _now();
731
+ if (delta > 0) {
732
+ var rUp = await query(
733
+ "UPDATE inventory SET stock_on_hand = stock_on_hand + ?1, updated_at = ?2 WHERE sku = ?3",
734
+ [delta, ts, sku],
735
+ );
736
+ if (rUp.rowCount === 0) return null;
737
+ await _afterMutation(sku);
738
+ return { adjusted: true, sku: sku, delta: delta };
739
+ }
740
+ var need = -delta;
741
+ var rDown = await query(
742
+ "UPDATE inventory SET stock_on_hand = stock_on_hand - ?1, updated_at = ?2 " +
743
+ "WHERE sku = ?3 AND (stock_on_hand - stock_held) >= ?1",
744
+ [need, ts, sku],
745
+ );
746
+ if (rDown.rowCount === 1) {
747
+ await _afterMutation(sku);
748
+ return { adjusted: true, sku: sku, delta: delta };
749
+ }
750
+ // Zero rows: either no inventory row (un-tracked SKU) or the debit
751
+ // would eat into held stock. Distinguish so the caller can let an
752
+ // un-tracked SKU through while refusing a tracked-but-insufficient
753
+ // one.
754
+ var existing = await this.get(sku);
755
+ if (!existing) return null;
756
+ return { adjusted: false, sku: sku, delta: delta };
757
+ },
758
+
709
759
  release: async function (sku, qty) {
710
760
  _sku(sku);
711
761
  _positiveInt(qty, "qty");
@@ -32,12 +32,20 @@
32
32
  * duplicate code, invalid type, etc).
33
33
  * listLocations / getLocation / updateLocation / deactivateLocation
34
34
  * — operator CRUD over the location set.
35
- * setStock — absolute set (overwrite).
36
- * adjustStock — relative delta with audit row.
37
- * transferStock atomic two-row write moving qty
38
- * from one location to another; if
39
- * the source doesn't have enough, the
40
- * whole transfer refuses (no money
35
+ * setStock — absolute set (overwrite) via an
36
+ * atomic upsert.
37
+ * adjustStock relative delta with audit row. Debits
38
+ * run as a single conditional UPDATE
39
+ * (`quantity >= need`); credits as an
40
+ * upsert. No read-then-write window — a
41
+ * debit that would drive the row below
42
+ * zero matches no rows and refuses.
43
+ * transferStock — moves qty from one location to
44
+ * another; the source debit is the same
45
+ * conditional UPDATE guard, so a transfer
46
+ * racing a checkout debit (or another
47
+ * transfer) for the last unit can't
48
+ * oversell — the loser refuses (no money
41
49
  * created, no row half-committed).
42
50
  * stockForSku — `{ total, by_location: [...] }`.
43
51
  * totalForSku — sum across every location.
@@ -521,20 +529,21 @@ function create(opts) {
521
529
  throw new TypeError("inventory-locations.setStock: location_code " +
522
530
  JSON.stringify(input.location_code) + " not found");
523
531
  }
532
+ // Absolute set is last-writer-wins by contract — the operator is
533
+ // declaring the authoritative count, not applying a relative
534
+ // change. The prior value is read only to record the signed
535
+ // delta on the audit row; the upsert itself is a single atomic
536
+ // statement so a first-touch insert and an overwrite never
537
+ // collide on the composite PK.
524
538
  var prev = await _getStockRow(input.sku, input.location_code);
525
539
  var prevQty = prev ? prev.quantity : 0;
526
540
  var ts = _now();
527
- if (prev) {
528
- await query(
529
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
530
- [input.quantity, ts, input.sku, input.location_code],
531
- );
532
- } else {
533
- await query(
534
- "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
535
- [input.sku, input.location_code, input.quantity, ts],
536
- );
537
- }
541
+ await query(
542
+ "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) " +
543
+ "VALUES (?1, ?2, ?3, ?4) " +
544
+ "ON CONFLICT(sku, location_code) DO UPDATE SET quantity = ?3, updated_at = ?4",
545
+ [input.sku, input.location_code, input.quantity, ts],
546
+ );
538
547
  var delta = input.quantity - prevQty;
539
548
  await _audit(input.sku, input.location_code, delta, _reason(input.reason));
540
549
  return { sku: input.sku, location_code: input.location_code, quantity: input.quantity, delta: delta };
@@ -545,6 +554,18 @@ function create(opts) {
545
554
  // row below zero refuses the whole operation. Inserts a row
546
555
  // at quantity 0 + delta if the (sku, location_code) pair has
547
556
  // never been touched before (and delta is positive).
557
+ //
558
+ // Concurrency: the negative-delta path is a single atomic
559
+ // conditional UPDATE — `SET quantity = quantity + delta WHERE
560
+ // quantity + delta >= 0` — mirroring the catalog hold/decrement
561
+ // guards. D1 evaluates the predicate and applies the write inside
562
+ // one statement, so two racing debits against the last unit can't
563
+ // both succeed: the second racer's predicate sees the first's
564
+ // decrement and matches zero rows. The positive-delta path is an
565
+ // upsert (ON CONFLICT DO UPDATE) so a credit and a fresh-row
566
+ // insert never collide. Both paths re-read the post-write quantity
567
+ // for the audit row + return value rather than trusting a stale
568
+ // pre-read.
548
569
  adjustStock: async function (input) {
549
570
  if (!input || typeof input !== "object") {
550
571
  throw new TypeError("inventory-locations.adjustStock: input object required");
@@ -560,28 +581,42 @@ function create(opts) {
560
581
  throw new TypeError("inventory-locations.adjustStock: location_code " +
561
582
  JSON.stringify(input.location_code) + " not found");
562
583
  }
563
- var prev = await _getStockRow(input.sku, input.location_code);
564
- var prevQty = prev ? prev.quantity : 0;
565
- var next = prevQty + input.delta;
566
- if (next < 0) {
567
- throw new TypeError("inventory-locations.adjustStock: delta " + input.delta +
568
- " would drive stock below zero (current=" + prevQty + ", sku=" + input.sku +
569
- ", location=" + input.location_code + ")");
570
- }
571
584
  var ts = _now();
572
- if (prev) {
585
+ if (input.delta > 0) {
586
+ // Credit: upsert in one atomic statement. A concurrent credit
587
+ // + first-touch insert serialize on the composite PK.
573
588
  await query(
574
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
575
- [next, ts, input.sku, input.location_code],
589
+ "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) " +
590
+ "VALUES (?1, ?2, ?3, ?4) " +
591
+ "ON CONFLICT(sku, location_code) DO UPDATE SET " +
592
+ "quantity = quantity + ?3, updated_at = ?4",
593
+ [input.sku, input.location_code, input.delta, ts],
576
594
  );
577
595
  } else {
578
- await query(
579
- "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
580
- [input.sku, input.location_code, next, ts],
596
+ // Debit: atomic conditional UPDATE. The WHERE predicate is the
597
+ // serialization point it refuses the write (zero rows) when
598
+ // the shelf lacks enough to cover the debit, so the row never
599
+ // goes negative even under concurrent debits. A SKU with no
600
+ // row at this location can't be debited (no negative
601
+ // first-touch); zero rows there is the insufficient case too.
602
+ var need = -input.delta;
603
+ var r = await query(
604
+ "UPDATE inventory_stock SET quantity = quantity + ?1, updated_at = ?2 " +
605
+ "WHERE sku = ?3 AND location_code = ?4 AND quantity >= ?5",
606
+ [input.delta, ts, input.sku, input.location_code, need],
581
607
  );
608
+ if (r.rowCount === 0) {
609
+ var cur = await _getStockRow(input.sku, input.location_code);
610
+ var have = cur ? cur.quantity : 0;
611
+ throw new TypeError("inventory-locations.adjustStock: delta " + input.delta +
612
+ " would drive stock below zero (current=" + have + ", sku=" + input.sku +
613
+ ", location=" + input.location_code + ")");
614
+ }
582
615
  }
616
+ var after = await _getStockRow(input.sku, input.location_code);
617
+ var nextQty = after ? after.quantity : 0;
583
618
  await _audit(input.sku, input.location_code, input.delta, _reason(input.reason));
584
- return { sku: input.sku, location_code: input.location_code, quantity: next, delta: input.delta };
619
+ return { sku: input.sku, location_code: input.location_code, quantity: nextQty, delta: input.delta };
585
620
  },
586
621
 
587
622
  // Atomic two-row move. Reads the source quantity; if
@@ -612,44 +647,44 @@ function create(opts) {
612
647
  JSON.stringify(input.to_location) + " not found");
613
648
  }
614
649
 
615
- var fromRow = await _getStockRow(input.sku, input.from_location);
616
- var fromQty = fromRow ? fromRow.quantity : 0;
617
- if (fromQty < input.quantity) {
618
- throw new TypeError("inventory-locations.transferStock: insufficient stock at " +
619
- input.from_location + " (have " + fromQty + ", need " + input.quantity + ")");
620
- }
621
- var toRow = await _getStockRow(input.sku, input.to_location);
622
- var toQty = toRow ? toRow.quantity : 0;
623
-
624
650
  var ts = _now();
625
- // Decrement source first. If the destination upsert below
626
- // fails for any reason, the rollback path re-increments the
627
- // source so the invariant (total quantity unchanged) holds.
628
- await query(
629
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
630
- [fromQty - input.quantity, ts, input.sku, input.from_location],
651
+ // Debit the source with a single atomic conditional UPDATE. The
652
+ // `quantity >= need` predicate is the serialization point: two
653
+ // racing transfers (or a transfer racing a checkout debit)
654
+ // against the last unit can't both succeed — the second sees the
655
+ // first's decrement and matches zero rows, which we surface as
656
+ // the insufficient-stock refusal. No read-then-write window.
657
+ var srcDebit = await query(
658
+ "UPDATE inventory_stock SET quantity = quantity - ?1, updated_at = ?2 " +
659
+ "WHERE sku = ?3 AND location_code = ?4 AND quantity >= ?1",
660
+ [input.quantity, ts, input.sku, input.from_location],
631
661
  );
662
+ if (srcDebit.rowCount === 0) {
663
+ var fromRow = await _getStockRow(input.sku, input.from_location);
664
+ var fromHave = fromRow ? fromRow.quantity : 0;
665
+ throw new TypeError("inventory-locations.transferStock: insufficient stock at " +
666
+ input.from_location + " (have " + fromHave + ", need " + input.quantity + ")");
667
+ }
632
668
  try {
633
- if (toRow) {
634
- await query(
635
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
636
- [toQty + input.quantity, ts, input.sku, input.to_location],
637
- );
638
- } else {
639
- await query(
640
- "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) VALUES (?1, ?2, ?3, ?4)",
641
- [input.sku, input.to_location, input.quantity, ts],
642
- );
643
- }
669
+ // Credit the destination via an atomic upsert. A concurrent
670
+ // credit + first-touch insert serialize on the composite PK.
671
+ await query(
672
+ "INSERT INTO inventory_stock (sku, location_code, quantity, updated_at) " +
673
+ "VALUES (?1, ?2, ?3, ?4) " +
674
+ "ON CONFLICT(sku, location_code) DO UPDATE SET " +
675
+ "quantity = quantity + ?3, updated_at = ?4",
676
+ [input.sku, input.to_location, input.quantity, ts],
677
+ );
644
678
  } catch (e) {
645
- // Best-effort compensating restore on the source so the
646
- // pair stays balanced. If THIS write also fails the
647
- // operator will see two adjacent audit rows with no
648
- // counterpart the caller can replay or reconcile.
679
+ // Best-effort compensating restore on the source so the pair
680
+ // stays balanced. The restore is itself an atomic increment
681
+ // (no read-then-write), so it can't clobber a concurrent
682
+ // mutation that landed between the debit and this catch.
649
683
  try {
650
684
  await query(
651
- "UPDATE inventory_stock SET quantity = ?1, updated_at = ?2 WHERE sku = ?3 AND location_code = ?4",
652
- [fromQty, _now(), input.sku, input.from_location],
685
+ "UPDATE inventory_stock SET quantity = quantity + ?1, updated_at = ?2 " +
686
+ "WHERE sku = ?3 AND location_code = ?4",
687
+ [input.quantity, _now(), input.sku, input.from_location],
653
688
  );
654
689
  } catch (_e2) { /* drop-silent — the original error is what the caller needs to fix */ }
655
690
  throw e;
@@ -659,13 +694,15 @@ function create(opts) {
659
694
  await _audit(input.sku, input.from_location, -input.quantity, reason);
660
695
  await _audit(input.sku, input.to_location, input.quantity, reason);
661
696
 
697
+ var fromAfterRow = await _getStockRow(input.sku, input.from_location);
698
+ var toAfterRow = await _getStockRow(input.sku, input.to_location);
662
699
  return {
663
700
  sku: input.sku,
664
701
  from_location: input.from_location,
665
702
  to_location: input.to_location,
666
703
  quantity: input.quantity,
667
- from_after: fromQty - input.quantity,
668
- to_after: toQty + input.quantity,
704
+ from_after: fromAfterRow ? fromAfterRow.quantity : 0,
705
+ to_after: toAfterRow ? toAfterRow.quantity : 0,
669
706
  };
670
707
  },
671
708
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
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": {