@blamejs/blamejs-shop 0.4.28 → 0.4.29

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.29 (2026-06-11) — **A gift card can no longer pay for two orders at once — credits debit before any charge — and store-credit wallets, capped discounts, and the gift-card audit chain all hold under concurrency.** A money-integrity release closing five concurrency windows, each reproduced before fixing. The serious one: gift-card and loyalty credits were debited after the order existed, with failures captured for reconciliation — so two simultaneous checkouts presenting the same gift card both produced paid orders while the card was only debited once. Credits now debit before any charge: the database balance gate decides the race, the loser gets a clean re-quote, and a checkout that fails after the debit but before the order exists reverses the debit automatically. Store-credit wallets stop computing balances from a stale read — concurrent debits can no longer overdraw, and two grants landing in the same millisecond both count. Capped automatic discounts are reserved atomically before charging, so a last-redemption race refuses one buyer with a clear message instead of granting both. Every gift-card ledger entry — debits included — now participates in the per-card tamper-evidence hash chain, a uniqueness fence keeps concurrent writes from forking it, and a new verifyChain call recomputes a card's chain on demand. The payment idempotency cache absorbs same-key races instead of failing one of them. Upgrade applies two D1 migrations. **Fixed:** *Store-credit wallets hold under concurrent writes* — Wallet writes computed the new balance from a separately-read snapshot, so two concurrent debits could both fulfill against one balance — overdrawing the wallet — and two grants landing in the same millisecond could tie on their timestamp and silently drop one. Every wallet write now computes the live balance and a strictly-monotonic per-customer timestamp inside a single guarded insert: a debit that loses the race is refused as insufficient, both same-instant grants land and sum, and the scheduled expiry sweep keeps its degrade-gracefully cap. · *Capped automatic discounts are reserved before charging* — A rule's redemption caps were only read at quote time and counted after the order existed, so a single-use discount applied to every order that raced the last redemption. The applied rules are now claimed atomically before any charge — total cap and per-customer cap both enforced inside single guarded statements — and a refused claim fails the checkout closed with a clear message and re-quote, never a silently different price. A checkout that fails before its order exists releases its reservations, recording a redemption is idempotent per order, and a retried checkout reuses its own claim instead of double-reserving. · *Same-key payment calls absorb their race* — Two concurrent calls carrying the same idempotency key could both miss the replay cache and collide on its primary key, failing one of them with a constraint error. The cache claim is now conflict-aware: one call stores its response, the other defers to it and replays — and a same-key call carrying a different request body is still refused as a collision, racing or not. **Security:** *Gift-card and loyalty credits debit before any charge* — A checkout's gift-card and loyalty debits are now the first money movement, ahead of the payment intent and the order row, on the card-payment and PayPal paths alike. The database balance predicate is the cross-checkout double-spend gate: two carts presenting the same card race it directly, exactly one wins, and the loser's checkout rolls back cleanly — stock holds released, cart reusable, a clear message and re-quote, nothing charged. A checkout that dies between the debit and order creation reverses the debit (claim-guarded, exactly once). Once the order exists the debit is attached to it, so refunds and cancellations keep reversing credit proportionally exactly as before. · *Every gift-card ledger entry is chained, and the chain can't fork* — Debit rows — previously written outside the hash chain by the atomic overdraft guard — now carry the same parent and row hashes as credits and expirations, with the overdraft gate still enforced inside the insert. A per-card uniqueness fence (one child per chain tip) makes concurrent writes serialize instead of forking the chain or basing a balance on a stale snapshot; a writer that loses the race re-reads the tip and retries. A new verifyChain call recomputes a card's chain end to end and reports the first divergence, tolerating rows that predate the chain columns as a counted, unverifiable prefix.
12
+
11
13
  - v0.4.28 (2026-06-11) — **Console refunds reach PayPal, refund webhooks apply their stated amount instead of reversing everything, and gift cards and loyalty points now ride the PayPal button.** A payment-lifecycle release for stores taking PayPal. Refunding a PayPal-paid order from the console — full, partial, or through the returns flow — now dials PayPal; previously every console refund dialed the card processor with a PayPal id and failed, leaving the PayPal dashboard as the only way to refund. Refund webhooks from both processors now apply the amount they state: a partial refund issued from the processor's dashboard reverses gift-card and loyalty credit proportionally, where before it triggered the full terminal reversal and could hand a customer the entire credited value back for a five-dollar refund. PayPal webhook deliveries are now claimed in a replay store after signature verification, the verification call runs on its own circuit breaker behind a per-IP budget so a forged-delivery flood can't fast-fail live checkouts, and a buyer paying with the PayPal button can now apply a gift card or spend loyalty points like any other checkout. A deployment with PayPal credentials but no webhook id gets a boot warning naming the missing variable. Upgrade applies two D1 migrations. **Fixed:** *Console refunds route by the order's payment provider* — Orders persist which processor took the payment, and every refund surface — the order console's full and partial refund, the returns console's provider refund, and the refund-automation library — routes to that processor. PayPal refunds dial the capture (recovered from the order record, the payment transition's metadata, or the PayPal API, in that order), and the operator's idempotency key flows through as the PayPal request id so a retried partial refund deduplicates while distinct partial refunds execute distinctly. Orders that predate the provider column fall back to the payment-id shape. The refund button now reflects provider reality: it offers a refund only when the processor that took the payment is configured, and refuses with a specific reason — provider not configured, no capture on record — instead of a generic failure. · *Refund webhooks apply their stated amount* — A refund event arriving from the processor now reads the refunded amount instead of unconditionally driving the order to the terminal refunded state. A partial refund reverses gift-card and loyalty credit proportionally through the same accounting the console's partial refund uses; only a balance-clearing refund transitions the order to refunded. Both processors are covered: PayPal events carry a per-refund amount and deduplicate on the refund id, card-processor events carry a cumulative total and apply the delta against the local ledger. An event with a missing or unparseable amount is refused so the processor redelivers it — the handler never guesses a full refund. · *Gift cards and loyalty points apply to PayPal button payments* — The PayPal button now sends the gift-card code and loyalty-points fields from the pay form, matching card checkout. When a gift card covers the whole total, the page completes the order and redirects without opening the PayPal popup — the previous behavior surfaced a payment error to the buyer after the order had already been created, paid, and the card debited. **Security:** *PayPal webhook deliveries are claimed once and verified in isolation* — Each verified PayPal event id is claimed in a replay store before any state transition — matching the card-processor webhook discipline — so a replayed or re-delivered event is absorbed exactly once across the redelivery window. The signature-verification call to PayPal runs on its own circuit breaker, and the webhook path carries a per-IP request budget, so a flood of forged deliveries can neither trip the breaker that live checkout dials ride nor crowd out legitimate redeliveries. · *Boot warning when the PayPal webhook id is missing* — A deployment with PayPal client credentials but no PAYPAL_WEBHOOK_ID logs a warning at boot naming the variable. Verification itself remains mandatory and fails closed — without the id every webhook delivery is refused, which keeps forged events out but also means dashboard-issued refunds never mirror locally and PayPal will eventually disable the webhook endpoint; the warning makes that state visible instead of silent.
12
14
 
13
15
  - v0.4.27 (2026-06-11) — **Stale quotes expire on schedule, operators can reprice an open quote or convert a verbally-approved one to an order, and customers see the operator's per-line notes and the validity window.** A quote-lifecycle release. Quotes whose validity window has elapsed now transition to expired on a scheduled sweep instead of lingering as open work — the accept-time guard already refused stale prices; now the console's queue and the customer's account list reflect that reality, and the admin list gains an expired filter. Operators can reprice a quote that is awaiting the customer's answer (the customer's existing link immediately shows the new pricing) and can convert a quote the customer approved outside the site — by phone or email — directly to an order, with a required reason recorded in the audit log. The customer-facing quote view now renders the per-line notes the operator wrote alongside each price and shows the validity date in the account list. Upgrade applies one D1 migration. **Added:** *Quotes past their validity window expire on a scheduled sweep* — A scheduled tick transitions responded quotes whose valid-until date has elapsed to expired, in one bounded pass per fire. The transition is race-safe: each row re-checks its status and validity inside a conditional update, so a customer accepting at the same moment wins, a reprice that extends validity rescues the quote, and overlapping ticks never double-transition. The sweep rides the same shared-secret internal endpoint discipline as the other scheduled tasks — secret-checked at the edge and again in the container. The per-pass batch size is tunable with QUOTE_EXPIRY_BATCH (validated at boot). · *Operators can reprice a quote awaiting the customer's answer* — A responded quote that the customer has not yet accepted can be repriced from the console — new per-line pricing, shipping, tax, and validity window through the same validation as the original response, with a version counter recording each revision. The customer's existing quote link keeps working and renders the new pricing; no new email is sent, so the link the customer already holds stays valid. Repricing a quote in any other state is refused with a conflict. · *Operators can convert a verbally-approved quote to an order* — When a customer approves a quote outside the site — by phone or email — the operator can convert it to an order directly from the console. The action requires a written reason, records an audit row with the operator, the reason, and the minted order id, and runs through the same conversion path customer acceptance uses: inventory holds are placed first and released if order creation fails, and the accept-time expiry guard still refuses a quote past its validity window. The action requires order-write permission. **Changed:** *The customer quote view shows per-line notes and the validity window* — Notes the operator writes against individual quote lines now render on the customer's quote page under the line they describe, and the account quote list shows the validity date for open quotes — so the customer sees the full offer, not just the numbers, and knows how long it stands. · *The admin quote list gains an expired filter* — The console's quote list accepts a status filter and ships an Expired view, so quotes the sweep transitioned are reviewable rather than invisible. An unrecognized status value falls back to the default queue.
package/README.md CHANGED
@@ -98,7 +98,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
98
98
  | **`lib/translations.js`** | Storefront localization. The UI chrome (nav, search, newsletter band, footer) renders in the visitor's locale, resolved identically at the edge and container: `?lang=`, then a first-party cookie, then `Accept-Language`, then the operator default. A footer locale switcher (languages shown by their autonyms) persists the choice and 303s back; `GET /locale` sets the cookie. Right-to-left languages set the document `dir`. Strings layer the operator's translation rows over a built-in English baseline — a missing key falls back to English, never a raw placeholder. Enable it by seeding a locale policy (default + supported locales) via `localeRouter`; with none seeded the storefront renders the English baseline and shows no switcher. `SHOP_DEFAULT_LOCALE` sets the edge default and `SHOP_LOCALES` (the supported list) lets the edge forward an `Accept-Language`-preferred non-default locale to the container instead of caching the default; an explicit cookie/`?lang=` choice is always container-served. Server-rendered (works with JS off), byte-identical edge/container. |
99
99
  | **`lib/subscriptions.js`** | Stripe-backed recurring billing — `subscription_plans` (interval / amount / trial) + `subscriptions` (mirrors Stripe's object byte-for-byte). `subscriptions.create` POSTs to Stripe via the payment dep, then persists the returned object locally. `handleStripeEvent` replays `customer.subscription.*` events into the local row so the shop has an authoritative view without round-tripping. Customers view + cancel their own subscriptions at `/account/subscriptions` (ownership-checked; cancel mounts when the payment handle is wired). |
100
100
  | **`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). |
101
- | **`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
+ | **`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. Every row of every kind participates in a per-card SHA3-512 hash chain whose parent fence (one child per chain tip, enforced by a unique index) makes concurrent writes serialize instead of forking; `verifyChain(id)` recomputes a card's chain and reports the first divergence. The audit trail behind the admin gift-card ledger console; overdraft is refused at the primitive layer, inside the same guarded insert. |
102
102
  | **`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. |
103
103
  | **`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. **Operators** (`/admin/operators`) is the staff-account console — create operators with their own credential (Argon2id password and/or a per-operator API key shown once) and a least-privilege role (owner / manager / viewer), enforced at the single admin write chokepoint on every POST/PUT/DELETE rather than by hiding menu items; disable takes effect on the operator's next request, `ADMIN_API_KEY` stays the bootstrap / break-glass owner credential so an upgrade can never lock the store out, and every operator-management action plus every role-denied attempt is audited. **Email campaigns** (`/admin/campaigns`) is the consent-gated broadcast console — author a campaign (escape-by-default Markdown body), target a mailing audience, preview, test-send to an operator-supplied address, and send to the recipients who are actually reachable: the recipient set resolves at send time from the newsletter list (the only place a deliverable address exists — customer accounts keep only an email hash), every recipient is re-checked against the unsubscribe flag and the marketing suppression list at the send moment, every message carries RFC 8058 one-click unsubscribe headers plus an in-body link, and a per-recipient send ledger makes a resumed broadcast never re-mail. Sending drains in rate-bounded batches on the scheduled tick; per-campaign delivered / failed / skipped counts show on the detail screen. **Quotes** (`/admin/quotes`) is the RFQ response queue — open a request's lines and customer message, respond with per-line pricing and a validity window, or withdraw a responded quote; an accepted quote converts to an order through the storefront's normal checkout path, holds included. **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, Write-offs, Quotes, Email campaigns, and Operators 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. |
104
104
  | **`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. |
package/SECURITY.md CHANGED
@@ -311,6 +311,15 @@ node -e "
311
311
  (success / failure / denied) and paginated. Opening the log is itself
312
312
  recorded (an `audit.read` row), so reviewing the audit trail leaves
313
313
  its own forensic mark.
314
+ - **The gift-card ledger is hash-chained, fork-proof, and verifiable.**
315
+ Every ledger entry — credits, debits, and expirations alike — links to
316
+ its predecessor through a per-card SHA3-512 chain, so a direct edit or
317
+ deletion of a row breaks the linkage. A uniqueness fence (one child per
318
+ chain tip) makes concurrent writes serialize rather than fork the
319
+ chain, and `giftCardLedger.verifyChain(cardId)` recomputes a card's
320
+ chain end to end and reports the first divergence — run it whenever a
321
+ card's balance is disputed. The overdraft refusal stays inside the same
322
+ guarded insert, so the integrity device never weakens the balance gate.
314
323
  - **Privacy exports hold the whole record; erasure states a basis per
315
324
  domain.** A subject-access export walks every table that keys a row
316
325
  by the customer — identity, orders, subscriptions, addresses, saved
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.28",
2
+ "version": "0.4.29",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -1159,16 +1159,23 @@ function create(opts) {
1159
1159
  throw new TypeError("autoDiscount.recordApplication: rule_slug " + JSON.stringify(slug) + " not found");
1160
1160
  }
1161
1161
 
1162
+ // Idempotent per (rule, order): the unique index (migration 0231)
1163
+ // makes a re-delivered record a no-op, and the redemption counter
1164
+ // only advances when this call actually created the row — a retry
1165
+ // can never double-count a cap.
1162
1166
  var id = b.uuid.v7();
1163
- await query(
1167
+ var ins = await query(
1164
1168
  "INSERT INTO auto_discount_applications (id, rule_slug, order_id, customer_id, savings_minor, applied_at) " +
1165
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1169
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6) " +
1170
+ "ON CONFLICT(rule_slug, order_id) DO NOTHING",
1166
1171
  [id, slug, orderId, customerId, savings, appliedAt],
1167
1172
  );
1168
- await query(
1169
- "UPDATE auto_discount_rules SET redemptions_used = redemptions_used + 1, updated_at = ?1 WHERE slug = ?2",
1170
- [_now(), slug],
1171
- );
1173
+ if (Number(ins.rowCount || 0) === 1) {
1174
+ await query(
1175
+ "UPDATE auto_discount_rules SET redemptions_used = redemptions_used + 1, updated_at = ?1 WHERE slug = ?2",
1176
+ [_now(), slug],
1177
+ );
1178
+ }
1172
1179
  return {
1173
1180
  id: id,
1174
1181
  rule_slug: slug,
@@ -1179,6 +1186,167 @@ function create(opts) {
1179
1186
  };
1180
1187
  }
1181
1188
 
1189
+ // ---- pre-charge redemption claims -----------------------------------
1190
+ //
1191
+ // A capped rule is a finite resource, so checkout RESERVES it before any
1192
+ // money moves — the same reserve-then-settle discipline inventory holds
1193
+ // follow — instead of best-effort recording after the fact (which let a
1194
+ // single-use code apply to every concurrent order: the cap was only ever
1195
+ // read at quote time and incremented unconditionally post-commit).
1196
+ //
1197
+ // claimRedemption — atomically reserve one redemption under BOTH caps,
1198
+ // writing the application row up front keyed by a
1199
+ // caller-supplied claim ref (the order doesn't exist
1200
+ // yet). Fails CLOSED: { claimed: false, reason }.
1201
+ // linkClaimToOrder — re-key the claim row to the real order id once the
1202
+ // order exists.
1203
+ // releaseClaim — compensation for a checkout that died before its
1204
+ // order existed: delete the claim row and return the
1205
+ // reservation to the counter (floored at 0).
1206
+ //
1207
+ // The per-customer cap is enforced INSIDE the claim INSERT (a correlated
1208
+ // COUNT in the WHERE), and the total cap inside a guarded UPDATE — both
1209
+ // single statements, so concurrent claims serialize at the database and
1210
+ // the loser is refused rather than both passing a stale read. A re-claim
1211
+ // with the SAME (rule, claim_ref) — a checkout retry whose earlier
1212
+ // rollback didn't complete — reuses the existing claim instead of
1213
+ // double-reserving.
1214
+
1215
+ async function claimRedemption(input) {
1216
+ if (!input || typeof input !== "object") {
1217
+ throw new TypeError("autoDiscount.claimRedemption: input object required");
1218
+ }
1219
+ var slug = _slug(input.rule_slug);
1220
+ var claimRef = input.claim_ref;
1221
+ if (typeof claimRef !== "string" || !claimRef.length || claimRef.length > 128) {
1222
+ throw new TypeError("autoDiscount.claimRedemption: claim_ref must be a non-empty string (<= 128 chars)");
1223
+ }
1224
+ var savings = input.savings_minor == null ? 0 : input.savings_minor;
1225
+ _nonNegInt(savings, "savings_minor");
1226
+ var customerId = input.customer_id == null ? null : input.customer_id;
1227
+ if (customerId != null && (typeof customerId !== "string" || !customerId.length)) {
1228
+ throw new TypeError("autoDiscount.claimRedemption: customer_id must be a non-empty string when provided");
1229
+ }
1230
+ var rule = await getRule(slug);
1231
+ if (!rule) {
1232
+ throw new TypeError("autoDiscount.claimRedemption: rule_slug " + JSON.stringify(slug) + " not found");
1233
+ }
1234
+ var ts = _now();
1235
+
1236
+ // A claim this checkout already holds (a retry whose earlier rollback
1237
+ // didn't complete) is reused, not re-reserved — its counter increment
1238
+ // was already paid and its row already passed the caps.
1239
+ var existing = (await query(
1240
+ "SELECT id FROM auto_discount_applications WHERE rule_slug = ?1 AND order_id = ?2",
1241
+ [slug, claimRef],
1242
+ )).rows[0];
1243
+ if (existing) {
1244
+ return { claimed: true, id: existing.id, reused: true };
1245
+ }
1246
+
1247
+ // Reserve against the TOTAL cap first — one guarded atomic increment;
1248
+ // a refused update means the cap is exhausted (or was won by a
1249
+ // concurrent claim, which is the same answer).
1250
+ var counter = await query(
1251
+ "UPDATE auto_discount_rules SET redemptions_used = redemptions_used + 1, updated_at = ?1 " +
1252
+ "WHERE slug = ?2 AND (max_redemptions_total IS NULL OR redemptions_used < max_redemptions_total)",
1253
+ [ts, slug],
1254
+ );
1255
+ if (Number(counter.rowCount || 0) === 0) {
1256
+ return { claimed: false, reason: "total-cap" };
1257
+ }
1258
+
1259
+ // Write the claim row gated on the PER-CUSTOMER cap — the COUNT runs
1260
+ // inside the INSERT's WHERE, so two concurrent claims for the same
1261
+ // customer serialize at the database and the loser is refused. A guest
1262
+ // claim (no customer id) has no per-customer bound to enforce.
1263
+ var id = b.uuid.v7();
1264
+ var ins;
1265
+ try {
1266
+ ins = await query(
1267
+ "INSERT INTO auto_discount_applications (id, rule_slug, order_id, customer_id, savings_minor, applied_at) " +
1268
+ "SELECT ?1, ?2, ?3, ?4, ?5, ?6 " +
1269
+ "WHERE ?4 IS NULL OR ?7 IS NULL OR " +
1270
+ "(SELECT COUNT(*) FROM auto_discount_applications WHERE rule_slug = ?2 AND customer_id = ?4) < ?7",
1271
+ [id, slug, claimRef, customerId, savings, ts, rule.max_redemptions_per_customer],
1272
+ );
1273
+ } catch (e) {
1274
+ // UNIQUE(rule_slug, order_id) collision: this checkout already holds
1275
+ // a claim from a prior attempt whose rollback didn't complete —
1276
+ // reuse it (its counter reservation was already paid), after
1277
+ // returning the increment this call just took.
1278
+ if (/UNIQUE constraint failed/i.test((e && e.message) || "")) {
1279
+ await query(
1280
+ "UPDATE auto_discount_rules SET redemptions_used = " +
1281
+ "CASE WHEN redemptions_used > 0 THEN redemptions_used - 1 ELSE 0 END, updated_at = ?1 WHERE slug = ?2",
1282
+ [_now(), slug],
1283
+ );
1284
+ var held = (await query(
1285
+ "SELECT id FROM auto_discount_applications WHERE rule_slug = ?1 AND order_id = ?2",
1286
+ [slug, claimRef],
1287
+ )).rows[0];
1288
+ return { claimed: true, id: held ? held.id : null, reused: true };
1289
+ }
1290
+ throw e;
1291
+ }
1292
+ if (Number(ins.rowCount || 0) === 0) {
1293
+ // Per-customer cap refused — return the total-cap reservation.
1294
+ await query(
1295
+ "UPDATE auto_discount_rules SET redemptions_used = " +
1296
+ "CASE WHEN redemptions_used > 0 THEN redemptions_used - 1 ELSE 0 END, updated_at = ?1 WHERE slug = ?2",
1297
+ [_now(), slug],
1298
+ );
1299
+ return { claimed: false, reason: "customer-cap" };
1300
+ }
1301
+ return { claimed: true, id: id };
1302
+ }
1303
+
1304
+ async function linkClaimToOrder(input) {
1305
+ if (!input || typeof input !== "object") {
1306
+ throw new TypeError("autoDiscount.linkClaimToOrder: input object required");
1307
+ }
1308
+ var slug = _slug(input.rule_slug);
1309
+ var claimRef = input.claim_ref;
1310
+ var orderId = input.order_id;
1311
+ if (typeof claimRef !== "string" || !claimRef.length) {
1312
+ throw new TypeError("autoDiscount.linkClaimToOrder: claim_ref must be a non-empty string");
1313
+ }
1314
+ if (typeof orderId !== "string" || !orderId.length || orderId.length > 128) {
1315
+ throw new TypeError("autoDiscount.linkClaimToOrder: order_id must be a non-empty string (<= 128 chars)");
1316
+ }
1317
+ var res = await query(
1318
+ "UPDATE auto_discount_applications SET order_id = ?1 WHERE rule_slug = ?2 AND order_id = ?3",
1319
+ [orderId, slug, claimRef],
1320
+ );
1321
+ return Number(res.rowCount || 0) === 1;
1322
+ }
1323
+
1324
+ async function releaseClaim(input) {
1325
+ if (!input || typeof input !== "object") {
1326
+ throw new TypeError("autoDiscount.releaseClaim: input object required");
1327
+ }
1328
+ var slug = _slug(input.rule_slug);
1329
+ var claimRef = input.claim_ref;
1330
+ if (typeof claimRef !== "string" || !claimRef.length) {
1331
+ throw new TypeError("autoDiscount.releaseClaim: claim_ref must be a non-empty string");
1332
+ }
1333
+ // Delete-then-decrement, each guarded: the delete's rowCount is the
1334
+ // claim's existence check (a double release finds no row and stops),
1335
+ // and the decrement floors at zero so a release can never drive the
1336
+ // counter negative.
1337
+ var del = await query(
1338
+ "DELETE FROM auto_discount_applications WHERE rule_slug = ?1 AND order_id = ?2",
1339
+ [slug, claimRef],
1340
+ );
1341
+ if (Number(del.rowCount || 0) === 0) return false;
1342
+ await query(
1343
+ "UPDATE auto_discount_rules SET redemptions_used = " +
1344
+ "CASE WHEN redemptions_used > 0 THEN redemptions_used - 1 ELSE 0 END, updated_at = ?1 WHERE slug = ?2",
1345
+ [_now(), slug],
1346
+ );
1347
+ return true;
1348
+ }
1349
+
1182
1350
  // ---- metricsForRule ------------------------------------------------
1183
1351
 
1184
1352
  async function metricsForRule(input) {
@@ -1222,6 +1390,9 @@ function create(opts) {
1222
1390
  ruleForCode: ruleForCode,
1223
1391
  evaluate: evaluate,
1224
1392
  recordApplication: recordApplication,
1393
+ claimRedemption: claimRedemption,
1394
+ linkClaimToOrder: linkClaimToOrder,
1395
+ releaseClaim: releaseClaim,
1225
1396
  metricsForRule: metricsForRule,
1226
1397
  };
1227
1398
  }