A past-due subscriber is shown their ladder as unheld and offered a second subscription #148

Open
opened 2026-09-27 23:31:00 +00:00 by cgalo5758 · 3 comments
Owner

What happens

Found by reading, not yet reproduced. A past_due, unpaid or paused subscription suspends its provisions (reconcile.go, classifyStatus), and the status trigger copies the suspension to the ladder rows. The Products page finds the current rung through GetActiveAttachmentsByPool, which reads status = 'active' only, so the ladder renders as not held and every priced tier offers Subscribe. Checkout's guard (HasActiveSubscriptionOnLadder) resolves through the same active-only query, so it lets a second Stripe subscription start while the first is still being retried. The model already treats a suspended position as live for restoration (docs/models/plan-ladders-transitions.md); the member surfaces do not.

What should happen

A suspended position is still the member's: the ladder shows it as current, with its state, and no Checkout is offered on that ladder while it is suspended. What the member sees and can do about the unpaid invoice is part of this issue.

Done when

  • A test with a suspended subscription position renders that ladder as held, with no enabled Subscribe or Upgrade to Checkout.
  • Checkout refuses a second subscription on a ladder whose position is suspended.
## What happens Found by reading, not yet reproduced. A `past_due`, `unpaid` or `paused` subscription suspends its provisions (`reconcile.go`, `classifyStatus`), and the status trigger copies the suspension to the ladder rows. The Products page finds the current rung through `GetActiveAttachmentsByPool`, which reads `status = 'active'` only, so the ladder renders as not held and every priced tier offers Subscribe. Checkout's guard (`HasActiveSubscriptionOnLadder`) resolves through the same active-only query, so it lets a second Stripe subscription start while the first is still being retried. The model already treats a suspended position as live for restoration (docs/models/plan-ladders-transitions.md); the member surfaces do not. ## What should happen A suspended position is still the member's: the ladder shows it as current, with its state, and no Checkout is offered on that ladder while it is suspended. What the member sees and can do about the unpaid invoice is part of this issue. ## Done when - A test with a suspended subscription position renders that ladder as held, with no enabled Subscribe or Upgrade to Checkout. - Checkout refuses a second subscription on a ladder whose position is suspended.
cgalo5758 added the
kind
bug
area/billingarea/member-ui
labels 2026-09-27 23:31:00 +00:00
Author
Owner

The same active-only lookup sits under every plan act, not only the page and Checkout: resolveLadderSubscription (internal/fulfillment/plan_change.go) reads it for the switch, its preview, Keep and Cancel, so a past-due subscriber also cannot switch or cancel; they get ErrNoActivePaidSubscription. ListLivePlanPositionsByOrgType already reads live positions as active or suspended, so the fix is one filter in a shared position lookup (#188, which replaces the five copies of the loop).

The same active-only lookup sits under every plan act, not only the page and Checkout: `resolveLadderSubscription` (`internal/fulfillment/plan_change.go`) reads it for the switch, its preview, Keep and Cancel, so a past-due subscriber also cannot switch or cancel; they get `ErrNoActivePaidSubscription`. `ListLivePlanPositionsByOrgType` already reads live positions as active or suspended, so the fix is one filter in a shared position lookup (#188, which replaces the five copies of the loop).
Author
Owner

The Products page, Checkout and the plan switch now read the organization's position on a ladder through one loader (internal/server/plan_offer.go) and decide the move with plans.Offer. The position has no suspended state yet, so a suspended subscription still reads there as no position. Adding the state to the loader's query and to plans.Position makes the decision refuse Checkout on a suspended ladder, on the page and at the endpoint alike, which covers both points of this issue's Done-when.

The Products page, Checkout and the plan switch now read the organization's position on a ladder through one loader (`internal/server/plan_offer.go`) and decide the move with `plans.Offer`. The position has no suspended state yet, so a suspended subscription still reads there as no position. Adding the state to the loader's query and to `plans.Position` makes the decision refuse Checkout on a suspended ladder, on the page and at the endpoint alike, which covers both points of this issue's Done-when.
Author
Owner

The position read is now one query, ListLadderPositionsByPool (internal/entitlements/queries/pool_provision_ladders.sql), and its l.status = 'active' is the only place "held" is decided. Widening it to the live statuses is this issue's change: that predicate, plus a status column so a reader can tell a suspended position from an active one. The entitlements panel and the dashboard's entitlement check read the same query, so they follow the wider predicate unless this change filters them.

The position read is now one query, `ListLadderPositionsByPool` (`internal/entitlements/queries/pool_provision_ladders.sql`), and its `l.status = 'active'` is the only place "held" is decided. Widening it to the live statuses is this issue's change: that predicate, plus a status column so a reader can tell a suspended position from an active one. The entitlements panel and the dashboard's entitlement check read the same query, so they follow the wider predicate unless this change filters them.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: wiki-cafe/member-console#148