@blamejs/blamejs-shop 0.4.80 → 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 +4 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/notifications.js +52 -32
- package/lib/returns.js +57 -19
- package/lib/shipping-insurance.js +4 -0
- package/lib/shipping-labels.js +31 -6
- package/package.json +1 -1
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.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
|
+
|
|
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.
|
|
14
|
+
|
|
11
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.
|
|
12
16
|
|
|
13
17
|
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -651,17 +676,30 @@ function create(opts) {
|
|
|
651
676
|
// operator sees committed-payout vs pending-payout. Currency
|
|
652
677
|
// mix is reported per-bucket so a multi-currency shop doesn't
|
|
653
678
|
// collapse to a meaningless single number.
|
|
679
|
+
//
|
|
680
|
+
// These rollups pin their OWN status ('refunded' / approved+received), so
|
|
681
|
+
// they must build off a status-free base WHERE (from/to only). Grafting
|
|
682
|
+
// onto the caller-filtered whereSql produced a contradictory predicate
|
|
683
|
+
// (e.g. `status = 'pending' AND status = 'refunded'`) that silently
|
|
684
|
+
// summed to zero whenever an explicit status filter was supplied.
|
|
685
|
+
var valueWhere = [];
|
|
686
|
+
var valueParams = [];
|
|
687
|
+
var vidx = 1;
|
|
688
|
+
if (from != null) { valueWhere.push("created_at >= ?" + vidx); valueParams.push(from); vidx += 1; }
|
|
689
|
+
if (to != null) { valueWhere.push("created_at < ?" + vidx); valueParams.push(to); vidx += 1; }
|
|
690
|
+
var valueWhereSql = valueWhere.length ? (" WHERE " + valueWhere.join(" AND ")) : "";
|
|
691
|
+
|
|
654
692
|
var refundedRows = (await query(
|
|
655
693
|
"SELECT refund_currency, SUM(refund_amount_minor) AS total FROM return_authorizations" +
|
|
656
|
-
(
|
|
694
|
+
valueWhereSql + (valueWhereSql ? " AND " : " WHERE ") + "status = 'refunded'" +
|
|
657
695
|
" GROUP BY refund_currency",
|
|
658
|
-
|
|
696
|
+
valueParams,
|
|
659
697
|
)).rows;
|
|
660
698
|
var pendingRows = (await query(
|
|
661
699
|
"SELECT refund_currency, SUM(refund_amount_minor) AS total FROM return_authorizations" +
|
|
662
|
-
(
|
|
700
|
+
valueWhereSql + (valueWhereSql ? " AND " : " WHERE ") + "status IN ('approved','received')" +
|
|
663
701
|
" GROUP BY refund_currency",
|
|
664
|
-
|
|
702
|
+
valueParams,
|
|
665
703
|
)).rows;
|
|
666
704
|
|
|
667
705
|
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/lib/shipping-labels.js
CHANGED
|
@@ -373,12 +373,22 @@ function create(opts) {
|
|
|
373
373
|
}
|
|
374
374
|
|
|
375
375
|
var ts = _now();
|
|
376
|
-
|
|
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
|
-
|
|
443
|
-
|
|
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