A member's downgrade is lost when a reconcile releases the old schedule the downgrade has just reused #219

Open
opened 2026-10-12 02:10:30 +00:00 by cgalo5758 · 0 comments
Owner

What happens

A downgrade scheduled while a reconcile of the same subscription is finishing can be dropped at Stripe. The member is told the change is scheduled, and the plan reads "moves to Basic on" the renewal date. Shortly after, that line is gone, and the subscription renews on the higher tier.

A downgrade waits for the renewal. downgradeAtRenewal (internal/fulfillment/plan_change.go:339) attaches a Stripe subscription schedule with two phases, the current price until the period ends and the lower price after it, and writes the console price it was made from into the schedule's metadata (scheduled_from_price_id). When the subscription already has a schedule attached, it does not make a new one: it reads the attached schedule (line 355) and rewrites its phases and metadata (lines 396-397).

The reconcile, ReconcileSubscription in internal/fulfillment/reconcile.go, brings the console's records in line with Stripe after each subscription event and from the billing sweep. It releases a schedule the console attached once the subscription no longer carries the price in the schedule's metadata, because that downgrade landed or the member moved since (findSpentSchedule, line 892). It reads the subscription and its schedule once, at the start (line 66), decides from that copy, commits, and then releases the schedule by id (line 926). It does not read the schedule again before the release. The release runs after the commit, outside the subscription's lock, and a downgrade takes no lock.

So a downgrade that rewrites the attached schedule after the reconcile's read and before its release is released with it. Stripe detaches the schedule, and the lower price never starts. The downgrade's row in core.subscription_scheduled_changes is then superseded: by the same reconcile if it had already listed the row, or by the next one, which finds no schedule attached.

The window opens only while a spent schedule is still attached: after an earlier downgrade lands at renewal, until a reconcile releases its schedule; and after an upgrade whose own release of the schedule failed, which plan_change.go:315-324 leaves to the reconcile. It lasts one reconcile, from its read of the subscription to its release call.

What should happen

A downgrade the console confirmed stays scheduled at Stripe until it lands or the member changes it, and a reconcile releases only a schedule that holds nothing new.

  • Never give an attached schedule new work. downgradeAtRenewal releases the attached schedule, taking one that is already released as done, and then creates a new one from the subscription, the way the cancellation releases the attached schedule before it sets its own change (line 585). Stripe makes a schedule from a subscription only when none is attached, so the release comes first. A reconcile holding the old id can then release only a schedule that is spent. It costs one more Stripe call per downgrade, and changing the target of a pending downgrade replaces its schedule instead of rewriting it.
  • Read the schedule again just before the release, and release it only if its metadata still names a price the subscription no longer carries. This narrows the window to the time between two Stripe calls; it does not close it.

The first closes the race and is the one to take. A test can show it with the mock Stripe backend in internal/stripetest: its Handle hook runs the downgrade when the reconcile's release call arrives, and the test checks that Stripe still holds the downgrade and its row stays scheduled. The mock answers every schedule call with one id today, so it needs a second id for the new schedule.

Where

internal/fulfillment/plan_change.go (downgradeAtRenewal); internal/fulfillment/reconcile.go (ReconcileSubscription, findSpentSchedule, releaseSpentSchedule).

Steps

  1. A member on Pro scheduled a downgrade to Standard, and it landed at renewal. The subscription is on Standard; the console's schedule is still attached, with Pro in its metadata.
  2. Stripe's renewal events start a reconcile. It reads the subscription, finds the schedule spent and commits.
  3. Before it sends the release, the member downgrades from Standard to Basic. The console rewrites the attached schedule, confirms the change, and the plan reads "moves to Basic on" the next renewal date.
  4. The reconcile releases the schedule. The scheduled move disappears from the plan, and the subscription renews on Standard.

Why it matters

The member is told a downgrade is scheduled, nothing tells them it was dropped, and they pay the higher tier for another period.

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

## What happens A downgrade scheduled while a reconcile of the same subscription is finishing can be dropped at Stripe. The member is told the change is scheduled, and the plan reads "moves to Basic on" the renewal date. Shortly after, that line is gone, and the subscription renews on the higher tier. A downgrade waits for the renewal. `downgradeAtRenewal` (`internal/fulfillment/plan_change.go:339`) attaches a Stripe subscription schedule with two phases, the current price until the period ends and the lower price after it, and writes the console price it was made from into the schedule's metadata (`scheduled_from_price_id`). When the subscription already has a schedule attached, it does not make a new one: it reads the attached schedule (line 355) and rewrites its phases and metadata (lines 396-397). The reconcile, `ReconcileSubscription` in `internal/fulfillment/reconcile.go`, brings the console's records in line with Stripe after each subscription event and from the billing sweep. It releases a schedule the console attached once the subscription no longer carries the price in the schedule's metadata, because that downgrade landed or the member moved since (`findSpentSchedule`, line 892). It reads the subscription and its schedule once, at the start (line 66), decides from that copy, commits, and then releases the schedule by id (line 926). It does not read the schedule again before the release. The release runs after the commit, outside the subscription's lock, and a downgrade takes no lock. So a downgrade that rewrites the attached schedule after the reconcile's read and before its release is released with it. Stripe detaches the schedule, and the lower price never starts. The downgrade's row in `core.subscription_scheduled_changes` is then superseded: by the same reconcile if it had already listed the row, or by the next one, which finds no schedule attached. The window opens only while a spent schedule is still attached: after an earlier downgrade lands at renewal, until a reconcile releases its schedule; and after an upgrade whose own release of the schedule failed, which `plan_change.go:315-324` leaves to the reconcile. It lasts one reconcile, from its read of the subscription to its release call. ## What should happen A downgrade the console confirmed stays scheduled at Stripe until it lands or the member changes it, and a reconcile releases only a schedule that holds nothing new. - Never give an attached schedule new work. `downgradeAtRenewal` releases the attached schedule, taking one that is already released as done, and then creates a new one from the subscription, the way the cancellation releases the attached schedule before it sets its own change (line 585). Stripe makes a schedule from a subscription only when none is attached, so the release comes first. A reconcile holding the old id can then release only a schedule that is spent. It costs one more Stripe call per downgrade, and changing the target of a pending downgrade replaces its schedule instead of rewriting it. - Read the schedule again just before the release, and release it only if its metadata still names a price the subscription no longer carries. This narrows the window to the time between two Stripe calls; it does not close it. The first closes the race and is the one to take. A test can show it with the mock Stripe backend in `internal/stripetest`: its `Handle` hook runs the downgrade when the reconcile's release call arrives, and the test checks that Stripe still holds the downgrade and its row stays scheduled. The mock answers every schedule call with one id today, so it needs a second id for the new schedule. ## Where `internal/fulfillment/plan_change.go` (`downgradeAtRenewal`); `internal/fulfillment/reconcile.go` (`ReconcileSubscription`, `findSpentSchedule`, `releaseSpentSchedule`). ## Steps 1. A member on Pro scheduled a downgrade to Standard, and it landed at renewal. The subscription is on Standard; the console's schedule is still attached, with Pro in its metadata. 2. Stripe's renewal events start a reconcile. It reads the subscription, finds the schedule spent and commits. 3. Before it sends the release, the member downgrades from Standard to Basic. The console rewrites the attached schedule, confirms the change, and the plan reads "moves to Basic on" the next renewal date. 4. The reconcile releases the schedule. The scheduled move disappears from the plan, and the subscription renews on Standard. ## Why it matters The member is told a downgrade is scheduled, nothing tells them it was dropped, and they pay the higher tier for another period. Related: #177 asks who owns each kind of scheduled-change row and what its states mean.
cgalo5758 added the
kind
bug
area/billing
labels 2026-10-12 02:10:30 +00:00
Sign in to join this conversation.
No labels area/billing
kind
bug
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wiki-cafe/member-console#219