@blamejs/blamejs-shop 0.4.18 → 0.4.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.20 (2026-06-06) — **Payment integrity: refunds, gift-card balances, and checkout submission are race-proof, and abandoned checkouts return gift-card funds.** A money-path hardening release. Concurrent refund clicks on the same return can no longer reach the payment provider twice. A gift card that part-paid a checkout gets its balance back when the order is cancelled, fails payment, or is reaped as stale. Double-submitting checkout can no longer create two charges and two orders. Return requests are refused server-side on orders that aren't in a returnable state, the refund confirmation screen shows the currency the provider will actually refund, and a buy-online-pickup-in-store order now reaches its delivered state when picked up. **Fixed:** *A return can only be refunded once, even under concurrent requests* — Two simultaneous refund requests for the same approved return could both pass the status check and both reach the payment provider, each with a fresh idempotency key — a double refund. The refund now claims the return atomically before the provider is called (the second request answers 409), the provider call uses a key derived from the return itself so even a retry collapses into the same refund, and a provider failure releases the claim so the refund can be retried. · *Gift-card funds come back when a part-paid checkout dies* — When a gift card partially covered a checkout and the order was then cancelled, failed payment, or sat abandoned until reaped, the card's debit was never returned — the balance was permanently gone. Redemptions are now reversed on those order transitions: the card balance is restored (a fully drained card is reactivated), the reversal is idempotent, and it rides the same order lifecycle that releases inventory holds. · *Double-submitting checkout creates one charge and one order* — Two concurrent checkout submissions could both pass the cart-status read and each create a payment intent and an order. The cart is now claimed atomically as the single gate — the second submission is redirected back to the cart — and the payment-provider idempotency key is derived from the cart, so even a duplicate that slipped through would collapse into one charge on the provider's side. A failure before the order exists releases the claim so the customer can retry. · *Return requests are validated server-side* — The return-request form and its submission are now refused on orders that are not in a returnable state — a refunded, cancelled, or unpaid order answers with a clear message instead of opening a return that could feed a second refund downstream. Previously only the button's visibility enforced this. · *A failed gift-card settlement no longer strands a completed payment* — If recording a gift card's redemption failed after the payment had already succeeded and the order existed, the checkout aborted mid-way — leaving a paid order with a cart that never converted. The settlement step now completes the order regardless and surfaces the failure for follow-up instead of stranding the purchase. · *The refund confirmation shows the currency the provider refunds* — The confirmation screen displayed the return's approved currency, but the provider refunds in the original charge's currency. The screen now reads the same source the refund uses. · *Pickup orders reach their delivered state* — Marking a pickup order as picked up drove an order transition that was only legal for shipped orders, and the failure was silently swallowed — the pickup schedule said picked up while the order stayed in its paid state. The order lifecycle now has a pickup-completion transition, so a picked-up order genuinely lands in delivered and downstream reporting sees it.
12
+
13
+ - v0.4.19 (2026-06-06) — **Wishlist alerts fire once per event instead of repeating daily, stale digests catch up with a single email, and webhook delivery stops throttling itself.** A set of notification and delivery fixes. Back-in-stock and price-drop wishlist alerts now fire only when something actually changes — a steadily in-stock item no longer re-alerts every day. A wishlist digest whose schedule fell behind sends one catch-up email instead of one per minute until current. Outbound webhook delivery no longer counts its own rate-limit refusals against the limit (which kept the throttle tripped once hit), retrying an exhausted delivery no longer duplicates its dead-letter record, and the blog RSS feed renders correctly when a post title or body contains dollar-sign sequences. **Fixed:** *Back-in-stock and price-drop alerts fire on the change, not the state* — The wishlist alert sweep evaluated the current state of each item, so anything in stock re-alerted every wishlister on every sweep — the daily dedupe window just made the flood daily. Alerts now track the last-observed state per customer and item: back-in-stock fires only on an out-of-stock-to-in-stock transition, price-drop only when the price falls below the level already alerted, and a recovered price re-arms the alert. A steadily in-stock item never re-fires. The weekly cap and dedupe are also enforced atomically, so two overlapping sweeps can't double-send past the cap. · *A stale digest enrollment catches up with one email* — A wishlist digest whose next-send time had fallen far behind advanced one period per scheduler tick and sent an email each time — a subscriber could receive dozens of digests in an hour while the schedule caught up. Catching up now snaps the schedule to the present and sends at most one digest. · *Webhook delivery rate limiting no longer feeds on its own refusals* — The outbound webhook rate-limit gate counted its own refusal records toward the limit, so once an endpoint tripped the throttle it could stay tripped indefinitely, and every suppressed attempt persisted another row. Refusals are no longer counted by the gate and collapse to a single record per endpoint per window. · *Retrying an exhausted webhook delivery is idempotent* — Manually retrying a delivery that had already exhausted its attempts inserted a fresh dead-letter record on every click. Retry of an exhausted or already-delivered delivery now answers as a clean no-op, and the dead-letter table enforces one record per delivery. · *The RSS feed renders posts containing dollar-sign sequences* — The feed assembled items with a plain string replacement, which gives `$` sequences special meaning — a post title or body containing a dollar sign followed by a backtick or ampersand could corrupt the entire feed XML. The feed now uses the same literal splice the page renderers use. The feed's author field also no longer exposes an internal identifier; it carries the shop name, matching the blog pages.
14
+
11
15
  - v0.4.18 (2026-06-06) — **Staff accounts: per-operator credentials with owner, manager, and viewer roles — the shared admin key becomes break-glass.** The admin console gains multi-operator support. Create staff accounts at the new Operators screen, each with its own password and optional API key, and assign a least-privilege role: owner (everything, including operator management), manager (catalog, orders, customers, marketing), or viewer (read-only). Role enforcement happens at the admin write layer on every state-changing request — a viewer is refused the write itself, not just the menu link. The shared ADMIN_API_KEY keeps working as the bootstrap and break-glass owner credential, so upgrading can never lock a store out; once staff accounts exist, treat it like a recovery key. **Added:** *Operators console with per-operator credentials* — `/admin/operators` creates and disables staff accounts. Each operator gets their own password (Argon2id) and optionally a personal API key for the JSON surface, shown once at creation. The screen lists who created each operator and when; disabling one takes effect on their very next request — the signed-in session re-reads the live account row, so there is no revocation lag. · *Owner / manager / viewer roles, enforced on the write itself* — Every admin mutation passes a single permission gate keyed to the action being performed: owner holds every permission including operator management and settings; manager covers catalog, orders, customers, and marketing; viewer holds none — every POST, PUT, and DELETE is refused with a 403, regardless of how the request arrives (browser form or bearer JSON). Read screens remain available to any authenticated operator. Role-denied attempts are recorded in the audit chain alongside every operator-management action. · *Bootstrap and break-glass: the shared key still works* — `ADMIN_API_KEY` resolves to the owner role exactly as before — a store with no operator rows behaves identically to the previous release, and the first staff account is created while signed in with the shared key. Unknown sign-in attempts burn a real password verification against a fixed dummy hash, so response timing does not reveal whether an account exists.
12
16
 
13
17
  - v0.4.17 (2026-06-06) — **Email campaigns: consent-gated broadcasts to the newsletter list, with one-click unsubscribe and a send ledger that never re-mails.** The admin console gains an Email campaigns screen: author a broadcast, target a mailing audience, preview it, test-send to yourself, and send. Consent is the design center — 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 moment their message sends, and every message carries one-click unsubscribe headers plus an in-body link. Sending drains in rate-bounded batches on the scheduled tick, one bad address never aborts a campaign, and a per-recipient ledger makes a resumed send never deliver twice. **Added:** *Campaign console: author, preview, test-send, send* — `/admin/campaigns` lists campaigns with status and delivered / failed / skipped counts, and a campaign editor takes a subject and a Markdown-or-plaintext body. The body renders escape-by-default with an https-only link gate — the same rendering discipline as the blog — so markup or script in a body lands as inert text in the inbox and in the console list. Preview and an operator-addressed test send are available before any real send. · *Consent resolved per recipient, at the send moment* — The reachable-recipient count shown before sending is computed live from the newsletter list, excluding anyone unsubscribed or on the marketing suppression list — and the same two checks run again for each recipient at the moment their message sends, so someone who unsubscribes mid-broadcast is skipped. Customer accounts keep only an email hash by design, so the newsletter list is the only deliverable-address source and the console says exactly how many recipients are reachable. · *One-click unsubscribe on every broadcast* — Every campaign message carries the RFC 8058 `List-Unsubscribe` / `List-Unsubscribe-Post` headers (so mail clients show their native unsubscribe control), an RFC 2919 `List-Id`, and an in-body unsubscribe link through the existing newsletter opt-out flow. · *Rate-bounded, resumable sending* — Sends drain in batches on the scheduled tick with a reserved hourly budget, so a large campaign spreads out rather than bursting. Each recipient's outcome lands in a send ledger keyed uniquely per campaign and recipient — a send interrupted by the rate budget or a restart resumes where it left off and never mails anyone twice. A failing address is counted and shown, never fatal to the rest of the campaign.
package/lib/admin.js CHANGED
@@ -4622,26 +4622,60 @@ function mount(router, deps) {
4622
4622
  function (id, body) { return returns.markReceived(id, { operator_notes: body.operator_notes || undefined }); },
4623
4623
  ));
4624
4624
 
4625
- // Issue the provider refund for an RMA (when a captured intent exists),
4626
- // then record the RMA refund. The provider call runs FIRST — only if it
4627
- // succeeds does the RMA move to refunded so the queue never marks a
4628
- // return refunded with the customer never paid back. The refund amount
4629
- // is the RMA's approved refund_amount_minor (set at approve time);
4630
- // absent one, the provider issues a full refund of the intent.
4625
+ // Issue the provider refund for an RMA (when a captured intent exists).
4626
+ //
4627
+ // Two guards stop a concurrent double-click / two-operator race from
4628
+ // refunding the customer twice:
4629
+ //
4630
+ // 1. The RMA is CLAIMED atomically (received refunded) BEFORE the
4631
+ // provider call. returns.refund's conditional UPDATE matches the
4632
+ // row for exactly one racer; the loser gets RMA_TRANSITION_REFUSED
4633
+ // (mapped to 409) and never reaches the provider. The claim runs
4634
+ // first so a winner holds the row while money moves.
4635
+ // 2. The Stripe idempotency key is DETERMINISTIC in the return id
4636
+ // ("rma-refund:<rma.id>") — no per-request uuid. Even if a logic
4637
+ // regression let two calls reach the provider, Stripe collapses
4638
+ // them onto one Refund (same key → same result), so the customer is
4639
+ // never paid back twice. The deterministic key is also stable
4640
+ // across an operator retry of the SAME refund.
4641
+ //
4642
+ // On a provider failure the claim is RELEASED (refunded → received) so a
4643
+ // retry can run — the RMA never strands in `refunded` with no money
4644
+ // moved. The refund amount is the RMA's approved refund_amount_minor
4645
+ // (set at approve time); absent one, the provider issues a full refund
4646
+ // of the intent IN THE ORDER'S CHARGE CURRENCY (a Stripe refund against a
4647
+ // PaymentIntent has no currency of its own — it always returns the
4648
+ // charge currency, which is order2.currency).
4631
4649
  async function _rmaProviderRefund(rma, order2, body) {
4632
- var idem = "rma-refund:" + rma.id + ":" + (body.idempotency_suffix || b.uuid.v7());
4633
- var refund = await payment.refund({
4634
- payment_intent: order2.payment_intent_id,
4635
- amount_minor: (rma.refund_amount_minor != null && rma.refund_amount_minor > 0) ? rma.refund_amount_minor : undefined,
4636
- reason: "requested_by_customer",
4637
- metadata: { order_id: order2.id, rma_id: rma.id, rma_code: rma.rma_code || "" },
4638
- }, idem);
4639
- var updated = await returns.refund(rma.id, {
4650
+ // Claim the RMA first. A lost claim (RMA_TRANSITION_REFUSED) bubbles
4651
+ // to the route, which maps it to 409 via _returnsClientError — the
4652
+ // loser of a concurrent refund never touches the provider. The note
4653
+ // stamped at claim time is the operator's own text when supplied, else
4654
+ // a stable marker; the live provider refund id is on the audit row +
4655
+ // the JSON response, so it stays reconcilable without a second write.
4656
+ var claimed = await returns.refund(rma.id, {
4640
4657
  operator_notes: (body.operator_notes && body.operator_notes.length)
4641
4658
  ? body.operator_notes
4642
- : ("provider refund " + refund.id),
4659
+ : "provider refund issued",
4643
4660
  });
4644
- return { refund: refund, rma: updated };
4661
+ var idem = "rma-refund:" + rma.id;
4662
+ var refund;
4663
+ try {
4664
+ refund = await payment.refund({
4665
+ payment_intent: order2.payment_intent_id,
4666
+ amount_minor: (rma.refund_amount_minor != null && rma.refund_amount_minor > 0) ? rma.refund_amount_minor : undefined,
4667
+ reason: "requested_by_customer",
4668
+ metadata: { order_id: order2.id, rma_id: rma.id, rma_code: rma.rma_code || "" },
4669
+ }, idem);
4670
+ } catch (e) {
4671
+ // Provider call failed — release the claim so the operator can retry
4672
+ // a transient failure. A release failure can't be recovered here, so
4673
+ // surface the original provider error (the operator-actionable one).
4674
+ try { await returns.releaseRefundClaim(rma.id); }
4675
+ catch (_releaseErr) { /* drop-silent — the original provider error below is the actionable one */ }
4676
+ throw e;
4677
+ }
4678
+ return { refund: refund, rma: claimed };
4645
4679
  }
4646
4680
 
4647
4681
  // Browser confirmation interstitial for a provider-backed RMA refund —
@@ -4665,9 +4699,17 @@ function mount(router, deps) {
4665
4699
  // record-only form handles it.
4666
4700
  return _redirect(res, "/admin/returns/" + enc + "?err=1");
4667
4701
  }
4702
+ // Format the confirm amount in the ORDER'S CHARGE CURRENCY, not the
4703
+ // RMA's approved refund_currency. A Stripe refund against a captured
4704
+ // PaymentIntent carries no currency of its own — it always settles in
4705
+ // the charge currency. The two differ on a multi-currency shop (an
4706
+ // RMA approved with the default USD currency against a EUR order), and
4707
+ // showing the RMA currency would promise the operator a different
4708
+ // figure than the provider actually refunds.
4709
+ var refundCcy = rmaCtx.order.currency;
4668
4710
  var amount = (rma.refund_amount_minor != null && rma.refund_amount_minor > 0)
4669
- ? pricing.format(rma.refund_amount_minor, rma.refund_currency || "USD")
4670
- : pricing.format(rmaCtx.order.grand_total_minor, rmaCtx.order.currency);
4711
+ ? pricing.format(rma.refund_amount_minor, refundCcy)
4712
+ : pricing.format(rmaCtx.order.grand_total_minor, refundCcy);
4671
4713
  _sendHtml(res, 200, renderAdminConfirm({
4672
4714
  shop_name: deps.shop_name, nav_available: navAvailable, active: "returns",
4673
4715
  heading: "Refund return " + _htmlEscape(rma.rma_code || rma.id.slice(0, 8)) + "?",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.18",
2
+ "version": "0.4.20",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
package/lib/cart.js CHANGED
@@ -354,6 +354,50 @@ function create(opts) {
354
354
  return (await query("SELECT * FROM carts WHERE id = ?1", [cartId])).rows[0];
355
355
  },
356
356
 
357
+ // Atomically claim an ACTIVE cart for checkout. The `AND status =
358
+ // 'active'` predicate is the serialization point: on D1 a single UPDATE
359
+ // is atomic, so when two concurrent POST /checkout requests race the same
360
+ // cart (a double-click, two tabs) exactly ONE flips it to 'converted' and
361
+ // matches the row; the other matches zero rows and loses. The winner runs
362
+ // the (single) charge + order; the loser is told the checkout is already
363
+ // in progress and never creates a second PaymentIntent or order.
364
+ //
365
+ // Returns `{ claimed: true, cart }` for the winner, `{ claimed: false,
366
+ // cart }` for the loser (cart is the post-claim row so the caller can
367
+ // redirect to its order), or `{ claimed: false, cart: null }` when the
368
+ // cart id doesn't exist at all. A non-active cart (already converted /
369
+ // abandoned) loses the claim — the existing checkout owns it.
370
+ claimForCheckout: async function (cartId) {
371
+ _uuid(cartId, "cart_id");
372
+ var ts = _now();
373
+ var r = await query(
374
+ "UPDATE carts SET status = 'converted', updated_at = ?1 WHERE id = ?2 AND status = 'active'",
375
+ [ts, cartId],
376
+ );
377
+ var cart = (await query("SELECT * FROM carts WHERE id = ?1", [cartId])).rows[0] || null;
378
+ return { claimed: Number(r.rowCount || 0) === 1, cart: cart };
379
+ },
380
+
381
+ // Release a checkout claim back to 'active' so the buyer can retry. The
382
+ // checkout flow claims the cart (claimForCheckout) BEFORE the charge so a
383
+ // concurrent racer is locked out; if the charge / order creation then
384
+ // throws BEFORE the order exists, the claimer releases the cart so the
385
+ // shopper isn't stranded on a permanently-'converted' cart with no order
386
+ // to show. Atomic + self-targeting: `AND status = 'converted'` makes a
387
+ // double-release a no-op, and only the claimer (which set 'converted')
388
+ // ever calls this, so it can't resurrect a cart a DIFFERENT completed
389
+ // checkout legitimately converted. Returns true when the cart was
390
+ // released, false when there was nothing to release.
391
+ releaseCheckoutClaim: async function (cartId) {
392
+ _uuid(cartId, "cart_id");
393
+ var ts = _now();
394
+ var r = await query(
395
+ "UPDATE carts SET status = 'active', updated_at = ?1 WHERE id = ?2 AND status = 'converted'",
396
+ [ts, cartId],
397
+ );
398
+ return Number(r.rowCount || 0) === 1;
399
+ },
400
+
357
401
  // ---- abandoned-cart visibility (operator dashboard) ---------------
358
402
  //
359
403
  // A cart is "abandoned" for dashboard purposes when it is still
package/lib/checkout.js CHANGED
@@ -413,6 +413,15 @@ function create(deps) {
413
413
  // the order id. Called once per order, immediately after the order
414
414
  // row exists, so a card is never burned for an order that failed to
415
415
  // create.
416
+ //
417
+ // The ledger debit is BEST-EFFORT: the card row's balance (decremented by
418
+ // redeem above) is the authoritative spendable balance, and the ledger is
419
+ // the audit trail. A ledger write failure (e.g. an unmigrated ledger table,
420
+ // or a ledger view that lags the card row) must not throw out of the
421
+ // post-create redeem path and strand the already-created order + its live
422
+ // PaymentIntent. The card-row debit already happened; the ledger row is
423
+ // reconcilable from the redemption record. Same posture the admin issue
424
+ // route takes when seeding the opening credit.
416
425
  async function _redeemGiftCard(resolved, orderId) {
417
426
  var redemption = await giftcards.redeem({
418
427
  code: resolved.code_plain,
@@ -420,15 +429,58 @@ function create(deps) {
420
429
  amount_minor: resolved.applied_minor,
421
430
  });
422
431
  if (giftCardLedger) {
423
- await giftCardLedger.debit({
424
- gift_card_id: resolved.card.id,
425
- order_id: orderId,
426
- amount_minor: resolved.applied_minor,
427
- });
432
+ try {
433
+ await giftCardLedger.debit({
434
+ gift_card_id: resolved.card.id,
435
+ order_id: orderId,
436
+ amount_minor: resolved.applied_minor,
437
+ });
438
+ } catch (ledgerErr) {
439
+ // Surface for reconciliation, but never let the audit-row write undo
440
+ // a card debit that already landed or strand the placed order.
441
+ try {
442
+ b.audit.safeEmit({
443
+ action: "checkout.giftcard.ledger_debit.error",
444
+ outcome: "failure",
445
+ metadata: {
446
+ gift_card_id: resolved.card.id,
447
+ order_id: orderId,
448
+ amount_minor: resolved.applied_minor,
449
+ message: (ledgerErr && ledgerErr.message) || String(ledgerErr),
450
+ },
451
+ });
452
+ } catch (_auditErr) { /* drop-silent — the redemption record is the durable trail */ }
453
+ }
428
454
  }
429
455
  return redemption;
430
456
  }
431
457
 
458
+ // Run a post-create credit redemption (gift-card / loyalty burn) so a
459
+ // failure can NEVER strand the already-created order + its live
460
+ // PaymentIntent. The order row and the PaymentIntent exist by the time
461
+ // this runs, and the buyer will pay the credit-reduced amount — so a
462
+ // throw out of the redeem (a card that raced to zero, a transient DB
463
+ // error) must not bubble up and leave the cart un-converted with an
464
+ // orphaned charge. The failure is captured to the audit sink for operator
465
+ // reconciliation (the credit reduced the charge but the debit didn't
466
+ // land, so the operator settles the card / points by hand) and the flow
467
+ // continues. `fn` returns the redeem promise (or null when there's no
468
+ // credit of that kind to burn).
469
+ async function _settleCreditPostCreate(kind, orderId, fn) {
470
+ try {
471
+ return await fn();
472
+ } catch (e) {
473
+ try {
474
+ b.audit.safeEmit({
475
+ action: "checkout." + kind + ".redeem.error",
476
+ outcome: "failure",
477
+ metadata: { order_id: orderId, message: (e && e.message) || String(e) },
478
+ });
479
+ } catch (_auditErr) { /* drop-silent — never let the audit write mask the original failure */ }
480
+ return null;
481
+ }
482
+ }
483
+
432
484
  // Resolve an optional loyalty points credit against a priced quote.
433
485
  // `points` is the number of points the customer asked to spend;
434
486
  // `customerId` is the signed-in customer's UUID (a guest cart has
@@ -693,14 +745,23 @@ function create(deps) {
693
745
 
694
746
  // Compose a quote from a cart + ship-to + (optional) selected
695
747
  // shipping service. Pure read — no DB writes.
696
- async function _buildQuote(input) {
748
+ async function _buildQuote(input, qOpts) {
697
749
  if (!input || typeof input !== "object") throw new TypeError("checkout.quote: input required");
698
750
  _uuid(input.cart_id, "cart_id");
699
751
  _shipTo(input.ship_to);
752
+ qOpts = qOpts || {};
700
753
 
701
754
  var c = await cart.get(input.cart_id);
702
755
  if (!c) throw new TypeError("checkout: cart " + input.cart_id + " not found");
703
- if (c.status !== "active") throw new TypeError("checkout: cart status is " + c.status + ", cannot quote");
756
+ // A live preview (quote) requires the cart to be active. confirm() claims
757
+ // the cart to 'converted' BEFORE building the quote (the single-charge
758
+ // gate against a double-submit), so on that path the claimed status is
759
+ // permitted via qOpts.allowClaimed — the cart confirm is settling IS
760
+ // 'converted', and rejecting it here would break the very flow that owns
761
+ // the conversion.
762
+ if (c.status !== "active" && !(qOpts.allowClaimed && c.status === "converted")) {
763
+ throw new TypeError("checkout: cart status is " + c.status + ", cannot quote");
764
+ }
704
765
  var rawLines = await cart.listLines(c.id);
705
766
  if (rawLines.length === 0) throw new TypeError("checkout: cart is empty");
706
767
  // Reapply the active quantity-break for each line at confirm time —
@@ -816,43 +877,84 @@ function create(deps) {
816
877
  throw new TypeError("checkout: customer.name — Enter a name of 120 characters or fewer.");
817
878
  }
818
879
  if (!input.selected_shipping_id) throw new TypeError("checkout.confirm: selected_shipping_id required");
819
- var idempotencyKey = input.idempotency_key;
820
- if (typeof idempotencyKey !== "string" || idempotencyKey.length < 8) {
821
- throw new TypeError("checkout.confirm: idempotency_key (≥8 chars) required");
822
- }
823
880
 
824
- var quote = await _buildQuote(input);
825
- if (!quote.selected_shipping) {
826
- // _buildQuote already threw above, but defense in depth.
827
- throw new TypeError("checkout.confirm: selected_shipping resolution returned null");
828
- }
829
- if (quote.totals.grand_total_minor <= 0) {
830
- throw new TypeError("checkout.confirm: grand_total_minor must be > 0 (zero-total orders use a separate freebie flow)");
881
+ // Atomic single-charge gate against a double-submit. Two concurrent
882
+ // POST /checkout requests for the same cart (a double-click, two tabs,
883
+ // a retried form post) must NOT each create a PaymentIntent + order.
884
+ // claimForCheckout flips the cart 'active' → 'converted' in one atomic
885
+ // UPDATE; exactly one racer matches the row and proceeds, the other
886
+ // loses the claim and is told the checkout is already in progress —
887
+ // never a second charge. A non-active cart (already converting /
888
+ // converted / abandoned) loses for the same reason. The claim runs
889
+ // BEFORE the PaymentIntent so the loser can't slip a charge in.
890
+ var claim = await cart.claimForCheckout(input.cart_id);
891
+ if (!claim.cart) throw new TypeError("checkout: cart " + input.cart_id + " not found");
892
+ if (!claim.claimed) {
893
+ // Lost the claim — another checkout for this cart is in flight or
894
+ // already completed. Surface a typed signal so the storefront can
895
+ // redirect to the in-progress order rather than starting a second.
896
+ var inProgress = new Error("checkout: a checkout is already in progress for this cart");
897
+ inProgress.code = "CHECKOUT_IN_PROGRESS";
898
+ throw inProgress;
831
899
  }
832
900
 
833
- // Reserve stock for every shippable, non-exempt line BEFORE any
834
- // charge. Insufficient stock throws a coded INSUFFICIENT_STOCK error
835
- // here (no PaymentIntent, no order) which the storefront re-renders
836
- // inline. The holds placed here ride into the pending order and are
837
- // converted to a real shelf debit on the paid transition (or released
838
- // when the order is cancelled / expires). Everything AFTER this point
839
- // runs inside a guard that releases the holds if a later step throws
840
- // before the order is committed, so a refused gift-card / payment
841
- // error never strands held stock.
842
- var stockHolds = await _placeStockHolds(quote.lines);
843
- // Conditional rollback: the catch releases the placed holds ONLY when
844
- // no pending order was created. Once createFromCart succeeds, the
845
- // order row OWNS those holds — releasing them here would double-free
846
- // (the paid decrement / cancel release, or the stale-pending reaper,
847
- // settles them once the order exists), and a floored blanket release
848
- // could even eat a CONCURRENT shopper's hold on the same SKU. The
849
- // context object is mutated inside _confirmAfterHolds the instant the
850
- // order is created.
901
+ // The cart is claimed ('converted'); from here a throw BEFORE the order
902
+ // is created must RELEASE the claim ('converted' → 'active') so the
903
+ // shopper can retry. Once the order exists the claim is permanent (the
904
+ // order owns the conversion) the reaper / cancel edge handles an
905
+ // abandoned pending order, not a re-openable cart. rollbackCtx, shared
906
+ // with _confirmAfterHolds, flips orderCreated true the instant the order
907
+ // row exists so neither the stock-hold release nor the claim release
908
+ // fires once the order owns them.
851
909
  var rollbackCtx = { orderCreated: false };
852
910
  try {
853
- return await this._confirmAfterHolds(input, quote, email, stockHolds, rollbackCtx);
911
+ // Deterministic Stripe idempotency key, derived from the claimed
912
+ // cart. One cart = one checkout (the claim guarantees it), so even if
913
+ // a double-fire reached the provider, Stripe collapses both onto the
914
+ // same PaymentIntent (same key → same intent). The caller-supplied
915
+ // idempotency_key is ignored for the provider call — the cart claim
916
+ // is the authoritative single-charge identity — but still accepted on
917
+ // the input for backward compatibility / the record-only paths.
918
+ var idempotencyKey = "checkout:" + input.cart_id;
919
+
920
+ var quote = await _buildQuote(input, { allowClaimed: true });
921
+ if (!quote.selected_shipping) {
922
+ // _buildQuote already threw above, but defense in depth.
923
+ throw new TypeError("checkout.confirm: selected_shipping resolution returned null");
924
+ }
925
+ if (quote.totals.grand_total_minor <= 0) {
926
+ throw new TypeError("checkout.confirm: grand_total_minor must be > 0 (zero-total orders use a separate freebie flow)");
927
+ }
928
+
929
+ // Reserve stock for every shippable, non-exempt line BEFORE any
930
+ // charge. Insufficient stock throws a coded INSUFFICIENT_STOCK error
931
+ // here (no PaymentIntent, no order) which the storefront re-renders
932
+ // inline. The holds placed here ride into the pending order and are
933
+ // converted to a real shelf debit on the paid transition (or released
934
+ // when the order is cancelled / expires).
935
+ var stockHolds = await _placeStockHolds(quote.lines);
936
+ // Stash the placed holds on rollbackCtx so the single catch below can
937
+ // release them when a later step throws before the order is created.
938
+ rollbackCtx.stockHolds = stockHolds;
939
+ return await this._confirmAfterHolds(input, quote, email, stockHolds, rollbackCtx, idempotencyKey);
854
940
  } catch (e) {
855
- if (!rollbackCtx.orderCreated) await _releaseStockHolds(stockHolds);
941
+ // Only roll back when no pending order was created. Once
942
+ // createFromCart succeeds the order OWNS the holds + the conversion —
943
+ // releasing the holds would double-free (the paid decrement / cancel
944
+ // release, or the stale-pending reaper, settles them) and could eat a
945
+ // concurrent shopper's hold on the same SKU, and re-opening the cart
946
+ // would orphan the order. A throw before the order exists releases
947
+ // both the placed holds AND the checkout claim so the shopper can
948
+ // retry on the same cart. Both releases are atomic + self-targeting,
949
+ // so neither can disturb a genuinely completed checkout.
950
+ if (!rollbackCtx.orderCreated) {
951
+ if (rollbackCtx.stockHolds) {
952
+ try { await _releaseStockHolds(rollbackCtx.stockHolds); }
953
+ catch (_holdErr) { /* drop-silent — claim release below + original error are the actionable parts */ }
954
+ }
955
+ try { await cart.releaseCheckoutClaim(input.cart_id); }
956
+ catch (_releaseErr) { /* drop-silent — the original error below is the actionable one */ }
957
+ }
856
958
  throw e;
857
959
  }
858
960
  },
@@ -867,10 +969,16 @@ function create(deps) {
867
969
  // mutable flag the catch reads: set `orderCreated` true the moment a
868
970
  // pending order exists so a LATER throw doesn't release holds the order
869
971
  // now owns.
870
- _confirmAfterHolds: async function (input, quote, email, stockHolds, rollbackCtx) {
871
- // Already validated in confirm() above; re-read here since the PI
872
- // creation moved into this split-out body.
873
- var idempotencyKey = input.idempotency_key;
972
+ _confirmAfterHolds: async function (input, quote, email, stockHolds, rollbackCtx, idempotencyKey) {
973
+ // The Stripe idempotency key is derived deterministically from the
974
+ // claimed cart by confirm() (one cart = one checkout), so a double-fire
975
+ // collapses onto a single PaymentIntent on Stripe's side. Defended for
976
+ // a direct call: fall back to the input key.
977
+ if (typeof idempotencyKey !== "string" || idempotencyKey.length < 8) {
978
+ idempotencyKey = (typeof input.idempotency_key === "string" && input.idempotency_key.length >= 8)
979
+ ? input.idempotency_key
980
+ : ("checkout:" + input.cart_id);
981
+ }
874
982
  // Resolve an optional gift-card credit BEFORE any charge so a bad
875
983
  // code fails the checkout without touching Stripe. The credit
876
984
  // reduces the amount due; the order still records the full grand
@@ -940,8 +1048,16 @@ function create(deps) {
940
1048
  // The pending order now owns the holds — a throw from here on must
941
1049
  // NOT blanket-release them (see the conditional rollback in confirm).
942
1050
  if (rollbackCtx) rollbackCtx.orderCreated = true;
943
- if (gc) await _redeemGiftCard(gc, paidOrder.id);
944
- if (loy) await _redeemLoyalty(loy, loyaltyCustomerId, paidOrder.id);
1051
+ // Same settlement posture as the PaymentIntent branch below: the
1052
+ // order exists and the credits priced it, so a redeem failure here
1053
+ // must not strand the cart un-converted — it is captured and
1054
+ // reconciled, never propagated.
1055
+ await _settleCreditPostCreate("giftcard", paidOrder.id, function () {
1056
+ return gc ? _redeemGiftCard(gc, paidOrder.id) : null;
1057
+ });
1058
+ await _settleCreditPostCreate("loyalty", paidOrder.id, function () {
1059
+ return loy ? _redeemLoyalty(loy, loyaltyCustomerId, paidOrder.id) : null;
1060
+ });
945
1061
  // Best-effort: record the auto-discount redemptions against the
946
1062
  // placed order. Runs post-commit so a recording failure can't
947
1063
  // reverse an order that's already paid.
@@ -1003,11 +1119,20 @@ function create(deps) {
1003
1119
  if (rollbackCtx) rollbackCtx.orderCreated = true;
1004
1120
 
1005
1121
  // Burn the gift-card + loyalty credits against the created order.
1006
- // Runs after the order row exists so a failed order never spends
1007
- // either credit; each redeem decrement is its own double-spend
1008
- // guard.
1009
- if (gc) await _redeemGiftCard(gc, createdOrder.id);
1010
- if (loy) await _redeemLoyalty(loy, loyaltyCustomerId, createdOrder.id);
1122
+ // POST-COMMIT: the order row + its PaymentIntent already exist, so a
1123
+ // redeem failure must NOT throw out of confirm — that would leave the
1124
+ // cart un-converted (still `active`) while a live, charge-able
1125
+ // PaymentIntent and order sit orphaned, and the conditional rollback
1126
+ // above no longer fires (orderCreated is set). Each redeem is captured
1127
+ // and reconciled instead (see _settleCreditPostCreate): the order is
1128
+ // valid and the buyer pays the credit-reduced amount; a failed debit is
1129
+ // surfaced for the operator to reconcile, never a stranded cart.
1130
+ await _settleCreditPostCreate("giftcard", createdOrder.id, function () {
1131
+ return gc ? _redeemGiftCard(gc, createdOrder.id) : null;
1132
+ });
1133
+ await _settleCreditPostCreate("loyalty", createdOrder.id, function () {
1134
+ return loy ? _redeemLoyalty(loy, loyaltyCustomerId, createdOrder.id) : null;
1135
+ });
1011
1136
  // Best-effort: record the auto-discount redemptions against the
1012
1137
  // placed order. Runs post-commit so a recording failure can't
1013
1138
  // reverse or fail an order whose PaymentIntent already exists.
@@ -1256,7 +1381,12 @@ function create(deps) {
1256
1381
  lines: ppLines,
1257
1382
  });
1258
1383
  ppOrderCreated = true;
1259
- await _redeemGiftCard(gc, paidOrder.id);
1384
+ // Same settlement posture as the Stripe branches: the order exists,
1385
+ // so a redeem failure is captured and reconciled, never allowed to
1386
+ // strand the cart un-converted.
1387
+ await _settleCreditPostCreate("giftcard", paidOrder.id, function () {
1388
+ return _redeemGiftCard(gc, paidOrder.id);
1389
+ });
1260
1390
  await cart.setStatus(quote.cart_id, "converted");
1261
1391
  var settled = await order.transition(paidOrder.id, "mark_paid", { reason: "gift_card:full" });
1262
1392
  return { order: settled, paypal_order_id: null, status: "PAID_BY_GIFT_CARD", gift_card: { applied_minor: gc.applied_minor, amount_due_minor: 0 } };
@@ -1285,7 +1415,14 @@ function create(deps) {
1285
1415
  lines: ppLines,
1286
1416
  });
1287
1417
  ppOrderCreated = true;
1288
- if (gc) await _redeemGiftCard(gc, createdOrder.id);
1418
+ // Post-commit gift-card burn — captured, never stranding. The order row
1419
+ // + the PayPal order already exist; a redeem throw must not bubble to
1420
+ // the outer catch (which re-throws and would leave the cart active with
1421
+ // an orphaned PayPal order). The buyer pays the credit-reduced PayPal
1422
+ // amount; a failed debit is surfaced for reconciliation.
1423
+ await _settleCreditPostCreate("giftcard", createdOrder.id, function () {
1424
+ return gc ? _redeemGiftCard(gc, createdOrder.id) : null;
1425
+ });
1289
1426
  await cart.setStatus(quote.cart_id, "converted");
1290
1427
  return { order: createdOrder, paypal_order_id: ppOrder.id, status: ppOrder.status, gift_card: gc ? { applied_minor: gc.applied_minor, amount_due_minor: amountDue } : null };
1291
1428
  } catch (e) {
@@ -574,24 +574,39 @@ function create(opts) {
574
574
  [input.picked_up_at, sigHash, proofKind, now, orderId],
575
575
  );
576
576
 
577
- // Drive the parent order FSM to delivered. The transition is
578
- // guarded an already-delivered order surfaces as
579
- // `fsm/illegal-transition` and we swallow it so a double
580
- // markPickedUp (idempotent at the schedule layer) doesn't
581
- // spuriously fail at the order layer. Other refusal codes
582
- // surface to the caller.
577
+ // Drive the parent order FSM to `delivered` so the order really
578
+ // reaches its terminal state when the goods leave the store.
579
+ //
580
+ // A pickup order is normally `paid` (or `fulfilling`, if the picker
581
+ // started staging) when the customer collects for which the order
582
+ // FSM's BOPIS edge `mark_picked_up` goes straight to `delivered` (an
583
+ // in-store collection has no carrier ship leg). If the operator
584
+ // already shipped the order through the standard flow, `mark_picked_up`
585
+ // is illegal from `shipped`, so we fall back to the standard
586
+ // `mark_delivered` edge. If BOTH are illegal the order is already
587
+ // `delivered` (or otherwise terminal) — a double markPickedUp is
588
+ // idempotent at the schedule layer, so the order-layer no-op is
589
+ // expected and swallowed. Any non-FSM-refusal error still surfaces.
590
+ var pickupMeta = {
591
+ reason: "click_and_collect_picked_up",
592
+ metadata: {
593
+ location_code: schedule.location_code,
594
+ picked_up_at: input.picked_up_at,
595
+ },
596
+ };
583
597
  if (orderPrim) {
584
598
  try {
585
- await orderPrim.transition(orderId, "mark_delivered", {
586
- reason: "click_and_collect_picked_up",
587
- metadata: {
588
- location_code: schedule.location_code,
589
- picked_up_at: input.picked_up_at,
590
- },
591
- });
599
+ await orderPrim.transition(orderId, "mark_picked_up", pickupMeta);
592
600
  } catch (e) {
593
- var code = e && e.code;
594
- if (code !== "fsm/illegal-transition") throw e;
601
+ if ((e && e.code) !== "fsm/illegal-transition") throw e;
602
+ // Not legal from the order's current state — try the standard
603
+ // ship→deliver edge (operator already shipped it), then treat an
604
+ // already-terminal order as the idempotent no-op it is.
605
+ try {
606
+ await orderPrim.transition(orderId, "mark_delivered", pickupMeta);
607
+ } catch (e2) {
608
+ if ((e2 && e2.code) !== "fsm/illegal-transition") throw e2;
609
+ }
595
610
  }
596
611
  }
597
612
  return await _getScheduleByOrder(orderId);