@blamejs/blamejs-shop 0.4.57 → 0.4.59

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.59 (2026-06-19) — **Win-back campaign sends now actually run (they were being silently blocked).** Fixes a defect that left win-back campaigns completely inert since they shipped: the scheduled worker tick that drives win-back enrolment and dispatch was being rejected by the application bot-guard before it could reach its own shared-secret gate, so no win-back step ever sent. The worker fires its internal cron ticks machine-to-machine with only a content type and the bridge-secret header — no browser fingerprint — and the bot-guard's missing-Accept-Language heuristic returned 403 for the win-back path. Every other internal cron tick (cart-recovery, stock-alert and low-stock sweeps, wishlist alerts and digests, campaign send, customer-portal expiry, stale-order reap, quote expiry) was already exempt from the bot-guard; the win-back tick was the one omission. It is now exempt as well, so the tick reaches its handler (still gated by the bridge secret), and an operator who has win-back configured now sees the escalating offer sequence actually go out. A new test asserts every internal cron path is exempt from the bot-guard, so a future cron-driven feature can't ship blocked the same way. **Fixed:** *Win-back campaign cron tick was blocked by the bot-guard* — The internal /_/winback-send-tick POST the worker fires on a schedule was missing from the bot-guard exemption list, so the application bot-guard returned 403 on the missing-Accept-Language heuristic before the handler's bridge-secret check ran. Win-back enrolment and step dispatch therefore never executed. The path is now exempt like the other nine internal cron ticks (the bridge-secret gate remains the deciding check, so an unauthenticated caller is still refused). A bot-guard skip-list parity test now asserts every internal cron path is exempt, anchored to the security middleware so it runs in the container image too — closing the gap that let this ship.
12
+
13
+ - v0.4.58 (2026-06-19) — **Make concurrent state transitions atomic across payouts, key rotation, and credit accounting.** Closes a class of concurrency races where a state transition or ledger write read the current state, checked it in application code, and then wrote — leaving a window in which two requests for the same record could both pass the check and both apply their effect. Each of these now performs the check and the write as a single conditional statement (the update or insert only lands when the record is still in the expected state, or the balance still satisfies the invariant), so exactly one of two racing callers wins and the other is cleanly refused. The affected operations: an affiliate commission can no longer be marked paid (or voided) twice, so a second payout reference can't be issued for one commission; rotating an API key claims the rotation before minting the replacement, so two concurrent rotations can't issue two successor keys; a B2B credit charge, hold release, and payment now evaluate the credit limit and running balance inside the insert, so concurrent charges can't push an account past its limit or corrupt its denormalized balance; fulfilling or cancelling a backorder line debits the per-SKU pending counter only when it wins the line transition, so the counter can't be double-debited; and a click-and-collect pickup advances the parent order exactly once. No API change and no migration — the operations behave identically for a single caller and now stay correct under concurrent ones. **Fixed:** *Affiliate commission payout transitions are atomic* — markCommissionPaid and markCommissionVoided now gate the status change on the row still being pending in the UPDATE itself and refuse (AFFILIATE_COMMISSION_TRANSITION_REFUSED) when they lose the race. Previously the pending check ran in application code before an unconditional write, so two concurrent payouts for the same commission could both proceed and issue two payout references for one commission, or a void could clobber a paid row. · *API-key rotation claims the rotation before issuing the replacement* — rotate now transitions the key from active to rotated with a conditional update and refuses (API_KEY_NOT_ROTATABLE) if the row is no longer active, before minting the successor key. Previously two concurrent rotations of the same key could both pass the active check and both issue a replacement, leaving an unexpected extra valid credential. · *B2B credit charges, releases, and payments enforce their invariant atomically* — chargeOrder, releaseHold, and recordPayment now compute the new running balance and evaluate their invariant (the credit limit for a charge, the outstanding balance for a payment, the order's net exposure for a release) inside a single conditional insert. Previously the balance was read, checked, and written in separate steps, so two concurrent charges could both pass the limit check — pushing the account past its credit limit and stamping the same balance_after on both rows, permanently under-stating outstanding exposure. Refusals carry the same typed codes (CREDIT_LIMIT_EXCEEDED, CREDIT_PAYMENT_EXCEEDS_OUTSTANDING, CREDIT_RELEASE_NOT_FOUND) and the denormalized per-customer balance chain stays correct under concurrent writes. · *Backorder fulfilment and cancellation debit the counter exactly once* — fulfillBackorder and cancelBackorder now claim the line's pending to fulfilled/cancelled transition with a conditional update and only decrement the per-SKU pending_quantity counter when the claim wins. Previously the line status was checked in application code before an unconditional write and counter decrement, so two concurrent calls for one line could both decrement the counter, under-counting pending demand. · *Click-and-collect pickup advances the order once* — markPickedUp now claims the schedule's ready to picked_up transition with a conditional update before driving the parent order to its delivered state, so two concurrent pickups for one order can't both advance the order FSM.
14
+
11
15
  - v0.4.57 (2026-06-19) — **Refresh the vendored blamejs framework to 0.15.13.** Refreshes the vendored blamejs framework from 0.15.12 to 0.15.13, which consolidates a set of duplicated, security-sensitive code paths onto shared hardened primitives so the strongest available guard now applies everywhere an operation happens. The shop picks up several concrete fixes by composing the framework: a webhook delivery that fails with a transient transport error (connection reset, timeout, 5xx) now backs off and retries up to the configured limit instead of being recorded as permanently failed and dead-lettered, with the SSRF / blocked-host destination check the only permanent failure; an inline webhook delivery is claim-guarded before the POST so a concurrent retry pass can no longer pick up and re-send the same delivery; the webhook deliveries table now creates on a MySQL backend (the pending-delivery index is dialect-aware); and the best-effort final audit checkpoint fired during database shutdown now binds to the database it measured and refuses to write if that handle has been closed or replaced, so a checkpoint can never anchor one database's chain tip into a freshly opened one. File reads across the framework route through a single TOCTOU-safe reader that binds a file's size, contents, and integrity measurement to the inode an open fd holds, closing the swap-after-stat race on operator-writable paths. The Content-Security-Policy response-header parser now splits on the literal comma in linear time, removing a quadratic stall on a crafted header. Two configuration-validation paths (HTTP client pool options, database stream-limit) now throw the framework's typed error instead of a bare Error; both remain Error subclasses, so existing try/catch handling is unaffected. This refresh carries no shop-facing API change and applies no migration; it keeps the bundled framework current and tightens the security posture without adding any opt-in. **Changed:** *Vendored blamejs refreshed to 0.15.13* — The bundled framework is updated to blamejs 0.15.13. Webhook deliveries that hit a transient transport failure (connection reset, timeout, 5xx) now retry with backoff up to the configured limit instead of being dead-lettered as permanently failed; only the SSRF / blocked-host destination check is a permanent failure. An inline webhook delivery is claimed (marked in-flight before the POST) so a concurrent retry pass cannot double-send it, and a row left in-flight by a crash is reclaimed after the stale-claim window. The webhook deliveries table now creates on MySQL via a dialect-aware index. The audit checkpoint fired at database shutdown binds to the database it read the tip from and fail-closes (writes nothing) if that handle has since been closed or replaced. Every framework file read now uses a TOCTOU-safe reader (open fd, then bind size / contents / integrity to that inode), with symlink refusal, inode-equality, a byte cap, and an integrity hash available to callers. The Content-Security-Policy header parser splits on the literal comma in linear time (removing a quadratic-stall vector on a crafted header). Parsed INI and OpenAPI path maps are null-prototype objects, so a parser gap could only set an own property and never reach Object.prototype. HTTP-client pool-option and database stream-limit configuration errors now throw the module's typed framework error (still an Error subclass). No shop API change; the framework's PQC-first crypto, security middleware, and request lifecycle are carried forward as-is.
12
16
 
13
17
  - v0.4.56 (2026-06-14) — **Win-back campaigns: re-engage lapsed customers with an escalating sequence of offers.** A new operator console defines win-back campaigns that re-engage customers who have not ordered in a while. A campaign sets a lapse window and an escalating sequence of steps, each with its own delay and an optional coupon. A scheduled pass enrols customers who cross the lapse window and sends each due step through the store's email transport, minting the step's coupon. The send is gated for consent and deliverability: a customer who has withdrawn marketing-email consent or sits on the marketing suppression list is never emailed, and an opted-out customer is removed from the sequence so no later step reaches them — and if the suppression check itself is unavailable the send is held rather than risked. Each step is sent at most once even if two scheduled passes overlap. Campaigns are inert until an operator creates and activates one and the store's email transport is configured. No migration to apply. **Added:** *Win-back campaign console + scheduled send* — Operators can create, list, activate, pause, and archive win-back campaigns from a new loyalty/marketing console screen: a lapse window plus an escalating sequence of steps, each with a delay and an optional coupon (a single-use, per-customer discount code minted when the step sends). A scheduled pass scans for lapsed customers, enrols them, advances each enrolment through the sequence, sends the due step via the store's email transport, and records a delivery log and per-campaign metrics. Every send is consent- and suppression-gated: a customer who withdrew marketing-email consent or is on the marketing suppression list is never emailed and is dropped from the sequence; a suppression-check outage holds the send rather than risking an unwanted message. The scheduled pass is idempotent — a step is sent at most once even if two passes overlap. The feature is inert until a campaign is created and activated and the email transport is configured.
package/lib/affiliates.js CHANGED
@@ -886,10 +886,27 @@ function create(opts) {
886
886
  refused.code = "AFFILIATE_COMMISSION_TRANSITION_REFUSED";
887
887
  throw refused;
888
888
  }
889
- await query(
890
- "UPDATE affiliate_commissions SET status = 'paid', paid_at = ?1, payout_reference = ?2 WHERE id = ?3",
889
+ // Atomic claim: the `AND status = 'pending'` predicate is the
890
+ // serialization point. The JS check above only refuses a
891
+ // SEQUENTIAL re-pay; two concurrent calls would both read
892
+ // 'pending' and both issue a payout reference. Gating the UPDATE
893
+ // on the status means exactly one caller transitions the row —
894
+ // the loser sees zero rows and is refused, so a second payout
895
+ // reference can never be issued for one commission.
896
+ var paidUpd = await query(
897
+ "UPDATE affiliate_commissions SET status = 'paid', paid_at = ?1, payout_reference = ?2 " +
898
+ "WHERE id = ?3 AND status = 'pending'",
891
899
  [paidAt, reference, id],
892
900
  );
901
+ if (Number(paidUpd.rowCount || 0) !== 1) {
902
+ var racedPaid = await _getCommissionRaw(id);
903
+ var refusedPaid = new Error(
904
+ "affiliates.markCommissionPaid: refused — commission is " +
905
+ (racedPaid ? racedPaid.status : "gone")
906
+ );
907
+ refusedPaid.code = "AFFILIATE_COMMISSION_TRANSITION_REFUSED";
908
+ throw refusedPaid;
909
+ }
893
910
  return await _getCommissionRaw(id);
894
911
  },
895
912
 
@@ -926,10 +943,24 @@ function create(opts) {
926
943
  throw refused;
927
944
  }
928
945
  var ts = _now();
929
- await query(
930
- "UPDATE affiliate_commissions SET status = 'voided', voided_at = ?1, void_reason = ?2 WHERE id = ?3",
946
+ // Atomic claim (see markCommissionPaid). Gating on 'pending'
947
+ // stops a void from racing a concurrent pay without it both
948
+ // transitions could fire, clobbering a paid row (and its payout
949
+ // reference) into 'voided', or vice versa.
950
+ var voidUpd = await query(
951
+ "UPDATE affiliate_commissions SET status = 'voided', voided_at = ?1, void_reason = ?2 " +
952
+ "WHERE id = ?3 AND status = 'pending'",
931
953
  [ts, reason, id],
932
954
  );
955
+ if (Number(voidUpd.rowCount || 0) !== 1) {
956
+ var racedVoid = await _getCommissionRaw(id);
957
+ var refusedVoid = new Error(
958
+ "affiliates.markCommissionVoided: refused — commission is " +
959
+ (racedVoid ? racedVoid.status : "gone")
960
+ );
961
+ refusedVoid.code = "AFFILIATE_COMMISSION_TRANSITION_REFUSED";
962
+ throw refusedVoid;
963
+ }
933
964
  return await _getCommissionRaw(id);
934
965
  },
935
966
 
package/lib/api-keys.js CHANGED
@@ -558,11 +558,26 @@ function create(opts) {
558
558
  var placeholderHash = b.crypto.namespaceHash(
559
559
  "api-key-rotated-placeholder", current.id + ":" + ts
560
560
  );
561
- await query(
561
+ // Atomic claim: gate the active->rotated transition on the row
562
+ // still being 'active'. The JS status check above only refuses a
563
+ // sequential re-rotate; two concurrent rotates would both pass it
564
+ // and both issue a replacement key (credential over-issuance).
565
+ // The `AND status = 'active'` predicate lets exactly one caller
566
+ // claim the rotation — the loser is refused before any new key is
567
+ // minted.
568
+ var rotateClaim = await query(
562
569
  "UPDATE api_keys SET status = 'rotated', rotated_at = ?1, " +
563
- "token_hash_previous = ?2, token_hash = ?3 WHERE id = ?4",
570
+ "token_hash_previous = ?2, token_hash = ?3 WHERE id = ?4 AND status = 'active'",
564
571
  [ts, current.token_hash, placeholderHash, id],
565
572
  );
573
+ if (Number(rotateClaim.rowCount || 0) !== 1) {
574
+ var lost = await _getRaw(id);
575
+ var refusedRotate = new Error(
576
+ "apiKeys.rotate: refused — key is " + (lost ? lost.status : "gone")
577
+ );
578
+ refusedRotate.code = "API_KEY_NOT_ROTATABLE";
579
+ throw refusedRotate;
580
+ }
566
581
 
567
582
  // Issue the replacement row. Scopes / rate-limit / expiry /
568
583
  // owner all carry forward verbatim — rotation is plaintext-only.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.57",
2
+ "version": "0.4.59",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/lib/backorder.js CHANGED
@@ -322,10 +322,20 @@ function create(opts) {
322
322
  ", only pending lines can be fulfilled");
323
323
  }
324
324
  var ts = _now();
325
- await query(
326
- "UPDATE backorder_lines SET status = 'fulfilled', fulfilled_at = ?1 WHERE id = ?2",
325
+ // Atomic claim: gate on `status = 'pending'`. The JS check above
326
+ // only refuses a sequential re-fulfill; two concurrent calls
327
+ // would both pass it and both decrement the per-SKU counter for
328
+ // one line (a double debit). Claiming the line first means the
329
+ // counter is only debited by the caller that won the transition.
330
+ var fulfillClaim = await query(
331
+ "UPDATE backorder_lines SET status = 'fulfilled', fulfilled_at = ?1 " +
332
+ "WHERE id = ?2 AND status = 'pending'",
327
333
  [ts, line.id],
328
334
  );
335
+ if (Number(fulfillClaim.rowCount || 0) !== 1) {
336
+ throw new TypeError("backorder.fulfillBackorder: line is no longer pending " +
337
+ "(concurrently fulfilled or cancelled)");
338
+ }
329
339
  // MAX(0, ...) clamps the counter so an out-of-band counter
330
340
  // corruption (operator hand-edited a row) can't drive it
331
341
  // negative. The CHECK constraint on pending_quantity would
@@ -359,11 +369,18 @@ function create(opts) {
359
369
  ", only pending lines can be cancelled");
360
370
  }
361
371
  var ts = _now();
362
- await query(
372
+ // Atomic claim (see fulfillBackorder): gate on `status = 'pending'`
373
+ // so a cancel racing a concurrent fulfill/cancel can't double-debit
374
+ // the per-SKU counter for one line.
375
+ var cancelClaim = await query(
363
376
  "UPDATE backorder_lines SET status = 'cancelled', reason = ?1, cancelled_at = ?2 " +
364
- "WHERE id = ?3",
377
+ "WHERE id = ?3 AND status = 'pending'",
365
378
  [reason, ts, line.id],
366
379
  );
380
+ if (Number(cancelClaim.rowCount || 0) !== 1) {
381
+ throw new TypeError("backorder.cancelBackorder: line is no longer pending " +
382
+ "(concurrently fulfilled or cancelled)");
383
+ }
367
384
  await query(
368
385
  "UPDATE backorder_skus SET pending_quantity = MAX(0, pending_quantity - ?1), " +
369
386
  "updated_at = ?2 WHERE sku = ?3",
@@ -581,12 +581,21 @@ function create(opts) {
581
581
  }
582
582
 
583
583
  var now = _now();
584
- await query(
584
+ // Atomic claim: gate on `status = 'ready'`. The JS status check
585
+ // above only refuses a sequential re-pickup; two concurrent
586
+ // markPickedUp calls would both pass it and both drive the parent
587
+ // order FSM to `delivered`. Claiming the schedule first means
588
+ // exactly one caller advances the order.
589
+ var pickupClaim = await query(
585
590
  "UPDATE pickup_schedules SET status = 'picked_up', picked_up_at = ?1, " +
586
591
  "signature_hash = ?2, customer_id_proof_kind = ?3, updated_at = ?4 " +
587
- "WHERE order_id = ?5",
592
+ "WHERE order_id = ?5 AND status = 'ready'",
588
593
  [input.picked_up_at, sigHash, proofKind, now, orderId],
589
594
  );
595
+ if (Number(pickupClaim.rowCount || 0) !== 1) {
596
+ throw new TypeError("click-and-collect.markPickedUp: schedule is no longer ready " +
597
+ "(concurrently picked up)");
598
+ }
590
599
 
591
600
  // Drive the parent order FSM to `delivered` so the order really
592
601
  // reaches its terminal state when the goods leave the store.
@@ -61,10 +61,10 @@
61
61
  *
62
62
  * Monotonic per-customer `occurred_at`: two writes in the same
63
63
  * millisecond would tie and make the "latest row" ambiguous for
64
- * the denormalized balance read. `_resolveOccurredAt` bumps the
65
- * requested timestamp to `prior + 1` on collision, guaranteeing
66
- * strict monotonicity in the `(customer_id, occurred_at DESC)`
67
- * index.
64
+ * the denormalized balance read. Every ledger append bumps the
65
+ * requested timestamp to `prior + 1` on collision (in-statement,
66
+ * atomically with the write), guaranteeing strict monotonicity in
67
+ * the `(customer_id, occurred_at DESC)` index.
68
68
  *
69
69
  * Surface:
70
70
  * - defineAccount({ customer_id, credit_limit_minor, currency,
@@ -249,27 +249,58 @@ function create(opts) {
249
249
  return { balance: r.rows[0].balance_after_minor, occurred_at: r.rows[0].occurred_at };
250
250
  }
251
251
 
252
- // Two writes against the same customer in the same millisecond
253
- // would tie on `occurred_at` and make the "latest row" ambiguous
254
- // for denormalized-balance reads. Bump to `prior + 1` on
255
- // collision; the result is a strictly-monotonic per-customer
256
- // `occurred_at` sequence in the `(customer_id, occurred_at DESC)`
257
- // index.
258
- function _resolveOccurredAt(requestedTs, latestTs) {
259
- if (latestTs == null) return requestedTs;
260
- if (requestedTs > latestTs) return requestedTs;
261
- return latestTs + 1;
262
- }
263
-
264
- async function _writeTxn(customerId, kind, orderId, amountMinor, balanceAfter, paymentRef, ts) {
252
+ // Shared subquery for the customer's current outstanding balance
253
+ // the denormalized `balance_after_minor` of their latest transaction.
254
+ // References ?2 (customer_id), so every _appendTxnAtomic call binds
255
+ // customer_id as the second parameter.
256
+ var _BAL_SUB = "COALESCE((SELECT balance_after_minor FROM credit_transactions " +
257
+ "WHERE customer_id = ?2 ORDER BY occurred_at DESC LIMIT 1), 0)";
258
+
259
+ // Append a ledger transaction ATOMICALLY. The new balance, the
260
+ // strictly-monotonic occurred_at, and the operation's invariant
261
+ // (credit limit / sufficient balance / outstanding charge) are all
262
+ // evaluated inside one INSERT ... SELECT ... WHERE statement, so the
263
+ // read of the latest balance can't be separated from the write that
264
+ // depends on it. SQLite serializes writers, so a second concurrent
265
+ // append sees the first's committed row: the denormalized running
266
+ // balance chains correctly and an invariant can't be bypassed by
267
+ // interleaving. (The racy read-check-write this replaced let two
268
+ // concurrent charges both pass the limit check, exceed the limit,
269
+ // and stamp the same balance_after — permanently under-stating
270
+ // exposure.) `guardExpr` false -> zero rows inserted -> returns
271
+ // null, and the caller raises its own typed refusal after a re-read.
272
+ //
273
+ // Fixed params: ?1 id, ?2 customer_id, ?3 kind, ?4 order_id,
274
+ // ?5 requested occurred_at, ?6 payment_ref. amountExpr / afterExpr /
275
+ // guardExpr may reference _BAL_SUB and bind extra params from ?7 up.
276
+ async function _appendTxnAtomic(customerId, kind, orderId, paymentRef, requestedTs,
277
+ amountExpr, afterExpr, guardExpr, extraParams) {
265
278
  var id = b.uuid.v7();
266
- await query(
279
+ var tsSub = "COALESCE((SELECT occurred_at FROM credit_transactions " +
280
+ "WHERE customer_id = ?2 ORDER BY occurred_at DESC LIMIT 1), 0)";
281
+ // Strictly-monotonic per-customer occurred_at: bump to prior + 1 on
282
+ // a same-ms collision so the "latest row" stays unambiguous for the
283
+ // denormalized-balance reads.
284
+ var tsExpr = "CASE WHEN ?5 > " + tsSub + " THEN ?5 ELSE " + tsSub + " + 1 END";
285
+ var params = [id, customerId, kind, orderId, requestedTs, paymentRef].concat(extraParams || []);
286
+ var res = await query(
267
287
  "INSERT INTO credit_transactions " +
268
288
  "(id, customer_id, kind, order_id, amount_minor, balance_after_minor, payment_ref, occurred_at) " +
269
- "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
270
- [id, customerId, kind, orderId, amountMinor, balanceAfter, paymentRef, ts],
289
+ "SELECT ?1, ?2, ?3, ?4, " + amountExpr + ", " + afterExpr + ", ?6, " + tsExpr + " " +
290
+ "WHERE " + guardExpr,
291
+ params,
271
292
  );
272
- return id;
293
+ if (Number(res.rowCount || 0) === 0) return null;
294
+ var row = (await query(
295
+ "SELECT amount_minor, balance_after_minor, occurred_at FROM credit_transactions WHERE id = ?1",
296
+ [id],
297
+ )).rows[0];
298
+ return {
299
+ id: id,
300
+ amount_minor: row.amount_minor,
301
+ balance_after_minor: row.balance_after_minor,
302
+ occurred_at: row.occurred_at,
303
+ };
273
304
  }
274
305
 
275
306
  async function _requireActive(customerId, op) {
@@ -475,29 +506,48 @@ function create(opts) {
475
506
  var requested = _epochMs(input.occurred_at, "occurred_at");
476
507
  if (requested == null) requested = _now();
477
508
 
478
- var acct = await _requireActive(customerId, "chargeOrder");
479
- var latest = await _readLatestTxn(customerId);
480
- var after = latest.balance + amount;
481
- if (after > acct.credit_limit_minor) {
509
+ // Fast pre-check for a clean NOT_FOUND / NOT_ACTIVE error; the
510
+ // credit-limit invariant itself is enforced atomically in the
511
+ // INSERT guard below (re-checking status = 'active' there too),
512
+ // so a charge can't slip past the limit by racing another charge.
513
+ await _requireActive(customerId, "chargeOrder");
514
+ var written = await _appendTxnAtomic(
515
+ customerId, "charge", orderId, null, requested,
516
+ "?7", // amount_minor: the charge
517
+ _BAL_SUB + " + ?7", // balance_after = outstanding + amount
518
+ "(" + _BAL_SUB + " + ?7) <= COALESCE((SELECT credit_limit_minor " +
519
+ "FROM credit_accounts WHERE customer_id = ?2 AND status = 'active'), -1)",
520
+ [amount],
521
+ );
522
+ if (!written) {
523
+ // Guard refused. Re-read to report precisely: a status change
524
+ // since the pre-check (rare race with suspend/close) vs the
525
+ // limit being exceeded.
526
+ var acctNow = await _readAccount(customerId);
527
+ if (!acctNow || acctNow.status !== "active") {
528
+ var na = new Error("creditLimits.chargeOrder: account status is " +
529
+ (acctNow ? acctNow.status : "missing") + " — operation refused");
530
+ na.code = acctNow ? "CREDIT_ACCOUNT_NOT_ACTIVE" : "CREDIT_ACCOUNT_NOT_FOUND";
531
+ throw na;
532
+ }
533
+ var curBal = (await _readLatestTxn(customerId)).balance;
482
534
  var over = new Error("creditLimits.chargeOrder: amount " + amount +
483
- " would exceed credit limit (outstanding " + latest.balance +
484
- " + amount " + amount + " > limit " + acct.credit_limit_minor + ")");
535
+ " would exceed credit limit (outstanding " + curBal +
536
+ " + amount " + amount + " > limit " + acctNow.credit_limit_minor + ")");
485
537
  over.code = "CREDIT_LIMIT_EXCEEDED";
486
- over.outstanding_minor = latest.balance;
487
- over.credit_limit_minor = acct.credit_limit_minor;
538
+ over.outstanding_minor = curBal;
539
+ over.credit_limit_minor = acctNow.credit_limit_minor;
488
540
  over.attempted_minor = amount;
489
541
  throw over;
490
542
  }
491
- var ts = _resolveOccurredAt(requested, latest.occurred_at);
492
- var id = await _writeTxn(customerId, "charge", orderId, amount, after, null, ts);
493
543
  return {
494
- id: id,
544
+ id: written.id,
495
545
  customer_id: customerId,
496
546
  kind: "charge",
497
547
  order_id: orderId,
498
548
  amount_minor: amount,
499
- balance_after_minor: after,
500
- occurred_at: ts,
549
+ balance_after_minor: written.balance_after_minor,
550
+ occurred_at: written.occurred_at,
501
551
  };
502
552
  },
503
553
 
@@ -527,50 +577,42 @@ function create(opts) {
527
577
  throw closed;
528
578
  }
529
579
 
530
- // Sum the customer's net exposure to this order: charge +
531
- // hold sums minus prior release sums. Refuse if zero (the
532
- // order was never charged, or has already been fully
533
- // released) re-releasing would drive the balance below the
534
- // genuine outstanding and corrupt the ledger.
535
- var sumRow = (await query(
536
- "SELECT " +
537
- " COALESCE(SUM(CASE WHEN kind IN ('charge', 'hold') THEN amount_minor ELSE 0 END), 0) AS charged, " +
538
- " COALESCE(SUM(CASE WHEN kind = 'release' THEN amount_minor ELSE 0 END), 0) AS released " +
539
- "FROM credit_transactions " +
540
- "WHERE customer_id = ?1 AND order_id = ?2",
541
- [customerId, orderId],
542
- )).rows[0];
543
- var charged = sumRow ? sumRow.charged : 0;
544
- var released = sumRow ? sumRow.released : 0;
545
- var remaining = charged - released;
546
- if (remaining <= 0) {
580
+ // Net exposure to this order charge + hold sums minus prior
581
+ // release sums capped at the live balance, computed and written
582
+ // in ONE atomic statement. The racy read-sum-then-write this
583
+ // replaced let two concurrent releases (or a release racing a
584
+ // payment) both read the same remaining/balance and both write,
585
+ // double-releasing the order and corrupting the running balance.
586
+ // The guard refuses when nothing is outstanding (never charged /
587
+ // already fully released) or the balance is drained, so a release
588
+ // can't drive the ledger negative. Capping at _BAL_SUB stays
589
+ // defensive against operator-induced ledger drift.
590
+ var remainingSub = "COALESCE((SELECT SUM(CASE " +
591
+ "WHEN kind IN ('charge', 'hold') THEN amount_minor " +
592
+ "WHEN kind = 'release' THEN -amount_minor ELSE 0 END) " +
593
+ "FROM credit_transactions WHERE customer_id = ?2 AND order_id = ?4), 0)";
594
+ var toReleaseExpr = "CASE WHEN " + remainingSub + " < " + _BAL_SUB +
595
+ " THEN " + remainingSub + " ELSE " + _BAL_SUB + " END";
596
+ var written = await _appendTxnAtomic(
597
+ customerId, "release", orderId, null, requested,
598
+ toReleaseExpr, // amount actually released
599
+ _BAL_SUB + " - (" + toReleaseExpr + ")", // new balance
600
+ remainingSub + " > 0 AND " + _BAL_SUB + " > 0", // nothing outstanding / drained -> refuse
601
+ [],
602
+ );
603
+ if (!written) {
547
604
  var nothing = new Error("creditLimits.releaseHold: no outstanding charge for order " + JSON.stringify(orderId));
548
605
  nothing.code = "CREDIT_RELEASE_NOT_FOUND";
549
606
  throw nothing;
550
607
  }
551
-
552
- var latest = await _readLatestTxn(customerId);
553
- // Cap the release at the outstanding balance — defensive
554
- // against operator-induced ledger drift (a payment that
555
- // somehow shrank the balance below the charge total
556
- // shouldn't drive a release into negative territory).
557
- var toRelease = remaining > latest.balance ? latest.balance : remaining;
558
- if (toRelease <= 0) {
559
- var drained = new Error("creditLimits.releaseHold: outstanding balance is zero — nothing to release");
560
- drained.code = "CREDIT_RELEASE_NOT_FOUND";
561
- throw drained;
562
- }
563
- var ts = _resolveOccurredAt(requested, latest.occurred_at);
564
- var after = latest.balance - toRelease;
565
- var id = await _writeTxn(customerId, "release", orderId, toRelease, after, null, ts);
566
608
  return {
567
- id: id,
609
+ id: written.id,
568
610
  customer_id: customerId,
569
611
  kind: "release",
570
612
  order_id: orderId,
571
- amount_minor: toRelease,
572
- balance_after_minor: after,
573
- occurred_at: ts,
613
+ amount_minor: written.amount_minor,
614
+ balance_after_minor: written.balance_after_minor,
615
+ occurred_at: written.occurred_at,
574
616
  };
575
617
  },
576
618
 
@@ -594,31 +636,37 @@ function create(opts) {
594
636
  // customer with a closed account paying down their balance is
595
637
  // a legitimate AR workflow.
596
638
 
597
- var latest = await _readLatestTxn(customerId);
598
- // Refuse payments that exceed the outstanding balance —
639
+ // Refuse a payment that exceeds the outstanding balance —
599
640
  // credit-on-account-from-overpayment is a separate primitive
600
- // (store-credit) and the operator must route the overage
601
- // there explicitly rather than implicitly inflating the
602
- // outstanding into negative territory.
603
- if (amount > latest.balance) {
641
+ // (store-credit) and the operator must route the overage there
642
+ // explicitly rather than implicitly inflating the outstanding
643
+ // into negative territory. Enforced in the atomic INSERT guard so
644
+ // two concurrent payments can't both pass an outdated balance
645
+ // read and over-apply (driving the ledger negative).
646
+ var written = await _appendTxnAtomic(
647
+ customerId, "payment", null, paymentRef, requested,
648
+ "?7", // amount paid
649
+ _BAL_SUB + " - ?7", // new balance
650
+ "?7 <= " + _BAL_SUB, // amount <= outstanding, else refuse
651
+ [amount],
652
+ );
653
+ if (!written) {
654
+ var curBal = (await _readLatestTxn(customerId)).balance;
604
655
  var over = new Error("creditLimits.recordPayment: amount " + amount +
605
- " exceeds outstanding balance " + latest.balance);
656
+ " exceeds outstanding balance " + curBal);
606
657
  over.code = "CREDIT_PAYMENT_EXCEEDS_OUTSTANDING";
607
- over.outstanding_minor = latest.balance;
658
+ over.outstanding_minor = curBal;
608
659
  over.attempted_minor = amount;
609
660
  throw over;
610
661
  }
611
- var ts = _resolveOccurredAt(requested, latest.occurred_at);
612
- var after = latest.balance - amount;
613
- var id = await _writeTxn(customerId, "payment", null, amount, after, paymentRef, ts);
614
662
  return {
615
- id: id,
663
+ id: written.id,
616
664
  customer_id: customerId,
617
665
  kind: "payment",
618
666
  amount_minor: amount,
619
667
  payment_ref: paymentRef,
620
- balance_after_minor: after,
621
- occurred_at: ts,
668
+ balance_after_minor: written.balance_after_minor,
669
+ occurred_at: written.occurred_at,
622
670
  };
623
671
  },
624
672
 
@@ -107,6 +107,7 @@ var INTERNAL_BRIDGE_PATHS = [
107
107
  "/_/wishlist-alerts-sweep",
108
108
  "/_/wishlist-digest-sweep",
109
109
  "/_/campaign-send-tick",
110
+ "/_/winback-send-tick",
110
111
  "/_/customer-portal-expire",
111
112
  "/_/stale-order-reap",
112
113
  "/_/quote-expiry-tick",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.57",
3
+ "version": "0.4.59",
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": {