@blamejs/blamejs-shop 0.4.77 → 0.4.79
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/credit-limits.js +45 -6
- package/lib/giftcards.js +25 -20
- package/lib/inventory-receive.js +18 -5
- package/lib/smart-restocking.js +5 -2
- 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.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
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- v0.4.77 (2026-06-21) — **Exactly-once hardening for payment capture, auto-replenishment, gift redemption, and carrier-credential rotation.** Four correctness fixes that make money- and inventory-moving state transitions happen exactly once under concurrency or delayed settlement. A PayPal order is no longer marked paid when the capture itself is still pending — settlement is now read from the capture status, not the order envelope, so a delayed or under-review payment leaves the order pending until funds actually settle. Overlapping auto-replenishment ticks can no longer both fire the same policy: the cadence advance is now an atomic claim, so a vendor purchase order is composed and submitted once per window instead of being double-cut. Redeeming one gift-subscription link twice — two clicks racing within milliseconds — now mints exactly one subscription: the redemption claims the gift before creating the subscription, and rolls the claim back if the mint fails. And two carrier-credential rotations firing at once can no longer both commit; the rotation now compare-and-swaps on the observed key, so the loser is refused instead of clobbering the winner's fresh key and stranding the previous-key grace window. No configuration changes; upgrade to pick them up. **Fixed:** *A PayPal order is marked paid only when the capture itself completes* — Capturing a PayPal order now gates the paid transition on the per-capture status rather than the order envelope. The order-level status can read COMPLETED while the capture is still PENDING (a delayed-settlement or under-review payment), which previously marked the order paid before the money settled. Any non-completed capture now leaves the order pending and reports the capture as unhandled so the checkout surfaces the unsettled state. · *Overlapping auto-replenishment ticks no longer double-submit a purchase order* — The replenishment scheduler advances a policy's run cadence with a single conditional claim that is the serialization point: two ticks firing at once contend on the policy row, only one matches the due-window predicate, and the vendor purchase order is composed and submitted exactly once per window. Previously the cadence was an in-memory check followed by an unconditional stamp, so two overlapping ticks both passed the check and cut duplicate POs. · *Redeeming a gift-subscription link twice creates exactly one subscription* — Gift redemption now claims the gift's pending→redeemed transition before creating the recipient's subscription, and only the winner of that atomic claim mints. A leaked or double-clicked redemption link presented twice within milliseconds therefore creates one subscription, not two; if the subscription mint then fails, the claim is rolled back so the gift can be redeemed again rather than being stranded as redeemed-with-no-subscription. · *Concurrent carrier-credential rotations no longer over-issue or strand credentials* — Rotating a shipping-carrier account's API credentials now compare-and-swaps on the observed key and account state, so two rotations firing at once can't both commit. The loser is refused with a rotation-conflict error instead of overwriting the winner's freshly minted key and leaving the previous-key grace window pointing at a key no deployed worker holds.
|
|
12
16
|
|
|
13
17
|
- v0.4.76 (2026-06-21) — **Account security hardening — revoking a passkey ends the account's other sessions, and registration no longer creates an account before the security key is enrolled.** Four hardenings on the customer account surface. Revoking a passkey now ends every other live session for the account — including pending magic-link sign-in tokens — while keeping the device that performed the revoke signed in; previously the credential was deleted but a sealed session cookie a lost or compromised authenticator had already established stayed valid for its full 14-day life. Signing out everywhere now also revokes any unredeemed magic-link token, so a link mailed before sign-out can no longer sign the account back in. Passkey registration no longer creates the customer record until the security-key ceremony actually completes: the pending sign-up is carried in the sealed challenge cookie and the account is created only on a verified finish, so an abandoned or scripted registration can no longer reserve an email address or leave empty accounts behind. And passkey sign-in performs the same credential lookup whether or not the address has an account, removing a response-time difference that could be used to test which emails are registered. No configuration changes; upgrade to pick them up. **Security:** *Revoking a passkey ends the account's other sessions and pending sign-in links* — Revoking a passkey is a response to a lost or compromised authenticator, so it now moves the account's server-side session-revocation boundary forward — invalidating every sealed auth cookie issued before the revoke, including one the compromised authenticator established — and revokes any pending customer-portal (magic-link) token. The browser that performed the revoke is re-issued a fresh cookie so it stays signed in; every other device is signed out on its next request. Previously only the credential row was deleted, leaving already-issued session cookies valid for their full 14-day life. · *Signing out everywhere also revokes pending magic-link tokens* — POST /account/logout already moved the session-revocation boundary forward to kill live sealed cookies; it now also revokes any unredeemed magic-link / customer-portal token for the account. An emailed sign-in link issued before the sign-out can no longer be redeemed to sign the account back in. · *Passkey registration creates the account only after the security key is enrolled* — The public passkey register-begin endpoint no longer writes a customer record before the WebAuthn ceremony completes. The pending sign-up (email + display name + challenge) is sealed into the tamper-proof challenge cookie, and the account is created only on a verified register-finish, where the duplicate-email check runs atomically to settle a begin/finish race. An abandoned or scripted register-begin can no longer reserve (squat) an email address or leave behind empty accounts. · *Passkey sign-in does not reveal whether an email is registered via timing* — Passkey login-begin now runs the same credential lookup on both paths — against the real account for a registered address, against a fixed non-matching id otherwise — so a registered address no longer takes measurably longer to respond. The challenge response shape was already identical on both paths; this closes the remaining response-time signal that could enumerate registered emails.
|
package/lib/asset-manifest.json
CHANGED
package/lib/credit-limits.js
CHANGED
|
@@ -506,22 +506,61 @@ function create(opts) {
|
|
|
506
506
|
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
507
507
|
if (requested == null) requested = _now();
|
|
508
508
|
|
|
509
|
+
// Per-order idempotency is independent of current account status: a
|
|
510
|
+
// retry of an order that ALREADY has a charge returns the original
|
|
511
|
+
// charge even if the account was later suspended or closed — a retried
|
|
512
|
+
// capture must be a stable no-op, never a double-charge, a spurious
|
|
513
|
+
// NOT_ACTIVE, or a misleading limit error. Checked up front (BEFORE the
|
|
514
|
+
// active-account gate) and again on the atomic guard's refusal (to catch
|
|
515
|
+
// a charge that landed concurrently between the two reads).
|
|
516
|
+
function _idempotentReturn(prior) {
|
|
517
|
+
return {
|
|
518
|
+
id: prior.id,
|
|
519
|
+
customer_id: customerId,
|
|
520
|
+
kind: "charge",
|
|
521
|
+
order_id: orderId,
|
|
522
|
+
amount_minor: Number(prior.amount_minor),
|
|
523
|
+
balance_after_minor: Number(prior.balance_after_minor),
|
|
524
|
+
occurred_at: Number(prior.occurred_at),
|
|
525
|
+
idempotent: true,
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
async function _priorCharge() {
|
|
529
|
+
return (await query(
|
|
530
|
+
"SELECT id, amount_minor, balance_after_minor, occurred_at FROM credit_transactions " +
|
|
531
|
+
"WHERE customer_id = ?1 AND order_id = ?2 AND kind = 'charge' ORDER BY occurred_at ASC, id ASC LIMIT 1",
|
|
532
|
+
[customerId, orderId],
|
|
533
|
+
)).rows[0];
|
|
534
|
+
}
|
|
535
|
+
var existingCharge = await _priorCharge();
|
|
536
|
+
if (existingCharge) return _idempotentReturn(existingCharge);
|
|
537
|
+
|
|
509
538
|
// Fast pre-check for a clean NOT_FOUND / NOT_ACTIVE error; the
|
|
510
|
-
// credit-limit invariant
|
|
511
|
-
// INSERT guard below (re-checking status = 'active' there too),
|
|
512
|
-
//
|
|
539
|
+
// credit-limit invariant + per-order dedup are enforced atomically in
|
|
540
|
+
// the INSERT guard below (re-checking status = 'active' there too), so a
|
|
541
|
+
// charge can't slip past the limit or double-charge by racing another.
|
|
513
542
|
await _requireActive(customerId, "chargeOrder");
|
|
514
543
|
var written = await _appendTxnAtomic(
|
|
515
544
|
customerId, "charge", orderId, null, requested,
|
|
516
545
|
"?7", // amount_minor: the charge
|
|
517
546
|
_BAL_SUB + " + ?7", // balance_after = outstanding + amount
|
|
547
|
+
// Cap check AND per-order dedup in one atomic guard: refuse a second
|
|
548
|
+
// charge for the same order so the same order_id can't be charged
|
|
549
|
+
// twice against the line (the bridge serializes statements, so the
|
|
550
|
+
// NOT EXISTS sees a prior committed charge).
|
|
518
551
|
"(" + _BAL_SUB + " + ?7) <= COALESCE((SELECT credit_limit_minor " +
|
|
519
|
-
"FROM credit_accounts WHERE customer_id = ?2 AND status = 'active'), -1)"
|
|
552
|
+
"FROM credit_accounts WHERE customer_id = ?2 AND status = 'active'), -1) " +
|
|
553
|
+
"AND NOT EXISTS (SELECT 1 FROM credit_transactions " +
|
|
554
|
+
"WHERE customer_id = ?2 AND order_id = ?4 AND kind = 'charge')",
|
|
520
555
|
[amount],
|
|
521
556
|
);
|
|
522
557
|
if (!written) {
|
|
523
|
-
//
|
|
524
|
-
//
|
|
558
|
+
// A charge for this order landed concurrently between the up-front
|
|
559
|
+
// check and the insert — return it idempotently.
|
|
560
|
+
var raced = await _priorCharge();
|
|
561
|
+
if (raced) return _idempotentReturn(raced);
|
|
562
|
+
// Guard refused for the cap. Re-read to report precisely: a status
|
|
563
|
+
// change since the pre-check (rare race with suspend/close) vs the
|
|
525
564
|
// limit being exceeded.
|
|
526
565
|
var acctNow = await _readAccount(customerId);
|
|
527
566
|
if (!acctNow || acctNow.status !== "active") {
|
package/lib/giftcards.js
CHANGED
|
@@ -577,34 +577,39 @@ function create(opts) {
|
|
|
577
577
|
"void": async function (id, opts2) {
|
|
578
578
|
opts2 = opts2 || {};
|
|
579
579
|
_uuid(id, "giftcard id");
|
|
580
|
-
var
|
|
581
|
-
|
|
582
|
-
|
|
580
|
+
var ts = _now();
|
|
581
|
+
// Atomic guarded transition active -> voided. The status='active' fence
|
|
582
|
+
// lives in SQL (mirroring redeem()), so a concurrent redeem or expire
|
|
583
|
+
// that already moved the card to a terminal state is NOT silently
|
|
584
|
+
// overwritten — the prior read-decide-write clobbered a racing
|
|
585
|
+
// redemption (discarding its remaining-balance debit) because the
|
|
586
|
+
// UPDATE omitted the status fence.
|
|
587
|
+
var upd = await query(
|
|
588
|
+
"UPDATE giftcards SET status = 'voided', updated_at = ?1 WHERE id = ?2 AND status = 'active'",
|
|
589
|
+
[ts, id],
|
|
583
590
|
);
|
|
591
|
+
// `opts2.reason` is operator-supplied free-form; it's not persisted on
|
|
592
|
+
// the row (no schema column) but accepted so a future audit-log
|
|
593
|
+
// primitive can ride alongside without a surface change.
|
|
594
|
+
void opts2.reason;
|
|
595
|
+
if (Number(upd.rowCount || 0) === 1) {
|
|
596
|
+
var after = await query("SELECT * FROM giftcards WHERE id = ?1", [id]);
|
|
597
|
+
return after.rows[0] || null;
|
|
598
|
+
}
|
|
599
|
+
// Zero rows: the card is missing or already in a terminal state.
|
|
600
|
+
// Re-read to disambiguate and preserve the prior call's contract.
|
|
601
|
+
var r = await query("SELECT * FROM giftcards WHERE id = ?1", [id]);
|
|
584
602
|
if (!r.rows.length) return null;
|
|
585
603
|
var row = r.rows[0];
|
|
604
|
+
if (row.status === "voided") return row; // idempotent — already voided
|
|
586
605
|
if (row.status === "redeemed") {
|
|
587
606
|
var already = new Error("giftcards.void: card is fully redeemed");
|
|
588
607
|
already.code = "GIFTCARD_ALREADY_REDEEMED";
|
|
589
608
|
throw already;
|
|
590
609
|
}
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
return existing.rows[0] || null;
|
|
595
|
-
}
|
|
596
|
-
var ts = _now();
|
|
597
|
-
await query(
|
|
598
|
-
"UPDATE giftcards SET status = 'voided', updated_at = ?1 WHERE id = ?2",
|
|
599
|
-
[ts, id],
|
|
600
|
-
);
|
|
601
|
-
// `opts2.reason` is operator-supplied free-form; it's not
|
|
602
|
-
// persisted on the row (no schema column) but accepted so a
|
|
603
|
-
// future audit-log primitive can ride alongside without a
|
|
604
|
-
// surface change.
|
|
605
|
-
void opts2.reason;
|
|
606
|
-
var after = await query("SELECT * FROM giftcards WHERE id = ?1", [id]);
|
|
607
|
-
return after.rows[0] || null;
|
|
610
|
+
// Any other terminal state (e.g. expired) — return the row as-is rather
|
|
611
|
+
// than forcing it to voided over a terminal transition.
|
|
612
|
+
return row;
|
|
608
613
|
},
|
|
609
614
|
|
|
610
615
|
// Operator-facing list of issued cards — drives the admin
|
package/lib/inventory-receive.js
CHANGED
|
@@ -432,12 +432,25 @@ function create(opts) {
|
|
|
432
432
|
for (var i = 0; i < receipt.lines.length; i += 1) {
|
|
433
433
|
var l = receipt.lines[i];
|
|
434
434
|
var ts = _now();
|
|
435
|
+
// Capture how many units the decrement ACTUALLY removes: a prior
|
|
436
|
+
// out-of-band decrement can leave stock below qty_received, so the
|
|
437
|
+
// removable amount is floored at what's on hand. Recording the real
|
|
438
|
+
// delta (not qty_received) is what makes a compensating add-back on
|
|
439
|
+
// a mid-loop failure an exact inverse — the old `MAX(0,...)` clamp
|
|
440
|
+
// removed fewer units than the compensation later added back, minting
|
|
441
|
+
// phantom stock. `removed <= prior`, so the subtraction never goes
|
|
442
|
+
// negative and the clamp is no longer needed.
|
|
443
|
+
var priorRow = (await query(
|
|
444
|
+
"SELECT stock_on_hand FROM inventory WHERE sku = ?1", [l.sku],
|
|
445
|
+
)).rows[0];
|
|
446
|
+
var prior = priorRow ? Number(priorRow.stock_on_hand) : 0;
|
|
447
|
+
var removed = Math.min(l.qty_received, prior);
|
|
435
448
|
await query(
|
|
436
|
-
"UPDATE inventory SET stock_on_hand =
|
|
437
|
-
[
|
|
449
|
+
"UPDATE inventory SET stock_on_hand = stock_on_hand - ?1, updated_at = ?2 WHERE sku = ?3",
|
|
450
|
+
[removed, ts, l.sku],
|
|
438
451
|
);
|
|
439
|
-
decremented.push(l);
|
|
440
|
-
stockChanges.push({ sku: l.sku, qty: -
|
|
452
|
+
decremented.push({ sku: l.sku, removed: removed });
|
|
453
|
+
stockChanges.push({ sku: l.sku, qty: -removed });
|
|
441
454
|
}
|
|
442
455
|
} catch (e) {
|
|
443
456
|
// Compensate the decrements that already landed and release the claim so
|
|
@@ -449,7 +462,7 @@ function create(opts) {
|
|
|
449
462
|
try {
|
|
450
463
|
await query(
|
|
451
464
|
"UPDATE inventory SET stock_on_hand = stock_on_hand + ?1, updated_at = ?2 WHERE sku = ?3",
|
|
452
|
-
[dl.
|
|
465
|
+
[dl.removed, _now(), dl.sku],
|
|
453
466
|
);
|
|
454
467
|
} catch (_compErr) { /* best-effort compensation; the claim release below restores retryability */ }
|
|
455
468
|
}
|
package/lib/smart-restocking.js
CHANGED
|
@@ -149,7 +149,10 @@ var b = require("./vendor/blamejs");
|
|
|
149
149
|
|
|
150
150
|
var SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
151
151
|
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
152
|
-
|
|
152
|
+
// Mirrors inventory-locations.js CODE_RE exactly — a divergent stricter
|
|
153
|
+
// (uppercase-only, no dot) pattern here rejected valid lowercase/dotted
|
|
154
|
+
// location codes that the rest of the inventory surface accepts.
|
|
155
|
+
var LOCATION_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
153
156
|
|
|
154
157
|
var MAX_HOLDING_BPS = 100000; // 1000% / yr — operator's upper bound; the migration CHECK enforces too
|
|
155
158
|
var MAX_ORDERING_COST_MINOR = 1000000000; // 1e9 minor units — sanity ceiling
|
|
@@ -233,7 +236,7 @@ function _sku(s, label) {
|
|
|
233
236
|
function _locationOrNull(s) {
|
|
234
237
|
if (s == null) return null;
|
|
235
238
|
if (typeof s !== "string" || !LOCATION_RE.test(s)) {
|
|
236
|
-
throw new TypeError("smart-restocking: location_code must match /^[A-
|
|
239
|
+
throw new TypeError("smart-restocking: location_code must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (1..64 chars)");
|
|
237
240
|
}
|
|
238
241
|
return s;
|
|
239
242
|
}
|
package/package.json
CHANGED