@blamejs/blamejs-shop 0.4.68 → 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 +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/giftcards.js +29 -5
- package/package.json +1 -1
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.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
|
+
|
|
11
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.
|
|
12
14
|
|
|
13
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.
|
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 {
|
package/package.json
CHANGED