@blamejs/blamejs-shop 0.4.23 → 0.4.24

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.
@@ -1,13 +1,13 @@
1
1
  {
2
- "version": "0.4.23",
2
+ "version": "0.4.24",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
- "integrity": "sha384-6k53cvkRrxMgmeStLIoLjVXZQHqIJgTmv1Izd8TYhh1HOC4POgE6GCvx1bsalyEP",
6
- "fingerprinted": "css/admin.44eb97700c660798.css"
5
+ "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
6
+ "fingerprinted": "css/admin.6941d5151488a7c1.css"
7
7
  },
8
8
  "css/main.css": {
9
- "integrity": "sha384-nRqjlZAYU5a0XVp9OWxcVw3zzZpj6vPcj+5XRf4byAgK49fg8W5QFx8vFLm40ldA",
10
- "fingerprinted": "css/main.4eb7c53f87817566.css"
9
+ "integrity": "sha384-z6x2cNhNfsxxs7OZi3ZIg84HAcsJIUyhH4RjZwuHCdf03Rr68dm9zxciLgThkGLh",
10
+ "fingerprinted": "css/main.cf0a6763075ece73.css"
11
11
  },
12
12
  "js/announcement.js": {
13
13
  "integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
package/lib/customers.js CHANGED
@@ -167,6 +167,23 @@ function create(opts) {
167
167
  return b.crypto.namespaceHash(EMAIL_NAMESPACE, email);
168
168
  }
169
169
 
170
+ // Monotonic upsert of the per-customer session-revocation boundary. The
171
+ // INSERT-OR-conflict keeps the LATER of the existing and incoming value
172
+ // so two concurrent revokes (erasure + a passkey-revoke firing together)
173
+ // can never roll the boundary backwards and resurrect a cookie one of
174
+ // them meant to kill. `MAX(...)` on the conflict path is the conservation
175
+ // guard; `excluded` references the row the INSERT tried to add.
176
+ async function _bumpSessionRevocation(id, at) {
177
+ await query(
178
+ "INSERT INTO customer_session_revocations (customer_id, sessions_valid_from, updated_at) " +
179
+ "VALUES (?1, ?2, ?2) " +
180
+ "ON CONFLICT(customer_id) DO UPDATE SET " +
181
+ "sessions_valid_from = MAX(customer_session_revocations.sessions_valid_from, excluded.sessions_valid_from), " +
182
+ "updated_at = excluded.updated_at",
183
+ [id, at],
184
+ );
185
+ }
186
+
170
187
  var api = {
171
188
  EMAIL_NAMESPACE: EMAIL_NAMESPACE,
172
189
 
@@ -600,6 +617,12 @@ function create(opts) {
600
617
  );
601
618
  emailHashCleared = Number((upd && upd.rowCount) || 0);
602
619
  }
620
+ // Terminate every live sealed auth cookie for the erased account.
621
+ // Deleting the durable credentials stops a NEW session from being
622
+ // minted, but a cookie issued before erasure is stateless and stays
623
+ // valid for its 14-day TTL — so the anonymized account remains usable
624
+ // until then unless we move the revocation boundary forward now.
625
+ await _bumpSessionRevocation(id, ts);
603
626
  return {
604
627
  passkeys: Number((pkDel && pkDel.rowCount) || 0),
605
628
  oauth_identities: Number((oaDel && oaDel.rowCount) || 0),
@@ -607,6 +630,36 @@ function create(opts) {
607
630
  };
608
631
  },
609
632
 
633
+ // Move the customer's session-revocation boundary to `at` (default
634
+ // now). Every sealed auth cookie whose `iat` predates the boundary —
635
+ // and every pre-`iat` cookie once a boundary exists — fails the
636
+ // revocation check on its next authenticated request and signs out.
637
+ // Idempotent and monotonic: a later bump only ever moves the boundary
638
+ // forward, so two concurrent revokes can't roll it backwards. Used by
639
+ // erasure (above), passkey-revoke, and explicit sign-out.
640
+ revokeSessionsForCustomer: async function (id, at) {
641
+ _uuid(id, "customer id");
642
+ var boundary = (at == null) ? _now() : at;
643
+ if (!Number.isInteger(boundary) || boundary < 0) {
644
+ throw new TypeError("customers.revokeSessionsForCustomer: at must be a non-negative integer epoch-ms or null");
645
+ }
646
+ await _bumpSessionRevocation(id, boundary);
647
+ return { customer_id: id, sessions_valid_from: boundary };
648
+ },
649
+
650
+ // The customer's session-revocation boundary (epoch-ms), or 0 when no
651
+ // revocation has ever been recorded (the default-allow state). The
652
+ // auth-cookie reader compares the cookie's `iat` against this: a cookie
653
+ // is live when the boundary is 0, or when its `iat` is >= the boundary.
654
+ sessionsValidFrom: async function (id) {
655
+ _uuid(id, "customer id");
656
+ var row = (await query(
657
+ "SELECT sessions_valid_from FROM customer_session_revocations WHERE customer_id = ?1 LIMIT 1",
658
+ [id],
659
+ )).rows[0];
660
+ return row ? Number(row.sessions_valid_from) : 0;
661
+ },
662
+
610
663
  // Mutate a customer's editable profile fields. v1 covers display_name
611
664
  // only — the one field a customer can safely change without a
612
665
  // verification round trip.
@@ -576,6 +576,25 @@ function create(opts) {
576
576
  throw new TypeError("cycle-counting.finalizeCount: count is " + header.status +
577
577
  ", only scheduled or in_progress counts can be finalized");
578
578
  }
579
+ // Claim the (scheduled|in_progress) -> finalized transition atomically
580
+ // BEFORE computing variances or applying adjustments. Two concurrent
581
+ // finalizes both pass the read above, but only one UPDATE matches the
582
+ // expected status; the loser refuses, so the per-shelf adjustments are
583
+ // applied exactly once. (Without this guard both calls would write the
584
+ // variance adjustments through inventoryLocations.adjustStock, double-
585
+ // applying.) The final aggregate counts are stamped below once the loop
586
+ // has run.
587
+ var ts = _now();
588
+ var claim = await query(
589
+ "UPDATE cycle_counts SET status = 'finalized', finalized_at = ?1 " +
590
+ "WHERE slug = ?2 AND status IN ('scheduled', 'in_progress')",
591
+ [ts, input.slug],
592
+ );
593
+ if (Number(claim.rowCount || 0) !== 1) {
594
+ throw new TypeError("cycle-counting.finalizeCount: count " +
595
+ JSON.stringify(input.slug) + " is no longer scheduled or in_progress " +
596
+ "(finalized by a concurrent call)");
597
+ }
579
598
  var lines = await _getLines(input.slug);
580
599
  var varianceCount = 0;
581
600
  var varianceValueMinor = 0;
@@ -616,11 +635,12 @@ function create(opts) {
616
635
  }
617
636
  }
618
637
  }
619
- var ts = _now();
638
+ // Status + finalized_at were already claimed above; stamp the computed
639
+ // aggregate variance totals onto the (now-finalized) header.
620
640
  await query(
621
- "UPDATE cycle_counts SET status = 'finalized', variance_count = ?1, " +
622
- "variance_value_minor = ?2, finalized_at = ?3 WHERE slug = ?4",
623
- [varianceCount, varianceValueMinor, ts, input.slug],
641
+ "UPDATE cycle_counts SET variance_count = ?1, variance_value_minor = ?2 " +
642
+ "WHERE slug = ?3",
643
+ [varianceCount, varianceValueMinor, input.slug],
624
644
  );
625
645
  return {
626
646
  variance_count: varianceCount,
@@ -72,6 +72,13 @@ var b = require("./vendor/blamejs");
72
72
  var KINDS = ["credit", "debit", "expire"];
73
73
  var SOURCES = ["purchase", "refund_to_giftcard", "promotional", "manual"];
74
74
 
75
+ var SHA3_512_HEX_LEN = 128;
76
+ // Genesis anchor for a card's first ledger row. Each gift card is its own
77
+ // independent chain — the first row keyed by a `gift_card_id` links to
78
+ // ZERO, every subsequent row links to the prior row's row_hash for the
79
+ // SAME card.
80
+ var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
81
+
75
82
  var MAX_SOURCE_REF_LEN = 128;
76
83
  // Source_ref / reason are short correlation handles (originating
77
84
  // order id, refund handle, campaign code, operator note). Refuse
@@ -130,6 +137,44 @@ function _epochMs(ts, label) {
130
137
 
131
138
  function _now() { return Date.now(); }
132
139
 
140
+ // ---- chain hashing ------------------------------------------------------
141
+ //
142
+ // Mirrors operator-audit-log's composition exactly: the preimage is
143
+ //
144
+ // row_hash = SHA3-512(prev_hash || canonical-json(row-fields))
145
+ //
146
+ // where row-fields is every persisted ledger column except prev_hash +
147
+ // row_hash. `b.auditChain.canonicalize` (RFC 8785 walker, sorted keys,
148
+ // hash columns excluded) makes the preimage byte-identical across
149
+ // deployments + node versions. We deliberately do NOT use
150
+ // b.auditChain.computeRowHash / verifyChain — those expect camelCase
151
+ // rowHash / monotonicCounter / nonce columns this snake_case schema does
152
+ // not carry; composing canonicalize + sha3Hash directly is the
153
+ // snake_case-compatible path.
154
+
155
+ function _rowFieldsForHash(row) {
156
+ return {
157
+ id: row.id,
158
+ gift_card_id: row.gift_card_id,
159
+ kind: row.kind,
160
+ amount_minor: Number(row.amount_minor),
161
+ source: row.source == null ? null : row.source,
162
+ source_ref: row.source_ref == null ? null : row.source_ref,
163
+ order_id: row.order_id == null ? null : row.order_id,
164
+ balance_after_minor: Number(row.balance_after_minor),
165
+ occurred_at: Number(row.occurred_at),
166
+ };
167
+ }
168
+
169
+ function _computeRowHash(prevHash, rowFields) {
170
+ var canonical = b.auditChain.canonicalize(rowFields, ["prev_hash", "row_hash"]);
171
+ var preimage = Buffer.concat([
172
+ Buffer.from(prevHash, "hex"),
173
+ Buffer.from(canonical, "utf8"),
174
+ ]);
175
+ return b.crypto.sha3Hash(preimage);
176
+ }
177
+
133
178
  // ---- factory ------------------------------------------------------------
134
179
 
135
180
  function create(opts) {
@@ -158,12 +203,21 @@ function create(opts) {
158
203
  // `_resolveOccurredAt`).
159
204
  async function _readLatest(giftCardId) {
160
205
  var r = await query(
161
- "SELECT balance_after_minor, occurred_at FROM gift_card_ledger " +
162
- "WHERE gift_card_id = ?1 ORDER BY occurred_at DESC LIMIT 1",
206
+ "SELECT id, balance_after_minor, occurred_at, row_hash FROM gift_card_ledger " +
207
+ "WHERE gift_card_id = ?1 ORDER BY occurred_at DESC, id DESC LIMIT 1",
163
208
  [giftCardId],
164
209
  );
165
- if (!r.rows.length) return { balance: 0, occurred_at: null };
166
- return { balance: r.rows[0].balance_after_minor, occurred_at: r.rows[0].occurred_at };
210
+ if (!r.rows.length) return { id: null, balance: 0, occurred_at: null, row_hash: ZERO_HASH };
211
+ return {
212
+ id: r.rows[0].id,
213
+ balance: r.rows[0].balance_after_minor,
214
+ occurred_at: r.rows[0].occurred_at,
215
+ // A legacy pre-chain row carries NULL row_hash — chain forward from
216
+ // ZERO so the first hashed row anchors the chain; verifyChain treats
217
+ // the unhashed legacy prefix as unverifiable (NULL row_hash break)
218
+ // rather than silently trusting it.
219
+ row_hash: r.rows[0].row_hash == null ? ZERO_HASH : r.rows[0].row_hash,
220
+ };
167
221
  }
168
222
 
169
223
  async function _currentBalance(giftCardId) {
@@ -187,13 +241,30 @@ function create(opts) {
187
241
  return latestTs + 1;
188
242
  }
189
243
 
190
- async function _writeRow(giftCardId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, ts) {
244
+ // credit() + expire() write through here: they already read the prior
245
+ // row (balance + occurred_at), so chaining is a straight read-then-write
246
+ // — `prevHash` is that prior row's row_hash, and row_hash is computed
247
+ // over the fully-resolved field tuple before the INSERT. debit() does
248
+ // NOT use this path: its overdraft guard must stay a single atomic
249
+ // statement, so it chains separately (see below).
250
+ async function _writeRow(giftCardId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, ts, prevHash) {
191
251
  var id = b.uuid.v7();
252
+ var rowHash = _computeRowHash(prevHash, {
253
+ id: id,
254
+ gift_card_id: giftCardId,
255
+ kind: kind,
256
+ amount_minor: amountMinor,
257
+ source: source,
258
+ source_ref: sourceRef,
259
+ order_id: orderId,
260
+ balance_after_minor: balanceAfter,
261
+ occurred_at: ts,
262
+ });
192
263
  await query(
193
264
  "INSERT INTO gift_card_ledger " +
194
- "(id, gift_card_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, occurred_at) " +
195
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
196
- [id, giftCardId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, ts],
265
+ "(id, gift_card_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, occurred_at, prev_hash, row_hash) " +
266
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
267
+ [id, giftCardId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, ts, prevHash, rowHash],
197
268
  );
198
269
  return id;
199
270
  }
@@ -216,7 +287,7 @@ function create(opts) {
216
287
  var latest = await _readLatest(giftCardId);
217
288
  var ts = _resolveOccurredAt(requested, latest.occurred_at);
218
289
  var after = latest.balance + amount;
219
- var id = await _writeRow(giftCardId, "credit", amount, source, sourceRef, null, after, ts);
290
+ var id = await _writeRow(giftCardId, "credit", amount, source, sourceRef, null, after, ts, latest.row_hash);
220
291
 
221
292
  return {
222
293
  id: id,
@@ -334,7 +405,7 @@ function create(opts) {
334
405
  }
335
406
  var ts = _resolveOccurredAt(requested, latest.occurred_at);
336
407
  var after = latest.balance - toBurn;
337
- var id = await _writeRow(giftCardId, "expire", toBurn, null, reason, null, after, ts);
408
+ var id = await _writeRow(giftCardId, "expire", toBurn, null, reason, null, after, ts, latest.row_hash);
338
409
 
339
410
  return {
340
411
  id: id,
package/lib/giftcards.js CHANGED
@@ -406,6 +406,94 @@ function create(opts) {
406
406
  return reversed;
407
407
  },
408
408
 
409
+ // Credit a card's spend back PROPORTIONALLY to a partial refund — the
410
+ // refund-by-amount counterpart to reverseRedemption's all-or-nothing
411
+ // (cancel) release. A full refund (cancel / payment-failed) returns the
412
+ // whole spend; a partial refund must return only the share the refund
413
+ // covers, or a 10%-refunded order would re-mint the entire gift-card
414
+ // value as spendable while the customer keeps 90% of the goods.
415
+ //
416
+ // For each redemption against the order the target cumulative reversal is
417
+ // floor(amount_minor * refunded_minor / order_total_minor), clamped to
418
+ // amount_minor so rounding + a refund that exceeds the recorded total can
419
+ // never credit back more than the card actually paid. The credit is the
420
+ // delta between that target and what's already been reversed
421
+ // (reversed_minor), so a partial-then-final refund sequence converges
422
+ // exactly on the spend without ever over-crediting. reversed_minor is
423
+ // advanced under a guarded UPDATE keyed on the row's live value, so two
424
+ // concurrent reversals can't both credit the same slice (the second sees
425
+ // its expected-prior value no longer matches and re-derives the delta as
426
+ // zero). A redemption that reaches full reversal also stamps reversed_at
427
+ // so the existing (binary) reverseRedemption short-circuits it.
428
+ //
429
+ // Returns the list of redemptions that received a non-zero credit, each
430
+ // with the minor-units credited this call — the caller writes the
431
+ // matching ledger credits. An order that used no gift card, or one
432
+ // already reversed up to the requested proportion, returns [].
433
+ reverseRedemptionProRata: async function (orderId, input) {
434
+ _uuid(orderId, "order_id");
435
+ input = input || {};
436
+ var refundedMinor = input.refunded_minor;
437
+ var orderTotalMinor = input.order_total_minor;
438
+ if (!Number.isInteger(refundedMinor) || refundedMinor < 0) {
439
+ throw new TypeError("giftcards.reverseRedemptionProRata: refunded_minor must be a non-negative integer (minor units)");
440
+ }
441
+ if (!Number.isInteger(orderTotalMinor) || orderTotalMinor <= 0) {
442
+ throw new TypeError("giftcards.reverseRedemptionProRata: order_total_minor must be a positive integer (minor units)");
443
+ }
444
+ // A refund can't exceed the order total; clamp the fraction at 1 so a
445
+ // recorded over-refund (rounding at the provider, a manual adjustment)
446
+ // never targets more than the full spend.
447
+ var effRefunded = refundedMinor > orderTotalMinor ? orderTotalMinor : refundedMinor;
448
+ var rows = (await query(
449
+ "SELECT id, giftcard_id, amount_minor, reversed_minor FROM giftcard_redemptions " +
450
+ "WHERE order_id = ?1",
451
+ [orderId],
452
+ )).rows;
453
+ var credited = [];
454
+ for (var i = 0; i < rows.length; i += 1) {
455
+ var red = rows[i];
456
+ var amount = Number(red.amount_minor);
457
+ var already = Number(red.reversed_minor || 0);
458
+ // Proportional target in minor units. floor so we never round UP
459
+ // into crediting a fraction the refund didn't cover; clamp to the
460
+ // full spend so cumulative reversals can't exceed it.
461
+ var target = Math.floor((amount * effRefunded) / orderTotalMinor);
462
+ if (target > amount) target = amount;
463
+ var delta = target - already;
464
+ if (delta <= 0) continue; // already reversed to (or past) this proportion
465
+ var ts = _now();
466
+ var nowFull = target >= amount;
467
+ // Claim the slice: advance reversed_minor from its expected prior
468
+ // value so a concurrent reversal can't double-credit. Stamp
469
+ // reversed_at only when the redemption is now fully reversed (so the
470
+ // binary reverseRedemption treats it as done).
471
+ var claim = await query(
472
+ "UPDATE giftcard_redemptions SET reversed_minor = ?1, " +
473
+ "reversed_at = CASE WHEN ?2 = 1 THEN ?3 ELSE reversed_at END " +
474
+ "WHERE id = ?4 AND reversed_minor = ?5",
475
+ [target, nowFull ? 1 : 0, ts, red.id, already],
476
+ );
477
+ if (Number(claim.rowCount || 0) === 0) continue; // lost the claim to a concurrent reversal
478
+ // Restore the spendable balance on the card row. Capped at the card's
479
+ // issued_minor by the schema CHECK; the cumulative reversed_minor is
480
+ // bounded by amount_minor, so it can't exceed the face value.
481
+ // Reactivate a card drained to `redeemed` — it carries balance again.
482
+ await query(
483
+ "UPDATE giftcards SET balance_minor = balance_minor + ?1, " +
484
+ "status = CASE WHEN status = 'redeemed' THEN 'active' ELSE status END, " +
485
+ "updated_at = ?2 WHERE id = ?3",
486
+ [delta, ts, red.giftcard_id],
487
+ );
488
+ credited.push({
489
+ redemption_id: red.id,
490
+ gift_card_id: red.giftcard_id,
491
+ amount_minor: delta,
492
+ });
493
+ }
494
+ return credited;
495
+ },
496
+
409
497
  "void": async function (id, opts2) {
410
498
  opts2 = opts2 || {};
411
499
  _uuid(id, "giftcard id");
@@ -369,25 +369,44 @@ function create(opts) {
369
369
  " has no location_code — pin a location before commit");
370
370
  }
371
371
 
372
- // Debit the committed shelf through inventoryLocations.adjustStock
373
- // FIRST. If the shelf write fails (insufficient stock, unknown
374
- // location), the hold row stays in 'held' status so a retry or
375
- // a manual release decides next. Only after the debit lands does
376
- // the hold flip to 'committed' the audit trail then proves
377
- // the commit happened.
378
- await locations.adjustStock({
379
- sku: existing.sku,
380
- location_code: existing.location_code,
381
- delta: -existing.quantity,
382
- reason: "hold-commit:" + existing.id + " order:" + input.order_id,
383
- });
384
-
372
+ // Claim the held -> committed transition atomically BEFORE debiting the
373
+ // shelf. Two concurrent commits both pass the status read above, but only
374
+ // one UPDATE matches `status = 'held'`; the loser sees rowCount 0 and
375
+ // refuses, so the committed shelf is debited exactly once. (Without this
376
+ // guard both calls would debit the shelf, oversell.) The committed_order_id
377
+ // also lands now so the row's order pointer is set before the debit.
385
378
  var ts = _monotonicTs();
386
- await query(
379
+ var claim = await query(
387
380
  "UPDATE inventory_holds SET status = 'committed', committed_at = ?1, " +
388
381
  "committed_order_id = ?2 WHERE id = ?3 AND status = 'held'",
389
382
  [ts, input.order_id, input.hold_id],
390
383
  );
384
+ if (Number(claim.rowCount || 0) !== 1) {
385
+ throw new TypeError("inventory-allocations.commitHold: hold " + existing.id +
386
+ " is no longer held (committed by a concurrent call)");
387
+ }
388
+
389
+ // Debit the committed shelf through inventoryLocations.adjustStock. If the
390
+ // shelf write fails (insufficient stock, unknown location), release the
391
+ // claim back to 'held' so a retry or a manual release decides next — the
392
+ // hold must not strand in 'committed' with no shelf movement behind it.
393
+ try {
394
+ await locations.adjustStock({
395
+ sku: existing.sku,
396
+ location_code: existing.location_code,
397
+ delta: -existing.quantity,
398
+ reason: "hold-commit:" + existing.id + " order:" + input.order_id,
399
+ });
400
+ } catch (e) {
401
+ try {
402
+ await query(
403
+ "UPDATE inventory_holds SET status = 'held', committed_at = NULL, " +
404
+ "committed_order_id = NULL WHERE id = ?1 AND status = 'committed'",
405
+ [input.hold_id],
406
+ );
407
+ } catch (_e2) { /* drop-silent — the adjustStock error is the caller's signal */ }
408
+ throw e;
409
+ }
391
410
  return _shapeHold(await _getHoldRow(input.hold_id));
392
411
  }
393
412
 
@@ -78,6 +78,43 @@ var MAX_LIST_LIMIT = 200;
78
78
  var RECEIPT_STATUSES = Object.freeze(["pending", "applied", "reversed"]);
79
79
  var RECEIPT_ORDER_KEY = ["received_at:desc", "id:desc"];
80
80
 
81
+ // The receipt lifecycle, modelled on b.fsm — the same composition the order /
82
+ // quote / stock-transfer primitives use. The FSM is the single source of truth
83
+ // for which (status, event) pairs are legal:
84
+ //
85
+ // apply pending -> applied (apply; restocks every line)
86
+ // reverse applied -> reversed (reverse; decrements every line back out)
87
+ //
88
+ // Terminal (reversed) declares no outgoing edge, so the machine itself refuses
89
+ // a reverse-after-reverse. `apply` on an already-'applied' receipt is a
90
+ // documented IDEMPOTENT no-op (returns { applied_count: 0 }) — apply() short-
91
+ // circuits to that no-op before consulting the machine, preserving the
92
+ // replay-safe contract callers depend on.
93
+ var RECEIPT_TRANSITIONS = Object.freeze([
94
+ { from: "pending", to: "applied", on: "apply" },
95
+ { from: "applied", to: "reversed", on: "reverse" },
96
+ ]);
97
+
98
+ var _receiptFsm = null;
99
+ function _getReceiptFsm() {
100
+ if (_receiptFsm) return _receiptFsm;
101
+ try { b.audit.registerNamespace("fsm"); } catch (_e) { /* idempotent; ignore */ }
102
+ _receiptFsm = b.fsm.define({
103
+ name: "inventory_receipt",
104
+ initial: "pending",
105
+ states: { pending: {}, applied: {}, reversed: {} },
106
+ transitions: RECEIPT_TRANSITIONS.map(function (t) {
107
+ return { from: t.from, to: t.to, on: t.on };
108
+ }),
109
+ });
110
+ return _receiptFsm;
111
+ }
112
+
113
+ function _canReceipt(fromStatus, event) {
114
+ var fsm = _getReceiptFsm();
115
+ return fsm.restore({ state: fromStatus, history: [], context: {} }).can(event);
116
+ }
117
+
81
118
  // ---- validators ---------------------------------------------------------
82
119
 
83
120
  function _id(s, label) {
@@ -293,13 +330,32 @@ function create(opts) {
293
330
  if (!receipt) {
294
331
  throw new TypeError("inventory-receive.apply: receipt " + receiptId + " not found");
295
332
  }
296
- if (receipt.status === "applied") {
297
- // Idempotent caller can replay without surprise.
298
- return { id: receiptId, applied_count: 0, stock_changes: [] };
299
- }
300
- if (receipt.status !== "pending") {
333
+ // Preserve the idempotent no-op on an already-'applied' receipt: the
334
+ // FSM has no apply edge out of 'applied', so short-circuit here BEFORE
335
+ // asserting the transition (regression-safe replay contract callers
336
+ // replay apply() without surprise).
337
+ if (!_canReceipt(receipt.status, "apply")) {
338
+ if (receipt.status === "applied") {
339
+ return { id: receiptId, applied_count: 0, stock_changes: [] };
340
+ }
301
341
  throw new TypeError("inventory-receive.apply: receipt is " + receipt.status + ", only pending receipts can be applied");
302
342
  }
343
+ // Claim the pending -> applied transition atomically BEFORE restocking.
344
+ // Two concurrent applies both pass the read above, but only one UPDATE
345
+ // matches `status = 'pending'`; the loser sees rowCount 0 and returns the
346
+ // idempotent no-op instead of double-restocking the catalog. (Without
347
+ // this guard both calls would restock every line, inflating
348
+ // stock_on_hand.)
349
+ var ts = _now();
350
+ var claim = await query(
351
+ "UPDATE inventory_receipts SET status = 'applied', updated_at = ?1 WHERE id = ?2 AND status = 'pending'",
352
+ [ts, receiptId],
353
+ );
354
+ if (Number(claim.rowCount || 0) !== 1) {
355
+ // A concurrent call won the claim and is restocking (or already did).
356
+ // Replay-safe no-op so the loser doesn't double-apply.
357
+ return { id: receiptId, applied_count: 0, stock_changes: [] };
358
+ }
303
359
  var stockChanges = [];
304
360
  var applied = [];
305
361
  try {
@@ -310,8 +366,8 @@ function create(opts) {
310
366
  stockChanges.push({ sku: l.sku, qty: l.qty_received });
311
367
  }
312
368
  } catch (e) {
313
- // Undo every successful restock so the database state matches
314
- // the pre-apply snapshot. The receipt stays 'pending' so the
369
+ // Undo every successful restock so the database state matches the
370
+ // pre-apply snapshot, then release the claim back to 'pending' so the
315
371
  // operator can fix the offending line and retry.
316
372
  for (var j = applied.length - 1; j >= 0; j -= 1) {
317
373
  try {
@@ -321,15 +377,16 @@ function create(opts) {
321
377
  );
322
378
  } catch (_e3) { /* drop-silent — the original apply error is what the operator needs to fix */ }
323
379
  }
380
+ try {
381
+ await query(
382
+ "UPDATE inventory_receipts SET status = 'pending', updated_at = ?1 WHERE id = ?2 AND status = 'applied'",
383
+ [_now(), receiptId],
384
+ );
385
+ } catch (_e4) { /* drop-silent — the original apply error is the operator's signal */ }
324
386
  var err = new Error("inventory-receive.apply: restock failed — " + (e && e.message || e));
325
387
  err.cause = e;
326
388
  throw err;
327
389
  }
328
- var ts = _now();
329
- await query(
330
- "UPDATE inventory_receipts SET status = 'applied', updated_at = ?1 WHERE id = ?2",
331
- [ts, receiptId],
332
- );
333
390
  return {
334
391
  id: receiptId,
335
392
  applied_count: applied.length,
@@ -352,18 +409,55 @@ function create(opts) {
352
409
  if (!receipt) {
353
410
  throw new TypeError("inventory-receive.reverse: receipt " + receiptId + " not found");
354
411
  }
355
- if (receipt.status !== "applied") {
412
+ if (!_canReceipt(receipt.status, "reverse")) {
356
413
  throw new TypeError("inventory-receive.reverse: receipt is " + receipt.status + ", only applied receipts can be reversed");
357
414
  }
415
+ // Claim the applied -> reversed transition atomically BEFORE decrementing.
416
+ // Two concurrent reverses both pass the read above, but only one UPDATE
417
+ // matches `status = 'applied'`; the loser refuses, so the shelf is
418
+ // decremented exactly once. (Without this guard both calls would
419
+ // decrement every line, destroying unrelated base stock.)
420
+ var claimTs = _now();
421
+ var claim = await query(
422
+ "UPDATE inventory_receipts SET status = 'reversed', updated_at = ?1 WHERE id = ?2 AND status = 'applied'",
423
+ [claimTs, receiptId],
424
+ );
425
+ if (Number(claim.rowCount || 0) !== 1) {
426
+ throw new TypeError("inventory-receive.reverse: receipt " + receiptId +
427
+ " is no longer applied (reversed by a concurrent call)");
428
+ }
358
429
  var stockChanges = [];
359
- for (var i = 0; i < receipt.lines.length; i += 1) {
360
- var l = receipt.lines[i];
361
- var ts = _now();
430
+ var decremented = [];
431
+ try {
432
+ for (var i = 0; i < receipt.lines.length; i += 1) {
433
+ var l = receipt.lines[i];
434
+ var ts = _now();
435
+ await query(
436
+ "UPDATE inventory SET stock_on_hand = MAX(0, stock_on_hand - ?1), updated_at = ?2 WHERE sku = ?3",
437
+ [l.qty_received, ts, l.sku],
438
+ );
439
+ decremented.push(l);
440
+ stockChanges.push({ sku: l.sku, qty: -l.qty_received });
441
+ }
442
+ } catch (e) {
443
+ // Compensate the decrements that already landed and release the claim so
444
+ // the receipt stays eligible for another reverse — otherwise a mid-loop
445
+ // failure strands it 'reversed' with only a partial stock rollback that
446
+ // the status check then refuses to retry.
447
+ for (var c = 0; c < decremented.length; c += 1) {
448
+ var dl = decremented[c];
449
+ try {
450
+ await query(
451
+ "UPDATE inventory SET stock_on_hand = stock_on_hand + ?1, updated_at = ?2 WHERE sku = ?3",
452
+ [dl.qty_received, _now(), dl.sku],
453
+ );
454
+ } catch (_compErr) { /* best-effort compensation; the claim release below restores retryability */ }
455
+ }
362
456
  await query(
363
- "UPDATE inventory SET stock_on_hand = MAX(0, stock_on_hand - ?1), updated_at = ?2 WHERE sku = ?3",
364
- [l.qty_received, ts, l.sku],
457
+ "UPDATE inventory_receipts SET status = 'applied', updated_at = ?1 WHERE id = ?2 AND status = 'reversed'",
458
+ [_now(), receiptId],
365
459
  );
366
- stockChanges.push({ sku: l.sku, qty: -l.qty_received });
460
+ throw e;
367
461
  }
368
462
  // Append the reason into the receipt notes so the audit trail
369
463
  // carries the rationale. Operators that want a richer reversal
@@ -377,9 +471,11 @@ function create(opts) {
377
471
  newNotes = newNotes.slice(0, MAX_NOTES_LEN);
378
472
  }
379
473
  }
474
+ // Status was already flipped to 'reversed' by the atomic claim above;
475
+ // this write only stamps the reversal reason into the notes.
380
476
  var ts2 = _now();
381
477
  await query(
382
- "UPDATE inventory_receipts SET status = 'reversed', notes = ?1, updated_at = ?2 WHERE id = ?3",
478
+ "UPDATE inventory_receipts SET notes = ?1, updated_at = ?2 WHERE id = ?3",
383
479
  [newNotes, ts2, receiptId],
384
480
  );
385
481
  return {