Record what the sandbox check found for suspended plans

The landing check ran on the Stripe sandbox with API version
2025-02-24.acacia. S1 to S5 and S8 went through the console's pages with
stripe listen forwarding; S9 to S11 ran from the CLI on test-clock
customers the console does not map, with a daily price so that a period
ended before the retries ran out.

- S1: settled. The billing-cycle-anchor update was accepted on a
  subscription the console's schedule manages; the charge failed and the
  subscription read past_due at once, its invoice open. The Products
  page, the entitlements panel, and the organization page's Pools panel
  and Billing card matched the design.
- S2: settled. cancel_at_period_end=true was accepted while past_due; the
  card read "ends <date>", the Billing card "Past due 1 subscription ·
  ends <date>".
- S3: settled. Keep plan cleared the flag; the card read "payment due".
- S4: settled. The plan's Pay invoice opened Stripe's page for the open
  invoice, and the detail's Pay invoice answered a redirect to it.
  Paying with 4242 returned the subscription to active; the card renewed,
  the entitlements returned, the Suspended badge went away.
- S5: no. The hosted page made the new card the customer's invoice
  default; the subscription kept its own default, the failed card, which
  Stripe charges first, and Checkout sets that default. Posted on #256.
- S8, pending half: settled. Keep plan released the console's schedule
  on the past_due subscription. The landed half is not settled and rests
  on TestKeepRefusesADowngradeThatLanded.
- S9: no for other subscriptions. The period-end cancellation stopped
  collection of the cancelled subscription's own open invoice only; the
  other subscription's open invoice kept collecting. No issue follows.
- S10: settled. Marked uncollectible from the CLI, since the check leaves
  the shared account's retry settings as they are; Stripe's page took
  payment and the invoice read paid. Posted on #150.
- S11: settled. Stripe voided the abandoned upgrade's invoice when its
  pending update expired; the webhook routes invoice.voided. No issue.
- S6 and S7: not settled.

The proposal's card and collection lines and the two risks those
answers decide now state the answers. Tasks 6.5, 6.6, 7.1 and 7.3 are
ticked; 7.2 and 7.4 wait.

make screens: the same five screens every section's run reported
(operator-person-detail, operator-billing-invoice-detail at both widths,
operator-domains at desktop) differ from the baseline in data only.

make lint passes; openspec validate --strict passes; the suite on reset
databases passes, 4613 tests, 15 skipped.
This commit is contained in:
2026-10-24 04:49:41 -04:00
parent dc2fd46aa0
commit 0db7ce9933
3 changed files with 23 additions and 8 deletions
@@ -286,11 +286,26 @@ The local sources are the stripe-go v81.4.0 doc comments (API version `2025-02-2
A subscription schedule made from a `past_due` subscription, and `pending_if_incomplete` on one, are not exercised: D3 refuses the switch before either. Keep plan and Cancel plan do release a schedule that the console attached while the subscription was active, which S8 exercises. Keeping or cancelling a `paused` subscription is not exercised: fulfillment refuses both (D6, D9).
### What the landing check found
The check ran on the sandbox with API version `2025-02-24.acacia`. S1 to S5 and S8 ran through the console's pages with `stripe listen` forwarding to the console. S9 to S11 ran from the CLI on test-clock customers the console does not map, with a daily price so that a period ended before the retries ran out.
- **S1:** settled as the question states. Stripe accepted the billing-cycle-anchor update on a subscription that the console's schedule manages; the renewal charge failed and the subscription read `past_due` at once, its new invoice open. The Products page, the entitlements panel, and the organization page's Pools panel and Billing card showed what this design says they show.
- **S2:** settled. Stripe accepted `cancel_at_period_end=true` on the `past_due` subscription; the card read "ends <date>", and the organization page's Billing card read "Past due 1 subscription · ends <date>".
- **S3:** settled. Keep plan cleared the flag, and the card read "payment due" again.
- **S4:** settled. The plan's Pay invoice opened Stripe's page for the open invoice, and the open invoice's detail answered a redirect to it. Paying with `4242 4242 4242 4242` returned the subscription to `active`; the card read "renews <date>", the entitlements returned, and the Pools panel dropped the Suspended badge.
- **S5:** answered no. Stripe's page attached the new card to the customer and made it the customer's `invoice_settings.default_payment_method`, but the subscription's own `default_payment_method` stayed the card that failed. Stripe charges the subscription's own default before the customer's, so the next renewal charges the failed card again. The result goes on #256.
- **S8, the pending half:** settled. Keep plan on the `past_due` subscription released the console's schedule, and the card read "payment due" again. The landed half is not settled and rests on `TestKeepRefusesADowngradeThatLanded`.
- **S9:** answered no for the other subscriptions. When the period-end cancellation took effect, Stripe stopped automatic collection of the cancelled subscription's own open invoice (`auto_advance` false, no next attempt), which stays due, and left the other subscription's open invoice collecting with its next attempt unchanged. No issue follows.
- **S10:** settled. The invoice was marked `uncollectible` from the CLI, since the check leaves the shared account's retry settings as they are. Stripe's page for it took payment with `4242 4242 4242 4242`, and the invoice read `paid`, so a Pay invoice the console still offers on such an invoice leads to a page that works.
- **S11:** settled, and no issue follows. When the pending update expired, Stripe voided the abandoned upgrade's invoice and left the subscription on its price. The webhook routes `invoice.voided`, so the invoice's detail stops offering Pay invoice.
- **S6 and S7** are not settled.
## Risks / Trade-offs
- **A member cannot move to a cheaper plan until they pay.** A member who cannot afford the current plan can cancel at the period end, or pay and then downgrade. Allowing a downgrade at renewal while past due needs a schedule from a past-due subscription, which is unverified.
- **The hosted page may not keep the new card.** If S5 shows it does not, a member who pays with a new card can fail again at the next renewal; #256 covers it.
- **A member who pays with a new card fails again at the next renewal.** Stripe's page makes the new card the customer's default for invoices and leaves the subscription's own default on the card that failed, which Stripe charges first (S5). #256 covers changing the card.
- **A payment-due card can name a plan the subscription has already left.** A downgrade that landed is converged by the renewal's reconcile while the subscription is still active, so it stays unconverged only when no reconcile ran in the hour before Stripe charged the invoice (D6, D14). Then the card names the plan the position holds and reads the price line from the subscription. The card draws no "moves to" line and no Keep plan for it, and Keep plan refuses it, so the member cannot act on the difference; paying converges the plan. Cancel plan stays on it, and releases the schedule that holds the lower price and supersedes the row Stripe applied, with a body that names the plan the card holds ending while Stripe bills the lower one (F30); the case is rare and accepted.
- **A period-end cancellation of a payment-due plan can stop collection of the customer's other open invoices.** Stripe may stop automatic collection when the cancellation takes effect (S9). The member pays through Pay invoice, which does not depend on automatic collection.
- **A period-end cancellation of a payment-due plan stops collection of its own open invoice.** The invoice stays due and Stripe no longer retries it; the customer's other subscriptions keep collecting (S9). The member pays through the invoice's Pay invoice on the Billing page, which does not depend on automatic collection.
- **The card's Pay invoice can lead to the Billing page.** When the plan's latest invoice is not the open one, or the read fails, the new tab shows `/billing`, where the open invoice's own link pays it.
- **A golden diff in every JSON golden of the member plans, the member entitlements and the organization page.** Each new view-model field adds a key to every row of those files; the sections name the files, and the HTML goldens change only for the states that show the new state.
@@ -75,9 +75,9 @@ None.
Each is filed as its own issue, noted on an existing one, left to a landing check, or a note only.
- **A grace period for `past_due`.** The design documents describe a `past_due` subscription that keeps delivering for a configurable grace period; the code suspends at once, and the specs and the model card follow the code. This change keeps the code's behaviour. #255.
- **Changing the card on file.** A member cannot replace the card that fails; paying on Stripe's page may not make the new card the subscription's default (unverified until the landing check). Stripe's customer portal has a `payment_method_update` flow that could serve it. #256.
- **Changing the card on file.** A member cannot replace the card that fails. Paying on Stripe's page makes the new card the customer's default for invoices but leaves the subscription's own default on the card that failed, which Stripe charges first, so the next renewal fails again (the landing check, S5). Stripe's customer portal has a `payment_method_update` flow that could serve it. #256.
- **`invoice.marked_uncollectible` is not handled.** An invoice Stripe marks uncollectible keeps reading Open until the invoice sweep re-reads it, up to about a day later. The Pay invoice routes read Stripe when opened and do not depend on it. Noted on #150.
- **A second Checkout while the first subscription is `incomplete`.** An incomplete subscription has no position yet, so Checkout on its ladder stays allowed; Stripe expires an incomplete subscription after 23 hours. Note only.
- **An earlier Checkout Session that has not expired** can still complete a second subscription on a suspended plan's ladder. The console sets no `expires_at` on a session (`internal/server/billing.go:137-153`), so this holds for active plans too. #257.
- **An add-on whose subscription is suspended.** The add-ons page reads no holdings, offers Add again and shows no state; on recovery both subscriptions deliver and bill. #258.
- **Collection stops when a period-end cancellation takes effect.** When the cancellation of a `past_due` plan takes effect, Stripe may stop automatic collection of all the customer's finalized invoices, which reaches the customer's other subscriptions; the member pays through Pay invoice. Filed only if S9 confirms it.
- **Collection when a period-end cancellation takes effect.** When the cancellation of a `past_due` plan takes effect, Stripe stops automatic collection of that subscription's own open invoice, which stays due, and leaves the customer's other subscriptions collecting (the landing check, S9). The member pays through Pay invoice. Not filed.
@@ -159,8 +159,8 @@ Sections 1 to 6 are one commit each, and their items are that commit's parts; se
- [x] 6.2 `ExcludeSuspended` and `TestExcludeSuspendedKeepsEverythingElse` are deleted, since no reader calls it (`deadcode` would refuse it).
- [x] 6.3 Quick fixes (proposal): `subscriptionStatePrecedence` becomes `unpaid`, `past_due`, `paused`, `canceling`, `active`, `trialing`, with its comment rewritten to say why; `subscriptionPeriodNote` returns `""` for `past_due`, `unpaid` and `paused` unless the subscription is set to cancel at the period end, which keeps `ends <date>` as an active one does, and its doc comment says why; a single `incomplete_expired` subscription says nothing, as a `canceled` one does (a ride-along fix, listed in the proposal). The subscription's `unpaid` badge is drawn through a new badge key, `subscription_unpaid` (`internal/server/anatomy.go`, label `Unpaid`, tone `danger`), by the existing `BillingSummaryViewModel.SubscriptionsBadge()` (`anatomy.go:716-722`, which returns a `Badge`) and by `SubscriptionViewModel.StatusBadge()` (`anatomy.go:856`), the badge of the operator's subscriptions list that the card's count links to, while the invoice's and the balance's `unpaid` keep their key and the warning tone; `TestStatusBadgeMapIsComplete` lists the new key.
- [x] 6.4 Tests. `TestOrgEnrollmentGolden`: `suspended-subscription` changes on purpose (`.html`: the Pools panel shows Small2 with its rung pill and the Suspended badge and no "No plan is delivering into this pool", the Issue grant select marks Small2 and the form shows the supersession warning, and the Billing card has no "renews" note; `.json`: the delivery, `HasAttachment` true, Small2's `SupersedesSubscription` true and `SubscriptionNote` `""`); the JSON goldens with deliveries, `rich`, `default-only`, `second-pool` and `rich-history-search`, gain `"Suspended": false` on each delivery and no other change. `TestSummarizeSubscriptions` (`internal/server/operator_enrollment_render_test.go:343-383`): the row "cancel at period end on a past-due subscription reads past due" now wants the note `ends Mar 1, 2026`, on purpose; new rows: unpaid outranks past due (`1 unpaid`), past due outranks paused (`1 past due`), paused outranks canceling (`1 paused`), one unpaid and one paused subscription each carry no note, cancel at period end on an unpaid and on a paused subscription each reads `ends Mar 1, 2026`, one incomplete expired says nothing; the test's doc comment states the new precedence. A test on the two badge methods, tested by tone because both return a `Badge`: `SubscriptionsBadge()` for the state `unpaid` has the tone `danger` and `SubscriptionViewModel.StatusBadge()` for the state `unpaid` has the tone `danger`, every other state keeps the badge `StatusBadge` gives it, and `StatusBadge("unpaid")` keeps the tone `warning`, and `SubscriptionsBadge()` for `unpaid` has the label `Unpaid`; failures read `SubscriptionsBadge(unpaid).Label = %q, want "Unpaid"`, `SubscriptionsBadge(unpaid).Tone = %q, want "danger"`, `SubscriptionViewModel(unpaid).StatusBadge().Tone = %q, want "danger"` and `StatusBadge(unpaid).Tone = %q, want "warning"`.
- [ ] 6.5 `make screens`: no changed screen expected; the demo organization the operator sheet shows holds an active, canceling subscription, whose badge and note are unchanged.
- [ ] 6.6 Sandbox check (design, "What is unverified about Stripe"), on this worktree's stack with `stripe listen` forwarding to the console, the sandbox calls made with `--stripe-version 2025-02-24.acacia`: subscribe a member through Checkout with `4242 4242 4242 4242`; attach `pm_card_chargeCustomerFail` to the customer and set it as the subscription's `default_payment_method`; press Downgrade on a lower priced tier through the console, which attaches the console's schedule with its `scheduled_from_price_id` metadata and records the `plan_switch` row (a schedule made with the CLI carries neither, because the console sets the metadata in a second call, `plan_change.go:356`, `:395-396`, so Keep plan would have nothing to act on); start a new period now (`--billing-cycle-anchor now --proration-behavior none`). Check S1 (the subscription reads `past_due`; if Stripe refuses the billing-cycle-anchor update on a subscription that a schedule manages, record S8 as not settled and release the schedule from the CLI so that the rest of S1 continues) and the Products page: the plan current with copy rows 1 and 3, Pay invoice and Cancel plan, no Subscribe, Upgrade or switch; the entitlements panel's badge; the operator's Pools panel badge and Billing card. Still past due and before S2, press Keep plan and check S8: record Stripe's answer to the release of the schedule and that the card reads "payment due" again. Then Cancel plan and check S2 and, in its confirmation, copy row 6 and the row that the prediction picks; Keep plan and check S3. Pay invoice: the new tab opens Stripe's page for the open invoice; pay with `4242 4242 4242 4242` and check S4 (the subscription `active`, the card renewing, the entitlements back) and S5 (the subscription's `default_payment_method` after paying). The landed half of S8 (Keep plan over a downgrade that Stripe applied) cannot be reached without a test clock and is recorded as not settled; it rests on `TestKeepRefusesADowngradeThatLanded`. S9 and S10 need a test-clock customer (S10 opens the invoice's `hosted_invoice_url`, read with the CLI, because the console maps no test-clock customer); when none is used they are recorded as not settled. Capture the Products page and the open invoice's detail at desktop width and at 390-pixel mobile width (the emulated viewport, checked through `innerWidth`) for the maintainer. Record each answer in the commit message; a result that contradicts the design is a stop.
- [x] 6.5 `make screens`: no changed screen expected; the demo organization the operator sheet shows holds an active, canceling subscription, whose badge and note are unchanged.
- [x] 6.6 Sandbox check (design, "What is unverified about Stripe"), on this worktree's stack with `stripe listen` forwarding to the console, the sandbox calls made with `--stripe-version 2025-02-24.acacia`: subscribe a member through Checkout with `4242 4242 4242 4242`; attach `pm_card_chargeCustomerFail` to the customer and set it as the subscription's `default_payment_method`; press Downgrade on a lower priced tier through the console, which attaches the console's schedule with its `scheduled_from_price_id` metadata and records the `plan_switch` row (a schedule made with the CLI carries neither, because the console sets the metadata in a second call, `plan_change.go:356`, `:395-396`, so Keep plan would have nothing to act on); start a new period now (`--billing-cycle-anchor now --proration-behavior none`). Check S1 (the subscription reads `past_due`; if Stripe refuses the billing-cycle-anchor update on a subscription that a schedule manages, record S8 as not settled and release the schedule from the CLI so that the rest of S1 continues) and the Products page: the plan current with copy rows 1 and 3, Pay invoice and Cancel plan, no Subscribe, Upgrade or switch; the entitlements panel's badge; the operator's Pools panel badge and Billing card. Still past due and before S2, press Keep plan and check S8: record Stripe's answer to the release of the schedule and that the card reads "payment due" again. Then Cancel plan and check S2 and, in its confirmation, copy row 6 and the row that the prediction picks; Keep plan and check S3. Pay invoice: the new tab opens Stripe's page for the open invoice; pay with `4242 4242 4242 4242` and check S4 (the subscription `active`, the card renewing, the entitlements back) and S5 (the subscription's `default_payment_method` after paying). The landed half of S8 (Keep plan over a downgrade that Stripe applied) cannot be reached without a test clock and is recorded as not settled; it rests on `TestKeepRefusesADowngradeThatLanded`. S9 and S10 need a test-clock customer (S10 opens the invoice's `hosted_invoice_url`, read with the CLI, because the console maps no test-clock customer); when none is used they are recorded as not settled. Capture the Products page and the open invoice's detail at desktop width and at 390-pixel mobile width (the emulated viewport, checked through `innerWidth`) for the maintainer. Record each answer in the commit message; a result that contradicts the design is a stop.
- [x] 6.7 Breaks:
- the Pools panel's loop keeps `ExcludeSuspended`: caught by `TestOrgEnrollmentGolden`, `want_new` `testdata/org_enrollment/suspended-subscription.html differs from what the code produced`;
- `subscriptionHeldLadders` keeps it: caught by the same golden (no warning);
@@ -181,7 +181,7 @@ Sections 1 to 6 are one commit each, and their items are that commit's parts; se
## 7. Landing
- [ ] 7.1 With the test stack up, `make test` passes (it runs `make lint` first), and `openspec validate suspended-plans-stay-held --strict` passes.
- [x] 7.1 With the test stack up, `make test` passes (it runs `make lint` first), and `openspec validate suspended-plans-stay-held --strict` passes.
- [ ] 7.2 Post a comment on #148 under `CONTRIBUTING.md`'s rules for forge text (item 1: it reads for someone who has never opened the repository, explains the console's terms once, and names no decision number, change name, date as narrative, deployment or count), and close it. The comment states that a plan whose subscription's payment is due, or that is paused, now stays the organization's current plan on the Products page and the entitlements panel, with its state, while it delivers no entitlement; that no Subscribe or Upgrade is offered on its plan and Checkout refuses a second subscription there; that a plan change is refused until the invoice is paid, while Keep plan and Cancel plan at the period end work; that Pay invoice opens Stripe's page for the unpaid invoice, read when the member opens it and never stored, from the plan's card and from an open invoice's detail, and that the invoice route is the one #150 describes for its invoice number link; that the organization page now shows the suspended delivery, warns before a grant supersedes it, and ranks an unpaid subscription worst on the Billing card; and what the sandbox check found. It names the commits of sections 1 to 6.
- [ ] 7.3 Handed to the maintainer, none filed or posted without his go: the candidate issues in the proposal's "Out of scope" (a grace period for `past_due`, changing the card on file, with the sandbox's S5 answer), the candidate comment on #150 (`invoice.marked_uncollectible` is not handled; the by-invoice route exists for its number link), the add-on whose subscription is suspended, the collection stop when a period-end cancellation takes effect (with S9's and S10's results, or the statement that they are not settled), and the notes on a second Checkout while the first subscription is `incomplete` and on an earlier Checkout Session that has not expired.
- [x] 7.3 Handed to the maintainer, none filed or posted without his go: the candidate issues in the proposal's "Out of scope" (a grace period for `past_due`, changing the card on file, with the sandbox's S5 answer), the candidate comment on #150 (`invoice.marked_uncollectible` is not handled; the by-invoice route exists for its number link), the add-on whose subscription is suspended, the collection stop when a period-end cancellation takes effect (with S9's and S10's results, or the statement that they are not settled), and the notes on a second Checkout while the first subscription is `incomplete` and on an earlier Checkout Session that has not expired.
- [ ] 7.4 The archive waits for the maintainer's go; the deltas carry every spec change, and no Purpose needs an edit a delta cannot make. Nothing in this change touches a production host.