@blamejs/blamejs-shop 0.4.28 → 0.4.30

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.
@@ -241,32 +241,84 @@ function create(opts) {
241
241
  return latestTs + 1;
242
242
  }
243
243
 
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) {
244
+ // How many times a writer re-derives the chain tip when the parent fence
245
+ // refuses its INSERT (another write landed first). Each retry re-reads
246
+ // the tip, so contention resolves in one extra round trip per racing
247
+ // writer; five attempts is far beyond any realistic burst.
248
+ var CHAIN_WRITE_ATTEMPTS = 5;
249
+
250
+ function _isChainFenceCollision(err) {
251
+ var msg = (err && err.message) || "";
252
+ return /UNIQUE constraint failed/i.test(msg) && /(prev_hash|chain_parent)/i.test(msg);
253
+ }
254
+
255
+ // One fenced write attempt against a tip the caller just read. EVERY
256
+ // event kind writes through here so every row participates in the
257
+ // per-card hash chain — prev_hash + row_hash are computed app-side over
258
+ // the fully-resolved tuple and bound explicitly. What makes the
259
+ // app-computed values safe to bind is the chain-parent fence
260
+ // (UNIQUE(gift_card_id, prev_hash), migration 0230): a row's prev_hash
261
+ // names its parent, a chain has exactly one child per parent, so a
262
+ // write derived from a STALE tip collides at the constraint instead of
263
+ // forking the chain or persisting a stale balance_after — the caller
264
+ // re-reads and retries. The debit overdraft gate additionally stays
265
+ // INSIDE the statement (WHERE live-balance >= amount, the correlated
266
+ // subquery — never an app-side check): with the fence it is
267
+ // defense-in-depth, and it is what turns a genuine overdraft into a
268
+ // zero-row refusal rather than a retry loop. Returns one of
269
+ // { written }, { refused } (guard said no against the live tip), or
270
+ // { collided } (tip moved — retry).
271
+ async function _attemptChainedWrite(giftCardId, kind, latest, d) {
251
272
  var id = b.uuid.v7();
252
- var rowHash = _computeRowHash(prevHash, {
273
+ var rowHash = _computeRowHash(latest.row_hash, {
253
274
  id: id,
254
275
  gift_card_id: giftCardId,
255
276
  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,
277
+ amount_minor: d.amount,
278
+ source: d.source,
279
+ source_ref: d.sourceRef,
280
+ order_id: d.orderId,
281
+ balance_after_minor: d.after,
282
+ occurred_at: d.ts,
262
283
  });
263
- await query(
264
- "INSERT INTO gift_card_ledger " +
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],
268
- );
269
- return id;
284
+ var balSub =
285
+ "COALESCE((SELECT balance_after_minor FROM gift_card_ledger " +
286
+ "WHERE gift_card_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
287
+ try {
288
+ var res = await query(
289
+ "INSERT INTO gift_card_ledger " +
290
+ "(id, gift_card_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, occurred_at, prev_hash, row_hash) " +
291
+ "SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11 " +
292
+ "WHERE " + (d.guarded ? balSub + " >= ?4" : "1"),
293
+ [id, giftCardId, kind, d.amount, d.source, d.sourceRef, d.orderId, d.after, d.ts, latest.row_hash, rowHash],
294
+ );
295
+ if (Number(res.rowCount || 0) === 0) return { refused: true };
296
+ return { written: { id: id, balance_after_minor: d.after, occurred_at: d.ts } };
297
+ } catch (e) {
298
+ if (_isChainFenceCollision(e)) return { collided: true };
299
+ throw e;
300
+ }
301
+ }
302
+
303
+ // Run a chained write with bounded tip-contention retries. `derive` maps
304
+ // the freshly-read tip to the attempt's concrete fields — or a noop
305
+ // sentinel ({ noop: true }) for writes that degrade gracefully on an
306
+ // empty wallet. Each fence collision re-reads the tip and re-derives, so
307
+ // the values that land are always consistent with the parent they chain
308
+ // off.
309
+ async function _writeChained(giftCardId, kind, derive) {
310
+ for (var attempt = 0; attempt < CHAIN_WRITE_ATTEMPTS; attempt += 1) {
311
+ var latest = await _readLatest(giftCardId);
312
+ var d = derive(latest);
313
+ if (d.noop) return { noop: true, balance: latest.balance };
314
+ var r = await _attemptChainedWrite(giftCardId, kind, latest, d);
315
+ if (r.collided) continue;
316
+ if (r.refused) return { refused: true };
317
+ return r.written;
318
+ }
319
+ var contention = new Error("giftCardLedger." + kind + ": persistent chain-tip contention — retry the write");
320
+ contention.code = "GIFT_CARD_LEDGER_CONTENTION";
321
+ throw contention;
270
322
  }
271
323
 
272
324
  return {
@@ -284,20 +336,27 @@ function create(opts) {
284
336
  var requested = _epochMs(input.occurred_at, "occurred_at");
285
337
  if (requested == null) requested = _now();
286
338
 
287
- var latest = await _readLatest(giftCardId);
288
- var ts = _resolveOccurredAt(requested, latest.occurred_at);
289
- var after = latest.balance + amount;
290
- var id = await _writeRow(giftCardId, "credit", amount, source, sourceRef, null, after, ts, latest.row_hash);
339
+ var w = await _writeChained(giftCardId, "credit", function (latest) {
340
+ return {
341
+ amount: amount,
342
+ source: source,
343
+ sourceRef: sourceRef,
344
+ orderId: null,
345
+ after: latest.balance + amount,
346
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
347
+ guarded: false,
348
+ };
349
+ });
291
350
 
292
351
  return {
293
- id: id,
352
+ id: w.id,
294
353
  gift_card_id: giftCardId,
295
354
  kind: "credit",
296
355
  amount_minor: amount,
297
356
  source: source,
298
357
  source_ref: sourceRef,
299
- balance_after_minor: after,
300
- occurred_at: ts,
358
+ balance_after_minor: w.balance_after_minor,
359
+ occurred_at: w.occurred_at,
301
360
  };
302
361
  },
303
362
 
@@ -311,53 +370,38 @@ function create(opts) {
311
370
  var requested = _epochMs(input.occurred_at, "occurred_at");
312
371
  if (requested == null) requested = _now();
313
372
 
314
- // Atomic guarded INSERT the overdraft check and the row write
315
- // happen in ONE statement so two concurrent debits can't both
316
- // read the same balance, both pass the check, and both write
317
- // (the read-then-write race that corrupted `balance_after_minor`).
318
- // The latest row's snapshot (balance + occurred_at) is read by
319
- // correlated scalar subqueries INSIDE the INSERT; the WHERE gates
320
- // the write on `current_balance >= amount` against that live
321
- // value, and the inserted `balance_after_minor` / monotonic
322
- // `occurred_at` are derived from the same subqueries — so on D1
323
- // (where a single statement is atomic) exactly one of two racing
324
- // debits lands. rowCount === 0 means the guard refused: either a
325
- // genuine overdraft or the loser of a race.
326
- var id = b.uuid.v7();
327
- var balSub =
328
- "COALESCE((SELECT balance_after_minor FROM gift_card_ledger " +
329
- "WHERE gift_card_id = ?2 ORDER BY occurred_at DESC LIMIT 1), 0)";
330
- var tsSub =
331
- "(SELECT occurred_at FROM gift_card_ledger " +
332
- "WHERE gift_card_id = ?2 ORDER BY occurred_at DESC LIMIT 1)";
333
- var ins = await query(
334
- "INSERT INTO gift_card_ledger " +
335
- "(id, gift_card_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, occurred_at) " +
336
- "SELECT ?1, ?2, 'debit', ?3, NULL, NULL, ?4, " +
337
- balSub + " - ?3, " +
338
- "CASE WHEN ?5 > COALESCE(" + tsSub + ", 0) THEN ?5 ELSE COALESCE(" + tsSub + ", 0) + 1 END " +
339
- "WHERE " + balSub + " >= ?3",
340
- [id, giftCardId, amount, orderId, requested],
341
- );
342
- if (Number(ins.rowCount || 0) === 0) {
373
+ // The overdraft gate stays INSIDE the guarded INSERT (live correlated
374
+ // subquery never an app-side check), and the debit row now ALSO
375
+ // binds prev_hash/row_hash so it participates in the per-card chain
376
+ // like every other kind: the chain-parent fence is what makes the
377
+ // app-computed values safe (a stale-tip write collides and retries
378
+ // instead of forking the chain). A zero-row refusal against the live
379
+ // tip is a genuine overdraft either always insufficient, or the
380
+ // loser of a race whose winner drained the balance.
381
+ var w = await _writeChained(giftCardId, "debit", function (latest) {
382
+ return {
383
+ amount: amount,
384
+ source: null,
385
+ sourceRef: null,
386
+ orderId: orderId,
387
+ after: latest.balance - amount,
388
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
389
+ guarded: true,
390
+ };
391
+ });
392
+ if (w.refused) {
343
393
  var insufficient = new Error("giftCardLedger.debit: amount exceeds available balance");
344
394
  insufficient.code = "GIFT_CARD_LEDGER_INSUFFICIENT_BALANCE";
345
395
  throw insufficient;
346
396
  }
347
- // Re-read the row we just wrote to surface the resolved
348
- // balance_after / occurred_at without recomputing them client-side.
349
- var wrote = (await query(
350
- "SELECT balance_after_minor, occurred_at FROM gift_card_ledger WHERE id = ?1",
351
- [id],
352
- )).rows[0];
353
397
  return {
354
- id: id,
398
+ id: w.id,
355
399
  gift_card_id: giftCardId,
356
400
  kind: "debit",
357
401
  amount_minor: amount,
358
402
  order_id: orderId,
359
- balance_after_minor: wrote ? wrote.balance_after_minor : null,
360
- occurred_at: wrote ? wrote.occurred_at : requested,
403
+ balance_after_minor: w.balance_after_minor,
404
+ occurred_at: w.occurred_at,
361
405
  };
362
406
  },
363
407
 
@@ -377,20 +421,30 @@ function create(opts) {
377
421
  var requested = _epochMs(input.occurred_at, "occurred_at");
378
422
  if (requested == null) requested = _now();
379
423
 
380
- var latest = await _readLatest(giftCardId);
381
- // Expire caps at the current balance operators running a
382
- // scheduled sweep over computed "expiring before X" amounts
383
- // should degrade gracefully rather than refusing when an
384
- // interim debit has already drained the card. A capped expire
385
- // returns the actual amount burned so the operator can
386
- // reconcile.
387
- var toBurn = amount > latest.balance ? latest.balance : amount;
388
- if (toBurn === 0) {
389
- // No-op write: persist a zero-amount row would violate the
390
- // CHECK(amount_minor > 0) constraint. Surface a structured
391
- // refusal so the caller can distinguish "already empty" from
392
- // "actually burned N". A no-op expire is a valid outcome of
393
- // a bulk sweep — we don't throw.
424
+ // Expire caps at the current balance — operators running a scheduled
425
+ // sweep over computed "expiring before X" amounts degrade gracefully
426
+ // rather than refusing when an interim debit has already drained the
427
+ // card. The cap re-derives on every fence retry, so the burn is
428
+ // always consistent with the tip it chains off. An empty wallet is
429
+ // the structured no-op below, never a throw — a zero-amount row
430
+ // would violate CHECK(amount_minor > 0), and a no-op expire is a
431
+ // valid outcome of a bulk sweep.
432
+ var burned = { amount: 0 };
433
+ var w = await _writeChained(giftCardId, "expire", function (latest) {
434
+ var toBurn = amount > latest.balance ? latest.balance : amount;
435
+ if (toBurn === 0) return { noop: true };
436
+ burned.amount = toBurn;
437
+ return {
438
+ amount: toBurn,
439
+ source: null,
440
+ sourceRef: reason,
441
+ orderId: null,
442
+ after: latest.balance - toBurn,
443
+ ts: _resolveOccurredAt(requested, latest.occurred_at),
444
+ guarded: false,
445
+ };
446
+ });
447
+ if (w.noop) {
394
448
  return {
395
449
  id: null,
396
450
  gift_card_id: giftCardId,
@@ -398,28 +452,94 @@ function create(opts) {
398
452
  amount_minor: 0,
399
453
  requested_minor: amount,
400
454
  reason: reason,
401
- balance_after_minor: latest.balance,
455
+ balance_after_minor: w.balance,
402
456
  occurred_at: requested,
403
457
  noop: true,
404
458
  };
405
459
  }
406
- var ts = _resolveOccurredAt(requested, latest.occurred_at);
407
- var after = latest.balance - toBurn;
408
- var id = await _writeRow(giftCardId, "expire", toBurn, null, reason, null, after, ts, latest.row_hash);
409
460
 
410
461
  return {
411
- id: id,
462
+ id: w.id,
412
463
  gift_card_id: giftCardId,
413
464
  kind: "expire",
414
- amount_minor: toBurn,
465
+ amount_minor: burned.amount,
415
466
  requested_minor: amount,
416
467
  reason: reason,
417
- balance_after_minor: after,
418
- occurred_at: ts,
468
+ balance_after_minor: w.balance_after_minor,
469
+ occurred_at: w.occurred_at,
419
470
  noop: false,
420
471
  };
421
472
  },
422
473
 
474
+ // Recompute one card's hash chain and flag the first divergence — the
475
+ // tamper-evidence READ side of the chain the writers maintain. Walks
476
+ // the card's rows in (occurred_at, id) order; rows from before the
477
+ // chain columns existed (NULL row_hash) are tolerated as an
478
+ // unverifiable legacy PREFIX and counted, but once a hashed row
479
+ // appears every later row must link — an unhashed row after the
480
+ // anchor, a prev_hash that doesn't name its parent, or a row_hash
481
+ // that doesn't recompute is a break. O(n) per card; operator-audit
482
+ // use, not hot-path.
483
+ verifyChain: async function (giftCardId) {
484
+ _uuid(giftCardId, "gift_card_id");
485
+ var r = await query(
486
+ "SELECT * FROM gift_card_ledger WHERE gift_card_id = ?1 " +
487
+ "ORDER BY occurred_at ASC, id ASC",
488
+ [giftCardId],
489
+ );
490
+ var rows = r.rows;
491
+ var legacyPrefix = 0;
492
+ var anchored = false;
493
+ var prevHash = ZERO_HASH;
494
+ for (var i = 0; i < rows.length; i += 1) {
495
+ var row = rows[i];
496
+ if (!anchored && row.row_hash == null) { legacyPrefix += 1; continue; }
497
+ anchored = true;
498
+ var breakBase = {
499
+ ok: false,
500
+ rows_verified: i - legacyPrefix,
501
+ legacy_prefix: legacyPrefix,
502
+ break_at: i,
503
+ break_row_id: row.id,
504
+ };
505
+ if (row.row_hash == null) {
506
+ return Object.assign(breakBase, { reason: "unhashed row after chain anchor" });
507
+ }
508
+ if (row.prev_hash !== prevHash) {
509
+ return Object.assign(breakBase, {
510
+ reason: "prev_hash mismatch",
511
+ expected: prevHash,
512
+ actual: row.prev_hash,
513
+ });
514
+ }
515
+ var computed = _computeRowHash(prevHash, {
516
+ id: row.id,
517
+ gift_card_id: row.gift_card_id,
518
+ kind: row.kind,
519
+ amount_minor: row.amount_minor,
520
+ source: row.source,
521
+ source_ref: row.source_ref,
522
+ order_id: row.order_id,
523
+ balance_after_minor: row.balance_after_minor,
524
+ occurred_at: row.occurred_at,
525
+ });
526
+ if (computed !== row.row_hash) {
527
+ return Object.assign(breakBase, {
528
+ reason: "row_hash mismatch",
529
+ expected: computed,
530
+ actual: row.row_hash,
531
+ });
532
+ }
533
+ prevHash = row.row_hash;
534
+ }
535
+ return {
536
+ ok: true,
537
+ rows_verified: rows.length - legacyPrefix,
538
+ legacy_prefix: legacyPrefix,
539
+ last_hash: prevHash,
540
+ };
541
+ },
542
+
423
543
  balance: async function (giftCardId) {
424
544
  _uuid(giftCardId, "gift_card_id");
425
545
  var bal = await _currentBalance(giftCardId);
package/lib/giftcards.js CHANGED
@@ -406,6 +406,62 @@ function create(opts) {
406
406
  return reversed;
407
407
  },
408
408
 
409
+ // Attach a redemption that was debited BEFORE its order existed to the
410
+ // order it paid for. Checkout debits the card pre-charge (the debit is
411
+ // the double-spend gate, so it must land before any money moves) with
412
+ // `order_id` NULL, then links the row here once the order row exists.
413
+ // The link is what the refund/cancel reversers key on — an unlinked
414
+ // redemption is invisible to reverseRedemption / reverseRedemptionProRata.
415
+ // Write-once: only a NULL order_id row accepts a link, so a re-fire or a
416
+ // mistaken second link against a settled redemption is a no-op rather
417
+ // than a silent re-keying. Returns true when the link landed.
418
+ linkRedemptionToOrder: async function (redemptionId, orderId) {
419
+ _uuid(redemptionId, "redemption_id");
420
+ _uuid(orderId, "order_id");
421
+ var res = await query(
422
+ "UPDATE giftcard_redemptions SET order_id = ?1 WHERE id = ?2 AND order_id IS NULL",
423
+ [orderId, redemptionId],
424
+ );
425
+ return Number(res.rowCount || 0) === 1;
426
+ },
427
+
428
+ // Reverse ONE redemption by its own id — the compensation edge for a
429
+ // pre-charge debit whose checkout failed before the order existed
430
+ // (PaymentIntent refused, order insert threw). reverseRedemption can't
431
+ // reach these rows: it keys on order_id, which is still NULL at that
432
+ // point. Same claim discipline as the order-keyed reversal — the
433
+ // `reversed_at IS NULL` predicate is the serialization point, so a
434
+ // double-fire credits the balance back exactly once — and the same
435
+ // status semantics (a card that drained to `redeemed` reactivates; a
436
+ // voided/expired card keeps its terminal status). Returns the reversed
437
+ // row, or null when the claim was already taken (or the id is unknown).
438
+ reverseRedemptionById: async function (redemptionId) {
439
+ _uuid(redemptionId, "redemption_id");
440
+ var red = (await query(
441
+ "SELECT id, giftcard_id, amount_minor FROM giftcard_redemptions " +
442
+ "WHERE id = ?1 AND reversed_at IS NULL",
443
+ [redemptionId],
444
+ )).rows[0];
445
+ if (!red) return null;
446
+ var ts = _now();
447
+ var claim = await query(
448
+ "UPDATE giftcard_redemptions SET reversed_at = ?1 WHERE id = ?2 AND reversed_at IS NULL",
449
+ [ts, red.id],
450
+ );
451
+ if (Number(claim.rowCount || 0) === 0) return null; // lost the claim
452
+ await query(
453
+ "UPDATE giftcards SET balance_minor = balance_minor + ?1, " +
454
+ "status = CASE WHEN status = 'redeemed' THEN 'active' ELSE status END, " +
455
+ "updated_at = ?2 WHERE id = ?3",
456
+ [red.amount_minor, ts, red.giftcard_id],
457
+ );
458
+ return {
459
+ redemption_id: red.id,
460
+ gift_card_id: red.giftcard_id,
461
+ amount_minor: red.amount_minor,
462
+ };
463
+ },
464
+
409
465
  // Credit a card's spend back PROPORTIONALLY to a partial refund — the
410
466
  // refund-by-amount counterpart to reverseRedemption's all-or-nothing
411
467
  // (cancel) release. A full refund (cancel / payment-failed) returns the
package/lib/loyalty.js CHANGED
@@ -335,7 +335,7 @@ function create(opts) {
335
335
  throw raced;
336
336
  }
337
337
 
338
- await _writeTx(customerId, "redeem", -points, "redeem", orderId, notes, ts);
338
+ var txId = await _writeTx(customerId, "redeem", -points, "redeem", orderId, notes, ts);
339
339
 
340
340
  var after = await _readAccount(customerId);
341
341
  return {
@@ -343,9 +343,69 @@ function create(opts) {
343
343
  lifetime: after.lifetime_points,
344
344
  tier: after.tier,
345
345
  tier_expires_at: after.tier_expires_at,
346
+ // Ledger row id of this burn — checkout debits points BEFORE its
347
+ // order exists (the debit is the double-spend gate) and links the
348
+ // row to the order afterwards via linkRedemptionToOrder, or
349
+ // compensates via reverseRedemptionById when the checkout dies
350
+ // before the order is created.
351
+ tx_id: txId,
346
352
  };
347
353
  },
348
354
 
355
+ // Attach a redeem ledger row that was debited BEFORE its order existed
356
+ // to the order it tendered for. The order link is what
357
+ // restoreRedemption keys on — an unlinked burn is invisible to the
358
+ // refund restore. Write-once: only a NULL order_id redeem row accepts a
359
+ // link, so a re-fire never re-keys a settled burn. Returns true when
360
+ // the link landed.
361
+ linkRedemptionToOrder: async function (txId, orderId) {
362
+ var tid = _uuid(txId, "tx_id");
363
+ var oid = _uuid(orderId, "order_id");
364
+ var res = await query(
365
+ "UPDATE loyalty_transactions SET order_id = ?1 " +
366
+ "WHERE id = ?2 AND transaction_type = 'redeem' AND order_id IS NULL",
367
+ [oid, tid],
368
+ );
369
+ return Number(res.rowCount || 0) === 1;
370
+ },
371
+
372
+ // Reverse ONE redeem burn by its ledger row id — the compensation edge
373
+ // for a pre-charge debit whose checkout failed before the order existed
374
+ // (PaymentIntent refused, order insert threw). restoreRedemption can't
375
+ // reach these rows: it keys on order_id, still NULL at that point. The
376
+ // claim rides the same restored_points column the refund restore uses —
377
+ // advancing it 0 → spent is the serialization point, so a double-fire
378
+ // credits the balance back exactly once. Balance only; lifetime is
379
+ // untouched (the burn never moved it). Returns { restored_points }, 0
380
+ // when the claim was already taken (or the id is unknown / not a burn).
381
+ reverseRedemptionById: async function (txId) {
382
+ var tid = _uuid(txId, "tx_id");
383
+ var row = (await query(
384
+ "SELECT id, customer_id, points, restored_points FROM loyalty_transactions " +
385
+ "WHERE id = ?1 AND transaction_type = 'redeem'",
386
+ [tid],
387
+ )).rows[0];
388
+ if (!row) return { restored_points: 0 };
389
+ var spent = Math.abs(Number(row.points || 0));
390
+ if (spent <= 0 || Number(row.restored_points || 0) !== 0) return { restored_points: 0 };
391
+ var ts = _now();
392
+ var claim = await query(
393
+ "UPDATE loyalty_transactions SET restored_points = ?1 " +
394
+ "WHERE id = ?2 AND restored_points = 0",
395
+ [spent, row.id],
396
+ );
397
+ if (Number(claim.rowCount || 0) === 0) return { restored_points: 0 }; // lost the claim
398
+ await _ensureAccountRow(row.customer_id, ts);
399
+ await query(
400
+ "UPDATE loyalty_accounts SET balance_points = balance_points + ?1, " +
401
+ "updated_at = ?2 WHERE customer_id = ?3",
402
+ [spent, ts, row.customer_id],
403
+ );
404
+ await _writeTx(row.customer_id, "redeem", spent, "redeem-reversal", null,
405
+ "restored ref=tx:" + row.id, ts);
406
+ return { restored_points: spent };
407
+ },
408
+
349
409
  // Restore points a customer SPENT as a checkout tender when the order
350
410
  // that spent them is refunded — the symmetric counterpart to the
351
411
  // gift-card-spend restore. `redeem` debited the balance at checkout
package/lib/order.js CHANGED
@@ -1219,6 +1219,52 @@ function create(opts) {
1219
1219
  }
1220
1220
  },
1221
1221
 
1222
+ // Erasure scrub for the reconciliation audit trail. The attach linkage
1223
+ // (order_id / customer_id / linked_via / occurred_at) is RETAINED under
1224
+ // the same audit basis the orders themselves carry — a disputed link
1225
+ // must stay traceable after the account is gone. But the recorded
1226
+ // email_hash is a verbatim copy of the live customer-email lookup key,
1227
+ // and account erasure deliberately severs that key on the customers
1228
+ // row, so a surviving copy here would defeat the severance for any
1229
+ // customer who ever claimed a guest order. This rewrites it to a
1230
+ // per-customer, non-reversible tombstone under its OWN hash namespace
1231
+ // ("guest-recon-erased-email" — deliberately distinct from the
1232
+ // customers row's "customer-erased-email" label, so the two tombstones
1233
+ // never share a digest and can't be correlated as the same
1234
+ // derivation). Only live (non-tombstoned) hashes rewrite, so a re-run
1235
+ // is a no-op.
1236
+ //
1237
+ // `dry_run: true` counts the rows a wet run WOULD rewrite without
1238
+ // mutating anything — the side-effect-free preview the deletion
1239
+ // pipeline's dry-run contract requires. Returns `{ table, deleted }`
1240
+ // where `deleted` is the rows tombstoned (the rows themselves stay).
1241
+ // Defensive: a schema without the audit table collapses to
1242
+ // `{ table, deleted: 0 }` (drop-silent, same posture as the sibling
1243
+ // reads) so a partial schema never fails the caller's erasure run.
1244
+ scrubReconciliationEmailHashForCustomer: async function (customerId, opts2) {
1245
+ _uuid(customerId, "customer id");
1246
+ var dryRun = !!(opts2 && opts2.dry_run);
1247
+ try {
1248
+ if (dryRun) {
1249
+ var c = (await query(
1250
+ "SELECT COUNT(*) AS n FROM guest_order_reconciliations " +
1251
+ "WHERE customer_id = ?1 AND email_hash NOT LIKE 'erased:%'",
1252
+ [customerId],
1253
+ )).rows[0];
1254
+ return { table: "guest_order_reconciliations", deleted: c ? Number(c.n) : 0 };
1255
+ }
1256
+ var tombstone = "erased:" + b.crypto.namespaceHash("guest-recon-erased-email", customerId);
1257
+ var r = await query(
1258
+ "UPDATE guest_order_reconciliations SET email_hash = ?1 " +
1259
+ "WHERE customer_id = ?2 AND email_hash NOT LIKE 'erased:%'",
1260
+ [tombstone, customerId],
1261
+ );
1262
+ return { table: "guest_order_reconciliations", deleted: Number((r && r.rowCount) || 0) };
1263
+ } catch (_e) {
1264
+ return { table: "guest_order_reconciliations", deleted: 0 };
1265
+ }
1266
+ },
1267
+
1222
1268
  // Has this customer purchased this product? True iff an order
1223
1269
  // line for any variant of the product sits in an order owned by
1224
1270
  // the customer whose status is a real purchase — anything except
package/lib/payment.js CHANGED
@@ -438,12 +438,43 @@ async function _runIdempotent(state, operation, key, requestObj, doCall) {
438
438
  ? result._stripeRawText
439
439
  : JSON.stringify(result);
440
440
 
441
- await query(
441
+ // Atomic claim: two concurrent same-key calls both miss the lookup above
442
+ // and both reach here — ON CONFLICT DO NOTHING lets exactly one cache its
443
+ // response while the loser defers to the winner's row instead of dying on
444
+ // the PRIMARY KEY violation. Never OR REPLACE / DO UPDATE: the loser must
445
+ // not overwrite the winner's cached response, and a same-key racer with a
446
+ // DIFFERENT body must still hit the collision refusal below.
447
+ var ins = await query(
442
448
  "INSERT INTO payment_idempotency " +
443
449
  "(idempotency_key, operation, request_hash, response_status, response_body, created_at, expires_at) " +
444
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
450
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) " +
451
+ "ON CONFLICT(idempotency_key) DO NOTHING",
445
452
  [key, operation, requestHash, status, rawText, now, now + IDEMPOTENCY_TTL_MS],
446
453
  );
454
+ var changes = ins && (ins.rowCount != null ? ins.rowCount
455
+ : (ins.meta && ins.meta.changes != null ? ins.meta.changes : ins.changes));
456
+ if (Number(changes || 0) === 0) {
457
+ // A concurrent call claimed the key first. Replay its cached row —
458
+ // unless our request body differs, which is the same collision the
459
+ // up-front check refuses.
460
+ var winner = (await query(
461
+ "SELECT request_hash, response_status, response_body " +
462
+ "FROM payment_idempotency WHERE idempotency_key = ?1 LIMIT 1",
463
+ [key],
464
+ )).rows[0];
465
+ if (winner && winner.request_hash !== requestHash) {
466
+ throw new TypeError("payment: idempotency_key collision (different inputs)");
467
+ }
468
+ if (winner) {
469
+ var winnerReplay = null;
470
+ try { winnerReplay = JSON.parse(winner.response_body); } catch (_e) { winnerReplay = { _raw: winner.response_body }; }
471
+ Object.defineProperty(winnerReplay, "_stripeStatus", { value: Number(winner.response_status), enumerable: false });
472
+ Object.defineProperty(winnerReplay, "_replayed", { value: true, enumerable: false });
473
+ return winnerReplay;
474
+ }
475
+ // Conflicted but the row vanished (TTL purge between the two
476
+ // statements) — fall through: our own result is still the outcome.
477
+ }
447
478
 
448
479
  return result;
449
480
  }