fix(subscription): apply NEXT_PERIOD plan changes when the period opens
What
changePlan with effective = 'NEXT_PERIOD' answered settlement: 'SCHEDULED' and wrote metadata.scheduledPlanChange = { planId, effectiveAt, description }. Nothing ever read that key: git grep on develop finds its one writer in applyChange and no reader. So the subscription kept renewing on the plan it was leaving, and the customer was told the change was scheduled. The same happened to a change of lines on a subscription with no current period.
How
When the change takes effect. executeCycle now calls applyScheduledChange before it prices the period, for the cycle that opens the period the change was scheduled for (effectiveAt ≤ the period's start). The cycle holds that period's billing claim, so one pass applies it. It runs only after the settled-row and dunning-schedule guards, so a period already billed is never re-priced.
What it does:
- A plan change is checked again against the plan as it stands then, as
changePlanchecks it (assertSubscribableplus same currency). - Its lines are priced from the new plan over the lines the subscription has at that point. This is the same computation
changePlanuses, now shared asplanLines. - The stated quantity is applied, and the discount is restated to the new plan's.
- The period opens on the new plan's cadence. A row an earlier attempt wrote for that period moves its end with the change (
SubscriptionBillingService.recutUnsettled, predicated on the row being PENDING or FAILED). This is the retry of a failed charge: the subscription staysACTIVEthrough dunning, so a change can be scheduled after the first attempt. The order, the row and the calendar then describe one period on every retry. - It is one transaction, with the claim first. The plan, quantity, discount and cleared key are one write predicated on the version the change was read at. The lines follow on the same transaction (
SubscriptionItemService.writePreparedItems). A change made in the meantime (an immediate plan change, a change recorded again) makes the claim match nothing before any line is written, and the change is read again rather than overwritten. If that write took the change away, the cycle stops (SUBSCRIPTION_CHANGED_DURING_CYCLE) rather than charge what it read before it, and the next pass bills the subscription as it now stands. - The plan the change moves to is read again under its share lock, as
createSubscriptionreads it, and must still be the plan the change was priced on. A plan deactivated, re-priced in another currency or given other terms in that window has the change read again, which refuses it or prices it on the plan as it stands. - A retry restates the unsettled row's amount, gross and discount to what it charges (
repriceUnsettled), so the row, the outcome andsubscription.renewedagree with the order. A row settled in the meantime stops the retry (SUBSCRIPTION_BILLING_CYCLE_SETTLED) before anything is charged. The cycle's discount rows stay append-only and add up to the discount the order carries. A larger discount on a retry adds a furtherPROMOTION. A smaller one, or none, is a governedMANUALcorrection under the service'sSUBSCRIPTION_DISCOUNT_RESTATEDreason, because aPROMOTIONcan only be negative. - What
applyChangeschedules is HMAC-signed (subscription.schedule-signature.ts). The key is derived fromENCRYPTION_KEYor a non-defaultJWT_SECRET, as the account-deletion digests derive theirs, and the signature is bound to the subscription and stable when the database reorders JSON keys. Before the key was reserved a caller's metadata could write it, so an unsigned entry has its lines and quantity ignored, and an entry whose signature is present but doesn't verify is refused. A legacy plan entry, the only shape the service wrote before this change, still takes hold, re-checked and priced on the plan's own terms. Signed lines are also re-checked throughprepareItems. - With no current period, a change of lines builds on the lines a change of lines already recorded, so edits made before the first cycle compose.
Refusal. A plan that may no longer be taken up (deactivated, gone, its target or a line no longer sellable) or that is now priced in another currency refuses the change. The key is cleared and subscription.scheduled-change-refused is appended to the outbox (and streamed), on one transaction, and a warning is logged. The cycle goes on with the subscription as the refusal left it, so a later metadata write in the same cycle (clearPendingCredit, rememberSetupFee) can't put the change back. The period then opens on the current plan. Any other error, such as a database failure, fails the cycle (which is retried) and keeps the change.
Preview. previewNextBilling prices what the change makes of the subscription, writing nothing, so the preview and the bill still come from the same function.
What else changes in applyChange:
- A scheduled change no longer restates the discount right away. Restated at scheduling time, a cycle still billed on the old plan (for example an unbilled opening period) took the new plan's discount.
- The scheduled record keeps the stated quantity (it was dropped). A change of lines alone keeps its priced lines.
- A plan change made now clears an earlier scheduled one. The latest decision about the plan holds, so an upgrade isn't undone at renewal by a downgrade scheduled before it.
SEC-B-03. scheduledPlanChange is now a reserved metadata key. Once the key is acted on, a caller's metadata: { scheduledPlanChange: { planId } } would move the subscription to another plan at renewal with no plan change and no permission check.
Tests
New block "a change from the next period takes hold when that period opens" in subscription.service.spec.ts:
- schedule → preview → renewal on the new plan, at the new lines, quantity, discount and yearly cadence; preview equals bill;
- the period the change was scheduled in is billed on the old plan, and the change takes hold at the next one;
- refused when the plan was deactivated or re-priced in another currency (event + cleared key + old plan);
- a database error fails the cycle and keeps the change;
- an immediate plan change replaces a scheduled one;
- a change of lines with no current period is applied at the first cycle;
- the key can't be planted or erased through
metadata; - review round 1: a refused change stays cleared when the cycle then consumes a deferred credit; no line is written when a change made meanwhile wins the claim; a change that takes hold at the retry of a failed attempt moves that row onto the new cadence (fail, schedule, fail, pay). Each of these fails on the first commit;
- review round 2: the lost claim stops the cycle and the next pass charges the winning change's lines (60); a plan deactivated between the read and its lock refuses the change; a paid retry records the 300 its order charged. Each of these fails on the round-1 commit;
- review round 3: a row settled while the retry priced it is not charged; promotions are reversed and re-granted on a retry under another discount; an unstamped lines entry is refused and an unstamped plan entry applies without its quantity; two line edits before the first cycle compose. Each of these fails on the round-2 commit.
- review round 4: a larger discount on a retry tops the promotion up and a smaller one is a MANUAL correction, now under the ledger's real sign and reason rules; an entry with the old stamp, or signed without the key, is refused; signed lines apply; and a signature spec covers reordering, edits, copying and key rotation.
- review round 5: a plan change whose signature is present but does not verify is refused (and told) instead of applied without its quantity; a promotion with another reference is left out of the plan discount a retry restates. Each of these fails on the round-4 commit.
Results: the whole plugin passes 411/411 locally (Node 26). Two resolver suites (write-routes, upcoming-billing) don't load locally only because this PC's linked node_modules is stale (an OpenTelemetry type clash in core/bootstrap/tracer.ts). CI runs them. cspell is clean.
Known limits (unchanged, follow-ups)
- An immediate change (
applyChange) still writes its lines before its own version-predicated write. This is develop's behaviour; this PR doesn't touch that path. - Nothing fences an operator's settlement (pay or waive) against a cycle charge that is in flight. This PR refuses a retry whose row was settled before the reprice, but a real fence needs a claim column on
subscription_billing, a migration and changes to the settlement entrypoints. This is develop's behaviour:raiseCycleOrderdetects such a settlement after the fact and keeps the operator's status. - With no current period, a scheduled plan change and a scheduled change of lines still replace each other's pending entry.
- The plan's cycle ceiling (
maxBillingCycles) is checked against the plan the subscription is on before the cycle runs. A subscription that has used its plan's last cycle therefore ends, even when a change to another plan was scheduled for that boundary.
🤖 Generated with Claude Code