@blamejs/blamejs-shop 0.4.79 → 0.4.81

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,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.81 (2026-06-22) — **Notification transitions are atomic, a missing insurance policy no longer crashes claim approval, and a returns summary no longer zeroes its refund totals under a status filter.** Three correctness fixes on the fulfillment surface. Notification status transitions (mark-sent / mark-failed / mark-read) are now single atomic claims that fold the expected status into the write, so two concurrent delivery callbacks can't both stamp the row last-write-wins, a mark-failed can't clobber a mark-sent, and two concurrent mark-failed calls each count once (the retry counter is incremented in the database rather than read-then-written). Approving a shipping-insurance claim whose parent policy row is missing now returns a clear typed error instead of dereferencing null and throwing an opaque crash. And the operator returns summary builds its refund-value totals from a status-free base filter, so supplying a status filter (e.g. to view rejected returns) no longer collapses the refunded and pending payout totals to zero through a self-contradictory query. No configuration changes; upgrade to pick them up. **Fixed:** *Notification status transitions are atomic claims* — mark-sent, mark-failed, and mark-read now gate the transition on the observed status inside the UPDATE and treat the row count as the claim signal, instead of checking status in a prior read and writing unconditionally. Concurrent delivery callbacks therefore can't both transition one notification last-write-wins, a mark-failed can't overwrite a mark-sent, and the retry counter is incremented in the database (count + 1) so two simultaneous failures can't lose an increment. mark-sent now applies only to a pending notification: a failed delivery is a durable failure record (the scheduler only re-dispatches pending notifications), so a stale or duplicate success callback can no longer flip a failed notification to sent and clear its error — retrying a failed notification is an explicit re-queue. · *Approving an insurance claim with a missing parent policy fails cleanly* — shipping-insurance claim approval now refuses with a typed not-found error when the claim's parent insurance row can't be loaded, matching the null-guard the rest of the module already applies, instead of dereferencing a null row while computing the payout ceiling. · *A returns summary keeps its refund totals when a status filter is applied* — The operator returns summary computes its refunded and pending payout totals from a base filter scoped only to the date window, so they pin their own status independently of the caller's status filter. Previously, supplying a status filter grafted a second, conflicting status predicate onto the payout queries (for example status = 'rejected' AND status = 'refunded'), silently zeroing both rollups; the per-status counts were always correct and remain so.
12
+
13
+ - v0.4.80 (2026-06-21) — **Recording an affiliate commission is idempotent under concurrency.** Recording an affiliate commission for an order now converges instead of failing when two attribution hooks fire for the same order at once. The recorder already returned the existing commission on a sequential repeat; under concurrency, both callers passed that pre-read and the second insert hit the (order_id, affiliate_id) uniqueness constraint and threw an unhandled error. The losing writer now catches the constraint violation and returns the committed row, so exactly one commission is recorded and both callers get it. No configuration changes; upgrade to pick it up. **Fixed:** *Concurrent affiliate-commission recording converges on one row* — recordCommissionEvent wraps its insert so that when a concurrent call for the same (order_id, affiliate_id) wins the race, the losing writer catches the uniqueness violation and returns the already-committed commission rather than throwing. The sequential idempotent behaviour is unchanged; this closes the gap where two simultaneous attribution events for one order surfaced an unhandled error instead of a single recorded commission.
14
+
11
15
  - v0.4.79 (2026-06-21) — **Reversing a receipt no longer mints phantom stock, and restock recommendations accept lowercase location codes.** Two inventory correctness fixes. Reversing an applied goods-receipt now rolls back exactly the quantity the reversal removed: when a SKU had already been decremented elsewhere to below the received quantity, the reversal's forward step removes only what's on hand, and a mid-reversal failure now compensates by that exact amount instead of adding back the full received quantity — which previously minted phantom stock. Separately, restock recommendations now accept lowercase and dotted location codes, matching the location-code format the rest of the inventory surface already accepts; a stricter uppercase-only pattern had been rejecting valid codes. No configuration changes; upgrade to pick them up. **Fixed:** *Reversing a goods-receipt rolls back the exact quantity removed* — Receipt reversal now records how many units each line's decrement actually removed (floored at the quantity on hand) and, if a later line in the same reversal fails, compensates the already-decremented lines by that exact amount. Previously the forward decrement clamped at zero while the compensation added back the full received quantity, so reversing a receipt for a SKU that had since been drawn down below the received amount could leave more stock on hand than before the reversal. · *Restock recommendations accept lowercase and dotted location codes* — smart-restocking now validates location codes against the same pattern the inventory-locations primitive uses (mixed case, with dot/underscore/hyphen), so a recommendation request for a location like "aisle-3.bin-2" is accepted. A divergent uppercase-only pattern had been rejecting valid location codes that every other inventory operation accepts.
12
16
 
13
17
  - v0.4.78 (2026-06-21) — **Voiding a gift card no longer clobbers a concurrent redemption, and a credit order can't be charged twice.** Two money-safety fixes that make terminal transitions exactly-once. Voiding a gift card is now a single atomic transition guarded on the card still being active, so a void that races a redemption can no longer overwrite the redemption's terminal state and silently discard the spent balance — whichever transition lands first wins and the other is refused. Separately, charging a credit-line order is now idempotent per order: the charge guard refuses a second charge for an order that already has one (and a retry returns the original charge rather than erroring), so the same order can't be charged twice against the customer's credit line. No configuration changes; upgrade to pick them up. **Fixed:** *Voiding a gift card is an atomic, fenced transition* — Gift-card void is now a single guarded UPDATE that only matches a card still in the active state, mirroring redeem. A void that races a redemption (or an expiry) can no longer overwrite the card's terminal state — the prior read-decide-write could void a card a concurrent redemption had just spent, discarding the remaining-balance debit. A void that loses the race is refused (or returns idempotently if the card was already voided). · *A credit-line order is charged at most once* — creditLimits.chargeOrder now refuses a second charge for an order that already carries one, enforced in the same atomic statement as the credit-cap check. A retried charge for an already-charged order returns the original charge instead of erroring or charging the line a second time, so a duplicate capture or retry can't double-draw the customer's credit.
package/lib/affiliates.js CHANGED
@@ -796,13 +796,30 @@ function create(opts) {
796
796
  );
797
797
 
798
798
  var id = b.uuid.v7();
799
- await query(
800
- "INSERT INTO affiliate_commissions " +
801
- "(id, order_id, affiliate_id, order_total_minor, commission_minor, " +
802
- " currency, status, occurred_at, paid_at, voided_at, payout_reference, void_reason) " +
803
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, NULL, NULL, NULL, NULL)",
804
- [id, orderId, affiliateId, orderTotal, commissionMinor, currency, occurredAt],
805
- );
799
+ try {
800
+ await query(
801
+ "INSERT INTO affiliate_commissions " +
802
+ "(id, order_id, affiliate_id, order_total_minor, commission_minor, " +
803
+ " currency, status, occurred_at, paid_at, voided_at, payout_reference, void_reason) " +
804
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', ?7, NULL, NULL, NULL, NULL)",
805
+ [id, orderId, affiliateId, orderTotal, commissionMinor, currency, occurredAt],
806
+ );
807
+ } catch (e) {
808
+ // Concurrent recordCommissionEvent for the same (order_id,
809
+ // affiliate_id): the pre-read above missed a racing insert, so this
810
+ // writer loses on the UNIQUE constraint. Matching on the error MESSAGE
811
+ // is unreliable — the production D1 service-binding redacts the SQLite
812
+ // "UNIQUE constraint failed" text to a generic "HTTP 500" — so converge
813
+ // message-agnostically: re-read, and if the row now exists the failure
814
+ // was the duplicate, so return it (idempotent). If no row exists the
815
+ // insert failed for some other reason, so re-throw.
816
+ var raced = await query(
817
+ "SELECT * FROM affiliate_commissions WHERE order_id = ?1 AND affiliate_id = ?2",
818
+ [orderId, affiliateId],
819
+ );
820
+ if (raced.rows.length) return raced.rows[0];
821
+ throw e;
822
+ }
806
823
  return await _getCommissionRaw(id);
807
824
  },
808
825
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.79",
2
+ "version": "0.4.81",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -241,18 +241,29 @@ function create(opts) {
241
241
  _uuid(id, "notification id");
242
242
  options = options || {};
243
243
  var sentAt = options.sent_at == null ? _now() : _scheduledAt(options.sent_at, _now());
244
- var existing = (await query("SELECT status FROM notifications WHERE id = ?1", [id])).rows[0];
245
- if (!existing) return null;
246
- if (existing.status !== "pending" && existing.status !== "failed") {
247
- var err = new Error("notifications.markSent: cannot transition from " + existing.status + " to sent");
248
- err.code = "NOTIFICATION_BAD_TRANSITION";
249
- throw err;
250
- }
251
- await query(
252
- "UPDATE notifications SET status = 'sent', sent_at = ?1, last_error = NULL, updated_at = ?1 WHERE id = ?2",
244
+ // Atomic claim, pending sent ONLY. Folding the status into the UPDATE
245
+ // makes the transition a single serialized claim (no read-then-write
246
+ // last-write-wins). Crucially the claim does NOT match a 'failed' row: a
247
+ // delivery that failed is a durable failure record (the scheduler only
248
+ // re-dispatches 'pending', so retrying means an explicit re-queue), and
249
+ // accepting 'failed' here let a stale or duplicate success callback flip
250
+ // a failed notification to sent and clear its last_error — silently
251
+ // masking the failure. rowCount is the claim signal.
252
+ var upd = await query(
253
+ "UPDATE notifications SET status = 'sent', sent_at = ?1, last_error = NULL, updated_at = ?1 " +
254
+ "WHERE id = ?2 AND status = 'pending'",
253
255
  [sentAt, id],
254
256
  );
255
- return (await query("SELECT * FROM notifications WHERE id = ?1", [id])).rows[0];
257
+ if (Number(upd.rowCount || 0) === 1) {
258
+ return (await query("SELECT * FROM notifications WHERE id = ?1", [id])).rows[0];
259
+ }
260
+ // Lost the claim: the row is gone or already left pending/failed. Report
261
+ // exactly as the prior JS guard did.
262
+ var row = (await query("SELECT status FROM notifications WHERE id = ?1", [id])).rows[0];
263
+ if (!row) return null;
264
+ var err = new Error("notifications.markSent: cannot transition from " + row.status + " to sent");
265
+ err.code = "NOTIFICATION_BAD_TRANSITION";
266
+ throw err;
256
267
  },
257
268
 
258
269
  markFailed: async function (id, options) {
@@ -266,26 +277,42 @@ function create(opts) {
266
277
  if (!Number.isInteger(increment) || increment < 0) {
267
278
  throw new TypeError("notifications.markFailed: retry_count_increment must be a non-negative integer");
268
279
  }
269
- var existing = (await query("SELECT status, retry_count FROM notifications WHERE id = ?1", [id])).rows[0];
270
- if (!existing) return null;
271
- if (existing.status !== "pending" && existing.status !== "failed") {
272
- var err = new Error("notifications.markFailed: cannot transition from " + existing.status + " to failed");
273
- err.code = "NOTIFICATION_BAD_TRANSITION";
274
- throw err;
275
- }
276
- var newCount = Number(existing.retry_count) + increment;
277
280
  var ts = _now();
278
- await query(
279
- "UPDATE notifications SET status = 'failed', retry_count = ?1, last_error = ?2, updated_at = ?3 WHERE id = ?4",
280
- [newCount, errMsg, ts, id],
281
+ // Atomic claim with an in-SQL increment: the retry_count bump is computed
282
+ // in the UPDATE (retry_count + ?) and gated on the observed status, so two
283
+ // concurrent markFailed calls can't read the same retry_count and lose an
284
+ // increment, and a markFailed can't clobber a markSent. rowCount is the
285
+ // claim signal.
286
+ var upd = await query(
287
+ "UPDATE notifications SET status = 'failed', retry_count = retry_count + ?1, last_error = ?2, updated_at = ?3 " +
288
+ "WHERE id = ?4 AND status IN ('pending', 'failed')",
289
+ [increment, errMsg, ts, id],
281
290
  );
282
- return (await query("SELECT * FROM notifications WHERE id = ?1", [id])).rows[0];
291
+ if (Number(upd.rowCount || 0) === 1) {
292
+ return (await query("SELECT * FROM notifications WHERE id = ?1", [id])).rows[0];
293
+ }
294
+ var row = (await query("SELECT status FROM notifications WHERE id = ?1", [id])).rows[0];
295
+ if (!row) return null;
296
+ var err = new Error("notifications.markFailed: cannot transition from " + row.status + " to failed");
297
+ err.code = "NOTIFICATION_BAD_TRANSITION";
298
+ throw err;
283
299
  },
284
300
 
285
301
  markRead: async function (id, options) {
286
302
  _uuid(id, "notification id");
287
303
  options = options || {};
288
304
  var readAt = options.read_at == null ? _now() : _scheduledAt(options.read_at, _now());
305
+ // Atomic claim: fold the in-app channel + observed-status predicate into
306
+ // the UPDATE so the read transition is a single serialized claim. rowCount
307
+ // is the claim signal; the loser re-reads to report the precise reason.
308
+ var upd = await query(
309
+ "UPDATE notifications SET status = 'read', read_at = ?1, updated_at = ?1 " +
310
+ "WHERE id = ?2 AND channel = 'in-app' AND status IN ('sent', 'pending')",
311
+ [readAt, id],
312
+ );
313
+ if (Number(upd.rowCount || 0) === 1) {
314
+ return (await query("SELECT * FROM notifications WHERE id = ?1", [id])).rows[0];
315
+ }
289
316
  var existing = (await query("SELECT status, channel FROM notifications WHERE id = ?1", [id])).rows[0];
290
317
  if (!existing) return null;
291
318
  if (existing.channel !== "in-app") {
@@ -293,16 +320,9 @@ function create(opts) {
293
320
  err.code = "NOTIFICATION_BAD_CHANNEL";
294
321
  throw err;
295
322
  }
296
- if (existing.status !== "sent" && existing.status !== "pending") {
297
- var err2 = new Error("notifications.markRead: cannot transition from " + existing.status + " to read");
298
- err2.code = "NOTIFICATION_BAD_TRANSITION";
299
- throw err2;
300
- }
301
- await query(
302
- "UPDATE notifications SET status = 'read', read_at = ?1, updated_at = ?1 WHERE id = ?2",
303
- [readAt, id],
304
- );
305
- return (await query("SELECT * FROM notifications WHERE id = ?1", [id])).rows[0];
323
+ var err2 = new Error("notifications.markRead: cannot transition from " + existing.status + " to read");
324
+ err2.code = "NOTIFICATION_BAD_TRANSITION";
325
+ throw err2;
306
326
  },
307
327
 
308
328
  dismiss: async function (id) {
package/lib/returns.js CHANGED
@@ -651,17 +651,30 @@ function create(opts) {
651
651
  // operator sees committed-payout vs pending-payout. Currency
652
652
  // mix is reported per-bucket so a multi-currency shop doesn't
653
653
  // collapse to a meaningless single number.
654
+ //
655
+ // These rollups pin their OWN status ('refunded' / approved+received), so
656
+ // they must build off a status-free base WHERE (from/to only). Grafting
657
+ // onto the caller-filtered whereSql produced a contradictory predicate
658
+ // (e.g. `status = 'pending' AND status = 'refunded'`) that silently
659
+ // summed to zero whenever an explicit status filter was supplied.
660
+ var valueWhere = [];
661
+ var valueParams = [];
662
+ var vidx = 1;
663
+ if (from != null) { valueWhere.push("created_at >= ?" + vidx); valueParams.push(from); vidx += 1; }
664
+ if (to != null) { valueWhere.push("created_at < ?" + vidx); valueParams.push(to); vidx += 1; }
665
+ var valueWhereSql = valueWhere.length ? (" WHERE " + valueWhere.join(" AND ")) : "";
666
+
654
667
  var refundedRows = (await query(
655
668
  "SELECT refund_currency, SUM(refund_amount_minor) AS total FROM return_authorizations" +
656
- (whereSql ? whereSql + " AND status = 'refunded'" : " WHERE status = 'refunded'") +
669
+ valueWhereSql + (valueWhereSql ? " AND " : " WHERE ") + "status = 'refunded'" +
657
670
  " GROUP BY refund_currency",
658
- params,
671
+ valueParams,
659
672
  )).rows;
660
673
  var pendingRows = (await query(
661
674
  "SELECT refund_currency, SUM(refund_amount_minor) AS total FROM return_authorizations" +
662
- (whereSql ? whereSql + " AND status IN ('approved','received')" : " WHERE status IN ('approved','received')") +
675
+ valueWhereSql + (valueWhereSql ? " AND " : " WHERE ") + "status IN ('approved','received')" +
663
676
  " GROUP BY refund_currency",
664
- params,
677
+ valueParams,
665
678
  )).rows;
666
679
 
667
680
  function _byCurrency(rows) {
@@ -597,6 +597,10 @@ function create(opts) {
597
597
  " is " + claim.status + ", only filed claims can move to approved");
598
598
  }
599
599
  var ins = await _getInsuranceRow(claim.insurance_id);
600
+ if (!ins) {
601
+ throw new TypeError("shippingInsurance.markClaimApproved: parent insurance " +
602
+ claim.insurance_id + " not found for claim " + claimId);
603
+ }
600
604
  if (input.payout_minor > Number(ins.declared_value_minor)) {
601
605
  throw new TypeError("shippingInsurance.markClaimApproved: payout_minor " +
602
606
  input.payout_minor + " exceeds declared_value_minor " +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.79",
3
+ "version": "0.4.81",
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": {