@blamejs/blamejs-shop 0.5.3 → 0.5.5
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/dunning.js +29 -4
- package/lib/plan-changes.js +288 -66
- package/lib/storefront.js +420 -0
- package/lib/vendor/MANIFEST.json +12 -10
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/flag-targeting.js +14 -1
- package/lib/vendor/blamejs/lib/guard-regex.js +52 -3
- package/lib/vendor/blamejs/lib/mcp.js +9 -1
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.15.38.json +26 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/flag.test.js +24 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mcp.test.js +15 -0
- 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.5.x
|
|
10
10
|
|
|
11
|
+
- v0.5.5 (2026-06-27) — **Customers can change their subscription plan — proration-aware upgrades and downgrades from the account portal.** Adds a self-serve plan-change surface to the customer subscription portal. From /account/subscriptions a customer can switch an active subscription to any other plan in the same currency: the page previews the proration for each candidate plan and offers two timings. An immediate change applies now and prorates the rest of the current period — an upgrade is charged the prorated difference (recorded on the subscription invoice ledger), and a downgrade is issued the unused remainder as store credit. A next-billing-cycle change is queued and applied automatically when the period rolls over, with the new plan starting clean and no proration. The transition is exactly-once: the immediate path claims the plan switch atomically before recording the charge or credit, and the scheduled path claims each due change with a single guarded update, so a retry or an overlapping scheduler run cannot double-charge or double-credit. A downgrade that owes a credit is never applied without paying it — if the credit write fails the change rolls back and retries. The surface mounts automatically when subscriptions and store credit are composed (the default for a configured storefront); the scheduler runs on the existing cron, and the tables it uses already shipped, so there are no schema changes and no operator action is required. **Added:** *Self-serve subscription plan changes with proration* — Customers can change the plan on an active subscription from /account/subscriptions/:id/change. The page lists every plan in the subscription's currency (excluding the current and archived plans) with a proration preview, and offers an immediate switch or a switch at the next billing date. An immediate upgrade charges the prorated difference for the rest of the period and records it on the subscription invoice ledger; an immediate downgrade issues the unused remainder of the outgoing plan as store credit. A still-pending change is shown on the subscription with a one-click cancel. Cross-currency, same-plan, and archived-plan requests are refused server-side, and the surface enforces subscription ownership before any read or write. · *Scheduler applies next-billing-cycle changes automatically* — A change queued for the next billing cycle is applied without operator involvement: a minute-cadence tick flips every due change to its new plan, records the proration, and issues any owed credit. Each due change is claimed with a single guarded update so an overlapping or retried run cannot apply it twice, and a change that owes a credit is left queued rather than applied if no credit can be issued — the credit is never dropped. The scheduler is inert on a deploy without subscriptions or store credit composed. **Changed:** *Dunning step failures are logged with their cause* — When a dunning step (reminder send, retry-charge record, pause, or cancel) fails, the schedule still advances and the step is recorded as failed — but the underlying error is now written to the server log instead of being discarded, so an operator can see why a step failed rather than only that it did.
|
|
12
|
+
|
|
13
|
+
- v0.5.4 (2026-06-27) — **Vendored framework refreshed to 0.15.38 — caller-supplied regexes are screened for ReDoS before they run on request data.** Updates the vendored blamejs framework to 0.15.38. The upstream release closes two ReDoS exposures and strengthens the framework's regex guard: a feature-flag targeting condition (`op: regex`) and an MCP tool's input-schema `pattern` each compiled a caller-supplied regex and matched it against request-controlled input behind only a length cap — which does not bound catastrophic backtracking, so a six-character pattern like `(a+)+$` could pin a CPU. Both now pass through `b.guardRegex` (strict) before compilation, and the nested-quantifier detector was hardened to also catch wrapped forms (`((a)+)+`, `(([a-z]+)*)*`) that previously slipped past — which strengthens every guardRegex caller. This storefront exposes neither MCP tools nor operator-authored regex flag rules on its request path, and its own patterns are static and ReDoS-clean, so there is no runtime or behavior change for it; the hardened framework rides along and no operator action is required. **Changed:** *Vendored framework refreshed to 0.15.38 (caller-supplied regexes screened for ReDoS)* — Refreshes the vendored blamejs framework to 0.15.38. The upstream fix screens an operator-supplied feature-flag targeting regex and an MCP tool input-schema `pattern` through `b.guardRegex` (strict) before they are compiled and matched against request input, so a catastrophic-backtracking shape is refused at validation time instead of pinning a CPU; the nested-quantifier detector also now catches wrapped forms at any group depth, strengthening every guardRegex caller. The shipped storefront does not expose those caller-supplied-regex surfaces and its own patterns are static, so its runtime, public API, and wire format are unchanged; the hardened framework simply rides along.
|
|
14
|
+
|
|
11
15
|
- v0.5.3 (2026-06-27) — **Vendored framework refreshed to 0.15.37 — non-finite numeric options can no longer silently disable a security check.** Updates the vendored blamejs framework to 0.15.37. The upstream release closes a class of fail-open configuration bugs: several numeric options validated a value only as `typeof === number && >= 0`, which accepts `Infinity`. Where the value is a clock-skew tolerance or a resource cap, an `Infinity` (or `NaN`) silently turned off the very protection it tunes — an `Infinity` CWT / OCSP-staple / ARC clock-skew made the expiry and freshness comparisons unsatisfiable (so an expired token, a replayed pre-revocation OCSP `good` response, or an expired ARC seal would be accepted), and an `Infinity` WebSocket message/frame cap, inbox/flag-cache size cap, or audit-chain fan-out cap disabled the bound. These options now route through the framework's finite-bounds validator, refusing a non-finite value at the entry point (or falling back to the safe default), while options where unbounded is deliberate (reconnect-indefinitely, retain-indefinitely) still accept it. This storefront configures these options with finite values, so there is no runtime or behavior change for it; the hardened framework rides along and no operator action is required. **Changed:** *Vendored framework refreshed to 0.15.37 (non-finite option values rejected)* — Refreshes the vendored blamejs framework to 0.15.37. The upstream fix makes a clock-skew tolerance or resource cap of `Infinity` / `NaN` a rejected value rather than one that silently disables the check it configures — covering the CWT / OCSP-staple / ARC time checks, the WebSocket-client inbound size and handshake caps, and the inbox / flag-cache / audit-chain size and count caps, with deliberately-unbounded options (reconnect/retain indefinitely) unaffected. The shipped storefront passes finite values to these options, so its runtime, public API, and wire format are unchanged; the hardened framework simply rides along.
|
|
12
16
|
|
|
13
17
|
- v0.5.2 (2026-06-27) — **Vendored framework refreshed to 0.15.36.** Updates the vendored blamejs framework to 0.15.36. The upstream release is internal test-suite tooling only: it re-verifies the guard-suite class that allows a bare comma/semicolon split on token-only RFC header grammars (RRULE, RFC 9421 signature components, TLS-RPT rua, SCIM attribute paths), renames its marker to a descriptive token, and prunes five inert marker comments. No runtime code, public API, or wire format changed upstream, so there is no runtime or behavior change for this storefront and no operator action is required. **Changed:** *Vendored framework refreshed to 0.15.36* — Refreshes the vendored blamejs framework to 0.15.36, an upstream release scoped to test-suite tooling — a guard-suite class re-verified and its marker renamed, plus five inert marker comments removed. The shipped framework runtime, public API, and wire format are unchanged, so no operator action is required.
|
package/lib/asset-manifest.json
CHANGED
package/lib/dunning.js
CHANGED
|
@@ -108,6 +108,11 @@ var b = require("./vendor/blamejs");
|
|
|
108
108
|
// Framework constants (C.TIME duration helpers). Safe at module-eval —
|
|
109
109
|
// the index entry point exposes `framework` before the require cascade.
|
|
110
110
|
var C = b.constants;
|
|
111
|
+
// Observability sink for best-effort step failures: a dunning step that
|
|
112
|
+
// throws still returns the "failed" status so the schedule advances, but
|
|
113
|
+
// the CAUSE is logged here rather than discarded — a swallowed exception
|
|
114
|
+
// is a bug deferred to a later patch, not a clean degrade.
|
|
115
|
+
var _log = b.log.create({});
|
|
111
116
|
|
|
112
117
|
// ---- constants ----------------------------------------------------------
|
|
113
118
|
|
|
@@ -322,7 +327,12 @@ function create(opts) {
|
|
|
322
327
|
});
|
|
323
328
|
}
|
|
324
329
|
return "ok";
|
|
325
|
-
} catch (
|
|
330
|
+
} catch (e) {
|
|
331
|
+
_log.error("dunning step failed", {
|
|
332
|
+
action: action,
|
|
333
|
+
invoice_id: enrollment.invoice_id,
|
|
334
|
+
err: (e && e.message) || String(e),
|
|
335
|
+
});
|
|
326
336
|
return "failed";
|
|
327
337
|
}
|
|
328
338
|
}
|
|
@@ -342,7 +352,12 @@ function create(opts) {
|
|
|
342
352
|
failure_code: "dunning_retry",
|
|
343
353
|
});
|
|
344
354
|
return "ok";
|
|
345
|
-
} catch (
|
|
355
|
+
} catch (e) {
|
|
356
|
+
_log.error("dunning step failed", {
|
|
357
|
+
action: action,
|
|
358
|
+
invoice_id: enrollment.invoice_id,
|
|
359
|
+
err: (e && e.message) || String(e),
|
|
360
|
+
});
|
|
346
361
|
return "failed";
|
|
347
362
|
}
|
|
348
363
|
}
|
|
@@ -362,7 +377,12 @@ function create(opts) {
|
|
|
362
377
|
reason: "dunning: pause_subscription step",
|
|
363
378
|
});
|
|
364
379
|
return "ok";
|
|
365
|
-
} catch (
|
|
380
|
+
} catch (e) {
|
|
381
|
+
_log.error("dunning step failed", {
|
|
382
|
+
action: action,
|
|
383
|
+
invoice_id: enrollment.invoice_id,
|
|
384
|
+
err: (e && e.message) || String(e),
|
|
385
|
+
});
|
|
366
386
|
return "failed";
|
|
367
387
|
}
|
|
368
388
|
}
|
|
@@ -379,7 +399,12 @@ function create(opts) {
|
|
|
379
399
|
outcome: "cancelled",
|
|
380
400
|
});
|
|
381
401
|
return "ok";
|
|
382
|
-
} catch (
|
|
402
|
+
} catch (e) {
|
|
403
|
+
_log.error("dunning step failed", {
|
|
404
|
+
action: action,
|
|
405
|
+
invoice_id: enrollment.invoice_id,
|
|
406
|
+
err: (e && e.message) || String(e),
|
|
407
|
+
});
|
|
383
408
|
return "failed";
|
|
384
409
|
}
|
|
385
410
|
}
|
package/lib/plan-changes.js
CHANGED
|
@@ -62,6 +62,12 @@
|
|
|
62
62
|
|
|
63
63
|
var b = require("./vendor/blamejs");
|
|
64
64
|
|
|
65
|
+
// Observability sink for batch-path settlement failures: the scheduler
|
|
66
|
+
// reverts a due row to 'pending' and carries on when a Stripe push or a
|
|
67
|
+
// credit write throws, but the CAUSE is logged here rather than discarded
|
|
68
|
+
// so an operator can see why scheduled plan changes aren't landing.
|
|
69
|
+
var _log = b.log.create({});
|
|
70
|
+
|
|
65
71
|
// ---- constants ----------------------------------------------------------
|
|
66
72
|
|
|
67
73
|
var CHANGE_KINDS = ["immediate", "next_billing_cycle"];
|
|
@@ -180,8 +186,92 @@ function create(opts) {
|
|
|
180
186
|
// ledger; if the handle is absent the change is refused (executeChange
|
|
181
187
|
// throws) or left pending (applyScheduledChanges) rather than applied
|
|
182
188
|
// with the credit lost.
|
|
189
|
+
//
|
|
190
|
+
// NOTE: the store-credit / invoice path is the model for NON-Stripe
|
|
191
|
+
// subscriptions only. A Stripe-backed subscription (below) lets Stripe
|
|
192
|
+
// own the proration — Stripe invoices the difference (upgrade) or
|
|
193
|
+
// carries the balance forward as a credit (downgrade), so this primitive
|
|
194
|
+
// must NOT also issue a local credit / invoice for those rows, or the
|
|
195
|
+
// customer would be settled twice.
|
|
183
196
|
var storeCreditHandle = opts.storeCredit || null;
|
|
184
197
|
|
|
198
|
+
// Optional payment handle (the shared Stripe adapter). When wired, a
|
|
199
|
+
// plan change on a Stripe-backed subscription swaps the subscription
|
|
200
|
+
// item's price at Stripe BEFORE the local plan_id is touched, so the
|
|
201
|
+
// plan the shop shows and the plan Stripe actually bills never diverge.
|
|
202
|
+
// Without it (a deploy with no payment processor, or a non-Stripe row),
|
|
203
|
+
// the change stays local-only and the store-credit / invoice proration
|
|
204
|
+
// model applies. The handle must expose `subscriptions.retrieve(id)` +
|
|
205
|
+
// `subscriptions.update(id, body, idempotencyKey)` — the same shape
|
|
206
|
+
// subscriptionControls composes.
|
|
207
|
+
var payment = opts.payment || null;
|
|
208
|
+
var hasStripe = !!(payment && payment.subscriptions &&
|
|
209
|
+
typeof payment.subscriptions.update === "function" &&
|
|
210
|
+
typeof payment.subscriptions.retrieve === "function");
|
|
211
|
+
|
|
212
|
+
// A subscription is Stripe-backed when the processor adapter is wired
|
|
213
|
+
// AND the row carries the upstream subscription id the webhook + billing
|
|
214
|
+
// mirror key on. Rows without one are shop-local (e.g. a manually seeded
|
|
215
|
+
// subscription on a deploy that never reached Stripe) and the local
|
|
216
|
+
// store-credit / invoice proration model applies to them.
|
|
217
|
+
function _isStripeBacked(sub) {
|
|
218
|
+
return hasStripe && sub && typeof sub.stripe_subscription_id === "string" && sub.stripe_subscription_id.length > 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Swap the subscription item's price at Stripe for a Stripe-backed
|
|
222
|
+
// subscription. Stripe models the price on the subscription ITEM, not
|
|
223
|
+
// the subscription, so we retrieve the live subscription to find its
|
|
224
|
+
// (single) item id, then update that item's price. Every subscription
|
|
225
|
+
// this shop creates binds exactly one price (`items: [{ price }]`), so
|
|
226
|
+
// the first item is authoritative; if a subscription somehow carries no
|
|
227
|
+
// item we surface a structured error rather than silently writing a
|
|
228
|
+
// local-only change that diverges from Stripe.
|
|
229
|
+
//
|
|
230
|
+
// `prorationBehavior` selects who owns the money: "always_invoice"
|
|
231
|
+
// (immediate change) has Stripe invoice the proration delta now;
|
|
232
|
+
// "none" (next-cycle change) applies the new price cleanly from the
|
|
233
|
+
// next invoice with no mid-cycle adjustment. The idempotency key folds
|
|
234
|
+
// in the plan-change id so a retried call is a safe no-op at Stripe.
|
|
235
|
+
async function _pushPlanToStripe(stripeSubscriptionId, newStripePriceId, prorationBehavior, planChangeId) {
|
|
236
|
+
var live;
|
|
237
|
+
try {
|
|
238
|
+
live = await payment.subscriptions.retrieve(stripeSubscriptionId);
|
|
239
|
+
} catch (e) {
|
|
240
|
+
var rErr = new Error(
|
|
241
|
+
"planChanges: could not reach Stripe to swap the subscription plan — " + (e && e.message || e),
|
|
242
|
+
);
|
|
243
|
+
rErr.code = "PLAN_CHANGE_STRIPE_PUSH_FAILED";
|
|
244
|
+
rErr.cause = e;
|
|
245
|
+
throw rErr;
|
|
246
|
+
}
|
|
247
|
+
var items = live && live.items && Array.isArray(live.items.data) ? live.items.data : [];
|
|
248
|
+
if (!items.length || !items[0] || !items[0].id) {
|
|
249
|
+
var noItem = new Error(
|
|
250
|
+
"planChanges: Stripe subscription " + stripeSubscriptionId + " has no billable item to reprice",
|
|
251
|
+
);
|
|
252
|
+
noItem.code = "PLAN_CHANGE_STRIPE_NO_ITEM";
|
|
253
|
+
throw noItem;
|
|
254
|
+
}
|
|
255
|
+
var idemKey = "planchange:" + planChangeId;
|
|
256
|
+
try {
|
|
257
|
+
return await payment.subscriptions.update(
|
|
258
|
+
stripeSubscriptionId,
|
|
259
|
+
{ items: [{ id: items[0].id, price: newStripePriceId }], proration_behavior: prorationBehavior },
|
|
260
|
+
idemKey,
|
|
261
|
+
);
|
|
262
|
+
} catch (e2) {
|
|
263
|
+
// The processor rejected or failed the plan swap. Wrap it in a stable
|
|
264
|
+
// code so the route can surface a "nothing changed, retry" notice; the
|
|
265
|
+
// local row is still untouched (this runs before the local write).
|
|
266
|
+
var uErr = new Error(
|
|
267
|
+
"planChanges: Stripe rejected the plan swap — " + (e2 && e2.message || e2),
|
|
268
|
+
);
|
|
269
|
+
uErr.code = "PLAN_CHANGE_STRIPE_PUSH_FAILED";
|
|
270
|
+
uErr.cause = e2;
|
|
271
|
+
throw uErr;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
185
275
|
async function _getSubscription(subscriptionId) {
|
|
186
276
|
// Prefer the injected handle (production composition); fall back
|
|
187
277
|
// to a direct read so tests that pass a minimal handle
|
|
@@ -399,6 +489,9 @@ function create(opts) {
|
|
|
399
489
|
// ledger) and the credit (owed to the customer). Exactly one is
|
|
400
490
|
// non-zero: an upgrade charges the difference, a downgrade leaves
|
|
401
491
|
// the customer in credit for the outgoing plan's unused remainder.
|
|
492
|
+
// For a Stripe-backed subscription these are informational only —
|
|
493
|
+
// Stripe owns the money — but they're still recorded on the row for
|
|
494
|
+
// history / display.
|
|
402
495
|
var chargeMinor = _max0(proposed.first_charge_minor - proposed.proration_credit_minor);
|
|
403
496
|
var creditMinor = _max0(proposed.proration_credit_minor - proposed.first_charge_minor);
|
|
404
497
|
// Status: `executed` when the effective clock is now-or-past,
|
|
@@ -409,12 +502,37 @@ function create(opts) {
|
|
|
409
502
|
var status = proposed.effective_at <= ts ? "executed" : "pending";
|
|
410
503
|
var executedAt = status === "executed" ? ts : null;
|
|
411
504
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
|
|
505
|
+
var stripeBacked = _isStripeBacked(sub);
|
|
506
|
+
|
|
507
|
+
// Stripe owns the proration for a Stripe-backed subscription: it
|
|
508
|
+
// invoices the upgrade delta now (always_invoice) or carries a
|
|
509
|
+
// downgrade balance forward. So the local store-credit / invoice
|
|
510
|
+
// settlement MUST NOT also run for these rows — that would settle the
|
|
511
|
+
// customer twice. Conversely, a non-Stripe row's only settlement
|
|
512
|
+
// vehicle is the local store-credit ledger, so the credit-vehicle
|
|
513
|
+
// gate still applies there.
|
|
514
|
+
if (status === "executed" && stripeBacked) {
|
|
515
|
+
// Confirm the target plan carries a Stripe price to swap to. A plan
|
|
516
|
+
// with no `stripe_price_id` can't be expressed at Stripe; refuse
|
|
517
|
+
// before any local row is written rather than diverge the local plan
|
|
518
|
+
// from what Stripe bills.
|
|
519
|
+
var toPlanImmediate = await _getPlan(newPlanId);
|
|
520
|
+
if (!toPlanImmediate || !toPlanImmediate.stripe_price_id) {
|
|
521
|
+
var mErr = new Error(
|
|
522
|
+
"planChanges.executeChange: target plan " + newPlanId +
|
|
523
|
+
" has no stripe_price_id — cannot reprice a Stripe-backed subscription"
|
|
524
|
+
);
|
|
525
|
+
mErr.code = "PLAN_CHANGE_STRIPE_PRICE_MISSING";
|
|
526
|
+
throw mErr;
|
|
527
|
+
}
|
|
528
|
+
} else if (status === "executed") {
|
|
529
|
+
// Fail closed: a downgrade taking effect now owes the customer a
|
|
530
|
+
// credit, and the store-credit ledger is the only vehicle for it.
|
|
531
|
+
// Refuse before any row is written rather than apply the transition
|
|
532
|
+
// and silently drop the credit. (A future-dated downgrade is left
|
|
533
|
+
// pending; applyScheduledChanges re-checks at execution time.)
|
|
534
|
+
_assertCreditable(creditMinor);
|
|
535
|
+
}
|
|
418
536
|
|
|
419
537
|
await query(
|
|
420
538
|
"INSERT INTO subscription_plan_changes " +
|
|
@@ -429,56 +547,94 @@ function create(opts) {
|
|
|
429
547
|
],
|
|
430
548
|
);
|
|
431
549
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
|
|
550
|
+
// A pending (future / next-cycle) change is NOT applied here, whether
|
|
551
|
+
// Stripe-backed or not — applyScheduledChanges pushes it to Stripe (or
|
|
552
|
+
// settles the local credit/invoice) once its clock matures.
|
|
553
|
+
if (status !== "executed") return await _getById(id);
|
|
554
|
+
|
|
555
|
+
if (stripeBacked) {
|
|
556
|
+
// STRIPE-FIRST: swap the price at Stripe with always_invoice (Stripe
|
|
557
|
+
// invoices the proration delta now) BEFORE the local plan_id write.
|
|
558
|
+
// If Stripe rejects, throw and leave the local row on the outgoing
|
|
559
|
+
// plan — the row the shop shows never diverges from what Stripe bills.
|
|
560
|
+
// (toPlanImmediate is resolved above and is guaranteed to carry a
|
|
561
|
+
// stripe_price_id.)
|
|
562
|
+
try {
|
|
563
|
+
await _pushPlanToStripe(sub.stripe_subscription_id, toPlanImmediate.stripe_price_id, "always_invoice", id);
|
|
564
|
+
} catch (e) {
|
|
565
|
+
// The Stripe swap failed. The row was inserted 'executed' above but
|
|
566
|
+
// the subscription was NOT repriced and its plan_id is untouched —
|
|
567
|
+
// void the row so history never shows a phantom-executed change that
|
|
568
|
+
// didn't happen, then rethrow so the route surfaces a clean retry.
|
|
569
|
+
await query(
|
|
570
|
+
"UPDATE subscription_plan_changes SET status = 'cancelled', cancelled_at = ?1, " +
|
|
571
|
+
"cancel_reason = ?2 WHERE id = ?3",
|
|
572
|
+
[_now(), "stripe-push-failed", id],
|
|
573
|
+
);
|
|
574
|
+
throw e;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Atomic transition claim — same CAS as the local path. Stripe owns
|
|
578
|
+
// the money, so on a lost race (a concurrent executeChange already
|
|
579
|
+
// flipped the plan) there is no local credit/invoice to undo; we just
|
|
580
|
+
// skip. The Stripe push is idempotent (idempotencyKey folds in this
|
|
581
|
+
// change id), so a racing duplicate is a Stripe no-op.
|
|
582
|
+
await query(
|
|
442
583
|
"UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3 AND plan_id = ?4",
|
|
443
584
|
[newPlanId, ts, subscriptionId, sub.plan_id],
|
|
444
585
|
);
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
586
|
+
return await _getById(id);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// NON-STRIPE — today's local settlement path, unchanged.
|
|
590
|
+
// Atomic transition claim. The plan update is conditional on the
|
|
591
|
+
// CURRENT plan id, so two concurrent executeChange calls for the same
|
|
592
|
+
// immediate change can't both apply it — only the call that observes
|
|
593
|
+
// the pre-change plan id transitions the row (rowCount 1); a racing
|
|
594
|
+
// second call sees rowCount 0. The _pendingFor guard above only blocks
|
|
595
|
+
// PENDING changes, so without this an immediate change executed twice
|
|
596
|
+
// (a double-submit or retry) would record the proration invoice twice
|
|
597
|
+
// and double-charge. The proration below is gated on winning the claim.
|
|
598
|
+
var planUpd = await query(
|
|
599
|
+
"UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3 AND plan_id = ?4",
|
|
600
|
+
[newPlanId, ts, subscriptionId, sub.plan_id],
|
|
601
|
+
);
|
|
602
|
+
var wonTransition = Number((planUpd && planUpd.rowCount) || 0) > 0;
|
|
603
|
+
if (wonTransition) {
|
|
604
|
+
if (creditMinor > 0) {
|
|
605
|
+
try {
|
|
606
|
+
await _issueDowngradeCredit({
|
|
607
|
+
customerId: sub.customer_id,
|
|
608
|
+
creditMinor: creditMinor,
|
|
609
|
+
planChangeId: id,
|
|
610
|
+
});
|
|
611
|
+
} catch (e) {
|
|
612
|
+
// Revert-on-throw: the credit is the customer's money, so a
|
|
613
|
+
// transient write failure must not finalize a downgrade it
|
|
614
|
+
// was never paid for. Roll the subscription back to the
|
|
615
|
+
// outgoing plan (guarded on the just-applied plan id so a
|
|
616
|
+
// concurrent change isn't clobbered) and void this change,
|
|
617
|
+
// then rethrow so the caller retries from a clean state.
|
|
618
|
+
await query(
|
|
619
|
+
"UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3 AND plan_id = ?4",
|
|
620
|
+
[sub.plan_id, _now(), subscriptionId, newPlanId],
|
|
621
|
+
);
|
|
622
|
+
await query(
|
|
623
|
+
"UPDATE subscription_plan_changes SET status = 'cancelled', cancelled_at = ?1, " +
|
|
624
|
+
"cancel_reason = ?2 WHERE id = ?3",
|
|
625
|
+
[_now(), "credit-settlement-failed", id],
|
|
626
|
+
);
|
|
627
|
+
throw e;
|
|
472
628
|
}
|
|
473
|
-
await _recordProrationInvoice({
|
|
474
|
-
subscriptionId: subscriptionId,
|
|
475
|
-
chargeMinor: chargeMinor,
|
|
476
|
-
currency: proposed.currency,
|
|
477
|
-
periodStart: proposed.effective_at,
|
|
478
|
-
periodEnd: sub.current_period_end,
|
|
479
|
-
planChangeId: id,
|
|
480
|
-
});
|
|
481
629
|
}
|
|
630
|
+
await _recordProrationInvoice({
|
|
631
|
+
subscriptionId: subscriptionId,
|
|
632
|
+
chargeMinor: chargeMinor,
|
|
633
|
+
currency: proposed.currency,
|
|
634
|
+
periodStart: proposed.effective_at,
|
|
635
|
+
periodEnd: sub.current_period_end,
|
|
636
|
+
planChangeId: id,
|
|
637
|
+
});
|
|
482
638
|
}
|
|
483
639
|
|
|
484
640
|
return await _getById(id);
|
|
@@ -558,20 +714,34 @@ function create(opts) {
|
|
|
558
714
|
var row = due.rows[i];
|
|
559
715
|
var chargeMinor = _max0(row.first_charge_minor - row.proration_credit_minor);
|
|
560
716
|
var creditMinor = _max0(row.proration_credit_minor - row.first_charge_minor);
|
|
561
|
-
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
|
|
717
|
+
|
|
718
|
+
// Read the subscription up front to decide the settlement model.
|
|
719
|
+
// A Stripe-backed row lets Stripe own the proration (the new price
|
|
720
|
+
// applies cleanly from the next invoice — next-cycle semantics), so
|
|
721
|
+
// the local store-credit / invoice path is skipped for it entirely,
|
|
722
|
+
// INCLUDING the credit-vehicle skip guard below (that guard only
|
|
723
|
+
// gates the local store-credit path; Stripe carries a downgrade
|
|
724
|
+
// balance forward without one).
|
|
725
|
+
var dueSub = await _getSubscription(row.subscription_id);
|
|
726
|
+
var rowStripeBacked = _isStripeBacked(dueSub);
|
|
727
|
+
|
|
728
|
+
if (!rowStripeBacked) {
|
|
729
|
+
// Fail closed: a due downgrade owes the customer a credit. If no
|
|
730
|
+
// store-credit vehicle is wired, leave the row PENDING (skip
|
|
731
|
+
// before claiming) so it applies on a later sweep once the handle
|
|
732
|
+
// exists — never transition it and drop the credit.
|
|
733
|
+
if (creditMinor > 0 && !storeCreditHandle) continue;
|
|
734
|
+
}
|
|
566
735
|
|
|
567
736
|
// Atomic claim: flip pending -> executed gated on the status.
|
|
568
737
|
// Two overlapping scheduler runs (cron overrun, retry, two
|
|
569
738
|
// workers) both SELECT the same due row; WITHOUT this guard both
|
|
570
739
|
// would update the subscription plan AND record a proration
|
|
571
|
-
// invoice —
|
|
572
|
-
// caller whose UPDATE matches one row proceeds;
|
|
573
|
-
// (The synchronous executeChange path is already
|
|
574
|
-
// plan_id claim; this is the batch path's
|
|
740
|
+
// invoice / push to Stripe — settling the customer twice for one
|
|
741
|
+
// change. Only the caller whose UPDATE matches one row proceeds;
|
|
742
|
+
// the loser skips. (The synchronous executeChange path is already
|
|
743
|
+
// protected by its plan_id claim; this is the batch path's
|
|
744
|
+
// equivalent.) Both settlement models claim the same way.
|
|
575
745
|
var claim = await query(
|
|
576
746
|
"UPDATE subscription_plan_changes SET status = 'executed', executed_at = ?1 " +
|
|
577
747
|
"WHERE id = ?2 AND status = 'pending'",
|
|
@@ -579,6 +749,52 @@ function create(opts) {
|
|
|
579
749
|
);
|
|
580
750
|
if (!b.sql.casWon(claim).won) continue; // another run claimed it
|
|
581
751
|
|
|
752
|
+
if (rowStripeBacked) {
|
|
753
|
+
// STRIPE-FIRST: swap the price at Stripe with proration_behavior
|
|
754
|
+
// "none" — the new price applies from the next invoice cleanly
|
|
755
|
+
// (next-cycle semantics), no mid-cycle adjustment. On any Stripe
|
|
756
|
+
// failure, roll the claim back to 'pending' (guarded on the
|
|
757
|
+
// just-claimed state) so a later sweep retries it; the local
|
|
758
|
+
// plan_id was NOT touched yet, so there's nothing else to undo.
|
|
759
|
+
// One bad row must not abort the run.
|
|
760
|
+
var toPlan = await _getPlan(row.to_plan_id);
|
|
761
|
+
if (!toPlan || !toPlan.stripe_price_id) {
|
|
762
|
+
await query(
|
|
763
|
+
"UPDATE subscription_plan_changes SET status = 'pending', executed_at = NULL " +
|
|
764
|
+
"WHERE id = ?1 AND status = 'executed'",
|
|
765
|
+
[row.id],
|
|
766
|
+
);
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
try {
|
|
770
|
+
await _pushPlanToStripe(dueSub.stripe_subscription_id, toPlan.stripe_price_id, "none", row.id);
|
|
771
|
+
} catch (e) {
|
|
772
|
+
// Observe the cause (don't discard it), then roll the claim back
|
|
773
|
+
// to 'pending' so a later sweep retries this row; the local
|
|
774
|
+
// plan_id was not touched. One bad row never aborts the run.
|
|
775
|
+
_log.error("planChanges: scheduled Stripe plan swap failed", {
|
|
776
|
+
plan_change_id: row.id,
|
|
777
|
+
subscription_id: row.subscription_id,
|
|
778
|
+
err: (e && e.message) || String(e),
|
|
779
|
+
});
|
|
780
|
+
await query(
|
|
781
|
+
"UPDATE subscription_plan_changes SET status = 'pending', executed_at = NULL " +
|
|
782
|
+
"WHERE id = ?1 AND status = 'executed'",
|
|
783
|
+
[row.id],
|
|
784
|
+
);
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
// Stripe accepted — land the local plan_id transition. No local
|
|
788
|
+
// credit / invoice for a Stripe-backed row: Stripe owns the money.
|
|
789
|
+
await query(
|
|
790
|
+
"UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3",
|
|
791
|
+
[row.to_plan_id, now, row.subscription_id],
|
|
792
|
+
);
|
|
793
|
+
executed.push(await _getById(row.id));
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// NON-STRIPE — today's local settlement path, unchanged.
|
|
582
798
|
await query(
|
|
583
799
|
"UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3",
|
|
584
800
|
[row.to_plan_id, now, row.subscription_id],
|
|
@@ -593,12 +809,18 @@ function create(opts) {
|
|
|
593
809
|
creditMinor: creditMinor,
|
|
594
810
|
planChangeId: row.id,
|
|
595
811
|
});
|
|
596
|
-
} catch (
|
|
597
|
-
//
|
|
598
|
-
// the customer's money, so a transient write
|
|
599
|
-
// finalize this downgrade. Roll the plan back and
|
|
600
|
-
// to 'pending' (guarded on the just-applied state)
|
|
601
|
-
// sweep retries it cleanly; one bad row must not abort
|
|
812
|
+
} catch (e) {
|
|
813
|
+
// Observe the cause, then revert-on-throw and carry on with the
|
|
814
|
+
// batch: the credit is the customer's money, so a transient write
|
|
815
|
+
// failure must not finalize this downgrade. Roll the plan back and
|
|
816
|
+
// return the row to 'pending' (guarded on the just-applied state)
|
|
817
|
+
// so a later sweep retries it cleanly; one bad row must not abort
|
|
818
|
+
// the run.
|
|
819
|
+
_log.error("planChanges: scheduled downgrade credit failed", {
|
|
820
|
+
plan_change_id: row.id,
|
|
821
|
+
subscription_id: row.subscription_id,
|
|
822
|
+
err: (e && e.message) || String(e),
|
|
823
|
+
});
|
|
602
824
|
await query(
|
|
603
825
|
"UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3 AND plan_id = ?4",
|
|
604
826
|
[row.from_plan_id, now, row.subscription_id, row.to_plan_id],
|