Say that an upgrade to another billing interval starts a new period today

#287. When a member upgrades to a tier billed on another interval, for
example from a monthly tier to a yearly one, Stripe does not prorate
within the current period: it starts a new period on the day of the
switch, charges the full new period less a credit for the unused time,
and moves the billing date to that day. The confirmation said the
charge was "for the rest of this period". It now reads "Your card is
charged $270.32 now, after a credit for the unused part of your current
plan, then $300.00 a year from today."; when the credit covers the new
price, "Nothing is charged now; then $40.00 a month from today."; and
when Stripe's preview gives no figure, the paused switch's sentence,
"Your card is charged {price} now, minus a credit for the unused part
of your current plan, then {recurring} from today."

The offer decides the case: rule 16 marks an enabled upward switch as a
new period when both the held price's interval and the target's are
known and differ (Position.Interval, set in onLadder from the rows
payingOn already reads; no statement is added). A switch at the
subscription's own interval and a downgrade read as before. Accepted:
from a yearly price to a monthly one, the no-figure sentence names a
charge the credit can exceed; it shows only when Stripe's preview fails
or Stripe still carries the console's pause.

docs/stripe.md and docs/models/payments-billing.md say the same.

Tests: TestOffer rows for the interval change (including a yearly
subscription offered a switch to a new period above it, an upgrade from
a yearly price to a monthly one), TestPlanMoveMessageStatesTheTiming
rows for N1, N2 and the no-figure sentence, and
TestSwitchPreviewAcrossIntervals (three members priced across
intervals, the confirmation read from the page). No golden changed.

Full suite on reset databases: 5161 passed, 15 skipped, none failed.
make screens: no page changed; no demo state reaches the confirmation.
Sandbox walk on this commit: monthly to yearly confirmed $270.00 and
Stripe's invoice charged 27000 with the period and billing date moved
to the switch; yearly to monthly read "Nothing is charged now", the
invoice was paid at 0 and the credit stayed as the customer's balance;
an upgrade over a scheduled downgrade left no schedule on the
subscription and a year's period from the switch.

Breaks, all CAUGHT (15 records):
- rule 16 marks no new period
- relation test dropped
- unknown interval test dropped
- enabled test dropped
- interval comparison reduced to the target's interval being set
- onLadder leaves the interval unset
- N1 worded as today's figure sentence
- N2 without from today
- N1 reached without a new period
This commit is contained in:
2026-10-30 01:29:48 -05:00
parent dec031e6dc
commit d1352b3b3e
15 changed files with 933 additions and 31 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ summary: "How money moves: billing accounts, subscriptions, invoices and payment
This model handles money, and its first rule is that the console does not process payments itself: **Stripe**, the payment processor, executes all charging, while the console keeps its own records. Traffic flows both ways — the console pushes its catalog and customers out to Stripe, and projects Stripe's billing activity (subscriptions, invoices, payments) back in. Everything money-related roots in a **billing account**, a container owned by one **organization** (the entity a member acts within; every member gets a personal one at signup); subscriptions, invoices, payments, and payment methods all hang from it.
The central object is the **subscription**: the standing agreement that an organization pays recurringly for a place on a plan ladder, or for an add-on. It carries the billing date, the cycle and the card; what it pays for is its **subscription item**, a product at a price. An organization holds at most one subscription per ladder (invariant 12), so moving to another paid tier changes what the subscription's item pays for: an upgrade reprices the item in place, charged the prorated difference at once and taking effect once that charge is paid; a downgrade takes effect at renewal through a Stripe subscription schedule, whose next phase replaces the item with a new one at the lower price. The subscription itself, and its id, stay the same. Three kinds of record sit around it and must never be confused: **invoices** and **payments** are one-shot facts, projected in from Stripe after they happen; **scheduled changes (intent)** are the future — a plan switch, a quantity change, or a cancellation that has not fired yet; **subscription changes (history)** are the past — an append-only log of status transitions. The **reconciler** is the single code path that converges the console's records, and the capabilities the organization actually holds, with what Stripe says is true.
The central object is the **subscription**: the standing agreement that an organization pays recurringly for a place on a plan ladder, or for an add-on. It carries the billing date, the cycle and the card; what it pays for is its **subscription item**, a product at a price. An organization holds at most one subscription per ladder (invariant 12), so moving to another paid tier changes what the subscription's item pays for: an upgrade reprices the item in place, charged the prorated difference at once, or, toward a price of another interval, a new period less a credit for the unused time, which moves the billing date to the switch, and taking effect once that charge is paid; a downgrade takes effect at renewal through a Stripe subscription schedule, whose next phase replaces the item with a new one at the lower price. The subscription itself, and its id, stay the same. Three kinds of record sit around it and must never be confused: **invoices** and **payments** are one-shot facts, projected in from Stripe after they happen; **scheduled changes (intent)** are the future — a plan switch, a quantity change, or a cancellation that has not fired yet; **subscription changes (history)** are the past — an append-only log of status transitions. The **reconciler** is the single code path that converges the console's records, and the capabilities the organization actually holds, with what Stripe says is true.
One purchase, end to end: a member clicks buy; the console runs its guard checks (invariant 12) and starts a **Stripe Checkout session** — a Stripe-hosted payment page; after paying, the member returns to the console, which immediately reconciles the new subscription so the purchase is visible right away; Stripe's webhook event arrives shortly after, is stored, and triggers the same reconciler again — harmless, because reconciling is repeat-safe; the invoice events that follow trigger the invoice reconcile, which reads the invoice, its lines and its payment attempts back from Stripe and converges the invoice and payment rows the same repeat-safe way.
+2 -2
View File
@@ -208,13 +208,13 @@ Every Stripe object the console reads carries a `livemode` field naming the envi
A member moves between the paid tiers of a plan ladder on the Products page, and the console changes the price on the member's existing subscription; it never opens a second subscription for the same ladder.
- **Upgrade (to a higher tier).** The console asks Stripe to invoice the prorated difference at once (`proration_behavior=always_invoice`) and to apply the new price only when that invoice is paid (`payment_behavior=pending_if_incomplete`). The confirmation shows the amount, and the switch sends Stripe the moment the confirmation prorated at, so the amount charged is the amount shown; a confirmation older than an hour is shown again with fresh figures instead of being charged. When the card is declined, the console voids the invoice, which also discards Stripe's pending change, and the member stays on the current tier; the subscription does not go past due. When the bank must approve the payment (3-D Secure), the member gets a link to the invoice's Stripe page, and the tier changes once the payment is approved there; an unapproved change expires after 23 hours, or at the period end if that comes first, and Stripe then voids its invoice and leaves the subscription on its price. The other payment behaviours do not fit: `error_if_incomplete` refuses every payment that needs the bank's approval, and `allow_incomplete` and `default_incomplete` apply the new price whatever the payment does, so a declined card would leave the subscription on the new tier, unpaid and past due. Stripe refuses `pending_if_incomplete` when the customer's default payment method is a SEPA Direct Debit, so such a member cannot upgrade (#286).
- **Upgrade (to a higher tier).** The console asks Stripe to invoice the prorated difference at once (`proration_behavior=always_invoice`) and to apply the new price only when that invoice is paid (`payment_behavior=pending_if_incomplete`). The confirmation shows the amount, and the switch sends Stripe the moment the confirmation prorated at, so the amount charged is the amount shown; a confirmation older than an hour is shown again with fresh figures instead of being charged. Toward a price of another interval, such as a monthly tier to a yearly one, Stripe does not prorate within the current period: it starts a new period at the switch, charges the new price's full period less a credit for the unused part of the current one, and moves the billing date to the day of the switch. The console sends no `billing_cycle_anchor`, so Stripe's rule applies, and the confirmation says "from today": "Your card is charged {net} now, after a credit for the unused part of your current plan, then {then} from today." with Stripe's figure as `{net}`, "Nothing is charged now; then {then} from today." when nothing is due, and "Your card is charged {target price} now, minus a credit for the unused part of your current plan, then {then} from today." when Stripe gives no figure. A subscription moved to Stripe's flexible billing mode in the Dashboard keeps its billing date across an interval change, which the confirmation does not know. When the card is declined, the console voids the invoice, which also discards Stripe's pending change, and the member stays on the current tier; the subscription does not go past due. When the bank must approve the payment (3-D Secure), the member gets a link to the invoice's Stripe page, and the tier changes once the payment is approved there; an unapproved change expires after 23 hours, or at the period end if that comes first, and Stripe then voids its invoice and leaves the subscription on its price. The other payment behaviours do not fit: `error_if_incomplete` refuses every payment that needs the bank's approval, and `allow_incomplete` and `default_incomplete` apply the new price whatever the payment does, so a declined card would leave the subscription on the new tier, unpaid and past due. Stripe refuses `pending_if_incomplete` when the customer's default payment method is a SEPA Direct Debit, so such a member cannot upgrade (#286).
- **Downgrade (to a lower tier).** The change waits for renewal. The console attaches a Stripe subscription schedule whose second phase carries the lower price, with no proration, so the renewal invoice bills the lower tier and no credit arises. The schedule's metadata carries `scheduled_from_price_id`, the console price the member was on when the downgrade was scheduled.
- **Cancellation.** Every paid plan offers **Cancel plan** on its card. Access continues until the end of the paid period (`cancel_at_period_end`), after which the organization moves to its type's default plan, when one is configured, or leaves the ladder. An upgrade still awaiting the bank's approval survives the cancellation: Stripe keeps its pending change through `cancel_at_period_end`, and paying its invoice later applies the new price while the plan still ends at the period end.
- **Taking a scheduled move back.** While a downgrade or a cancellation is scheduled, the plan's card offers **Keep plan**, which releases the schedule or clears `cancel_at_period_end` at no charge. While a downgrade is scheduled, **Cancel plan** follows it and replaces the downgrade; while a cancellation is scheduled, **Keep plan** is the only control.
- **A plan whose payment is due.** A subscription Stripe holds as `past_due` or `unpaid` keeps its card on the Products page, shown as the current plan with its state, while its entitlements are suspended. No switch is offered in either direction and a posted one is refused before any Stripe call, because a switch would invoice while the failed invoice is still open. **Keep plan** and **Cancel plan** at the period end work as on an active plan. A posted immediate cancellation is refused, because Stripe's immediate cancellation stops automatic collection of the customer's finalized invoices, and a **Keep plan** over a downgrade that Stripe has already applied is refused, because there is nothing left to take back. The unpaid invoice stays due after a cancellation: once the cancellation takes effect, Stripe stops retrying that subscription's open invoice and leaves the customer's other subscriptions collecting, so the member pays it through the invoice's **Pay invoice**. A paused subscription shows its state and is refused every act; only a subscription made outside the console reaches `paused`, which follows a trial that ends without a payment method, because the console's Checkout creates no trial.
- **A grant over a paid plan.** An operator's grant over a tier a subscription pays for keeps the ladder until it ends or is revoked, and the subscription delivers nothing there. The console pauses the subscription's collection at Stripe (`pause_collection` with `behavior=void`, and the marker `member_console_collection=paused_for_grant` in the subscription's metadata) within seconds, through the outbox, and sets no `resumes_at`. Each renewal Stripe makes while the grant holds is a draft for about an hour and is then voided, so nothing is charged. When the grant ends, the tier is delivered again in the act that ends the grant, and the console clears the pause and the marker within seconds; no money moves at either edge. While the grant holds the ladder, the organization page's Billing card and the subscription's row in the operator's subscriptions list carry a **Not delivering** badge. Once Stripe confirms the pause, which the subscription row records, its title reads "Billing paused while a grant holds {ladder}." ("grants hold" when several grants hold the ladders); until it does, and while the pause has failed, it reads "Billing continues while a grant holds {ladder}." When the superseded tier is a tier of several ladders, the grant ends it on every one of them, so the tier delivers nothing anywhere and the console pauses its collection as for one ladder. The title then adds the ladders of that tier no plan holds: "Billing paused while a grant holds {ladder}; nothing is delivered on {ladders}.", with "Billing continues" in its place until Stripe confirms the pause. On each of those ladders the Products page draws the paid tier's card with its pause and no control: "Paused while {granted} is granted on {ladder}", or "Billing continues while {granted} is granted on {ladder}" until Stripe confirms the pause; the controls stay on the card of the ladder the grant holds. When the ladder is left to the default or to nothing in another way, such as a tier removal, the subscription's next reconcile delivers its tier again. The member's Products page draws the card of the tier the subscription pays for with its own price and its pause as the subscription row records it: "Paused while {granted} is granted" once Stripe confirmed the pause, and the ordinary "{price}, renews {date}" line until it has. That card offers Cancel plan, which sets `cancel_at_period_end` on the subscription and leaves the pause as it is, so the subscription ends at its period end with no final invoice and the grant keeps the ladder; Keep plan, which clears that flag and any scheduled downgrade and likewise sends no pause change; and, for a subscription whose payment is due, Pay invoice, which opens the hosted page of the subscription's open invoice rather than its latest invoice, because the latest invoice is a renewal voided while the grant holds. If the grant ends before the cancellation date, the subscription delivers its tier until its period ends. A new purchase over a grant still replaces the grant. A switch to the granted tier or above ends the grant's hold, and the subscription delivers the new tier. Under a grant ranked below the paid tier the paid tier's card also offers **Resume plan**, before Cancel plan: the member's request confers the subscription's own tier over the grant, which the grant's end would do, and makes no Stripe call; the console then clears the pause and the marker through the outbox, and nothing is charged until the next renewal, which is charged at the subscription's own price. The rest of a period whose renewal was voided is therefore free, up to one whole period (nearly a year on an annual price), at a moment the member picks; this is the end edge's cost, accepted until the exact money rule (#280). Resume plan is not offered while the pause is not yet confirmed, while a cancellation or a downgrade is scheduled, while the payment is due or Stripe has paused the subscription, for a pause made outside the console, or for a tier that belongs to another ladder too. What this accepts until the exact money rule (#280): the renewal date decides which way a grant that is not whole periods falls, because a period's renewal is voided or charged whole; a pause landing after a renewal's finalization lets that renewal be charged, and a clear landing before the finalization of a renewal made while paused lets it be charged; a pause or clear that lands late moves a whole period one way or the other, and a void cannot be undone; the first charge after a grant can come without Stripe's upcoming-renewal reminder. A subscription item with no entitlement set, such as a donation line added in the Dashboard, stops billing with the tier beside it while the pause holds. An invoice open before the grant stays due: Stripe keeps retrying it, and the account's dunning setting can cancel the paused subscription.
- **The switch from a paused plan.** A subscription the console paused for a grant switches to a tier at or above the grant (the granted tier's own card offers **Pay for this plan**, the tiers above it **Upgrade**) and above the paid tier, unless the paid tier is also a tier of another ladder, which is offered no switch, because the switch would move the subscription off that ladder, and a target that is a tier of another ladder too is offered none, because its conferral would supersede whatever holds that ladder, a grant included. Stripe refuses every update that would create an invoice on a paused subscription, an `always_invoice` price change and a billing-cycle reset (`billing_cycle_anchor=now`) alike, and it refuses `pause_collection` beside `payment_behavior=pending_if_incomplete`, which the approval path needs, so the switch is two calls: the console clears the pause and its marker, then sends the upgrade unchanged. The confirmation's heading reads "Pay for {tier}?" on the granted tier's own card and "Switch to {tier}?" on the others, and its second paragraph opens "Your subscription replaces the grant.", and once the subscription's row records the pause it states no figure, because Stripe answers no proration preview for a paused subscription. A confirmation shown before a renewal and sent after it is shown again with fresh figures before any change is sent, because Stripe refuses a `proration_date` from before the current period's start. When the card declines, the payment needs approval, or any call fails, the console pauses the subscription again at once, through the same reconcile that decides every pause, and an outbox entry written before the clear and due four minutes later does so if the process stops between the calls. The switch refuses with "Try again in a moment." while a paused renewal is about to finalize, about the last ten minutes of the renewal's draft hour, and, in live mode, for as long as the endpoint's `invoice.created` deliveries fail, up to 72 hours, because Stripe holds a renewal's finalization until they succeed. Cancel plan voids the open invoice of a switch the member left awaiting approval before it sets `cancel_at_period_end`, because paying that invoice later would apply the switch on a plan the member chose to end. A pause made in the Dashboard refuses the switch with the generic failure, and the console leaves that pause as its maker left it. A switch toward a price of another interval is offered and its confirmation reads "Your card is charged {target price} now, minus a credit for the unused part of your current plan, then {then} from today.", because Stripe then charges the new price's full period less the unused time and moves the billing date to the switch. A tier priced in another currency than the subscription's is never offered, because Stripe refuses a change of a subscription's currency. What this accepts until the exact money rule (#280): the switch's proration credits the old price's unused time for a period whose renewal was voided, so the member pays the price difference, not the new price, for the rest of that period (toward a price of another interval, the new period less that credit); a renewal that Stripe finalizes inside the unpaused window despite the guard, because it finalizes earlier than the later of the draft's two times or because the restore is late, is charged in full on a declined or approval-pending attempt, and fails and opens dunning on a declining card; a switch applied inside a paused renewal's draft hour also pays that renewal at the old price in full, on top of the proration, and when that charge fails the subscription that replaced the grant goes past due and its tier is suspended; an approval paid after the operator changed what holds the ladder or how the tiers rank takes the ladder only when the new tier ranks at or above the holder then, any member of the organization can pay the abandoned approval's invoice from the Billing page without reading the confirmation, and a lost apply event delays the paid tier up to one period; and after a paid approval the subscription delivers for about 15 seconds while Stripe still pauses it, so a renewal finalized then is voided at the operator's cost.
- **The switch from a paused plan.** A subscription the console paused for a grant switches to a tier at or above the grant (the granted tier's own card offers **Pay for this plan**, the tiers above it **Upgrade**) and above the paid tier, unless the paid tier is also a tier of another ladder, which is offered no switch, because the switch would move the subscription off that ladder, and a target that is a tier of another ladder too is offered none, because its conferral would supersede whatever holds that ladder, a grant included. Stripe refuses every update that would create an invoice on a paused subscription, an `always_invoice` price change and a billing-cycle reset (`billing_cycle_anchor=now`) alike, and it refuses `pause_collection` beside `payment_behavior=pending_if_incomplete`, which the approval path needs, so the switch is two calls: the console clears the pause and its marker, then sends the upgrade unchanged. The confirmation's heading reads "Pay for {tier}?" on the granted tier's own card and "Switch to {tier}?" on the others, and its second paragraph opens "Your subscription replaces the grant.", and once the subscription's row records the pause it states no figure, because Stripe answers no proration preview for a paused subscription. A confirmation shown before a renewal and sent after it is shown again with fresh figures before any change is sent, because Stripe refuses a `proration_date` from before the current period's start. When the card declines, the payment needs approval, or any call fails, the console pauses the subscription again at once, through the same reconcile that decides every pause, and an outbox entry written before the clear and due four minutes later does so if the process stops between the calls. The switch refuses with "Try again in a moment." while a paused renewal is about to finalize, about the last ten minutes of the renewal's draft hour, and, in live mode, for as long as the endpoint's `invoice.created` deliveries fail, up to 72 hours, because Stripe holds a renewal's finalization until they succeed. Cancel plan voids the open invoice of a switch the member left awaiting approval before it sets `cancel_at_period_end`, because paying that invoice later would apply the switch on a plan the member chose to end. A pause made in the Dashboard refuses the switch with the generic failure, and the console leaves that pause as its maker left it. A switch toward a price of another interval is offered; once the subscription's row records the pause, its confirmation reads "Your card is charged {target price} now, minus a credit for the unused part of your current plan, then {then} from today.", and before then it states Stripe's figure as an upgrade across intervals does (the Upgrade bullet). A tier priced in another currency than the subscription's is never offered, because Stripe refuses a change of a subscription's currency. What this accepts until the exact money rule (#280): the switch's proration credits the old price's unused time for a period whose renewal was voided, so the member pays the price difference, not the new price, for the rest of that period (toward a price of another interval, the new period less that credit); a renewal that Stripe finalizes inside the unpaused window despite the guard, because it finalizes earlier than the later of the draft's two times or because the restore is late, is charged in full on a declined or approval-pending attempt, and fails and opens dunning on a declining card; a switch applied inside a paused renewal's draft hour also pays that renewal at the old price in full, on top of the proration, and when that charge fails the subscription that replaced the grant goes past due and its tier is suspended; an approval paid after the operator changed what holds the ladder or how the tiers rank takes the ladder only when the new tier ranks at or above the holder then, any member of the organization can pay the abandoned approval's invoice from the Billing page without reading the confirmation, and a lost apply event delays the paid tier up to one period; and after a paid approval the subscription delivers for about 15 seconds while Stripe still pauses it, so a renewal finalized then is voided at the operator's cost.
- **Pay invoice.** The card of a plan whose payment is due, and the detail of an open invoice, link to Stripe's hosted invoice page. The console reads the page's URL from Stripe when the member opens the link and redirects there; it never stores or logs the URL, which is a bearer link, and it needs no key permission beyond the reads of subscriptions and invoices that the reconciles already make. Paying there 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 charges the failed card again. Stripe's page still takes payment for an invoice Stripe has marked uncollectible, which the console shows as open until the invoice sweep reads it again.
A restricted key needs permission for the calls these moves make, beyond those the console already uses: `POST /v1/subscription_schedules`, `POST /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}/release` and `POST /v1/invoices/{id}/void`. The paused switch also lists a subscription's draft invoices with `GET /v1/invoices`, among the reads the key already holds for the invoice events. The console also updates a subscription with `POST /v1/subscriptions/{id}` to set `pause_collection` while a grant holds its ladder and to clear it when the grant no longer does, which is the Subscriptions write the plan moves already need. Stripe names the missing permission in the error a key without it receives.
+12 -8
View File
@@ -290,11 +290,13 @@ func SwitchPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode st
}
// upgradeNow sends an upgrade as a pending update invoiced at once
// (plan-switch-timing D1): Stripe charges the prorated difference and moves
// the item to the target price only when that invoice is paid. A declined
// card leaves the subscription, the member's tier and every scheduled change
// as they were; a payment needing the cardholder's approval returns the
// invoice's hosted page. Only an applied upgrade drops a scheduled move.
// (plan-switch-timing D1): Stripe charges the prorated difference, or toward a
// price of another interval the new price's full period less the unused time,
// moving the billing date to the switch, and moves the item to the target
// price only when that invoice is paid. A declined card leaves the
// subscription, the member's tier and every scheduled change as they were; a
// payment needing the cardholder's approval returns the invoice's hosted page.
// Only an applied upgrade drops a scheduled move.
//
// It has two forms, chosen from the subscription it fetches first. When the
// subscription carries the console's collection pause (consolePaused), Stripe
@@ -719,9 +721,11 @@ func PreviewSwitch(ctx context.Context, db *sql.DB, logger *slog.Logger, orgID,
logger.Debug("preview: stripe create invoice preview", slog.Any("error", err))
return unavailable
}
// always_invoice previews the invoice the switch would issue now, which
// carries only the proration lines; amount_due is what the card is
// charged after any customer credit balance.
// always_invoice previews the invoice the switch would issue now: its
// proration lines or, toward a price of another interval, the credit for
// the unused time and the new price's full period, which starts at the
// switch. amount_due is what the card is charged after any customer
// credit balance.
return SwitchPreview{Available: true, NetAmount: inv.AmountDue, Currency: string(inv.Currency), ProrationDate: moment}
}
+15 -4
View File
@@ -26,6 +26,11 @@ type Position struct {
// false when a grant, the organization type's default or a purchase holds
// it.
Subscribed bool
// Interval is the recurring interval of the price through which the
// subscription that backs the current rung pays for it; "" when no
// subscription backs the rung or the console holds no live row for that
// subscription.
Interval string
// CancelScheduled is true when the subscription's cancellation is
// scheduled.
CancelScheduled bool
@@ -184,9 +189,12 @@ type Move struct {
// holds: the subscription starts paying for the plan the card already
// shows as current.
OwnTier bool
// NewPeriod is true when the switch moves the paying subscription to a
// price of another recurring interval, so Stripe charges the new price's
// full period less the unused time and moves the billing date.
// NewPeriod is true when an enabled upward switch moves the subscription
// to a price of another recurring interval, under rule 3a.2 for the
// paying subscription and rule 16 for the one that backs the rung. Stripe
// then charges the new price's full period less the unused time and moves
// the billing date to the switch. A downgrade never carries it: it takes
// effect at the period end, where the new period starts anyway.
NewPeriod bool
}
@@ -280,7 +288,9 @@ func (p Position) rankOf(productID string) (int32, bool) {
// rung no subscription pays for: Checkout, through the price rule.
// 14. The target is the unpriced rank-0 tier: Cancel.
// 15. The position is suspended: none, with Suspended.
// 16. Otherwise: Switch, through the price rule.
// 16. Otherwise: Switch, through the price rule; an enabled upward switch
// toward a price of another recurring interval than the subscription's
// moves it to a new period.
//
// The paused rule sits above the held rung's rule 8 and the free rung's rule
// 14 because it replaces their Cancel answers on a paused position, which is
@@ -431,6 +441,7 @@ func (a ask) route() Move {
m = Move{Reason: Suspended}
default:
m = priceRule(Switch, a.Target)
m.NewPeriod = m.Enabled && a.rel == Upgrade && a.Interval != "" && a.Price.Interval != a.Interval
}
m.Relation = a.rel
return m
+21 -1
View File
@@ -32,6 +32,13 @@ func holds(current string, subscribed bool) Position {
return Position{Tiers: ladderTiers, Current: current, Subscribed: subscribed}
}
// holdsAt is holds(current, true) with the interval its subscription pays.
func holdsAt(current, interval string) Position {
p := holds(current, true)
p.Interval = interval
return p
}
// target is a listed product with the buyable price; the free rung has none.
func target(id string) Target {
t := Target{ProductID: id, Listed: true, Price: buyable}
@@ -91,6 +98,19 @@ func TestOffer(t *testing.T) {
kind: Switch, enabled: true, relation: Downgrade},
{name: "a subscription offers nothing on its own tier", pos: holds("plus", true), target: target("plus"),
reason: Current},
{name: "a subscription offers a switch to a new period above it", pos: holdsAt("plus", "month"), target: pricedTarget("pro", "usd", "year", true),
kind: Switch, enabled: true, relation: Upgrade, newPeriod: true},
{name: "a yearly subscription offers a switch to a new period above it", pos: holdsAt("basic", "year"), target: pricedTarget("pro", "usd", "month", true),
kind: Switch, enabled: true, relation: Upgrade, newPeriod: true},
{name: "a subscription offers a switch above it at its own interval", pos: holdsAt("plus", "month"), target: target("pro"),
kind: Switch, enabled: true, relation: Upgrade},
{name: "a subscription's switch below it to another interval is no new period", pos: holdsAt("plus", "month"), target: pricedTarget("basic", "usd", "year", true),
kind: Switch, enabled: true, relation: Downgrade},
{name: "a subscription whose interval is unknown offers no new period", pos: holds("plus", true), target: pricedTarget("pro", "usd", "year", true),
kind: Switch, enabled: true, relation: Upgrade},
{name: "a disabled switch to another interval is no new period", pos: holdsAt("plus", "month"),
target: with(pricedTarget("pro", "usd", "year", true), func(t *Target) { t.Price.Purchasable = false }),
kind: Switch, reason: Unpurchasable, relation: Upgrade},
// The held rung.
{name: "the held rung below the current rung is a cancellation", pos: held(nil), target: target("standard"),
@@ -371,7 +391,7 @@ type offerCase struct {
relation Relation
held bool
// replacesGrant and newPeriod are the switch's two facts for the
// confirmation (rule 3a.2).
// confirmation: replacesGrant from rule 3a.2, newPeriod from rules 3a.2 and 16.
replacesGrant bool
newPeriod bool
// ownTier is true when the switch's target is the current rung.
+14 -8
View File
@@ -28,12 +28,14 @@ import (
// the second paragraph with "Your subscription replaces the grant." for a
// switch of a subscription that pays under a grant. ownTier, true when that
// switch's target is the current rung the grant holds, words the heading
// "Pay for <tier>?" in place of "Switch to <tier>?". newPeriodCharge, when set,
// is the target price's amount and replaces the charge sentence whatever the
// preview answered: Stripe charges the new price's full period less a credit
// for the unused time and moves the billing date, and previews nothing while
// the subscription is paused, so its figure would otherwise be followed by
// "for the rest of this period", which is false when the period restarts.
// "Pay for <tier>?" in place of "Switch to <tier>?". newPeriodCharge is set for
// an upgrade the decision marks as a new period and is the target price's
// amount: with the preview's figure the sentence states the figure "after a
// credit", with nothing due it states that nothing is charged, and with no
// figure it states newPeriodCharge "minus a credit"; each ends "from today",
// because Stripe charges the new price's full period less the unused time and
// moves the billing date to the switch, so "for the rest of this period" would
// be false.
// Tier names are the one piece here that is not the console's own copy, so
// they alone are escaped.
func planMoveMessage(tierName, currentName, then string, replacesGrant, ownTier bool, newPeriodCharge string, p fulfillment.SwitchPreview) template.HTML {
@@ -47,6 +49,10 @@ func planMoveMessage(tierName, currentName, then string, replacesGrant, ownTier
msg += "Your subscription replaces the grant. "
}
switch {
case newPeriodCharge != "" && p.Available && p.NetAmount > 0:
msg += "Your card is charged " + esc(formatMoney(p.NetAmount, p.Currency)) + " now, after a credit for the unused part of your current plan, then " + esc(then) + " from today."
case newPeriodCharge != "" && p.Available:
msg += "Nothing is charged now; then " + esc(then) + " from today."
case newPeriodCharge != "":
msg += "Your card is charged " + esc(newPeriodCharge) + " now, minus a credit for the unused part of your current plan, then " + esc(then) + " from today."
case p.Downgrade && p.Available:
@@ -124,8 +130,8 @@ func (h *MemberProductsHandler) GetSwitchPreview(w http.ResponseWriter, r *http.
// again with fresh figures), and a declined upgrade, whose reason formErr
// carries into the form-level slot at status 422. move is the move
// switchOffer allowed: the confirmation states its ReplacesGrant (Copy M3's
// sentence) and, for its NewPeriod, the new period's charge (Copy M12). The
// caller has already applied the switch's gates.
// sentence) and, for its NewPeriod, the new period's charge (Copy N1, N2 or
// M12). The caller has already applied the switch's gates.
func (h *MemberProductsHandler) renderSwitchPreview(w http.ResponseWriter, ctx context.Context, orgID, ladderID string, target billing.Price, move plans.Move, status int, formErr string) {
var tierName string
if product, err := h.BillingQ.GetProductByID(ctx, target.ProductID); err == nil {
+7 -1
View File
@@ -45,7 +45,13 @@ func TestPlanMoveMessageStatesTheTiming(t *testing.T) {
{name: "a new period with no figure", p: fulfillment.SwitchPreview{}, then: yearly, replacesGrant: true, newPeriodCharge: "$300.00",
want: "Your subscription replaces the grant. Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today."},
{name: "a new period with a figure", p: fulfillment.SwitchPreview{Available: true, NetAmount: 27032, Currency: "usd"}, then: yearly, replacesGrant: true, newPeriodCharge: "$300.00",
want: "Your subscription replaces the grant. Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today."},
want: "Your subscription replaces the grant. Your card is charged $270.32 now, after a credit for the unused part of your current plan, then $300.00 a year from today."},
{name: "a new period with a figure, no grant", p: fulfillment.SwitchPreview{Available: true, NetAmount: 27032, Currency: "usd"}, then: yearly, newPeriodCharge: "$300.00",
want: "Your card is charged $270.32 now, after a credit for the unused part of your current plan, then $300.00 a year from today."},
{name: "a new period with nothing due", p: fulfillment.SwitchPreview{Available: true, NetAmount: 0, Currency: "usd"}, then: then, newPeriodCharge: "$30.00",
want: "Nothing is charged now; then $30.00 a month from today."},
{name: "a new period with no figure, no grant", p: fulfillment.SwitchPreview{}, then: yearly, newPeriodCharge: "$300.00",
want: "Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today."},
{name: "the granted tier's own card", p: fulfillment.SwitchPreview{Available: true, NetAmount: 900, Currency: "usd"}, then: then, replacesGrant: true, ownTier: true,
want: "Your subscription replaces the grant. Your card is charged $9.00 now for the rest of this period, then $30.00 a month."},
{name: "the granted tier's own card, a new period", p: fulfillment.SwitchPreview{}, then: yearly, replacesGrant: true, ownTier: true, newPeriodCharge: "$300.00",
+19
View File
@@ -236,6 +236,19 @@ func payingFrom(r billing.ListSubscriptionRungsByOrgRow, held entitlements.ListL
}
}
// backingInterval is the recurring interval of the price through which the
// subscription that backs the held position pays for its rung, read from the
// rows ListSubscriptionRungsByOrg returned: the first row of that subscription
// on the ladder and the held product. It is "" when no row matches.
func backingInterval(rows []billing.ListSubscriptionRungsByOrgRow, held entitlements.ListLadderPositionsByPoolRow, ladderID string) string {
for _, r := range rows {
if r.SubscriptionID == held.SubscriptionID.UUID.String() && r.PlanLadderID == ladderID && r.ProductID == held.ProductID {
return r.RecurringInterval.String
}
}
return ""
}
// ladderFacts are what the Products page draws beside a position: the row
// behind the current rung, the date an armed cancellation takes effect, the
// tier a scheduled downgrade moves to and when, and the cancel prediction.
@@ -303,6 +316,11 @@ func suspensionOf(positionStatus, subscriptionStatus string) string {
// carries no foreign key, so the provision can name it before then, and the
// position is subscribed with no period end.
//
// pos.Interval comes from l.subscriptionRows, which payingOn fills: it has run
// for the organization before every call, as all four callers ensure
// (billing.go, member_plans.go, switchOffer and resumeOfferFor). Without it
// the interval is "" and the confirmation reads the same-interval sentences.
//
// A suspended position is the current one, and pos.Suspended says so; it is
// paused only when its subscription's status is paused. A suspended
// position's scheduled downgrade whose date is not after now has already been
@@ -320,6 +338,7 @@ func (l *positionLoader) onLadder(ctx context.Context, ladderID string, tiers []
return pos, facts, nil
}
pos.Subscribed = true
pos.Interval = backingInterval(l.subscriptionRows, held, ladderID)
if !held.ProjectedSubscriptionID.Valid {
return pos, facts, nil
}
+105 -6
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
"testing"
@@ -24,6 +25,7 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
stripe "github.com/stripe/stripe-go/v81"
@@ -42,9 +44,25 @@ type switchTimingMember struct {
stripePrices [3]string
stripeCustomerID string
stripeItemID string
db *sql.DB
}
// tierPrice is the price a tier of the switch-timing ladder is created with.
type tierPrice struct {
cents int32
interval string
}
// seedSwitchTimingMember seeds the member with every tier priced monthly:
// $5.00, $12.00 and $30.00.
func seedSwitchTimingMember(t *testing.T) switchTimingMember {
t.Helper()
return seedSwitchTimingMemberPriced(t, [3]tierPrice{{500, "month"}, {1200, "month"}, {3000, "month"}})
}
// seedSwitchTimingMemberPriced seeds the member with the tiers' prices in rank
// order, Basic, Plus and Pro; the member holds Plus.
func seedSwitchTimingMemberPriced(t *testing.T, tiers [3]tierPrice) switchTimingMember {
t.Helper()
database := dbtest.Open(t)
ctx := context.Background()
@@ -64,11 +82,11 @@ func seedSwitchTimingMember(t *testing.T) switchTimingMember {
if err != nil {
t.Fatalf("create ladder: %v", err)
}
m := switchTimingMember{ladderID: ladder.PlanLadderID, stripeSubID: "sub_pst_" + sfx, stripeCustomerID: "cus_pst_" + sfx, stripeItemID: "si_pst_" + sfx}
m := switchTimingMember{ladderID: ladder.PlanLadderID, stripeSubID: "sub_pst_" + sfx, stripeCustomerID: "cus_pst_" + sfx, stripeItemID: "si_pst_" + sfx, db: database}
for i, spec := range []struct {
label string
amount int32
}{{"Basic", 500}, {"Plus", 1200}, {"Pro", 3000}} {
label string
tierPrice
}{{"Basic", tiers[0]}, {"Plus", tiers[1]}, {"Pro", tiers[2]}} {
set, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: "pst-" + spec.label + "-" + sfx, IsActive: true})
if err != nil {
t.Fatalf("create set: %v", err)
@@ -81,8 +99,8 @@ func seedSwitchTimingMember(t *testing.T) switchTimingMember {
t.Fatalf("create product: %v", err)
}
price, err := bq.CreatePrice(ctx, billing.CreatePriceParams{
ProductID: prod.ProductID, Currency: "usd", UnitAmount: spec.amount,
RecurringInterval: sql.NullString{String: "month", Valid: true},
ProductID: prod.ProductID, Currency: "usd", UnitAmount: spec.cents,
RecurringInterval: sql.NullString{String: spec.interval, Valid: true},
})
if err != nil {
t.Fatalf("create price: %v", err)
@@ -281,3 +299,84 @@ func TestSwitchOverASubscriptionTheKeyCannotReachIsRefused(t *testing.T) {
t.Errorf("the refused switch made %d Stripe calls, want none", n)
}
}
// switch-across-intervals-starts-a-period D2, D3: the preview of an upgrade
// that moves a subscription holding its rung to a price of another interval
// reads a new period, from the interval of the price the subscription pays.
func TestSwitchPreviewAcrossIntervals(t *testing.T) {
type intervalRow struct {
name string
invoice *stripe.Invoice
refuse bool
// target is the rank the preview is asked for.
target int
want func(t *testing.T, m switchTimingMember) string
}
text := func(s string) func(*testing.T, switchTimingMember) string {
return func(*testing.T, switchTimingMember) string { return s }
}
fixtures := []struct {
name string
tiers [3]tierPrice
rows []intervalRow
}{
{"Pro priced yearly", [3]tierPrice{{500, "month"}, {1200, "month"}, {30000, "year"}}, []intervalRow{
{name: "a yearly Pro with a figure", invoice: &stripe.Invoice{Currency: stripe.CurrencyUSD, AmountDue: 27032}, target: 2,
want: text("Your card is charged $270.32 now, after a credit for the unused part of your current plan, then $300.00 a year from today.")},
{name: "a yearly Pro with nothing due", invoice: &stripe.Invoice{Currency: stripe.CurrencyUSD, AmountDue: 0}, target: 2,
want: text("Nothing is charged now; then $300.00 a year from today.")},
{name: "a yearly Pro without a figure", refuse: true, target: 2,
want: text("Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today.")},
}},
{"Basic priced yearly", [3]tierPrice{{5000, "year"}, {1200, "month"}, {3000, "month"}}, []intervalRow{
{name: "a yearly Basic, a downgrade", target: 0, want: func(t *testing.T, m switchTimingMember) string {
t.Helper()
mapping, err := internalstripe.New(m.db).GetSubscriptionMappingByStripeID(context.Background(), sql.NullString{String: m.stripeSubID, Valid: true})
if err != nil {
t.Fatalf("read the subscription mapping: %v", err)
}
sub, err := billing.New(m.db).GetSubscriptionByID(context.Background(), mapping.SubscriptionID)
if err != nil {
t.Fatalf("read the subscription: %v", err)
}
return "You keep " + m.names[1] + " until " + sub.CurrentPeriodEnd.Time.Format("Jan 2, 2006") + ", then pay $50.00 a year."
}},
}},
{"every tier monthly", [3]tierPrice{{500, "month"}, {1200, "month"}, {3000, "month"}}, []intervalRow{
{name: "a monthly Pro with a figure", invoice: &stripe.Invoice{Currency: stripe.CurrencyUSD, AmountDue: 1800}, target: 2,
want: text("Your card is charged $18.00 now for the rest of this period, then $30.00 a month.")},
}},
}
for _, f := range fixtures {
t.Run(f.name, func(t *testing.T) {
m := seedSwitchTimingMemberPriced(t, f.tiers)
for _, row := range f.rows {
t.Run(row.name, func(t *testing.T) {
checkPreviewAcrossIntervals(t, m, row.name, row.target, row.invoice, row.refuse, row.want(t, m))
})
}
})
}
}
// checkPreviewAcrossIntervals sends GetSwitchPreview for the tier of the rank
// given, with the mock answering the invoice preview as the row says, and
// checks the confirmation's second paragraph.
func checkPreviewAcrossIntervals(t *testing.T, m switchTimingMember, name string, rank int, invoice *stripe.Invoice, refuse bool, want string) {
t.Helper()
m.mock.Invoice = invoice
m.mock.PathErrs = nil
if refuse {
m.mock.PathErrs = map[string]error{"/v1/invoices/create_preview": errors.New("stripetest: the preview is refused")}
}
req := httptest.NewRequest(http.MethodGet, "/partials/member/plans/switch/preview?"+url.Values{"ladder_id": {m.ladderID}, "price_id": {m.prices[rank]}}.Encode(), nil).WithContext(m.sctx)
rec := httptest.NewRecorder()
m.h.GetSwitchPreview(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: the preview answered %d, want 200", name, rec.Code)
}
got := htmltest.Parse(t, rec.Body.String()).Texts("p.mb-0")
if !slices.Contains(got, want) {
t.Errorf("%s: the confirmation reads %q, want %q", name, got, want)
}
}
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-30
@@ -0,0 +1,135 @@
## Context
**Stripe's rule.** Stripe's page "Change the price of existing subscriptions" (`/billing/subscriptions/change-price`, section Billing periods) says: "If both prices have the same billing periods (combination of interval and interval_count), the subscription retains the same billing dates. If the prices have different billing periods, the new price is billed at the new interval, starting on the day of the change. [...] switching a customer from a monthly subscription to a yearly subscription moves the billing date to the date of the switch." The page "Set the subscription billing renewal date" (`/billing/subscriptions/billing-cycle`, section Change the billing period on existing subscriptions) says: "The billing_cycle_anchor resets to the current time when switching to a price with a different recurring.interval." The change-price page adds, under Immediate payment, that "Stripe immediately attempts payment when a subscription's billing cycle anchor is reset". The page "Prorations" (`/billing/subscriptions/prorations`, Identify proration items) classifies the debit of such an update as a "Full-period debit created during a subscription update" ("Changing the billing cycle anchor on an existing monthly subscription can start a new billing period and create a non-proration debit for the complete period"), beside the credit proration for the unused time on the previous item. So the invoice an upgrade across intervals issues carries a credit for the unused part of the current period and a debit for the new price's whole period, and the billing date moves to the switch.
The rule is classic billing mode's. The page "Billing mode" (`/billing/subscriptions/billing-mode/compare`) says that in flexible mode "The changes listed in the Classic column don't reset the billing cycle anchor", the interval change among them. The console's subscriptions are classic: stripe-go `v81.4.0` (`go.mod:23`) pins API version `2025-02-24.acacia` (`docs/stripe.md:105`), billing mode exists from `2025-06-30.basil` on, and for every version before `2025-09-30.clover` a subscription created through the API, Checkout's included, defaults to classic (`/billing/subscriptions/billing-mode`). The console sends no `billing_mode` anywhere.
The sandbox has run this rule once, for the paused switch, whose update has the same form: a $30-a-month subscription switched to a $300-a-year price a third of a day into its period was charged $270.32, a year less $29.68 for the unused month, with the billing anchor moved to the moment of the switch and the next period's end a year later (`openspec/changes/archive/2026-10-30-grant-pauses-subscription-billing/design.md:317`). #287's figure for the ordinary upgrade at the same prices is the same $270.32.
**What the console sends.** The upgrade's one update (`upgradeParams`, `internal/fulfillment/plan_change.go:348-369`) sets the item's price, `proration_behavior=always_invoice`, `proration_date` (the preview's moment) and `payment_behavior=pending_if_incomplete` (`:349-357`), and `cancel_at_period_end=false` only when a cancellation is armed and no schedule manages the subscription (`:364-366`). The preview (`PreviewSwitch`, `:654-726`) sends the same item, `proration_behavior` and `proration_date` to `invoice.CreatePreview` (`:707-717`). Neither sends `billing_cycle_anchor`, `trial_end`, `cancel_at` or `billing_mode`, so nothing the console sends overrides Stripe's rule: the preview's `amount_due` is the new period less the credit, which is the figure the confirmation shows, and the switch charges it. `proration_date` sets the moment the credit is computed at; the anchor moves to the moment of the update. The comment at `:722-724` says the preview "carries only the proration lines", which is false across intervals.
**Interval count.** `core.prices` records `recurring_interval` and no interval count (`internal/db/migrations/00001_init.sql:209`). The outbox creates every Stripe price with `recurring[interval]` and no `interval_count` (`internal/integrations/stripe/workflows/outbox.go:503-507`), so Stripe gives it a count of one, and only that create writes a price mapping with a Stripe id (`:518`); the catalog sync writes a pending row with none (`internal/integrations/stripe/catalogsync/catalogsync.go:244`), and the price webhook only updates a mapping that exists (`internal/integrations/stripe/workflows/webhook.go:584-643`). Every price the console can switch to or from therefore has a count of one, and two of them have the same billing period exactly when their intervals are equal.
**Where the decision's facts are.** The offer decision (`plans.Offer`, `internal/plans/offer.go:296`) sets `Move.NewPeriod` (`:187-190`) only in rule 3a.2, the switch of a subscription that pays on a ladder a grant holds (`paidSwitch`, `:362-375`, the comparison at `:373` against `Paying.Interval`). Rule 16, the switch of a subscription that holds its rung (`route`, `:423-436`), reaches it through `priceRule(Switch, ...)` and sets nothing else. Its `Position` (`:19-53`) carries no price for the subscription that backs the rung, and neither does the row it is built from, `ListLadderPositionsByPool` (`internal/entitlements/queries/pool_provision_ladders.sql:221-254`). `ListSubscriptionRungsByOrg` (`internal/billing/queries/subscriptions.sql:140-179`) returns, for every live subscription of the organization, each current item's product, price and `recurring_interval` (`:155-156`) with the item's tier rows. `payingOn` reads it once per request and keeps every row in `l.subscriptionRows` (`internal/server/plan_offer.go:95-110`, `:101`), and every caller of `onLadder` calls `payingOn` first: Checkout's decision (`internal/server/billing.go:284-288`), the Products page (`internal/server/member_plans.go:407-411`), the switch and its preview (`switchOffer`, `plan_offer.go:545-549`) and Resume plan (`resumeOfferFor`, `:619-623`).
**The confirmation.** `renderSwitchPreview` (`internal/server/member_plan_moves.go:129-165`) passes the target price's amount as `newPeriodCharge` when the move it was handed carries `NewPeriod` (`:135-138`), and `planMoveMessage` (`:39-66`) chooses the charge sentence in this order: the new period (`:50-51`), the downgrade (`:52-57`), the figure (`:58-59`), nothing due (`:60-61`), and the fallback (`:62-63`). The new-period case wins whatever the preview answered.
## Goals / Non-Goals
**Goals:**
- Every upgrade across intervals, from a subscription that holds its rung or from one paused for a grant, states the charge without "for the rest of this period" or "the prorated difference", and says that the billing date moves to the switch.
- One case reads one way: the paused switch's no-figure sentence is reused unchanged, and the figure and nothing-due forms serve both paths.
- No read is added.
**Non-Goals:**
- A downgrade's sentence, the approval and decline answers, and every sentence of a switch at the subscription's own interval: each is true (Cases).
- Flexible billing mode, the commitment-boundary switch, and the ordinary switch toward another currency (proposal, Out of scope).
## Decisions
### D1. The rule the sentences state
An upgrade toward a price of another recurring interval than the price the subscription pays starts a new billing period at the switch: Stripe charges the new price's full period less a credit for the unused part of the current one, and the next charge of the new price falls one new interval after the switch. A downgrade toward a price of another interval does not: the console attaches a schedule whose next phase starts at `current_period_end` (`plan_change.go:467-484`), so the new price is first billed on the date the current period ends, which is the date the sentence already names. The console keeps sending no `billing_cycle_anchor`.
Rejected: keeping the billing date. Classic billing mode resets the anchor on every interval change (Context), so the console cannot keep the date; flexible mode keeps it, and moving subscriptions to flexible mode belongs to an upgrade of the Stripe API version, not to a sentence.
Rejected: refusing switches across intervals. A member on a monthly tier could reach a yearly tier above it only by cancelling and buying again, and the move would go missing with no explanation; the paused switch's ruling rejected the same (`openspec/changes/archive/2026-10-30-grant-pauses-subscription-billing/design.md:318`).
### D2. The decision marks the new period for every upward switch
`plans.Position` gains `Interval string`: the recurring interval of the price through which the subscription that backs the current rung pays for it, `""` when no subscription backs the rung or the console holds no live row for it. `onLadder` sets it, right after it sets `Subscribed` (`plan_offer.go:322`), from the row of `l.subscriptionRows` whose subscription is the held position's `subscription_id`, whose ladder is the ladder asked about and whose product is the held product. Its doc comment states the precondition that `payingOn` has run for the organization, which all four callers meet; without it the rows are nil, the interval is `""`, and the confirmation reads today's sentences.
Rule 16 in `route` sets `NewPeriod` on its switch when the switch is enabled, the relation is `Upgrade`, `Position.Interval` is not empty, and the target price's interval differs from it. The relation test keeps a downgrade's sentence (D1). The empty test keeps a subscription the console has no live row for out of the new period: its `Interval` is unknown, and `"" != "year"` would otherwise mark it. Rule 3a.2 is unchanged: it compares with `Paying.Interval`, which its rows always carry. `Move.NewPeriod`'s doc comment names both rules, and the `Offer` doc comment's rule 16 reads "Otherwise: Switch, through the price rule; an enabled upward switch toward a price of another recurring interval than the subscription's moves it to a new period."
Cost: no statement. The row comes from `ListSubscriptionRungsByOrg`, which `payingOn` already reads once per request on every path that asks the decision (Context). `TestProductsPageReadsPayingOnce` (`internal/server/member_plans_read_count_db_test.go:24`) therefore keeps its result.
Rejected, each with its cost:
- **`PreviewSwitch` decides it**, adding `SwitchPreview.NewPeriod` from the current item's price. `resolveSubscriptionOnLadder` reads the items (`plan_change.go:990`) but keeps no price id, so the preview needs one more `GetPrice` read per request; and the fact would then live in two places, the preview for an ordinary switch and the move for a paused one.
- **The handler decides it** in `renderSwitchPreview` from the target (in hand) and the current item's price (one more read per request). It takes a fact out of the decision that already states it for the paused switch, and the switch, the preview and the re-rendered confirmation would have to agree on it separately.
- **Stripe's preview lines decide it**: a line that is not a proration and whose period starts at the proration moment. Only a preview that answered has lines, so the no-figure fallback, the case a member reads when Stripe is unreachable or the row records a pause, would stay false.
### D3. The confirmation's three new-period forms
`planMoveMessage` keeps its signature. `newPeriodCharge`, the target price's amount, now means "this is an upgrade that starts a new period", and the preview chooses among three sentences:
1. **With a figure** (`p.Available && p.NetAmount > 0`): Copy N1, the preview's amount followed by "after a credit for the unused part of your current plan" and the recurring price "from today".
2. **Nothing due** (`p.Available`, amount zero or less): Copy N2, "Nothing is charged now; then {recurring} from today."
3. **No figure**: Copy M12, unchanged.
These three cases come first in the `switch`, as the one new-period case does today, followed by the downgrade, figure, nothing-due and fallback cases unchanged. A downgrade never carries `newPeriodCharge` (D2), so the order does not decide its sentence.
N1 says "after a credit" where M12 says "minus a credit", because N1's amount already has the credit taken off and M12's does not. N2 says nothing about the credit's remainder: when the unused part of a yearly price exceeds a month's price, Stripe charges nothing and the remainder becomes a credit balance that the next invoices draw on (Stripe, "Customer credit balance", `/invoicing/customer/balance`: "The credit balance automatically applies to the next finalized invoice to a customer"). That the update's negative total lands in that balance is not on the pages read and has not been run (What is unverified). The amount can also be zero because a credit balance from earlier covers it, which the console cannot tell apart; N2 holds in both cases.
Rejected wordings:
- "for a full year" or "for the new year": the noun changes with the interval, and the recurring price that follows already names it.
- "less the unused part": bookkeeping language, rejected with M12 (`openspec/changes/archive/2026-10-30-grant-pauses-subscription-billing/design.md:320`).
- "starting today" in place of "from today": M12 says "from today", and one case reads one way.
- N1 with the target price and "minus a credit", as M12: the member would read $300.00 where the card is charged $270.32.
### D4. The paused switch takes the same forms
Today the paused switch reads M12 "whatever the preview answered" (`openspec/specs/stripe-subscription-management/spec.md:839`), so in the seconds before the subscription's row records the pause, when Stripe still answers a figure, the confirmation drops it. Under D3 it reads N1 with that figure, or N2 when nothing is due. Once the row records the pause the preview answers no figure (`plan_change.go:700-702`) and M12 stands as before. The figure is right in that window: the row records no pause because Stripe has none yet, and on an unpaused subscription the preview equals the switch's invoice.
A subscription that holds its rung while Stripe still carries the console's pause (the case the paused switch's design accepted as reading the fallback sentence, F80 in `openspec/changes/archive/2026-10-30-grant-pauses-subscription-billing/design.md:566`) now reads M12: its rule-16 switch carries `NewPeriod` from its own row, and its preview, which the row's pause makes unavailable, gives no figure.
### D5. Documents and comments
`docs/stripe.md`, "Plan changes", the Upgrade bullet (`:211`) gains: toward a price of another interval Stripe does not prorate within the current period; it starts a new period at the switch, charges the new price's full period less a credit for the unused part of the current one, and moves the billing date to the day of the switch; the console sends no `billing_cycle_anchor`, and the confirmation says "from today". The paused switch's bullet (`:217`) keeps M12 for the state with no figure and refers to the Upgrade bullet for the rest. `docs/models/payments-billing.md:13` says "an upgrade reprices the item in place, charged the prorated difference at once and taking effect once that charge is paid"; it gains "or, toward a price of another interval, a new period less a credit for the unused time, which moves the billing date to the switch," after "at once", so it reads "charged the prorated difference at once, or, toward a price of another interval, a new period less a credit for the unused time, which moves the billing date to the switch, and taking effect once that charge is paid". `docs/plan-management.md` states no upgrade's charge or billing date and does not change.
The other surfaces: the member's Products page shows the moved date after the switch, because the reconcile writes Stripe's period end (`internal/fulfillment/reconcile.go:509`) and the current card's "renews {date}" line reads it (`internal/server/member_plans.go:912`); the Billing page lists the switch's invoice with its period and lines as Stripe gives them. Neither states a date the switch could make false.
The specs: "Member-initiated paid-to-paid plan switch" says an upgrade "invoices the prorated difference" (`openspec/specs/stripe-subscription-management/spec.md:11`), and "Live proration preview before a switch" (`:114`) states the figure, nothing-due and fallback sentences for every upgrade; neither says that the billing date can move. The paused switch's requirement (`:839`) states M12 whatever the preview answered. `member-offer`'s decision (`openspec/specs/member-offer/spec.md:11-26`) marks only the paused switch. The deltas modify all four.
## Cases
Every branch of `planMoveMessage` a switch across intervals reaches. "Ordinary" is a subscription that holds its rung (rule 16); "paused" is a subscription that pays under a grant (rule 3a.2). Amounts are those of a $30.00-a-month and a $300.00-a-year price, and of a $300.00-a-year and a $40.00-a-month price for the yearly-to-monthly rows.
| # | Case | Reached when | Today the member reads | True today? | After |
|---|---|---|---|---|---|
| K1 | Ordinary upgrade, monthly to yearly, figure | Stripe's preview answers an amount above zero | "Your card is charged $270.32 now for the rest of this period, then $300.00 a year." | No: the amount pays a new year less the unused month, not the rest of this period, and the next $300.00 falls a year after the switch, not at the old renewal date | N1 |
| K2a | Ordinary upgrade, monthly to yearly, no figure | Stripe's preview fails, or the subscription's row records a pause Stripe still carries | "Your card is charged the prorated difference now, then $300.00 a year." | No: the charge is not a prorated difference, and it understates a new period's charge | M12 |
| K2b | Ordinary upgrade, yearly to monthly, no figure | As K2a | "Your card is charged the prorated difference now, then $40.00 a month." | No, as K2a; and M12 afterwards overstates the charge when the unused year exceeds $40.00, because then nothing is charged | M12, accepted (Risks) |
| K3 | Ordinary upgrade, yearly to monthly, nothing due | The unused part of the year covers the month's price, or a credit balance covers the charge | "Nothing is charged now; then $40.00 a month." | Partly: nothing is charged, but the sentence implies the billing date stays, and it moves to the switch | N2 |
| K4 | Ordinary upgrade, yearly to monthly, figure | The unused part of the year is smaller than the month's price, as in the year's last days | "Your card is charged $39.18 now for the rest of this period, then $40.00 a month." | No, as K1 | N1 |
| K5 | Downgrade to a tier on another interval | Any downgrade on an evergreen subscription | "You keep Plus until Nov 29, 2026, then pay $300.00 a year." | Yes: the schedule's next phase starts at the period end, so the new price is first billed on the date named | Unchanged |
| K6 | Downgrade deferred to a commitment boundary | Never: no code writes a commitment policy | "You keep Plus until {boundary}, then pay {price}." | Not reachable; its firing (`internal/fulfillment/sweep.go:208-215`, `create_prorations`) would reset the anchor at the boundary | Unchanged, out of scope |
| K7 | Paused switch across intervals, no figure | The row records the pause | "Your subscription replaces the grant. Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today." | Yes from monthly to yearly (the sandbox run); from yearly to monthly it names a charge the credit can exceed, as accepted with that switch | Unchanged |
| K8 | Paused switch across intervals, figure | The row records no pause yet and Stripe answers an amount | The same M12 sentence, Stripe's figure dropped | True, without the figure | N1 after "Your subscription replaces the grant." |
| K9 | Paused switch across intervals, nothing due | The row records no pause yet and Stripe answers zero | The same M12 sentence | True only as an equation that ends at zero | N2 after "Your subscription replaces the grant." |
| K10 | Any switch at the subscription's own interval | Every same-interval upgrade, downgrade or fallback | Today's sentences | Yes | Unchanged |
| K11 | Approval, decline, success | After the switch is posted | "Your bank must approve the $270.32 charge before Max starts.", "Your card was declined; you're still on Plus.", "Your plan change has been submitted." | Yes: none names a period | Unchanged |
## Copy
`{net}` is the preview's amount due, `{price}` the target price's amount, `{recurring}` the target price as `recurringPhrase` words it ("$300.00 a year", `internal/server/price_text.go:15-21`). On the paused switch each sentence follows "Your subscription replaces the grant.". Every string below is approved; M12's text was approved for the paused switch and is reused here for the ordinary one.
| ID | Where | Today | Approved text | Decided by | Status |
|---|---|---|---|---|---|
| N1 | Upgrade across intervals, the preview answers an amount above zero (K1, K4, K8) | "Your card is charged $270.32 now for the rest of this period, then $300.00 a year." (K8: M12) | "Your card is charged {net} now, after a credit for the unused part of your current plan, then {recurring} from today." ({net} is the preview's amount due, {recurring} the target price as `recurringPhrase` words it.) | `plans.Offer` (`NewPeriod`), `planMoveMessage` (the figure from `PreviewSwitch`) | approved |
| N2 | Upgrade across intervals, the preview answers zero or less (K3, K9) | "Nothing is charged now; then $40.00 a month." (K9: M12) | "Nothing is charged now; then {recurring} from today." | `plans.Offer`, `planMoveMessage` | approved |
| M12 | Upgrade across intervals, no figure (K2a, K2b, K7) | Ordinary: "Your card is charged the prorated difference now, then $300.00 a year." Paused: unchanged | "Your card is charged {price} now, minus a credit for the unused part of your current plan, then {recurring} from today." | `plans.Offer`, `renderSwitchPreview` (`newPeriodCharge`), `planMoveMessage` | approved (for the paused switch, reused unchanged for the ordinary one) |
N1 sits beside the "for the rest of this period" sentence it replaces; the alternative "Your card is charged $270.32 now, then $300.00 a year from today." was passed over because it does not say why the amount is not the yearly price beside it. N2's alternative, naming the remainder of the credit, was passed over because it is wrong when an earlier credit balance made the amount zero and rests on Stripe behaviour not yet run (What is unverified).
No other string changes. The confirmation's heading, the downgrade's sentence, and the approval, decline and success answers stay (K5, K10, K11).
## Locks and transactions
None. The change reads a row the request already holds and changes two pure functions and the text they return.
## Tests
`TestOffer` gains rows for rule 16 across intervals: upward to another interval (new period), upward at the subscription's own interval, downward to another interval, upward with the subscription's interval unknown, and a disabled upward switch to another interval, none of which but the first carries `NewPeriod`. `TestPlanMoveMessageStatesTheTiming` gains rows for N1 and N2 without the grant sentence and for M12 without it, and its row "a new period with a figure" changes from M12 to N1. A new database test drives `GetSwitchPreview` for a subscription that holds its rung, over three fixtures of tier prices, so `onLadder`'s interval and the decision's new period are read end to end. Each test claim is pinned by a break (`docs/testing.md`, "Prove a test with deliberate breaks"); the tasks list each break with the text its failing test prints.
## What is unverified
- **The upgrade from a yearly price to a monthly one** (K3, N2). Stripe's rule says the anchor resets; that the invoice's negative total becomes a credit balance which the next invoices draw on is read from Stripe's balance page and not run. The sandbox walk of task 5.1 runs it; N2's approved wording does not depend on it; the passed-over wording did.
- **The ordinary upgrade's figure and dates.** The sandbox ran the paused switch's update, which has the same form; #287 reports the ordinary preview's same $270.32. Task 5.1 confirms the invoice's lines and the moved anchor on the ordinary path.
- **A scheduled downgrade followed by an upgrade across intervals.** The console releases the schedule after the update applies, but the update runs while the schedule manages the subscription. Stripe says a direct `items` update on such a subscription "might automatically create a new subscription schedule phase" ("Subscription updates when a schedule is attached", `/billing/subscriptions/subscription-schedules`) and that a pending update is voided when the schedule changes phase ("Expired updates", `/billing/subscriptions/pending-updates`). The sandbox walk (task 5.1, step 8) ran it: after the upgrade to a higher yearly tier the subscription's `schedule` is null, its item carries the yearly price and its period runs a year from the switch; the schedule is `released` and keeps its phases, the downgrade's among them, as a record Stripe no longer applies.
- **A downgrade toward another interval** (K5). Read from the schedule the console attaches and from Stripe's rule; not run in the sandbox.
## Risks / Trade-offs
- **A subscription in flexible billing mode.** An operator can move a subscription to flexible mode in the Dashboard whatever the integration's API version (`/billing/subscriptions/billing-mode`). Its billing date then stays across an interval change, and N1, N2 and M12 say "from today" wrongly. The console reads no billing mode; a Stripe API upgrade to `2025-09-30.clover` or later makes flexible the default for new subscriptions and must revisit this rule.
- **N2 omits the credit's remainder.** A member moving from a yearly price to a monthly one with most of the year unused reads "then $40.00 a month from today" and is charged nothing for several months. The sentence is not false; the passed-over wording names the remainder at the cost of the two conditions above.
- **The no-figure sentence from yearly to monthly, on either path** (K2b, K7): M12 names "{price} now" and the credit can exceed it, so nothing is charged and the sentence overstates. It was accepted with the paused switch; the ordinary switch reaches it only when Stripe's preview fails or Stripe still carries the console's pause.
@@ -0,0 +1,45 @@
## Why
On a ladder whose lower tier is priced monthly and whose upper tier is priced yearly, the upgrade's confirmation reads "Your card is charged $270.32 now for the rest of this period, then $300.00 a year." The amount is Stripe's and is right. The sentence around it is not. When a subscription item moves to a price of another recurring interval, Stripe does not prorate within the current period: it resets the subscription's billing cycle anchor to the moment of the switch, charges the new price's full period less a credit for the unused part of the current one, and moves the billing date to that day (Stripe, "Change the price of existing subscriptions", Billing periods; "Set the subscription billing renewal date", Change the billing period on existing subscriptions). The $270.32 is a year less the unused month, not the rest of this period, and the next $300.00 falls a year after the switch, not at the old renewal date.
The console already words this case for one path. The switch of a subscription paused for a grant reads "Your card is charged {price} now, minus a credit for the unused part of your current plan, then {recurring} from today." toward a price of another interval (`planMoveMessage`, `internal/server/member_plan_moves.go:50-51`), because the offer decision marks that switch with `NewPeriod` (`internal/plans/offer.go:373`). The ordinary upgrade, made by a subscription that holds its rung, carries no such mark, so it reads the same-interval sentences: the figure "for the rest of this period", the no-figure fallback "the prorated difference", and "Nothing is charged now; then {price}." with no word that the billing date moves.
## What Changes
- **The offer decision marks every upward switch across intervals as a new period.** `plans.Position` gains the recurring interval of the price the subscription that backs the rung pays, read from the rows `ListSubscriptionRungsByOrg` already returns once per request (no new statement). Rule 16's enabled upward switch carries `NewPeriod` when the target price's interval differs from it. A downgrade never does: it waits for the period end, where the new period starts anyway.
- **The confirmation states the new period in each of its three forms.** With Stripe's figure: "Your card is charged $270.32 now, after a credit for the unused part of your current plan, then $300.00 a year from today." With nothing due: "Nothing is charged now; then $40.00 a month from today." Without a figure: the paused switch's sentence, unchanged. The paused switch takes the same three forms, so one case reads one way across the console; until now it dropped Stripe's figure when the preview gave one. The wording is approved (design, Copy).
- **Docs:** `docs/stripe.md`'s Upgrade bullet says that a switch across intervals starts a new period and moves the billing date, and that the console sends no `billing_cycle_anchor`. The paused switch's bullet points to it. `docs/models/payments-billing.md`'s sentence on what an upgrade is charged gains the new-period case.
- **Comments:** the decision's rule 16 and `Move.NewPeriod`, `planMoveMessage`, `upgradeNow` and `PreviewSwitch` say what Stripe charges across intervals.
## Out of scope
- **A downgrade toward another interval.** Its sentence, "You keep {tier} until {date}, then pay {price}.", is true: the schedule's next phase starts at the period end (design, Cases).
- **A switch deferred to a commitment boundary.** No code writes a commitment policy, so no surface reaches it.
- **Subscriptions in Stripe's flexible billing mode.** The console's subscriptions are classic: the pinned API version predates `billing_mode`. A subscription an operator moves to flexible mode in the Dashboard keeps its billing date across an interval change, and the new sentences are then wrong about it (design, Risks).
- **A switch toward a price in another currency on a subscription that holds its rung.** The decision refuses it only for the paused switch (`OtherCurrency`, `internal/plans/offer.go:370-372`); an ordinary switch is offered and fails at Stripe with the generic answer. It is a separate defect and is not examined here.
- **The no-figure sentence from a yearly price to a monthly one, on either path**, which names a charge the credit can exceed. It was accepted with the paused switch; the ordinary switch reaches it only when Stripe's preview fails or Stripe still carries the console's pause.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `member-offer`: "One decision says which move a member is offered on a ladder" reads the interval of the price the backing subscription pays and marks an enabled upward switch toward another interval as a new period.
- `stripe-subscription-management`: "Member-initiated paid-to-paid plan switch" states what Stripe charges across intervals and that the console sends no `billing_cycle_anchor`; "Live proration preview before a switch" states the three new-period sentences; "A subscription paused for a grant is switched to the granted tier or above" takes them in place of the one sentence it stated whatever the preview answered.
## Impact
- **Code:** `internal/plans/offer.go` (`Position.Interval`, rule 16 in `route`, the doc comments of `Move.NewPeriod` and `Offer`), `internal/server/plan_offer.go` (`onLadder` sets the interval), `internal/server/member_plan_moves.go` (`planMoveMessage`'s two new cases and its doc comment, `renderSwitchPreview`'s doc comment), `internal/fulfillment/plan_change.go` (the doc comments of `upgradeNow` and `PreviewSwitch`).
- **Schema, routes, Stripe calls:** none. The switch and its preview send the same forms.
- **Reads:** none added. The interval comes from `ListSubscriptionRungsByOrg`, which `payingOn` already reads once per request on every path that asks the decision.
- **Behaviour a member sees:** the upgrade's confirmation across intervals names the charge truthfully and says the billing date moves to today. The Products page already shows the moved renewal date once the switch lands, because the reconcile writes Stripe's period end.
- **Tests:** new rows in `TestOffer` and `TestPlanMoveMessageStatesTheTiming`, one changed row in the latter, and a new database test of the preview across intervals. No golden file carries a switch confirmation, so none changes.
- **Docs:** `docs/stripe.md` and `docs/models/payments-billing.md`.
## Related issues
- **#287** is this change; it closes with it.
- **#280** (the exact money rule for a grant's edges) is untouched: the paused switch's credit for a period whose renewal was voided stays as accepted.
@@ -0,0 +1,176 @@
## MODIFIED Requirements
### Requirement: One decision says which move a member is offered on a ladder
The console SHALL decide which move an organization is offered toward a target price on a plan ladder in one function, `plans.Offer` in `internal/plans`, that reads no database row and calls no payment provider. Its inputs SHALL be the organization's position on the ladder and the target:
- the position: the ladder's tiers and their ranks; the product the organization holds on the ladder, if any, through a position that is active or suspended; whether a subscription pays for that rung, as opposed to a grant, the organization type's default or a purchase, and the recurring interval of the price through which that subscription pays for it; whether the position is suspended, which its ladder row's status says (the subscription reconcile suspends it while the subscription is `past_due`, `unpaid` or `paused`); whether it is paused, which the subscription's status `paused` says; whether the subscription's cancellation is scheduled; the product an active scheduled downgrade moves to; the held rung (`member-product-discovery`, "Plan-move controls reflect built billing mechanics"); when the cancel prediction exists, the product it puts on the ladder, if any; and, when a live subscription of the organization pays for a tier of the ladder without holding the ladder's rung (the paying subscription), the tier it pays for and its rank, the currency and recurring interval of the price it pays, whether a grant holds the rung, whether the paying subscription is suspended (its status is `past_due`, `unpaid` or `paused`) and whether it is `paused`, whether its `core.subscriptions` row records a `pause_collection` behavior together with the console's marker, whether a cancellation or a downgrade is scheduled on it, and whether its product is a tier of another ladder too;
- the target: the product; whether it clears the member gate (published, active, public); and the target price, with whether it is the product's default price (price-management, "Each product has at most one default price, which every purchase surface tracks"), whether it is recurring, its currency and recurring interval, and whether its Stripe mapping carries an id the current key can reach. A product with no active price is a target with no price.
The paying subscription SHALL be read from the organization's live subscriptions (status `active`, `trialing`, `past_due`, `unpaid` or `paused`), their current items, and the tiers of the items' products, in one statement per pass over the organization's ladders, never one per ladder (a switch that moves and then renders the page again makes two passes, the second reading the state the move left); on each ladder the first such subscription by id that does not hold the ladder's position is the paying subscription. The recurring interval of the price through which the subscription that backs the held rung pays for it SHALL be read from the same statement's rows, with no statement of its own; when those rows hold no live row for that subscription, the interval is unknown.
Its answer SHALL be one move: Checkout, a switch, a downgrade at the period end (the cancellation after which the held rung or the free rung follows), Resume (the paying subscription takes the ladder back from a lower grant at its own price), or none; whether an offered move is enabled; the reason when no move is offered or the move is disabled; for a switch of the paying subscription, that the switch replaces the grant; and, for every enabled switch toward a higher tier, whether it moves the subscription to a price of another recurring interval, which the confirmation states. It SHALL route moves as `member-product-discovery`'s "Plan-move controls reflect built billing mechanics" states, and in addition:
- a price that is not the product's default price SHALL be offered no move;
- a Checkout or a switch toward a price that is one-time, or whose mapping has no Stripe id the key can reach, or toward a tier with no price, SHALL be offered disabled;
- a Checkout or a switch toward a product that fails the member gate SHALL be offered no move;
- a position whose current product is not a tier of the ladder SHALL be offered no move on that ladder;
- on a ladder with a paying subscription while no subscription backs the held position, ahead of the rule for the current tier: the tier the subscription pays for SHALL be offered Resume, enabled, when a grant holds the rung at a tier ranked below it, the paying subscription is not suspended, its row records the pause with the console's marker, no cancellation or downgrade is scheduled on it, and its product is a tier of this ladder alone, through no price rule and no member gate, as Keep plan applies none; it SHALL otherwise be offered no move, with the reason that a subscription pays for it on this ladder; when a grant holds the rung, the paying subscription is not suspended, its product and the tier are tiers of this ladder alone, a tier ranked at or above the held rung and above the tier the subscription pays for SHALL be offered a switch of the paying subscription, through the price rules above, except that a switch those rules enable toward a price in another currency than the paying subscription's price SHALL be offered no move, with the reason that the price is in another currency, because Stripe refuses a change of a subscription's currency; an enabled switch toward a price of another recurring interval SHALL be offered as moving the subscription to a new period; the current tier SHALL otherwise keep the reason that it is current; every other tier SHALL be offered no move, with the reason that a subscription pays on the ladder without holding it, a subscription whose product is a tier of another ladder too included, because the switch would move the subscription off its product's other ladders, and a tier that is a tier of another ladder too included, because its conferral would supersede whatever holds its other ladders, a grant included, which the confirmation cannot name; and no tier of the ladder SHALL be offered Checkout;
- an enabled switch of the subscription that backs the held rung toward a higher tier whose price's recurring interval differs from that of the price the subscription pays SHALL be offered as moving the subscription to a new period, because Stripe then starts a new billing period at the switch; a switch toward a lower tier SHALL NOT, because it takes effect at the period end, and neither SHALL a switch whose subscription's interval is unknown;
- a suspended position SHALL be offered no switch, in either direction, with the reason that the position is suspended; the downgrades at the period end (the held rung's and the free rung's) SHALL be offered as for a position that is not suspended, and, because a subscription backs the rung, no tier of the ladder SHALL be offered Checkout;
- a paused position SHALL be offered no move toward any tier but its current one, with the reason that the position is paused: no switch, and no downgrade at the period end either, because what a cancellation does to a paused subscription is unverified; the current tier keeps the reason that it is current.
The Products page, Checkout, the plan switch, the switch preview and Resume plan SHALL all decide through this function, so no surface offers or accepts a move another refuses. Checkout of a product SHALL be refused when any of the product's ladders has a paying subscription. The switch, its preview and Resume plan SHALL refuse a target the decision answers with no move because the paying subscription is suspended with "Pay the unpaid invoice before changing this plan.", or "A paused plan cannot be changed." when its status is `paused`.
#### Scenario: A price other than the default is offered nothing
- **WHEN** a product on a ladder has an older active price and a newer default price, and the decision is asked about the older price for an organization that holds nothing on the ladder
- **THEN** it answers no move, with the reason that the price is not the default
#### Scenario: A one-time default price cannot be bought
- **WHEN** the decision is asked about a tier whose default price is one-time, for an organization that holds nothing on the ladder
- **THEN** it answers Checkout, disabled, with the reason that the price is one-time
#### Scenario: A tier below a granted rung is offered nothing
- **WHEN** an organization holds Plus by a grant and the decision is asked about Basic, ranked below Plus
- **THEN** it answers no move, with the reason that no subscription pays for the rung
#### Scenario: The granted tier itself is offered nothing
- **WHEN** an organization holds Plus by a grant, no subscription pays on the ladder, and the decision is asked about Plus's default price
- **THEN** it answers no move, with the reason that the tier is current
#### Scenario: A higher tier over a grant is Checkout
- **WHEN** an organization holds Plus by a grant, no subscription pays on the ladder, and the decision is asked about Pro's default price, recurring and reachable
- **THEN** it answers Checkout, enabled
#### Scenario: A tier above a grant that displaced a subscription is a switch
- **WHEN** an organization holds Pro by a grant, its subscription to Plus pays on the ladder and is active, and the decision is asked about Max's default price, recurring and reachable
- **THEN** it answers a switch, enabled, and no Checkout
#### Scenario: A tier of another interval above the grant is a switch to a new period
- **WHEN** the same organization's subscription pays for Plus monthly, and the decision is asked about Max's default price, yearly, recurring and reachable
- **THEN** it answers a switch, enabled, that replaces the grant and moves the subscription to a new period
#### Scenario: A tier priced in another currency above the grant is offered nothing
- **WHEN** the same organization's subscription pays for Plus in US dollars, and the decision is asked about Max's default price, in euros, recurring and reachable
- **THEN** it answers no move, with the reason that the price is in another currency
#### Scenario: The granted tier over a paying subscription is a switch
- **WHEN** the same organization's decision is asked about Pro's default price
- **THEN** it answers a switch, enabled
#### Scenario: A subscription that pays for a bundle is offered no switch
- **WHEN** an organization holds Pro by a grant on the first of two ladders, its subscription pays for Plus, a tier of both ladders, and the decision is asked about Max, a tier of the first ladder alone
- **THEN** it answers no move, with the reason that a subscription pays on the ladder without holding it, and the switch endpoint refuses Max with the unavailable-plan sentence
#### Scenario: The paid tier is offered nothing
- **WHEN** the same organization's decision is asked about Plus
- **THEN** it answers no move, with the reason that a subscription pays for it on this ladder
#### Scenario: A tier between a lower grant and the paid tier is offered nothing
- **WHEN** an organization holds Basic by a grant, its subscription to Pro pays on the ladder, Plus ranks between them, and the decision is asked about Plus
- **THEN** it answers no move, with the reason that a subscription pays on the ladder without holding it
#### Scenario: The paid tier above a lower grant is offered Resume
- **WHEN** the same organization's subscription to Pro is active, its row records the pause with the console's marker, nothing is scheduled on it, and the decision is asked about Pro
- **THEN** it answers Resume, enabled
#### Scenario: A tier above the paid tier over a lower grant is a switch
- **WHEN** the same organization's decision is asked about Max, ranked above Pro
- **THEN** it answers a switch, enabled, and no Checkout
#### Scenario: No Resume without the console's pause
- **WHEN** the same organization's subscription to Pro has no pause recorded yet, or a pause made in the Stripe Dashboard without the console's marker, and the decision is asked about Pro
- **THEN** it answers no move, with the reason that a subscription pays for it on this ladder
#### Scenario: No Resume while a move is scheduled
- **WHEN** the same subscription to Pro has a cancellation or a downgrade scheduled, and the decision is asked about Pro
- **THEN** it answers no move, with the reason that a subscription pays for it on this ladder
#### Scenario: Resume is offered over a product no longer public
- **WHEN** a Basic grant holds the ladder, the organization's subscription to Pro is paused for it with the console's marker, Pro has since been retired, and the decision is asked about Pro
- **THEN** it answers Resume, enabled
#### Scenario: No Resume under a grant of the paid tier or above
- **WHEN** an organization holds Plus or Pro by a grant, its subscription to Plus pays on the ladder, and the decision is asked about Plus
- **THEN** it answers no move, with the reason that a subscription pays for it on this ladder
#### Scenario: A past-due paying subscription is offered no switch
- **WHEN** an organization holds Pro by a grant, its subscription to Plus is `past_due`, and the decision is asked about Max
- **THEN** it answers no move, and the switch endpoint refuses Max with "Pay the unpaid invoice before changing this plan."
#### Scenario: A subscription that holds the rung over another keeps its moves
- **WHEN** an organization's Dashboard-made subscription to Pro holds the ladder's rung over its subscription to Plus, and the decision is asked about Max
- **THEN** it answers a switch, as for any subscription that holds its rung, and not the reason that a subscription pays on the ladder without holding it
#### Scenario: A vacated ladder of a bundle offers no Checkout
- **WHEN** an organization's subscription pays for a bundle that is a tier of ladders L1 and L2, a grant holds L2, L1 holds nothing, and the decision is asked about a tier of L1
- **THEN** it answers no move and no Checkout, because a subscription pays for a tier of L1
#### Scenario: The tier a scheduled downgrade moves to is offered nothing
- **WHEN** an organization's subscription on Pro has a downgrade to Standard scheduled and the decision is asked about Standard's default price
- **THEN** it answers no move, with the reason that the move is scheduled
#### Scenario: A suspended position is offered no switch
- **WHEN** an organization's subscription on Standard is `past_due`, so its position is suspended, and the decision is asked about Pro, ranked above Standard, or about Basic, a priced tier ranked below it
- **THEN** it answers no move, with the reason that the position is suspended
- **AND** it answers no Checkout for either
#### Scenario: A suspended position keeps its downgrade at the period end
- **WHEN** the same organization's subscription bought over a grant for Basic that is marked to resume, and the decision is asked about Basic
- **THEN** it answers the downgrade at the period end, enabled, as it does for a position that is not suspended
#### Scenario: A paused position is offered nothing
- **WHEN** an organization's subscription on Standard is `paused`, and the decision is asked about Pro, about a held rung ranked below Standard, and about the free rung that follows a cancellation
- **THEN** it answers no move for each, with the reason that the position is paused
- **AND** asked about Standard itself, it answers no move, with the reason that the tier is current
#### Scenario: A subscription's switch up to another interval is a switch to a new period
- **WHEN** an organization's subscription pays for Plus monthly and holds the rung, and the decision is asked about Pro's default price, yearly, recurring and reachable
- **THEN** it answers a switch, enabled, that moves the subscription to a new period
#### Scenario: A subscription's switch up at its own interval is not a new period
- **WHEN** Pro's default price is monthly instead, and the same organization's decision is asked about it
- **THEN** it answers a switch, enabled, that does not move the subscription to a new period
#### Scenario: A subscription's switch down to another interval is not a new period
- **WHEN** the same organization's decision is asked about Basic's default price, yearly, ranked below Plus
- **THEN** it answers a switch, enabled, that does not move the subscription to a new period, because the downgrade takes effect at the period end
#### Scenario: A subscription whose interval is unknown is not moved to a new period
- **WHEN** an organization's subscription holds Plus's rung, the console has no live row for it, and the decision is asked about Pro's default price, yearly
- **THEN** it answers a switch, enabled, that does not move the subscription to a new period
#### Scenario: The decision reads nothing
- **WHEN** the decision's package is built
- **THEN** it imports no database, query or payment-provider package, and its table test runs without a database
@@ -0,0 +1,283 @@
## MODIFIED Requirements
### Requirement: Member-initiated paid-to-paid plan switch
The system SHALL provide a member-initiated endpoint that moves an active paid subscription from its current tier to another paid tier on the same ladder by changing the **existing subscription item's price** to the target tier's default `price_id`. It SHALL NOT create a second subscription, nor cancel-and-recreate. The move's direction is decided by the tiers' ranks on the ladder, and the direction decides its timing:
- **Upgrade** (target of higher rank): the system SHALL update the item with the preview's `proration_date` (see "Live proration preview before a switch"), `proration_behavior=always_invoice` and `payment_behavior=pending_if_incomplete`, so Stripe invoices the prorated difference at once and changes the subscription only when that invoice is paid. Toward a price of another recurring interval than the price the subscription pays, Stripe does not prorate within the current period: the invoice charges the new price's full period less a credit for the unused part of the current one, and the subscription's billing cycle anchor moves to the moment of the switch, so the billing date moves to that day. The system SHALL NOT send `billing_cycle_anchor` on the update, so Stripe's rule applies. When the update applies, the system SHALL release a schedule a scheduled downgrade attached, supersede the subscription's active scheduled changes, and reconcile. When the invoice's payment needs the cardholder's approval, the system SHALL answer with the invoice's hosted page and change nothing else. When the payment fails otherwise, the system SHALL void that invoice, so the pending update can never apply, and SHALL answer that the card was declined, changing nothing else. Before it sends an upgrade, the system SHALL void the invoice of any pending update an earlier attempt left.
- **Downgrade** (target of lower rank) on an evergreen subscription or under the `allow` policy: the system SHALL leave the current price in force until `current_period_end` and SHALL attach a Stripe subscription schedule whose next phase carries the target price with `proration_behavior=none` and whose end behavior is `release`, reusing the schedule already attached by an earlier downgrade, and SHALL record the current tier's console price id in the schedule's metadata as `scheduled_from_price_id`. A cancellation armed at period end SHALL be cleared first. The system SHALL supersede the subscription's active scheduled changes and record a `subscription_scheduled_changes` row with `change_type='plan_switch'`, `effective_trigger='period_end'`, `effective_at=current_period_end` and the target `price_id`. No proration and no credit SHALL arise.
A request whose target is the current tier SHALL be refused as a move to the current tier; taking a scheduled move back is the keep endpoint's job (see "Member keeps the current plan").
When the item's price changes, `fulfillment.ReconcileSubscription`'s product-keyed diff (see "Subscription reconcile computes a genuine diff, grouped by product") SHALL converge it: the prior tier's product becomes surplus and SHALL be ended via `entitlements.end_conferral`, and the target tier's product becomes missing and SHALL be conferred via `entitlements.confer`, sourced by the subscription — so that exactly one tier's position is held on that ladder. The recorded position transitions and `subscription_changes` SHALL attribute the member as initiator for a change the member's request applied, and Stripe's event for a change a schedule applied. The console SHALL NOT store the schedule's id; it SHALL read the subscription's `schedule` from Stripe when a move needs it.
The target tier's product SHALL clear the member gate that checkout applies (published, active, public) before any subscription is read. A price can outlive its product's publication state, so the gate is checked on the product, independently of the listing queries that hide unpublished tiers from the catalog.
The switch SHALL also refuse, before any Stripe call, when a Stripe id it would send cannot be reached under the current API key: the target price mapping, the subscription mapping or the item mapping records an environment that disagrees with the key's mode, or carries `sync_status = 'stale'` (stripe-integration-infrastructure, Requirement: Mappings record the environment that made their ids). The refusal SHALL carry the unavailable-plan message the unpublished-tier refusal carries and SHALL change nothing. A target price the key cannot reach is not purchasable, so the offer decision refuses it before the switch reads the subscription (below); for an unreachable subscription or item mapping the switch SHALL log `plan_change: price mapping unreachable under the current key; skipping` with the row id, the Stripe id, `recorded_mode` and `key_mode`, naming `stale` in place of the recorded mode for a stale row. An unverified mapping is not a disagreement and SHALL switch as it does today.
On a ladder with a held rung (`member-product-discovery`, "Plan-move controls reflect built billing mechanics"; `grant-resumption`), the switch SHALL also refuse, before any Stripe call, the held rung and every rung ranked below both the held rung and the current rung, because the catalog reaches the held rung only by a cancellation at the period end (through the held rung's own "Downgrade" when it ranks below the current rung, and through the current card's "Cancel plan" when it ranks above) and offers no move below both. It SHALL read the held rung through the same position loader, and decide through the same offer decision, that the catalog's routing uses. The refusal SHALL carry the unavailable-plan message and SHALL change nothing, so a page rendered before the grant was marked cannot schedule a switch the catalog no longer offers.
The switch SHALL accept only a target that the offer decision (`member-offer`, "One decision says which move a member is offered on a ladder") offers as an enabled switch on the named ladder, asked with the organization's position on that ladder and the posted price. So it SHALL refuse, before any Stripe call and changing nothing: a price that is not the target product's default price, an inactive price, a one-time price, a target that is not a tier of the named ladder, the tier a scheduled downgrade already moves to, and every target the catalog reaches through Checkout, through the cancellation, or not at all. A target that fails the member gate, and a price id that is malformed or unknown, SHALL be refused with the unavailable-plan message before the order below applies. For every other target the refusal's answer SHALL be chosen in this order, first match wins: a target that is not a tier of the named ladder SHALL be refused with the unavailable-plan message, whatever holds the ladder; then a position that is suspended SHALL be refused with "Pay the unpaid invoice before changing this plan.", or with "A paused plan cannot be changed." when its subscription is `paused` ("A suspended subscription is kept or cancelled, never switched"); then a ladder no subscription pays for SHALL be refused with the answer for a ladder without an active paid subscription; then a target the decision answers as the current tier SHALL be refused as a move to the current tier; every other refusal SHALL carry the unavailable-plan message ("That plan is no longer available. Refresh the page and choose a current plan."). When any read the decision needs fails (the ladder's tiers, the organization's default pool, the positions in it with their provisions, subscriptions, scheduled changes and a scheduled downgrade's target product, the held grant, the cancel prediction, the target product's prices, or its price mapping for a reason other than its absence), the switch SHALL refuse, before any Stripe call, with the move-failed message ("We couldn't complete that change. Try again in a moment.") and SHALL change nothing.
#### Scenario: Switch modifies the existing subscription in place
- **WHEN** a member on a paid tier of a ladder moves to another paid tier on the same ladder, up or down
- **THEN** the price SHALL change on the existing subscription's item, at once for an upgrade and at `current_period_end` for a downgrade
- **AND** the system SHALL NOT create a new subscription or cancel the existing one
#### Scenario: An upgrade is charged now and applies once paid
- **WHEN** a member on Plus moves up to Pro, priced at Plus's interval, mid-period and the card on file pays the invoice
- **THEN** Stripe SHALL issue an invoice for the prorated difference at once and change the item's price to Pro's
- **AND** reconcile's diff SHALL end Plus's conferral and confer Pro's, so exactly one tier's position is held on that ladder
#### Scenario: An upgrade to a price of another interval starts a new period
- **WHEN** a member on Plus at $30.00 a month moves up to Max at $300.00 a year a third of a day into the period, and the card on file pays the invoice
- **THEN** Stripe SHALL charge $300.00 less a credit for the unused part of the month, the subscription's period SHALL start at the switch and end a year later, and the update SHALL carry no `billing_cycle_anchor`
- **AND** after the reconcile, Max's card on the Products page SHALL show the renewal date a year after the switch
#### Scenario: A declined upgrade changes nothing
- **WHEN** the card on file declines an upgrade's invoice
- **THEN** the system SHALL void that invoice and answer that the card was declined
- **AND** the subscription's price, the member's tier, a pending cancellation and a scheduled downgrade SHALL be as they were before the request
#### Scenario: An upgrade awaiting the bank's approval links to Stripe
- **WHEN** an upgrade's invoice needs the cardholder's approval
- **THEN** the system SHALL answer with the amount and the invoice's hosted page, and SHALL change nothing else
- **AND** when the member approves the payment there, the tier SHALL change through the resulting subscription event and reconcile
#### Scenario: A downgrade takes effect at renewal
- **WHEN** a member on Pro moves down to Plus on an evergreen subscription mid-period
- **THEN** the member SHALL keep Pro until `current_period_end`, no proration or credit SHALL arise, and a `plan_switch` row with `effective_trigger='period_end'` SHALL be scheduled
- **AND** the renewal invoice SHALL bill Plus's price, and reconcile SHALL move the tier when Stripe enters the schedule's next phase
#### Scenario: A second downgrade replaces the first
- **WHEN** a member with a downgrade to Plus scheduled chooses Basic before renewal
- **THEN** the schedule's next phase SHALL carry Basic's price, the Plus row SHALL be superseded and a Basic row scheduled
#### Scenario: An upgrade drops a scheduled downgrade once it lands
- **WHEN** a member with a downgrade to Plus scheduled moves up to Premium and the invoice is paid
- **THEN** the schedule SHALL be released and the scheduled row superseded, and the renewal SHALL bill Premium
#### Scenario: Switch is rejected when there is no active paid subscription on the ladder
- **WHEN** a member requests a paid-to-paid switch but holds no active paid subscription on the target ladder
- **THEN** the system SHALL reject the request rather than create a subscription (the free/default→paid path is owned by `member-plan-upgrade` / Checkout)
#### Scenario: A tier of another ladder is unavailable whatever holds the named ladder
- **WHEN** a member whose organization holds nothing on the named ladder posts a switch to a published tier of another ladder
- **THEN** the request SHALL be refused with the unavailable-plan message, not with the answer for a ladder without an active paid subscription, and nothing SHALL change
#### Scenario: Switch onto an unpublished tier is refused
- **WHEN** a member posts a switch whose target price belongs to a product that is draft, retired, inactive or not public, even one with an active, Stripe-mapped price
- **THEN** the request SHALL be refused with the unavailable-plan message before any subscription is read, and nothing SHALL change
#### Scenario: Switch onto a price the key cannot reach is refused
- **WHEN** a member posts a switch whose target price mapping, subscription mapping or item mapping is recorded in the environment the key is not in, or carries `sync_status = 'stale'`
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and nothing SHALL change
- **AND** for an unreachable subscription or item mapping the system SHALL log `plan_change: price mapping unreachable under the current key; skipping` with the row id, the Stripe id, `recorded_mode` and `key_mode`
#### Scenario: A switch to a held rung is refused
- **WHEN** a member on Pro, whose subscription bought over a grant for Plus that is marked to resume and holds Plus as the held rung, posts a switch to Plus, or to Basic ranked below Plus
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and nothing SHALL be scheduled or changed
#### Scenario: A switch to a price other than the default is refused
- **WHEN** a member on Pro by subscription posts a switch to an active, mapped price of Plus that is not Plus's default price
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and nothing SHALL change
#### Scenario: A switch to a one-time price is refused
- **WHEN** a member on Pro by subscription posts a switch to Plus, whose default price is one-time
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and nothing SHALL change
#### Scenario: A switch to the tier a scheduled downgrade moves to is refused
- **WHEN** a member on Pro with a downgrade to Standard scheduled posts a switch to Standard's default price
- **THEN** the request SHALL be refused with the unavailable-plan message before any Stripe call, and the scheduled downgrade SHALL stay as it was
#### Scenario: A switch refuses when a read fails
- **WHEN** a read the offer decision needs fails while a member's switch is checked, such as the organization's default pool or the positions in it, the held grant or the target product's prices
- **THEN** the request SHALL be refused with the move-failed message, no Stripe call SHALL be made, and nothing SHALL change
### Requirement: Live proration preview before a switch
Before a member confirms a paid-to-paid switch, the system SHALL state what the move costs and when. For an **upgrade** it SHALL display the amount the card will be charged now, computed from Stripe's invoice preview with `proration_behavior=always_invoice` and a `proration_date` set to the moment of the preview, as the invoice's amount due, and the target's recurring price after it. The preview SHALL carry that `proration_date` to the switch, which SHALL send it on the update so the amount charged is the amount shown. The switch SHALL accept a `proration_date` no later than the moment it runs and no older than one hour; for a missing, future or older value it SHALL charge nothing and SHALL answer with the preview re-rendered with fresh figures and a fresh `proration_date`. For a **downgrade** it SHALL display the date the current tier ends (`current_period_end`) and the target's recurring price, read from the console's records without a Stripe call. The upgrade preview SHALL be **best-effort**: if it cannot be obtained, the system SHALL fall back to copy that states the difference is charged now without a figure, and SHALL still allow the member to proceed. For an upgrade the offer decision marks as moving the subscription to a new period (`member-offer`, "One decision says which move a member is offered on a ladder"), because the target price's recurring interval is not that of the price the subscription pays, the confirmation SHALL NOT say "for the rest of this period" or "the prorated difference", and SHALL say that the recurring price runs from today: with an amount due above zero, "Your card is charged {net} now, after a credit for the unused part of your current plan, then {recurring} from today."; with nothing due, "Nothing is charged now; then {recurring} from today."; and when Stripe's preview cannot be obtained, "Your card is charged {price} now, minus a credit for the unused part of your current plan, then {recurring} from today.", {price} being the target price's amount and {recurring} the target price as the console words a recurring price ("$300.00 a year"). A downgrade toward a price of another interval SHALL keep the downgrade's sentence, because it takes effect at the period end. The preview SHALL apply the switch's own gates before it reads the target or calls Stripe: the target's product SHALL clear the member gate (published, active, public), and the offer decision SHALL offer the posted price as an enabled switch on the named ladder, checked by the same function the switch uses; a target the switch would refuse SHALL be refused by the preview with the same answer, and nothing about it SHALL be rendered. The switch's held-rung refusal is one of those gates: the preview SHALL refuse the held rung and every rung ranked below both it and the current rung with the unavailable-plan message, reading the held rung through the same position loader. When a read the decision needs fails, the preview SHALL refuse with the move-failed message and SHALL NOT call Stripe.
#### Scenario: Upgrade preview shows the charge now
- **WHEN** a member opens the move to a higher paid tier
- **THEN** the system SHALL show the amount charged now and the target's recurring price
#### Scenario: The amount charged is the amount shown
- **WHEN** a member confirms an upgrade within an hour of opening its preview
- **THEN** the switch SHALL send the preview's `proration_date`, and Stripe's invoice SHALL carry the amount the preview showed
#### Scenario: A stale preview is shown again, not charged
- **WHEN** a member confirms an upgrade whose `proration_date` is missing, in the future, or more than an hour old
- **THEN** the system SHALL make no subscription update and SHALL re-render the preview with fresh figures
#### Scenario: Upgrade preview with nothing due
- **WHEN** the previewed invoice's amount due is zero, because a credit balance covers it
- **THEN** the system SHALL state that nothing is charged now, and the target's recurring price
#### Scenario: Downgrade preview shows the renewal date
- **WHEN** a member opens the move to a lower paid tier
- **THEN** the system SHALL show the date the current tier ends and the target's recurring price
- **AND** it SHALL NOT call Stripe
#### Scenario: Preview failure degrades gracefully
- **WHEN** the Stripe preview for an upgrade at the subscription's own interval cannot be obtained
- **THEN** the system SHALL state that the prorated difference is charged now, without a figure
- **AND** the member SHALL still be able to confirm the switch
#### Scenario: An upgrade preview across intervals states the new period
- **WHEN** a member on Plus at $30.00 a month opens the move to Max at $300.00 a year, and Stripe's preview answers $270.32
- **THEN** the confirmation SHALL read "Your card is charged $270.32 now, after a credit for the unused part of your current plan, then $300.00 a year from today."
#### Scenario: An upgrade preview across intervals with nothing due
- **WHEN** a member on Basic at $300.00 a year opens the move to Pro at $40.00 a month, and Stripe's preview answers an amount due of zero
- **THEN** the confirmation SHALL read "Nothing is charged now; then $40.00 a month from today."
#### Scenario: An upgrade preview across intervals without a figure
- **WHEN** a member on Plus at $30.00 a month opens the move to Max at $300.00 a year, and Stripe's preview cannot be obtained
- **THEN** the confirmation SHALL read "Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today."
- **AND** the member SHALL still be able to confirm the switch
#### Scenario: A downgrade preview across intervals keeps the renewal date
- **WHEN** a member on Plus at $30.00 a month opens the move to Basic at $50.00 a year
- **THEN** the confirmation SHALL read "You keep Plus until {date}, then pay $50.00 a year.", {date} being the end of the current period
#### Scenario: Preview of an unpublished or off-ladder target is refused
- **WHEN** a member requests the preview with a `price_id` whose product is not published for members, or is not a tier of the `ladder_id` named
- **THEN** the system SHALL answer with the refusal the switch gives for that target
- **AND** it SHALL NOT render the target product's name and SHALL NOT call Stripe
#### Scenario: Preview of a held rung is refused
- **WHEN** a member on Pro requests the preview for Plus, the held rung of a grant marked to resume that the subscription bought over
- **THEN** the system SHALL answer with the unavailable-plan message and SHALL NOT call Stripe
#### Scenario: Preview of a price other than the default is refused
- **WHEN** a member on Pro by subscription requests the preview for an active price of Plus that is not Plus's default price
- **THEN** the system SHALL answer with the unavailable-plan message and SHALL NOT call Stripe
#### Scenario: Preview refuses when a read fails
- **WHEN** a read the offer decision needs fails while the preview is checked
- **THEN** the system SHALL answer with the move-failed message, SHALL NOT render the target and SHALL NOT call Stripe
### Requirement: A subscription paused for a grant is switched to the granted tier or above
A member SHALL be able to switch a subscription that pays on a ladder a grant holds to the granted tier or to any tier ranked above it, when that tier is also ranked above the tier the subscription pays for and the subscription is not suspended, and the subscription's product and that tier are tiers of that ladder alone, through the existing switch endpoint and its preview. A tier whose price is in another currency than the subscription's price SHALL NOT be offered, because Stripe refuses a change of a subscription's currency, and the switch endpoint SHALL refuse it with "That plan is no longer available. Refresh the page and choose a current plan.". A tier priced at another recurring interval SHALL be offered as usual. The switch SHALL be an immediate upgrade, charged at once with `proration_behavior=always_invoice` and `payment_behavior=pending_if_incomplete`, so that a payment that needs the cardholder's approval keeps its approval link. When the subscription Stripe returns carries the console's pause (`pause_collection` set and the marker), whether or not the subscription holds its ladder, the console SHALL clear the pause and remove the marker in a call of its own and SHALL then send the upgrade's update with its form unchanged, because Stripe refuses an `always_invoice` update on a paused subscription and refuses `pause_collection` in an update sent with `pending_if_incomplete`. In that case, before any Stripe write, the console SHALL show the confirmation again with fresh figures when the confirmation's proration moment lies before the subscription's current period, which Stripe refuses; SHALL refuse the switch with "We couldn't complete that change. Try again in a moment." when the subscription's drafts include a renewal Stripe finalizes automatically, the later of its `next_payment_attempt` and `automatically_finalizes_at` falling within ten minutes or already past, or when the drafts cannot be read; and SHALL write a `reconcile_subscription` entry for the subscription whose `next_attempt_at` lies four minutes on, refusing the switch the same way when the entry cannot be written. On every outcome after the clear but an applied update (an error from either call, a declined payment once its invoice is voided, a payment awaiting approval, and a request that ends early), the request SHALL run the outbox's reconcile of the subscription, whose decision pauses the subscription again unless the rule no longer wants it paused; its failure SHALL be logged and SHALL NOT change the member's answer. Each Stripe call from the draft read to that reconcile's last SHALL carry a deadline, the reconcile's as "A reconcile's Stripe calls end when its context does" states, and SHALL NOT end when the member's request does. A marker without a pause SHALL NOT be cleared by the switch, which then sends the upgrade's update alone. A pause without the marker SHALL NOT be cleared, and Stripe's refusal then answers the switch's generic failure. A declined payment SHALL leave the subscription on its tier with nothing charged, as for every upgrade. Once Stripe applies the update, the reconcile SHALL confer the new tier over the grant, recording the grant as replaced by the subscription. The preview of a subscription whose row records any pause SHALL be the existing unavailable preview, with no Stripe call, because Stripe answers no preview for a paused subscription. Its confirmation SHALL read "Switch to {target}?", or "Pay for {target}?" when the target is the granted tier's own current card, and its second paragraph SHALL open "Your subscription replaces the grant." before the charge sentence. Toward a price of another recurring interval than the subscription's, the charge sentence SHALL be the new period's, as "Live proration preview before a switch" states it for every upgrade across intervals, because Stripe then charges the new price's full period less the unused time and moves the billing date to the switch: while the row records the pause, the preview answers no figure and the sentence SHALL read "Your card is charged {target price} now, minus a credit for the unused part of your current plan, then {recurring} from today.", {target price} the target price's amount and {recurring} its recurring price ("$300.00 a year"); while the row records no pause yet and Stripe answers an amount, it SHALL read "Your card is charged {net} now, after a credit for the unused part of your current plan, then {recurring} from today.", or "Nothing is charged now; then {recurring} from today." when the amount is zero.
#### Scenario: A switch to the granted tier's superior
- **WHEN** a grant holds Pro, the organization's subscription to Plus is paused for it, and the member switches to Max
- **THEN** the console clears `pause_collection` and the marker, then sends the update that changes the item to Max with `always_invoice` and `pending_if_incomplete`, the proration is charged now, and the reconcile confers Max over the grant
#### Scenario: An upgrade of a holding subscription Stripe still has paused
- **WHEN** the grant that displaced S was revoked, S holds Plus again, the outbox has not yet cleared S's pause, and the member upgrades S to Max
- **THEN** the console clears `pause_collection` and the marker before the update, so Stripe does not refuse it
- **AND** when the card is declined, the request's reconcile leaves S unpaused, because S holds its ladder
#### Scenario: A switch to the granted tier itself
- **WHEN** the same member switches to Pro
- **THEN** the subscription's item becomes Pro, the reconcile confers Pro as a transfer over the grant, and the grant is recorded as replaced by the subscription
#### Scenario: A declined switch leaves the subscription paused
- **WHEN** the card is declined on that switch
- **THEN** the console voids the upgrade's invoice, the request's reconcile pauses the subscription again with the marker, the member reads "Your card was declined; you're still on Plus.", and the grant keeps the ladder
- **AND** nothing is charged, and the renewal made while the grant holds is voided
#### Scenario: A switch awaiting approval is paused again
- **WHEN** the payment of that switch needs the cardholder's approval
- **THEN** the member gets the approval link, and the request's reconcile pauses the subscription again while the update is pending
- **AND** paying the invoice later applies the update, and the reconcile confers Max over the grant; left unpaid, the update expires and nothing is charged
#### Scenario: A failure after the clear restores the pause
- **WHEN** Stripe answers the update with a server error after the clear, or the member's request ends while the switch runs
- **THEN** the request's reconcile pauses the subscription again, and the member reads "We couldn't complete that change. Try again in a moment." when the request still answers
#### Scenario: A crash between the two calls is healed
- **WHEN** the process stops after the clear and before the update, and no webhook from the clear arrives
- **THEN** the `reconcile_subscription` entry written before the clear runs about four minutes later, and its reconcile pauses the subscription again
#### Scenario: A renewal about to finalize refuses the switch
- **WHEN** a renewal Stripe made while the subscription was paused is a draft that finalizes within ten minutes, and the member switches to Max
- **THEN** the switch is refused with "We couldn't complete that change. Try again in a moment." before any Stripe write, and the subscription stays paused, so the renewal is voided
#### Scenario: A confirmation sent after a renewal is shown again
- **WHEN** the member opened the switch to Max before the paused subscription renewed and confirms it after the renewal
- **THEN** the console shows the confirmation again with fresh figures, and sends Stripe no write
#### Scenario: A stale marker is left to the reconcile
- **WHEN** Stripe's subscription carries the marker and no pause, and the member switches to Max
- **THEN** the console sends the upgrade's update alone
#### Scenario: The confirmation says the subscription replaces the grant
- **WHEN** the member opens the switch to Max, priced monthly as Plus is, from the paused Plus subscription
- **THEN** the confirmation reads "Switch to Max?", and its second paragraph reads "Your subscription replaces the grant. Your card is charged the prorated difference now, then {price}.", because Stripe answers no preview for a paused subscription
#### Scenario: The confirmation on the granted tier's own card reads Pay for
- **WHEN** the member opens the switch from the paused Plus subscription on the card of Pro, the tier the grant holds
- **THEN** the confirmation reads "Pay for Pro?", and its second paragraph reads "Your subscription replaces the grant." before the charge sentence
#### Scenario: The confirmation states the amount while no pause is recorded
- **WHEN** the subscription's row records no pause yet, the preview answers an amount, and Max is priced monthly as Plus is
- **THEN** the second paragraph reads "Your subscription replaces the grant. Your card is charged {net} now for the rest of this period, then {price}."
#### Scenario: A switch to a price of another interval states the new period
- **WHEN** the paused subscription pays for Plus monthly, Max's price is $300.00 a year, and the member opens the switch to Max
- **THEN** the confirmation reads "Switch to Max?", and its second paragraph reads "Your subscription replaces the grant. Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today."
#### Scenario: A switch to a price of another interval states Stripe's figure while no pause is recorded
- **WHEN** the subscription's row records no pause yet, Plus is priced monthly, Max is priced $300.00 a year, and the preview answers $270.32
- **THEN** the second paragraph reads "Your subscription replaces the grant. Your card is charged $270.32 now, after a credit for the unused part of your current plan, then $300.00 a year from today."
#### Scenario: A tier priced in another currency is not offered
- **WHEN** the paused subscription pays for Plus in US dollars and Max's price is in euros
- **THEN** Max's card offers no switch, and a switch to Max posted from an older page is refused with "That plan is no longer available. Refresh the page and choose a current plan." before any Stripe call
#### Scenario: Under a lower grant only the tiers above the paid tier switch
- **WHEN** a grant holds Basic, the organization's subscription to Pro is paused for it, and the member asks for Plus, ranked between them, and for Max, ranked above Pro
- **THEN** the switch endpoint refuses Plus with the unavailable-plan sentence and switches to Max as above
@@ -0,0 +1,96 @@
Tests follow `docs/testing.md`: database tests open the database through `dbtest`, build their rows through the test kit or the package's fixtures, filter to the rows their own fixture made, and pass twice in a row without a reset. A Stripe test drives `stripetest.MockBackend` with the API state Stripe would return, never a hand-built event payload, and runs without `t.Parallel()`.
Sections 1 to 4 are the parts of one commit; sections 5 and 6 are not commits. The commit passes `make test` with the test stack up, which runs `make lint` first and then every package one at a time (`-p 1`). No golden file carries a switch confirmation (the sentences occur only in Go tests and in a screens sheet of an archived change), so no golden changes, and no route table changes. One existing expectation changes: the row `a new period with a figure` of `TestPlanMoveMessageStatesTheTiming` (`internal/server/plan_move_message_test.go:47-48`) reads N1 in place of M12 (design D4). Any other change of an existing test's result is a stop. A script's usage is learned from its header or source, never by running it.
The Copy table's strings (design, Copy) are approved: N1, N2 and M12. The code, the spec deltas, the test rows, the breaks' `old` texts, the docs and the forge comment write them as the table states.
**Breaks.** The list of item 3.5 is written to a file outside the repository and run with `go run ./scripts/mutation-check -list <file> -side new` on the commit, from the repository root, with `test/.env` loaded as `make test` loads it (without it the database tests skip and every record reports SKIPPED), after its green control run. A break of Go code is a test record (`file`, `old`, `new`, `pkg`, `run_new`, `want_new`). A break caught by two tests is two records, one per test, with the same `file`, `old` and `new`. Every record's `want_new` is a text the failing test prints only when it fails, never a test or subtest name alone. Every record must be CAUGHT. An `old` text below is the code as the section's items write it; when the committed code words it differently, the record is corrected to the committed text before the run, never dropped, and its `new` text keeps the package compiling. A break that no test of the commit sees is named with the reason, and the commit message says so. The commit message lists every break with its result.
## 1. The decision marks every upward switch across intervals
- [x] 1.1 `plans.Position` (`internal/plans/offer.go:19-53`, design D2) gains `Interval string` after `Subscribed`. Its doc comment: the recurring interval of the price through which the subscription that backs the current rung pays for it; `""` when no subscription backs the rung or the console holds no live row for that subscription.
- [x] 1.2 `route` (`internal/plans/offer.go:423-436`): in its default case, after `m = priceRule(Switch, a.Target)`, it sets `m.NewPeriod = m.Enabled && a.rel == Upgrade && a.Interval != "" && a.Price.Interval != a.Interval`. Rule 3a.2 (`paidSwitch`, `:362-375`) is unchanged.
- [x] 1.3 Doc comments: `Move.NewPeriod` (`:187-190`) says that it is true when an enabled upward switch moves the subscription to a price of another recurring interval, under rule 3a.2 for the paying subscription and rule 16 for the one that backs the rung, so Stripe charges the new price's full period less the unused time and moves the billing date to the switch, and that a downgrade never carries it because it takes effect at the period end. The `Offer` doc comment's rule 16 (`:283`) reads "Otherwise: Switch, through the price rule; an enabled upward switch toward a price of another recurring interval than the subscription's moves it to a new period."
- [x] 1.4 `onLadder` (`internal/server/plan_offer.go:310-333`): right after `pos.Subscribed = true` (`:322`) it sets `pos.Interval = backingInterval(l.subscriptionRows, held, ladderID)`. `backingInterval`, new beside `payingFrom` (`:224-237`), returns `RecurringInterval.String` of the first row whose `SubscriptionID` is `held.SubscriptionID.UUID.String()`, whose `PlanLadderID` is `ladderID` and whose `ProductID` is `held.ProductID`, and `""` when no row matches; it reads no row. `onLadder`'s doc comment states the precondition: `payingOn` has run for the organization, as all four callers ensure (`internal/server/billing.go:284`, `internal/server/member_plans.go:407`, `plan_offer.go:545`, `:619`), and without it the interval is `""` and the confirmation reads the same-interval sentences.
## 2. The confirmation states the new period
- [x] 2.1 `planMoveMessage` (`internal/server/member_plan_moves.go:39-66`, design D3): the case `newPeriodCharge != ""` (`:50-51`) becomes three cases, in this order, ahead of the downgrade's:
- `newPeriodCharge != "" && p.Available && p.NetAmount > 0`: `"Your card is charged " + esc(formatMoney(p.NetAmount, p.Currency)) + " now, after a credit for the unused part of your current plan, then " + esc(then) + " from today."` (N1);
- `newPeriodCharge != "" && p.Available`: `"Nothing is charged now; then " + esc(then) + " from today."` (N2);
- `newPeriodCharge != ""`: M12, unchanged.
The doc comment's sentences on `newPeriodCharge` (`:31-36`) are replaced: `newPeriodCharge` is set for an upgrade the decision marks as a new period and is the target price's amount; with the preview's figure the sentence states the figure "after a credit", with nothing due it states that nothing is charged, and with no figure it states `newPeriodCharge` "minus a credit"; each ends "from today", because Stripe charges the new price's full period less the unused time and moves the billing date to the switch, so "for the rest of this period" would be false.
- [x] 2.2 `renderSwitchPreview`'s doc comment (`internal/server/member_plan_moves.go:119-128`): "for its NewPeriod, the new period's charge (Copy M12)" reads "for its NewPeriod, the new period's charge (Copy N1, N2 or M12)".
- [x] 2.3 `internal/fulfillment/plan_change.go`: the comment in `PreviewSwitch` (`:722-724`) says that `always_invoice` previews the invoice the switch would issue now, its proration lines or, toward a price of another interval, the credit for the unused time and the new price's full period, which starts at the switch, and that `amount_due` is what the card is charged after any customer credit balance. `upgradeNow`'s doc comment (`:292-294`) adds after "Stripe charges the prorated difference" the words ", or toward a price of another interval the new price's full period less the unused time, moving the billing date to the switch,".
## 3. The tests and their breaks
- [x] 3.1 `TestOffer` (`internal/plans/offer_test.go:44`) gains a helper `holdsAt(current, interval string) Position`, `holds(current, true)` with `Interval` set, and five rows after `a subscription offers nothing on its own tier` (`:92`):
- `a subscription offers a switch to a new period above it`: `holdsAt("plus", "month")`, `pricedTarget("pro", "usd", "year", true)`; Switch, enabled, `Upgrade`, `newPeriod: true`;
- `a subscription offers a switch above it at its own interval`: `holdsAt("plus", "month")`, `target("pro")`; Switch, enabled, `Upgrade`;
- `a subscription's switch below it to another interval is no new period`: `holdsAt("plus", "month")`, `pricedTarget("basic", "usd", "year", true)`; Switch, enabled, `Downgrade`;
- `a subscription whose interval is unknown offers no new period`: `holds("plus", true)`, `pricedTarget("pro", "usd", "year", true)`; Switch, enabled, `Upgrade`;
- `a disabled switch to another interval is no new period`: `holdsAt("plus", "month")`, `pricedTarget("pro", "usd", "year", true)` with `Purchasable` false; Switch, reason `Unpurchasable`, `Upgrade`.
Failure text, `checkOffer`'s: `Offer(%s) = %+v, want %+v`.
- [x] 3.2 `TestPlanMoveMessageStatesTheTiming` (`internal/server/plan_move_message_test.go:18-72`): the row `a new period with a figure` (`:47-48`) wants `Your subscription replaces the grant. Your card is charged $270.32 now, after a credit for the unused part of your current plan, then $300.00 a year from today.` Three rows are added:
- `a new period with a figure, no grant`: `SwitchPreview{Available: true, NetAmount: 27032, Currency: "usd"}`, `then: yearly`, `newPeriodCharge: "$300.00"`; wants `Your card is charged $270.32 now, after a credit for the unused part of your current plan, then $300.00 a year from today.`;
- `a new period with nothing due`: `SwitchPreview{Available: true, NetAmount: 0, Currency: "usd"}`, `then: then` ($30.00 a month), `newPeriodCharge: "$30.00"`; wants `Nothing is charged now; then $30.00 a month from today.`;
- `a new period with no figure, no grant`: `SwitchPreview{}`, `then: yearly`, `newPeriodCharge: "$300.00"`; wants `Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today.`
Failure text, the test's: `%s: message = %s, want the heading %s and the second paragraph %q`.
- [x] 3.3 `TestSwitchPreviewAcrossIntervals` (`internal/server/plan_switch_timing_db_test.go`), rows through a named function (`checkPreviewAcrossIntervals`); each fixture below is a `t.Run` named as the `run_new` patterns of item 3.5 require (`Pro priced yearly`, `Basic priced yearly`, `every tier monthly`), and each row is a `t.Run` inside it named as written. The fixture `seedSwitchTimingMember` (`:47-131`) becomes `seedSwitchTimingMemberPriced(t, tiers [3]tierPrice)`, `tierPrice` carrying an amount in cents and an interval, which creates each tier's price from its entry in place of the fixed monthly interval (`:85`) and keeps the database handle on `switchTimingMember`; `seedSwitchTimingMember(t)` calls it with today's prices, $5.00, $12.00 and $30.00 a month, so every existing test keeps its fixture. The member holds Plus (rank 1), as today. Each row sets the mock's `Invoice` and `PathErrs` as it names, clearing what the row before set, sends `GetSwitchPreview` for its target with `ladder_id` and `price_id` as `TestSwitchPreviewFromAPausedSubscription` does (`internal/server/member_switch_offer_db_test.go:991-994`), checks the status (failure text `%s: the preview answered %d, want 200`) and reads `htmltest.Parse(t, body).Texts("p.mb-0")`; failure text `%s: the confirmation reads %q, want %q`.
- Fixture `Pro priced yearly` ($5.00 a month, $12.00 a month, $300.00 a year):
- `a yearly Pro with a figure`: `Invoice` answers `AmountDue: 27032` in `usd`; wants `Your card is charged $270.32 now, after a credit for the unused part of your current plan, then $300.00 a year from today.`;
- `a yearly Pro with nothing due`: `AmountDue: 0`; wants `Nothing is charged now; then $300.00 a year from today.`;
- `a yearly Pro without a figure`: `PathErrs` refuses `/v1/invoices/create_preview`; wants `Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today.`
- Fixture `Basic priced yearly` ($50.00 a year, $12.00 a month, $30.00 a month):
- `a yearly Basic, a downgrade`: wants `You keep {Plus's name} until {date}, then pay $50.00 a year.`, `{date}` being the subscription's `current_period_end` read from `core.subscriptions` through `stripe.subscription_mappings` and formatted `Jan 2, 2006`, as `memberWorld.renewal` does (`internal/server/grant_resumption_member_test.go:237-244`).
- Fixture `every tier monthly` (today's prices):
- `a monthly Pro with a figure`: `AmountDue: 1800`; wants `Your card is charged $18.00 now for the rest of this period, then $30.00 a month.`
- [x] 3.4 Existing tests keep their results: `TestSwitchPreviewFromAPausedSubscription` (the row records the pause, so the preview gives no figure and its two rows read the fallback and M12), `TestSwitchWithoutItsMomentShowsThePreviewAgain`, every other test of `plan_switch_timing_db_test.go` on the unchanged fixture, `TestMemberPlansGolden`, and `TestProductsPageReadsPayingOnce`, which holds because no statement is added.
- [x] 3.5 Breaks. Each `old` is one whole line of the committed code, tabs included (a fragment such as `a.rel == Upgrade && ` also occurs at `internal/plans/offer.go:426`, so a fragment would report NOT-FOUND), and occurs exactly once in its `file`; check each with `grep -c` before the run. `pkg` is `./internal/plans` for `offer.go`, and `./internal/server` for `plan_offer.go` and `member_plan_moves.go`. Every `run_new` is `^Test$/^subtest$` of the test named, with the subtest's spaces written as underscores.
- rule 16 marking no new period: `file` `internal/plans/offer.go`, `pkg` `./internal/plans`, `old` ` m.NewPeriod = m.Enabled && a.rel == Upgrade && a.Interval != "" && a.Price.Interval != a.Interval`, `new` ` m.NewPeriod = false`; caught by `run_new` `^TestOffer$/^a_subscription_offers_a_switch_to_a_new_period_above_it$`, `want_new` `Offer(a subscription offers a switch to a new period above it) =`, and by `run_new` `^TestSwitchPreviewAcrossIntervals$/^Pro_priced_yearly$`, `want_new` `a yearly Pro with a figure: the confirmation reads`;
- the relation test dropped: `file` `internal/plans/offer.go`, `pkg` `./internal/plans`, `old` ` m.NewPeriod = m.Enabled && a.rel == Upgrade && a.Interval != "" && a.Price.Interval != a.Interval`, `new` ` m.NewPeriod = m.Enabled && a.Interval != "" && a.Price.Interval != a.Interval`; caught by `run_new` `^TestOffer$/^a_subscription's_switch_below_it_to_another_interval_is_no_new_period$`, `want_new` `Offer(a subscription's switch below it to another interval is no new period) =`, and by `run_new` `^TestSwitchPreviewAcrossIntervals$/^Basic_priced_yearly$`, `want_new` `a yearly Basic, a downgrade: the confirmation reads`;
- the unknown-interval test dropped: `file` `internal/plans/offer.go`, `pkg` `./internal/plans`, `old` ` m.NewPeriod = m.Enabled && a.rel == Upgrade && a.Interval != "" && a.Price.Interval != a.Interval`, `new` ` m.NewPeriod = m.Enabled && a.rel == Upgrade && a.Price.Interval != a.Interval`; caught by `run_new` `^TestOffer$/^a_subscription_whose_interval_is_unknown_offers_no_new_period$`, `want_new` `Offer(a subscription whose interval is unknown offers no new period) =`;
- the enabled test dropped: `file` `internal/plans/offer.go`, `pkg` `./internal/plans`, `old` ` m.NewPeriod = m.Enabled && a.rel == Upgrade && a.Interval != "" && a.Price.Interval != a.Interval`, `new` ` m.NewPeriod = a.rel == Upgrade && a.Interval != "" && a.Price.Interval != a.Interval`; caught by `run_new` `^TestOffer$/^a_disabled_switch_to_another_interval_is_no_new_period$`, `want_new` `Offer(a disabled switch to another interval is no new period) =`;
- the interval comparison reduced to the target's interval being set: `file` `internal/plans/offer.go`, `pkg` `./internal/plans`, `old` ` m.NewPeriod = m.Enabled && a.rel == Upgrade && a.Interval != "" && a.Price.Interval != a.Interval`, `new` ` m.NewPeriod = m.Enabled && a.rel == Upgrade && a.Interval != "" && a.Price.Interval != ""`; caught by `run_new` `^TestOffer$/^a_subscription_offers_a_switch_above_it_at_its_own_interval$`, `want_new` `Offer(a subscription offers a switch above it at its own interval) =`, and by `run_new` `^TestSwitchPreviewAcrossIntervals$/^every_tier_monthly$`, `want_new` `a monthly Pro with a figure: the confirmation reads`;
- `onLadder` leaving the interval unset: `file` `internal/server/plan_offer.go`, `pkg` `./internal/server`, `old` ` pos.Interval = backingInterval(l.subscriptionRows, held, ladderID)`, `new` ` pos.Interval = ""`; caught by `run_new` `^TestSwitchPreviewAcrossIntervals$/^Pro_priced_yearly$`, `want_new` `a yearly Pro with a figure: the confirmation reads`;
- N1 worded as today's figure sentence: `file` `internal/server/member_plan_moves.go`, `pkg` `./internal/server`, `old` ` msg += "Your card is charged " + esc(formatMoney(p.NetAmount, p.Currency)) + " now, after a credit for the unused part of your current plan, then " + esc(then) + " from today."`, `new` ` msg += "Your card is charged " + esc(formatMoney(p.NetAmount, p.Currency)) + " now for the rest of this period, then " + esc(then) + " from today."`; caught by `run_new` `^TestPlanMoveMessageStatesTheTiming$/^a_new_period_with_a_figure,_no_grant$`, `want_new` `a new period with a figure, no grant: message =`, and by `run_new` `^TestSwitchPreviewAcrossIntervals$/^Pro_priced_yearly$`, `want_new` `a yearly Pro with a figure: the confirmation reads`;
- N2 without "from today": `file` `internal/server/member_plan_moves.go`, `pkg` `./internal/server`, `old` ` msg += "Nothing is charged now; then " + esc(then) + " from today."`, `new` ` msg += "Nothing is charged now; then " + esc(then) + "."`; caught by `run_new` `^TestPlanMoveMessageStatesTheTiming$/^a_new_period_with_nothing_due$`, `want_new` `a new period with nothing due: message =`, and by `run_new` `^TestSwitchPreviewAcrossIntervals$/^Pro_priced_yearly$`, `want_new` `a yearly Pro with nothing due: the confirmation reads`;
- N1 reached without a new period: `file` `internal/server/member_plan_moves.go`, `pkg` `./internal/server`, `old` ` case newPeriodCharge != "" && p.Available && p.NetAmount > 0:`, `new` ` case p.Available && p.NetAmount > 0:`; caught by `run_new` `^TestPlanMoveMessageStatesTheTiming$/^upgrade$`, `want_new` `upgrade: message =`, and by `run_new` `^TestSwitchPreviewAcrossIntervals$/^every_tier_monthly$`, `want_new` `a monthly Pro with a figure: the confirmation reads`;
## 4. The documents
- [x] 4.1 `docs/stripe.md`, "Plan changes", the Upgrade bullet (`:211`, design D5), after "instead of being charged.": toward a price of another interval, such as a monthly tier to a yearly one, Stripe does not prorate within the current period; it starts a new period at the switch, charges the new price's full period less a credit for the unused part of the current one, and moves the billing date to the day of the switch. The console sends no `billing_cycle_anchor`, so Stripe's rule applies, and the confirmation says "from today": N1 with Stripe's figure, N2 when nothing is due, M12 when Stripe gives no figure, each quoted with its placeholders. One sentence adds that a subscription moved to Stripe's flexible billing mode in the Dashboard keeps its billing date across an interval change, which the confirmation does not know.
- [x] 4.2 `docs/stripe.md`, the paused switch's bullet (`:217`): the sentence "A switch toward a price of another interval is offered and its confirmation reads "…", because Stripe then charges the new price's full period less the unused time and moves the billing date to the switch." reads that such a switch is offered, that once the subscription's row records the pause its confirmation reads M12, and that before then it states Stripe's figure as an upgrade across intervals does (the Upgrade bullet).
- [x] 4.3 `docs/models/payments-billing.md:13` (design D5): "charged the prorated difference at once and taking effect once that charge is paid" reads "charged the prorated difference at once, or, toward a price of another interval, a new period less a credit for the unused time, which moves the billing date to the switch, and taking effect once that charge is paid". No other document changes: `docs/plan-management.md` states no upgrade's charge or billing date.
## 5. The sandbox walk and the captures
- [x] 5.1 The console sandbox walk, for the caller, on the test stack with the app built from the commit (`cd test && ./bootstrap-stack.sh`, then `docker compose up -d` in `test/`, as `test/AGENTS.md` states), Stripe sandbox test keys, and `stripe listen --all-snapshot --forward-to localhost:<the console's port in test/.env>/webhooks/stripe`. Every `stripe` read below passes `--stripe-version 2025-02-24.acacia`, the version the console pins.
1. As the operator, make two ladders, each tier a published public product with one default price, every price synced to Stripe: ladder A with Lite at $10.00 a month ranked below Plus at $30.00 a month, which is ranked below Max at $300.00 a year; ladder B with Basic at $300.00 a year ranked below Pro at $40.00 a month.
2. A first member buys Plus through Checkout with `4242 4242 4242 4242`. The Products page shows Plus current, renewing a month on.
3. The member opens Max's Upgrade. The confirmation reads N1 with Stripe's figure, which is $300.00 less the unused part of the month. Capture it at 1280 desktop width and 390 mobile width (emulated, `innerWidth` checked) and record the figure.
4. The member confirms within the hour. `stripe invoices list --subscription <sub id> --limit 1`: `amount_due` equals the recorded figure, and the lines are a `proration: true` credit for the unused time on Plus and a `proration: false` line for Max whose period runs a year from the switch. `stripe subscriptions retrieve <sub id>`: `billing_cycle_anchor` and `current_period_start` lie within a minute of the switch, and `current_period_end` a year after it. The Products page shows Max current, renewing a year after the switch.
5. A second member buys Basic through Checkout with `4242 4242 4242 4242`.
6. The member opens Pro's Upgrade. The confirmation reads N2. Capture it at both widths.
7. The member confirms. The latest invoice has a negative `total`, `amount_due` 0 and `status` `paid`, with a `proration: true` credit for the unused part of Basic's year and a `proration: false` line for Pro's month. The subscription's `current_period_end` lies a month after the switch. `stripe customers retrieve <customer id>` shows a negative `balance` equal to the invoice's `total`, and `stripe invoices create_preview --customer <customer id> --subscription <sub id> --stripe-version 2025-02-24.acacia` shows that balance as its `starting_balance` and an `amount_due` of 0. The Products page shows Pro current, renewing a month after the switch.
8. A third member buys Plus through Checkout with `4242 4242 4242 4242` and schedules the downgrade to Lite. `stripe subscriptions retrieve <sub id>` shows a `schedule`. The member opens Max's Upgrade, confirms, and the walk reads `stripe subscriptions retrieve <sub id>` again: `schedule` is null, the item carries Max's price, and `current_period_end` lies a year after the switch. `stripe subscription_schedules retrieve <schedule id>` shows the schedule `released` or `canceled`. A released schedule keeps its phases as a record, a Lite phase among them; Stripe applies none of them once the schedule no longer manages the subscription, so the subscription's own fields are what the step reads.
The captures, the figures and the Stripe objects' ids go to the review. A figure unequal to the invoice's `amount_due`, or a period that does not start at the switch, stops the landing and returns to the design. A balance that is not the invoice's negative total contradicts design D3's account of N2's remainder, and a `schedule` that survives the upgrade, an item price other than Max's, or a period that does not run a year from the switch (step 8) also returns to the design.
- [x] 5.2 `make screens`: no demo state reaches the switch's confirmation, so no capture changes; a change is read and named.
- [x] 5.3 Not captured by hand, with the reason, in the review: M12 on the ordinary switch, which shows only when Stripe's preview fails, which a walk cannot bring about without breaking the key (its text and layout are those of the paused switch's capture without the grant sentence; `TestSwitchPreviewAcrossIntervals` pins it); and N1 and N2 on the paused switch, which show only in the seconds before the subscription's row records the pause (`TestPlanMoveMessageStatesTheTiming` pins them).
## 6. Landing
- [x] 6.1 `openspec validate switch-across-intervals-starts-a-period --strict` passes, and with the test stack up, `make test` passes (it runs `make lint` first).
- [x] 6.2 The break list of item 3.5 reads CAUGHT in the commit message.
- [ ] 6.3 After the push, post on #287 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), with the approved wording, and close it. Draft:
> On a plan ladder (an ordered set of plans, the tiers, that an organization moves up and down) whose lower tier is billed monthly and whose upper tier is billed yearly, the upgrade's confirmation said the charge covered the rest of the current period. When a subscription moves to a price billed on another interval, Stripe does something else: it starts a new period on the day of the switch, charges the new price's full period less a credit for the unused part of the current one, and moves the billing date to that day. The confirmation now says so. With Stripe's figure it reads "Your card is charged $270.32 now, after a credit for the unused part of your current plan, then $300.00 a year from today." When nothing is due, as when a yearly plan with most of its year unused moves up to a monthly one, it reads "Nothing is charged now; then $40.00 a month from today." When Stripe gives no figure, it reads "Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today.", the sentence already used by the switch from a paid plan the console paused while a grant (a plan an operator gives an organization) holds its ladder. That switch now shows Stripe's figure the same way when Stripe gives one. A downgrade keeps its sentence, because it waits for the end of the current period, where the new period starts anyway, and upgrades between prices of the same interval read as before. The console tells that a switch starts a new period by comparing the interval of the price the member pays with the target price's, both read by queries each confirmation already makes, so no query is added.
>
> Commit: …
- [ ] 6.4 The archive waits for the maintainer's go; the deltas carry the spec changes, and no Purpose needs an edit a delta cannot make.