@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 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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.5.3",
2
+ "version": "0.5.5",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
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 (_e) {
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 (_e) {
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 (_e) {
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 (_e) {
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
  }
@@ -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
- // Fail closed: a downgrade taking effect now owes the customer a
413
- // credit, and the store-credit ledger is the only vehicle for it.
414
- // Refuse before any row is written rather than apply the transition
415
- // and silently drop the credit. (A future-dated downgrade is left
416
- // pending; applyScheduledChanges re-checks at execution time.)
417
- if (status === "executed") _assertCreditable(creditMinor);
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
- if (status === "executed") {
433
- // Atomic transition claim. The plan update is conditional on the
434
- // CURRENT plan id, so two concurrent executeChange calls for the same
435
- // immediate change can't both apply it — only the call that observes
436
- // the pre-change plan id transitions the row (rowCount 1); a racing
437
- // second call sees rowCount 0. The _pendingFor guard above only blocks
438
- // PENDING changes, so without this an immediate change executed twice
439
- // (a double-submit or retry) would record the proration invoice twice
440
- // and double-charge. The proration below is gated on winning the claim.
441
- var planUpd = await query(
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
- var wonTransition = Number((planUpd && planUpd.rowCount) || 0) > 0;
446
- if (wonTransition) {
447
- if (creditMinor > 0) {
448
- try {
449
- await _issueDowngradeCredit({
450
- customerId: sub.customer_id,
451
- creditMinor: creditMinor,
452
- planChangeId: id,
453
- });
454
- } catch (e) {
455
- // Revert-on-throw: the credit is the customer's money, so a
456
- // transient write failure must not finalize a downgrade it
457
- // was never paid for. Roll the subscription back to the
458
- // outgoing plan (guarded on the just-applied plan id so a
459
- // concurrent change isn't clobbered) and void this change,
460
- // then rethrow so the caller retries from a clean state.
461
- await query(
462
- "UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3 AND plan_id = ?4",
463
- [sub.plan_id, _now(), subscriptionId, newPlanId],
464
- );
465
- await query(
466
- "UPDATE subscription_plan_changes SET status = 'cancelled', cancelled_at = ?1, " +
467
- "cancel_reason = ?2 WHERE id = ?3",
468
- [_now(), "credit-settlement-failed", id],
469
- );
470
- throw e;
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
- // Fail closed: a due downgrade owes the customer a credit. If no
562
- // store-credit vehicle is wired, leave the row PENDING (skip
563
- // before claiming) so it applies on a later sweep once the handle
564
- // exists never transition it and drop the credit.
565
- if (creditMinor > 0 && !storeCreditHandle) continue;
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 — billing the customer twice for one change. Only the
572
- // caller whose UPDATE matches one row proceeds; the loser skips.
573
- // (The synchronous executeChange path is already protected by its
574
- // plan_id claim; this is the batch path's equivalent.)
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 (_e) {
597
- // Revert-on-throw, then carry on with the batch: the credit is
598
- // the customer's money, so a transient write failure must not
599
- // finalize this downgrade. Roll the plan back and return the row
600
- // to 'pending' (guarded on the just-applied state) so a later
601
- // sweep retries it cleanly; one bad row must not abort the run.
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],