@blamejs/blamejs-shop 0.4.57 → 0.4.58
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/affiliates.js +35 -4
- package/lib/api-keys.js +17 -2
- package/lib/asset-manifest.json +1 -1
- package/lib/backorder.js +21 -4
- package/lib/click-and-collect.js +11 -2
- package/lib/credit-limits.js +132 -84
- 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.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.
|
|
12
|
+
|
|
11
13
|
- 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
14
|
|
|
13
15
|
- 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
|
-
|
|
890
|
-
|
|
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
|
-
|
|
930
|
-
|
|
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
|
-
|
|
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.
|
package/lib/asset-manifest.json
CHANGED
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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",
|
package/lib/click-and-collect.js
CHANGED
|
@@ -581,12 +581,21 @@ function create(opts) {
|
|
|
581
581
|
}
|
|
582
582
|
|
|
583
583
|
var now = _now();
|
|
584
|
-
|
|
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.
|
package/lib/credit-limits.js
CHANGED
|
@@ -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.
|
|
65
|
-
* requested timestamp to `prior + 1` on collision,
|
|
66
|
-
*
|
|
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
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
270
|
-
|
|
289
|
+
"SELECT ?1, ?2, ?3, ?4, " + amountExpr + ", " + afterExpr + ", ?6, " + tsExpr + " " +
|
|
290
|
+
"WHERE " + guardExpr,
|
|
291
|
+
params,
|
|
271
292
|
);
|
|
272
|
-
return
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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 " +
|
|
484
|
-
" + amount " + amount + " > limit " +
|
|
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 =
|
|
487
|
-
over.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:
|
|
500
|
-
occurred_at:
|
|
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
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
var
|
|
545
|
-
|
|
546
|
-
|
|
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:
|
|
572
|
-
balance_after_minor:
|
|
573
|
-
occurred_at:
|
|
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
|
-
|
|
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
|
-
//
|
|
602
|
-
//
|
|
603
|
-
|
|
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 " +
|
|
656
|
+
" exceeds outstanding balance " + curBal);
|
|
606
657
|
over.code = "CREDIT_PAYMENT_EXCEEDS_OUTSTANDING";
|
|
607
|
-
over.outstanding_minor =
|
|
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:
|
|
621
|
-
occurred_at:
|
|
668
|
+
balance_after_minor: written.balance_after_minor,
|
|
669
|
+
occurred_at: written.occurred_at,
|
|
622
670
|
};
|
|
623
671
|
},
|
|
624
672
|
|
package/package.json
CHANGED