@blamejs/blamejs-shop 0.4.67 → 0.4.68

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 CHANGED
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - 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.
12
+
11
13
  - 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
14
 
13
15
  - 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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.67",
2
+ "version": "0.4.68",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -260,8 +260,14 @@ function create(opts) {
260
260
  }
261
261
 
262
262
  var loyalty = opts.loyalty;
263
- if (!loyalty || typeof loyalty.redeem !== "function" || typeof loyalty.adjust !== "function") {
264
- throw new TypeError("loyaltyRedemption: opts.loyalty handle required (must expose redeem + adjust)");
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
- // call surfaces LOYALTY_INSUFFICIENT_BALANCE on its own when the
479
- // customer can't afford the reward; that error propagates as-is.
480
- await loyalty.redeem({
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
- var minted = await coupons.issueSingleUseFromReward({
495
- customer_id: customerId,
496
- reward_slug: rewardSlug,
497
- value_json: reward.value_json,
498
- expires_at: expiresAt,
499
- });
500
- if (!minted || typeof minted.code !== "string" || !minted.code.length) {
501
- throw new TypeError("loyaltyRedemption.redeemForCustomer: coupons.issueSingleUseFromReward must return { code: string }");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.67",
3
+ "version": "0.4.68",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {