@blamejs/blamejs-shop 0.4.67 → 0.4.69
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/lib/asset-manifest.json +1 -1
- package/lib/giftcards.js +29 -5
- package/lib/loyalty-redemption.js +92 -22
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.69 (2026-06-20) — **A gift-card redemption that can't record its audit row no longer loses the balance.** Redeeming a gift card debits the card's balance in one atomic conditional update, then writes a redemption record in a separate statement. Because the bridge runs each statement on its own, a transient failure writing that record left the balance already debited with no redemption row — and the reversal path keys on that row, so the spent balance could never be returned. Redemption is now all-or-nothing: if the record can't be written, the card is re-credited for the amount (and moved back to active if the debit had zeroed it and closed it) before the error is surfaced, so a failed redemption never silently consumes a customer's balance. No API change. **Fixed:** *Redemption re-credits the card if its audit row can't be written* — After the atomic balance debit, the redemption record insert is wrapped so a transient write failure re-credits the card by the redeemed amount and restores it to active if that debit had zeroed and closed it. The customer keeps the balance and the card stays usable, instead of losing the spend to a redemption that left no row for the reversal path to act on.
|
|
12
|
+
|
|
13
|
+
- v0.4.68 (2026-06-20) — **Redeeming a loyalty reward no longer burns points on a failed mint or exceeds the per-customer cap under load.** Redeeming a reward for points debited the customer's balance, then checked the per-customer redemption cap with a separate count and minted the reward coupon as separate steps. Two problems followed. The cap was a read-then-act check: two redemptions arriving together both read a count under the cap and both succeeded, so a customer could exceed a lifetime cap (for example mint two of a once-ever coupon). And if the coupon mint failed after the points were debited — the coupons primitive erroring, or a transient fault — the points were gone with no redemption row recorded, so no refund path could return them; the customer was charged for a reward they never received. Redemption now claims the cap slot in a single conditional insert that recomputes the live count, so concurrent redemptions can't both exceed the cap, and it reverses the points debit (and removes the claimed row) if the coupon can't be minted, so a failed redemption always leaves the balance whole. No API change. **Fixed:** *Per-customer redemption cap is enforced atomically* — The redemption row is now inserted only if the customer's live active/consumed count for that reward is still under the cap, recomputed inside the insert. Two redemptions racing the last slot resolve to a single winner; the loser is refused with the same cap error and its points are returned. The cheap pre-check is kept as a fast path but is no longer the thing that enforces the limit. · *A failed coupon mint returns the points and leaves no row* — If the coupon can't be minted after the points were debited and the slot claimed, both are rolled back — the points debit is reversed and the claimed redemption row is removed — so the customer's balance is whole and no redemption is left recorded for a reward that was never issued. The points reversal rides the same idempotent restore the checkout path uses, so a retry credits exactly once.
|
|
14
|
+
|
|
11
15
|
- v0.4.67 (2026-06-20) — **Subject-access exports now carry the customer's complete record, not just the first page.** A data-subject access export assembled each domain by reading a single page from that domain's reader. A customer with more than one page in any paginated domain — orders, the loyalty points ledger, product reviews, wishlist entries, survey invitations, or support tickets — silently received a truncated export, which is an incomplete response to a GDPR Article 15 request. Each of those domains is now drained to the end by following the reader's own cursor, so the export carries every row. Readers that already return the whole set in one read (addresses, the consent ledger, subscriptions) were already complete and are unchanged. As part of this, the support-ticket reader keyed on customer id (the one the export uses, since it holds no plaintext email) gained cursor pagination so it can be paged to completion. **Changed:** *supportTickets.listByCustomerId and customerSurveys.invitationsForCustomer return { rows, next_cursor }* — Both customer-scoped readers that the export uses now return a paginated envelope — { rows, next_cursor } — instead of a bare array, so a caller can page a customer's full history to the end. listByCustomerId pages on the (opened_at desc, id desc) tuple (matching the email-keyed listForCustomer); invitationsForCustomer pages on the invitation id. If you call either directly, read the rows off the .rows field and pass the previous next_cursor as the cursor option to continue where the last page ended. **Fixed:** *Access exports drain every paginated domain to completion* — The export now follows each reader's cursor to the end for orders, the loyalty points ledger, product reviews, wishlist entries, survey invitations, and support tickets, concatenating every page. A customer with hundreds of orders or ledger entries receives all of them rather than the most recent hundred. A page-count backstop far above any real per-customer record guards against a misbehaving cursor without ever truncating a genuine export.
|
|
12
16
|
|
|
13
17
|
- v0.4.66 (2026-06-20) — **Inventory-audit finalization can no longer double-apply its stock adjustments.** Finalizing an inventory audit with apply_adjustments writes one shelf adjustment per counted line, then marks the audit finalized. Two failure modes could move a shelf twice. First, if a later line was refused mid-loop — most commonly a negative variance that would have pushed the shelf below its outstanding paid holds — the audit stayed open, but the lines already adjusted had moved; re-running the finalize re-applied those earlier adjustments on top of themselves. Second, two finalize calls overlapping on the same audit both applied every adjustment before either marked the audit finalized. Finalization now claims the audit's transition to finalized in a single conditional write BEFORE touching any shelf, so exactly one finalize applies the adjustments and a concurrent or duplicate call applies nothing. Each adjustment is applied as the difference between the counted variance and what this audit has already moved, so a retry lands only the not-yet-applied remainder and a recount made before the retry is reflected exactly. A finalize that is refused part-way re-opens the audit so the operator can clear the blocker and retry. No API change. **Fixed:** *Finalization claims the audit before moving any shelf* — The transition that marks an audit finalized now lands in one conditional write, gated on the audit still being open or in progress, BEFORE any stock adjustment is written. Two finalize calls racing the same audit — or a duplicate retry overlapping a still-running finalize — resolve to a single winner that applies the adjustments; the loser writes nothing and returns the computed totals. The claim also freezes the audit's scan lines, so the variances being applied can't change underneath the writer. · *Adjustments reconcile against what already landed* — Each shelf adjustment is applied as the counted variance minus what this audit has already moved for that SKU and location. A finalize refused part-way (a respect-holds floor on one line) re-opens the audit; the retry then applies only the remaining difference instead of re-applying lines that already succeeded, and a recount that changed a line's variance before the retry is corrected to the new value rather than left at the old one.
|
package/lib/asset-manifest.json
CHANGED
package/lib/giftcards.js
CHANGED
|
@@ -333,11 +333,35 @@ function create(opts) {
|
|
|
333
333
|
}
|
|
334
334
|
|
|
335
335
|
var redemptionId = b.uuid.v7();
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
336
|
+
try {
|
|
337
|
+
await query(
|
|
338
|
+
"INSERT INTO giftcard_redemptions (id, giftcard_id, order_id, amount_minor, redeemed_at) " +
|
|
339
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
340
|
+
[redemptionId, row.id, orderId, input.amount_minor, ts],
|
|
341
|
+
);
|
|
342
|
+
} catch (insErr) {
|
|
343
|
+
// The balance was already debited by the atomic UPDATE above. The
|
|
344
|
+
// insert threw — but on an autocommit-per-statement bridge a throw
|
|
345
|
+
// does NOT prove the row didn't commit (a connection reset while
|
|
346
|
+
// returning the response, or a retry that then collides on the
|
|
347
|
+
// redemption-id primary key, both surface as a throw over a row that
|
|
348
|
+
// actually landed). So DELETE the redemption row by its id FIRST: if
|
|
349
|
+
// it committed, this removes it so a later reverseRedemption can't
|
|
350
|
+
// credit the spend a second time; if it never landed, the delete is a
|
|
351
|
+
// no-op. ONLY THEN re-credit the card (and un-terminate it if this
|
|
352
|
+
// debit had zeroed it), making the redeem all-or-nothing either way.
|
|
353
|
+
// Deleting before crediting is deliberate: crediting first and then
|
|
354
|
+
// failing to delete would leave a live row against a full balance —
|
|
355
|
+
// the double-credit this guards against.
|
|
356
|
+
await query("DELETE FROM giftcard_redemptions WHERE id = ?1", [redemptionId]);
|
|
357
|
+
await query(
|
|
358
|
+
"UPDATE giftcards SET balance_minor = balance_minor + ?1, " +
|
|
359
|
+
"status = CASE WHEN status = 'redeemed' THEN 'active' ELSE status END, " +
|
|
360
|
+
"updated_at = ?2 WHERE id = ?3",
|
|
361
|
+
[input.amount_minor, _now(), row.id],
|
|
362
|
+
);
|
|
363
|
+
throw insErr;
|
|
364
|
+
}
|
|
341
365
|
|
|
342
366
|
var remaining = row.balance_minor - input.amount_minor;
|
|
343
367
|
return {
|
|
@@ -260,8 +260,14 @@ function create(opts) {
|
|
|
260
260
|
}
|
|
261
261
|
|
|
262
262
|
var loyalty = opts.loyalty;
|
|
263
|
-
|
|
264
|
-
|
|
263
|
+
// reverseRedemptionById is REQUIRED, not optional: redeemForCustomer
|
|
264
|
+
// debits points before claiming the cap slot + minting the coupon, and
|
|
265
|
+
// reverses that debit if either fails. A handle that can't reverse a
|
|
266
|
+
// burn would silently strand the points on those failure paths, so the
|
|
267
|
+
// contract is enforced at wiring time rather than discovered at runtime.
|
|
268
|
+
if (!loyalty || typeof loyalty.redeem !== "function" || typeof loyalty.adjust !== "function" ||
|
|
269
|
+
typeof loyalty.reverseRedemptionById !== "function") {
|
|
270
|
+
throw new TypeError("loyaltyRedemption: opts.loyalty handle required (must expose redeem + adjust + reverseRedemptionById)");
|
|
265
271
|
}
|
|
266
272
|
|
|
267
273
|
// Optional coupons handle. When wired, expected shape is
|
|
@@ -448,6 +454,22 @@ function create(opts) {
|
|
|
448
454
|
return Number(row.n || row.N || 0);
|
|
449
455
|
}
|
|
450
456
|
|
|
457
|
+
// Best-effort reversal of a points debit when a redemption can't be
|
|
458
|
+
// completed (lost cap claim, or coupon mint failed). loyalty.redeem
|
|
459
|
+
// returns the burn's tx_id; reverseRedemptionById is idempotent (it
|
|
460
|
+
// claims `restored_points` 0 -> spent under a guarded UPDATE) and
|
|
461
|
+
// balance-only, so a double-fire credits exactly once and an unknown /
|
|
462
|
+
// already-reversed tx is a no-op. Its own failure is swallowed so the
|
|
463
|
+
// ORIGINAL error (cap / mint) is what surfaces — the reversal is the
|
|
464
|
+
// recovery, not the reported outcome.
|
|
465
|
+
async function _reverseBurn(burn) {
|
|
466
|
+
try {
|
|
467
|
+
if (burn && burn.tx_id) {
|
|
468
|
+
await loyalty.reverseRedemptionById(burn.tx_id);
|
|
469
|
+
}
|
|
470
|
+
} catch (_e) { /* drop-silent — reversal is best-effort recovery */ }
|
|
471
|
+
}
|
|
472
|
+
|
|
451
473
|
async function redeemForCustomer(input) {
|
|
452
474
|
if (!input || typeof input !== "object") {
|
|
453
475
|
throw new TypeError("loyaltyRedemption.redeemForCustomer: input object required");
|
|
@@ -465,6 +487,11 @@ function create(opts) {
|
|
|
465
487
|
throw nope;
|
|
466
488
|
}
|
|
467
489
|
|
|
490
|
+
// Fast-fail cap pre-check — cheap, and avoids debiting points only to
|
|
491
|
+
// reverse them in the common already-capped case. NOT the real guard:
|
|
492
|
+
// two concurrent redeems both read a count under the cap here, so the
|
|
493
|
+
// atomic claim below (which recomputes the count inside the INSERT) is
|
|
494
|
+
// what actually serializes them.
|
|
468
495
|
if (reward.max_per_customer != null) {
|
|
469
496
|
var count = await _countActiveOrConsumedForCustomer(customerId, rewardSlug);
|
|
470
497
|
if (count >= reward.max_per_customer) {
|
|
@@ -474,10 +501,12 @@ function create(opts) {
|
|
|
474
501
|
}
|
|
475
502
|
}
|
|
476
503
|
|
|
477
|
-
// Debit points via the composed loyalty primitive. The redeem
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
|
|
504
|
+
// Debit points via the composed loyalty primitive. The redeem call
|
|
505
|
+
// surfaces LOYALTY_INSUFFICIENT_BALANCE on its own when the customer
|
|
506
|
+
// can't afford the reward; that error propagates as-is (nothing to
|
|
507
|
+
// compensate — no points moved). Capture the burn so a later failure
|
|
508
|
+
// can give the points back.
|
|
509
|
+
var burn = await loyalty.redeem({
|
|
481
510
|
customer_id: customerId,
|
|
482
511
|
points: reward.point_cost,
|
|
483
512
|
notes: "redeem:" + rewardSlug,
|
|
@@ -489,27 +518,68 @@ function create(opts) {
|
|
|
489
518
|
? null
|
|
490
519
|
: ts + (reward.expires_days_after_redemption * MS_PER_DAY);
|
|
491
520
|
|
|
521
|
+
// Atomic per-customer cap claim — the serialization point on the D1
|
|
522
|
+
// bridge (no interactive transactions). Insert the redemption row ONLY
|
|
523
|
+
// if the live active/consumed count is STILL under the cap, recomputed
|
|
524
|
+
// inside the INSERT...SELECT so two concurrent redeems can't both land
|
|
525
|
+
// (the loser changes zero rows). A NULL cap means no limit, so the
|
|
526
|
+
// guard short-circuits true. coupon_code is filled in after the coupon
|
|
527
|
+
// is minted below.
|
|
528
|
+
var claim = await query(
|
|
529
|
+
"INSERT INTO loyalty_redemptions (id, customer_id, reward_slug, points_debited, coupon_code, " +
|
|
530
|
+
"status, redeemed_at, consumed_at, expires_at, order_id, cancel_reason) " +
|
|
531
|
+
"SELECT ?1, ?2, ?3, ?4, NULL, 'active', ?5, NULL, ?6, NULL, NULL " +
|
|
532
|
+
"WHERE ?7 IS NULL OR (SELECT COUNT(*) FROM loyalty_redemptions " +
|
|
533
|
+
"WHERE customer_id = ?2 AND reward_slug = ?3 AND status IN ('active','consumed')) < ?7",
|
|
534
|
+
[redemptionId, customerId, rewardSlug, reward.point_cost, ts, expiresAt, reward.max_per_customer],
|
|
535
|
+
);
|
|
536
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
537
|
+
// Lost the cap claim — another redeem filled the last slot between
|
|
538
|
+
// the pre-check and here. Return the points and refuse, so a raced
|
|
539
|
+
// caller is never charged for a redemption it didn't get.
|
|
540
|
+
await _reverseBurn(burn);
|
|
541
|
+
var capRace = new Error("loyaltyRedemption.redeemForCustomer: customer has reached the per-customer redemption cap");
|
|
542
|
+
capRace.code = "REDEMPTION_CAP_REACHED";
|
|
543
|
+
throw capRace;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Mint the reward coupon now that the slot is claimed. If minting fails
|
|
547
|
+
// (the coupons primitive throws, or returns a malformed code), roll
|
|
548
|
+
// back BOTH the points debit and the claim row — otherwise the customer
|
|
549
|
+
// is charged for a reward they never received and the burn is orphaned
|
|
550
|
+
// (no redemption row for any refund path to key on).
|
|
492
551
|
var couponCode = null;
|
|
493
552
|
if (coupons) {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
553
|
+
try {
|
|
554
|
+
var minted = await coupons.issueSingleUseFromReward({
|
|
555
|
+
customer_id: customerId,
|
|
556
|
+
reward_slug: rewardSlug,
|
|
557
|
+
value_json: reward.value_json,
|
|
558
|
+
expires_at: expiresAt,
|
|
559
|
+
});
|
|
560
|
+
if (!minted || typeof minted.code !== "string" || !minted.code.length) {
|
|
561
|
+
throw new TypeError("loyaltyRedemption.redeemForCustomer: coupons.issueSingleUseFromReward must return { code: string }");
|
|
562
|
+
}
|
|
563
|
+
couponCode = minted.code;
|
|
564
|
+
// Link the minted coupon to the claimed row INSIDE the try, and
|
|
565
|
+
// confirm the write landed (rowCount === 1). A throw OR a silently
|
|
566
|
+
// lost update here rolls the redemption back like a mint failure —
|
|
567
|
+
// otherwise the customer is charged, the cap slot is consumed, and
|
|
568
|
+
// the redemption is left with a NULL coupon_code it can never use.
|
|
569
|
+
var upd = await query(
|
|
570
|
+
"UPDATE loyalty_redemptions SET coupon_code = ?1 WHERE id = ?2",
|
|
571
|
+
[couponCode, redemptionId],
|
|
572
|
+
);
|
|
573
|
+
if (Number(upd.rowCount || 0) !== 1) {
|
|
574
|
+
throw new Error("loyaltyRedemption.redeemForCustomer: failed to persist coupon_code on the claimed redemption");
|
|
575
|
+
}
|
|
576
|
+
} catch (mintErr) {
|
|
577
|
+
await _reverseBurn(burn);
|
|
578
|
+
await query("DELETE FROM loyalty_redemptions WHERE id = ?1", [redemptionId]);
|
|
579
|
+
throw mintErr;
|
|
502
580
|
}
|
|
503
|
-
couponCode = minted.code;
|
|
504
581
|
}
|
|
505
582
|
|
|
506
|
-
await query(
|
|
507
|
-
"INSERT INTO loyalty_redemptions (id, customer_id, reward_slug, points_debited, coupon_code, " +
|
|
508
|
-
"status, redeemed_at, consumed_at, expires_at, order_id, cancel_reason) " +
|
|
509
|
-
"VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6, NULL, ?7, NULL, NULL)",
|
|
510
|
-
[redemptionId, customerId, rewardSlug, reward.point_cost, couponCode, ts, expiresAt],
|
|
511
|
-
);
|
|
512
|
-
|
|
513
583
|
return {
|
|
514
584
|
redemption_id: redemptionId,
|
|
515
585
|
coupon_code: couponCode,
|
package/package.json
CHANGED