@blamejs/blamejs-shop 0.4.19 → 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,8 @@ 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
+
11
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.
12
14
 
13
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.
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.19",
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);
package/lib/giftcards.js CHANGED
@@ -346,6 +346,66 @@ function create(opts) {
346
346
  };
347
347
  },
348
348
 
349
+ // Credit a card's spend back when the order that redeemed it never
350
+ // completed (abandoned / cancelled / refunded). `redeem` debited the
351
+ // card row's balance at checkout; if the order dies, that money must
352
+ // return to the card or the customer's balance is silently gone.
353
+ //
354
+ // Keyed on the ORDER id — the order FSM drives this on its cancel /
355
+ // refund edges (the same transitions that release inventory holds), so
356
+ // reversal is transition-driven, not a separate operator action. Every
357
+ // unreversed redemption against the order is restored.
358
+ //
359
+ // Idempotent + concurrency-safe: each redemption is claimed with
360
+ // `WHERE id = ? AND reversed_at IS NULL` and the rowCount checked, so a
361
+ // double-fire (the stale-order reaper racing a payment-failed webhook,
362
+ // or a re-delivered cancel) credits the balance back exactly once. The
363
+ // balance restore is itself bounded by the card's issued_minor CHECK, so
364
+ // it can never push the card above its original face value. A card that
365
+ // had drained to `redeemed` is reactivated since it now carries balance
366
+ // again. Returns the list of reversed redemptions (empty when there was
367
+ // nothing to reverse — an order that used no gift card, or one already
368
+ // fully reversed).
369
+ reverseRedemption: async function (orderId) {
370
+ _uuid(orderId, "order_id");
371
+ var rows = (await query(
372
+ "SELECT id, giftcard_id, amount_minor FROM giftcard_redemptions " +
373
+ "WHERE order_id = ?1 AND reversed_at IS NULL",
374
+ [orderId],
375
+ )).rows;
376
+ var reversed = [];
377
+ for (var i = 0; i < rows.length; i += 1) {
378
+ var red = rows[i];
379
+ var ts = _now();
380
+ // Claim the redemption first — the unreversed predicate is the
381
+ // serialization point so two concurrent reversals can't both credit.
382
+ var claim = await query(
383
+ "UPDATE giftcard_redemptions SET reversed_at = ?1 WHERE id = ?2 AND reversed_at IS NULL",
384
+ [ts, red.id],
385
+ );
386
+ if (Number(claim.rowCount || 0) === 0) continue; // lost the claim
387
+ // Restore the spendable balance on the card row. Capped at the card's
388
+ // issued_minor by the schema CHECK; the amount restored is exactly
389
+ // what this redemption debited, so it can't exceed the face value.
390
+ // Reactivate a card that had drained to `redeemed` — it carries
391
+ // balance again. A `voided`/`expired` card stays in its terminal
392
+ // status (the balance is restored on the row for reconciliation, but
393
+ // a voided/expired card isn't spendable regardless).
394
+ await query(
395
+ "UPDATE giftcards SET balance_minor = balance_minor + ?1, " +
396
+ "status = CASE WHEN status = 'redeemed' THEN 'active' ELSE status END, " +
397
+ "updated_at = ?2 WHERE id = ?3",
398
+ [red.amount_minor, ts, red.giftcard_id],
399
+ );
400
+ reversed.push({
401
+ redemption_id: red.id,
402
+ gift_card_id: red.giftcard_id,
403
+ amount_minor: red.amount_minor,
404
+ });
405
+ }
406
+ return reversed;
407
+ },
408
+
349
409
  "void": async function (id, opts2) {
350
410
  opts2 = opts2 || {};
351
411
  _uuid(id, "giftcard id");
package/lib/order.js CHANGED
@@ -58,6 +58,25 @@ var ORDER_TRANSITIONS = Object.freeze([
58
58
  { from: "delivered", to: "refunded", on: "refund", label: "Refund" },
59
59
  ]);
60
60
 
61
+ // Pickup (BOPIS) edges. An in-store collection has no carrier ship leg:
62
+ // the goods sit on the hold shelf and the customer walks in. So a pickup
63
+ // order goes straight from `paid` (or `fulfilling`, if the picker started
64
+ // staging) to `delivered` on a single `mark_picked_up` event — driving it
65
+ // through mark_shipped would record a carrier handoff that never happened.
66
+ // These edges are kept OUT of ORDER_TRANSITIONS (and therefore out of
67
+ // transitionsFrom) so the operator order-detail page doesn't sprout a
68
+ // "mark picked up" button on every paid order — the click-and-collect
69
+ // primitive fires the event when the front-counter operator captures the
70
+ // pickup. They ARE merged into the FSM definition below so the transition
71
+ // is legal: previously markPickedUp tried `mark_delivered` from `paid`,
72
+ // which the FSM refused (delivered is only reachable from shipped) and the
73
+ // caller swallowed — leaving the pickup schedule `picked_up` while the
74
+ // parent order stayed stuck at paid/fulfilling.
75
+ var PICKUP_TRANSITIONS = Object.freeze([
76
+ { from: "paid", to: "delivered", on: "mark_picked_up" },
77
+ { from: "fulfilling", to: "delivered", on: "mark_picked_up" },
78
+ ]);
79
+
61
80
  function _getOrderFsm() {
62
81
  if (_orderFsm) return _orderFsm;
63
82
  // b.fsm emits audit events under the 'fsm' namespace —
@@ -76,7 +95,7 @@ function _getOrderFsm() {
76
95
  refunded: {},
77
96
  cancelled: {},
78
97
  },
79
- transitions: ORDER_TRANSITIONS.map(function (t) {
98
+ transitions: ORDER_TRANSITIONS.concat(PICKUP_TRANSITIONS).map(function (t) {
80
99
  return { from: t.from, to: t.to, on: t.on };
81
100
  }),
82
101
  });
@@ -217,6 +236,28 @@ function create(opts) {
217
236
  // settlement failures still surface to the audit sink (b.audit.safeEmit
218
237
  // below), just not the durable error feed.
219
238
  var errorLog = opts.errorLog || null;
239
+ // Optional gift-card handle — when present, the order FSM credits a
240
+ // gift-card spend back when the order dies without completing. A checkout
241
+ // that partially covers payment with a gift card debits the card's balance
242
+ // at confirm; if the order is then cancelled (the stale-pending reaper, an
243
+ // explicit cancel) or refunded (a payment-failed / refund webhook), that
244
+ // debit must return to the card or the customer's balance is silently
245
+ // burned. This mirrors the inventory-hold release: it's transition-driven
246
+ // on the same cancel / refund edges, runs synchronously before the
247
+ // fire-and-forget fan-outs, and is idempotent (reverseRedemption claims
248
+ // each redemption with an unreversed predicate) so a re-delivered webhook
249
+ // can't double-credit. Opt-in like the other handles; absent it, an
250
+ // unwired deploy (or a test with no gift cards) runs unchanged.
251
+ var giftCards = opts.giftCards || null;
252
+ if (giftCards && typeof giftCards.reverseRedemption !== "function") {
253
+ throw new TypeError("order.create: opts.giftCards must expose a reverseRedemption(order_id) method");
254
+ }
255
+ // Optional gift-card ledger handle — when present alongside giftCards, a
256
+ // reversal also writes a refund_to_giftcard credit so the append-only
257
+ // ledger history records the money returning to the card (the card row's
258
+ // balance is authoritative; the ledger is the audit trail surfaced in the
259
+ // admin console).
260
+ var giftCardLedger = opts.giftCardLedger || null;
220
261
  // Pagination cursors for listForCustomer are HMAC-tagged via
221
262
  // b.pagination so an operator can't hand-craft one to skip past a
222
263
  // hidden order or replay across deployments. The secret defaults
@@ -273,6 +314,60 @@ function create(opts) {
273
314
  }
274
315
  }
275
316
 
317
+ // Credit back any gift-card spend on an order that died without
318
+ // completing — the cancel / refund edge releasing the customer's money,
319
+ // mirroring how _settleSku releases an inventory hold. reverseRedemption
320
+ // is idempotent (each redemption claimed with an unreversed predicate), so
321
+ // a re-delivered webhook or the reaper racing the cancel can't double-
322
+ // credit. For each restored redemption a refund_to_giftcard ledger credit
323
+ // is written when the ledger is wired so the audit trail records the money
324
+ // returning.
325
+ //
326
+ // drop-silent-with-capture — same discipline as _settleSku: the cancel /
327
+ // refund has already persisted and the webhook driving it MUST return 2xx,
328
+ // so a reversal failure is caught, NOT re-thrown, and surfaced loudly (an
329
+ // `order.giftcard.reversal.error` audit event plus, when the error-log
330
+ // handle is wired, a durable /admin/errors row) for manual reconciliation.
331
+ async function _settleGiftCards(orderId) {
332
+ if (!giftCards) return;
333
+ try {
334
+ var reversed = await giftCards.reverseRedemption(orderId);
335
+ if (giftCardLedger && typeof giftCardLedger.credit === "function") {
336
+ for (var i = 0; i < reversed.length; i += 1) {
337
+ var rev = reversed[i];
338
+ try {
339
+ // allow:money-binding-currency-without-catalog-check — this is a REVERSAL credit of an existing redemption: it replays the exact amount already debited from an already-issued card (whose currency was ISO-4217-validated at giftcards.issue time). No currency is supplied or chosen here — the ledger credit carries no currency field; it inherits the card's. There is no new currency surface to catalog-check.
340
+ await giftCardLedger.credit({
341
+ gift_card_id: rev.gift_card_id,
342
+ amount_minor: rev.amount_minor,
343
+ source: "refund_to_giftcard",
344
+ source_ref: orderId,
345
+ });
346
+ } catch (_ledgerErr) { /* drop-silent — the card-row balance restore above is authoritative; the ledger credit is the audit trail */ }
347
+ }
348
+ }
349
+ } catch (e) {
350
+ var message = "order.giftcard-reversal failed — order=" + orderId + ": " + (e && e.message || e);
351
+ try {
352
+ b.audit.safeEmit({
353
+ action: "order.giftcard.reversal.error",
354
+ outcome: "failure",
355
+ metadata: { order_id: orderId, message: (e && e.message) || String(e) },
356
+ });
357
+ } catch (_auditErr) { /* drop-silent — the capture below is the durable record */ }
358
+ if (errorLog && typeof errorLog.captureServerError === "function") {
359
+ try {
360
+ await errorLog.captureServerError({
361
+ route: "/order/" + orderId + "/giftcard-reversal",
362
+ method: "POST",
363
+ status: 500,
364
+ message: message,
365
+ });
366
+ } catch (_logErr) { /* drop-silent — never let the error-feed write mask the original failure */ }
367
+ }
368
+ }
369
+ }
370
+
276
371
  return {
277
372
  TERMINAL_STATES: TERMINAL_STATES,
278
373
 
@@ -477,6 +572,21 @@ function create(opts) {
477
572
  }
478
573
  }
479
574
  }
575
+ // Gift-card settlement — SYNCHRONOUS, alongside the inventory release.
576
+ // An order that reaches a terminal state WITHOUT completing the sale
577
+ // (cancelled — pending-abandon or post-paid; refunded — payment failed
578
+ // or a refund issued) must return any gift-card spend to the card.
579
+ // Unlike inventory (where a refund deliberately doesn't auto-restock a
580
+ // physical good), the gift-card credit is the customer's own money, so
581
+ // it's released on every death edge. reverseRedemption is idempotent,
582
+ // so a re-delivered webhook or the reaper racing a cancel credits back
583
+ // exactly once. Drop-silent-with-capture (see _settleGiftCards): the
584
+ // transition has already persisted and the webhook must return 2xx, so
585
+ // a reversal failure surfaces loudly for reconciliation rather than
586
+ // 500ing the request.
587
+ if (result.to === "cancelled" || result.to === "refunded") {
588
+ await _settleGiftCards(orderId);
589
+ }
480
590
  // Fan-out to merchant webhook subscribers is fire-and-forget. The
481
591
  // transition has already persisted; the request must not wait on
482
592
  // outbound HTTP, or a slow / unreachable endpoint would block the
package/lib/returns.js CHANGED
@@ -376,21 +376,61 @@ function create(opts) {
376
376
  var refundedAt = _epochOrNull(input.refunded_at, "refunded_at");
377
377
  var operatorNotes = _boundedString(input.operator_notes, "operator_notes", MAX_NOTE_LEN);
378
378
 
379
- var current = await this._currentStatus(rmaId);
380
- _assertTransition(current, "refund");
381
-
382
379
  var ts = _now();
383
380
  var rfAt = refundedAt == null ? ts : refundedAt;
384
- await query(
381
+ // Atomic claim: the `AND status = 'received'` predicate is the
382
+ // serialization point. Two concurrent refund POSTs (an operator
383
+ // double-click, two operators on the same RMA) both read 'received'
384
+ // up the stack, but on D1 a single UPDATE is atomic — exactly one
385
+ // matches the row and writes 'refunded'; the loser matches zero rows
386
+ // and is refused. Without this the read-then-write let both pass and
387
+ // both moved the RMA to refunded (and, on the provider-backed path,
388
+ // both issued a refund). A zero-row result distinguishes "already
389
+ // refunded / not received" — surfaced as RMA_TRANSITION_REFUSED so
390
+ // the request layer maps it to 409, the same shape the prior
391
+ // _assertTransition refusal produced.
392
+ var upd = await query(
385
393
  "UPDATE return_authorizations SET status = 'refunded', " +
386
394
  "refunded_at = ?1, " +
387
395
  "operator_notes = CASE WHEN ?2 = '' THEN operator_notes ELSE ?2 END, " +
388
- "updated_at = ?3 WHERE id = ?4",
396
+ "updated_at = ?3 WHERE id = ?4 AND status = 'received'",
389
397
  [rfAt, operatorNotes, ts, rmaId],
390
398
  );
399
+ if (Number(upd.rowCount || 0) === 0) {
400
+ // Either the RMA doesn't exist or it isn't in 'received'. Read the
401
+ // current status to surface the right typed refusal (RMA_NOT_FOUND
402
+ // vs RMA_TRANSITION_REFUSED) so the caller's mapping is unchanged.
403
+ var current = await this._currentStatus(rmaId);
404
+ _assertTransition(current, "refund");
405
+ // _assertTransition throws for any non-'received' state; reaching
406
+ // here means the RMA was 'received' a moment ago but a concurrent
407
+ // claim won the row first. Refuse with the transition-refused shape.
408
+ var raced = new Error("returns: refund already claimed for rma " + rmaId);
409
+ raced.code = "RMA_TRANSITION_REFUSED";
410
+ throw raced;
411
+ }
391
412
  return await this.get(rmaId);
392
413
  },
393
414
 
415
+ // Revert a refund claim back to 'received'. The provider-backed refund
416
+ // flow claims the RMA (refund() above) BEFORE moving money so a
417
+ // concurrent racer is locked out; if the provider call then fails, the
418
+ // caller releases the claim so a retry can run. Atomic + self-targeting:
419
+ // the `AND status = 'refunded'` predicate makes a double-release (or a
420
+ // release of an RMA a webhook already advanced) a no-op, never an
421
+ // underflow. Returns true when the claim was released, false when there
422
+ // was nothing to release.
423
+ releaseRefundClaim: async function (rmaId) {
424
+ _uuid(rmaId, "rma id");
425
+ var ts = _now();
426
+ var upd = await query(
427
+ "UPDATE return_authorizations SET status = 'received', refunded_at = NULL, " +
428
+ "updated_at = ?1 WHERE id = ?2 AND status = 'refunded'",
429
+ [ts, rmaId],
430
+ );
431
+ return Number(upd.rowCount || 0) === 1;
432
+ },
433
+
394
434
  reject: async function (rmaId, input) {
395
435
  _uuid(rmaId, "rma id");
396
436
  if (!input || typeof input !== "object") {
package/lib/storefront.js CHANGED
@@ -13999,6 +13999,16 @@ function mount(router, deps) {
13999
13999
  // fat-fingered value re-prompts rather than 500-ing checkout.
14000
14000
  var code = (e && typeof e.code === "string") ? e.code : "";
14001
14001
  var msg = (e && e.message) || "checkout failed";
14002
+ // Lost the single-charge claim: another POST for this cart (a double-
14003
+ // click / second tab) is already converting it. The winner created
14004
+ // (or is creating) the one order; bounce this loser to the cart so it
14005
+ // can't start a second checkout. The winner's redirect carries the
14006
+ // shopper to /pay or /orders — this branch just refuses the duplicate.
14007
+ if (code === "CHECKOUT_IN_PROGRESS") {
14008
+ res.status(303);
14009
+ res.setHeader && res.setHeader("location", "/cart");
14010
+ return res.end ? res.end() : res.send("");
14011
+ }
14002
14012
  // A coded gift-card / loyalty / out-of-stock error is something the
14003
14013
  // shopper can fix in place — re-render the checkout form with the
14004
14014
  // message inline (preserving the cart + their prefilled fields where
@@ -17968,6 +17978,18 @@ function mount(router, deps) {
17968
17978
  var auth = _returnsAuth(req, res); if (!auth) return;
17969
17979
  var order = await _ownedOrder(req, res, auth); if (!order) return;
17970
17980
  var cartCount = await _cartCountForReq(req);
17981
+ // An ineligible order (unpaid, cancelled, or already refunded) never
17982
+ // gets the open-return form — the same window the return button is
17983
+ // gated on. Without this the form renders on, say, a refunded order
17984
+ // and the POST below would mint an RMA the operator could then refund
17985
+ // a SECOND time through the provider.
17986
+ if (!_orderEligibleForReturn(order.status)) {
17987
+ return _send(res, 400, renderReturnForm({
17988
+ order: order, lines: order.lines || [],
17989
+ notice: "This order isn't eligible for a return.",
17990
+ shop_name: shopName, cart_count: cartCount,
17991
+ }));
17992
+ }
17971
17993
  _send(res, 200, renderReturnForm({ order: order, lines: order.lines || [], shop_name: shopName, cart_count: cartCount }));
17972
17994
  });
17973
17995
 
@@ -17976,6 +17998,21 @@ function mount(router, deps) {
17976
17998
  var order = await _ownedOrder(req, res, auth); if (!order) return;
17977
17999
  var body = req.body || {};
17978
18000
  var cartCount = await _cartCountForReq(req);
18001
+ // Server-side eligibility gate — the load-bearing check. A return is
18002
+ // only openable while the goods are in the customer's hands and paid
18003
+ // for (paid / fulfilling / shipped / delivered); an unpaid pending
18004
+ // order, a cancelled one, or an already-refunded one is refused. The
18005
+ // form/button gate on the same predicate, but a forged direct POST —
18006
+ // or an order that changed state between the GET and this POST — must
18007
+ // not slip an RMA onto an ineligible order (which would enable a
18008
+ // second provider refund downstream).
18009
+ if (!_orderEligibleForReturn(order.status)) {
18010
+ return _send(res, 400, renderReturnForm({
18011
+ order: order, lines: order.lines || [],
18012
+ notice: "This order isn't eligible for a return.",
18013
+ shop_name: shopName, cart_count: cartCount,
18014
+ }));
18015
+ }
17979
18016
  // Build the return lines from the order's own lines (authoritative
17980
18017
  // sku/qty), keyed by the checkboxes the customer ticked — never
17981
18018
  // trust a client-supplied sku.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.19",
3
+ "version": "0.4.20",
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": {