@blamejs/blamejs-shop 0.4.23 → 0.4.25

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.25",
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",
@@ -115,6 +115,48 @@ var STATUSES = Object.freeze([
115
115
  "received", "processing", "fulfilled", "delivered", "dismissed",
116
116
  ]);
117
117
 
118
+ // Statutory response window per jurisdiction — the clock a supervisory
119
+ // authority measures the controller against once a subject files the
120
+ // request. The deadline is `requested_at + days`. GDPR Art. 12(3): one
121
+ // month from receipt (encoded as 30 days — the controller-defensible
122
+ // reading; extendable by two further months for complex requests, which an
123
+ // operator records out of band). CCPA Cal. Civ. Code §1798.130(a)(2): 45
124
+ // days (one 45-day extension permitted). LGPD Art. 19 §II / §3: 15 days for
125
+ // the full declaration. `other` carries no statutory clock — the operator's
126
+ // own SLA governs, so no deadline is surfaced rather than inventing one.
127
+ //
128
+ // This is the DSR-response analogue of b.breach.deadline (which encodes the
129
+ // US-state breach-NOTIFICATION statutes — a different clock with different
130
+ // citations); a subject-access response window has no entry in that
131
+ // registry, so the per-jurisdiction window lives here keyed to the same
132
+ // jurisdiction vocabulary the request rows already carry.
133
+ var DSR_RESPONSE_WINDOW = Object.freeze({
134
+ gdpr: Object.freeze({ days: 30, statute: "GDPR Art. 12(3) (one month from receipt)" }),
135
+ ccpa: Object.freeze({ days: 45, statute: "Cal. Civ. Code §1798.130(a)(2)" }),
136
+ lgpd: Object.freeze({ days: 15, statute: "LGPD Art. 19 §II" }),
137
+ other: null, // operator SLA governs — no statutory clock to surface
138
+ });
139
+
140
+ var MS_PER_DAY = b.constants.TIME.days(1);
141
+
142
+ // Compute the statutory response deadline for a DSR request. Returns null
143
+ // for a jurisdiction with no statutory clock (`other`) or a non-finite
144
+ // requested-at. The shape mirrors b.breach.deadline.forStates entries —
145
+ // `{ jurisdiction, days, due_by, statute }` — so a future operator clock
146
+ // (b.breach.deadline.createClock-style escalation) can adapt it without a
147
+ // reshape.
148
+ function _statutoryDeadline(jurisdiction, requestedAtMs) {
149
+ var win = DSR_RESPONSE_WINDOW[jurisdiction];
150
+ if (!win) return null;
151
+ if (typeof requestedAtMs !== "number" || !isFinite(requestedAtMs)) return null;
152
+ return {
153
+ jurisdiction: jurisdiction,
154
+ days: win.days,
155
+ due_by: requestedAtMs + (win.days * MS_PER_DAY),
156
+ statute: win.statute,
157
+ };
158
+ }
159
+
118
160
  var MAX_REASON_LEN = 4000;
119
161
  var MAX_DISMISS_REASON_LEN = 4000;
120
162
  var MAX_DELIVERY_METHOD_LEN = 64;
@@ -264,6 +306,7 @@ function _isEmptySection(section) {
264
306
 
265
307
  function _hydrate(r) {
266
308
  if (!r) return null;
309
+ var requestedAt = Number(r.requested_at);
267
310
  return {
268
311
  id: r.id,
269
312
  customer_id: r.customer_id,
@@ -272,13 +315,19 @@ function _hydrate(r) {
272
315
  scope: r.scope == null ? null : r.scope,
273
316
  status: r.status,
274
317
  requested_by: r.requested_by,
275
- requested_at: Number(r.requested_at),
318
+ requested_at: requestedAt,
276
319
  fulfilled_at: r.fulfilled_at == null ? null : Number(r.fulfilled_at),
277
320
  delivered_at: r.delivered_at == null ? null : Number(r.delivered_at),
278
321
  dismiss_reason: r.dismiss_reason == null ? null : r.dismiss_reason,
279
322
  delivery_method: r.delivery_method == null ? null : r.delivery_method,
280
323
  delivery_address: r.delivery_address == null ? null : r.delivery_address,
281
324
  reason: r.reason == null ? null : r.reason,
325
+ // Derived: the statutory response deadline the supervisory authority
326
+ // measures against (computed from jurisdiction + requested_at, never
327
+ // persisted — so it always reflects the current registry). Null for a
328
+ // jurisdiction with no statutory clock. The admin DSR console surfaces
329
+ // `due_by` so an operator sees the wall before it elapses.
330
+ statutory_deadline: _statutoryDeadline(r.jurisdiction, requestedAt),
282
331
  };
283
332
  }
284
333
 
@@ -658,10 +707,15 @@ function create(opts) {
658
707
  }
659
708
 
660
709
  module.exports = {
661
- create: create,
662
- REQUEST_KINDS: REQUEST_KINDS,
663
- JURISDICTIONS: JURISDICTIONS,
664
- SCOPES: SCOPES,
665
- STATUSES: STATUSES,
666
- SCOPE_SECTIONS: SCOPE_SECTIONS,
710
+ create: create,
711
+ REQUEST_KINDS: REQUEST_KINDS,
712
+ JURISDICTIONS: JURISDICTIONS,
713
+ SCOPES: SCOPES,
714
+ STATUSES: STATUSES,
715
+ SCOPE_SECTIONS: SCOPE_SECTIONS,
716
+ // DSR statutory response-window registry + the per-request deadline
717
+ // calculator (the console reads the calculator's `due_by`; tests pin the
718
+ // per-jurisdiction windows).
719
+ DSR_RESPONSE_WINDOW: DSR_RESPONSE_WINDOW,
720
+ statutoryDeadline: _statutoryDeadline,
667
721
  };
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