A downgrade on a subscription with two items removes the other item at Stripe #233

Open
opened 2026-10-16 02:59:54 +00:00 by cgalo5758 · 1 comment
Owner

What happens

A member's 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, then the lower price. It builds both phases from one item, the first one Stripe lists on the subscription (current.Items.Data[0], lines 344 and 373-394). Phase one holds only that item's price and quantity. Phase two holds only the target price.

A schedule phase lists every item the subscription carries during that phase, and Stripe removes an item a phase leaves out. The console's Checkout creates a subscription with one item, and on those the schedule is right. But an edit at Stripe can add a second item, and the reconcile records every item and confers each item's product (#200 describes the same case on the plan card). The switch already knows which item belongs to the ladder: resolveLadderSubscription (line 704) picks the item whose product is the ladder's current tier, and the upgrade sends that item's id.

On a subscription whose two items each pay for a tier on a different ladder:

  • A downgrade on the first item's ladder removes the second item at once, because phase one leaves it out. Stripe prorates the removal, and the reconcile that follows (line 415) ends the second ladder's tier.
  • A downgrade on the second item's ladder writes phase one with the first item only, so the item being downgraded is removed at once instead of at renewal. The schedule's metadata names that removed item's price (line 396), so the same reconcile reads the schedule as spent (findSpentSchedule, internal/fulfillment/reconcile.go:892) and releases it. The lower tier never starts.

I have not run this against Stripe. Both outcomes follow from how Stripe applies a schedule's phases and from the reconcile's rules.

What should happen

The schedule changes only the ladder's own item. Phase one carries every current item at its current price and quantity. Phase two carries the same items, with the ladder's item, the one resolveLadderSubscription found, moved to the target price.

Where

internal/fulfillment/plan_change.go:339-416 (downgradeAtRenewal).

Steps

  1. Subscribe an organization to a paid tier on one ladder through Checkout.
  2. At Stripe, add to that subscription a price of a product that is a paid tier on another ladder, and let the subscription reconcile.
  3. On the Products page, downgrade the first ladder's tier.
  4. At Stripe, the subscription no longer carries the second item. Tier changes shows the second ladder's tier ended.

Why it matters

A member who downgrades one plan loses another plan they pay for, or loses the plan they downgraded at once instead of at renewal. Stripe's billing changes on the spot, and nothing tells the member.

Related: #219 changes how the downgrade attaches its schedule; the phases it writes stay as they are.

## What happens A member's 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, then the lower price. It builds both phases from one item, the first one Stripe lists on the subscription (`current.Items.Data[0]`, lines 344 and 373-394). Phase one holds only that item's price and quantity. Phase two holds only the target price. A schedule phase lists every item the subscription carries during that phase, and Stripe removes an item a phase leaves out. The console's Checkout creates a subscription with one item, and on those the schedule is right. But an edit at Stripe can add a second item, and the reconcile records every item and confers each item's product (#200 describes the same case on the plan card). The switch already knows which item belongs to the ladder: `resolveLadderSubscription` (line 704) picks the item whose product is the ladder's current tier, and the upgrade sends that item's id. On a subscription whose two items each pay for a tier on a different ladder: - A downgrade on the first item's ladder removes the second item at once, because phase one leaves it out. Stripe prorates the removal, and the reconcile that follows (line 415) ends the second ladder's tier. - A downgrade on the second item's ladder writes phase one with the first item only, so the item being downgraded is removed at once instead of at renewal. The schedule's metadata names that removed item's price (line 396), so the same reconcile reads the schedule as spent (`findSpentSchedule`, `internal/fulfillment/reconcile.go:892`) and releases it. The lower tier never starts. I have not run this against Stripe. Both outcomes follow from how Stripe applies a schedule's phases and from the reconcile's rules. ## What should happen The schedule changes only the ladder's own item. Phase one carries every current item at its current price and quantity. Phase two carries the same items, with the ladder's item, the one `resolveLadderSubscription` found, moved to the target price. ## Where `internal/fulfillment/plan_change.go:339-416` (`downgradeAtRenewal`). ## Steps 1. Subscribe an organization to a paid tier on one ladder through Checkout. 2. At Stripe, add to that subscription a price of a product that is a paid tier on another ladder, and let the subscription reconcile. 3. On the Products page, downgrade the first ladder's tier. 4. At Stripe, the subscription no longer carries the second item. Tier changes shows the second ladder's tier ended. ## Why it matters A member who downgrades one plan loses another plan they pay for, or loses the plan they downgraded at once instead of at renewal. Stripe's billing changes on the spot, and nothing tells the member. Related: #219 changes how the downgrade attaches its schedule; the phases it writes stay as they are.
cgalo5758 added the
kind
bug
area/billing
priority
low
labels 2026-10-16 02:59:54 +00:00
Author
Owner

The switch the sweeper fires at the end of a commitment term reads the first item the same way. firePlanSwitch (internal/fulfillment/sweep.go:183) moves the subscription's first current item to the target price (GetSubscriptionItemsBySubscriptionID, then items[0], lines 200-204), and scheduledChangeMappings (line 233) checks the environment of that same first item before the switch fires (lines 248-252). A member's move is deferred to the term's end when the plan's early-termination policy blocks it before then (internal/fulfillment/plan_change.go:255-272). On a subscription with two items, such a switch can reprice the other ladder's item, and since items recorded in one reconcile share their created_at (#200), which item moves is not fixed. The fix this issue describes, choosing the ladder's own item the way resolveLadderSubscription does, applies here too.

The switch the sweeper fires at the end of a commitment term reads the first item the same way. `firePlanSwitch` (`internal/fulfillment/sweep.go:183`) moves the subscription's first current item to the target price (`GetSubscriptionItemsBySubscriptionID`, then `items[0]`, lines 200-204), and `scheduledChangeMappings` (line 233) checks the environment of that same first item before the switch fires (lines 248-252). A member's move is deferred to the term's end when the plan's early-termination policy blocks it before then (`internal/fulfillment/plan_change.go:255-272`). On a subscription with two items, such a switch can reprice the other ladder's item, and since items recorded in one reconcile share their `created_at` (#200), which item moves is not fixed. The fix this issue describes, choosing the ladder's own item the way `resolveLadderSubscription` does, applies here too.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wiki-cafe/member-console#233