Files
cgalo5758 23e5689df8 Let a member switch a plan paused under a grant to the granted tier or above
Eleventh section of #269's change, and #278. A member whose paid plan is
paused while an operator's grant holds its ladder can now switch that
plan to the granted tier, or to a tier above it, from that tier's card.
The switch supersedes the grant, so the subscription takes the ladder;
a grant marked to resume delivers again when the subscription ends.

Stripe refuses an always_invoice update on a paused subscription and
refuses clearing the pause beside pending_if_incomplete, which the
approval path needs, so the paused form is two calls: the pause is
cleared in a call of its own, then today's upgrade is sent unchanged.
Before the clear, the switch writes a reconcile_subscription outbox
entry due four minutes later, so a process that stops between the calls
still has the subscription paused again; the outbox relay now takes an
entry only once its next_attempt_at has passed (EnqueueAt,
RequestSubscriptionReconcileAt), and every other writer leaves it due at
once. A guard refuses the switch with "Try again in a moment." while a
paused renewal draft is due to finalize within ten minutes, reading the
later of next_payment_attempt and automatically_finalizes_at. When the
update does not apply (a decline, an approval, a Stripe error from
either call, a deadline, a closed request), a deferred restore runs the
subscription's reconcile under context.WithoutCancel and pauses it
again; the draft list, the voids and the clear carry 20 seconds, the
charge 60, and the restore 60, through the reconcile's context (#285).
An abandoned upgrade's open invoice is voided before the clear, and
Cancel plan voids it too on a subscription that pays without holding
its ladder.

The offer: rule 3a item 2 offers the switch on the granted tier's
current card and on each tier above it, and refuses it when the paying
product or the target is a tier of another ladder, as Resume plan
does, because the supersession ends the whole grant. A target priced in
another currency is never offered. Stripe answers no preview for a
paused subscription, so the confirmation reads "Your subscription
replaces the grant." with the fallback charge sentence, and toward a
price of another interval "Your card is charged <price> now, minus a
credit for the unused part of your current plan, then <then> from
today." The server's 8-second write timeout can lose a slow switch's
answer while its state still converges; that is #290.

docs/stripe.md, docs/database-locks.md and
docs/models/provider-integration.md say the same. The change text also
carries section 12's granted-card ride-along, built there.

Tests: TestSwitchAPausedSubscription_* (seventeen: the two calls, the
granted tier, a decline and an approval paused again, every other exit
restoring, a closed request, a holding subscription, a stale marker, a
foreign pause, an ordinary upgrade, the guard, the backstop before the
clear, every call's deadline, the abandoned upgrade voided first, a
failed restore, a marked grant resuming), TestPreviewSwitchOfAPausedSubscription,
TestCancelAPausedSubscriptionVoidsAnAbandonedSwitch,
TestCancelAHoldingSubscriptionVoidsNothing,
TestOutboxPollSkipsAnEntryNotYetDue, the server's paused-switch post and
preview tests, and new TestOffer, TestPlanMoveMessageStatesTheTiming
and TestPayingByLadder rows. The golden
paid-retired-plus-under-granted-pro gains the switch; the five
paid-plus-under-granted-pro and granted-plus-own-tier states pin its
refusal for a tier of two ladders and are unchanged.

Deviations: the past-due and paused post rows set Stripe's status on
the mock and reconcile it, since the kit's Suspend and Subscribe build
no Stripe subscription for the mock; the break "the replacement not
resumed when S ends" breaks the resumption's read in
grant_resumption.go, because no Go write records a replacement
(core.grant_replacements is a view).

Sandbox 11.1 ran before the code: pending_if_incomplete refuses
pause_collection, a paused subscription answers no proration preview,
and the two-call form works, approval included; every non-applied
outcome ended paused with nothing charged.

Full suite on reset databases: 5018 passed, 14 skipped, none failed.
make screens: no member page changed; the known flakes on the plan
ladder and domains pages.

Breaks, all CAUGHT (75):
- the clear not sent
- the clear folded into call 2
- call 2's form changed
- the restore not run after a decline
- the restore not run after an approval
- the restore run only in the two pending branches, in place of the deferred step
- the deferred step set up after call 1 answers, in place of before it
- the restore called before each return, in place of a deferred step
- WithoutCancel dropped from restorePause's context
- WithoutCancel dropped from stripeCallContext
- the applied branch run under the request's context
- the restore made a blind set
- the scope widened to a stale marker
- the scope widened to a foreign pause
- the scope narrowed to a subscription that does not hold its ladder
- the paused form taken with no pause
- the guard not called
- the passed time not refused
- the earlier of the two times taken
- automatically_finalizes_at not read
- next_payment_attempt not read
- the billing-reason test dropped
- the auto-advance test dropped
- a failed list let through
- a draft with both times null let through
- the backstop written after the clear
- the backstop's delay dropped
- EnqueueAt leaving the column's default
- a failed backstop insert ignored
- the poll's next_attempt_at filter dropped
- call 2 sent with no context
- call 2 given pausedSwitchCallTimeout
- the clear sent with no context
- the draft list sent with no context
- the restore given no deadline
- the proration moment's check dropped
- the preview calling Stripe for a paused subscription
- the preview keyed on the console's pause alone
- Cancel's void dropped
- a refused void let through to the cancellation
- Cancel's void placed after the commitment's deferral
- Cancel's void widened to a holding subscription
- the replacement not resumed when S ends
- rule 3a applied after rule 3
- ReplacesGrant not set by rule 3a item 2
- the paid-tier rank test dropped
- the suspension test dropped
- replacesGrant ignored
- section 8's exception removed for the switch's product
- a paying refusal left to !pos.Subscribed (the not-suspended mapping deleted)
- the suspended mapping deleted from switchOffer
- the Paying.Paused test dropped from switchOffer
- the !pos.Subscribed test dropped from the mappings' condition
- the currency test dropped
- the currency test applied ahead of the price rule
- NewPeriod not set
- NewPeriod set on a disabled move
- NewPeriod set whatever the interval
- payingFrom leaving Currency and Interval unset
- postedPrice leaving Currency unset
- defaultPrice leaving Currency unset
- planMoveMessage ignoring newPeriodCharge
- M12 chosen only when the preview has no figure
- renderSwitchPreview passing no newPeriodCharge
- renderSwitchPreview passing no ReplacesGrant
- step 4 dropped from the paused form
- the abandoned void sent with no context
- step 4 moved after the clear
- the failed restore not logged (L7)
- the failed restore logged without stripe_subscription_id (L7)
- the restore's failure changing the member's answer (F64)
- a bundle's subscription offered the switch (rule 3a item 2 reading OtherLadders)
- the server preview calling Stripe for a paused subscription
- a bundle target offered the switch (rule 3a item 2 reading Target.OtherLadders)
- a bundle target posted from a paused subscription
2026-10-29 16:24:33 -05:00
..