A member's plan change does not see a grant issued while it runs, and replaces that grant when it finishes #202

Open
opened 2026-10-09 16:02:10 +00:00 by cgalo5758 · 0 comments
Owner

What happens

Switch, Cancel and Keep on the member's Products page all start in resolveLadderSubscription (internal/fulfillment/plan_change.go). It reads which tier the organization holds on the ladder and the subscription that pays for it. (A ladder is a sequence of tiers such as Plus, Pro and Max. An organization holds one tier on each ladder at a time, from a subscription or from a grant.) These reads take no lock and run in no transaction, and nothing reads the tier again before the change goes to Stripe. Each change first reads the subscription from Stripe, so the gap lasts at least one Stripe round trip.

An operator's grant runs core.confer, which locks the organization's pool row and ends whatever holds the ladder. The plan change never takes that lock, so it does not see a grant that commits inside the gap. The change is sent to Stripe for a subscription that no longer holds the ladder. Each change then ends with a reconcile, which finds the subscription delivering nothing and confers its tier again, and that ends the grant. If the operator marked the grant to resume, it waits for the subscription to end. If not, it is gone.

Neither order gives this result. If the grant had committed first, the change would have answered "There's no active subscription to change on this plan." If the change had finished first, the grant would have replaced its tier.

What should happen

A plan change and a conferral on the same organization run one after the other, so the outcome is always one of those two orders.

Where

internal/fulfillment/plan_change.go: resolveLadderSubscription and its callers SwitchPlan, CancelSubscription and KeepCurrentPlan. The pool row lock is taken in core.confer (internal/db/migrations/00023_grant_resumption.sql).

Steps

  1. An organization pays for Plus through a subscription. The member confirms a switch to Pro.
  2. While the switch waits on its Stripe read, an operator issues a Max grant to the organization. Max replaces Plus on the ladder.
  3. The switch sends the upgrade, and the member's card is charged the prorated difference to Pro.
  4. The reconcile that follows confers Pro for the subscription, which replaces the Max grant.

A downgrade, a cancellation at the end of the period and a Keep of a scheduled change end the same way at step 4.

Why it matters

The member pays for a change to a plan an operator had just replaced, and the operator's grant is undone a moment after it was issued.

## What happens Switch, Cancel and Keep on the member's Products page all start in `resolveLadderSubscription` (`internal/fulfillment/plan_change.go`). It reads which tier the organization holds on the ladder and the subscription that pays for it. (A ladder is a sequence of tiers such as Plus, Pro and Max. An organization holds one tier on each ladder at a time, from a subscription or from a grant.) These reads take no lock and run in no transaction, and nothing reads the tier again before the change goes to Stripe. Each change first reads the subscription from Stripe, so the gap lasts at least one Stripe round trip. An operator's grant runs `core.confer`, which locks the organization's pool row and ends whatever holds the ladder. The plan change never takes that lock, so it does not see a grant that commits inside the gap. The change is sent to Stripe for a subscription that no longer holds the ladder. Each change then ends with a reconcile, which finds the subscription delivering nothing and confers its tier again, and that ends the grant. If the operator marked the grant to resume, it waits for the subscription to end. If not, it is gone. Neither order gives this result. If the grant had committed first, the change would have answered "There's no active subscription to change on this plan." If the change had finished first, the grant would have replaced its tier. ## What should happen A plan change and a conferral on the same organization run one after the other, so the outcome is always one of those two orders. ## Where `internal/fulfillment/plan_change.go`: `resolveLadderSubscription` and its callers `SwitchPlan`, `CancelSubscription` and `KeepCurrentPlan`. The pool row lock is taken in `core.confer` (`internal/db/migrations/00023_grant_resumption.sql`). ## Steps 1. An organization pays for Plus through a subscription. The member confirms a switch to Pro. 2. While the switch waits on its Stripe read, an operator issues a Max grant to the organization. Max replaces Plus on the ladder. 3. The switch sends the upgrade, and the member's card is charged the prorated difference to Pro. 4. The reconcile that follows confers Pro for the subscription, which replaces the Max grant. A downgrade, a cancellation at the end of the period and a Keep of a scheduled change end the same way at step 4. ## Why it matters The member pays for a change to a plan an operator had just replaced, and the operator's grant is undone a moment after it was issued.
cgalo5758 added the
kind
bug
area/entitlementsarea/billing
labels 2026-10-09 16:02:10 +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#202