@blamejs/blamejs-shop 0.4.103 → 0.4.105
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 +11 -8
- package/lib/affiliates.js +2 -2
- package/lib/api-keys.js +1 -1
- package/lib/asset-manifest.json +1 -1
- package/lib/auto-discount.js +4 -4
- package/lib/auto-replenish.js +1 -1
- package/lib/backorder.js +2 -2
- package/lib/carrier-accounts.js +1 -1
- package/lib/cart-bulk-ops.js +1 -1
- package/lib/cart.js +2 -2
- package/lib/catalog.js +3 -3
- package/lib/click-and-collect.js +2 -2
- package/lib/customer-merge.js +2 -2
- package/lib/customer-surveys.js +1 -1
- package/lib/cycle-counting.js +1 -1
- package/lib/dropship-forwarding.js +1 -1
- package/lib/dunning.js +1 -1
- package/lib/email-campaigns.js +4 -4
- package/lib/giftcards.js +6 -6
- package/lib/inventory-allocations.js +1 -1
- package/lib/inventory-audits.js +1 -1
- package/lib/inventory-receive.js +2 -2
- package/lib/inventory-writeoffs.js +1 -1
- package/lib/invoice-renderer.js +1 -1
- package/lib/knowledge-base.js +1 -1
- package/lib/loyalty-redemption.js +3 -3
- package/lib/loyalty.js +410 -195
- package/lib/operator-approvals.js +1 -1
- package/lib/operator-help-center.js +1 -1
- package/lib/order-exchanges.js +1 -1
- package/lib/order-export.js +4 -4
- package/lib/payment-retries.js +2 -2
- package/lib/pick-lists.js +1 -1
- package/lib/plan-changes.js +1 -1
- package/lib/preorder.js +4 -4
- package/lib/print-on-demand.js +1 -1
- package/lib/print-queue.js +2 -2
- package/lib/purchase-orders.js +1 -1
- package/lib/push-notifications.js +2 -2
- package/lib/quotes.js +7 -7
- package/lib/referrals.js +3 -3
- package/lib/reorder-reminders.js +1 -1
- package/lib/returns.js +4 -4
- package/lib/seller-signup.js +1 -1
- package/lib/shipping-insurance.js +2 -2
- package/lib/sms-dispatcher.js +2 -2
- package/lib/split-shipments.js +1 -1
- package/lib/stock-alerts.js +1 -1
- package/lib/stock-transfers.js +4 -4
- package/lib/subscription-gifts.js +2 -2
- package/lib/suggestion-box.js +2 -2
- package/lib/webhooks.js +10 -3
- package/lib/wishlist-alerts.js +1 -1
- package/lib/wishlist-digest.js +1 -1
- package/package.json +1 -1
package/lib/loyalty.js
CHANGED
|
@@ -154,6 +154,51 @@ function _validateThresholds(thresholds) {
|
|
|
154
154
|
|
|
155
155
|
function _now() { return Date.now(); }
|
|
156
156
|
|
|
157
|
+
// ---- chain hashing ------------------------------------------------------
|
|
158
|
+
//
|
|
159
|
+
// loyalty_transactions is a per-customer running-balance hash chain (the
|
|
160
|
+
// gift-card-ledger / store-credit-ledger model). Each mutating row carries the
|
|
161
|
+
// running balance_after_points + lifetime_after_points + tier_after, written by
|
|
162
|
+
// the SAME guarded INSERT that records the event, so the balance change and the
|
|
163
|
+
// audit row are one write — the stored-column two-statement window (and the
|
|
164
|
+
// double-credit-on-revert hazard it forced) is structurally gone. A SHA3-512
|
|
165
|
+
// chain (prev_hash + row_hash, fenced by UNIQUE(customer_id, prev_hash)) makes
|
|
166
|
+
// the running snapshot tamper-evident and serializes concurrent writers.
|
|
167
|
+
//
|
|
168
|
+
// row_hash = SHA3-512(prev_hash || canonical-json(row-fields))
|
|
169
|
+
//
|
|
170
|
+
// order_id and restored_points are EXCLUDED from the hashed fields: both are
|
|
171
|
+
// mutated AFTER the row is inserted (linkRedemptionToOrder stamps order_id;
|
|
172
|
+
// reverse/restore advance restored_points), so hashing them would report a
|
|
173
|
+
// false tamper on every linked redemption and every restore.
|
|
174
|
+
var SHA3_512_HEX_LEN = 128;
|
|
175
|
+
var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
|
|
176
|
+
|
|
177
|
+
function _rowFieldsForHash(row) {
|
|
178
|
+
return {
|
|
179
|
+
id: row.id,
|
|
180
|
+
customer_id: row.customer_id,
|
|
181
|
+
transaction_type: row.transaction_type,
|
|
182
|
+
points: Number(row.points),
|
|
183
|
+
source: row.source == null ? null : row.source,
|
|
184
|
+
notes: row.notes == null ? null : row.notes,
|
|
185
|
+
balance_after_points: Number(row.balance_after_points),
|
|
186
|
+
lifetime_after_points: Number(row.lifetime_after_points),
|
|
187
|
+
tier_after: row.tier_after,
|
|
188
|
+
occurred_at: Number(row.occurred_at),
|
|
189
|
+
// order_id + restored_points EXCLUDED — both mutated post-insert.
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _computeRowHash(prevHash, rowFields) {
|
|
194
|
+
var canonical = b.auditChain.canonicalize(rowFields, ["prev_hash", "row_hash"]);
|
|
195
|
+
var preimage = Buffer.concat([
|
|
196
|
+
Buffer.from(prevHash, "hex"),
|
|
197
|
+
Buffer.from(canonical, "utf8"),
|
|
198
|
+
]);
|
|
199
|
+
return b.crypto.sha3Hash(preimage);
|
|
200
|
+
}
|
|
201
|
+
|
|
157
202
|
// ---- factory ------------------------------------------------------------
|
|
158
203
|
|
|
159
204
|
function create(opts) {
|
|
@@ -205,17 +250,6 @@ function create(opts) {
|
|
|
205
250
|
return r.rows[0] || null;
|
|
206
251
|
}
|
|
207
252
|
|
|
208
|
-
async function _writeTx(customerId, type, points, source, orderId, notes, ts) {
|
|
209
|
-
var id = b.uuid.v7();
|
|
210
|
-
await query(
|
|
211
|
-
"INSERT INTO loyalty_transactions " +
|
|
212
|
-
"(id, customer_id, transaction_type, points, source, order_id, notes, occurred_at) " +
|
|
213
|
-
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
214
|
-
[id, customerId, type, points, source, orderId, notes, ts],
|
|
215
|
-
);
|
|
216
|
-
return id;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
253
|
async function _ensureAccountRow(customerId, ts) {
|
|
220
254
|
// INSERT OR IGNORE is idempotent — repeated calls land a single
|
|
221
255
|
// row. We don't bump `updated_at` on the existing-row path; the
|
|
@@ -228,23 +262,133 @@ function create(opts) {
|
|
|
228
262
|
);
|
|
229
263
|
}
|
|
230
264
|
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
265
|
+
// O(1) chain-tip read: the latest row by (customer_id, occurred_at DESC,
|
|
266
|
+
// id DESC) carries the running balance/lifetime/tier snapshot AFTER it.
|
|
267
|
+
// No SUM at read time. Falls through to a bronze zero-tip when the customer
|
|
268
|
+
// has no rows (a brand-new account anchors its chain from ZERO on the first
|
|
269
|
+
// write). A legacy/genesis NULL row_hash coerces to ZERO_HASH so the first
|
|
270
|
+
// hashed row anchors the chain (the lazy genesis anchor).
|
|
271
|
+
async function _readLatest(customerId) {
|
|
272
|
+
var r = await query(
|
|
273
|
+
"SELECT id, balance_after_points, lifetime_after_points, tier_after, occurred_at, row_hash " +
|
|
274
|
+
"FROM loyalty_transactions WHERE customer_id = ?1 " +
|
|
275
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT 1",
|
|
276
|
+
[customerId],
|
|
277
|
+
);
|
|
278
|
+
if (!r.rows.length) {
|
|
279
|
+
return { id: null, balance: 0, lifetime: 0, tier: "bronze",
|
|
280
|
+
occurred_at: null, row_hash: ZERO_HASH };
|
|
281
|
+
}
|
|
282
|
+
var row = r.rows[0];
|
|
283
|
+
return {
|
|
284
|
+
id: row.id,
|
|
285
|
+
balance: Number(row.balance_after_points),
|
|
286
|
+
lifetime: Number(row.lifetime_after_points),
|
|
287
|
+
tier: row.tier_after,
|
|
288
|
+
occurred_at: Number(row.occurred_at),
|
|
289
|
+
row_hash: row.row_hash == null ? ZERO_HASH : row.row_hash,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Strict-monotonic per-customer occurred_at: two writes in the same ms (or a
|
|
294
|
+
// backdated write) would tie and make the tip ambiguous, so a colliding
|
|
295
|
+
// timestamp bumps to prior + 1. Non-colliding (future) timestamps land as
|
|
296
|
+
// requested.
|
|
297
|
+
function _resolveOccurredAt(requestedTs, latestTs) {
|
|
298
|
+
if (latestTs == null) return requestedTs;
|
|
299
|
+
if (requestedTs > latestTs) return requestedTs;
|
|
300
|
+
return latestTs + 1;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Re-derive the tip this many times when the chain-parent fence refuses an
|
|
304
|
+
// INSERT (a concurrent write claimed the slot). Each fence round lets exactly
|
|
305
|
+
// one racing writer win, so the cap is sized well beyond realistic same-
|
|
306
|
+
// customer fan-in; a genuine non-collision insert error re-throws on attempt
|
|
307
|
+
// one (the tip is unchanged), so a high cap never spins on a real failure.
|
|
308
|
+
var CHAIN_WRITE_ATTEMPTS = 64;
|
|
309
|
+
|
|
310
|
+
// One fenced write attempt off a freshly-read tip. Every event kind writes
|
|
311
|
+
// through here so every row joins the per-customer chain. The chain-parent
|
|
312
|
+
// fence (UNIQUE(customer_id, prev_hash)) makes the app-computed prev_hash /
|
|
313
|
+
// row_hash safe: a row derived from a stale tip collides instead of forking
|
|
314
|
+
// the chain or persisting a stale balance_after. The overdraft / underflow
|
|
315
|
+
// gate stays INSIDE the statement (correlated subquery against the live tip,
|
|
316
|
+
// never an app-side check) so a refusal is a zero-row no-op. Returns one of
|
|
317
|
+
// { written }, { refused } (guard said no), { collided } (tip moved — retry).
|
|
318
|
+
async function _attemptChainedWrite(customerId, d) {
|
|
319
|
+
var id = b.uuid.v7();
|
|
320
|
+
var rowHash = _computeRowHash(d.prevHash, {
|
|
321
|
+
id: id,
|
|
322
|
+
customer_id: customerId,
|
|
323
|
+
transaction_type: d.type,
|
|
324
|
+
points: d.points,
|
|
325
|
+
source: d.source,
|
|
326
|
+
notes: d.notes,
|
|
327
|
+
balance_after_points: d.balanceAfter,
|
|
328
|
+
lifetime_after_points: d.lifetimeAfter,
|
|
329
|
+
tier_after: d.tierAfter,
|
|
330
|
+
occurred_at: d.ts,
|
|
331
|
+
});
|
|
332
|
+
var balSub =
|
|
333
|
+
"COALESCE((SELECT balance_after_points FROM loyalty_transactions " +
|
|
334
|
+
"WHERE customer_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
|
|
335
|
+
try {
|
|
336
|
+
var res = await query(
|
|
337
|
+
"INSERT INTO loyalty_transactions " +
|
|
338
|
+
"(id, customer_id, transaction_type, points, source, order_id, notes, occurred_at, " +
|
|
339
|
+
"balance_after_points, lifetime_after_points, tier_after, prev_hash, row_hash) " +
|
|
340
|
+
"SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13 " +
|
|
341
|
+
"WHERE " + (d.guard ? d.guard.replace(/__BAL__/g, balSub) : "1"),
|
|
342
|
+
[id, customerId, d.type, d.points, d.source, d.orderId, d.notes, d.ts,
|
|
343
|
+
d.balanceAfter, d.lifetimeAfter, d.tierAfter, d.prevHash, rowHash],
|
|
344
|
+
);
|
|
345
|
+
if (Number(res.rowCount || 0) === 0) return { refused: true };
|
|
346
|
+
return { written: { id: id, points: d.points, balance: d.balanceAfter,
|
|
347
|
+
lifetime: d.lifetimeAfter, tier: d.tierAfter, occurred_at: d.ts } };
|
|
348
|
+
} catch (e) {
|
|
349
|
+
// State-agnostic collision detection — never error-message matching (the
|
|
350
|
+
// prod D1 bridge redacts the SQLite UNIQUE text to a generic HTTP 500).
|
|
351
|
+
// If the tip advanced past the parent we tried to chain off, a competing
|
|
352
|
+
// write claimed our slot — retry; otherwise the insert failed for another
|
|
353
|
+
// reason — re-throw.
|
|
354
|
+
var after = await _readLatest(customerId);
|
|
355
|
+
if (after.row_hash !== d.prevHash) return { collided: true };
|
|
356
|
+
throw e;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Run a chained write with bounded tip-contention retries. `derive(tip)` maps
|
|
361
|
+
// the freshly-read tip to the concrete row fields; each fence collision
|
|
362
|
+
// re-reads + re-derives so the values that land are always consistent with
|
|
363
|
+
// the parent they chain off. Returns the written row, or { refused: true }
|
|
364
|
+
// when the in-statement guard rejected it against the live tip.
|
|
365
|
+
async function _writeChained(customerId, derive) {
|
|
366
|
+
for (var attempt = 0; attempt < CHAIN_WRITE_ATTEMPTS; attempt += 1) {
|
|
367
|
+
var latest = await _readLatest(customerId);
|
|
368
|
+
var d = derive(latest);
|
|
369
|
+
d.prevHash = latest.row_hash;
|
|
370
|
+
var r = await _attemptChainedWrite(customerId, d);
|
|
371
|
+
if (r.collided) continue;
|
|
372
|
+
if (r.refused) return { refused: true };
|
|
373
|
+
return r.written;
|
|
374
|
+
}
|
|
375
|
+
var contention = new Error("loyalty: persistent chain-tip contention — retry the write");
|
|
376
|
+
contention.code = "LOYALTY_CONTENTION";
|
|
377
|
+
throw contention;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Best-effort refresh of the loyalty_accounts mirror from a freshly-written
|
|
381
|
+
// tip. The mirror is NOT authoritative and NOT on any mutation's decision
|
|
382
|
+
// path — balance() and every guard read the chain tip; the mirror exists only
|
|
383
|
+
// for the cross-customer tierLeaderboard and the operator tier_expires_at
|
|
384
|
+
// time-bound. Runs OUTSIDE the atomicity boundary: a crash here leaves money
|
|
385
|
+
// exactly-once correct and the next advancing write re-syncs it.
|
|
386
|
+
async function _refreshMirror(customerId, w, ts) {
|
|
387
|
+
await query(
|
|
388
|
+
"UPDATE loyalty_accounts SET balance_points = ?1, lifetime_points = ?2, " +
|
|
389
|
+
"tier = ?3, updated_at = ?4 WHERE customer_id = ?5",
|
|
390
|
+
[w.balance, w.lifetime, w.tier, ts, customerId],
|
|
391
|
+
);
|
|
248
392
|
}
|
|
249
393
|
|
|
250
394
|
return {
|
|
@@ -277,29 +421,31 @@ function create(opts) {
|
|
|
277
421
|
var orderId = input.order_id != null ? _uuid(input.order_id, "order_id") : null;
|
|
278
422
|
var notes = _notes(input.notes);
|
|
279
423
|
|
|
280
|
-
var
|
|
281
|
-
await _ensureAccountRow(customerId,
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
var
|
|
288
|
-
await
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
424
|
+
var requested = _now();
|
|
425
|
+
await _ensureAccountRow(customerId, requested);
|
|
426
|
+
// Balance + lifetime + tier all ride the SAME guarded chained INSERT off
|
|
427
|
+
// the freshly-read tip — there is no stored-column second statement to
|
|
428
|
+
// lose. earn advances lifetime and recomputes the tier off the new
|
|
429
|
+
// lifetime; `tier_changed` compares the chain's before/after tiers (both
|
|
430
|
+
// from the tip, no separate snapshot read).
|
|
431
|
+
var tierBefore;
|
|
432
|
+
var w = await _writeChained(customerId, function (latest) {
|
|
433
|
+
tierBefore = latest.tier;
|
|
434
|
+
var lifeAfter = latest.lifetime + points;
|
|
435
|
+
return {
|
|
436
|
+
type: "earn", points: points, source: source, orderId: orderId, notes: notes,
|
|
437
|
+
balanceAfter: latest.balance + points,
|
|
438
|
+
lifetimeAfter: lifeAfter,
|
|
439
|
+
tierAfter: computeTier(lifeAfter),
|
|
440
|
+
ts: _resolveOccurredAt(requested, latest.occurred_at),
|
|
441
|
+
};
|
|
442
|
+
});
|
|
443
|
+
await _refreshMirror(customerId, w, requested);
|
|
298
444
|
return {
|
|
299
|
-
balance:
|
|
300
|
-
lifetime:
|
|
301
|
-
tier:
|
|
302
|
-
tier_changed:
|
|
445
|
+
balance: w.balance,
|
|
446
|
+
lifetime: w.lifetime,
|
|
447
|
+
tier: w.tier,
|
|
448
|
+
tier_changed: w.tier !== tierBefore,
|
|
303
449
|
};
|
|
304
450
|
},
|
|
305
451
|
|
|
@@ -312,43 +458,41 @@ function create(opts) {
|
|
|
312
458
|
var orderId = input.order_id != null ? _uuid(input.order_id, "order_id") : null;
|
|
313
459
|
var notes = _notes(input.notes);
|
|
314
460
|
|
|
315
|
-
var
|
|
316
|
-
await _ensureAccountRow(customerId,
|
|
317
|
-
|
|
318
|
-
|
|
461
|
+
var requested = _now();
|
|
462
|
+
await _ensureAccountRow(customerId, requested);
|
|
463
|
+
// The overdraft refusal lives INSIDE the guarded INSERT (balSub + points
|
|
464
|
+
// >= 0, where points is the negative burn delta) — never an app-side
|
|
465
|
+
// pre-check — so two concurrent redemptions can't double-spend off a
|
|
466
|
+
// stale snapshot: the chain-parent fence serializes them and the loser
|
|
467
|
+
// re-reads the winner's smaller balance and is refused. Lifetime is not
|
|
468
|
+
// affected; redemption spends from the running balance only.
|
|
469
|
+
var w = await _writeChained(customerId, function (latest) {
|
|
470
|
+
return {
|
|
471
|
+
type: "redeem", points: -points, source: "redeem", orderId: orderId, notes: notes,
|
|
472
|
+
balanceAfter: latest.balance - points,
|
|
473
|
+
lifetimeAfter: latest.lifetime,
|
|
474
|
+
tierAfter: latest.tier,
|
|
475
|
+
ts: _resolveOccurredAt(requested, latest.occurred_at),
|
|
476
|
+
guard: "__BAL__ + ?4 >= 0",
|
|
477
|
+
};
|
|
478
|
+
});
|
|
479
|
+
if (w.refused) {
|
|
319
480
|
var ins = new Error("loyalty.redeem: insufficient balance");
|
|
320
481
|
ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
|
|
321
482
|
throw ins;
|
|
322
483
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
// two concurrent redemptions can't double-spend. Lifetime is not
|
|
326
|
-
// affected — redemption spends from the running balance only.
|
|
327
|
-
var dec = await query(
|
|
328
|
-
"UPDATE loyalty_accounts SET balance_points = balance_points - ?1, " +
|
|
329
|
-
"updated_at = ?2 WHERE customer_id = ?3 AND balance_points >= ?1",
|
|
330
|
-
[points, ts, customerId],
|
|
331
|
-
);
|
|
332
|
-
if (dec.rowCount === 0) {
|
|
333
|
-
var raced = new Error("loyalty.redeem: insufficient balance");
|
|
334
|
-
raced.code = "LOYALTY_INSUFFICIENT_BALANCE";
|
|
335
|
-
throw raced;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
var txId = await _writeTx(customerId, "redeem", -points, "redeem", orderId, notes, ts);
|
|
339
|
-
|
|
340
|
-
var after = await _readAccount(customerId);
|
|
484
|
+
await _refreshMirror(customerId, w, requested);
|
|
485
|
+
var acct = await _readAccount(customerId);
|
|
341
486
|
return {
|
|
342
|
-
balance:
|
|
343
|
-
lifetime:
|
|
344
|
-
tier:
|
|
345
|
-
tier_expires_at:
|
|
346
|
-
// Ledger row id of this burn — checkout debits points BEFORE its
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
tx_id: txId,
|
|
487
|
+
balance: w.balance,
|
|
488
|
+
lifetime: w.lifetime,
|
|
489
|
+
tier: w.tier,
|
|
490
|
+
tier_expires_at: acct ? acct.tier_expires_at : null,
|
|
491
|
+
// Ledger row id of this burn — checkout debits points BEFORE its order
|
|
492
|
+
// exists (the debit is the double-spend gate) and links the row to the
|
|
493
|
+
// order afterwards via linkRedemptionToOrder, or compensates via
|
|
494
|
+
// reverseRedemptionById when the checkout dies before the order exists.
|
|
495
|
+
tx_id: w.id,
|
|
352
496
|
};
|
|
353
497
|
},
|
|
354
498
|
|
|
@@ -366,7 +510,7 @@ function create(opts) {
|
|
|
366
510
|
"WHERE id = ?2 AND transaction_type = 'redeem' AND order_id IS NULL",
|
|
367
511
|
[oid, tid],
|
|
368
512
|
);
|
|
369
|
-
return
|
|
513
|
+
return b.sql.casWon(res).won;
|
|
370
514
|
},
|
|
371
515
|
|
|
372
516
|
// Reverse ONE redeem burn by its ledger row id — the compensation edge
|
|
@@ -394,15 +538,24 @@ function create(opts) {
|
|
|
394
538
|
"WHERE id = ?2 AND restored_points = 0",
|
|
395
539
|
[spent, row.id],
|
|
396
540
|
);
|
|
397
|
-
if (
|
|
541
|
+
if (!b.sql.casWon(claim).won) return { restored_points: 0 }; // lost the claim
|
|
398
542
|
await _ensureAccountRow(row.customer_id, ts);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
await
|
|
405
|
-
|
|
543
|
+
// Forward-only credit-back: ONE chained +spent row off the live tip
|
|
544
|
+
// (lifetime untouched). The restored_points claim above is the
|
|
545
|
+
// serialization point, so a double-fire wins no claim and appends
|
|
546
|
+
// nothing — exactly-once. There is no revert anywhere, so the
|
|
547
|
+
// two-statement double-credit-on-revert hazard is structurally gone.
|
|
548
|
+
var w = await _writeChained(row.customer_id, function (latest) {
|
|
549
|
+
return {
|
|
550
|
+
type: "redeem", points: spent, source: "redeem-reversal", orderId: null,
|
|
551
|
+
notes: "restored ref=tx:" + row.id,
|
|
552
|
+
balanceAfter: latest.balance + spent,
|
|
553
|
+
lifetimeAfter: latest.lifetime,
|
|
554
|
+
tierAfter: latest.tier,
|
|
555
|
+
ts: _resolveOccurredAt(ts, latest.occurred_at),
|
|
556
|
+
};
|
|
557
|
+
});
|
|
558
|
+
await _refreshMirror(row.customer_id, w, ts);
|
|
406
559
|
return { restored_points: spent };
|
|
407
560
|
},
|
|
408
561
|
|
|
@@ -473,20 +626,26 @@ function create(opts) {
|
|
|
473
626
|
"WHERE id = ?2 AND restored_points = ?3",
|
|
474
627
|
[target, row.id, already],
|
|
475
628
|
);
|
|
476
|
-
if (
|
|
629
|
+
if (!b.sql.casWon(claim).won) continue; // lost the claim
|
|
477
630
|
await _ensureAccountRow(row.customer_id, ts);
|
|
478
|
-
//
|
|
479
|
-
// lifetime
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
)
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
631
|
+
// Forward-only credit-back: ONE chained +delta row off the live tip,
|
|
632
|
+
// BALANCE only (lifetime untouched — a redeem never moved it, so the
|
|
633
|
+
// restore mustn't either). The restored_points claim above is the
|
|
634
|
+
// serialization point; there is no revert, so no double-credit hazard.
|
|
635
|
+
// `source='redeem-reversal'` distinguishes it from the original burn so
|
|
636
|
+
// the scan above never re-reads it. _writeChained is awaited before the
|
|
637
|
+
// loop advances, so `delta`/`oid`/`ts` are stable inside the derive.
|
|
638
|
+
var wRestore = await _writeChained(row.customer_id, function (latest) {
|
|
639
|
+
return {
|
|
640
|
+
type: "redeem", points: delta, source: "redeem-reversal", orderId: oid,
|
|
641
|
+
notes: "restored ref=order:" + oid,
|
|
642
|
+
balanceAfter: latest.balance + delta,
|
|
643
|
+
lifetimeAfter: latest.lifetime,
|
|
644
|
+
tierAfter: latest.tier,
|
|
645
|
+
ts: _resolveOccurredAt(ts, latest.occurred_at),
|
|
646
|
+
};
|
|
647
|
+
});
|
|
648
|
+
await _refreshMirror(row.customer_id, wRestore, ts);
|
|
490
649
|
restoredTotal += delta;
|
|
491
650
|
}
|
|
492
651
|
return { restored_points: restoredTotal };
|
|
@@ -501,47 +660,38 @@ function create(opts) {
|
|
|
501
660
|
var source = _source(input.source);
|
|
502
661
|
var notes = _notes(input.notes);
|
|
503
662
|
|
|
504
|
-
var
|
|
505
|
-
await _ensureAccountRow(customerId,
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
// Positive adjustments also increment lifetime — operators
|
|
513
|
-
// crediting a customer for a service recovery should see that
|
|
514
|
-
// credit count toward tier. Negative adjustments do NOT
|
|
515
|
-
// decrement lifetime (otherwise a clawback could downgrade tier
|
|
516
|
-
// retroactively, which is a customer-facing surprise). The
|
|
517
|
-
// lifetime delta is therefore the positive part of `delta`.
|
|
663
|
+
var requested = _now();
|
|
664
|
+
await _ensureAccountRow(customerId, requested);
|
|
665
|
+
// Positive adjustments increment lifetime (operator goodwill counts
|
|
666
|
+
// toward tier) and recompute the tier; negative adjustments do NOT move
|
|
667
|
+
// lifetime (no retroactive tier downgrade) and are guarded so the balance
|
|
668
|
+
// can't go below zero (balSub + delta >= 0, delta negative) — the loser
|
|
669
|
+
// of a concurrent over-draw re-reads the live balance and is refused.
|
|
518
670
|
var lifetimeDelta = delta > 0 ? delta : 0;
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
);
|
|
532
|
-
if (
|
|
671
|
+
var tierBefore;
|
|
672
|
+
var w = await _writeChained(customerId, function (latest) {
|
|
673
|
+
tierBefore = latest.tier;
|
|
674
|
+
var lifeAfter = latest.lifetime + lifetimeDelta;
|
|
675
|
+
return {
|
|
676
|
+
type: "adjust", points: delta, source: source, orderId: null, notes: notes,
|
|
677
|
+
balanceAfter: latest.balance + delta,
|
|
678
|
+
lifetimeAfter: lifeAfter,
|
|
679
|
+
tierAfter: delta > 0 ? computeTier(lifeAfter) : latest.tier,
|
|
680
|
+
ts: _resolveOccurredAt(requested, latest.occurred_at),
|
|
681
|
+
guard: delta < 0 ? "__BAL__ + ?4 >= 0" : undefined,
|
|
682
|
+
};
|
|
683
|
+
});
|
|
684
|
+
if (w.refused) {
|
|
533
685
|
var ins = new Error("loyalty.adjust: adjustment would underflow balance");
|
|
534
686
|
ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
|
|
535
687
|
throw ins;
|
|
536
688
|
}
|
|
537
|
-
await
|
|
538
|
-
|
|
539
|
-
var after = await _readAccount(customerId);
|
|
689
|
+
await _refreshMirror(customerId, w, requested);
|
|
540
690
|
return {
|
|
541
|
-
balance:
|
|
542
|
-
lifetime:
|
|
543
|
-
tier:
|
|
544
|
-
tier_changed:
|
|
691
|
+
balance: w.balance,
|
|
692
|
+
lifetime: w.lifetime,
|
|
693
|
+
tier: w.tier,
|
|
694
|
+
tier_changed: w.tier !== tierBefore,
|
|
545
695
|
};
|
|
546
696
|
},
|
|
547
697
|
|
|
@@ -564,21 +714,22 @@ function create(opts) {
|
|
|
564
714
|
var source = _source(input.source);
|
|
565
715
|
var notes = _notes(input.notes);
|
|
566
716
|
|
|
567
|
-
var
|
|
568
|
-
await _ensureAccountRow(customerId,
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
717
|
+
var requested = _now();
|
|
718
|
+
await _ensureAccountRow(customerId, requested);
|
|
719
|
+
// Forward-only +points chained row, BALANCE only — never moves lifetime
|
|
720
|
+
// and never recomputes tier (giving redeemed points back must not inflate
|
|
721
|
+
// the tier-driving total above what was actually earned).
|
|
722
|
+
var w = await _writeChained(customerId, function (latest) {
|
|
723
|
+
return {
|
|
724
|
+
type: "adjust", points: points, source: source, orderId: null, notes: notes,
|
|
725
|
+
balanceAfter: latest.balance + points,
|
|
726
|
+
lifetimeAfter: latest.lifetime,
|
|
727
|
+
tierAfter: latest.tier,
|
|
728
|
+
ts: _resolveOccurredAt(requested, latest.occurred_at),
|
|
729
|
+
};
|
|
730
|
+
});
|
|
731
|
+
await _refreshMirror(customerId, w, requested);
|
|
732
|
+
return { balance: w.balance, lifetime: w.lifetime, tier: w.tier };
|
|
582
733
|
},
|
|
583
734
|
|
|
584
735
|
expire: async function (input) {
|
|
@@ -592,55 +743,49 @@ function create(opts) {
|
|
|
592
743
|
}
|
|
593
744
|
var reason = _source(input.reason);
|
|
594
745
|
|
|
595
|
-
var
|
|
596
|
-
await _ensureAccountRow(customerId,
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
var toExpire = points > before.balance_points ? before.balance_points : points;
|
|
605
|
-
if (toExpire === 0) {
|
|
606
|
-
// No-op write the ledger row anyway so the audit trail
|
|
607
|
-
// records that the operator ran the sweep and found nothing.
|
|
608
|
-
await _writeTx(customerId, "expire", 0, reason, null, "", ts);
|
|
746
|
+
var requested = _now();
|
|
747
|
+
await _ensureAccountRow(customerId, requested);
|
|
748
|
+
// Cap at the LIVE balance on every attempt (an interim redemption may
|
|
749
|
+
// have shrunk it) — expire only what's there, never refusing. A zero-cap
|
|
750
|
+
// (empty balance) still writes a points:0 breadcrumb so the audit trail
|
|
751
|
+
// records the sweep ran. Lifetime is untouched. The chained row carries
|
|
752
|
+
// the new running balance, so there is no stored-column second statement.
|
|
753
|
+
var w = await _writeChained(customerId, function (latest) {
|
|
754
|
+
var toExpire = points > latest.balance ? latest.balance : points;
|
|
609
755
|
return {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
756
|
+
type: "expire", points: -toExpire, source: reason, orderId: null, notes: "",
|
|
757
|
+
balanceAfter: latest.balance - toExpire,
|
|
758
|
+
lifetimeAfter: latest.lifetime,
|
|
759
|
+
tierAfter: latest.tier,
|
|
760
|
+
ts: _resolveOccurredAt(requested, latest.occurred_at),
|
|
761
|
+
// toExpire is capped at the same tip so the debit never underflows;
|
|
762
|
+
// the guard is belt-and-braces against a racing debit (which would
|
|
763
|
+
// instead collide on the fence and re-cap on retry).
|
|
764
|
+
guard: "__BAL__ + ?4 >= 0",
|
|
614
765
|
};
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
await query(
|
|
618
|
-
"UPDATE loyalty_accounts SET balance_points = balance_points - ?1, " +
|
|
619
|
-
"updated_at = ?2 WHERE customer_id = ?3 AND balance_points >= ?1",
|
|
620
|
-
[toExpire, ts, customerId],
|
|
621
|
-
);
|
|
622
|
-
await _writeTx(customerId, "expire", -toExpire, reason, null, "", ts);
|
|
623
|
-
|
|
624
|
-
var after = await _readAccount(customerId);
|
|
766
|
+
});
|
|
767
|
+
await _refreshMirror(customerId, w, requested);
|
|
625
768
|
return {
|
|
626
|
-
balance:
|
|
627
|
-
lifetime:
|
|
628
|
-
tier:
|
|
629
|
-
expired:
|
|
769
|
+
balance: w.balance,
|
|
770
|
+
lifetime: w.lifetime,
|
|
771
|
+
tier: w.tier,
|
|
772
|
+
expired: -w.points,
|
|
630
773
|
};
|
|
631
774
|
},
|
|
632
775
|
|
|
633
776
|
balance: async function (customerId) {
|
|
634
777
|
_uuid(customerId, "customer_id");
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
778
|
+
// Authoritative balance/lifetime/tier come from the chain tip (O(1) on
|
|
779
|
+
// the running index), NOT the advisory loyalty_accounts mirror. Only
|
|
780
|
+
// tier_expires_at — an operator time-bound the ledger doesn't carry — is
|
|
781
|
+
// read from the mirror.
|
|
782
|
+
var tip = await _readLatest(customerId);
|
|
783
|
+
var acct = await _readAccount(customerId);
|
|
639
784
|
return {
|
|
640
|
-
balance:
|
|
641
|
-
lifetime:
|
|
642
|
-
tier:
|
|
643
|
-
tier_expires_at:
|
|
785
|
+
balance: tip.balance,
|
|
786
|
+
lifetime: tip.lifetime,
|
|
787
|
+
tier: tip.tier,
|
|
788
|
+
tier_expires_at: acct ? acct.tier_expires_at : null,
|
|
644
789
|
};
|
|
645
790
|
},
|
|
646
791
|
|
|
@@ -652,8 +797,14 @@ function create(opts) {
|
|
|
652
797
|
throw new TypeError("loyalty.history: limit must be an integer in [1, 500]");
|
|
653
798
|
}
|
|
654
799
|
var cursor = opts2.cursor;
|
|
800
|
+
// Exclude the per-customer chain-genesis anchor (the migration backfill
|
|
801
|
+
// row, id 'genesis-<customer_id>', points 0) — it carries the running-
|
|
802
|
+
// balance snapshot for the chain, not a real customer transaction. A real
|
|
803
|
+
// uuid.v7 id never starts with 'genesis-', so this excludes only the
|
|
804
|
+
// anchor (filtering by id-prefix, not by source: an operator source could
|
|
805
|
+
// legitimately be 'chain-genesis').
|
|
655
806
|
var sql = "SELECT id, customer_id, transaction_type, points, source, order_id, notes, occurred_at " +
|
|
656
|
-
"FROM loyalty_transactions WHERE customer_id = ?1";
|
|
807
|
+
"FROM loyalty_transactions WHERE customer_id = ?1 AND id NOT LIKE 'genesis-%'";
|
|
657
808
|
var params = [customerId];
|
|
658
809
|
if (cursor != null) {
|
|
659
810
|
if (typeof cursor !== "number" || !Number.isInteger(cursor) || cursor < 0) {
|
|
@@ -700,6 +851,70 @@ function create(opts) {
|
|
|
700
851
|
var r = await query(sql, params);
|
|
701
852
|
return r.rows;
|
|
702
853
|
},
|
|
854
|
+
|
|
855
|
+
// Walk a customer's running-balance chain and recompute every row_hash to
|
|
856
|
+
// prove no row was edited or re-ordered after the fact (the loyalty leg of
|
|
857
|
+
// the money-ledger tamper-evidence). Mirrors gift-card-ledger.verifyChain:
|
|
858
|
+
// a NULL-row_hash legacy/genesis prefix is skipped until the first hashed
|
|
859
|
+
// row anchors the chain; a populated all-NULL ledger is `unanchored` (the
|
|
860
|
+
// shape a full rewrite produces), not "valid". An optional trusted
|
|
861
|
+
// { count, head } anchor additionally rules out a tail truncation. Verifies
|
|
862
|
+
// over _rowFieldsForHash — the SAME tuple the writer hashes (order_id +
|
|
863
|
+
// restored_points excluded), so a linked redemption or a restore can't
|
|
864
|
+
// false-tamper. O(n); operator-audit use, not hot-path.
|
|
865
|
+
verifyChain: async function (customerId, opts) {
|
|
866
|
+
_uuid(customerId, "customer_id");
|
|
867
|
+
opts = opts || {};
|
|
868
|
+
var anchor = null;
|
|
869
|
+
if (opts.anchor != null) {
|
|
870
|
+
if (typeof opts.anchor !== "object"
|
|
871
|
+
|| typeof opts.anchor.count !== "number" || !Number.isInteger(opts.anchor.count) || opts.anchor.count < 1
|
|
872
|
+
|| typeof opts.anchor.head !== "string" || opts.anchor.head.length !== SHA3_512_HEX_LEN) {
|
|
873
|
+
throw new TypeError("loyalty.verifyChain: anchor must be { count: positive integer, head: " + SHA3_512_HEX_LEN + "-hex-char string }");
|
|
874
|
+
}
|
|
875
|
+
anchor = opts.anchor;
|
|
876
|
+
}
|
|
877
|
+
var rows = (await query(
|
|
878
|
+
"SELECT * FROM loyalty_transactions WHERE customer_id = ?1 ORDER BY occurred_at ASC, id ASC",
|
|
879
|
+
[customerId],
|
|
880
|
+
)).rows;
|
|
881
|
+
var legacyPrefix = 0, anchored = false, prevHash = ZERO_HASH;
|
|
882
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
883
|
+
var row = rows[i];
|
|
884
|
+
if (!anchored && row.row_hash == null) { legacyPrefix += 1; continue; }
|
|
885
|
+
anchored = true;
|
|
886
|
+
var breakBase = { ok: false, rows_verified: i - legacyPrefix, legacy_prefix: legacyPrefix,
|
|
887
|
+
break_at: i, break_row_id: row.id };
|
|
888
|
+
if (row.row_hash == null) return Object.assign(breakBase, { reason: "unhashed row after chain anchor" });
|
|
889
|
+
if (row.prev_hash !== prevHash) {
|
|
890
|
+
return Object.assign(breakBase, { reason: "prev_hash mismatch", expected: prevHash, actual: row.prev_hash });
|
|
891
|
+
}
|
|
892
|
+
var computed = _computeRowHash(prevHash, _rowFieldsForHash(row));
|
|
893
|
+
if (computed !== row.row_hash) {
|
|
894
|
+
return Object.assign(breakBase, { reason: "row_hash mismatch", expected: computed, actual: row.row_hash });
|
|
895
|
+
}
|
|
896
|
+
prevHash = row.row_hash;
|
|
897
|
+
}
|
|
898
|
+
if (!anchored && rows.length > 0) {
|
|
899
|
+
return { ok: false, rows_verified: 0, legacy_prefix: legacyPrefix, break_at: 0,
|
|
900
|
+
break_row_id: rows[0].id, reason: "unanchored chain (no hashed row in a populated ledger)" };
|
|
901
|
+
}
|
|
902
|
+
if (anchor) {
|
|
903
|
+
if (rows.length < anchor.count) {
|
|
904
|
+
return { ok: false, rows_verified: rows.length - legacyPrefix, legacy_prefix: legacyPrefix,
|
|
905
|
+
anchor_checked: true, reason: "row count below anchor (possible tail truncation)",
|
|
906
|
+
expected_count: anchor.count, actual_count: rows.length };
|
|
907
|
+
}
|
|
908
|
+
var anchorRow = rows[anchor.count - 1];
|
|
909
|
+
if (anchorRow.row_hash !== anchor.head) {
|
|
910
|
+
return { ok: false, rows_verified: rows.length - legacyPrefix, legacy_prefix: legacyPrefix,
|
|
911
|
+
anchor_checked: true, reason: "anchor head mismatch (chain replaced below snapshot)",
|
|
912
|
+
expected: anchor.head, actual: anchorRow.row_hash };
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return { ok: true, rows_verified: rows.length - legacyPrefix, legacy_prefix: legacyPrefix,
|
|
916
|
+
head: prevHash, anchor_checked: !!anchor };
|
|
917
|
+
},
|
|
703
918
|
};
|
|
704
919
|
}
|
|
705
920
|
|