Keep plan pressed just after a downgrade takes effect answers nothing and records the downgrade as withdrawn #275

Open
opened 2026-10-26 20:58:24 +00:00 by cgalo5758 · 0 comments
Owner

What happens

A member on Pro schedules a downgrade to Basic. Just after the renewal, they press Keep plan on the Products page. The page comes back with Basic as the current plan and no message. The console's records then say the member withdrew the downgrade, although Stripe carried it out.

A downgrade waits for the renewal. downgradeAtRenewal (internal/fulfillment/plan_change.go:378) attaches a Stripe subscription schedule whose second phase starts the lower price at the period end (lines 417-432). It records the move as a scheduled row in core.subscription_scheduled_changes, the console's record of each plan move that waits for a date. Keep plan (KeepCurrentPlan, plan_change.go:469) withdraws a scheduled downgrade or cancellation. It releases the schedule, marks the row superseded and reconciles. The reconcile (ReconcileSubscription, internal/fulfillment/reconcile.go) reads the subscription from Stripe and brings the console's records in line with it. It runs after each Stripe subscription event and from the hourly billing sweep. On an active subscription it marks the row of a downgrade that has landed applied (applyLandedDowngrades, reconcile.go:1127).

At the renewal, Stripe enters the schedule's second phase. The subscription now carries Basic's price, and the renewal invoice bills Basic. Until the reconcile runs on that event, the console still reads Pro with the downgrade scheduled. The plan's card still shows "moves to Basic on {date}" and Keep plan. scheduledChanges (internal/server/plan_offer.go:175) drops both once the date has passed, but only for a suspended plan, meaning one whose subscription Stripe holds as past_due, unpaid or paused.

When Keep plan is pressed in that interval, it reads the live subscription from Stripe (plan_change.go:489). It checks whether the downgrade has already landed only when the plan is suspended (lines 493-498). On an active plan it goes on as if the downgrade were still waiting:

  • It releases the schedule (lines 499-503). Releasing a schedule in its second phase leaves the subscription on its current price. Stripe keeps Basic, and the member pays Basic at this renewal and after. Without the press, Stripe would have ended in the same state, because the reconcile releases a spent schedule itself (releaseSpentSchedule, reconcile.go:1213).
  • It marks the downgrade's row superseded (line 509), so the record shows a downgrade the member withdrew, not one Stripe applied.
  • Its own reconcile (line 512) is the first to see the renewal. That reconcile moves the organization to Basic, and the provision changes carry Keep plan's reason, member:keep (reconcile.go:107), instead of the Stripe event's reason.
  • It answers with no banner (internal/server/member_plan_moves.go:363-365), so the card just turns into Basic's.

A suspended plan in the same state is refused with "That plan change has already taken effect.", and nothing changes. Tests cover that case at internal/fulfillment/suspended_subscription_test.go:211 and :241. No test covers an active plan. The check was written for suspended plans because their reconcile leaves scheduled rows alone (reconcile.go:123-126). On an active plan, a reconcile closes the row first (lines 114-122), and after that Keep plan finds nothing scheduled and changes nothing (plan_change.go:485-487). So the gap lasts from the renewal until its first reconcile. That is minutes when Stripe's event arrives, and up to the next hourly sweep when it does not.

What should happen

Keep plan on any plan, active or suspended, refuses a scheduled downgrade that Stripe has already applied. It answers "That plan change has already taken effect." and releases and supersedes nothing. The reconcile then marks the row applied, as openspec/specs/stripe-subscription-management/spec.md:404 requires. Once the downgrade's date has passed, the card stops showing the "moves to" line and Keep plan for an active plan as it does for a suspended one. The check at plan_change.go:493-498 already reads the live subscription, so running it for every plan may be the whole fix on the act's side.

Where

internal/fulfillment/plan_change.go:493 (KeepCurrentPlan), internal/server/plan_offer.go:175 (scheduledChanges)

Steps

  1. A member on Pro schedules a downgrade to Basic. The card reads "moves to Basic on" the renewal date and offers Keep plan.
  2. The renewal passes before the console has processed Stripe's renewal event, and the page is open.
  3. The member presses Keep plan.
  4. The card becomes Basic's, with no message. Stripe bills Basic, and the downgrade's row reads superseded.

A test can build this state with the mock Stripe backend in internal/stripetest, the way TestKeepRefusesADowngradeThatLanded does, without the step that holds the subscription as past due and reconciles it.

Why it matters

A member who pressed Keep plan to stay on Pro is moved to Basic with no word. The console's records say the member withdrew a downgrade that Stripe carried out, and they credit the move to the member's press.

Related: #177 asks who owns each kind of scheduled-change row and what its states mean.

### What happens A member on Pro schedules a downgrade to Basic. Just after the renewal, they press **Keep plan** on the Products page. The page comes back with Basic as the current plan and no message. The console's records then say the member withdrew the downgrade, although Stripe carried it out. A downgrade waits for the renewal. `downgradeAtRenewal` (`internal/fulfillment/plan_change.go:378`) attaches a Stripe subscription schedule whose second phase starts the lower price at the period end (lines 417-432). It records the move as a `scheduled` row in `core.subscription_scheduled_changes`, the console's record of each plan move that waits for a date. Keep plan (`KeepCurrentPlan`, `plan_change.go:469`) withdraws a scheduled downgrade or cancellation. It releases the schedule, marks the row `superseded` and reconciles. The reconcile (`ReconcileSubscription`, `internal/fulfillment/reconcile.go`) reads the subscription from Stripe and brings the console's records in line with it. It runs after each Stripe subscription event and from the hourly billing sweep. On an active subscription it marks the row of a downgrade that has landed `applied` (`applyLandedDowngrades`, `reconcile.go:1127`). At the renewal, Stripe enters the schedule's second phase. The subscription now carries Basic's price, and the renewal invoice bills Basic. Until the reconcile runs on that event, the console still reads Pro with the downgrade scheduled. The plan's card still shows "moves to Basic on {date}" and Keep plan. `scheduledChanges` (`internal/server/plan_offer.go:175`) drops both once the date has passed, but only for a suspended plan, meaning one whose subscription Stripe holds as `past_due`, `unpaid` or `paused`. When Keep plan is pressed in that interval, it reads the live subscription from Stripe (`plan_change.go:489`). It checks whether the downgrade has already landed only when the plan is suspended (lines 493-498). On an active plan it goes on as if the downgrade were still waiting: - It releases the schedule (lines 499-503). Releasing a schedule in its second phase leaves the subscription on its current price. Stripe keeps Basic, and the member pays Basic at this renewal and after. Without the press, Stripe would have ended in the same state, because the reconcile releases a spent schedule itself (`releaseSpentSchedule`, `reconcile.go:1213`). - It marks the downgrade's row `superseded` (line 509), so the record shows a downgrade the member withdrew, not one Stripe applied. - Its own reconcile (line 512) is the first to see the renewal. That reconcile moves the organization to Basic, and the provision changes carry Keep plan's reason, `member:keep` (`reconcile.go:107`), instead of the Stripe event's reason. - It answers with no banner (`internal/server/member_plan_moves.go:363-365`), so the card just turns into Basic's. A suspended plan in the same state is refused with "That plan change has already taken effect.", and nothing changes. Tests cover that case at `internal/fulfillment/suspended_subscription_test.go:211` and `:241`. No test covers an active plan. The check was written for suspended plans because their reconcile leaves scheduled rows alone (`reconcile.go:123-126`). On an active plan, a reconcile closes the row first (lines 114-122), and after that Keep plan finds nothing scheduled and changes nothing (`plan_change.go:485-487`). So the gap lasts from the renewal until its first reconcile. That is minutes when Stripe's event arrives, and up to the next hourly sweep when it does not. ### What should happen Keep plan on any plan, active or suspended, refuses a scheduled downgrade that Stripe has already applied. It answers "That plan change has already taken effect." and releases and supersedes nothing. The reconcile then marks the row `applied`, as `openspec/specs/stripe-subscription-management/spec.md:404` requires. Once the downgrade's date has passed, the card stops showing the "moves to" line and Keep plan for an active plan as it does for a suspended one. The check at `plan_change.go:493-498` already reads the live subscription, so running it for every plan may be the whole fix on the act's side. ### Where `internal/fulfillment/plan_change.go:493` (`KeepCurrentPlan`), `internal/server/plan_offer.go:175` (`scheduledChanges`) ### Steps 1. A member on Pro schedules a downgrade to Basic. The card reads "moves to Basic on" the renewal date and offers Keep plan. 2. The renewal passes before the console has processed Stripe's renewal event, and the page is open. 3. The member presses Keep plan. 4. The card becomes Basic's, with no message. Stripe bills Basic, and the downgrade's row reads `superseded`. A test can build this state with the mock Stripe backend in `internal/stripetest`, the way `TestKeepRefusesADowngradeThatLanded` does, without the step that holds the subscription as past due and reconciles it. ### Why it matters A member who pressed Keep plan to stay on Pro is moved to Basic with no word. The console's records say the member withdrew a downgrade that Stripe carried out, and they credit the move to the member's press. Related: #177 asks who owns each kind of scheduled-change row and what its states mean.
cgalo5758 added the
kind
bug
area/billingarea/member-ui
labels 2026-10-26 20:58:24 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wiki-cafe/member-console#275