@blamejs/blamejs-shop 0.4.80 → 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 +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/notifications.js +52 -32
- package/lib/returns.js +17 -4
- package/lib/shipping-insurance.js +4 -0
- 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.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
|
+
|
|
11
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.
|
|
12
14
|
|
|
13
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.
|
package/lib/asset-manifest.json
CHANGED
package/lib/notifications.js
CHANGED
|
@@ -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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
(
|
|
669
|
+
valueWhereSql + (valueWhereSql ? " AND " : " WHERE ") + "status = 'refunded'" +
|
|
657
670
|
" GROUP BY refund_currency",
|
|
658
|
-
|
|
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
|
-
(
|
|
675
|
+
valueWhereSql + (valueWhereSql ? " AND " : " WHERE ") + "status IN ('approved','received')" +
|
|
663
676
|
" GROUP BY refund_currency",
|
|
664
|
-
|
|
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