Checkout and the plan switch do not enforce what the Products page offers #175

Closed
opened 2026-10-06 21:11:37 +00:00 by cgalo5758 · 1 comment
Owner

What happens

The Products page decides which control a member gets on each tier: Checkout, a switch, a downgrade at the period end, or none (buildPlansData, internal/server/member_products.go). It offers only a product's default price, and outside plan tiers only a recurring one. The endpoints that move money check less:

  • Checkout (HandleCheckout, internal/server/billing.go) refuses only when a subscription already holds the ladder. An organization on a granted tier can buy a tier below it, which replaces the grant; a tab opened before the grant was issued gets there.
  • Checkout and the switch (SwitchPlan, internal/fulfillment/plan_change.go) accept any active price of the product, such as an older, cheaper one kept for existing subscribers. Checkout also accepts a one-time price, which then fails at Stripe.
  • Checkout's already-subscribed check logs a failed read and lets the request through. The switch repeats the held-tier rule by hand (switchTargetHeld, internal/server/member_products.go) and lets the request through when it cannot read the organization's pools.

What should happen

One function without database access takes the organization's position on a ladder and a target price and returns the move offered. The page draws from it; Checkout, the switch and its preview refuse anything else, and refuse when a read fails.

Done when

  • A table test covers the function across what pays for the tier, a held tier, a scheduled change, the unpriced lowest tier, default and other prices, recurring and one-time.
  • Checkout refuses a tier below a granted one, a non-default price and a one-time price; the switch refuses a non-default price and any target not offered as a switch; both refuse when a read fails.
## What happens The Products page decides which control a member gets on each tier: Checkout, a switch, a downgrade at the period end, or none (`buildPlansData`, `internal/server/member_products.go`). It offers only a product's default price, and outside plan tiers only a recurring one. The endpoints that move money check less: - Checkout (`HandleCheckout`, `internal/server/billing.go`) refuses only when a subscription already holds the ladder. An organization on a granted tier can buy a tier below it, which replaces the grant; a tab opened before the grant was issued gets there. - Checkout and the switch (`SwitchPlan`, `internal/fulfillment/plan_change.go`) accept any active price of the product, such as an older, cheaper one kept for existing subscribers. Checkout also accepts a one-time price, which then fails at Stripe. - Checkout's already-subscribed check logs a failed read and lets the request through. The switch repeats the held-tier rule by hand (`switchTargetHeld`, `internal/server/member_products.go`) and lets the request through when it cannot read the organization's pools. ## What should happen One function without database access takes the organization's position on a ladder and a target price and returns the move offered. The page draws from it; Checkout, the switch and its preview refuse anything else, and refuse when a read fails. ## Done when - A table test covers the function across what pays for the tier, a held tier, a scheduled change, the unpriced lowest tier, default and other prices, recurring and one-time. - Checkout refuses a tier below a granted one, a non-default price and a one-time price; the switch refuses a non-default price and any target not offered as a switch; both refuse when a read fails.
cgalo5758 added this to the Public launch milestone 2026-10-06 21:11:37 +00:00
cgalo5758 added the
kind
bug
area/billingarea/member-ui
priority
high
security
labels 2026-10-06 21:11:37 +00:00
Author
Owner

Fixed on main, in 2e1617c through f66a80b. plans.Offer (internal/plans/offer.go) decides the move offered on a tier from the organization's position on the ladder and the target price, and reads nothing. The Products page draws its controls from it, and Checkout, the switch and its preview refuse any target it does not offer as that move, and refuse when a read they need fails. Checkout now also refuses the tier a grant holds and every tier below it, a price that is not the product's default, a one-time price, and a product whose other ladders do not all offer Checkout. A table test covers the decision, and each refusal has a database test beside a control that the same fixture allows. Two cases that come from one product sitting on several ladders remain, and are on #88.

Fixed on main, in 2e1617c through f66a80b. `plans.Offer` (`internal/plans/offer.go`) decides the move offered on a tier from the organization's position on the ladder and the target price, and reads nothing. The Products page draws its controls from it, and Checkout, the switch and its preview refuse any target it does not offer as that move, and refuse when a read they need fails. Checkout now also refuses the tier a grant holds and every tier below it, a price that is not the product's default, a one-time price, and a product whose other ladders do not all offer Checkout. A table test covers the decision, and each refusal has a database test beside a control that the same fixture allows. Two cases that come from one product sitting on several ladders remain, and are on #88.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wiki-cafe/member-console#175