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

Closed
opened 2026-09-27 23:31:00 +00:00 by cgalo5758 · 4 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.
cgalo5758 added this to the Public launch milestone 2026-10-20 23:14:01 +00:00
Author
Owner

A plan whose subscription's payment is due (Stripe's past_due or unpaid), or whose subscription is paused, now stays the organization's current plan. The Products page and the member's entitlements panel show it with its state (Payment due, Unpaid or Paused) while it delivers no entitlement. Its plan offers no Subscribe or Upgrade, and Checkout refuses a second subscription on its ladder.

While the invoice is unpaid, a move to another plan is refused. Keep plan and Cancel plan at the period end still work; cancelling leaves the unpaid invoice due. Pay invoice opens Stripe's page for the unpaid invoice in a new tab. The console reads the page's address from Stripe when the member opens the link and never stores it. Pay invoice is on the plan's card and on an open invoice's detail on the Billing page; the invoice route is the one #150 describes for its invoice number link.

On the operator's organization page, the Pools panel lists the suspended delivery with a Suspended badge, the grant form warns before a grant supersedes it, and the Billing card ranks an unpaid subscription worst.

Checked in a Stripe sandbox (API version 2025-02-24.acacia):

  • A renewal that fails moves a Checkout-made subscription to past_due at once, also while a scheduled downgrade is attached; the pages show the states above.
  • Stripe accepts setting and clearing a period-end cancellation on a past_due subscription, and releasing a scheduled downgrade.
  • Paying the open invoice on Stripe's page returns the subscription to active, and the plan delivers again.
  • Paying there with a new card does not make it the subscription's default, so the next renewal charges the failed card again (#256).
  • When a period-end cancellation of a past_due subscription takes effect, Stripe stops collecting that subscription's own open invoice, which stays due, and leaves the customer's other subscriptions collecting.
  • Stripe's page for an invoice marked uncollectible still takes payment (#150).
  • Stripe voids an abandoned upgrade's invoice when its pending update expires.

Not checked: a subscription that reaches unpaid after its retries run out, and Keep plan after a scheduled downgrade has already taken effect, which a test covers.

Commits: 979e38b9, 10057f8e, 996d6eee, a7bea2fa, ddd57bf1, dc2fd46a, 0db7ce99.

A plan whose subscription's payment is due (Stripe's `past_due` or `unpaid`), or whose subscription is paused, now stays the organization's current plan. The Products page and the member's entitlements panel show it with its state (Payment due, Unpaid or Paused) while it delivers no entitlement. Its plan offers no Subscribe or Upgrade, and Checkout refuses a second subscription on its ladder. While the invoice is unpaid, a move to another plan is refused. Keep plan and Cancel plan at the period end still work; cancelling leaves the unpaid invoice due. Pay invoice opens Stripe's page for the unpaid invoice in a new tab. The console reads the page's address from Stripe when the member opens the link and never stores it. Pay invoice is on the plan's card and on an open invoice's detail on the Billing page; the invoice route is the one #150 describes for its invoice number link. On the operator's organization page, the Pools panel lists the suspended delivery with a Suspended badge, the grant form warns before a grant supersedes it, and the Billing card ranks an unpaid subscription worst. Checked in a Stripe sandbox (API version `2025-02-24.acacia`): - A renewal that fails moves a Checkout-made subscription to `past_due` at once, also while a scheduled downgrade is attached; the pages show the states above. - Stripe accepts setting and clearing a period-end cancellation on a `past_due` subscription, and releasing a scheduled downgrade. - Paying the open invoice on Stripe's page returns the subscription to `active`, and the plan delivers again. - Paying there with a new card does not make it the subscription's default, so the next renewal charges the failed card again (#256). - When a period-end cancellation of a `past_due` subscription takes effect, Stripe stops collecting that subscription's own open invoice, which stays due, and leaves the customer's other subscriptions collecting. - Stripe's page for an invoice marked uncollectible still takes payment (#150). - Stripe voids an abandoned upgrade's invoice when its pending update expires. Not checked: a subscription that reaches `unpaid` after its retries run out, and Keep plan after a scheduled downgrade has already taken effect, which a test covers. Commits: 979e38b9, 10057f8e, 996d6eee, a7bea2fa, ddd57bf1, dc2fd46a, 0db7ce99.
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