@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.
- package/CHANGELOG.md +4 -0
- package/README.md +1 -1
- package/SECURITY.md +25 -7
- package/lib/asset-manifest.json +1 -1
- package/lib/auto-discount.js +177 -6
- package/lib/checkout.js +300 -129
- package/lib/compliance-export.js +32 -7
- package/lib/gift-card-ledger.js +208 -88
- package/lib/giftcards.js +56 -0
- package/lib/loyalty.js +61 -1
- package/lib/order.js +46 -0
- package/lib/payment.js +33 -2
- package/lib/stock-alerts.js +110 -0
- package/lib/store-credit.js +99 -79
- package/lib/storefront.js +16 -5
- package/package.json +1 -1
package/lib/gift-card-ledger.js
CHANGED
|
@@ -241,32 +241,84 @@ function create(opts) {
|
|
|
241
241
|
return latestTs + 1;
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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(
|
|
273
|
+
var rowHash = _computeRowHash(latest.row_hash, {
|
|
253
274
|
id: id,
|
|
254
275
|
gift_card_id: giftCardId,
|
|
255
276
|
kind: kind,
|
|
256
|
-
amount_minor:
|
|
257
|
-
source: source,
|
|
258
|
-
source_ref: sourceRef,
|
|
259
|
-
order_id: orderId,
|
|
260
|
-
balance_after_minor:
|
|
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
|
-
|
|
264
|
-
"
|
|
265
|
-
"
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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:
|
|
300
|
-
occurred_at:
|
|
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
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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:
|
|
360
|
-
occurred_at:
|
|
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
|
-
|
|
381
|
-
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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:
|
|
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:
|
|
465
|
+
amount_minor: burned.amount,
|
|
415
466
|
requested_minor: amount,
|
|
416
467
|
reason: reason,
|
|
417
|
-
balance_after_minor:
|
|
418
|
-
occurred_at:
|
|
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
|
-
|
|
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
|
}
|