@blamejs/blamejs-shop 0.4.61 → 0.4.63

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.63 (2026-06-19) — **Fulfillment and customer-data actions can't double-fire under concurrent calls.** Closes a further set of concurrency races where a fulfillment or customer-data action read a row, checked its status in application code, and then performed an irreversible side-effect (open an inventory hold, create shipments, restock received stock, create an order, reparent customer records, requeue a print job, mint child carts) before an unconditional update committed — so two concurrent calls could both pass the check and both perform the side-effect. Each now claims the transition with a single conditional update and performs the side-effect only when that claim wins, reverting the claim if the side-effect then fails so a row is never stranded. Approving an exchange opens its replacement hold once; executing a split-shipment plan creates the parcels once; recording a partial purchase-order receipt restocks each line once (claimed per line); completing a pick list creates its shipment once; converting or launching a pre-order creates the order once and a reservation can't oversell its cap; merging or rolling back a customer reparents the records once; an impersonation notice, a duplicate-suggestion link, a print-job retry, and a cart split each happen once. Behavior for a single sequential caller is unchanged. **Fixed:** *Fulfillment transitions claim before their side-effect* — Approving an exchange now claims pending-to-approved (WHERE id = ? AND status = 'pending') before opening the replacement inventory hold, reverting to pending if the hold fails. Executing a split-shipment plan claims proposed-to-executed before creating any shipment. Recording a partial purchase-order receipt claims each receipt line by advancing its received quantity conditionally before restocking, rolling the claimed lines back if a later line fails. Completing a pick list claims its completion before creating the shipment. In each case two concurrent calls can no longer both create the holds / shipments / restock for one row. · *Pre-order conversion, launch, cancel, and reserve are atomic* — Converting a reservation to an order claims active-to-converted before creating the order (reverting on failure), so a reservation can't spawn two orders; launching a campaign claims active-to-launched before walking reservations; cancelling claims active-to-cancelled before decrementing the reserved counter. Reserving now enforces the campaign cap inside the increment itself (units_reserved + qty <= max_units_available in the update's WHERE), so concurrent reservations can't oversell the pool; a reservation that loses the cap is compensated. · *Customer merge/rollback, impersonation notice, suggestion dedup, print retry, and cart split run once* — Executing or rolling back a customer merge claims the transition before reparenting records, reverting if the reparent fails. Sending the 'an operator viewed your account' notice claims the notified stamp (WHERE customer_notified_at IS NULL) before enqueuing, so it's sent once. Linking a duplicate suggestion claims the source before migrating its votes to the canonical row. Marking a print job failed claims the transition before inserting the retry job, so a job isn't reprinted twice. Splitting a cart claims the source's abandon before minting the child carts, so a concurrent split can't create two sets of children.
12
+
13
+ - v0.4.62 (2026-06-19) — **Money and approval actions can't double-fire under concurrent calls.** Closes a set of concurrency races in money- and approval-critical paths where the action read a row, checked its status in application code, and then performed an irreversible side-effect (retry a card, execute an approved action, register a vendor, bill a subscription change) before an unconditional update committed — so two concurrent calls could both pass the check and both perform the side-effect. Each now claims the transition with a single conditional update and performs the side-effect only when that claim wins. A scheduled payment retry is claimed out of the due set before the processor is called, so an overlapping scheduler tick can't retry the same card twice; a payment outcome is claimed by its attempt number before the attempt is recorded, so a redelivered webhook can't double-count it. Executing an approved operator request claims the approved-to-executed transition before returning success, so the underlying money move runs once. Approving a seller application claims the in-review-to-approved transition before registering the vendor (rolling back to in-review if registration fails), so one application can't create two vendors. Applying scheduled subscription plan changes claims each pending change before updating the plan and recording its proration invoice, and stamps a deterministic idempotency key on the invoice, so an overlapping or retried scheduler run can't bill the proration twice. Behavior for a single sequential caller is unchanged. **Fixed:** *Scheduled payment retries and recorded outcomes claim before acting* — The retry scheduler now claims each due enrollment (nulling its next-retry time, conditional on the value the tick observed) before composing the processor retry, so two overlapping ticks can't both retry the same card for one scheduled slot — only the claim winner calls the processor. Recording a retry outcome claims the enrollment by bumping its attempt count conditionally on the observed value before writing the attempt row, so a redelivered webhook (or a manual record racing it) can't write a duplicate attempt or double-count toward the cap; the loser returns the already-advanced row. · *Executing an approved request runs the action once* — markExecuted now flips the request from approved to executed with a conditional update (WHERE id = ? AND status = 'approved') and returns success only when that update changes one row; a concurrent second call is refused with a typed error before the application runs the underlying action (a large refund, vendor payout, or bulk operation) a second time. · *Approving a seller application creates one vendor* — approveApplication now claims the in-review-to-approved transition first and registers the vendor only when the claim wins, so two concurrent approvals can't both register a vendor for one application. If vendor registration fails after the claim, the application is rolled back to in-review so it can be approved again rather than stranded as approved with no vendor. · *Applying scheduled plan changes bills the proration once* — applyScheduledChanges now claims each pending change (WHERE id = ? AND status = 'pending') before updating the subscription plan and recording the proration invoice, so an overlapping or retried scheduler run skips an already-applied change instead of billing it twice. Both the scheduled and the immediate plan-change paths now stamp a deterministic idempotency key on the proration invoice, so even a re-run that reached the billing call would dedupe against the unique invoice id rather than write a second charge. A regression test asserts a second apply pass records no additional invoice.
14
+
11
15
  - v0.4.61 (2026-06-19) — **Scheduled and dispatched sends no longer double-fire under concurrent ticks.** Closes a class of concurrency races in the scheduled-send and dispatch paths where a tick read a row, checked its status or schedule cursor in application code, and then performed the send (and its ledger write) before an unconditional update committed — so two overlapping ticks (a cron overrun, two workers, or a manual tick racing the schedule) could both pass the check and both send. Each path now claims the row with a single conditional update — flipping the status, or advancing the schedule cursor, only when the row is still in the expected state — and performs the send only when that claim wins; the losing tick changes zero rows and skips. The affected sends: an email campaign drain (the whole audience could be mailed twice), a queued push notification, a wishlist digest email and its sent-ledger row, a reorder reminder, a survey response record, and a dunning step's reminder email / subscription action. Behavior for a single (sequential) caller is unchanged; only the concurrent double-fire is removed. Where a send fails after the claim wins, the schedule cursor is rolled back so the row is retried on the next tick rather than skipped. **Fixed:** *Email campaign sends claim the campaign before draining* — sendNow, the scheduled dispatch tick, and the consent-gated broadcast drain now flip the campaign from scheduled to sending with a conditional update (WHERE slug = ? AND status = 'scheduled') and drain only when that update changes exactly one row. Previously the status check ran in application code before an unconditional flip, so two concurrent sends could both drain and mail the entire audience twice. The losing caller refuses (a typed send-race error) or, for the tick, skips the campaign. · *Push, wishlist-digest, and reorder-reminder ticks claim each row before sending* — Each per-row advance is now the atomic claim: a queued push notification advances to sent/failed only via WHERE id = ? AND status = 'queued'; a wishlist digest and a reorder reminder advance their schedule cursor with WHERE id = ? AND next_*_at = <the observed value>. The external send (and, for the digest, its sent-ledger row) runs only when the claim changes one row, so two overlapping ticks can't both dispatch the same row. A send that fails after winning the reminder claim rolls the cursor back so the row retries next tick. · *Survey responses and dunning steps gate their side-effect on an atomic claim* — Submitting a survey response now flips the invitation from issued to responded with WHERE id = ? AND status = 'issued' and inserts the response row only when that claim wins (a lost race returns a typed already-responded error), so a double-submit can't record two responses for one invitation. A dunning tick now claims the enrollment with WHERE id = ? AND status = 'active' AND next_action_at = <the observed value> before executing the step (the reminder email / in-app notification / pause or cancel), so overlapping dunning ticks can't send a step twice; the subsequent state transitions carry the same status guard.
12
16
 
13
17
  - v0.4.60 (2026-06-19) — **Free-shipping discount rules now actually waive the shipping charge.** Fixes a checkout defect that made a free_shipping auto-discount rule a complete no-op on the amount charged: the discount engine correctly flagged the order as free-shipping-eligible, but checkout dropped that flag and still billed the customer the full selected shipping rate. A matched free_shipping rule now zeroes the charged shipping in the quote and the confirmed order (the selected rate is still shown so the customer sees what was waived), so the payment is for merchandise and tax only. The rule is also now reserved against its redemption like every other auto-discount, so a free_shipping rule with a usage cap is enforced instead of applying without limit. Subtotal discounts are unaffected — free shipping reduces the shipping line, not the subtotal, and tax (computed on the post-discount subtotal) is unchanged. **Fixed:** *free_shipping auto-discount waives the charged shipping* — A matching free_shipping rule was flagged by the auto-discount engine but never applied to the amount charged — checkout excluded it from the discount (correct, since free shipping is not a subtotal reduction) but then charged the full shipping rate, so the rule saved the customer nothing. The quote and the confirmed order now set the charged shipping to zero when a free_shipping rule matches; the selected shipping service and its rate are still surfaced so the storefront can show the waived amount. The free_shipping rule is included in the order's applied auto-discounts and reserved against its redemption pre-charge, so a capped free-shipping rule is enforced and recorded the same way a percentage or amount-off rule is. A checkout integration test now asserts the charged total, the confirmed order total, and the payment amount all exclude shipping when free shipping applies.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.61",
2
+ "version": "0.4.63",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -516,12 +516,23 @@ function create(opts) {
516
516
  // carts are not the shopper's working cart, they're scratch
517
517
  // carts for downstream fulfillment / split-order quoting).
518
518
  var ts = _now();
519
- // Mark source abandoned first so the active-session index is
520
- // free for any reuse.
521
- await query(
522
- "UPDATE carts SET status = 'abandoned', updated_at = ?1 WHERE id = ?2",
519
+ // Atomic claim: mark the source abandoned in a single
520
+ // conditional UPDATE that re-checks `status = 'active'` in its
521
+ // own WHERE. `_loadCart` read the status in JS, but two
522
+ // concurrent splitCart calls would both pass that check and
523
+ // both mint a full set of child carts (double-create). SQLite /
524
+ // D1 serializes writers, so exactly one of the racing UPDATEs
525
+ // flips active→abandoned (rowCount === 1) and earns the right
526
+ // to mint the children; the loser sees rowCount === 0 and
527
+ // refuses rather than producing a second set of child carts.
528
+ // This also frees the active-session index for any reuse.
529
+ var claim = await query(
530
+ "UPDATE carts SET status = 'abandoned', updated_at = ?1 WHERE id = ?2 AND status = 'active'",
523
531
  [ts, input.cart_id],
524
532
  );
533
+ if (Number(claim.rowCount || 0) !== 1) {
534
+ throw new TypeError("cartBulkOps.splitCart: cart " + input.cart_id + " already split/modified");
535
+ }
525
536
  var children = [];
526
537
  for (var gi = 0; gi < groupOrder.length; gi += 1) {
527
538
  var groupKey = groupOrder[gi];
@@ -492,6 +492,20 @@ function create(opts) {
492
492
  if (!notifications) {
493
493
  return { notified: false, customer_notified_at: null };
494
494
  }
495
+
496
+ // Enqueue FIRST, then record the confirmation stamp — so
497
+ // `customer_notified_at` only ever means "the notice was
498
+ // successfully enqueued", never "claimed but the enqueue may have
499
+ // failed". A claim-before-enqueue stamp let a concurrent caller
500
+ // observe the provisional stamp and report `notified: true` while
501
+ // the winner's enqueue was still pending (and might then throw and
502
+ // roll the stamp back), silently dropping the privacy notice.
503
+ // Recording the stamp only on enqueue success closes that. A
504
+ // concurrent caller that also slipped past the null-check above may
505
+ // enqueue a second time; a duplicate "an operator viewed your
506
+ // account" notice is benign and strictly preferable to missing it,
507
+ // and the audit stamp still resolves to a single value.
508
+ var now = _now();
495
509
  await notifications.enqueue({
496
510
  recipient_id: existing.customer_id,
497
511
  channel: "account-impersonation",
@@ -506,12 +520,21 @@ function create(opts) {
506
520
  expires_at: Number(existing.expires_at),
507
521
  },
508
522
  });
509
- var now = _now();
523
+
524
+ // Stamp the confirmation idempotently — the first writer to land
525
+ // wins (WHERE customer_notified_at IS NULL); a concurrent second
526
+ // caller's UPDATE matches zero rows and returns the already-
527
+ // recorded stamp. An enqueue throw above propagates before this,
528
+ // leaving the row un-stamped so a retry re-attempts delivery.
510
529
  await query(
511
- "UPDATE impersonations SET customer_notified_at = ?1 WHERE id = ?2",
530
+ "UPDATE impersonations SET customer_notified_at = ?1 " +
531
+ "WHERE id = ?2 AND customer_notified_at IS NULL",
512
532
  [now, id],
513
533
  );
514
- return { notified: true, customer_notified_at: now };
534
+ var stampedRow = await _getRow(id);
535
+ var stampedAt = stampedRow && stampedRow.customer_notified_at != null
536
+ ? Number(stampedRow.customer_notified_at) : now;
537
+ return { notified: true, customer_notified_at: stampedAt };
515
538
  },
516
539
 
517
540
  // ---- listForOperator ------------------------------------------------
@@ -693,26 +693,70 @@ function create(opts) {
693
693
  throw drErr;
694
694
  }
695
695
 
696
- // Commit every reparent.
697
- var actual = await _reparentAll(row.source_customer_id, row.target_customer_id);
698
-
699
- // Archive the source customer + insert the redirect marker.
700
- await customers.archiveCustomer(row.source_customer_id);
696
+ // Atomically CLAIM the proposed->executed transition BEFORE
697
+ // running any exactly-once side effect. SQLite serializes
698
+ // writers, so this conditional UPDATE is the race-free claim:
699
+ // two concurrent executeMerge calls on the same proposed row
700
+ // both pass the JS status check above, but only one lands
701
+ // rowCount===1 here — the loser sees rowCount 0 and bails out
702
+ // returning the now-executed row, so the reparent / archive /
703
+ // redirect-insert fire exactly once.
701
704
  var ts = _now();
702
- await query(
703
- "INSERT INTO customer_merge_redirects " +
704
- "(source_customer_id, target_customer_id, merge_id, executed_at) " +
705
- "VALUES (?1, ?2, ?3, ?4)",
706
- [row.source_customer_id, row.target_customer_id, mergeId, ts],
705
+ var claim = await query(
706
+ "UPDATE customer_merges SET status = 'executed', " +
707
+ "executed_at = ?1, executed_by = ?2 WHERE id = ?3 AND status = 'proposed'",
708
+ [ts, executedBy, mergeId],
707
709
  );
710
+ if (Number(claim.rowCount || 0) !== 1) {
711
+ // Lost the race (or the row left 'proposed' between our read
712
+ // and the claim). Re-read: an already-executed row is the
713
+ // winner's result; anything else is a genuine state error.
714
+ var current = await _getMergeRow(mergeId);
715
+ if (current && current.status === "executed") {
716
+ return _hydrateMerge(current);
717
+ }
718
+ var raceErr = new Error("customerMerge.executeMerge: merge_id " + mergeId +
719
+ " is no longer proposed, only proposed merges can be executed");
720
+ raceErr.code = "CUSTOMER_MERGE_NOT_PROPOSED";
721
+ throw raceErr;
722
+ }
708
723
 
709
- // Land the merge in executed; freeze the actual reparent
710
- // counts on plan_json so rollback has the exact footprint.
724
+ // Claim won now run the exactly-once side effects. If any
725
+ // throws, REVERT the claim (self-targeting the row we just
726
+ // flipped) so the merge isn't stranded in 'executed' with the
727
+ // reparents only partially applied; then rethrow.
728
+ var actual;
729
+ try {
730
+ // Commit every reparent.
731
+ actual = await _reparentAll(row.source_customer_id, row.target_customer_id);
732
+
733
+ // Archive the source customer + insert the redirect marker.
734
+ await customers.archiveCustomer(row.source_customer_id);
735
+ await query(
736
+ "INSERT INTO customer_merge_redirects " +
737
+ "(source_customer_id, target_customer_id, merge_id, executed_at) " +
738
+ "VALUES (?1, ?2, ?3, ?4)",
739
+ [row.source_customer_id, row.target_customer_id, mergeId, ts],
740
+ );
741
+ } catch (sideErr) {
742
+ await query(
743
+ "UPDATE customer_merges SET status = 'proposed', " +
744
+ "executed_at = NULL, executed_by = NULL WHERE id = ?1 AND status = 'executed'",
745
+ [mergeId],
746
+ );
747
+ throw sideErr;
748
+ }
749
+
750
+ // Freeze the actual reparent counts on plan_json so rollback
751
+ // has the exact footprint. Status / executed_at / executed_by
752
+ // already landed in the claim above.
711
753
  var sealed = Object.assign({}, capturedPlan, { actual: actual });
754
+ // Gate on still-'executed' so this metadata seal can't land on a
755
+ // row a concurrent rollback (now allowed once the redirect exists)
756
+ // has already flipped to 'rolled_back'.
712
757
  await query(
713
- "UPDATE customer_merges SET status = 'executed', plan_json = ?1, " +
714
- "executed_at = ?2, executed_by = ?3 WHERE id = ?4",
715
- [JSON.stringify(sealed), ts, executedBy, mergeId],
758
+ "UPDATE customer_merges SET plan_json = ?1 WHERE id = ?2 AND status = 'executed'",
759
+ [JSON.stringify(sealed), mergeId],
716
760
  );
717
761
  return _hydrateMerge(await _getMergeRow(mergeId));
718
762
  },
@@ -743,6 +787,8 @@ function create(opts) {
743
787
  throw stErr;
744
788
  }
745
789
  var now = _now();
790
+ // Keep the in-window check BEFORE the claim — a past-window
791
+ // merge must refuse without flipping status.
746
792
  if (now - Number(row.executed_at) > ROLLBACK_WINDOW_MS) {
747
793
  var winErr = new Error("customerMerge.rollbackMerge: merge_id " + mergeId +
748
794
  " executed " + Math.floor((now - Number(row.executed_at)) / C.TIME.days(1)) +
@@ -752,21 +798,56 @@ function create(opts) {
752
798
  throw winErr;
753
799
  }
754
800
 
755
- // Reverse every reparent (target -> source).
756
- await _reparentAll(row.target_customer_id, row.source_customer_id);
757
-
758
- // Restore the source customer + drop the redirect marker.
759
- await customers.restoreCustomer(row.source_customer_id);
760
- await query(
761
- "DELETE FROM customer_merge_redirects WHERE source_customer_id = ?1",
762
- [row.source_customer_id],
763
- );
764
-
765
- await query(
801
+ // Atomically CLAIM the executed->rolled_back transition BEFORE
802
+ // running the reverse reparent / restore / redirect-delete.
803
+ // The conditional UPDATE is the race-free guard: two concurrent
804
+ // rollbackMerge calls both pass the JS status + window checks,
805
+ // but only one lands rowCount===1 here — the loser bails out so
806
+ // the reverse side effects fire exactly once.
807
+ // Gate the claim on the redirect row EXISTING. executeMerge flips
808
+ // status to 'executed' BEFORE its reparent/archive complete and
809
+ // inserts the redirect as its LAST side effect, so the redirect's
810
+ // presence is the proof that the reparent + archive are durable.
811
+ // Without this gate a rollback racing an in-flight execute (status
812
+ // already 'executed', reparents not yet done) would flip to
813
+ // 'rolled_back' and reverse nothing. If the redirect isn't there
814
+ // yet the merge is still finalizing — refuse so the caller retries.
815
+ var claim = await query(
766
816
  "UPDATE customer_merges SET status = 'rolled_back', " +
767
- "rolled_back_at = ?1, rollback_reason = ?2 WHERE id = ?3",
768
- [now, reason, mergeId],
817
+ "rolled_back_at = ?1, rollback_reason = ?2 WHERE id = ?3 AND status = 'executed' " +
818
+ "AND EXISTS (SELECT 1 FROM customer_merge_redirects WHERE source_customer_id = ?4)",
819
+ [now, reason, mergeId, row.source_customer_id],
769
820
  );
821
+ if (Number(claim.rowCount || 0) !== 1) {
822
+ var raceErr = new Error("customerMerge.rollbackMerge: merge_id " + mergeId +
823
+ " is no longer executed (or its merge is still finalizing); only fully-executed merges can be rolled back");
824
+ raceErr.code = "CUSTOMER_MERGE_NOT_EXECUTED";
825
+ throw raceErr;
826
+ }
827
+
828
+ // Claim won — now run the reverse side effects. If any throws,
829
+ // REVERT the claim (self-targeting the row we just flipped) so
830
+ // the merge isn't stranded in 'rolled_back' with the reparents
831
+ // only partially reversed; then rethrow.
832
+ try {
833
+ // Reverse every reparent (target -> source).
834
+ await _reparentAll(row.target_customer_id, row.source_customer_id);
835
+
836
+ // Restore the source customer + drop the redirect marker.
837
+ await customers.restoreCustomer(row.source_customer_id);
838
+ await query(
839
+ "DELETE FROM customer_merge_redirects WHERE source_customer_id = ?1",
840
+ [row.source_customer_id],
841
+ );
842
+ } catch (sideErr) {
843
+ await query(
844
+ "UPDATE customer_merges SET status = 'executed', " +
845
+ "rolled_back_at = NULL, rollback_reason = NULL WHERE id = ?1 AND status = 'rolled_back'",
846
+ [mergeId],
847
+ );
848
+ throw sideErr;
849
+ }
850
+
770
851
  return _hydrateMerge(await _getMergeRow(mergeId));
771
852
  },
772
853
 
@@ -724,11 +724,25 @@ function create(opts) {
724
724
  }
725
725
 
726
726
  var ts = _monotonicTs();
727
- await query(
727
+ // Atomic claim: gate the approved->executed transition on the row
728
+ // still being 'approved'. The JS check above only refuses a
729
+ // sequential re-execute; two concurrent markExecuted calls would
730
+ // both read 'approved', both pass that check, and both return
731
+ // success — so the application would run the underlying money move
732
+ // (a large refund / vendor payout / bulk catalog action) TWICE. The
733
+ // `AND status = 'approved'` predicate lets exactly one caller win
734
+ // the transition; the loser is refused before its side-effect runs.
735
+ var claim = await query(
728
736
  "UPDATE approval_requests SET status = 'executed', executed_by = ?1, " +
729
- "executed_at = ?2, result_json = ?3, updated_at = ?2 WHERE id = ?4",
737
+ "executed_at = ?2, result_json = ?3, updated_at = ?2 WHERE id = ?4 AND status = 'approved'",
730
738
  [executedBy, ts, resultJson, requestId],
731
739
  );
740
+ if (Number(claim.rowCount || 0) !== 1) {
741
+ var raced = new Error("operatorApprovals.markExecuted: request " +
742
+ JSON.stringify(requestId) + " is no longer approved — execution refused");
743
+ raced.code = "APPROVAL_EXECUTE_RACE";
744
+ throw raced;
745
+ }
732
746
 
733
747
  await _audit("approval.execute", executedBy, requestId,
734
748
  { status: req.status },
@@ -286,28 +286,59 @@ function create(opts) {
286
286
  }
287
287
  _assertTransition(existing.status, "approveExchange");
288
288
 
289
- // Pin the replacement shelf BEFORE flipping the DB row. A
290
- // hold failure (insufficient stock, unknown location)
291
- // surfaces to the caller; the row stays pending so the next
292
- // operator attempt decides whether to retry, source from a
293
- // different shelf, or reject the exchange.
294
- if (invAllocs) {
295
- await invAllocs.holdForCart({
296
- cart_id: existing.id,
297
- sku: existing.replacement_sku,
298
- variant_id: existing.replacement_variant_id || null,
299
- quantity: existing.replacement_qty,
300
- // allow:raw-time-literal — hold TTL in SECONDS (passed to holdForCart's ttl_seconds); C.TIME returns ms
301
- ttl_seconds: input.hold_ttl_seconds || 86400,
302
- });
303
- }
304
-
289
+ // Claim pending -> approved ATOMICALLY before the inventory
290
+ // hold. The conditional WHERE (status = 'pending') is the
291
+ // serialization point: two operators racing the same exchange
292
+ // both pass the JS _assertTransition above, but only the writer
293
+ // whose UPDATE matches a still-pending row wins (rowCount === 1).
294
+ // The loser's UPDATE matches zero rows -> it refuses here and
295
+ // never opens a duplicate hold. Without this, both callers would
296
+ // reach holdForCart and pin the replacement SKU twice.
305
297
  var ts = _monotonicTs();
306
- await query(
298
+ var claim = await query(
307
299
  "UPDATE order_exchanges SET status = 'approved', approver_id = ?1, updated_at = ?2 " +
308
- "WHERE id = ?3",
300
+ "WHERE id = ?3 AND status = 'pending'",
309
301
  [approverId, ts, exchangeId],
310
302
  );
303
+ if (Number(claim.rowCount || 0) !== 1) {
304
+ var refused = new Error(
305
+ "order-exchanges: transition 'approveExchange' refused from state '" +
306
+ existing.status + "' (lost the race to a concurrent transition)"
307
+ );
308
+ refused.code = "EXCHANGE_TRANSITION_REFUSED";
309
+ throw refused;
310
+ }
311
+
312
+ // Pin the replacement shelf AFTER winning the claim. A hold
313
+ // failure (insufficient stock, unknown location) surfaces to the
314
+ // caller; we revert the claim back to pending so the next
315
+ // operator attempt decides whether to retry, source from a
316
+ // different shelf, or reject the exchange — the row is never
317
+ // stranded in `approved` without a hold.
318
+ if (invAllocs) {
319
+ try {
320
+ await invAllocs.holdForCart({
321
+ cart_id: existing.id,
322
+ sku: existing.replacement_sku,
323
+ variant_id: existing.replacement_variant_id || null,
324
+ quantity: existing.replacement_qty,
325
+ // allow:raw-time-literal — hold TTL in SECONDS (passed to holdForCart's ttl_seconds); C.TIME returns ms
326
+ ttl_seconds: input.hold_ttl_seconds || 86400,
327
+ });
328
+ } catch (holdErr) {
329
+ // Self-targeting revert: only un-claim the row WE just
330
+ // claimed (status still 'approved'). A concurrent follow-on
331
+ // transition that already moved the row off `approved` is
332
+ // left untouched.
333
+ await query(
334
+ "UPDATE order_exchanges SET status = 'pending', approver_id = NULL, updated_at = ?1 " +
335
+ "WHERE id = ?2 AND status = 'approved'",
336
+ [_monotonicTs(), exchangeId],
337
+ );
338
+ throw holdErr;
339
+ }
340
+ }
341
+
311
342
  return await _getRow(exchangeId);
312
343
  },
313
344
 
@@ -587,6 +587,23 @@ function create(opts) {
587
587
  var advanced = [];
588
588
  for (var i = 0; i < due.rows.length; i += 1) {
589
589
  var enrollment = due.rows[i];
590
+ // Atomic claim: take this enrollment out of the due set (null its
591
+ // next_retry_at) gated on the value THIS tick observed, BEFORE any
592
+ // processor work. Two overlapping ticks (cron overrun / two
593
+ // workers / a manual tick racing the cron) both snapshot the same
594
+ // active+due row; only the one whose conditional UPDATE matches
595
+ // one row proceeds to _tickAdvance, which composes
596
+ // paymentHandle.retryIntent (the real card-charge retry). The
597
+ // loser changes zero rows and skips, so the customer's card is
598
+ // retried exactly once per scheduled slot. _tickAdvance re-stamps
599
+ // next_retry_at to the true next slot (or a terminal NULL).
600
+ var claim = await query(
601
+ "UPDATE payment_retry_enrollments SET next_retry_at = NULL " +
602
+ "WHERE id = ?1 AND status = 'active' AND next_retry_at = ?2",
603
+ [enrollment.id, enrollment.next_retry_at],
604
+ );
605
+ if (Number(claim.rowCount || 0) !== 1) continue; // another tick claimed it
606
+
590
607
  var policy = await _getPolicy(enrollment.policy_slug);
591
608
  if (!policy) {
592
609
  // Policy went missing between enrollment + tick — close
@@ -645,6 +662,24 @@ function create(opts) {
645
662
  var attemptCount = Number(enrollment.attempt_count);
646
663
  var nextAttempt = attemptCount + 1;
647
664
 
665
+ // Atomic claim: bump attempt_count gated on the value we observed,
666
+ // BEFORE writing the attempt ledger row. The status check above
667
+ // only refuses a SEQUENTIAL re-record once the row is terminal;
668
+ // two concurrent recordRetryOutcome calls while still active (a
669
+ // webhook redelivery, or a manual record racing the webhook) would
670
+ // both read attempt_count=N, both write a duplicate attempt row,
671
+ // and both double-count toward the cap. Only the caller whose
672
+ // UPDATE matches one row proceeds; a loser returns the
673
+ // already-advanced row idempotently.
674
+ var outcomeClaim = await query(
675
+ "UPDATE payment_retry_enrollments SET attempt_count = ?1 " +
676
+ "WHERE id = ?2 AND status = 'active' AND attempt_count = ?3",
677
+ [nextAttempt, enrollment.id, attemptCount],
678
+ );
679
+ if (Number(outcomeClaim.rowCount || 0) !== 1) {
680
+ return await _refetchEnrollment(enrollment.id);
681
+ }
682
+
648
683
  await _writeAttempt(enrollment.id, nextAttempt, succeeded, succeeded ? null : newFailureCode);
649
684
 
650
685
  if (succeeded) {
package/lib/pick-lists.js CHANGED
@@ -548,31 +548,69 @@ function create(opts) {
548
548
  }
549
549
  perOrder[oid].push(list.lines[k]);
550
550
  }
551
- var shipments = [];
552
- for (var m = 0; m < orderSeq.length; m += 1) {
553
- var ord = orderSeq[m];
554
- // A picked-from-shelf parcel has no carrier yet the operator
555
- // assigns one when they hand it off. orderTracking's carrier enum
556
- // doesn't carry a "pickup" / "none" value, so the parcel rides the
557
- // 'other' escape hatch with a "pickup" label until a real carrier
558
- // is recorded against it (the operator updates the shipment, or a
559
- // shipping-label record supplies the carrier).
560
- var s = await orderTracking.createShipment({
561
- order_id: ord,
562
- carrier: "other",
563
- carrier_other_name: "pickup",
564
- notes: "pick-list:" + listId,
565
- });
566
- shipments.push({
567
- order_id: ord,
568
- shipment_id: s.id,
569
- });
570
- }
551
+
552
+ // Atomically claim completion BEFORE fanning out any shipment.
553
+ // The JS status check above is necessary (it produces the
554
+ // operator-facing "list is <status>" refusal) but NOT sufficient:
555
+ // two concurrent markListComplete callers both read 'in_progress'
556
+ // and would each run the createShipment loop, double-creating one
557
+ // shipment per parent order. The conditional UPDATE guarded by
558
+ // the same precondition in its own WHERE — is the atomic claim
559
+ // (SQLite/D1 serializes writers, so exactly one UPDATE matches a
560
+ // row in an eligible state). The loser sees rowCount===0 and
561
+ // refuses; only the winner proceeds to createShipment.
571
562
  var ts = _now();
572
- await query(
573
- "UPDATE pick_lists SET status = 'complete', completed_at = ?1 WHERE id = ?2",
563
+ var claim = await query(
564
+ "UPDATE pick_lists SET status = 'complete', completed_at = ?1 " +
565
+ "WHERE id = ?2 AND status IN ('generated', 'in_progress')",
574
566
  [ts, listId],
575
567
  );
568
+ if (Number(claim.rowCount || 0) !== 1) {
569
+ // Lost the race (or the row left an eligible state between the
570
+ // read and the claim). Surface the same terminal-state refusal
571
+ // a sequential caller would see — the shipments belong to the
572
+ // caller that won the claim.
573
+ throw new TypeError("pick-lists.markListComplete: list is no longer " +
574
+ "generated or in_progress (already completed or cancelled by a concurrent caller)");
575
+ }
576
+
577
+ var shipments = [];
578
+ try {
579
+ for (var m = 0; m < orderSeq.length; m += 1) {
580
+ var ord = orderSeq[m];
581
+ // A picked-from-shelf parcel has no carrier yet — the operator
582
+ // assigns one when they hand it off. orderTracking's carrier enum
583
+ // doesn't carry a "pickup" / "none" value, so the parcel rides the
584
+ // 'other' escape hatch with a "pickup" label until a real carrier
585
+ // is recorded against it (the operator updates the shipment, or a
586
+ // shipping-label record supplies the carrier).
587
+ var s = await orderTracking.createShipment({
588
+ order_id: ord,
589
+ carrier: "other",
590
+ carrier_other_name: "pickup",
591
+ notes: "pick-list:" + listId,
592
+ });
593
+ shipments.push({
594
+ order_id: ord,
595
+ shipment_id: s.id,
596
+ });
597
+ }
598
+ } catch (e) {
599
+ // The side-effect threw after the claim landed. Revert the claim
600
+ // (self-targeting WHERE so we only undo a row WE moved to
601
+ // 'complete') so the list isn't stranded terminal with a
602
+ // partial / zero shipment fan-out — the operator can retry once
603
+ // the createShipment fault clears.
604
+ try {
605
+ await query(
606
+ "UPDATE pick_lists SET status = ?1, completed_at = NULL " +
607
+ "WHERE id = ?2 AND status = 'complete'",
608
+ [list.status, listId],
609
+ );
610
+ } catch (_e) { /* drop-silent — the original createShipment error is what the operator needs */ }
611
+ throw e;
612
+ }
613
+
576
614
  var hydrated = await _getHydrated(listId);
577
615
  hydrated.shipments = shipments;
578
616
  return hydrated;
@@ -382,6 +382,10 @@ function create(opts) {
382
382
  period_end: sub.current_period_end,
383
383
  amount_minor: net,
384
384
  currency: proposed.currency.toUpperCase(),
385
+ // Deterministic idempotency key keyed on this change row so
386
+ // a re-applied change dedupes against the UNIQUE
387
+ // processor_invoice_id instead of double-billing.
388
+ processor_invoice_id: "planchange:" + id,
385
389
  });
386
390
  } catch (_e) {
387
391
  // Drop-silent — by design. The billing handle is an
@@ -468,10 +472,21 @@ function create(opts) {
468
472
  var executed = [];
469
473
  for (var i = 0; i < due.rows.length; i += 1) {
470
474
  var row = due.rows[i];
471
- await query(
472
- "UPDATE subscription_plan_changes SET status = 'executed', executed_at = ?1 WHERE id = ?2",
475
+ // Atomic claim: flip pending -> executed gated on the status.
476
+ // Two overlapping scheduler runs (cron overrun, retry, two
477
+ // workers) both SELECT the same due row; WITHOUT this guard both
478
+ // would update the subscription plan AND record a proration
479
+ // invoice — billing the customer twice for one change. Only the
480
+ // caller whose UPDATE matches one row proceeds; the loser skips.
481
+ // (The synchronous executeChange path is already protected by its
482
+ // plan_id claim; this is the batch path's equivalent.)
483
+ var claim = await query(
484
+ "UPDATE subscription_plan_changes SET status = 'executed', executed_at = ?1 " +
485
+ "WHERE id = ?2 AND status = 'pending'",
473
486
  [now, row.id],
474
487
  );
488
+ if (Number(claim.rowCount || 0) !== 1) continue; // another run claimed it
489
+
475
490
  await query(
476
491
  "UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3",
477
492
  [row.to_plan_id, now, row.subscription_id],
@@ -492,6 +507,10 @@ function create(opts) {
492
507
  period_end: periodEnd,
493
508
  amount_minor: net,
494
509
  currency: String(row.currency).toUpperCase(),
510
+ // Deterministic idempotency key — this change applies once,
511
+ // so a re-run dedupes against the UNIQUE processor_invoice_id
512
+ // rather than writing a second proration invoice.
513
+ processor_invoice_id: "planchange:" + row.id,
495
514
  });
496
515
  } catch (_e) {
497
516
  // Drop-silent — see executeChange.