@blamejs/blamejs-shop 0.4.81 → 0.4.82

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.82 (2026-06-22) — **Return-authorization and shipping-label state transitions are exactly-once under concurrency.** Two fulfillment surfaces gain the atomic-claim discipline the refund path already uses. Approving, receiving, and rejecting a return authorization now gate the transition on the observed status inside the UPDATE, so two operators (or a double-clicked action) acting on the same return can no longer both apply — exactly one transition wins and the other is refused, instead of a read-then-write letting both pass last-write-wins. Likewise, recording a shipping label as purchased, voided, or used is now a single guarded claim, so two concurrent broker callbacks for the same label can't both record (which previously left conflicting tracking numbers and costs last-write-wins). The losing caller gets the same transition-refused error a sequential illegal transition already produced. No configuration changes; upgrade to pick them up. **Fixed:** *Return-authorization transitions are atomic claims* — returns approve, markReceived, and reject now fold the expected source status into the UPDATE (approve from pending, markReceived from approved, reject from pending or approved) and treat the row count as the claim signal, matching the refund path. Two concurrent transitions on the same return — two operators, or a double-submit — can no longer both write last-write-wins; exactly one wins and the loser is refused with the same transition-refused error, mapped to a 409. Sequential not-found and illegal-transition errors are unchanged. · *Shipping-label transitions are atomic claims* — shipping-labels markPurchased, voidLabel, and markUsed now gate on the observed status inside the UPDATE. Two concurrent broker callbacks marking the same pending label purchased (a worker-drain double-fire) can no longer both record, leaving conflicting tracking numbers and costs by last-write-wins — exactly one records and the other is refused. The void 30-day window and not-found / wrong-status refusals are unchanged.
12
+
11
13
  - 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
14
 
13
15
  - 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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.81",
2
+ "version": "0.4.82",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/lib/returns.js CHANGED
@@ -335,17 +335,26 @@ function create(opts) {
335
335
  var refundCurrency = input.refund_currency == null ? "USD" : _currency(input.refund_currency);
336
336
  var operatorNotes = _boundedString(input.operator_notes, "operator_notes", MAX_NOTE_LEN);
337
337
 
338
- var current = await this._currentStatus(rmaId);
339
- _assertTransition(current, "approve");
340
-
341
338
  var ts = _now();
342
- await query(
339
+ // Atomic claim — `AND status = 'pending'` is the serialization point, so
340
+ // two concurrent approve/reject POSTs on the same RMA can't both write
341
+ // (the prior read-then-write let both pass). A zero-row result means the
342
+ // RMA isn't pending: the read below surfaces RMA_NOT_FOUND vs
343
+ // RMA_TRANSITION_REFUSED so the request layer's 404/409 mapping holds.
344
+ var upd = await query(
343
345
  "UPDATE return_authorizations SET status = 'approved', " +
344
346
  "refund_amount_minor = ?1, refund_currency = ?2, approved_at = ?3, " +
345
347
  "operator_notes = CASE WHEN ?4 = '' THEN operator_notes ELSE ?4 END, " +
346
- "updated_at = ?3 WHERE id = ?5",
348
+ "updated_at = ?3 WHERE id = ?5 AND status = 'pending'",
347
349
  [input.refund_amount_minor, refundCurrency, ts, operatorNotes, rmaId],
348
350
  );
351
+ if (Number(upd.rowCount || 0) === 0) {
352
+ var current = await this._currentStatus(rmaId);
353
+ _assertTransition(current, "approve");
354
+ var raced = new Error("returns: approve already claimed for rma " + rmaId);
355
+ raced.code = "RMA_TRANSITION_REFUSED";
356
+ throw raced;
357
+ }
349
358
  return await this.get(rmaId);
350
359
  },
351
360
 
@@ -355,18 +364,25 @@ function create(opts) {
355
364
  var receivedAt = _epochOrNull(input.received_at, "received_at");
356
365
  var operatorNotes = _boundedString(input.operator_notes, "operator_notes", MAX_NOTE_LEN);
357
366
 
358
- var current = await this._currentStatus(rmaId);
359
- _assertTransition(current, "markReceived");
360
-
361
367
  var ts = _now();
362
368
  var rxAt = receivedAt == null ? ts : receivedAt;
363
- await query(
369
+ // Atomic claim — `AND status = 'approved'` serializes the transition so a
370
+ // concurrent markReceived/reject can't both write. Zero rows → re-read to
371
+ // surface RMA_NOT_FOUND vs RMA_TRANSITION_REFUSED unchanged.
372
+ var upd = await query(
364
373
  "UPDATE return_authorizations SET status = 'received', " +
365
374
  "received_at = ?1, " +
366
375
  "operator_notes = CASE WHEN ?2 = '' THEN operator_notes ELSE ?2 END, " +
367
- "updated_at = ?3 WHERE id = ?4",
376
+ "updated_at = ?3 WHERE id = ?4 AND status = 'approved'",
368
377
  [rxAt, operatorNotes, ts, rmaId],
369
378
  );
379
+ if (Number(upd.rowCount || 0) === 0) {
380
+ var current = await this._currentStatus(rmaId);
381
+ _assertTransition(current, "markReceived");
382
+ var raced = new Error("returns: markReceived already claimed for rma " + rmaId);
383
+ raced.code = "RMA_TRANSITION_REFUSED";
384
+ throw raced;
385
+ }
370
386
  return await this.get(rmaId);
371
387
  },
372
388
 
@@ -442,17 +458,26 @@ function create(opts) {
442
458
  var rejectedReason = _boundedString(input.rejected_reason, "rejected_reason", MAX_DETAIL_LEN);
443
459
  var operatorNotes = _boundedString(input.operator_notes, "operator_notes", MAX_NOTE_LEN);
444
460
 
445
- var current = await this._currentStatus(rmaId);
446
- _assertTransition(current, "reject");
447
-
448
461
  var ts = _now();
449
- await query(
462
+ // Atomic claim — reject is legal from 'pending' OR 'approved', so the
463
+ // predicate is `AND status IN ('pending','approved')`; it serializes
464
+ // against a concurrent approve/markReceived/reject so they can't both
465
+ // write. Zero rows → re-read to surface RMA_NOT_FOUND vs
466
+ // RMA_TRANSITION_REFUSED unchanged.
467
+ var upd = await query(
450
468
  "UPDATE return_authorizations SET status = 'rejected', " +
451
469
  "rejected_at = ?1, rejected_reason = ?2, " +
452
470
  "operator_notes = CASE WHEN ?3 = '' THEN operator_notes ELSE ?3 END, " +
453
- "updated_at = ?1 WHERE id = ?4",
471
+ "updated_at = ?1 WHERE id = ?4 AND status IN ('pending', 'approved')",
454
472
  [ts, rejectedReason, operatorNotes, rmaId],
455
473
  );
474
+ if (Number(upd.rowCount || 0) === 0) {
475
+ var current = await this._currentStatus(rmaId);
476
+ _assertTransition(current, "reject");
477
+ var raced = new Error("returns: reject already claimed for rma " + rmaId);
478
+ raced.code = "RMA_TRANSITION_REFUSED";
479
+ throw raced;
480
+ }
456
481
  return await this.get(rmaId);
457
482
  },
458
483
 
@@ -373,12 +373,22 @@ function create(opts) {
373
373
  }
374
374
 
375
375
  var ts = _now();
376
- await query(
376
+ // Atomic claim — `AND status = 'pending'` serializes the transition so
377
+ // two concurrent broker callbacks (worker-drain double-fire) can't both
378
+ // record, last-write-wins, conflicting tracking/cost. The JS check above
379
+ // gives the clean sequential not-found / wrong-status refusal; this
380
+ // rowCount guard catches the concurrent racer.
381
+ var upd = await query(
377
382
  "UPDATE shipping_labels SET status = 'purchased', tracking_number = ?1, " +
378
383
  "label_url = ?2, cost_minor = ?3, currency = ?4, purchased_via = ?5, " +
379
- "purchased_at = ?6 WHERE id = ?7",
384
+ "purchased_at = ?6 WHERE id = ?7 AND status = 'pending'",
380
385
  [tracking, url, input.cost_minor, input.currency, input.purchased_via, ts, input.label_id],
381
386
  );
387
+ if (Number(upd.rowCount || 0) === 0) {
388
+ var raced = new Error("shipping-labels.markPurchased: refused — label already transitioned (concurrent claim)");
389
+ raced.code = "SHIPPING_LABELS_TRANSITION_REFUSED";
390
+ throw raced;
391
+ }
382
392
  return await _getLabelRow(input.label_id);
383
393
  },
384
394
 
@@ -409,10 +419,18 @@ function create(opts) {
409
419
  throw werr;
410
420
  }
411
421
 
412
- await query(
413
- "UPDATE shipping_labels SET status = 'voided', void_reason = ?1, voided_at = ?2 WHERE id = ?3",
422
+ var upd = await query(
423
+ "UPDATE shipping_labels SET status = 'voided', void_reason = ?1, voided_at = ?2 WHERE id = ?3 AND status = 'purchased'",
414
424
  [reason, ts, input.label_id],
415
425
  );
426
+ if (Number(upd.rowCount || 0) === 0) {
427
+ // Lost the claim to a concurrent void/markUsed — the label already left
428
+ // 'purchased'. (Sequential not-found / wrong-status / void-window are
429
+ // refused above.)
430
+ var raced = new Error("shipping-labels.voidLabel: refused — label already transitioned (concurrent claim)");
431
+ raced.code = "SHIPPING_LABELS_TRANSITION_REFUSED";
432
+ throw raced;
433
+ }
416
434
  return await _getLabelRow(input.label_id);
417
435
  },
418
436
 
@@ -439,10 +457,17 @@ function create(opts) {
439
457
  throw err;
440
458
  }
441
459
  var ts = _now();
442
- await query(
443
- "UPDATE shipping_labels SET status = 'used', used_at = ?1 WHERE id = ?2",
460
+ // Atomic claim — `AND status = 'purchased'` serializes against a
461
+ // concurrent void/markUsed so the parcel can't be both voided and used.
462
+ var upd = await query(
463
+ "UPDATE shipping_labels SET status = 'used', used_at = ?1 WHERE id = ?2 AND status = 'purchased'",
444
464
  [ts, labelId],
445
465
  );
466
+ if (Number(upd.rowCount || 0) === 0) {
467
+ var raced = new Error("shipping-labels.markUsed: refused — label already transitioned (concurrent claim)");
468
+ raced.code = "SHIPPING_LABELS_TRANSITION_REFUSED";
469
+ throw raced;
470
+ }
446
471
  return await _getLabelRow(labelId);
447
472
  },
448
473
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.81",
3
+ "version": "0.4.82",
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": {