@blamejs/blamejs-shop 0.4.60 → 0.4.62
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/customer-surveys.js +21 -4
- package/lib/dunning.js +32 -4
- package/lib/email-campaigns.js +45 -6
- package/lib/operator-approvals.js +16 -2
- package/lib/payment-retries.js +35 -0
- package/lib/plan-changes.js +21 -2
- package/lib/push-notifications.js +17 -4
- package/lib/reorder-reminders.js +36 -8
- package/lib/seller-signup.js +37 -6
- package/lib/wishlist-digest.js +55 -19
- 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.62 (2026-06-19) — **Money and approval actions can't double-fire under concurrent calls.** Closes a set of concurrency races in money- and approval-critical paths where the action read a row, checked its status in application code, and then performed an irreversible side-effect (retry a card, execute an approved action, register a vendor, bill a subscription change) before an unconditional update committed — so two concurrent calls could both pass the check and both perform the side-effect. Each now claims the transition with a single conditional update and performs the side-effect only when that claim wins. A scheduled payment retry is claimed out of the due set before the processor is called, so an overlapping scheduler tick can't retry the same card twice; a payment outcome is claimed by its attempt number before the attempt is recorded, so a redelivered webhook can't double-count it. Executing an approved operator request claims the approved-to-executed transition before returning success, so the underlying money move runs once. Approving a seller application claims the in-review-to-approved transition before registering the vendor (rolling back to in-review if registration fails), so one application can't create two vendors. Applying scheduled subscription plan changes claims each pending change before updating the plan and recording its proration invoice, and stamps a deterministic idempotency key on the invoice, so an overlapping or retried scheduler run can't bill the proration twice. Behavior for a single sequential caller is unchanged. **Fixed:** *Scheduled payment retries and recorded outcomes claim before acting* — The retry scheduler now claims each due enrollment (nulling its next-retry time, conditional on the value the tick observed) before composing the processor retry, so two overlapping ticks can't both retry the same card for one scheduled slot — only the claim winner calls the processor. Recording a retry outcome claims the enrollment by bumping its attempt count conditionally on the observed value before writing the attempt row, so a redelivered webhook (or a manual record racing it) can't write a duplicate attempt or double-count toward the cap; the loser returns the already-advanced row. · *Executing an approved request runs the action once* — markExecuted now flips the request from approved to executed with a conditional update (WHERE id = ? AND status = 'approved') and returns success only when that update changes one row; a concurrent second call is refused with a typed error before the application runs the underlying action (a large refund, vendor payout, or bulk operation) a second time. · *Approving a seller application creates one vendor* — approveApplication now claims the in-review-to-approved transition first and registers the vendor only when the claim wins, so two concurrent approvals can't both register a vendor for one application. If vendor registration fails after the claim, the application is rolled back to in-review so it can be approved again rather than stranded as approved with no vendor. · *Applying scheduled plan changes bills the proration once* — applyScheduledChanges now claims each pending change (WHERE id = ? AND status = 'pending') before updating the subscription plan and recording the proration invoice, so an overlapping or retried scheduler run skips an already-applied change instead of billing it twice. Both the scheduled and the immediate plan-change paths now stamp a deterministic idempotency key on the proration invoice, so even a re-run that reached the billing call would dedupe against the unique invoice id rather than write a second charge. A regression test asserts a second apply pass records no additional invoice.
|
|
12
|
+
|
|
13
|
+
- v0.4.61 (2026-06-19) — **Scheduled and dispatched sends no longer double-fire under concurrent ticks.** Closes a class of concurrency races in the scheduled-send and dispatch paths where a tick read a row, checked its status or schedule cursor in application code, and then performed the send (and its ledger write) before an unconditional update committed — so two overlapping ticks (a cron overrun, two workers, or a manual tick racing the schedule) could both pass the check and both send. Each path now claims the row with a single conditional update — flipping the status, or advancing the schedule cursor, only when the row is still in the expected state — and performs the send only when that claim wins; the losing tick changes zero rows and skips. The affected sends: an email campaign drain (the whole audience could be mailed twice), a queued push notification, a wishlist digest email and its sent-ledger row, a reorder reminder, a survey response record, and a dunning step's reminder email / subscription action. Behavior for a single (sequential) caller is unchanged; only the concurrent double-fire is removed. Where a send fails after the claim wins, the schedule cursor is rolled back so the row is retried on the next tick rather than skipped. **Fixed:** *Email campaign sends claim the campaign before draining* — sendNow, the scheduled dispatch tick, and the consent-gated broadcast drain now flip the campaign from scheduled to sending with a conditional update (WHERE slug = ? AND status = 'scheduled') and drain only when that update changes exactly one row. Previously the status check ran in application code before an unconditional flip, so two concurrent sends could both drain and mail the entire audience twice. The losing caller refuses (a typed send-race error) or, for the tick, skips the campaign. · *Push, wishlist-digest, and reorder-reminder ticks claim each row before sending* — Each per-row advance is now the atomic claim: a queued push notification advances to sent/failed only via WHERE id = ? AND status = 'queued'; a wishlist digest and a reorder reminder advance their schedule cursor with WHERE id = ? AND next_*_at = <the observed value>. The external send (and, for the digest, its sent-ledger row) runs only when the claim changes one row, so two overlapping ticks can't both dispatch the same row. A send that fails after winning the reminder claim rolls the cursor back so the row retries next tick. · *Survey responses and dunning steps gate their side-effect on an atomic claim* — Submitting a survey response now flips the invitation from issued to responded with WHERE id = ? AND status = 'issued' and inserts the response row only when that claim wins (a lost race returns a typed already-responded error), so a double-submit can't record two responses for one invitation. A dunning tick now claims the enrollment with WHERE id = ? AND status = 'active' AND next_action_at = <the observed value> before executing the step (the reminder email / in-app notification / pause or cancel), so overlapping dunning ticks can't send a step twice; the subsequent state transitions carry the same status guard.
|
|
14
|
+
|
|
11
15
|
- v0.4.60 (2026-06-19) — **Free-shipping discount rules now actually waive the shipping charge.** Fixes a checkout defect that made a free_shipping auto-discount rule a complete no-op on the amount charged: the discount engine correctly flagged the order as free-shipping-eligible, but checkout dropped that flag and still billed the customer the full selected shipping rate. A matched free_shipping rule now zeroes the charged shipping in the quote and the confirmed order (the selected rate is still shown so the customer sees what was waived), so the payment is for merchandise and tax only. The rule is also now reserved against its redemption like every other auto-discount, so a free_shipping rule with a usage cap is enforced instead of applying without limit. Subtotal discounts are unaffected — free shipping reduces the shipping line, not the subtotal, and tax (computed on the post-discount subtotal) is unchanged. **Fixed:** *free_shipping auto-discount waives the charged shipping* — A matching free_shipping rule was flagged by the auto-discount engine but never applied to the amount charged — checkout excluded it from the discount (correct, since free shipping is not a subtotal reduction) but then charged the full shipping rate, so the rule saved the customer nothing. The quote and the confirmed order now set the charged shipping to zero when a free_shipping rule matches; the selected shipping service and its rate are still surfaced so the storefront can show the waived amount. The free_shipping rule is included in the order's applied auto-discounts and reserved against its redemption pre-charge, so a capped free-shipping rule is enforced and recorded the same way a percentage or amount-off rule is. A checkout integration test now asserts the charged total, the confirmed order total, and the payment amount all exclude shipping when free shipping applies.
|
|
12
16
|
|
|
13
17
|
- 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.
|
package/lib/asset-manifest.json
CHANGED
package/lib/customer-surveys.js
CHANGED
|
@@ -727,16 +727,33 @@ function create(opts) {
|
|
|
727
727
|
|
|
728
728
|
var canonicalAnswers = _validateAnswers(input.answers, questions);
|
|
729
729
|
|
|
730
|
+
// Atomic claim: flip 'issued' -> 'responded' guarded by the
|
|
731
|
+
// observed status in the WHERE before recording the response. The
|
|
732
|
+
// JS status checks above give precise typed errors for the
|
|
733
|
+
// sequential case, but two concurrent submits on the same token
|
|
734
|
+
// both pass those checks (each reads status='issued'); only this
|
|
735
|
+
// conditional UPDATE can win. SQLite/D1 serializes writers, so
|
|
736
|
+
// exactly one submit sees rowCount===1 and is allowed to INSERT
|
|
737
|
+
// the response row — the loser sees rowCount===0 and refuses,
|
|
738
|
+
// so the response is recorded exactly once (no duplicate
|
|
739
|
+
// survey_responses row, no double-counted rollup).
|
|
740
|
+
var claim = await query(
|
|
741
|
+
"UPDATE survey_invitations SET status = 'responded', responded_at = ?1 " +
|
|
742
|
+
"WHERE id = ?2 AND status = 'issued'",
|
|
743
|
+
[nowTs, invitation.id],
|
|
744
|
+
);
|
|
745
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
746
|
+
var raced = new Error("customerSurveys.submitResponse: invitation already responded");
|
|
747
|
+
raced.code = "SURVEY_INVITATION_ALREADY_RESPONDED";
|
|
748
|
+
throw raced;
|
|
749
|
+
}
|
|
750
|
+
|
|
730
751
|
var responseId = b.uuid.v7();
|
|
731
752
|
await query(
|
|
732
753
|
"INSERT INTO survey_responses (id, invitation_id, answers_json, occurred_at) " +
|
|
733
754
|
"VALUES (?1, ?2, ?3, ?4)",
|
|
734
755
|
[responseId, invitation.id, JSON.stringify(canonicalAnswers), occurredAt],
|
|
735
756
|
);
|
|
736
|
-
await query(
|
|
737
|
-
"UPDATE survey_invitations SET status = 'responded', responded_at = ?1 WHERE id = ?2",
|
|
738
|
-
[nowTs, invitation.id],
|
|
739
|
-
);
|
|
740
757
|
|
|
741
758
|
return {
|
|
742
759
|
response_id: responseId,
|
package/lib/dunning.js
CHANGED
|
@@ -390,6 +390,34 @@ function create(opts) {
|
|
|
390
390
|
// The function is the heart of tickDunning — it's surfaced
|
|
391
391
|
// internally so tests can drive a single advance deterministically.
|
|
392
392
|
async function _advance(enrollment, schedule, cancelAfterAttempts, now) {
|
|
393
|
+
// ---- atomic claim ----------------------------------------------
|
|
394
|
+
// tickDunning snapshots the due-set with a SELECT, then walks each
|
|
395
|
+
// row here. Two concurrent ticks (overlapping cron fires, a manual
|
|
396
|
+
// tick racing the cron) both see the SAME due row and would each
|
|
397
|
+
// run _executeAction — double-emailing the customer, double-
|
|
398
|
+
// recording the retry, double-pausing/cancelling the subscription.
|
|
399
|
+
// SQLite/D1 serializes writers, so a conditional UPDATE is the
|
|
400
|
+
// atomic claim: null out next_action_at guarded on the value this
|
|
401
|
+
// tick OBSERVED (enrollment.next_action_at) AND status='active'.
|
|
402
|
+
// The first tick to land flips it to 1 row; the loser matches 0
|
|
403
|
+
// rows and skips the row entirely (returns the row unchanged so
|
|
404
|
+
// the snapshot caller still gets a refreshed view without firing
|
|
405
|
+
// the side-effect twice). The subsequent state UPDATEs below all
|
|
406
|
+
// re-assert status='active' so a unsetEnrollment landing mid-walk
|
|
407
|
+
// can't be clobbered back to active either.
|
|
408
|
+
var observedNextAt = enrollment.next_action_at;
|
|
409
|
+
var claim = await query(
|
|
410
|
+
"UPDATE dunning_enrollments SET next_action_at = NULL " +
|
|
411
|
+
"WHERE id = ?1 AND status = 'active' AND next_action_at = ?2",
|
|
412
|
+
[enrollment.id, observedNextAt],
|
|
413
|
+
);
|
|
414
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
415
|
+
// Lost the race — another tick already claimed this row (or it
|
|
416
|
+
// was unset to a terminal status between the snapshot and now).
|
|
417
|
+
// Skip the side-effect; return the current row state.
|
|
418
|
+
return await _refetchEnrollment(enrollment.id);
|
|
419
|
+
}
|
|
420
|
+
|
|
393
421
|
var attemptCount = Number(enrollment.attempt_count);
|
|
394
422
|
var stepIndex = attemptCount;
|
|
395
423
|
if (stepIndex >= schedule.length) {
|
|
@@ -401,7 +429,7 @@ function create(opts) {
|
|
|
401
429
|
await query(
|
|
402
430
|
"UPDATE dunning_enrollments SET status = ?1, next_action_at = NULL, " +
|
|
403
431
|
"ended_at = ?2, end_reason = ?3, last_action = ?4, last_action_at = ?2 " +
|
|
404
|
-
"WHERE id = ?5",
|
|
432
|
+
"WHERE id = ?5 AND status = 'active'",
|
|
405
433
|
[closeAs, now, "schedule_exhausted", "schedule_exhausted", enrollment.id],
|
|
406
434
|
);
|
|
407
435
|
await _writeEvent(enrollment.id, attemptCount, "cancel_subscription", "ok", now);
|
|
@@ -419,7 +447,7 @@ function create(opts) {
|
|
|
419
447
|
await query(
|
|
420
448
|
"UPDATE dunning_enrollments SET status = 'cancelled', next_action_at = NULL, " +
|
|
421
449
|
"attempt_count = ?1, last_action = ?2, last_action_at = ?3, " +
|
|
422
|
-
"ended_at = ?3, end_reason = ?4 WHERE id = ?5",
|
|
450
|
+
"ended_at = ?3, end_reason = ?4 WHERE id = ?5 AND status = 'active'",
|
|
423
451
|
[newAttemptCount, action, now, action === "cancel_subscription" ? "policy_cancel_step" : "attempt_cap", enrollment.id],
|
|
424
452
|
);
|
|
425
453
|
await _writeEvent(enrollment.id, newAttemptCount, action, outcome, now);
|
|
@@ -433,7 +461,7 @@ function create(opts) {
|
|
|
433
461
|
await query(
|
|
434
462
|
"UPDATE dunning_enrollments SET status = 'abandoned', next_action_at = NULL, " +
|
|
435
463
|
"attempt_count = ?1, last_action = ?2, last_action_at = ?3, " +
|
|
436
|
-
"ended_at = ?3, end_reason = 'schedule_exhausted' WHERE id = ?4",
|
|
464
|
+
"ended_at = ?3, end_reason = 'schedule_exhausted' WHERE id = ?4 AND status = 'active'",
|
|
437
465
|
[newAttemptCount, action, now, enrollment.id],
|
|
438
466
|
);
|
|
439
467
|
await _writeEvent(enrollment.id, newAttemptCount, action, outcome, now);
|
|
@@ -442,7 +470,7 @@ function create(opts) {
|
|
|
442
470
|
|
|
443
471
|
await query(
|
|
444
472
|
"UPDATE dunning_enrollments SET attempt_count = ?1, last_action = ?2, " +
|
|
445
|
-
"last_action_at = ?3, next_action_at = ?4 WHERE id = ?5",
|
|
473
|
+
"last_action_at = ?3, next_action_at = ?4 WHERE id = ?5 AND status = 'active'",
|
|
446
474
|
[newAttemptCount, action, now, planned.next_action_at, enrollment.id],
|
|
447
475
|
);
|
|
448
476
|
await _writeEvent(enrollment.id, newAttemptCount, action, outcome, now);
|
package/lib/email-campaigns.js
CHANGED
|
@@ -1187,10 +1187,26 @@ function create(opts) {
|
|
|
1187
1187
|
);
|
|
1188
1188
|
}
|
|
1189
1189
|
await _fire(row, "start");
|
|
1190
|
-
|
|
1191
|
-
|
|
1190
|
+
// Atomic claim: the scheduled → sending flip is the exactly-once
|
|
1191
|
+
// gate on the drain. `_fire` only validates the FSM against the
|
|
1192
|
+
// row we read — two concurrent sendNow / dispatchTick calls can
|
|
1193
|
+
// both read `scheduled`, both pass that in-memory check, and both
|
|
1194
|
+
// reach the drain, double-mailing the whole audience. Guarding the
|
|
1195
|
+
// SET on the OBSERVED status makes exactly one caller win the flip;
|
|
1196
|
+
// SQLite serializes the writers so only one UPDATE reports a row
|
|
1197
|
+
// changed. The loser refuses rather than drains.
|
|
1198
|
+
var claim = await query(
|
|
1199
|
+
"UPDATE email_campaigns SET status = 'sending', updated_at = ?1 " +
|
|
1200
|
+
"WHERE slug = ?2 AND status = 'scheduled'",
|
|
1192
1201
|
[Date.now(), slug],
|
|
1193
1202
|
);
|
|
1203
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
1204
|
+
var raceErr = new TypeError(
|
|
1205
|
+
"emailCampaigns.sendNow: campaign '" + slug + "' is already being sent"
|
|
1206
|
+
);
|
|
1207
|
+
raceErr.code = "EMAIL_CAMPAIGN_SEND_RACE";
|
|
1208
|
+
throw raceErr;
|
|
1209
|
+
}
|
|
1194
1210
|
var stats = await _drainSend(row);
|
|
1195
1211
|
// Re-fire complete off the freshly-read row so the FSM sees the
|
|
1196
1212
|
// 'sending' state we just wrote.
|
|
@@ -1233,10 +1249,18 @@ function create(opts) {
|
|
|
1233
1249
|
// picked it up. Skip; the row is no longer scheduled.
|
|
1234
1250
|
continue;
|
|
1235
1251
|
}
|
|
1236
|
-
|
|
1237
|
-
|
|
1252
|
+
// Atomic claim: `_fire` only validated the FSM against the
|
|
1253
|
+
// snapshot row this tick SELECTed, so two concurrent ticks (or a
|
|
1254
|
+
// tick racing sendNow) can both pass that check and both drain —
|
|
1255
|
+
// double-mailing the audience. Guarding the SET on the observed
|
|
1256
|
+
// `scheduled` status lets exactly one writer win the flip; the
|
|
1257
|
+
// loser skips this row instead of re-sending.
|
|
1258
|
+
var claim = await query(
|
|
1259
|
+
"UPDATE email_campaigns SET status = 'sending', updated_at = ?1 " +
|
|
1260
|
+
"WHERE slug = ?2 AND status = 'scheduled'",
|
|
1238
1261
|
[Date.now(), row.slug],
|
|
1239
1262
|
);
|
|
1263
|
+
if (Number(claim.rowCount || 0) !== 1) continue;
|
|
1240
1264
|
var stats = await _drainSend(row);
|
|
1241
1265
|
var midRow = await _getRow(row.slug);
|
|
1242
1266
|
await _fire(midRow, "complete");
|
|
@@ -1546,10 +1570,25 @@ function create(opts) {
|
|
|
1546
1570
|
);
|
|
1547
1571
|
}
|
|
1548
1572
|
await _fire(row, "start");
|
|
1549
|
-
|
|
1550
|
-
|
|
1573
|
+
// Atomic claim on the scheduled → sending flip. The per-recipient
|
|
1574
|
+
// email_campaign_sends ledger already dedupes individual mails on
|
|
1575
|
+
// a resumed drain, but two concurrent broadcasts that both read
|
|
1576
|
+
// `scheduled` would both enter the drain and contend on that
|
|
1577
|
+
// ledger; guarding the flip on the observed status lets exactly
|
|
1578
|
+
// one caller take the campaign into `sending`. The loser refuses
|
|
1579
|
+
// (broadcastTick's per-campaign catch turns this into a skip).
|
|
1580
|
+
var claim = await query(
|
|
1581
|
+
"UPDATE email_campaigns SET status = 'sending', updated_at = ?1 " +
|
|
1582
|
+
"WHERE slug = ?2 AND status = 'scheduled'",
|
|
1551
1583
|
[Date.now(), slug],
|
|
1552
1584
|
);
|
|
1585
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
1586
|
+
var raceErr = new TypeError(
|
|
1587
|
+
"emailCampaigns.broadcast: campaign '" + slug + "' is already being sent"
|
|
1588
|
+
);
|
|
1589
|
+
raceErr.code = "EMAIL_CAMPAIGN_SEND_RACE";
|
|
1590
|
+
throw raceErr;
|
|
1591
|
+
}
|
|
1553
1592
|
row = await _getRow(slug);
|
|
1554
1593
|
}
|
|
1555
1594
|
|
|
@@ -724,11 +724,25 @@ function create(opts) {
|
|
|
724
724
|
}
|
|
725
725
|
|
|
726
726
|
var ts = _monotonicTs();
|
|
727
|
-
|
|
727
|
+
// Atomic claim: gate the approved->executed transition on the row
|
|
728
|
+
// still being 'approved'. The JS check above only refuses a
|
|
729
|
+
// sequential re-execute; two concurrent markExecuted calls would
|
|
730
|
+
// both read 'approved', both pass that check, and both return
|
|
731
|
+
// success — so the application would run the underlying money move
|
|
732
|
+
// (a large refund / vendor payout / bulk catalog action) TWICE. The
|
|
733
|
+
// `AND status = 'approved'` predicate lets exactly one caller win
|
|
734
|
+
// the transition; the loser is refused before its side-effect runs.
|
|
735
|
+
var claim = await query(
|
|
728
736
|
"UPDATE approval_requests SET status = 'executed', executed_by = ?1, " +
|
|
729
|
-
"executed_at = ?2, result_json = ?3, updated_at = ?2 WHERE id = ?4",
|
|
737
|
+
"executed_at = ?2, result_json = ?3, updated_at = ?2 WHERE id = ?4 AND status = 'approved'",
|
|
730
738
|
[executedBy, ts, resultJson, requestId],
|
|
731
739
|
);
|
|
740
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
741
|
+
var raced = new Error("operatorApprovals.markExecuted: request " +
|
|
742
|
+
JSON.stringify(requestId) + " is no longer approved — execution refused");
|
|
743
|
+
raced.code = "APPROVAL_EXECUTE_RACE";
|
|
744
|
+
throw raced;
|
|
745
|
+
}
|
|
732
746
|
|
|
733
747
|
await _audit("approval.execute", executedBy, requestId,
|
|
734
748
|
{ status: req.status },
|
package/lib/payment-retries.js
CHANGED
|
@@ -587,6 +587,23 @@ function create(opts) {
|
|
|
587
587
|
var advanced = [];
|
|
588
588
|
for (var i = 0; i < due.rows.length; i += 1) {
|
|
589
589
|
var enrollment = due.rows[i];
|
|
590
|
+
// Atomic claim: take this enrollment out of the due set (null its
|
|
591
|
+
// next_retry_at) gated on the value THIS tick observed, BEFORE any
|
|
592
|
+
// processor work. Two overlapping ticks (cron overrun / two
|
|
593
|
+
// workers / a manual tick racing the cron) both snapshot the same
|
|
594
|
+
// active+due row; only the one whose conditional UPDATE matches
|
|
595
|
+
// one row proceeds to _tickAdvance, which composes
|
|
596
|
+
// paymentHandle.retryIntent (the real card-charge retry). The
|
|
597
|
+
// loser changes zero rows and skips, so the customer's card is
|
|
598
|
+
// retried exactly once per scheduled slot. _tickAdvance re-stamps
|
|
599
|
+
// next_retry_at to the true next slot (or a terminal NULL).
|
|
600
|
+
var claim = await query(
|
|
601
|
+
"UPDATE payment_retry_enrollments SET next_retry_at = NULL " +
|
|
602
|
+
"WHERE id = ?1 AND status = 'active' AND next_retry_at = ?2",
|
|
603
|
+
[enrollment.id, enrollment.next_retry_at],
|
|
604
|
+
);
|
|
605
|
+
if (Number(claim.rowCount || 0) !== 1) continue; // another tick claimed it
|
|
606
|
+
|
|
590
607
|
var policy = await _getPolicy(enrollment.policy_slug);
|
|
591
608
|
if (!policy) {
|
|
592
609
|
// Policy went missing between enrollment + tick — close
|
|
@@ -645,6 +662,24 @@ function create(opts) {
|
|
|
645
662
|
var attemptCount = Number(enrollment.attempt_count);
|
|
646
663
|
var nextAttempt = attemptCount + 1;
|
|
647
664
|
|
|
665
|
+
// Atomic claim: bump attempt_count gated on the value we observed,
|
|
666
|
+
// BEFORE writing the attempt ledger row. The status check above
|
|
667
|
+
// only refuses a SEQUENTIAL re-record once the row is terminal;
|
|
668
|
+
// two concurrent recordRetryOutcome calls while still active (a
|
|
669
|
+
// webhook redelivery, or a manual record racing the webhook) would
|
|
670
|
+
// both read attempt_count=N, both write a duplicate attempt row,
|
|
671
|
+
// and both double-count toward the cap. Only the caller whose
|
|
672
|
+
// UPDATE matches one row proceeds; a loser returns the
|
|
673
|
+
// already-advanced row idempotently.
|
|
674
|
+
var outcomeClaim = await query(
|
|
675
|
+
"UPDATE payment_retry_enrollments SET attempt_count = ?1 " +
|
|
676
|
+
"WHERE id = ?2 AND status = 'active' AND attempt_count = ?3",
|
|
677
|
+
[nextAttempt, enrollment.id, attemptCount],
|
|
678
|
+
);
|
|
679
|
+
if (Number(outcomeClaim.rowCount || 0) !== 1) {
|
|
680
|
+
return await _refetchEnrollment(enrollment.id);
|
|
681
|
+
}
|
|
682
|
+
|
|
648
683
|
await _writeAttempt(enrollment.id, nextAttempt, succeeded, succeeded ? null : newFailureCode);
|
|
649
684
|
|
|
650
685
|
if (succeeded) {
|
package/lib/plan-changes.js
CHANGED
|
@@ -382,6 +382,10 @@ function create(opts) {
|
|
|
382
382
|
period_end: sub.current_period_end,
|
|
383
383
|
amount_minor: net,
|
|
384
384
|
currency: proposed.currency.toUpperCase(),
|
|
385
|
+
// Deterministic idempotency key keyed on this change row so
|
|
386
|
+
// a re-applied change dedupes against the UNIQUE
|
|
387
|
+
// processor_invoice_id instead of double-billing.
|
|
388
|
+
processor_invoice_id: "planchange:" + id,
|
|
385
389
|
});
|
|
386
390
|
} catch (_e) {
|
|
387
391
|
// Drop-silent — by design. The billing handle is an
|
|
@@ -468,10 +472,21 @@ function create(opts) {
|
|
|
468
472
|
var executed = [];
|
|
469
473
|
for (var i = 0; i < due.rows.length; i += 1) {
|
|
470
474
|
var row = due.rows[i];
|
|
471
|
-
|
|
472
|
-
|
|
475
|
+
// Atomic claim: flip pending -> executed gated on the status.
|
|
476
|
+
// Two overlapping scheduler runs (cron overrun, retry, two
|
|
477
|
+
// workers) both SELECT the same due row; WITHOUT this guard both
|
|
478
|
+
// would update the subscription plan AND record a proration
|
|
479
|
+
// invoice — billing the customer twice for one change. Only the
|
|
480
|
+
// caller whose UPDATE matches one row proceeds; the loser skips.
|
|
481
|
+
// (The synchronous executeChange path is already protected by its
|
|
482
|
+
// plan_id claim; this is the batch path's equivalent.)
|
|
483
|
+
var claim = await query(
|
|
484
|
+
"UPDATE subscription_plan_changes SET status = 'executed', executed_at = ?1 " +
|
|
485
|
+
"WHERE id = ?2 AND status = 'pending'",
|
|
473
486
|
[now, row.id],
|
|
474
487
|
);
|
|
488
|
+
if (Number(claim.rowCount || 0) !== 1) continue; // another run claimed it
|
|
489
|
+
|
|
475
490
|
await query(
|
|
476
491
|
"UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3",
|
|
477
492
|
[row.to_plan_id, now, row.subscription_id],
|
|
@@ -492,6 +507,10 @@ function create(opts) {
|
|
|
492
507
|
period_end: periodEnd,
|
|
493
508
|
amount_minor: net,
|
|
494
509
|
currency: String(row.currency).toUpperCase(),
|
|
510
|
+
// Deterministic idempotency key — this change applies once,
|
|
511
|
+
// so a re-run dedupes against the UNIQUE processor_invoice_id
|
|
512
|
+
// rather than writing a second proration invoice.
|
|
513
|
+
processor_invoice_id: "planchange:" + row.id,
|
|
495
514
|
});
|
|
496
515
|
} catch (_e) {
|
|
497
516
|
// Drop-silent — see executeChange.
|
|
@@ -810,18 +810,31 @@ function create(opts) {
|
|
|
810
810
|
var providerSlug = row.provider_slug;
|
|
811
811
|
var provider = providerSlug ? await _getProvider(providerSlug) : null;
|
|
812
812
|
if (!provider || Number(provider.active) !== 1 || provider.archived_at != null) {
|
|
813
|
-
|
|
813
|
+
// Atomic claim. The snapshot SELECT above is read without a
|
|
814
|
+
// lock, so two concurrent ticks can both pull this same
|
|
815
|
+
// `queued` row. The `AND status = 'queued'` guard makes the
|
|
816
|
+
// transition the claim: SQLite/D1 serialises writers, so
|
|
817
|
+
// exactly one tick's UPDATE matches the row (rowCount === 1)
|
|
818
|
+
// and the other sees rowCount === 0. Only the winner hands
|
|
819
|
+
// the row to the operator's send hook (via `advanced`), so
|
|
820
|
+
// the customer is never double-dispatched.
|
|
821
|
+
var lostFail = await query(
|
|
814
822
|
"UPDATE push_notifications SET status = 'failed', fail_reason = ?1, " +
|
|
815
|
-
"failed_at = ?2 WHERE id = ?3",
|
|
823
|
+
"failed_at = ?2 WHERE id = ?3 AND status = 'queued'",
|
|
816
824
|
["provider_unavailable_at_dispatch", now, row.id],
|
|
817
825
|
);
|
|
826
|
+
if (Number(lostFail.rowCount || 0) !== 1) continue;
|
|
818
827
|
advanced.push(await _getNotification(row.id));
|
|
819
828
|
continue;
|
|
820
829
|
}
|
|
821
|
-
|
|
822
|
-
|
|
830
|
+
// Atomic claim on the advance to `sent` — same race, same
|
|
831
|
+
// guard. The loser skips so the send hook fires once per row.
|
|
832
|
+
var claimed = await query(
|
|
833
|
+
"UPDATE push_notifications SET status = 'sent', dispatched_at = ?1 " +
|
|
834
|
+
"WHERE id = ?2 AND status = 'queued'",
|
|
823
835
|
[now, row.id],
|
|
824
836
|
);
|
|
837
|
+
if (Number(claimed.rowCount || 0) !== 1) continue;
|
|
825
838
|
advanced.push(await _getNotification(row.id));
|
|
826
839
|
}
|
|
827
840
|
return advanced;
|
package/lib/reorder-reminders.js
CHANGED
|
@@ -520,6 +520,29 @@ function create(opts) {
|
|
|
520
520
|
continue;
|
|
521
521
|
}
|
|
522
522
|
|
|
523
|
+
// Claim this row BEFORE sending. The send (email/sms/in_app)
|
|
524
|
+
// is an exactly-once side-effect; two concurrent ticks both
|
|
525
|
+
// read this same due row from the snapshot SELECT above and
|
|
526
|
+
// would both fire it. Advancing next_remind_at conditionally
|
|
527
|
+
// on the value we observed (next_remind_at = the snapshot's
|
|
528
|
+
// value) is the atomic claim — SQLite/D1 serializes writers,
|
|
529
|
+
// so exactly one tick's UPDATE matches and the loser sees
|
|
530
|
+
// rowCount 0 and skips the send. We claim by advancing one
|
|
531
|
+
// interval from the OBSERVED next_remind_at (not `now`) so a
|
|
532
|
+
// late tick doesn't drift the cadence forward.
|
|
533
|
+
var nextRemindAt = enrollment.next_remind_at + profile.interval_days * DAY_MS;
|
|
534
|
+
var claim = await query(
|
|
535
|
+
"UPDATE reorder_enrollments SET next_remind_at = ?1 " +
|
|
536
|
+
"WHERE id = ?2 AND status = 'active' AND next_remind_at = ?3",
|
|
537
|
+
[nextRemindAt, enrollment.id, enrollment.next_remind_at],
|
|
538
|
+
);
|
|
539
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
540
|
+
// Another tick already advanced this row's cursor (or it was
|
|
541
|
+
// cancelled between the SELECT and now) — it owns the send.
|
|
542
|
+
// Skip without dispatching so the customer is nudged once.
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
|
|
523
546
|
var result = await _dispatchOne(enrollment, profile);
|
|
524
547
|
var occurredAt = _now();
|
|
525
548
|
if (result.ok) {
|
|
@@ -528,14 +551,8 @@ function create(opts) {
|
|
|
528
551
|
"VALUES (?1, ?2, 'sent', ?3, NULL)",
|
|
529
552
|
[dispatchId, enrollment.id, occurredAt],
|
|
530
553
|
);
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
// doesn't drift the cadence forward.
|
|
534
|
-
var nextRemindAt = enrollment.next_remind_at + profile.interval_days * DAY_MS;
|
|
535
|
-
await query(
|
|
536
|
-
"UPDATE reorder_enrollments SET next_remind_at = ?1 WHERE id = ?2",
|
|
537
|
-
[nextRemindAt, enrollment.id],
|
|
538
|
-
);
|
|
554
|
+
// Cadence already advanced by the claim above; the send
|
|
555
|
+
// succeeded, so the row stays at the new next_remind_at.
|
|
539
556
|
dispatched.push(_shapeDispatch({
|
|
540
557
|
id: dispatchId,
|
|
541
558
|
enrollment_id: enrollment.id,
|
|
@@ -544,6 +561,17 @@ function create(opts) {
|
|
|
544
561
|
fail_reason: null,
|
|
545
562
|
}));
|
|
546
563
|
} else {
|
|
564
|
+
// The send failed after we won the claim. Roll the cursor
|
|
565
|
+
// back to the observed value so the retry sweep picks this
|
|
566
|
+
// row up on the next tick (failed rows stay due — matching
|
|
567
|
+
// the single-caller contract). The rollback is guarded on
|
|
568
|
+
// the value we set so a concurrent re-enrolment that moved
|
|
569
|
+
// the cursor forward in the meantime isn't clobbered.
|
|
570
|
+
await query(
|
|
571
|
+
"UPDATE reorder_enrollments SET next_remind_at = ?1 " +
|
|
572
|
+
"WHERE id = ?2 AND next_remind_at = ?3",
|
|
573
|
+
[enrollment.next_remind_at, enrollment.id, nextRemindAt],
|
|
574
|
+
);
|
|
547
575
|
await query(
|
|
548
576
|
"INSERT INTO reorder_dispatches (id, enrollment_id, status, occurred_at, fail_reason) " +
|
|
549
577
|
"VALUES (?1, ?2, 'failed', ?3, ?4)",
|
package/lib/seller-signup.js
CHANGED
|
@@ -842,6 +842,29 @@ function create(opts) {
|
|
|
842
842
|
throw notReady;
|
|
843
843
|
}
|
|
844
844
|
|
|
845
|
+
// Atomic claim FIRST: flip in_review -> approved gated on the
|
|
846
|
+
// status. The JS checks above only refuse a sequential re-approve;
|
|
847
|
+
// two concurrent approvals would both read 'in_review', both pass,
|
|
848
|
+
// and both call registerVendor — which mints a fresh vendor row
|
|
849
|
+
// each time, leaving a DUPLICATE vendor for one application. The
|
|
850
|
+
// `AND status = 'in_review'` predicate lets exactly one caller win;
|
|
851
|
+
// the loser is refused before registering anything. vendor_slug is
|
|
852
|
+
// stamped in a second UPDATE after the (winner-only) registration.
|
|
853
|
+
var ts = _now();
|
|
854
|
+
var claim = await query(
|
|
855
|
+
"UPDATE seller_applications " +
|
|
856
|
+
"SET status = 'approved', approved_at = ?1, approved_by = ?2, updated_at = ?1 " +
|
|
857
|
+
"WHERE id = ?3 AND status = 'in_review'",
|
|
858
|
+
[ts, approvedBy, applicationId],
|
|
859
|
+
);
|
|
860
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
861
|
+
var raced = new Error(
|
|
862
|
+
"seller-signup.approveApplication: refused — application is no longer in_review (concurrent approval)"
|
|
863
|
+
);
|
|
864
|
+
raced.code = "APPLICATION_NOT_READY";
|
|
865
|
+
throw raced;
|
|
866
|
+
}
|
|
867
|
+
|
|
845
868
|
// Compose vendors.registerVendor — the operator-facing v1
|
|
846
869
|
// defaults a fresh marketplace seller to bank_transfer payout +
|
|
847
870
|
// 70% commission split; the operator tunes both via
|
|
@@ -860,6 +883,17 @@ function create(opts) {
|
|
|
860
883
|
status: "active",
|
|
861
884
|
});
|
|
862
885
|
} catch (e) {
|
|
886
|
+
// Roll the claim back so the application returns to in_review and
|
|
887
|
+
// can be re-approved — the winning claim must not strand the app
|
|
888
|
+
// in 'approved' with no vendor behind it.
|
|
889
|
+
try {
|
|
890
|
+
await query(
|
|
891
|
+
"UPDATE seller_applications " +
|
|
892
|
+
"SET status = 'in_review', approved_at = NULL, approved_by = NULL, updated_at = ?1 " +
|
|
893
|
+
"WHERE id = ?2 AND status = 'approved'",
|
|
894
|
+
[_now(), applicationId],
|
|
895
|
+
);
|
|
896
|
+
} catch (_e2) { /* drop-silent — the registerVendor error is the caller's signal */ }
|
|
863
897
|
// Surface the vendors error with our typed-code wrapper so
|
|
864
898
|
// operators can distinguish a vendors-layer refusal from an
|
|
865
899
|
// application-layer refusal.
|
|
@@ -872,13 +906,10 @@ function create(opts) {
|
|
|
872
906
|
throw wrapped;
|
|
873
907
|
}
|
|
874
908
|
|
|
875
|
-
|
|
909
|
+
// Stamp the registered vendor slug onto the now-approved application.
|
|
876
910
|
await query(
|
|
877
|
-
"UPDATE seller_applications "
|
|
878
|
-
|
|
879
|
-
" vendor_slug = ?3, updated_at = ?1 " +
|
|
880
|
-
"WHERE id = ?4",
|
|
881
|
-
[ts, approvedBy, vendor.slug, applicationId],
|
|
911
|
+
"UPDATE seller_applications SET vendor_slug = ?1, updated_at = ?2 WHERE id = ?3",
|
|
912
|
+
[vendor.slug, _now(), applicationId],
|
|
882
913
|
);
|
|
883
914
|
|
|
884
915
|
return {
|
package/lib/wishlist-digest.js
CHANGED
|
@@ -900,6 +900,54 @@ function create(opts) {
|
|
|
900
900
|
}
|
|
901
901
|
var schedule = _shapeSchedule(scheduleRow);
|
|
902
902
|
|
|
903
|
+
// ---- ATOMIC CLAIM ------------------------------------------------
|
|
904
|
+
//
|
|
905
|
+
// The send + sent-ledger INSERT below are an EXACTLY-ONCE side
|
|
906
|
+
// effect. Two concurrent ticks both read this row from their own
|
|
907
|
+
// snapshot SELECT, both pass the JS checks, and — without a claim —
|
|
908
|
+
// both would email the customer and both would ledger a sent row.
|
|
909
|
+
// SQLite/D1 serializes writers, so a conditional UPDATE that
|
|
910
|
+
// advances the cursor on the OBSERVED next_dispatch_at IS the claim:
|
|
911
|
+
// exactly one tick flips next_dispatch_at away from `observedNext`,
|
|
912
|
+
// and only the winner (rowCount === 1) runs the side effect. The
|
|
913
|
+
// loser (rowCount === 0) lost the race — skip silently (no email,
|
|
914
|
+
// no ledger row). The new cursor matches what the post-send advance
|
|
915
|
+
// used to write, so a single sequential caller is unaffected.
|
|
916
|
+
var observedNext = Number(enrollment.next_dispatch_at);
|
|
917
|
+
// Advance the cadence one period past the claimed next_dispatch_at.
|
|
918
|
+
// A STALE enrollment (cron was down / enrolled long ago) sits many
|
|
919
|
+
// periods in the past; one period still doesn't clear `now`, so the
|
|
920
|
+
// next tick would re-fire — a flood. When one period still doesn't
|
|
921
|
+
// clear `now`, snap straight to the next occurrence strictly after
|
|
922
|
+
// `now`. The catch-up costs at most ONE digest, never a storm.
|
|
923
|
+
var nextDispatchAt = _advanceByOnePeriod(schedule, observedNext);
|
|
924
|
+
if (nextDispatchAt <= now) {
|
|
925
|
+
nextDispatchAt = _nextDispatchAt(schedule, now);
|
|
926
|
+
}
|
|
927
|
+
var claim = await query(
|
|
928
|
+
"UPDATE wishlist_digest_enrollments SET next_dispatch_at = ?1 " +
|
|
929
|
+
"WHERE id = ?2 AND status = 'active' AND next_dispatch_at = ?3",
|
|
930
|
+
[nextDispatchAt, enrollment.id, observedNext],
|
|
931
|
+
);
|
|
932
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
933
|
+
// Another tick already claimed this period — don't double-send.
|
|
934
|
+
continue;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Un-claim helper. The claim advanced next_dispatch_at before we
|
|
938
|
+
// knew whether the send would succeed; a TRANSIENT failure (compose
|
|
939
|
+
// threw, no email yet, mailer down) must leave the row due again so
|
|
940
|
+
// a future tick re-attempts — exactly the pre-claim behavior. Roll
|
|
941
|
+
// the cursor back to observedNext, guarded on the value we claimed
|
|
942
|
+
// so a concurrent winner's advance is never clobbered.
|
|
943
|
+
var _unclaim = async function () {
|
|
944
|
+
await query(
|
|
945
|
+
"UPDATE wishlist_digest_enrollments SET next_dispatch_at = ?1 " +
|
|
946
|
+
"WHERE id = ?2 AND next_dispatch_at = ?3",
|
|
947
|
+
[observedNext, enrollment.id, nextDispatchAt],
|
|
948
|
+
);
|
|
949
|
+
};
|
|
950
|
+
|
|
903
951
|
// Compose the digest body. composeDigest does its own reads
|
|
904
952
|
// against wishlist + catalog; failures inside it bubble up here
|
|
905
953
|
// and the dispatcher logs the row as skipped so a flapping
|
|
@@ -907,6 +955,7 @@ function create(opts) {
|
|
|
907
955
|
var digest = null;
|
|
908
956
|
try { digest = await composeDigest({ customer_id: enrollment.customer_id }); }
|
|
909
957
|
catch (_e) {
|
|
958
|
+
await _unclaim();
|
|
910
959
|
skipped += 1; skippedBy.email_dispatch_failed += 1;
|
|
911
960
|
continue;
|
|
912
961
|
}
|
|
@@ -921,6 +970,7 @@ function create(opts) {
|
|
|
921
970
|
catch (_e2) { customerEmail = null; }
|
|
922
971
|
}
|
|
923
972
|
if (typeof customerEmail !== "string" || !customerEmail.length) {
|
|
973
|
+
await _unclaim();
|
|
924
974
|
skipped += 1; skippedBy.no_email += 1;
|
|
925
975
|
continue;
|
|
926
976
|
}
|
|
@@ -954,6 +1004,7 @@ function create(opts) {
|
|
|
954
1004
|
text: digest.text,
|
|
955
1005
|
});
|
|
956
1006
|
} catch (_e4) {
|
|
1007
|
+
await _unclaim();
|
|
957
1008
|
skipped += 1; skippedBy.email_dispatch_failed += 1;
|
|
958
1009
|
continue;
|
|
959
1010
|
}
|
|
@@ -963,8 +1014,11 @@ function create(opts) {
|
|
|
963
1014
|
// attempt isn't counted as a successful send.
|
|
964
1015
|
}
|
|
965
1016
|
|
|
966
|
-
// Ledger
|
|
1017
|
+
// Ledger the sent row (both branches — suppressed cadence stays on
|
|
967
1018
|
// rails so the customer sees their next-dispatch-ETA roll forward).
|
|
1019
|
+
// The cadence advance already happened in the atomic claim above,
|
|
1020
|
+
// so reaching here means this tick won the race and is the sole
|
|
1021
|
+
// writer of this period's ledger row + side effect.
|
|
968
1022
|
var sentId = b.uuid.v7();
|
|
969
1023
|
var sentAt = _now(now);
|
|
970
1024
|
await query(
|
|
@@ -972,24 +1026,6 @@ function create(opts) {
|
|
|
972
1026
|
"VALUES (?1, ?2, ?3, ?4)",
|
|
973
1027
|
[sentId, enrollment.id, sentItemCount, sentAt],
|
|
974
1028
|
);
|
|
975
|
-
// Advance the cadence. The normal step is one period past the
|
|
976
|
-
// current next_dispatch_at — that keeps the schedule aligned to its
|
|
977
|
-
// target weekday / day-of-month when the tick fires on time. But a
|
|
978
|
-
// STALE enrollment (the cron was down, or the customer enrolled long
|
|
979
|
-
// ago) has a next_dispatch_at many periods in the past; advancing it
|
|
980
|
-
// one period at a time would leave it still due, so the very next
|
|
981
|
-
// tick re-fires, and a subscriber gets one digest per tick until the
|
|
982
|
-
// schedule catches up — a flood. Instead, when one period still
|
|
983
|
-
// doesn't clear `now`, snap straight to the next occurrence strictly
|
|
984
|
-
// after `now`. The catch-up costs at most ONE digest, never a storm.
|
|
985
|
-
var nextDispatchAt = _advanceByOnePeriod(schedule, Number(enrollment.next_dispatch_at));
|
|
986
|
-
if (nextDispatchAt <= now) {
|
|
987
|
-
nextDispatchAt = _nextDispatchAt(schedule, now);
|
|
988
|
-
}
|
|
989
|
-
await query(
|
|
990
|
-
"UPDATE wishlist_digest_enrollments SET next_dispatch_at = ?1 WHERE id = ?2",
|
|
991
|
-
[nextDispatchAt, enrollment.id],
|
|
992
|
-
);
|
|
993
1029
|
if (!isSuppressed) {
|
|
994
1030
|
dispatched.push({
|
|
995
1031
|
id: sentId,
|
package/package.json
CHANGED