Let a member switch a plan paused under a grant to the granted tier or above

Eleventh section of #269's change, and #278. A member whose paid plan is
paused while an operator's grant holds its ladder can now switch that
plan to the granted tier, or to a tier above it, from that tier's card.
The switch supersedes the grant, so the subscription takes the ladder;
a grant marked to resume delivers again when the subscription ends.

Stripe refuses an always_invoice update on a paused subscription and
refuses clearing the pause beside pending_if_incomplete, which the
approval path needs, so the paused form is two calls: the pause is
cleared in a call of its own, then today's upgrade is sent unchanged.
Before the clear, the switch writes a reconcile_subscription outbox
entry due four minutes later, so a process that stops between the calls
still has the subscription paused again; the outbox relay now takes an
entry only once its next_attempt_at has passed (EnqueueAt,
RequestSubscriptionReconcileAt), and every other writer leaves it due at
once. A guard refuses the switch with "Try again in a moment." while a
paused renewal draft is due to finalize within ten minutes, reading the
later of next_payment_attempt and automatically_finalizes_at. When the
update does not apply (a decline, an approval, a Stripe error from
either call, a deadline, a closed request), a deferred restore runs the
subscription's reconcile under context.WithoutCancel and pauses it
again; the draft list, the voids and the clear carry 20 seconds, the
charge 60, and the restore 60, through the reconcile's context (#285).
An abandoned upgrade's open invoice is voided before the clear, and
Cancel plan voids it too on a subscription that pays without holding
its ladder.

The offer: rule 3a item 2 offers the switch on the granted tier's
current card and on each tier above it, and refuses it when the paying
product or the target is a tier of another ladder, as Resume plan
does, because the supersession ends the whole grant. A target priced in
another currency is never offered. Stripe answers no preview for a
paused subscription, so the confirmation reads "Your subscription
replaces the grant." with the fallback charge sentence, and toward a
price of another interval "Your card is charged <price> now, minus a
credit for the unused part of your current plan, then <then> from
today." The server's 8-second write timeout can lose a slow switch's
answer while its state still converges; that is #290.

docs/stripe.md, docs/database-locks.md and
docs/models/provider-integration.md say the same. The change text also
carries section 12's granted-card ride-along, built there.

Tests: TestSwitchAPausedSubscription_* (seventeen: the two calls, the
granted tier, a decline and an approval paused again, every other exit
restoring, a closed request, a holding subscription, a stale marker, a
foreign pause, an ordinary upgrade, the guard, the backstop before the
clear, every call's deadline, the abandoned upgrade voided first, a
failed restore, a marked grant resuming), TestPreviewSwitchOfAPausedSubscription,
TestCancelAPausedSubscriptionVoidsAnAbandonedSwitch,
TestCancelAHoldingSubscriptionVoidsNothing,
TestOutboxPollSkipsAnEntryNotYetDue, the server's paused-switch post and
preview tests, and new TestOffer, TestPlanMoveMessageStatesTheTiming
and TestPayingByLadder rows. The golden
paid-retired-plus-under-granted-pro gains the switch; the five
paid-plus-under-granted-pro and granted-plus-own-tier states pin its
refusal for a tier of two ladders and are unchanged.

Deviations: the past-due and paused post rows set Stripe's status on
the mock and reconcile it, since the kit's Suspend and Subscribe build
no Stripe subscription for the mock; the break "the replacement not
resumed when S ends" breaks the resumption's read in
grant_resumption.go, because no Go write records a replacement
(core.grant_replacements is a view).

Sandbox 11.1 ran before the code: pending_if_incomplete refuses
pause_collection, a paused subscription answers no proration preview,
and the two-call form works, approval included; every non-applied
outcome ended paused with nothing charged.

Full suite on reset databases: 5018 passed, 14 skipped, none failed.
make screens: no member page changed; the known flakes on the plan
ladder and domains pages.

Breaks, all CAUGHT (75):
- the clear not sent
- the clear folded into call 2
- call 2's form changed
- the restore not run after a decline
- the restore not run after an approval
- the restore run only in the two pending branches, in place of the deferred step
- the deferred step set up after call 1 answers, in place of before it
- the restore called before each return, in place of a deferred step
- WithoutCancel dropped from restorePause's context
- WithoutCancel dropped from stripeCallContext
- the applied branch run under the request's context
- the restore made a blind set
- the scope widened to a stale marker
- the scope widened to a foreign pause
- the scope narrowed to a subscription that does not hold its ladder
- the paused form taken with no pause
- the guard not called
- the passed time not refused
- the earlier of the two times taken
- automatically_finalizes_at not read
- next_payment_attempt not read
- the billing-reason test dropped
- the auto-advance test dropped
- a failed list let through
- a draft with both times null let through
- the backstop written after the clear
- the backstop's delay dropped
- EnqueueAt leaving the column's default
- a failed backstop insert ignored
- the poll's next_attempt_at filter dropped
- call 2 sent with no context
- call 2 given pausedSwitchCallTimeout
- the clear sent with no context
- the draft list sent with no context
- the restore given no deadline
- the proration moment's check dropped
- the preview calling Stripe for a paused subscription
- the preview keyed on the console's pause alone
- Cancel's void dropped
- a refused void let through to the cancellation
- Cancel's void placed after the commitment's deferral
- Cancel's void widened to a holding subscription
- the replacement not resumed when S ends
- rule 3a applied after rule 3
- ReplacesGrant not set by rule 3a item 2
- the paid-tier rank test dropped
- the suspension test dropped
- replacesGrant ignored
- section 8's exception removed for the switch's product
- a paying refusal left to !pos.Subscribed (the not-suspended mapping deleted)
- the suspended mapping deleted from switchOffer
- the Paying.Paused test dropped from switchOffer
- the !pos.Subscribed test dropped from the mappings' condition
- the currency test dropped
- the currency test applied ahead of the price rule
- NewPeriod not set
- NewPeriod set on a disabled move
- NewPeriod set whatever the interval
- payingFrom leaving Currency and Interval unset
- postedPrice leaving Currency unset
- defaultPrice leaving Currency unset
- planMoveMessage ignoring newPeriodCharge
- M12 chosen only when the preview has no figure
- renderSwitchPreview passing no newPeriodCharge
- renderSwitchPreview passing no ReplacesGrant
- step 4 dropped from the paused form
- the abandoned void sent with no context
- step 4 moved after the clear
- the failed restore not logged (L7)
- the failed restore logged without stripe_subscription_id (L7)
- the restore's failure changing the member's answer (F64)
- a bundle's subscription offered the switch (rule 3a item 2 reading OtherLadders)
- the server preview calling Stripe for a paused subscription
- a bundle target offered the switch (rule 3a item 2 reading Target.OtherLadders)
- a bundle target posted from a paused subscription
This commit is contained in:
2026-10-29 16:24:33 -05:00
parent 84709d96d5
commit 23e5689df8
35 changed files with 2661 additions and 236 deletions
+8
View File
@@ -209,6 +209,14 @@ advisory lock, then the rest by branch.
reconcile (`ApplySubscriptionCollection`) makes the Stripe update after the
commit, with no lock held.
- **The paused switch:** a member's switch of a subscription carrying the
console's pause (`upgradeNow`) writes its backstop entry, a
`reconcile_subscription` row due four minutes later, as one insert on the
database handle that commits on its own, before any Stripe write, and holds
no lock. Its restore after a switch that did not apply is an ordinary
`ApplySubscriptionCollection` reconcile, so it takes the locks above in the
order above.
No path takes a pool row and then a subscription's advisory lock, so the
order cannot wait in a cycle with a grant act, which takes the pool row and
never a subscription's lock.
+1 -1
View File
@@ -76,7 +76,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
16. **[db]** A ladder's display name is unique, compared without regard to case (`uq_plan_ladders_name_ci`). The name is what every catalog and composite surface leads with, so two ladders sharing one would be indistinguishable to an operator. Migration `00012` deduped colliding names before adding the index, and folded the existing ladder keys to the key grammar (lower-cased, separator runs to `_`, leading non-letters removed), clearing any value the grammar still rejected and any that collided after folding.
17. **[app]** Every plan act's transition reason comes from one function, `PlanActReason` (`internal/entitlements/plan_act_runner.go`), which builds it from the act's detail row so the wording lives in one place. The three tier acts that write transitions (add, removal, reorder) name the ladder: `tier added to ladder <name>`, `tier removal (<product> from <ladder>)` and `plan-ladder tier reorder (<ladder>)`. The default change names the org type's key: `org-type default change (<org type>)`. `DeleteTier` records its act (cause `tier_removed`) but writes no transition, because no one holds the tier, so it builds no reason; a Retry moves no position and has no reason. A move that a drain settles later builds the reason from the original act's detail row, so it reads as the act's own, and a migrated or grandfathered position appends what was done to it (`MigratedReason`, `GrandfatheredReason`). The reason is stored on the transition rows, so it is a write-time snapshot: it records the name as it read then, and a later rename does not rewrite it. An act that cannot read the ladder's row refuses at the read of names and records nothing.
18. **[app]** When a subscription's end, a grant's revocation or a grant's expiry ends at least one provision of that plan, every grant `core.resumable_grants(now())` names with that plan as holder re-confers from its own row, with the reason `grant-resumption:subscription:`, `grant-resumption:revocation:` or `grant-resumption:expiration:` followed by the ending plan's id. The chain rule: a marked grant waits on the nearest plan above it in its chain of replacements that has not ended, and resumes when that plan ends; a plan between them that ends first (it expires, is revoked, or is unmarked while it waits) is passed over. A grant and its extensions are one plan, named by the lineage's head. `core.grant_waits` is the one statement of the walk and the conditions (marked, active, valid at the instant, delivering nothing, its product on at least one ladder whose rungs no source but the holder holds, and first in resumption order among grants sharing a ladder), and the operator's delivery states and the member's held rung read it through the two functions. No other ending path resumes a grant: not a suspension, not a subscription dropping an item, not an extension, not an org-type migration and not a tier removal.
19. **[app]** On a ladder of the product it buys or switches to, a member's own move never takes a pool below a rung a grant delivers: from a grant-held rung the only move offered is up: through Checkout when no subscription pays on the ladder. On a ladder a subscription pays for without holding its rung, no tier is sold through Checkout: the switch of the paying subscription is offered at or above the grant and above the paid tier, and the paid tier resumes under a lower grant. Checkout never supersedes a position on another ladder of the product unless that ladder offers Checkout too: Checkout of a product refuses unless every ladder it is a tier of offers it. Two exceptions are open (#88), because the decision reads a position only on the ladders it is asked about. The plan switch: a switch to a product that is a tier of several ladders can supersede a position on another of them, a grant's included, because deciding it needs the subscription that holds each ladder. And a rung on a ladder the product is not a tier of: supersession is whole-bundle (invariant 7), so Checkout that supersedes a grant's position on a ladder of the product also ends that grant's rungs on ladders the product is not a tier of, and no default is restored there while the pool holds the product (invariant 11). `plans.Offer` and `plans.Across` state the rule; the Products page, Checkout and the switch read it.
19. **[app]** On a ladder of the product it buys or switches to, a member's own move never takes a pool below a rung a grant delivers: from a grant-held rung the only move offered is up: through Checkout when no subscription pays on the ladder. On a ladder a subscription pays for without holding its rung, no tier is sold through Checkout: the switch of the paying subscription is offered at or above the grant and above the paid tier, unless the paid product or the target is a tier of another ladder too, and the paid tier resumes under a lower grant. Checkout never supersedes a position on another ladder of the product unless that ladder offers Checkout too: Checkout of a product refuses unless every ladder it is a tier of offers it. Two exceptions are open (#88), because the decision reads a position only on the ladders it is asked about. The plan switch: a switch to a product that is a tier of several ladders can supersede a position on another of them, a grant's included, because deciding it needs the subscription that holds each ladder. And a rung on a ladder the product is not a tier of: supersession is whole-bundle (invariant 7), so Checkout that supersedes a grant's position on a ladder of the product also ends that grant's rungs on ladders the product is not a tier of, and no default is restored there while the pool holds the product (invariant 11). `plans.Offer` and `plans.Across` state the rule; the Products page, Checkout and the switch read it.
20. **[db-code]** and **[app]** A subscription's reconcile does not take back a ladder another plan took from it. While a position of a non-default source other than the subscription holds a ladder on which the subscription's latest episode ended in a supersession by another source, the reconcile confers none of the subscription's products whose shape names that ladder; the subscription delivers nothing there. When the plan that holds the ladder ends and the act ended at least one of its provisions (a grant's revocation or expiry, the holding subscription's end, or a live subscription dropping the item that held the ladder), that act confers each such subscription's recorded products in its own transaction, after resumption (invariant 18) and before default restoration, with the reason `subscription-delivery:revocation:`, `subscription-delivery:expiration:`, `subscription-delivery:subscription:` or `subscription-delivery:dropped:` followed by the ending plan's id; a subscription the ending plan superseded goes first, then the rest in subscription id order, a past-due, unpaid or paused subscription comes back suspended, and a refused conferral is logged and leaves that subscription undelivered. In any other case the subscription's next reconcile after no such plan holds the ladder confers it again. While every conferring product the subscription pays for is left alone and every plan holding those ladders is a grant, the subscription's collection is paused at Stripe. The exception: a grant's hold ends when the subscription pays at or above the grant. The reconcile confers a product of the subscription whose shape names such a ladder when the holder is a grant, the product is not the one the grant displaced, and its rank on the ladder is at or above the rank of the grant's product; the grant is then replaced by the subscription. The product the grant displaced never takes the ladder back, whatever its rank. A grant product that is no tier of the ladder never yields. A default-sourced position never holds a ladder against a subscription (invariant 11). `core.subscription_displacements` is the one statement of which ladders those are; the reconcile, the organization page and the operator's subscriptions list read it.
## Dimensions
+1 -1
View File
@@ -112,7 +112,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
- **Capability rows vs operational rows** — boot-stamped from code and reconciled every boot, vs database-canonical and operator-owned. The line runs through the middle of `core.providers`.
- **Mandatory surface vs optional hooks** — every integration has a key, a manifest, and migrations; routes, workflows, config, UI, and dashboard cards are opt-in interfaces discovered at composition time.
- **Dispatch transport** — desired state is always delivered through the desired-state outbox and the core relay, whatever the provider. Lifecycle commands keep a per-provider transport: outbox handoff when dispatch must be atomic with a domain commit, direct Temporal when the workflow owns its own writes. That second choice is a capability decision per integration, not a doctrine.
- **Inbound vs outbound substrate** — `webhook_events` (verified events in; each Stripe row is one Temporal workflow, retried on Temporal's schedule to dead-letter) vs `outbox` (actions out; a relay polls pending rows and each becomes its own Temporal workflow, retried the same way to dead-letter) vs `desired_state_outbox` (notices that a subject changed, written by triggers; the core relay merges the notices it claims and expands them into the records they affect, and recomputes each record once). A notice is dead-lettered after five failed attempts: for a wide notice, failures of its expansion query or of requeueing a failed recipient; for a recipient_only notice, failures of its own recompute. A push that fails is never dead-lettered, because the gap between a record's generation and the applied generation already records it as lag.
- **Inbound vs outbound substrate** — `webhook_events` (verified events in; each Stripe row is one Temporal workflow, retried on Temporal's schedule to dead-letter) vs `outbox` (actions out; a relay polls the pending rows whose `next_attempt_at` has passed and each becomes its own Temporal workflow, retried the same way to dead-letter) vs `desired_state_outbox` (notices that a subject changed, written by triggers; the core relay merges the notices it claims and expands them into the records they affect, and recomputes each record once). A notice is dead-lettered after five failed attempts: for a wide notice, failures of its expansion query or of requeueing a failed recipient; for a recipient_only notice, failures of its own recompute. A push that fails is never dead-lettered, because the gap between a record's generation and the applied generation already records it as lag.
- **Key ownership × shape × metering** — ownership (the `provider` column) and metering (whether usage of the key is measured, via the usage rows in the entitlements model) are independent: a platform-owned key may be metered. Shape is not fully independent — as built, only numeric keys have usage machinery, so a boolean key cannot be metered.
- **The three roles of a key** — the bare stored id (`fedwiki_sites`), the grouping attribute (the `provider` column), and the display name shown to humans. A dotted `provider.key` form does not exist in storage and must never be parsed out of a key string.
- **Config key classes** — secret vs overridable; boot-required vs runtime-managed; enum-closed vs free. Orthogonal, all declared in the spec.
+2 -1
View File
@@ -212,9 +212,10 @@ A member moves between the paid tiers of a plan ladder on the Products page, and
- **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 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. 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 **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 an `always_invoice` update on a paused subscription, 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 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. 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.
- **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 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.
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.
## Making a product purchasable
@@ -120,7 +120,11 @@
Cancelling goes through the shared confirm modal, like the
held rung's and the free rung's controls below, which carry
the same body (grant-resumption D10, D16). A plan whose
payment is due offers Pay invoice first (design D8). */}}
payment is due offers Pay invoice first (design D8). The
granted tier's current card also draws the switch of the
paying subscription the grant displaced (offer rule 3a.2),
which replaces the grant, through the move controls below; no
other current card draws one. */}}
{{ if or (eq .Relation "current") .PaidUnderGrant }}{{ $pay := .PaysInvoice }}{{ if $pay }}
<a href="/billing/plans/{{ .LadderID }}/unpaid-invoice" target="_blank" rel="noopener" hx-boost="false" class="btn btn-outline-primary btn-sm w-100 mt-auto"{{ if $.BillingFacts.Shown }} aria-describedby="{{ $.BillingFacts.ID }}"{{ end }}>Pay invoice</a>
{{ end }}
@@ -143,7 +147,7 @@
data-action-fields='{"ladder_id": "{{ .LadderID }}"}'>Cancel plan</button>
{{ end }}
{{ end }}
{{ if ne .Relation "current" }}
{{ if or (ne .Relation "current") (eq .MoveKind "switch") }}
{{ if .MoveKind }}
{{ if .MoveEnabled }}
{{/* chrome-conventions D2 (one dominant primary action per screen):
+10
View File
@@ -6,6 +6,7 @@ package entitlements
import (
"context"
"fmt"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
@@ -23,6 +24,15 @@ func RequestSubscriptionReconcile(ctx context.Context, tx integration.DBTX, subs
map[string]string{"subscription_id": subscriptionID})
}
// RequestSubscriptionReconcileAt writes a reconcile_subscription entry that is
// due at notBefore, on tx, which may be a *sql.DB: the insert then commits on
// its own. The relay takes the entry only from that time. It is an insert and
// takes no lock.
func RequestSubscriptionReconcileAt(ctx context.Context, tx integration.DBTX, subscriptionID string, notBefore time.Time) error {
return integration.EnqueueAt(ctx, tx, "stripe", ReconcileSubscriptionAction,
map[string]string{"subscription_id": subscriptionID}, notBefore)
}
// RequestPoolSubscriptionReconciles writes one reconcile_subscription entry
// into tx for each live subscription of the pool's organization (active,
// trialing, past_due or unpaid), in subscription id order, except the
+21
View File
@@ -7,8 +7,11 @@ import (
"context"
"database/sql"
"log/slog"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
)
// SweepCollectionDriftPaged runs the collection drift sweep with a page size
@@ -23,3 +26,21 @@ func SweepCollectionDriftPaged(ctx context.Context, db *sql.DB, logger *slog.Log
}
return sweepCollectionDrift(ctx, db, logger, keyMode, pageSize, reads)
}
// The paused switch's deadlines, for the tests in fulfillment_test.
const (
PausedSwitchCallTimeout = pausedSwitchCallTimeout
PausedSwitchChargeTimeout = pausedSwitchChargeTimeout
PausedSwitchRestoreTimeout = pausedSwitchRestoreTimeout
PausedSwitchBackstopDelay = pausedSwitchBackstopDelay
)
// SetRequestBackstop replaces the paused switch's backstop writer for the
// test and restores it in t.Cleanup. A test that sets it does not call
// t.Parallel: the variable is the package's.
func SetRequestBackstop(t testing.TB, f func(context.Context, integration.DBTX, string, time.Time) error) {
t.Helper()
prev := requestBackstop
requestBackstop = f
t.Cleanup(func() { requestBackstop = prev })
}
+249
View File
@@ -0,0 +1,249 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package fulfillment
// paused_switch.go holds the parts of the switch of a subscription that
// carries the console's collection pause (grant-pauses-subscription-billing
// D12). Stripe refuses an always_invoice update on a paused subscription and
// refuses pause_collection beside payment_behavior=pending_if_incomplete, so
// the switch is two calls: the console's clear, and then upgradeNow's update
// with nothing changed in its form. Every way out but an applied update
// restores the pause through the reconcile's own decision.
import (
"context"
"database/sql"
"fmt"
"log/slog"
"time"
stripe "github.com/stripe/stripe-go/v81"
"github.com/stripe/stripe-go/v81/invoice"
"github.com/stripe/stripe-go/v81/subscription"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
)
// The paused switch's deadlines and delays (design D12). Each Stripe call of
// the paused form, from the draft list to the restore's last call, carries a
// deadline of its own that outlives the request (stripeCallContext).
//
// - pausedSwitchCallTimeout bounds the draft list, the two voids and the
// clear; pausedSwitchChargeTimeout bounds call 2, longer because a
// deadline that cuts a slow but successful charge answers the generic
// failure after the member was charged; pausedSwitchRestoreTimeout bounds
// the restore, whose calls share it.
// - pausedSwitchBackstopDelay is longer than the request's bounded length
// from the backstop's insert: the void 20 seconds, the clear 20, call 2
// 60, the decline's void 20 and the restore 60, three minutes in all, so
// the entry of this attempt never pauses the subscription between the two
// calls. An earlier attempt's backstop or echo entry, run by the relay
// between them, can: call 2 is then refused with 400, the restore finds
// agreement and nothing is charged.
// - renewalFinalizeMargin covers the latest moment the pause can be back
// when the restore fails: the backstop's delay, the relay's 15-second
// poll and one outbox attempt (outboxAttemptTimeout, two minutes).
const (
pausedSwitchCallTimeout = 20 * time.Second
pausedSwitchChargeTimeout = 60 * time.Second
pausedSwitchRestoreTimeout = 60 * time.Second
pausedSwitchBackstopDelay = 4 * time.Minute
renewalFinalizeMargin = 10 * time.Minute
)
// requestBackstop writes the paused switch's backstop entry. It is a variable
// as the seam of the row "a failed backstop insert" in
// TestSwitchAPausedSubscription_TheBackstopIsWrittenBeforeTheClear, which fails
// the insert; production never reassigns it.
var requestBackstop = entitlements.RequestSubscriptionReconcileAt
// consolePaused reports whether the fetched subscription carries the
// console's collection pause: pause_collection set and the marker present. A
// stale marker with no pause and a pause without the marker (made in the
// Dashboard) are not the console's. It reads no row.
func consolePaused(sub *stripe.Subscription) bool {
return sub.PauseCollection != nil && sub.Metadata[collectionMarkerKey] == collectionMarkerValue
}
// stripeCallContext is the context of one Stripe call the paused switch makes:
// the request's values with a deadline of d, and not the request's
// cancellation. A member who closes the page after the clear must not cut the
// calls that put the pause back, and a call cut mid-flight is resolved by the
// restore's fresh fetch.
func stripeCallContext(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(ctx), d)
}
// callTimeoutParams returns void params that carry a stripeCallContext of d,
// and its cancel.
func callTimeoutParams(ctx context.Context, d time.Duration) (*stripe.InvoiceVoidInvoiceParams, context.CancelFunc) {
cctx, cancel := stripeCallContext(ctx, d)
params := &stripe.InvoiceVoidInvoiceParams{}
params.Context = cctx
return params, cancel
}
// renewalFinalizesSoon reports whether one of the subscription's draft
// invoices is a renewal that Stripe finalizes by now + renewalFinalizeMargin.
// A renewal made while the subscription is paused is a draft for about an hour
// and Stripe reads the pause at its finalization: voided while paused, charged
// in full when not. The test reads the later of next_payment_attempt and
// automatically_finalizes_at, so a field Stripe leaves in the past while it
// holds a finalization does not refuse on its own; the cost is a miss when
// Stripe finalizes at the earlier field while the later lies beyond the margin
// (F66). A time already passed counts, because the draft is still a draft. A
// field Stripe leaves null is zero in stripe-go and is passed over when the
// other is set; a renewal draft with both null refuses. It reads no row.
func renewalFinalizesSoon(drafts []*stripe.Invoice, now time.Time) bool {
limit := now.Add(renewalFinalizeMargin).Unix()
for _, d := range drafts {
if d.BillingReason != stripe.InvoiceBillingReasonSubscriptionCycle || !d.AutoAdvance {
continue
}
if max(d.NextPaymentAttempt, d.AutomaticallyFinalizesAt) <= limit {
return true
}
}
return false
}
// guardRenewalFinalization refuses the paused switch while a paused renewal's
// draft is about to finalize: it lists the subscription's draft invoices (the
// Invoices read the restricted key already holds) and returns
// ErrRenewalFinalizing when renewalFinalizesSoon holds, with now taken after
// the list answered. A failed list refuses too, as a failed fetch does.
func guardRenewalFinalization(ctx context.Context, stripeSubID string) error {
lctx, cancel := stripeCallContext(ctx, pausedSwitchCallTimeout)
defer cancel()
params := &stripe.InvoiceListParams{
Subscription: stripe.String(stripeSubID),
Status: stripe.String(string(stripe.InvoiceStatusDraft)),
}
params.Limit = stripe.Int64(10)
params.Context = lctx
var drafts []*stripe.Invoice
it := invoice.List(params)
for it.Next() {
drafts = append(drafts, it.Invoice())
}
if err := it.Err(); err != nil {
return fmt.Errorf("switch: list the subscription's draft invoices: %w", err)
}
if renewalFinalizesSoon(drafts, time.Now()) {
return ErrRenewalFinalizing
}
return nil
}
// restorePause puts the collection pause back after a paused switch that did
// not apply. It is the reconcile's own decision, never a blind set:
// ApplySubscriptionCollection fetches the subscription after call 2, decides
// under the pool row, calls Stripe only when the rule disagrees and decides
// once more after its own call. So it pauses a subscription that does not hold
// its ladder again, leaves a holding one unpaused, and stays right when a
// revocation, an expiry or a Resume commits meanwhile, or when call 2 applied
// despite its error (the rule reads Stripe's items and lets the new tier take
// the ladder). The context drops the request's cancellation and carries a
// deadline of pausedSwitchRestoreTimeout shared by the restore's calls. The
// error is logged (L7) and never returned: the member reads call 2's answer.
// When a set fails the restore writes no outbox entry, so the clear's echo and
// the backstop entry carry the retry.
func restorePause(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode, coreSubID, stripeSubID string) {
rctx, cancel := stripeCallContext(ctx, pausedSwitchRestoreTimeout)
defer cancel()
if err := ApplySubscriptionCollection(rctx, db, logger, keyMode, coreSubID, "member:switch:restore"); err != nil {
logger.Warn("switch: the collection pause could not be restored; the outbox entry restores it",
slog.String("stripe_subscription_id", stripeSubID), slog.Any("error", err))
}
}
// pausedUpgrade is upgradeNow's form for a subscription that carries the
// console's pause, whatever holds the ladder (a holding subscription whose
// pause the outbox has not cleared yet is delivering, so clearing is right).
// current is the subscription upgradeNow fetched first. In order:
//
// 1. a proration moment before the current period returns ErrStalePreview,
// because Stripe refuses a proration_date outside the period and would
// refuse call 2 after the clear;
// 2. the finalization guard, a read;
// 3. the backstop entry, one insert on db that commits before any Stripe
// write: it covers a crash between the calls, or a failed restore while
// the clear's echo is lost, a case the drift sweep cannot see because no
// reconcile ran after call 1 and the mirror still reads paused; it is
// due after pausedSwitchBackstopDelay, so it never pauses the
// subscription between the two calls;
// 4. the void of an abandoned upgrade's invoice, on the still-paused
// subscription, where Stripe accepts it and drops the pending update;
// 5. a deferred restore that runs on every return from here and on a panic,
// unless the update applied;
// 6. call 1, the clear;
// 7. call 2, upgradeNow's update, unchanged;
// 8. its answer as upgradeNow reads it, an applied branch under
// context.WithoutCancel.
func pausedUpgrade(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, billingQ *billing.Queries, ls ladderSubscription, current *stripe.Subscription, targetStripePriceID string, prorationDate time.Time, reason string) error {
if prorationDate.Before(time.Unix(current.CurrentPeriodStart, 0)) {
return ErrStalePreview
}
if err := guardRenewalFinalization(ctx, ls.stripeSubID); err != nil {
return err
}
if err := requestBackstop(ctx, db, ls.coreSubID, time.Now().Add(pausedSwitchBackstopDelay)); err != nil {
return fmt.Errorf("switch: write the collection backstop: %w", err)
}
voidParams, cancelVoid := callTimeoutParams(ctx, pausedSwitchCallTimeout)
err := voidAbandonedUpgrade(current, voidParams)
cancelVoid()
if err != nil {
return fmt.Errorf("switch: void an abandoned upgrade's invoice: %w", err)
}
applied := false
defer func() {
if !applied {
restorePause(ctx, db, logger, keyMode, ls.coreSubID, ls.stripeSubID)
}
}()
if err := pausedSwitchClear(ctx, ls.stripeSubID); err != nil {
return err
}
updated, err := pausedSwitchUpdate(ctx, ls, current, targetStripePriceID, prorationDate)
if err != nil {
return err
}
voidParams, cancelVoid = callTimeoutParams(ctx, pausedSwitchCallTimeout)
defer cancelVoid()
if err := upgradeAnswer(logger, ls, updated, voidParams); err != nil {
return err
}
applied = true
return appliedUpgrade(context.WithoutCancel(ctx), db, logger, keyMode, billingQ, ls, current, updated, reason)
}
// pausedSwitchClear is call 1: the console's clear, under its own deadline.
func pausedSwitchClear(ctx context.Context, stripeSubID string) error {
cctx, cancel := stripeCallContext(ctx, pausedSwitchCallTimeout)
defer cancel()
params := collectionParams(collectionClear)
params.Context = cctx
if _, err := subscription.Update(stripeSubID, params); err != nil {
return fmt.Errorf("switch: clear the collection pause before the upgrade: %w", err)
}
return nil
}
// pausedSwitchUpdate is call 2: upgradeNow's update with nothing changed in
// its form, under pausedSwitchChargeTimeout.
func pausedSwitchUpdate(ctx context.Context, ls ladderSubscription, current *stripe.Subscription, targetStripePriceID string, prorationDate time.Time) (*stripe.Subscription, error) {
cctx, cancel := stripeCallContext(ctx, pausedSwitchChargeTimeout)
defer cancel()
params := upgradeParams(ls, current, targetStripePriceID, prorationDate)
params.Context = cctx
updated, err := subscription.Update(ls.stripeSubID, params)
if err != nil {
return nil, fmt.Errorf("switch: stripe upgrade subscription item: %w", err)
}
return updated, nil
}
+931
View File
@@ -0,0 +1,931 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package fulfillment_test
// The switch of a subscription that carries the console's collection pause
// (openspec change grant-pauses-subscription-billing, design D11 and D12,
// section 11): two Stripe calls, the clear and then the unchanged upgrade,
// with a restore on every exit but an applied update, the finalization guard,
// the backstop entry, and a deadline on every call. The Stripe mock answers as
// Stripe does in the sandbox (pausedStripe): an item update on a paused
// subscription is refused with 400, a clear and a set change the pause and its
// marker, and the void of an invoice drops a pending update.
import (
"context"
"database/sql"
"errors"
"log/slog"
"slices"
"strings"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/fulfillment"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
stripe "github.com/stripe/stripe-go/v81"
)
// tierRef is a plan tier of the fixture's ladder with its prices.
type tierRef struct {
productID, corePriceID, stripePriceID string
}
// pausedWorld is a subscription S on the fixture's tier (Plus), an operator's
// grant of the second tier (Pro) over it, and a third tier (Max) above both.
// S's Stripe subscription carries the console's pause, mirrored into its row.
type pausedWorld struct {
database *sql.DB
fx fixture
mock *stripetest.MockBackend
pro, max tierRef
grant entitlements.Grant
stripe *pausedHandler
}
// newPausedWorld builds on payingUnderGrantOf.
func newPausedWorld(t *testing.T, marked bool) *pausedWorld {
t.Helper()
database, fx, mock, pro, grant := payingUnderGrantOf(t, marked)
w := &pausedWorld{database: database, fx: fx, mock: mock, pro: pro, grant: grant}
w.max.productID, w.max.corePriceID, w.max.stripePriceID = addTier(t, database, fx, "Max", 5000)
w.stripe = pausedStripe(mock, w.max.stripePriceID, "applied")
return w
}
// pauseWithMarker puts the console's pause and marker on a Stripe subscription.
func pauseWithMarker(sub *stripe.Subscription) {
sub.PauseCollection = &stripe.SubscriptionPauseCollection{Behavior: stripe.SubscriptionPauseCollectionBehaviorVoid}
if sub.Metadata == nil {
sub.Metadata = map[string]string{}
}
sub.Metadata["member_console_collection"] = "paused_for_grant"
}
// pausedHandler makes the mock answer the paused switch's calls the way Stripe
// does. outcome is what the item update answers when the pause is clear:
// "applied", "declined" or "approval". itemHook, when set, answers the item
// update ahead of that; clearHook, when set, runs after a clear has been
// applied to mock.Sub and returns the error the clear answers with; setHook,
// when set, runs before a set is applied and answers the set with the error it
// returns, leaving mock.Sub as it was.
type pausedHandler struct {
mock *stripetest.MockBackend
target string
outcome string
itemHook func(c stripetest.Call) (any, error, bool)
clearHook func(c stripetest.Call) error
setHook func(c stripetest.Call) error
}
const abandonedInvoice = "in_paused_switch"
func stripeError(status int, msg string) error {
return &stripe.Error{Type: stripe.ErrorTypeInvalidRequest, HTTPStatusCode: status, Msg: msg}
}
// pausedStripe extends stripeLike. A subscription POST holding pause_collection=
// and no item clears mock.Sub's pause and marker; one holding
// pause_collection[behavior]=void sets them; one holding items[0][price] while
// mock.Sub is paused answers Stripe's 400, so a form that skips the clear fails
// as at Stripe; otherwise the item update answers the outcome. A POST to an
// invoice's void clears mock.Sub's pending update.
func pausedStripe(mock *stripetest.MockBackend, target, outcome string) *pausedHandler {
h := &pausedHandler{mock: mock, target: target, outcome: outcome}
prev := mock.Handle
mock.Handle = func(c stripetest.Call) (any, error, bool) {
switch {
case c.Method != "POST":
case c.Path == "/v1/subscriptions/"+mock.Sub.ID:
if p, err, handled := h.subscriptionPost(c); handled {
return p, err, true
}
case strings.HasPrefix(c.Path, "/v1/invoices/") && strings.HasSuffix(c.Path, "/void"):
mock.Sub.PendingUpdate = nil
}
if prev != nil {
return prev(c)
}
return nil, nil, false
}
return h
}
func (h *pausedHandler) subscriptionPost(c stripetest.Call) (any, error, bool) {
sub := h.mock.Sub
switch {
case c.Form.Has("items[0][price]"):
return h.itemUpdate(c)
case c.Form.Has("pause_collection") || c.Form.Get("pause_collection[behavior]") != "":
if h.setHook != nil && c.Form.Get("pause_collection[behavior]") != "" {
if err := h.setHook(c); err != nil {
return nil, err, true
}
}
applyCollectionForm(sub, c.Form)
if h.clearHook != nil && c.Form.Has("pause_collection") {
if err := h.clearHook(c); err != nil {
return nil, err, true
}
}
}
return nil, nil, false
}
func (h *pausedHandler) itemUpdate(c stripetest.Call) (any, error, bool) {
if h.itemHook != nil {
if p, err, handled := h.itemHook(c); handled {
return p, err, true
}
}
sub := h.mock.Sub
if sub.PauseCollection != nil {
return nil, stripeError(400, "You cannot create a new invoice for a paused subscription."), true
}
switch h.outcome {
case "declined":
h.pend(stripe.PaymentIntentStatusRequiresPaymentMethod)
case "approval":
h.pend(stripe.PaymentIntentStatusRequiresAction)
default:
h.apply()
}
return sub, nil, true
}
// apply moves mock.Sub's item to the target price, as an applied update does.
func (h *pausedHandler) apply() {
sub := h.mock.Sub
sub.Items.Data[0].Price = &stripe.Price{ID: h.target}
sub.PendingUpdate, sub.LatestInvoice = nil, nil
}
// pend leaves mock.Sub's item as it was with a pending update and an open
// invoice whose payment is in the given state.
func (h *pausedHandler) pend(status stripe.PaymentIntentStatus) {
sub := h.mock.Sub
sub.PendingUpdate = &stripe.SubscriptionPendingUpdate{ExpiresAt: time.Now().Add(23 * time.Hour).Unix()}
sub.LatestInvoice = &stripe.Invoice{
ID: abandonedInvoice, Status: stripe.InvoiceStatusOpen, AmountDue: 900, Currency: stripe.CurrencyUSD,
HostedInvoiceURL: "https://invoice.stripe.com/i/" + abandonedInvoice,
PaymentIntent: &stripe.PaymentIntent{Status: status},
}
}
// kindOf names a write: "clear", "set", "update", "void", or the line.
func kindOf(c stripetest.Call) string {
switch {
case c.Method != "POST":
return ""
case strings.HasSuffix(c.Path, "/void"):
return "void"
case c.Form.Has("items[0][price]"):
return "update"
case c.Form.Get("pause_collection[behavior]") != "":
return "set"
case c.Form.Has("pause_collection"):
return "clear"
}
return stripetest.Line(c)
}
// writesFrom are the kinds of the POSTs the mock received from mark on.
func writesFrom(mock *stripetest.MockBackend, mark int) []string {
var out []string
for _, c := range mock.Calls[mark:] {
if k := kindOf(c); k != "" {
out = append(out, k)
}
}
return out
}
// firstCall is the first call from mark on of the kind, the zero Call when
// there is none.
func firstCall(mock *stripetest.MockBackend, mark int, kind string) stripetest.Call {
for _, c := range mock.Calls[mark:] {
if kindOf(c) == kind {
return c
}
}
return stripetest.Call{}
}
// draftLists counts the draft invoice lists from mark on.
func draftLists(mock *stripetest.MockBackend, mark int) int {
n := 0
for _, c := range mock.Calls[mark:] {
if c.Method == "GET" && c.Path == "/v1/invoices" {
n++
}
}
return n
}
// ladderHolder is the product the pool's active rung on the fixture's ladder
// holds, "" when none.
func ladderHolder(t *testing.T, database *sql.DB, fx fixture) string {
t.Helper()
var product string
err := database.QueryRowContext(context.Background(), `
SELECT product_id FROM core.pool_provision_ladders
WHERE pool_id = $1 AND plan_ladder_id = $2 AND status = 'active'`, fx.poolID, fx.ladderID).Scan(&product)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("read the product the ladder holds: %v", err)
}
return product
}
// backstopsSince are the next_attempt_at of S's reconcile_subscription
// entries written after the outbox's id mark whose time lies at least a
// minute after their creation: the backstop, not the immediate entry a
// reconcile writes.
func backstopsSince(t *testing.T, database *sql.DB, coreSub string, mark int64) []time.Time {
t.Helper()
rows, err := database.QueryContext(context.Background(), `
SELECT next_attempt_at FROM core.outbox
WHERE action_type = 'reconcile_subscription' AND payload->>'subscription_id' = $1
AND id > $2 AND next_attempt_at >= created_at + interval '1 minute' ORDER BY id`, coreSub, mark)
if err != nil {
t.Fatalf("read the backstop entries: %v", err)
}
defer rows.Close()
var out []time.Time
for rows.Next() {
var at time.Time
if err := rows.Scan(&at); err != nil {
t.Fatalf("scan: %v", err)
}
out = append(out, at)
}
if err := rows.Err(); err != nil {
t.Fatalf("read the entries: %v", err)
}
return out
}
// entriesSince are the next_attempt_at of S's reconcile_subscription entries
// written after the outbox's id mark, whatever their time.
func entriesSince(t *testing.T, database *sql.DB, coreSub string, mark int64) []time.Time {
t.Helper()
rows, err := database.QueryContext(context.Background(), `
SELECT next_attempt_at FROM core.outbox
WHERE action_type = 'reconcile_subscription' AND payload->>'subscription_id' = $1 AND id > $2 ORDER BY id`, coreSub, mark)
if err != nil {
t.Fatalf("read the entries: %v", err)
}
defer rows.Close()
var out []time.Time
for rows.Next() {
var at time.Time
if err := rows.Scan(&at); err != nil {
t.Fatalf("scan: %v", err)
}
out = append(out, at)
}
if err := rows.Err(); err != nil {
t.Fatalf("read the entries: %v", err)
}
return out
}
func outboxMark(t *testing.T, database *sql.DB) int64 {
t.Helper()
return int64(scalarInt(t, database, `SELECT COALESCE(max(id), 0) FROM core.outbox`))
}
// switchTo runs the switch of S to the price with a proration moment of now.
func (w *pausedWorld) switchTo(ctx context.Context, corePrice string) error {
return w.switchLogged(ctx, corePrice, discardLogger())
}
// switchLogged is switchTo with the logger given.
func (w *pausedWorld) switchLogged(ctx context.Context, corePrice string, logger *slog.Logger) error {
return fulfillment.SwitchPlan(ctx, w.database, logger, stripetest.KeyMode(), w.fx.orgID, w.fx.ladderID, corePrice, time.Now(), "member:switch")
}
func lines(mock *stripetest.MockBackend, mark int) []string {
var out []string
for _, c := range mock.Calls[mark:] {
if c.Method == "POST" {
out = append(out, stripetest.Line(c))
}
}
return out
}
// formHolds reports whether the form holds every key.
func formHolds(c stripetest.Call, keys ...string) bool {
for _, k := range keys {
if !c.Form.Has(k) {
return false
}
}
return true
}
// A paused subscription's switch sends the clear and then the update, whose
// form is the ordinary upgrade's.
func TestSwitchAPausedSubscription_TwoCalls(t *testing.T) {
w := newPausedWorld(t, false)
mark := len(w.mock.Calls)
err := w.switchTo(context.Background(), w.max.corePriceID)
got := writesFrom(w.mock, mark)
clear := firstCall(w.mock, mark, "clear")
update := firstCall(w.mock, mark, "update")
formsOK := formHolds(clear, "pause_collection", "metadata[member_console_collection]") && !clear.Form.Has("items[0][price]") &&
formHolds(update, "items[0][id]", "items[0][price]", "proration_date") &&
update.Form.Get("proration_behavior") == "always_invoice" && update.Form.Get("payment_behavior") == "pending_if_incomplete" &&
!touchesPause(update)
if err != nil || !slices.Equal(got, []string{"clear", "update"}) || !formsOK {
t.Errorf("switch writes = %v (error %v), want the clear and then the unchanged update", lines(w.mock, mark), err)
}
if held := ladderHolder(t, w.database, w.fx); held != w.max.productID {
t.Errorf("after the switch, the ladder holds %v, want Max", held)
}
}
// A switch to the granted tier is a transfer that records the grant as
// replaced by the subscription.
func TestSwitchAPausedSubscription_ToTheGrantedTier(t *testing.T) {
w := newPausedWorld(t, false)
w.stripe.target = w.pro.stripePriceID
err := w.switchTo(context.Background(), w.pro.corePriceID)
core := coreSubID(t, w.database, w.fx.stripeSubID)
if n := scalarInt(t, w.database, `SELECT count(*) FROM core.grant_replacements WHERE grant_id = $1 AND replacing_subscription_id = $2`,
w.grant.GrantID, core); err != nil || n != 1 {
t.Errorf("the grant is not recorded as replaced by the subscription (error %v, %d replacements)", err, n)
}
}
// A declined card leaves S on Plus, voids the invoice and pauses S again.
func TestSwitchAPausedSubscription_ADeclineIsPausedAgain(t *testing.T) {
w := newPausedWorld(t, false)
w.stripe.outcome = "declined"
rows, holder := poolRows(t, w.database, w.fx), ladderHolder(t, w.database, w.fx)
mark := len(w.mock.Calls)
err := w.switchTo(context.Background(), w.max.corePriceID)
set := firstCall(w.mock, mark, "set")
setOK := set.Form.Get("pause_collection[behavior]") == "void" && set.Form.Get("metadata[member_console_collection]") == "paused_for_grant"
if !errors.Is(err, fulfillment.ErrUpgradeDeclined) || !slices.Equal(writesFrom(w.mock, mark), []string{"clear", "update", "void", "set"}) || !setOK {
t.Errorf("after a declined switch, writes = %v (error %v), want the clear, the update, the void and the set", lines(w.mock, mark), err)
}
if after := ladderHolder(t, w.database, w.fx); after != holder || poolRows(t, w.database, w.fx) != rows {
t.Errorf("a declined switch changed the ladder: %v then %v", holder, after)
}
}
// An approval left pending pauses S again, with no void.
func TestSwitchAPausedSubscription_AnApprovalIsPausedAgain(t *testing.T) {
w := newPausedWorld(t, false)
w.stripe.outcome = "approval"
mark := len(w.mock.Calls)
err := w.switchTo(context.Background(), w.max.corePriceID)
var approval *fulfillment.UpgradeNeedsApprovalError
if !errors.As(err, &approval) || approval.HostedInvoiceURL == "" || !slices.Equal(writesFrom(w.mock, mark), []string{"clear", "update", "set"}) {
t.Errorf("after a switch awaiting approval, writes = %v (error %v), want the set after the update and no void", lines(w.mock, mark), err)
}
}
type restoreRow struct {
name string
setup func(h *pausedHandler)
}
func hook500(h *pausedHandler) {
h.itemHook = func(stripetest.Call) (any, error, bool) { return nil, stripeError(500, "boom"), true }
}
// checkRestoreAfter runs the switch with the row's fault and requires that the
// switch failed (or panicked) and that the last write is the restore's set.
func checkRestoreAfter(t *testing.T, row restoreRow) {
t.Helper()
w := newPausedWorld(t, false)
row.setup(w.stripe)
mark := len(w.mock.Calls)
var err error
func() {
defer func() { _ = recover() }()
err = w.switchTo(context.Background(), w.max.corePriceID)
}()
got := writesFrom(w.mock, mark)
if len(got) < 2 || got[0] != "clear" || got[len(got)-1] != "set" {
t.Errorf("%s: writes after the clear = %v, want the restore's set (error %v)", row.name, lines(w.mock, mark), err)
}
}
// Every other exit restores the pause: an error of either call, and a panic.
func TestSwitchAPausedSubscription_EveryOtherExitRestores(t *testing.T) {
rows := []restoreRow{
{"the update answers 500", hook500},
{"the update answers 400", func(h *pausedHandler) {
h.itemHook = func(stripetest.Call) (any, error, bool) { return nil, stripeError(400, "no"), true }
}},
{"the clear answers 500 after Stripe applied it", func(h *pausedHandler) {
h.clearHook = func(stripetest.Call) error { return stripeError(500, "boom") }
}},
{"a panic in the update", func(h *pausedHandler) {
h.itemHook = func(stripetest.Call) (any, error, bool) { panic("the update panics") }
}},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) { checkRestoreAfter(t, row) })
}
t.Run("the update answers 500 after Stripe applied it", checkAppliedDespiteItsError)
}
// When call 2 applied despite its error, the restore finds the new tier and
// sends no set.
func checkAppliedDespiteItsError(t *testing.T) {
w := newPausedWorld(t, false)
w.stripe.itemHook = func(stripetest.Call) (any, error, bool) {
w.stripe.apply()
return nil, stripeError(500, "boom"), true
}
mark := len(w.mock.Calls)
err := w.switchTo(context.Background(), w.max.corePriceID)
got, held := writesFrom(w.mock, mark), ladderHolder(t, w.database, w.fx)
if err == nil || !slices.Equal(got, []string{"clear", "update"}) || held != w.max.productID {
t.Errorf("call 2 applied despite its error: writes after the clear = %v and the ladder holds %v, want no set and Max", got, held)
}
}
// A request whose context ends when the clear lands still restores the pause
// and still delivers an applied switch.
func TestSwitchAPausedSubscription_AClosedRequestStillRestores(t *testing.T) {
for _, outcome := range []string{"declined", "applied"} {
t.Run(outcome, func(t *testing.T) {
w := newPausedWorld(t, false)
w.stripe.outcome = outcome
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
w.stripe.clearHook = func(stripetest.Call) error { cancel(); return nil }
mark := len(w.mock.Calls)
_ = w.switchTo(ctx, w.max.corePriceID)
clearAt := slices.IndexFunc(w.mock.Calls[mark:], func(c stripetest.Call) bool { return kindOf(c) == "clear" })
for _, c := range w.mock.Calls[mark+clearAt+1:] {
if c.ContextDone {
t.Errorf("%s: after the request's context was cancelled, %s was sent with a cancelled context", outcome, stripetest.Line(c))
}
}
checkClosedRequest(t, w, outcome, mark+clearAt)
})
}
}
func checkClosedRequest(t *testing.T, w *pausedWorld, outcome string, clearAt int) {
t.Helper()
switch outcome {
case "declined":
after := writesFrom(w.mock, clearAt+1)
set := firstCall(w.mock, clearAt+1, "set")
if len(after) == 0 || after[len(after)-1] != "set" || set.ContextDone ||
set.Form.Get("pause_collection[behavior]") != "void" || set.Form.Get("metadata[member_console_collection]") != "paused_for_grant" {
t.Errorf("declined: after the request's context was cancelled, writes after the clear = %v, want the restore's set last", lines(w.mock, clearAt+1))
}
default:
if held := ladderHolder(t, w.database, w.fx); held != w.max.productID {
t.Errorf("applied: after the request's context was cancelled, the ladder holds %v, want Max", held)
}
}
}
// heldWorld is S holding its ladder (no grant) while Stripe still carries the
// console's pause, as between the ending act and the outbox's clear.
func newHeldWorld(t *testing.T) *pausedWorld {
t.Helper()
database, fx, mock := subscribed(t)
stripeLike(mock)
w := &pausedWorld{database: database, fx: fx, mock: mock}
w.max.productID, w.max.corePriceID, w.max.stripePriceID = addTier(t, database, fx, "Max", 5000)
pauseWithMarker(mock.Sub)
w.stripe = pausedStripe(mock, w.max.stripePriceID, "applied")
return w
}
func TestSwitchAPausedSubscription_AHoldingSubscriptionStillPausedClearsIt(t *testing.T) {
w := newHeldWorld(t)
mark := len(w.mock.Calls)
err := w.switchTo(context.Background(), w.max.corePriceID)
if got := writesFrom(w.mock, mark); err != nil || !slices.Equal(got, []string{"clear", "update"}) {
t.Errorf("a holding subscription's upgrade sent no clear: %v (error %v)", lines(w.mock, mark), err)
}
}
func TestSwitchAPausedSubscription_AHoldingSubscriptionIsNotPausedAgain(t *testing.T) {
w := newHeldWorld(t)
w.stripe.outcome = "declined"
mark := len(w.mock.Calls)
_ = w.switchTo(context.Background(), w.max.corePriceID)
if got := writesFrom(w.mock, mark); slices.Contains(got, "set") || !slices.Equal(got, []string{"clear", "update", "void"}) {
t.Errorf("a holding subscription was paused again after a declined upgrade: %v", lines(w.mock, mark))
}
}
func TestSwitchAPausedSubscription_AStaleMarkerSendsNoClear(t *testing.T) {
w := newPausedWorld(t, false)
w.mock.Sub.PauseCollection = nil
mark, outbox := len(w.mock.Calls), outboxMark(t, w.database)
err := w.switchTo(context.Background(), w.max.corePriceID)
got := writesFrom(w.mock, mark)
lists, backstops := draftLists(w.mock, mark), len(backstopsSince(t, w.database, coreSubID(t, w.database, w.fx.stripeSubID), outbox))
if err != nil || !slices.Equal(got, []string{"update"}) || lists != 0 || backstops != 0 {
t.Errorf("with a stale marker, writes = %v, draft lists %d and backstop entries %d, want today's update alone", lines(w.mock, mark), lists, backstops)
}
}
func TestSwitchAPausedSubscription_AForeignPauseIsNotCleared(t *testing.T) {
w := newPausedWorld(t, false)
delete(w.mock.Sub.Metadata, "member_console_collection")
mark, outbox := len(w.mock.Calls), outboxMark(t, w.database)
err := w.switchTo(context.Background(), w.max.corePriceID)
touched := slices.ContainsFunc(w.mock.Calls[mark:], func(c stripetest.Call) bool { return c.Method == "POST" && touchesPause(c) })
backstops := len(backstopsSince(t, w.database, coreSubID(t, w.database, w.fx.stripeSubID), outbox))
if err == nil || touched || draftLists(w.mock, mark) != 0 || backstops != 0 {
t.Errorf("a foreign pause was cleared by the switch: %v (error %v, backstops %d)", lines(w.mock, mark), err, backstops)
}
}
func TestSwitchAPausedSubscription_AnOrdinaryUpgradeSendsNoClear(t *testing.T) {
database, fx, mock := subscribed(t)
stripeLike(mock)
max := tierRef{}
max.productID, max.corePriceID, max.stripePriceID = addTier(t, database, fx, "Max", 5000)
pausedStripe(mock, max.stripePriceID, "applied")
w := &pausedWorld{database: database, fx: fx, mock: mock}
mark, outbox := len(mock.Calls), outboxMark(t, database)
err := w.switchTo(context.Background(), max.corePriceID)
touched := slices.ContainsFunc(mock.Calls[mark:], func(c stripetest.Call) bool { return c.Method == "POST" && touchesPause(c) })
backstops := len(backstopsSince(t, database, coreSubID(t, database, fx.stripeSubID), outbox))
if err != nil || touched || draftLists(mock, mark) != 0 || backstops != 0 {
t.Errorf("an ordinary upgrade sent the clear, listed drafts or wrote a backstop: %v, %d", lines(mock, mark), backstops)
}
}
// guardRow is one draft of S in the mock and what the switch does about it.
// A duration of null is a time Stripe leaves null.
type guardRow struct {
name string
reason stripe.InvoiceBillingReason
noAdvance bool
next, fin time.Duration
refused bool
listFailed bool
}
const null = time.Duration(-1 << 62)
func at(start time.Time, d time.Duration) int64 {
if d == null {
return 0
}
return start.Add(d).Unix()
}
func checkGuard(t *testing.T, row guardRow) {
t.Helper()
const cycle = stripe.InvoiceBillingReasonSubscriptionCycle
w := newPausedWorld(t, false)
start := time.Now()
if row.reason == "" {
row.reason = cycle
}
w.mock.Invoices = map[string]*stripe.Invoice{"in_draft": {
ID: "in_draft", Subscription: &stripe.Subscription{ID: w.fx.stripeSubID}, Status: stripe.InvoiceStatusDraft,
BillingReason: row.reason, AutoAdvance: !row.noAdvance,
NextPaymentAttempt: at(start, row.next), AutomaticallyFinalizesAt: at(start, row.fin),
}}
listErr := stripeError(500, "list failed")
if row.listFailed {
w.mock.PathErrs = map[string]error{"/v1/invoices": listErr}
}
mark, outbox := len(w.mock.Calls), outboxMark(t, w.database)
err := w.switchTo(context.Background(), w.max.corePriceID)
got := writesFrom(w.mock, mark)
backstops := len(backstopsSince(t, w.database, coreSubID(t, w.database, w.fx.stripeSubID), outbox))
ok := err == nil && slices.Equal(got, []string{"clear", "update"})
want := "to switch"
if row.refused {
want = "to be refused with no write and no backstop"
ok = len(got) == 0 && backstops == 0 &&
(row.listFailed && errors.Is(err, listErr) || !row.listFailed && errors.Is(err, fulfillment.ErrRenewalFinalizing))
}
if !ok {
t.Errorf("%s: switch = %v with writes %v and %d backstop entries, want %s", row.name, err, lines(w.mock, mark), backstops, want)
}
}
// The guard refuses the paused switch while a paused renewal's draft is about
// to finalize, reading the later of the draft's two times.
func TestSwitchAPausedSubscription_TheGuardRefusesNearAFinalization(t *testing.T) {
const m = time.Minute
rows := []guardRow{
{name: "a renewal draft finalizing within the margin", next: 5 * m, fin: 5 * m, refused: true},
{name: "a renewal draft past its time, still a draft", next: -m, fin: -m, refused: true},
{name: "next_payment_attempt within the margin, automatically_finalizes_at beyond it", next: 5 * m, fin: 20 * m},
{name: "automatically_finalizes_at within the margin, next_payment_attempt beyond it", next: 20 * m, fin: 5 * m},
{name: "a renewal draft beyond the margin", next: 20 * m, fin: 20 * m},
{name: "a draft that is not a renewal", reason: stripe.InvoiceBillingReasonSubscriptionUpdate, next: 5 * m, fin: 5 * m},
{name: "a renewal draft with auto_advance false", noAdvance: true, next: 5 * m, fin: 5 * m},
{name: "both times null", next: null, fin: null, refused: true},
{name: "next_payment_attempt null, automatically_finalizes_at beyond the margin", next: null, fin: 20 * m},
{name: "the list answers 500", next: 20 * m, fin: 20 * m, refused: true, listFailed: true},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) { checkGuard(t, row) })
}
}
// The backstop entry is written, due later, before the clear is sent, and a
// failed insert refuses the switch before any Stripe write.
func TestSwitchAPausedSubscription_TheBackstopIsWrittenBeforeTheClear(t *testing.T) {
t.Run("the entry", func(t *testing.T) {
w := newPausedWorld(t, false)
core := coreSubID(t, w.database, w.fx.stripeSubID)
outbox, began := outboxMark(t, w.database), time.Now()
var seen []time.Time
w.stripe.clearHook = func(stripetest.Call) error {
seen = entriesSince(t, w.database, core, outbox)
return nil
}
if err := w.switchTo(context.Background(), w.max.corePriceID); err != nil {
t.Fatalf("switch: %v", err)
}
if len(seen) != 1 {
t.Fatalf("when the clear was sent, the backstop entries were %d, want 1", len(seen))
}
if min := began.Add(fulfillment.PausedSwitchBackstopDelay - time.Second); seen[0].Before(min) {
t.Errorf("the backstop's next_attempt_at = %v, want at least %v", seen[0], min)
}
})
t.Run("a failed backstop insert", func(t *testing.T) {
w := newPausedWorld(t, false)
failure := errors.New("the insert failed")
fulfillment.SetRequestBackstop(t, func(context.Context, integration.DBTX, string, time.Time) error { return failure })
mark := len(w.mock.Calls)
err := w.switchTo(context.Background(), w.max.corePriceID)
if !errors.Is(err, failure) || len(writesFrom(w.mock, mark)) != 0 {
t.Errorf("a failed backstop insert = %v with writes %v, want an error and no Stripe write", err, lines(w.mock, mark))
}
})
}
// sentCall is a call with the time the mock received it.
type sentCall struct {
stripetest.Call
at time.Time
}
func checkDeadline(t *testing.T, row, name string, c sentCall, want time.Duration) {
t.Helper()
if c.Deadline.IsZero() {
t.Errorf("%s: %s carried no deadline", row, name)
return
}
if d := c.Deadline.Sub(c.at); d < want-time.Second || d > want+time.Second {
t.Errorf("%s: %s's deadline is %v after it was sent, want %v", row, name, d, want)
}
}
// Every Stripe call of the paused form carries a deadline of its own, and the
// restore's calls share one.
func TestSwitchAPausedSubscription_EveryCallCarriesADeadline(t *testing.T) {
for _, outcome := range []string{"declined", "approval"} {
t.Run(outcome, func(t *testing.T) {
w := newPausedWorld(t, false)
w.stripe.outcome = outcome
var sent []sentCall
w.mock.OnCall = func(c stripetest.Call) { sent = append(sent, sentCall{c, time.Now()}) }
_ = w.switchTo(context.Background(), w.max.corePriceID)
checkDeadlines(t, outcome, sent)
})
}
}
func checkDeadlines(t *testing.T, row string, sent []sentCall) {
t.Helper()
find := func(pred func(sentCall) bool) (sentCall, int) {
i := slices.IndexFunc(sent, pred)
if i < 0 {
t.Fatalf("%s: a call the paused form makes was not sent: %v", row, sent)
}
return sent[i], i
}
kind := func(k string) func(sentCall) bool { return func(c sentCall) bool { return kindOf(c.Call) == k } }
list, _ := find(func(c sentCall) bool { return c.Method == "GET" && c.Path == "/v1/invoices" })
clear, _ := find(kind("clear"))
update, last := find(kind("update"))
checkDeadline(t, row, "the draft list", list, fulfillment.PausedSwitchCallTimeout)
checkDeadline(t, row, "the clear", clear, fulfillment.PausedSwitchCallTimeout)
checkDeadline(t, row, "the update", update, fulfillment.PausedSwitchChargeTimeout)
if row == "declined" {
void, i := find(kind("void"))
checkDeadline(t, row, "the void", void, fulfillment.PausedSwitchCallTimeout)
last = i
}
restore := sent[last+1:]
if len(restore) == 0 {
t.Fatalf("%s: the restore sent no call", row)
}
first := restore[0]
for _, c := range restore {
if c.Deadline.IsZero() || !c.Deadline.Equal(first.Deadline) || first.Deadline.Sub(first.at) > fulfillment.PausedSwitchRestoreTimeout+time.Second {
t.Errorf("%s: the restore's calls carry deadlines %v, want one within %v of the first", row, deadlines(restore), fulfillment.PausedSwitchRestoreTimeout)
return
}
}
}
func deadlines(calls []sentCall) []time.Time {
var out []time.Time
for _, c := range calls {
out = append(out, c.Deadline)
}
return out
}
// An abandoned upgrade's invoice is voided on the still-paused subscription
// before the clear, under a deadline of its own.
func TestSwitchAPausedSubscription_AnAbandonedUpgradeIsVoidedFirst(t *testing.T) {
w := newPausedWorld(t, false)
abandonedSwitch(w.mock)
var sent []sentCall
w.mock.OnCall = func(c stripetest.Call) { sent = append(sent, sentCall{c, time.Now()}) }
mark := len(w.mock.Calls)
err := w.switchTo(context.Background(), w.max.corePriceID)
if got := writesFrom(w.mock, mark); err != nil || !slices.Equal(got, []string{"void", "clear", "update"}) {
t.Errorf("with an abandoned upgrade, writes = %v (error %v), want the void, the clear and the update", lines(w.mock, mark), err)
}
if i := slices.IndexFunc(sent, func(c sentCall) bool { return kindOf(c.Call) == "void" }); i >= 0 {
checkDeadline(t, "an abandoned upgrade", "the void", sent[i], fulfillment.PausedSwitchCallTimeout)
}
}
// A restore whose set fails is logged and does not change the member's
// answer.
func TestSwitchAPausedSubscription_AFailedRestoreIsLoggedAndTheDeclineIsAnswered(t *testing.T) {
w := newPausedWorld(t, false)
w.stripe.outcome = "declined"
w.stripe.setHook = func(stripetest.Call) error { return stripeError(500, "boom") }
var logs strings.Builder
logger := slog.New(slog.NewTextHandler(&logs, nil))
var err error
var panicked any
func() {
defer func() { panicked = recover() }()
err = w.switchLogged(context.Background(), w.max.corePriceID, logger)
}()
if !errors.Is(err, fulfillment.ErrUpgradeDeclined) || panicked != nil {
t.Errorf("a failing restore changed the member's answer: error %v, panic %v, want the decline", err, panicked)
}
parts := []string{"switch: the collection pause could not be restored; the outbox entry restores it", "stripe_subscription_id=" + w.fx.stripeSubID, "error="}
if !slices.ContainsFunc(strings.Split(logs.String(), "\n"), func(line string) bool {
return !slices.ContainsFunc(parts, func(p string) bool { return !strings.Contains(line, p) })
}) {
t.Errorf("a failed restore logged no line holding all of %q:\n%s", parts, logs.String())
}
}
// A marked grant the switch replaced delivers again when S ends.
func TestSwitchAPausedSubscription_AMarkedGrantResumesWhenItEnds(t *testing.T) {
w := newPausedWorld(t, true)
if err := w.switchTo(context.Background(), w.max.corePriceID); err != nil {
t.Fatalf("switch: %v", err)
}
if held := ladderHolder(t, w.database, w.fx); held != w.max.productID {
t.Fatalf("precondition: after the switch, the ladder holds %v, want Max", held)
}
w.mock.Sub = withStatus(w.mock.Sub, stripe.SubscriptionStatusCanceled)
reconcile(t, w.database, w.fx.stripeSubID, "stripe:subscription.deleted")
if held := ladderHolder(t, w.database, w.fx); held != w.pro.productID {
t.Errorf("after S ended, the marked grant holds %v, want its product", held)
}
}
// PreviewSwitch of a subscription whose row records any pause is the
// unavailable preview with no Stripe call.
func TestPreviewSwitchOfAPausedSubscription(t *testing.T) {
rows := []struct {
name string
prepare func(w *pausedWorld)
unavailable bool
}{
{"the console's pause", func(*pausedWorld) {}, true},
{"a foreign pause", func(w *pausedWorld) {
delete(w.mock.Sub.Metadata, "member_console_collection")
reconcile(t, w.database, w.fx.stripeSubID, "test:foreign")
}, true},
{"no pause recorded", func(w *pausedWorld) {
w.mock.Sub.PauseCollection = nil
delete(w.mock.Sub.Metadata, "member_console_collection")
reconcile(t, w.database, w.fx.stripeSubID, "test:unpaused")
w.mock.Invoice = &stripe.Invoice{Currency: stripe.CurrencyUSD, AmountDue: 900}
}, false},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
w := newPausedWorld(t, false)
row.prepare(w)
mark := len(w.mock.Calls)
p := fulfillment.PreviewSwitch(context.Background(), w.database, discardLogger(), w.fx.orgID, w.fx.ladderID, w.max.corePriceID)
calls := len(w.mock.Calls) - mark
ok := p.ProrationDate.IsZero() == false && !p.Available && p.NetAmount == 0 && calls == 0
want := "the unavailable preview with a proration moment and no call"
if !row.unavailable {
ok, want = p.Available && p.NetAmount == 900, "the mock's figure"
}
if !ok {
t.Errorf("%s: preview = %+v with %d Stripe calls, want %s", row.name, p, calls, want)
}
})
}
}
// abandonedSwitch gives mock.Sub the pending update and open invoice a paused
// switch left awaiting approval.
func abandonedSwitch(mock *stripetest.MockBackend) {
mock.Sub.PendingUpdate = &stripe.SubscriptionPendingUpdate{ExpiresAt: time.Now().Add(20 * time.Hour).Unix()}
mock.Sub.LatestInvoice = &stripe.Invoice{ID: abandonedInvoice, Status: stripe.InvoiceStatusOpen}
}
func cancelPlanAtPeriodEnd(w *pausedWorld) error {
return fulfillment.CancelSubscription(context.Background(), w.database, discardLogger(), stripetest.KeyMode(), w.fx.orgID, w.fx.ladderID, fulfillment.CancelAtPeriodEnd, "member:cancel")
}
func expandsLatestInvoice(c stripetest.Call) bool {
for k, v := range c.Form {
if strings.HasPrefix(k, "expand") && slices.Contains(v, "latest_invoice") {
return true
}
}
return false
}
// Cancel plan on a subscription that pays without holding voids an abandoned
// switch's invoice first.
func TestCancelAPausedSubscriptionVoidsAnAbandonedSwitch(t *testing.T) {
t.Run("the cancellation", func(t *testing.T) {
w := newPausedWorld(t, false)
abandonedSwitch(w.mock)
mark := len(w.mock.Calls)
err := cancelPlanAtPeriodEnd(w)
fetch := slices.IndexFunc(w.mock.Calls[mark:], func(c stripetest.Call) bool {
return c.Method == "GET" && c.Path == "/v1/subscriptions/"+w.fx.stripeSubID && expandsLatestInvoice(c)
})
void := slices.IndexFunc(w.mock.Calls[mark:], func(c stripetest.Call) bool { return kindOf(c) == "void" && strings.Contains(c.Path, abandonedInvoice) })
cancel := slices.IndexFunc(w.mock.Calls[mark:], func(c stripetest.Call) bool { return c.Form.Get("cancel_at_period_end") == "true" })
pauseTouched := slices.ContainsFunc(w.mock.Calls[mark:], func(c stripetest.Call) bool { return c.Method == "POST" && touchesPause(c) })
if err != nil || fetch < 0 || void <= fetch || cancel <= void || pauseTouched {
t.Errorf("cancel writes = %v, want the void of the abandoned switch's invoice and then cancel_at_period_end", lines(w.mock, mark))
}
})
t.Run("the void refused", func(t *testing.T) {
w := newPausedWorld(t, false)
abandonedSwitch(w.mock)
w.mock.PathErrs = map[string]error{"/v1/invoices/" + abandonedInvoice + "/void": stripeError(500, "boom")}
mark := len(w.mock.Calls)
err := cancelPlanAtPeriodEnd(w)
if err == nil ||
slices.ContainsFunc(w.mock.Calls[mark:], func(c stripetest.Call) bool { return c.Form.Get("cancel_at_period_end") != "" }) {
t.Errorf("a refused void = %v with writes %v, want an error and no cancellation", err, lines(w.mock, mark))
}
})
t.Run("a cancellation a commitment defers", func(t *testing.T) {
w := newPausedWorld(t, false)
abandonedSwitch(w.mock)
core := coreSubID(t, w.database, w.fx.stripeSubID)
if _, err := w.database.Exec(`UPDATE core.subscriptions SET commitment_end = NOW() + interval '90 days', early_termination_policy = 'block' WHERE subscription_id = $1`, core); err != nil {
t.Fatalf("commitment: %v", err)
}
mark := len(w.mock.Calls)
err := cancelPlanAtPeriodEnd(w)
void := slices.ContainsFunc(w.mock.Calls[mark:], func(c stripetest.Call) bool { return kindOf(c) == "void" })
flag := slices.ContainsFunc(w.mock.Calls[mark:], func(c stripetest.Call) bool { return c.Form.Has("cancel_at_period_end") })
scheduled := rowCount(t, w.database, core, "cancellation", "term_boundary", "scheduled")
if err != nil || !void || flag || scheduled != 1 {
t.Errorf("a cancellation a commitment defers: writes = %v and scheduled %d, want the void, no cancel_at_period_end and the cancellation scheduled", lines(w.mock, mark), scheduled)
}
})
}
// A subscription that holds its ladder keeps today's cancel: no expansion, no
// void.
func TestCancelAHoldingSubscriptionVoidsNothing(t *testing.T) {
w := newHeldWorld(t)
w.mock.Sub.PauseCollection = nil
abandonedSwitch(w.mock)
mark := len(w.mock.Calls)
err := cancelPlanAtPeriodEnd(w)
expanded := slices.ContainsFunc(w.mock.Calls[mark:], expandsLatestInvoice)
if err != nil || expanded || slices.Contains(writesFrom(w.mock, mark), "void") {
t.Errorf("a holding subscription's cancel sent %v, want today's fetch and no void", lines(w.mock, mark))
}
}
+122 -43
View File
@@ -91,11 +91,19 @@ var ErrCommitmentFee = errors.New("early-termination fee policy is not yet suppo
var ErrUpgradeDeclined = errors.New("switch: the card on file declined the upgrade's invoice")
// ErrStalePreview is returned when an upgrade arrives without a usable
// proration moment: missing, in the future, or older than
// PreviewValidFor. Nothing was charged; the caller shows the preview again
// with fresh figures (plan-switch-timing D6).
// proration moment: missing, in the future, older than PreviewValidFor, or
// from before the current period, which the paused switch answers in its first
// step. Nothing was charged; the caller shows the preview again with fresh
// figures (plan-switch-timing D6).
var ErrStalePreview = errors.New("switch: the preview is missing or too old to charge")
// ErrRenewalFinalizing is returned when the paused switch finds a paused
// renewal's draft about to finalize (or past its time and still a draft).
// Nothing was written. A failed list returns its own error, which the
// handler's default answers with the same sentence: "We couldn't complete that
// change. Try again in a moment."
var ErrRenewalFinalizing = errors.New("switch: a paused renewal is about to finalize")
// PreviewValidFor bounds how old a preview's proration moment may be when
// the member confirms it. An older preview is shown again rather than
// charged, so a figure the member saw an hour ago is never charged against
@@ -287,6 +295,14 @@ func SwitchPlan(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode st
// 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
// refuses the update, so the paused form clears the pause first, restores it
// on every outcome but an applied update, and refuses while a paused renewal
// is about to finalize (pausedUpgrade, paused_switch.go). Otherwise, with no
// pause, a stale marker or a pause made outside the console, it is the one
// call below.
func upgradeNow(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, billingQ *billing.Queries, ls ladderSubscription, targetStripePriceID string, prorationDate time.Time, reason string) error {
now := time.Now()
if prorationDate.IsZero() || prorationDate.After(now) || now.Sub(prorationDate) > PreviewValidFor {
@@ -297,16 +313,39 @@ func upgradeNow(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode st
if err != nil {
return fmt.Errorf("switch: read subscription before upgrade: %w", err)
}
// An earlier attempt the member abandoned leaves a pending update and an
// open invoice that stays payable; void it so paying it later cannot
// apply a move the member has since replaced (Stripe accepts the new
// pending update either way; sandbox Q4).
if current.PendingUpdate != nil && current.LatestInvoice != nil && current.LatestInvoice.Status == stripe.InvoiceStatusOpen {
if _, err := invoice.VoidInvoice(current.LatestInvoice.ID, nil); err != nil {
return fmt.Errorf("switch: void an abandoned upgrade's invoice: %w", err)
}
if consolePaused(current) {
return pausedUpgrade(ctx, db, logger, keyMode, billingQ, ls, current, targetStripePriceID, prorationDate, reason)
}
if err := voidAbandonedUpgrade(current, nil); err != nil {
return fmt.Errorf("switch: void an abandoned upgrade's invoice: %w", err)
}
updated, err := subscription.Update(ls.stripeSubID, upgradeParams(ls, current, targetStripePriceID, prorationDate))
if err != nil {
return fmt.Errorf("switch: stripe upgrade subscription item: %w", err)
}
if err := upgradeAnswer(logger, ls, updated, nil); err != nil {
return err
}
return appliedUpgrade(ctx, db, logger, keyMode, billingQ, ls, current, updated, reason)
}
// voidAbandonedUpgrade voids the invoice an earlier attempt the member
// abandoned left open. Such an attempt leaves a pending update and an open
// invoice that stays payable; voiding it means paying it later cannot apply a
// move the member has since replaced (Stripe accepts the new pending update
// either way; sandbox Q4). current is fetched with latest_invoice expanded.
// params carries the call's context, nil for none. The error is Stripe's, for
// the caller to name.
func voidAbandonedUpgrade(current *stripe.Subscription, params *stripe.InvoiceVoidInvoiceParams) error {
if current.PendingUpdate != nil && current.LatestInvoice != nil && current.LatestInvoice.Status == stripe.InvoiceStatusOpen {
_, err := invoice.VoidInvoice(current.LatestInvoice.ID, params)
return err
}
return nil
}
// upgradeParams is the upgrade's one Stripe update, whichever form sends it.
func upgradeParams(ls ladderSubscription, current *stripe.Subscription, targetStripePriceID string, prorationDate time.Time) *stripe.SubscriptionParams {
params := &stripe.SubscriptionParams{
Items: []*stripe.SubscriptionItemsParams{{
ID: stripe.String(ls.stripeItemID),
@@ -326,35 +365,44 @@ func upgradeNow(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode st
params.CancelAtPeriodEnd = stripe.Bool(false)
}
params.AddExpand("latest_invoice.payment_intent")
updated, err := subscription.Update(ls.stripeSubID, params)
if err != nil {
return fmt.Errorf("switch: stripe upgrade subscription item: %w", err)
}
return params
}
if updated.PendingUpdate != nil {
inv := updated.LatestInvoice
if inv != nil && inv.PaymentIntent != nil && inv.PaymentIntent.Status == stripe.PaymentIntentStatusRequiresAction {
return &UpgradeNeedsApprovalError{
HostedInvoiceURL: inv.HostedInvoiceURL,
AmountDue: inv.AmountDue,
Currency: string(inv.Currency),
}
}
// Declined, or any other failure to pay: void the invoice so a later
// retry of it can never apply the move (sandbox Q3: voiding clears
// the pending update at once).
if inv != nil {
if _, verr := invoice.VoidInvoice(inv.ID, nil); verr != nil {
logger.Warn("switch: void a declined upgrade's invoice",
slog.String("stripe_subscription_id", ls.stripeSubID), slog.Any("error", verr))
}
}
return ErrUpgradeDeclined
// upgradeAnswer reads the update's answer: nil when it applied (no pending
// update), UpgradeNeedsApprovalError when the payment needs the cardholder's
// approval, otherwise the declined invoice is voided and ErrUpgradeDeclined
// returned. voidParams carries the void's context, nil for none.
func upgradeAnswer(logger *slog.Logger, ls ladderSubscription, updated *stripe.Subscription, voidParams *stripe.InvoiceVoidInvoiceParams) error {
if updated.PendingUpdate == nil {
return nil
}
inv := updated.LatestInvoice
if inv != nil && inv.PaymentIntent != nil && inv.PaymentIntent.Status == stripe.PaymentIntentStatusRequiresAction {
return &UpgradeNeedsApprovalError{
HostedInvoiceURL: inv.HostedInvoiceURL,
AmountDue: inv.AmountDue,
Currency: string(inv.Currency),
}
}
// Declined, or any other failure to pay: void the invoice so a later
// retry of it can never apply the move (sandbox Q3: voiding clears
// the pending update at once).
if inv != nil {
if _, verr := invoice.VoidInvoice(inv.ID, voidParams); verr != nil {
logger.Warn("switch: void a declined upgrade's invoice",
slog.String("stripe_subscription_id", ls.stripeSubID), slog.Any("error", verr))
}
}
return ErrUpgradeDeclined
}
// Applied. A downgrade scheduled earlier would move the member back down
// at renewal (sandbox Q1), so its schedule goes; a failure here is left
// to reconcile's rule 4, which releases it on the next event or sweep.
// appliedUpgrade runs the steps after an update that applied. A downgrade
// scheduled earlier would move the member back down at renewal (sandbox Q1),
// so its schedule goes; a failure here is left to reconcile's rule 4, which
// releases it on the next event or sweep. Then reconcile updates the core
// item in place and supersedes the ladder attachment to the new tier via the
// transition primitive.
func appliedUpgrade(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, billingQ *billing.Queries, ls ladderSubscription, current, updated *stripe.Subscription, reason string) error {
if updated.Schedule != nil || current.Schedule != nil {
id := current.Schedule
if updated.Schedule != nil {
@@ -368,8 +416,6 @@ func upgradeNow(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode st
if err := billingQ.SupersedeActiveScheduledChanges(ctx, ls.coreSubID); err != nil {
return fmt.Errorf("switch: supersede prior scheduled changes: %w", err)
}
// Converge: reconcile updates the core item in place and supersedes the
// ladder attachment to the new tier via the transition primitive.
return ReconcileSubscription(ctx, db, logger, keyMode, ls.stripeSubID, reason)
}
@@ -579,6 +625,15 @@ func PreviewSwitch(ctx context.Context, db *sql.DB, logger *slog.Logger, orgID,
moment := time.Now().Truncate(time.Second)
unavailable := SwitchPreview{ProrationDate: moment, Currency: targetPrice.Currency}
// Stripe answers no proration preview for a paused subscription, a
// foreign pause included, so a subscription whose row records any pause
// gets the unavailable preview with no Stripe call. The row is read
// because PreviewSwitch fetches no subscription. The unavailable preview
// keeps its ProrationDate, so the switch never answers ErrStalePreview for
// a missing moment.
if ls.sub.PauseCollectionBehavior.Valid {
return unavailable
}
priceMapping, err := stripeQ.GetPriceMappingByPriceID(ctx, targetPriceID)
if err != nil || !priceMapping.StripePriceID.Valid {
return unavailable
@@ -613,7 +668,10 @@ func PreviewSwitch(ctx context.Context, db *sql.DB, logger *slog.Logger, orgID,
// after it keeps the provisions suspended. It refuses a paused subscription
// with ErrSubscriptionPaused and, on a suspended subscription, CancelImmediate
// with ErrImmediateCancelSuspended, each before it releases a schedule or
// makes a Stripe call.
// makes a Stripe call. For a subscription that pays without holding its
// ladder it first voids the open invoice of a switch the member left awaiting
// approval, so paying that invoice later cannot apply the switch with the
// cancellation set (D11).
func CancelSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, keyMode string, orgID, ladderID string, timing CancelTiming, reason string) error {
if logger == nil {
logger = slog.Default()
@@ -636,6 +694,25 @@ func CancelSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, ke
if derr != nil {
return derr // fee policy -> refuse
}
// A subscription that pays without holding its ladder may carry a switch
// the member left awaiting approval: a pending update and an open invoice.
// Stripe keeps the pending update through cancel_at_period_end, and
// paying the invoice later would apply the switch on a plan the member
// chose to end, so the invoice is voided before either timing's call and
// before a commitment defers the cancellation to its boundary, but not on
// a cancellation the fee policy refuses. A subscription that holds its
// ladder keeps today's form (D11).
var current *stripe.Subscription
if !ls.holdsLadder {
current, err = subscription.Get(ls.stripeSubID, &stripe.SubscriptionParams{Expand: []*string{stripe.String("latest_invoice")}})
if err != nil {
return fmt.Errorf("cancel: read subscription: %w", err)
}
if err := voidAbandonedUpgrade(current, nil); err != nil {
return fmt.Errorf("cancel: void an abandoned switch's invoice: %w", err)
}
}
if deferToBoundary {
return scheduleCancellation(ctx, billingQ, ls.coreSubID, boundary, "term_boundary")
}
@@ -644,9 +721,11 @@ func CancelSubscription(ctx context.Context, db *sql.DB, logger *slog.Logger, ke
// behaviour, and Stripe refuses cancel_at_period_end while one is
// attached (plan-switch-timing sandbox Q2): the cancellation replaces
// the downgrade, so the schedule goes first.
current, err := subscription.Get(ls.stripeSubID, nil)
if err != nil {
return fmt.Errorf("cancel: read subscription: %w", err)
if current == nil {
current, err = subscription.Get(ls.stripeSubID, nil)
if err != nil {
return fmt.Errorf("cancel: read subscription: %w", err)
}
}
if current.Schedule != nil {
if _, err := subscriptionschedule.Release(current.Schedule.ID, nil); err != nil {
@@ -15,6 +15,7 @@ import (
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/fulfillment"
"git.coopcloud.tech/wiki-cafe/member-console/internal/stripetest"
stripe "github.com/stripe/stripe-go/v81"
@@ -25,23 +26,28 @@ import (
// the ladder without holding it. The mock behaves like Stripe for the calls
// the moves make.
func payingUnderGrant(t *testing.T) (*sql.DB, fixture, *stripetest.MockBackend) {
t.Helper()
database, fx, mock, _, _ := payingUnderGrantOf(t, false)
return database, fx, mock
}
// payingUnderGrantOf is payingUnderGrant with the grant's mark to resume
// chosen, returning the second tier and the grant as well.
func payingUnderGrantOf(t *testing.T, marked bool) (*sql.DB, fixture, *stripetest.MockBackend, tierRef, entitlements.Grant) {
t.Helper()
database, fx, mock := subscribed(t)
stripeLike(mock)
pro, _, _ := secondTier(t, database, fx)
g := issueGrant(t, database, fx, pro, false)
var pro tierRef
pro.productID, pro.corePriceID, pro.stripePriceID = secondTier(t, database, fx)
g := issueGrant(t, database, fx, pro.productID, marked)
if !grantDelivers(t, database, g.GrantID) {
t.Fatalf("precondition: the grant does not deliver over the subscription")
}
// The console's pause, as the grant puts it on the subscription, mirrored
// into the row.
mock.Sub.PauseCollection = &stripe.SubscriptionPauseCollection{Behavior: stripe.SubscriptionPauseCollectionBehaviorVoid}
if mock.Sub.Metadata == nil {
mock.Sub.Metadata = map[string]string{}
}
mock.Sub.Metadata["member_console_collection"] = "paused_for_grant"
pauseWithMarker(mock.Sub)
reconcile(t, database, fx.stripeSubID, "test:paused")
return database, fx, mock
return database, fx, mock, pro, g
}
// updates are the calls that changed the Stripe subscription, from mark on.
+24 -3
View File
@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"time"
)
// Enqueue inserts a pending core.outbox row — the transactional dispatch
@@ -17,9 +18,9 @@ import (
// PollIntegrationOutbox) executes the action against the external provider
// later.
//
// This is the only supported write path into core.outbox; core code must
// not INSERT into it directly (see Decision 4's "concession to
// coherence"). It replaces the raw `INSERT INTO core.outbox` statements
// Enqueue and EnqueueAt are the only supported write paths into
// core.outbox; core code must not INSERT into it directly (see Decision 4's
// "concession to coherence"). Enqueue replaces the raw `INSERT INTO core.outbox` statements
// formerly hand-written in internal/server/operator_billing.go and
// internal/integrations/stripe/store/ensure_customer.go.
//
@@ -52,3 +53,23 @@ func Enqueue(ctx context.Context, db DBTX, providerKey, actionType string, paylo
}
return nil
}
// EnqueueAt is Enqueue for an entry that is not due yet: it inserts the row
// with next_attempt_at = notBefore, and the relay takes the row only from that
// time (PollPendingOutboxEntries). Enqueue, unchanged, leaves the column's
// default, NOW(), so its entry is due at once. The same rules apply as for
// Enqueue: pass a struct or map as payload, and a *sql.Tx to keep the insert
// atomic with the caller's other writes.
func EnqueueAt(ctx context.Context, db DBTX, providerKey, actionType string, payload any, notBefore time.Time) error {
payloadJSON, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal outbox payload for %s/%s: %w", providerKey, actionType, err)
}
if _, err := db.ExecContext(ctx,
`INSERT INTO core.outbox (provider, action_type, payload, next_attempt_at) VALUES ($1, $2, $3, $4)`,
providerKey, actionType, payloadJSON, notBefore,
); err != nil {
return fmt.Errorf("enqueue %s/%s outbox entry due at %s: %w", providerKey, actionType, notBefore.Format(time.RFC3339), err)
}
return nil
}
@@ -198,12 +198,17 @@ func NewOutboxActivities(db *sql.DB, logger *slog.Logger) *OutboxActivities {
return &OutboxActivities{DB: db, Logger: logger}
}
// PollPendingOutboxEntries selects the entries no workflow has taken yet.
// PollPendingOutboxEntries selects the entries no workflow has taken yet and
// that are due. An entry written with a later next_attempt_at
// (integration.EnqueueAt) waits for that time; every other writer sets NOW(),
// the column's default and what the catalog sync's requeue writes, so every
// other entry is polled as before. The boot sweep takes only failed and
// processing rows and is not affected.
func (a *OutboxActivities) PollPendingOutboxEntries(ctx context.Context, batchSize int) ([]OutboxEntry, error) {
rows, err := a.DB.QueryContext(ctx,
`SELECT id, provider, action_type, payload, status, attempts
FROM core.outbox
WHERE status = 'pending'
WHERE status = 'pending' AND next_attempt_at <= NOW()
ORDER BY created_at ASC
LIMIT $1`,
batchSize,
@@ -9,9 +9,12 @@ import (
"io"
"log/slog"
"testing"
"time"
"github.com/google/uuid"
"go.temporal.io/sdk/testsuite"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
)
@@ -140,3 +143,58 @@ func TestOutboxPollAndSweepSelectDisjointStates(t *testing.T) {
}
}
}
// An entry written with a later next_attempt_at (integration.EnqueueAt) waits
// for it; an entry written through integration.Enqueue is due at once.
func TestOutboxPollSkipsAnEntryNotYetDue(t *testing.T) {
db := dbtest.Open(t)
ctx := context.Background()
acts := NewOutboxActivities(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
marker := uuid.New().String()
payload := map[string]string{"marker": marker}
notDue := time.Now().Add(time.Hour)
if err := integration.EnqueueAt(ctx, db, "stripe", "create_stripe_product", payload, notDue); err != nil {
t.Fatalf("enqueue an entry not due: %v", err)
}
if err := integration.Enqueue(ctx, db, "stripe", "create_stripe_product", payload); err != nil {
t.Fatalf("enqueue an entry due now: %v", err)
}
t.Cleanup(func() {
_, _ = db.ExecContext(ctx, `DELETE FROM core.outbox WHERE payload->>'marker' = $1`, marker)
})
ids := map[bool]int64{}
rows, err := db.QueryContext(ctx, `SELECT id, next_attempt_at > NOW() FROM core.outbox WHERE payload->>'marker' = $1`, marker)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var id int64
var later bool
if err := rows.Scan(&id, &later); err != nil {
t.Fatal(err)
}
ids[later] = id
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if len(ids) != 2 {
t.Fatalf("setup: rows by due time %v, want one of each", ids)
}
polled, err := acts.PollPendingOutboxEntries(ctx, 1000)
if err != nil {
t.Fatal(err)
}
seen := map[int64]bool{}
for _, e := range polled {
seen[e.ID] = true
}
if seen[ids[true]] {
t.Errorf("the poll took an entry not due until %v", notDue)
}
if !seen[ids[false]] {
t.Error("the poll did not take the entry due now")
}
}
+55 -6
View File
@@ -74,6 +74,10 @@ type Paying struct {
MoveScheduled bool
// OtherLadders is true when its product is a tier of another ladder too.
OtherLadders bool
// Currency and Interval are its item's price's currency and recurring
// interval.
Currency string
Interval string
}
// Target is the product a move would land on and the price it would use.
@@ -82,7 +86,9 @@ type Target struct {
// Listed is true when the product clears the member gate: published,
// active and public.
Listed bool
Price Price
// OtherLadders is true when the product is a tier of another ladder too.
OtherLadders bool
Price Price
}
// Price is the target price.
@@ -95,6 +101,10 @@ type Price struct {
// Purchasable is true when the price's Stripe mapping carries an id the
// key mode can reach.
Purchasable bool
// Currency and Interval are the price's currency and recurring interval;
// Interval is "" for a one-time price.
Currency string
Interval string
}
// Kind is the mechanism a move uses.
@@ -139,6 +149,7 @@ const (
Paused Reason = "the position is paused"
PaidHere Reason = "a subscription pays for the target on this ladder without holding it"
PaysOnLadder Reason = "a subscription pays on the ladder without holding it"
OtherCurrency Reason = "the target's price is in another currency than the paying subscription's"
)
// Relation is the move relative to the current rung on the ladder. Offer sets
@@ -161,6 +172,14 @@ type Move struct {
Relation Relation
// Held marks the held rung.
Held bool
// ReplacesGrant is true on a switch of a subscription that pays under a
// grant (rule 3a.2): the subscription takes the ladder and the grant is
// replaced.
ReplacesGrant 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 bool
}
// Allows reports whether the move is an enabled move of kind k.
@@ -199,9 +218,22 @@ func (p Position) rankOf(productID string) (int32, bool) {
// a second subscription:
//
// - 3a.1 The target is the paying product: none, PaidHere.
// - 3a.2 A grant holds, the paying subscription is not suspended, and the
// target ranks at or above the current rung and above the paying product:
// none, PaysOnLadder.
// - 3a.2 A grant holds, the paying subscription is not suspended, its
// product and the target are tiers of no other ladder, and the target
// ranks at or above the current rung and above the paying product: Switch
// through the price rule, relation Upgrade, with ReplacesGrant. A paying
// product that is a tier of another ladder too gets no switch, because the
// switch moves the subscription off its product's other ladders, which
// #271 decides (the rule Resume plan follows). A target that is a tier of
// another ladder too gets none either, because its conferral would
// supersede whatever holds its other ladders, a grant included
// (invariant 7), which the confirmation cannot name. Both fall to 3a.3
// and 3a.4.
// When the move is enabled, a price in another currency than the paying
// subscription's answers none, OtherCurrency, because Stripe refuses a
// change of a subscription's currency, and a price of another recurring
// interval carries NewPeriod. A disabled move keeps the price rule's
// reason, is never answered OtherCurrency, and carries no NewPeriod.
// - 3a.3 The target is the current product: none, Current.
// - 3a.4 Any other target: none, PaysOnLadder.
//
@@ -286,14 +318,31 @@ func (a ask) payingRules() (Move, bool) {
switch {
case a.ProductID == p.ProductID:
return Move{Reason: PaidHere}, true
case p.GrantHolds && !p.Suspended && a.rank >= a.current && a.rank > p.Rank:
return Move{Reason: PaysOnLadder}, true
case p.GrantHolds && !p.Suspended && !p.OtherLadders && !a.OtherLadders && a.rank >= a.current && a.rank > p.Rank:
return a.paidSwitch(), true
case a.ProductID == a.Current:
return Move{Reason: Current}, true
}
return Move{Reason: PaysOnLadder}, true
}
// paidSwitch is rule 3a.2's answer. Offer sets the current product's relation
// to Downgrade (the granted tier is the current one), so the rule sets Upgrade
// itself.
func (a ask) paidSwitch() Move {
m := priceRule(Switch, a.Target)
m.Relation = Upgrade
m.ReplacesGrant = m.Kind == Switch
if !m.Enabled {
return m
}
if a.Price.Currency != a.Paying.Currency {
return Move{Reason: OtherCurrency}
}
m.NewPeriod = a.Price.Interval != a.Paying.Interval
return m
}
func (a ask) isHeld() bool {
return a.Held != nil && a.Held.ProductID == a.ProductID
}
+57 -7
View File
@@ -25,7 +25,7 @@ func tierOf(id string) *Tier {
}
// buyable is a default, recurring, reachable price.
var buyable = Price{ID: "price", Default: true, Recurring: true, Purchasable: true}
var buyable = Price{ID: "price", Default: true, Recurring: true, Purchasable: true, Currency: "usd", Interval: "month"}
// holds is a position on the ladder with the current rung held.
func holds(current string, subscribed bool) Position {
@@ -210,10 +210,29 @@ func TestOffer(t *testing.T) {
target: target("standard"), reason: PaysOnLadder},
{name: "a tier below the grant with a paying subscription", pos: payingOn(holds("plus", false), "basic", true),
target: target("standard"), reason: PaysOnLadder},
{name: "a tier above the grant with a paying subscription", pos: payingOn(holds("plus", false), "basic", true),
target: target("pro"), reason: PaysOnLadder},
{name: "the granted tier over a lower paid tier", pos: payingOn(holds("plus", false), "basic", true),
target: target("plus"), reason: PaysOnLadder},
{name: "a tier above a grant that displaced a subscription", pos: payingOn(holds("plus", false), "basic", true),
target: target("pro"), kind: Switch, enabled: true, relation: Upgrade, replacesGrant: true},
{name: "the granted tier over a paying subscription", pos: payingOn(holds("plus", false), "basic", true),
target: target("plus"), kind: Switch, enabled: true, relation: Upgrade, replacesGrant: true},
{name: "a tier above the paid tier over a lower grant", pos: payingOn(holds("basic", false), "standard", true),
target: target("pro"), kind: Switch, enabled: true, relation: Upgrade, replacesGrant: true},
{name: "the granted tier below the paid tier", pos: payingOn(holds("basic", false), "plus", true),
target: target("basic"), reason: Current},
{name: "a tier of another interval above a grant that displaced a subscription",
pos: payingOn(holds("plus", false), "basic", true), target: pricedTarget("pro", "usd", "year", true),
kind: Switch, enabled: true, relation: Upgrade, replacesGrant: true, newPeriod: true},
{name: "a tier priced in another currency above a grant that displaced a subscription",
pos: payingOn(holds("plus", false), "basic", true), target: pricedTarget("pro", "eur", "month", true),
reason: OtherCurrency},
{name: "a one-time tier priced in another currency above the grant",
pos: payingOn(holds("plus", false), "basic", true), target: pricedTarget("pro", "eur", "", false),
kind: Switch, reason: OneTime, relation: Upgrade, replacesGrant: true},
{name: "a tier above a grant over a bundle's subscription",
pos: payingBundle(payingOn(holds("plus", false), "basic", true)), target: target("pro"), reason: PaysOnLadder},
{name: "a bundle tier above a grant that displaced a subscription",
pos: payingOn(holds("plus", false), "basic", true), target: bundleTarget("pro"), reason: PaysOnLadder},
{name: "the granted tier over a bundle's subscription",
pos: payingBundle(payingOn(holds("plus", false), "basic", true)), target: target("plus"), reason: Current},
{name: "a past-due paying subscription", pos: payingSuspended(payingOn(holds("plus", false), "basic", true), false),
target: target("pro"), reason: PaysOnLadder},
{name: "a paused paying subscription", pos: payingSuspended(payingOn(holds("plus", false), "basic", true), true),
@@ -243,8 +262,34 @@ func TestOffer(t *testing.T) {
// payingOn adds a subscription that pays for the product on the ladder
// without holding its rung to a position; grant says a grant holds the rung.
// It pays the buyable price's currency and interval.
func payingOn(p Position, product string, grant bool) Position {
p.Paying = &Paying{ProductID: product, Rank: tierOf(product).Rank, GrantHolds: grant}
p.Paying = &Paying{ProductID: product, Rank: tierOf(product).Rank, GrantHolds: grant,
Currency: buyable.Currency, Interval: buyable.Interval}
return p
}
// pricedTarget is a listed product whose default price is in the currency and
// interval given; recurring false makes it a one-time price.
func pricedTarget(id, currency, interval string, recurring bool) Target {
return Target{ProductID: id, Listed: true, Price: Price{
ID: "price", Default: true, Recurring: recurring, Purchasable: true, Currency: currency, Interval: interval,
}}
}
// bundleTarget is a listed product that is a tier of another ladder too.
func bundleTarget(id string) Target {
t := target(id)
t.OtherLadders = true
return t
}
// payingBundle makes the paying subscription's product a tier of another
// ladder too.
func payingBundle(p Position) Position {
paying := *p.Paying
paying.OtherLadders = true
p.Paying = &paying
return p
}
@@ -287,6 +332,10 @@ type offerCase struct {
reason Reason
relation Relation
held bool
// replacesGrant and newPeriod are the switch's two facts for the
// confirmation (rule 3a.2).
replacesGrant bool
newPeriod bool
}
// checkOffer compares the decision with the row's move, printing the row's
@@ -294,7 +343,8 @@ type offerCase struct {
func checkOffer(t *testing.T, tc offerCase) {
t.Helper()
got := Offer(tc.pos, tc.target)
want := Move{Kind: tc.kind, Enabled: tc.enabled, Reason: tc.reason, Relation: tc.relation, Held: tc.held}
want := Move{Kind: tc.kind, Enabled: tc.enabled, Reason: tc.reason, Relation: tc.relation, Held: tc.held,
ReplacesGrant: tc.replacesGrant, NewPeriod: tc.newPeriod}
if got != want {
t.Errorf("Offer(%s) = %+v, want %+v", tc.name, got, want)
}
@@ -36,6 +36,7 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/plans"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
@@ -704,7 +705,8 @@ func TestMemberSwitch_RefusesTheHeldRung(t *testing.T) {
if err != nil {
t.Fatalf("price of %s: %v", w.names[target], err)
}
return w.h.switchOffer(w.ctx, org, ladder, price)
_, err = w.h.switchOffer(w.ctx, org, ladder, price)
return err
}
for _, target := range []string{w.plus, w.basic} {
if err := offer(target); !errors.Is(err, errPlanNotOffered) {
@@ -933,7 +935,7 @@ func TestMemberSwitchPreview_CancellationAddsNoSentence(t *testing.T) {
t.Fatalf("schedule cancellation: %v", err)
}
rec := httptest.NewRecorder()
w.h.renderSwitchPreview(rec, w.ctx, org, ladder, billing.Price{PriceID: w.prices[plus], ProductID: plus}, http.StatusOK, "")
w.h.renderSwitchPreview(rec, w.ctx, org, ladder, billing.Price{PriceID: w.prices[plus], ProductID: plus}, plans.Move{}, http.StatusOK, "")
body := rec.Body.String()
if !strings.Contains(body, "Switch to "+w.n("Pv Plus")+"?") {
t.Fatalf("no preview rendered:\n%s", body)
+32 -13
View File
@@ -16,6 +16,7 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/fulfillment"
"git.coopcloud.tech/wiki-cafe/member-console/internal/plans"
)
// planMoveMessage builds the switch confirmation's prose (plan-switch-timing
@@ -23,13 +24,25 @@ import (
// what it costs. An upgrade states the amount charged now, which is the
// amount Stripe charges because the switch sends back the moment the preview
// prorated at (D6); a downgrade states the date the current tier ends.
// then is the target's recurring price ("$30.00 a month"). 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, p fulfillment.SwitchPreview) template.HTML {
// then is the target's recurring price ("$30.00 a month"). replacesGrant opens
// the second paragraph with "Your subscription replaces the grant." for a
// switch of a subscription that pays under a grant. 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.
// 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 bool, newPeriodCharge string, p fulfillment.SwitchPreview) template.HTML {
esc := template.HTMLEscapeString
msg := "<p class=\"mb-2\"><strong>Switch to " + esc(tierName) + "?</strong></p><p class=\"mb-0\">"
if replacesGrant {
msg += "Your subscription replaces the grant. "
}
switch {
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:
keep := "You keep your current plan"
if currentName != "" {
@@ -76,7 +89,7 @@ func (h *MemberProductsHandler) GetSwitchPreview(w http.ResponseWriter, r *http.
// with the same answer, and nothing about it is rendered
// (stripe-subscription-management, "Live proration preview before a
// switch"; offer-enforcement D8).
ladderID, price, err := h.switchOfferFor(r.Context(), session.OrgID, postedLadder, postedPrice)
ladderID, price, move, err := h.switchOfferFor(r.Context(), session.OrgID, postedLadder, postedPrice)
if err != nil {
h.renderPlansAfterMove(w, r.Context(), session.OrgID, err, "switch-preview")
return
@@ -94,7 +107,7 @@ func (h *MemberProductsHandler) GetSwitchPreview(w http.ResponseWriter, r *http.
return
}
h.renderSwitchPreview(w, r.Context(), session.OrgID, ladderID, price, http.StatusOK, "")
h.renderSwitchPreview(w, r.Context(), session.OrgID, ladderID, price, move, http.StatusOK, "")
}
// renderSwitchPreview renders the plans partial with the switch confirmation
@@ -103,14 +116,20 @@ func (h *MemberProductsHandler) GetSwitchPreview(w http.ResponseWriter, r *http.
// charge equals it; for a downgrade, the date the current tier ends. It
// serves the preview request, a switch posted with a stale preview (shown
// again with fresh figures), and a declined upgrade, whose reason formErr
// carries into the form-level slot at status 422. 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, status int, formErr string) {
// 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.
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 {
tierName = product.Name
}
preview := fulfillment.PreviewSwitch(ctx, h.Database, h.Logger, orgID, ladderID, target.PriceID)
newPeriodCharge := ""
if move.NewPeriod {
newPeriodCharge = formatMoney(int64(target.UnitAmount), target.Currency)
}
values := forms.NewValues()
values.Set("ladder_id", ladderID)
@@ -121,7 +140,7 @@ func (h *MemberProductsHandler) renderSwitchPreview(w http.ResponseWriter, ctx c
binding := forms.Binding{
Mode: forms.ModeRecord,
Values: values,
Message: planMoveMessage(tierName, h.currentTierName(ctx, orgID, ladderID), recurringPhrase(target), preview),
Message: planMoveMessage(tierName, h.currentTierName(ctx, orgID, ladderID), recurringPhrase(target), move.ReplacesGrant, newPeriodCharge, preview),
}
if formErr != "" {
errs := forms.NewErrors()
@@ -203,7 +222,7 @@ func (h *MemberProductsHandler) PostSwitch(w http.ResponseWriter, r *http.Reques
// verifies the price and the ladder but not the product's publication
// state, the default price or the held rung, and it is checked before
// anything is scheduled.
ladderID, target, err := h.switchOfferFor(r.Context(), session.OrgID, postedLadder, postedPrice)
ladderID, target, move, err := h.switchOfferFor(r.Context(), session.OrgID, postedLadder, postedPrice)
if err != nil {
h.renderPlansAfterMove(w, r.Context(), session.OrgID, err, "switch")
return
@@ -230,11 +249,11 @@ func (h *MemberProductsHandler) PostSwitch(w http.ResponseWriter, r *http.Reques
if current := h.currentTierName(r.Context(), session.OrgID, ladderID); current != "" {
msg = "Your card was declined; you're still on " + current + "."
}
h.renderSwitchPreview(w, r.Context(), session.OrgID, ladderID, target, http.StatusUnprocessableEntity, msg)
h.renderSwitchPreview(w, r.Context(), session.OrgID, ladderID, target, move, http.StatusUnprocessableEntity, msg)
default:
// Stale: nothing was charged; show the preview again with fresh
// figures for the member to confirm.
h.renderSwitchPreview(w, r.Context(), session.OrgID, ladderID, target, http.StatusOK, "")
h.renderSwitchPreview(w, r.Context(), session.OrgID, ladderID, target, move, http.StatusOK, "")
}
return
}
+21 -3
View File
@@ -451,10 +451,16 @@ func (h *MemberProductsHandler) buildTier(ctx context.Context, b *plansBuild, la
}
tier.EntitlementFeatures, tier.MetadataFeatures = h.buildPlanFeatures(ctx, product, b.labels)
// The current tier's answer is none, and draws nothing; it counts in the
// The current tier's answer is none and draws nothing, except under a
// grant that holds over a paying subscription, where it is the switch
// (rule 3a.2) and needs the tier's default price; it counts in the
// combination across the ladders a product is a tier of.
target := plans.Target{ProductID: t.ProductID, Listed: listed}
if isCurrent {
bundle, err := b.loader.isBundleTier(ctx, t.ProductID)
if err != nil {
return tier, false, err
}
target := plans.Target{ProductID: t.ProductID, Listed: listed, OtherLadders: bundle}
if isCurrent && (pos.Paying == nil || !pos.Paying.GrantHolds) {
b.moves[t.ProductID] = append(b.moves[t.ProductID], plans.Offer(pos, target))
h.setCurrent(ctx, &tier, ladderID, pos, facts)
return tier, true, nil
@@ -464,6 +470,18 @@ func (h *MemberProductsHandler) buildTier(ctx context.Context, b *plansBuild, la
return tier, false, fmt.Errorf("read the price facts of product %s: %w", t.ProductID, err)
}
target.Price = price.Price
if isCurrent {
move := plans.Offer(pos, target)
b.moves[t.ProductID] = append(b.moves[t.ProductID], move)
h.setCurrent(ctx, &tier, ladderID, pos, facts)
if move.Kind == plans.Switch {
// The current card keeps its relation: the card states the
// plan, and the template draws the switch's control on it.
tier.setMove(move, ladderID, price)
tier.Relation = "current"
}
return tier, true, nil
}
move := plans.Offer(pos, target)
b.moves[t.ProductID] = append(b.moves[t.ProductID], move)
tier.setMove(move, ladderID, price)
@@ -393,8 +393,8 @@ func newRetiredPaidFixture(ctx context.Context, t *testing.T, db *sql.DB) (*worl
w.MapPrice(price)
return p
}
basic, plus, pro := product("Basic", 500), product("Plus", 1000), product("Pro", 3000)
wiki := w.Ladder("Wiki", basic, plus, pro)
basic, plus, pro, max := product("Basic", 500), product("Plus", 1000), product("Pro", 3000), product("Max", 5000)
wiki := w.Ladder("Wiki", basic, plus, pro, max)
norm.Name("ladder", "Wiki", wiki.ID)
owner := w.Person("Owner")
+1 -1
View File
@@ -73,7 +73,7 @@ func TestSwitchOfferAppliesTheMemberGateFirst(t *testing.T) {
BillingQ: fakeCheckoutQuerier{price: price, product: draft},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
err := h.switchOffer(context.Background(), "org-1", "ladder-1", price)
_, err := h.switchOffer(context.Background(), "org-1", "ladder-1", price)
if !errors.Is(err, errPlanNotOpenToMembers) {
t.Fatalf("err = %v, want errPlanNotOpenToMembers", err)
}
@@ -26,14 +26,17 @@ import (
"context"
"database/sql"
"errors"
"html"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
stripe "github.com/stripe/stripe-go/v81"
@@ -44,6 +47,8 @@ import (
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"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/htmltest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/world"
)
const (
@@ -659,3 +664,342 @@ func (m offerMember) scheduleDowngrade(t *testing.T, priceID, at string) {
t.Fatalf("schedule the downgrade: %v", err)
}
}
// The switch of a subscription a grant displaced and the console paused
// (openspec change grant-pauses-subscription-billing, section 11), against a
// real Postgres and the Stripe mock. pausedUnderGrant builds the member: tiers
// on a ladder of its own, a subscription paying for one of them, an operator's
// grant of another over it, and the console's pause on the Stripe subscription
// and in the row. The mock answers as Stripe does: an item update on a paused
// subscription is refused with 400, a clear and a set change the pause.
// tierSpec is one tier of the paused fixtures' ladder, in rank order.
type tierSpec struct {
name string
cents int32
currency string
interval string
}
// pausedMember is an offerMember with a ladder of its own and the subscription
// S on it.
type pausedMember struct {
offerMember
ladder string
products map[string]string // tier name -> product id
prices map[string]string // tier name -> console price id
stripeSub string
coreSub string
}
// pausedTiers are the tiers of the plain fixtures: Basic, Plus, Pro, Max, all
// monthly in dollars.
func pausedTiers(mod func(*tierSpec)) []tierSpec {
tiers := []tierSpec{
{"Basic", 500, "usd", "month"}, {"Plus", 1200, "usd", "month"},
{"Pro", 3000, "usd", "month"}, {"Max", 5000, "usd", "month"},
}
for i := range tiers {
if mod != nil {
mod(&tiers[i])
}
}
return tiers
}
// pausedUnderGrant builds the fixture: S pays for the tier paid, an operator's
// grant of the tier granted holds the ladder, and the console's pause is on S.
func (m offerMember) pausedUnderGrant(t *testing.T, tiers []tierSpec, paid, granted string) pausedMember {
t.Helper()
w := world.New(t, m.db)
pm := pausedMember{offerMember: m, products: map[string]string{}, prices: map[string]string{}, stripeSub: "sub_paused_" + m.sfx}
var tierProducts []world.Product
for _, spec := range tiers {
product := w.Product(spec.name)
price := w.RecurringPrice(product, spec.cents, spec.currency, spec.interval)
w.MapPrice(price)
pm.products[spec.name], pm.prices[spec.name] = product.ID, price.ID
tierProducts = append(tierProducts, product)
}
pm.ladder = w.Ladder("Paused", tierProducts...).ID
now := time.Now()
m.mock.Sub = &stripe.Subscription{
ID: pm.stripeSub, Status: stripe.SubscriptionStatusActive, Customer: &stripe.Customer{ID: m.stripeCustomerID},
CurrentPeriodStart: now.Add(-time.Hour).Unix(), CurrentPeriodEnd: now.Add(29 * 24 * time.Hour).Unix(),
Items: &stripe.SubscriptionItemList{Data: []*stripe.SubscriptionItem{{
ID: "si_paused_" + m.sfx, Quantity: 1, Price: &stripe.Price{ID: "price_" + pm.prices[paid]},
}}},
}
pm.reconcile(t, "test:setup")
pm.coreSub = pm.scalarString(t, `SELECT subscription_id FROM stripe.subscription_mappings WHERE stripe_subscription_id = $1`, pm.stripeSub)
pm.grant(t, granted)
m.mock.Sub.PauseCollection = &stripe.SubscriptionPauseCollection{Behavior: stripe.SubscriptionPauseCollectionBehaviorVoid}
m.mock.Sub.Metadata = map[string]string{"member_console_collection": "paused_for_grant"}
pm.reconcile(t, "test:paused")
pm.stripeApplies()
return pm
}
func (pm pausedMember) reconcile(t *testing.T, reason string) {
t.Helper()
if err := fulfillment.ReconcileSubscription(context.Background(), pm.db, discardLogger(), stripetest.KeyMode(), pm.stripeSub, reason); err != nil {
t.Fatalf("reconcile %s: %v", reason, err)
}
}
func (pm pausedMember) scalarString(t *testing.T, query string, args ...any) string {
t.Helper()
var s string
if err := pm.db.QueryRow(query, args...).Scan(&s); err != nil {
t.Fatalf("%s: %v", query, err)
}
return s
}
// grant issues an operator's complimentary grant of the named tier to the
// member's organization.
func (pm pausedMember) grant(t *testing.T, tier string) {
t.Helper()
person := pm.scalarString(t, `SELECT owner_person_id FROM core.organizations WHERE org_id = $1`, pm.orgID)
if _, err := entitlements.ConferGrant(context.Background(), pm.db, entitlements.ConferGrantInput{
ProductID: pm.products[tier], OrgID: pm.orgID, GrantedByPersonID: person, GrantReason: "complimentary", Quantity: 1,
ActorType: "operator", ActorID: uuid.NullUUID{UUID: uuid.MustParse(person), Valid: true}, TransitionReason: "test: issue grant",
}); err != nil {
t.Fatalf("issue the grant of %s: %v", tier, err)
}
}
// stripeApplies makes the mock change its subscription as Stripe does when an
// update lands.
func (pm pausedMember) stripeApplies() {
mock := pm.mock
mock.Handle = func(c stripetest.Call) (any, error, bool) {
if c.Method != "POST" || c.Path != "/v1/subscriptions/"+mock.Sub.ID {
return nil, nil, false
}
sub := mock.Sub
switch {
case c.Form.Has("items[0][price]"):
if sub.PauseCollection != nil {
return nil, &stripe.Error{Type: stripe.ErrorTypeInvalidRequest, HTTPStatusCode: 400, Msg: "You cannot create a new invoice for a paused subscription."}, true
}
sub.Items.Data[0].Price = &stripe.Price{ID: c.Form.Get("items[0][price]")}
case c.Form.Has("pause_collection"):
sub.PauseCollection = nil
delete(sub.Metadata, "member_console_collection")
case c.Form.Get("pause_collection[behavior]") != "":
sub.PauseCollection = &stripe.SubscriptionPauseCollection{Behavior: stripe.SubscriptionPauseCollectionBehaviorVoid}
sub.Metadata["member_console_collection"] = "paused_for_grant"
}
return nil, nil, false
}
}
// post posts the switch of S to the tier, with a proration moment of when,
// and returns what the page said: its alerts, with the page's "Error:" label
// (member_plans.html:12) taken off so the text is the sentence alone, or, when
// it shows the confirmation, its headings.
func (pm pausedMember) post(t *testing.T, tier string, when time.Time) string {
t.Helper()
form := url.Values{"ladder_id": {pm.ladder}, "price_id": {pm.prices[tier]}, "proration_date": {strconv.FormatInt(when.Unix(), 10)}}
req := httptest.NewRequest(http.MethodPost, "/partials/member/plans/switch", strings.NewReader(form.Encode())).WithContext(pm.sctx)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
pm.h.PostSwitch(rec, req)
doc := htmltest.Parse(t, rec.Body.String())
if alerts := doc.Texts(".alert"); len(alerts) > 0 {
return strings.TrimPrefix(strings.Join(alerts, " "), "Error: ")
}
return strings.Join(doc.Texts("strong"), " ")
}
// pausedPostRow is one posted switch: the fixture's change, the tier posted,
// the proration moment relative to the request, and the text the page holds.
type pausedPostRow struct {
name string
prep func(t *testing.T, pm pausedMember)
tier string
before time.Duration
want string
// heading, when set, replaces want with the confirmation's whole heading
// for the tier posted, whose product name carries the fixture's suffix.
heading bool
}
func checkPausedPost(t *testing.T, pm pausedMember, row pausedPostRow) {
t.Helper()
if row.prep != nil {
row.prep(t, pm)
}
got := pm.post(t, row.tier, time.Now().Add(-row.before))
if row.heading {
row.want = "Switch to " + pm.scalarString(t, `SELECT name FROM core.products WHERE product_id = $1`, pm.products[row.tier]) + "?"
}
if !strings.Contains(got, row.want) {
t.Errorf("%s: answered %q, want %q", row.name, got, row.want)
}
}
// setStatus makes Stripe's subscription the status and reconciles it, the
// writer the fixture builds S with, so the row and its provisions move as a
// suspended subscription's do.
func setStatus(status stripe.SubscriptionStatus) func(*testing.T, pausedMember) {
return func(t *testing.T, pm pausedMember) {
pm.mock.Sub.Status = status
pm.reconcile(t, "test:status")
}
}
const (
offerSubmitted = "Your plan change has been submitted."
offerAlready = "You&#39;re already on that plan."
)
func unescaped(s string) string { return html.UnescapeString(s) }
// TestPostSwitchFromAPausedSubscription: the switch posted from a subscription
// a grant displaced, in five fixtures.
func TestPostSwitchFromAPausedSubscription(t *testing.T) {
retry := unescaped(offerMoveFailed)
unavailable := unescaped(offerUnavailable)
t.Run("a Pro grant over Plus", func(t *testing.T) {
rows := []pausedPostRow{
{name: "Max", tier: "Max", want: offerSubmitted},
{name: "Plus", tier: "Plus", want: unavailable},
{name: "past due", prep: setStatus(stripe.SubscriptionStatusPastDue), tier: "Max", want: offerPayFirst},
{name: "paused", prep: setStatus(stripe.SubscriptionStatusPaused), tier: "Max", want: offerPaused},
{name: "a renewal finalizing", tier: "Max", want: retry, prep: func(t *testing.T, pm pausedMember) {
soon := time.Now().Add(5 * time.Minute).Unix()
pm.mock.Invoices = map[string]*stripe.Invoice{"in_renewal": {
ID: "in_renewal", Subscription: &stripe.Subscription{ID: pm.stripeSub}, Status: stripe.InvoiceStatusDraft,
BillingReason: stripe.InvoiceBillingReasonSubscriptionCycle, AutoAdvance: true,
NextPaymentAttempt: soon, AutomaticallyFinalizesAt: soon,
}}
}},
{name: "a proration date before the period", tier: "Max", before: 2 * time.Minute, heading: true, prep: func(t *testing.T, pm pausedMember) {
pm.mock.Sub.CurrentPeriodStart = time.Now().Add(-time.Minute).Unix()
}},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
pm := seedOfferMember(t).pausedUnderGrant(t, pausedTiers(nil), "Plus", "Pro")
checkPausedPost(t, pm, row)
})
}
})
t.Run("a Basic grant over Pro", func(t *testing.T) {
rows := []pausedPostRow{
{name: "Max", tier: "Max", want: offerSubmitted},
{name: "Plus, between", tier: "Plus", want: unavailable},
{name: "Basic, the granted tier below the paid tier", tier: "Basic", want: unavailable},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
checkPausedPost(t, basicGrantOverPro(t), row)
})
}
})
t.Run("held by another subscription", func(t *testing.T) {
pm := seedOfferMember(t).pausedUnderGrant(t, pausedTiers(nil), "Plus", "Pro")
pm.holdWithAnotherSubscription(t, "Max")
checkPausedPost(t, pm, pausedPostRow{name: "the holder's current tier", tier: "Max", want: unescaped(offerAlready)})
})
t.Run("a Pro grant over Plus, Max a tier of another ladder too", func(t *testing.T) {
pm := seedOfferMember(t).pausedUnderGrant(t, pausedTiers(nil), "Plus", "Pro")
world.New(t, pm.db).Ladder("Bundle", world.Product{ID: pm.products["Max"]})
checkPausedPost(t, pm, pausedPostRow{name: "a bundle tier", tier: "Max", want: unavailable})
})
t.Run("a Pro grant over Plus, Max priced in euros", func(t *testing.T) {
tiers := pausedTiers(func(s *tierSpec) {
if s.name == "Max" {
s.currency = "eur"
}
})
pm := seedOfferMember(t).pausedUnderGrant(t, tiers, "Plus", "Pro")
checkPausedPost(t, pm, pausedPostRow{name: "Max", tier: "Max", want: unavailable})
})
}
// basicGrantOverPro is S paying for Pro under a grant of Basic: the grant
// displaced S while Pro ranked below Basic, and the operator then reordered the
// ladder, so the grant ranks below the paid tier.
func basicGrantOverPro(t *testing.T) pausedMember {
t.Helper()
m := seedOfferMember(t)
order := []tierSpec{{"Pro", 3000, "usd", "month"}, {"Basic", 500, "usd", "month"}, {"Plus", 1200, "usd", "month"}, {"Max", 5000, "usd", "month"}}
pm := m.pausedUnderGrant(t, order, "Pro", "Basic")
w := world.New(t, m.db)
w.ReorderTiers(world.Ladder{ID: pm.ladder, Name: "Paused"},
world.Product{ID: pm.products["Basic"]}, world.Product{ID: pm.products["Plus"]},
world.Product{ID: pm.products["Pro"]}, world.Product{ID: pm.products["Max"]})
return pm
}
// holdWithAnotherSubscription makes a second subscription on the tier hold the
// ladder over S, as a subscription made in the Dashboard can.
func (pm pausedMember) holdWithAnotherSubscription(t *testing.T, tier string) {
t.Helper()
first := pm.mock.Sub
second := *first
second.ID = "sub_held_" + pm.sfx
second.PauseCollection, second.Metadata = nil, nil
second.Items = &stripe.SubscriptionItemList{Data: []*stripe.SubscriptionItem{{
ID: "si_held_" + pm.sfx, Quantity: 1, Price: &stripe.Price{ID: "price_" + pm.prices[tier]},
}}}
pm.mock.Sub = &second
if err := fulfillment.ReconcileSubscription(context.Background(), pm.db, discardLogger(), stripetest.KeyMode(), second.ID, "test:holder"); err != nil {
t.Fatalf("reconcile the holding subscription: %v", err)
}
pm.mock.Sub = first
}
// previewRow is the confirmation S's member reads when opening the preview of
// Max.
type previewRow struct {
name string
want string
}
// TestSwitchPreviewFromAPausedSubscription: the preview of Max from S, whose
// row records the console's pause, is the unavailable preview with no Stripe
// call, and its confirmation reads Copy M3 with the fallback sentence, or M12
// toward a price of another interval.
func TestSwitchPreviewFromAPausedSubscription(t *testing.T) {
const replaces = "Your subscription replaces the grant. "
fixtures := []struct {
name string
mod func(*tierSpec)
rows []previewRow
}{
{"a Pro grant over Plus", nil, []previewRow{
{name: "a monthly Max", want: replaces + "Your card is charged the prorated difference now, then $50.00 a month."},
}},
{"a Pro grant over Plus, Max priced yearly", func(s *tierSpec) {
if s.name == "Max" {
s.cents, s.interval = 30000, "year"
}
}, []previewRow{
{name: "a yearly Max", want: replaces + "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."},
}},
}
for _, f := range fixtures {
t.Run(f.name, func(t *testing.T) {
pm := seedOfferMember(t).pausedUnderGrant(t, pausedTiers(f.mod), "Plus", "Pro")
for _, row := range f.rows {
before := len(pm.mock.Calls)
pm.mock.Err = errors.New("stripetest: no call expected")
req := httptest.NewRequest(http.MethodGet, "/partials/member/plans/switch/preview?"+url.Values{"ladder_id": {pm.ladder}, "price_id": {pm.prices["Max"]}}.Encode(), nil).WithContext(pm.sctx)
rec := httptest.NewRecorder()
pm.h.GetSwitchPreview(rec, req)
if sent := len(pm.mock.Calls) - before; sent != 0 {
t.Errorf("%s: the preview sent %d Stripe calls, want 0", row.name, sent)
}
got := htmltest.Parse(t, rec.Body.String()).Texts("p.mb-0")
if !slices.Contains(got, row.want) {
t.Errorf("%s: the confirmation reads %q, want %q", row.name, got, row.want)
}
}
})
}
}
+27 -15
View File
@@ -20,32 +20,44 @@ func TestPlanMoveMessageStatesTheTiming(t *testing.T) {
if then != "$30.00 a month" {
t.Fatalf("recurring phrase = %q, want $30.00 a month", then)
}
const yearly = "$300.00 a year"
cases := []struct {
name string
p fulfillment.SwitchPreview
want string
name string
p fulfillment.SwitchPreview
then string
replacesGrant bool
newPeriodCharge string
want string
}{
{"upgrade", fulfillment.SwitchPreview{Available: true, NetAmount: 900, Currency: "usd"},
"Your card is charged $9.00 now for the rest of this period, then $30.00 a month."},
{"nothing due", fulfillment.SwitchPreview{Available: true, NetAmount: 0, Currency: "usd"},
"Nothing is charged now; then $30.00 a month."},
{"no figure", fulfillment.SwitchPreview{},
"Your card is charged the prorated difference now, then $30.00 a month."},
{"downgrade", fulfillment.SwitchPreview{Available: true, Downgrade: true, EffectiveAt: time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC)},
"You keep Pro until Oct 14, 2026, then pay $30.00 a month."},
{name: "upgrade", p: fulfillment.SwitchPreview{Available: true, NetAmount: 900, Currency: "usd"}, then: then,
want: "Your card is charged $9.00 now for the rest of this period, then $30.00 a month."},
{name: "nothing due", p: fulfillment.SwitchPreview{Available: true, NetAmount: 0, Currency: "usd"}, then: then,
want: "Nothing is charged now; then $30.00 a month."},
{name: "no figure", p: fulfillment.SwitchPreview{}, then: then,
want: "Your card is charged the prorated difference now, then $30.00 a month."},
{name: "downgrade", p: fulfillment.SwitchPreview{Available: true, Downgrade: true, EffectiveAt: time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC)}, then: then,
want: "You keep Pro until Oct 14, 2026, then pay $30.00 a month."},
{name: "replaces the grant", p: fulfillment.SwitchPreview{Available: true, NetAmount: 900, Currency: "usd"}, then: then, replacesGrant: 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: "replaces the grant with no figure", p: fulfillment.SwitchPreview{}, then: then, replacesGrant: true,
want: "Your subscription replaces the grant. Your card is charged the prorated difference now, then $30.00 a month."},
{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."},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := string(planMoveMessage("Plus", "Pro", then, tc.p))
if !strings.Contains(got, "<strong>Switch to Plus?</strong>") || !strings.Contains(got, tc.want) {
t.Errorf("message = %s, want the heading and %q", got, tc.want)
got := string(planMoveMessage("Plus", "Pro", tc.then, tc.replacesGrant, tc.newPeriodCharge, tc.p))
if !strings.Contains(got, "<strong>Switch to Plus?</strong>") || !strings.Contains(got, `<p class="mb-0">`+tc.want+"</p>") {
t.Errorf("message = %s, want the heading and the second paragraph %q", got, tc.want)
}
if strings.Contains(got, "estimate") || strings.Contains(got, "about") {
t.Errorf("message = %s: the figure is the charge, not an estimate", got)
}
})
}
if got := string(planMoveMessage("<b>", "Pro", then, fulfillment.SwitchPreview{})); strings.Contains(got, "<b>?") {
if got := string(planMoveMessage("<b>", "Pro", then, false, "", fulfillment.SwitchPreview{})); strings.Contains(got, "<b>?") {
t.Errorf("tier names must be escaped: %s", got)
}
}
+56 -23
View File
@@ -38,6 +38,9 @@ type positionLoader struct {
// payingRows is the row of ListSubscriptionRungsByOrg behind each entry of
// paying, for the paid plan's card, read with it and nil until then.
payingRows map[string]billing.ListSubscriptionRungsByOrgRow
// otherLadders is whether a product is a tier of more than one ladder,
// read once per product.
otherLadders map[string]bool
}
// newPositionLoader builds a loader over the billing and entitlements
@@ -164,6 +167,23 @@ func firstRowOf(rows []billing.ListSubscriptionRungsByOrgRow, subscriptionID str
return first
}
// isBundleTier reports whether the product is a tier of more than one ladder.
// The answer is read once per product and kept on the loader.
func (l *positionLoader) isBundleTier(ctx context.Context, productID string) (bool, error) {
if v, ok := l.otherLadders[productID]; ok {
return v, nil
}
rows, err := l.billing.ListLaddersByProduct(ctx, productID)
if err != nil {
return false, fmt.Errorf("read the ladders of product %s: %w", productID, err)
}
if l.otherLadders == nil {
l.otherLadders = make(map[string]bool)
}
l.otherLadders[productID] = len(rows) > 1
return len(rows) > 1, nil
}
// payingFrom states the facts of a paying subscription from its row and the
// ladder's held position. GrantHolds is the position's grant_id being set
// while it is not default-sourced: a default position is a grant row too.
@@ -177,6 +197,8 @@ func payingFrom(r billing.ListSubscriptionRungsByOrgRow, held entitlements.ListL
ConsolePaused: r.PauseCollectionBehavior.Valid && r.PauseCollectionMarked,
MoveScheduled: r.CancelAtPeriodEnd || r.CancellationAt.Valid || r.DowngradeAt.Valid,
OtherLadders: otherLadders,
Currency: r.Currency,
Interval: r.RecurringInterval.String,
}
}
@@ -377,6 +399,8 @@ func (l *positionLoader) defaultPrice(ctx context.Context, productID, keyMode st
if err != nil {
return priceFacts{}, fmt.Errorf("read price %s: %w", r.PriceID, err)
}
facts.Price.Currency = price.Currency
facts.Price.Interval = price.RecurringInterval.String
facts.Text = formatMoney(int64(price.UnitAmount), price.Currency) + "/" + price.RecurringInterval.String
return facts, nil
}
@@ -403,6 +427,8 @@ func (l *positionLoader) postedPrice(ctx context.Context, price billing.Price, k
Default: isDefault,
Recurring: isRecurringPrice(price),
Purchasable: isDefault && readinessPurchasable(r, keyMode),
Currency: price.Currency,
Interval: price.RecurringInterval.String,
}, nil
}
@@ -421,23 +447,25 @@ var errPlanNotOffered = errors.New("switch: the target is not offered as a switc
// string (uuid.Parse accepts a urn:uuid: prefix, which Postgres rejects, and
// spellings that do not compare equal to the id a row carries), and the
// canonical ladder id and the price row are returned for the caller's reads
// and for SwitchPlan.
func (h *MemberProductsHandler) switchOfferFor(ctx context.Context, orgID, postedLadder, postedPrice string) (string, billing.Price, error) {
// and for SwitchPlan, and the move the decision allowed beside them, which the
// confirmation reads (ReplacesGrant, NewPeriod).
func (h *MemberProductsHandler) switchOfferFor(ctx context.Context, orgID, postedLadder, postedPrice string) (string, billing.Price, plans.Move, error) {
ladder, lerr := uuid.Parse(postedLadder)
priceUUID, perr := uuid.Parse(postedPrice)
if lerr != nil || perr != nil {
h.Logger.Info("switch refused: an id is not a UUID")
return "", billing.Price{}, errPlanNotOffered
return "", billing.Price{}, plans.Move{}, errPlanNotOffered
}
price, err := h.BillingQ.GetPrice(ctx, priceUUID.String())
if errors.Is(err, sql.ErrNoRows) {
h.Logger.Info("switch refused: no such price", slog.String("price_id", priceUUID.String()))
return "", billing.Price{}, errPlanNotOffered
return "", billing.Price{}, plans.Move{}, errPlanNotOffered
}
if err != nil {
return "", billing.Price{}, fmt.Errorf("switch: get target price %s: %w", priceUUID, err)
return "", billing.Price{}, plans.Move{}, fmt.Errorf("switch: get target price %s: %w", priceUUID, err)
}
return ladder.String(), price, h.switchOffer(ctx, orgID, ladder.String(), price)
move, err := h.switchOffer(ctx, orgID, ladder.String(), price)
return ladder.String(), price, move, err
}
// switchOffer is the one authorization a plan-switch target passes, asked by
@@ -454,7 +482,8 @@ func (h *MemberProductsHandler) switchOfferFor(ctx context.Context, orgID, poste
// (SwitchPlan resolves the subscription before it compares the tier, so the
// order is the same); and anything else is an unavailable plan. The member
// gate and a malformed or unknown price id answer before all of them. A read
// that fails is returned.
// that fails is returned. It returns the move it allowed beside a nil error,
// and the zero move with every refusal.
//
// On a ladder a subscription pays on without backing the held position
// (pos.Paying set, pos.Subscribed false, the condition of the offer decision's
@@ -466,56 +495,60 @@ func (h *MemberProductsHandler) switchOfferFor(ctx context.Context, orgID, poste
// there. The !pos.Subscribed case answers only a ladder no subscription pays
// on, and a subscription that holds the rung over a paying one keeps the
// refusals above.
func (h *MemberProductsHandler) switchOffer(ctx context.Context, orgID, ladderID string, price billing.Price) error {
func (h *MemberProductsHandler) switchOffer(ctx context.Context, orgID, ladderID string, price billing.Price) (plans.Move, error) {
if err := h.switchTargetOpenToMembers(ctx, price.PriceID); err != nil {
return err
return plans.Move{}, err
}
tiers, err := h.BillingQ.ListTiersByLadderWithProducts(ctx, ladderID)
if err != nil {
return fmt.Errorf("switch: list tiers for ladder %s: %w", ladderID, err)
return plans.Move{}, fmt.Errorf("switch: list tiers for ladder %s: %w", ladderID, err)
}
loader := newPositionLoader(h.BillingQ, h.EntitlementsQ, h.Database, time.Now)
held, err := loader.positions(ctx, orgID)
if err != nil {
return fmt.Errorf("switch: %w", err)
return plans.Move{}, fmt.Errorf("switch: %w", err)
}
paying, err := loader.payingOn(ctx, orgID, ladderID, held)
if err != nil {
return fmt.Errorf("switch: %w", err)
return plans.Move{}, fmt.Errorf("switch: %w", err)
}
pos, _, err := loader.onLadder(ctx, ladderID, tiers, held[ladderID], paying)
if err != nil {
return fmt.Errorf("switch: read the position on ladder %s: %w", ladderID, err)
return plans.Move{}, fmt.Errorf("switch: read the position on ladder %s: %w", ladderID, err)
}
posted, err := loader.postedPrice(ctx, price, h.StripeMode)
if err != nil {
return fmt.Errorf("switch: %w", err)
return plans.Move{}, fmt.Errorf("switch: %w", err)
}
bundle, err := loader.isBundleTier(ctx, price.ProductID)
if err != nil {
return plans.Move{}, fmt.Errorf("switch: %w", err)
}
// The member gate passed in switchTargetOpenToMembers.
move := plans.Offer(pos, plans.Target{ProductID: price.ProductID, Listed: true, Price: posted})
move := plans.Offer(pos, plans.Target{ProductID: price.ProductID, Listed: true, OtherLadders: bundle, Price: posted})
switch {
case move.Allows(plans.Switch):
return nil
return move, nil
case move.Reason == plans.NotOnLadder:
return errPlanNotOffered
return plans.Move{}, errPlanNotOffered
case pos.Suspended:
h.Logger.Info("switch refused: the plan is suspended",
slog.String("ladder_id", ladderID),
slog.String("subscription_status", held[ladderID].SubscriptionStatus.String))
if pos.Paused {
return fulfillment.ErrSubscriptionPaused
return plans.Move{}, fulfillment.ErrSubscriptionPaused
}
return fulfillment.ErrSubscriptionSuspended
return plans.Move{}, fulfillment.ErrSubscriptionSuspended
case pos.Paying != nil && !pos.Subscribed:
return h.refusePayingLadder(pos, ladderID, price.PriceID, move.Reason)
return plans.Move{}, h.refusePayingLadder(pos, ladderID, price.PriceID, move.Reason)
case !pos.Subscribed:
return fulfillment.ErrNoActivePaidSubscription
return plans.Move{}, fulfillment.ErrNoActivePaidSubscription
case move.Reason == plans.Current:
return fulfillment.ErrSameTier
return plans.Move{}, fulfillment.ErrSameTier
}
h.Logger.Info("switch refused: the offer decision does not offer it",
slog.String("price_id", price.PriceID), slog.String("ladder_id", ladderID), slog.String("reason", string(move.Reason)))
return errPlanNotOffered
return plans.Move{}, errPlanNotOffered
}
// refusePayingLadder maps the refusal of a switch on a ladder a subscription
+3
View File
@@ -116,6 +116,9 @@ func TestPayingByLadder(t *testing.T) {
grantHolds, paying(func(p *plans.Paying) { p.Suspended = true })},
{"paused", withEdit(func(r *billing.ListSubscriptionRungsByOrgRow) { r.Status = "paused" }),
grantHolds, paying(func(p *plans.Paying) { p.Suspended, p.Paused = true, true })},
{"the paying price's currency and interval", withEdit(func(r *billing.ListSubscriptionRungsByOrgRow) {
r.Currency, r.RecurringInterval = "eur", sql.NullString{String: "year", Valid: true}
}), grantHolds, paying(func(p *plans.Paying) { p.Currency, p.Interval = "eur", "year" })},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
@@ -101,6 +101,51 @@
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-get="/partials/member/plans/switch/preview" hx-vals='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Pro>"}'
hx-target="#member-plans" hx-swap="innerHTML">Upgrade</button>
</div>
</div>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h4 class="card-title mb-0 h6">Max</h4>
</div>
<p class="text-muted small mb-2">$50.00/month</p>
<ul class="list-unstyled small mb-3 flex-grow-1">
</ul>
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-get="/partials/member/plans/switch/preview" hx-vals='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Max>"}'
hx-target="#member-plans" hx-swap="innerHTML">Upgrade</button>
</div>
</div>
</div>
@@ -78,14 +78,47 @@
"Relation": "current",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "",
"Purchasable": false,
"PriceText": "",
"PriceID": "<price:Pro>",
"Purchasable": true,
"PriceText": "$30.00/month",
"NoPriceTier": false,
"MoveEnabled": false,
"MoveKind": "",
"MoveLadderID": "",
"MoveLabel": "",
"MoveEnabled": true,
"MoveKind": "switch",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Upgrade",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
"BillingPending": false,
"LadderID": "",
"MovesTo": "",
"MovesOn": "",
"EndsOn": "",
"Suspension": "",
"CanCancel": false,
"CanKeep": false,
"CancelBody": "",
"Held": false,
"PaidUnderGrant": false,
"GrantedName": "",
"CollectionPaused": false
},
{
"ProductID": "<product:Max>",
"Name": "Max",
"Description": "",
"Rank": 3,
"Relation": "upgrade",
"EntitlementFeatures": null,
"MetadataFeatures": null,
"PriceID": "<price:Max>",
"Purchasable": true,
"PriceText": "$50.00/month",
"NoPriceTier": false,
"MoveEnabled": true,
"MoveKind": "switch",
"MoveLadderID": "<ladder:Wiki>",
"MoveLabel": "Upgrade",
"DisabledReason": "",
"BillingPriceText": "",
"BillingRenewsOn": "",
+11 -4
View File
@@ -25,19 +25,26 @@ type Price struct {
// operator's create form does. The first price of a product is its default.
func (w *World) Price(p Product, cents int32) Price {
w.TB.Helper()
return w.createPrice(p, cents, sql.NullString{String: "month", Valid: true})
return w.createPrice(p, cents, "usd", sql.NullString{String: "month", Valid: true})
}
// OneTimePrice creates a one-time price of p in US dollars.
func (w *World) OneTimePrice(p Product, cents int32) Price {
w.TB.Helper()
return w.createPrice(p, cents, sql.NullString{})
return w.createPrice(p, cents, "usd", sql.NullString{})
}
func (w *World) createPrice(p Product, cents int32, interval sql.NullString) Price {
// RecurringPrice creates a recurring price of p in the given currency and
// interval ("month" or "year"); Price is RecurringPrice in usd, monthly.
func (w *World) RecurringPrice(p Product, cents int32, currency, interval string) Price {
w.TB.Helper()
return w.createPrice(p, cents, currency, sql.NullString{String: interval, Valid: true})
}
func (w *World) createPrice(p Product, cents int32, currency string, interval sql.NullString) Price {
w.TB.Helper()
price, err := billing.New(w.DB).CreatePrice(w.Ctx, billing.CreatePriceParams{
ProductID: p.ID, Currency: "usd", UnitAmount: cents, RecurringInterval: interval,
ProductID: p.ID, Currency: currency, UnitAmount: cents, RecurringInterval: interval,
})
if err != nil {
w.TB.Fatalf("create a price of %s: %v", p.Name, err)
@@ -1,6 +1,6 @@
## Context
Line numbers are at HEAD `a4c31607`.
Line numbers are at HEAD `a4c31607`, except in the text that section 11 builds on (D1's exception, D9's rule 3a item 2 and the fields section 11 adds, D11's void, D12, D13's rows for the switch and Cancel plan, the failure rows from F63 on, and What is unverified), which cites the tree at `1d0e2be9`, with sections 1 to 10 committed. D21 cites `84709d96`.
**The displaced subscription today.** An operator's grant over a tier a subscription pays for supersedes the subscription's position inside `core.confer`. The subscription's reconcile then leaves the ladder alone: `convergeConferrals` (`internal/fulfillment/reconcile.go:766-825`) reads the live provisions, the shapes of the desired products (`conferralRungs`, `:697-703`) and the subscription's displacements (`productsLeftAlone`, `:831-867`, reading `ListSubscriptionDisplacements` over `core.subscription_displacements`, `internal/db/migrations/00034_subscription_displacements.sql`), and `DisplacedProducts` (`:740-757`) maps each missing product with a rung on a displaced ladder to its displacement. Those products are logged and not conferred; `chooseTiers` (`:899-933`) decides the rest. Nothing is sent to Stripe, so the subscription keeps billing. The pure rule is exported because the organization page (`displacedOnPage`, `internal/server/operator_enrollment.go:495-509`) and the subscriptions list (`subscriptionTitlesFor`, `internal/server/operator_billing.go:643`) call it too.
@@ -26,8 +26,15 @@ Line numbers are at HEAD `a4c31607`.
- An invoice already open when the pause is set keeps being retried on the account's schedule; the subscription stays `past_due`; renewals made while paused are voided; when the retries run out, the account's dunning setting cancels the paused subscription.
- A pause during a trial voids the trial-end invoice; `trialing` becomes `active` with the pause kept.
- Each pause or clear call produces one `customer.subscription.updated`, with `previous_attributes.pause_collection`.
- Stripe refuses, with 400 ("You cannot create a new invoice for a paused subscription. Unpause the subscription before attempting to create a new invoice."), an `always_invoice` price change on a paused subscription; nothing changes. The same change in one update that also clears the pause and removes the marker succeeds: its proration invoice is created after the clear and paid at once, and it credits the old price's unused time although that period's renewal was voided. Two calls, the clear and then the change, give the same invoice.
- Stripe refuses, with 400 ("You cannot create a new invoice for a paused subscription. Unpause the subscription before attempting to create a new invoice."), an `always_invoice` price change on a paused subscription; nothing changes. The same change in one update that also clears the pause and removes the marker, sent with no `payment_behavior`, succeeds: its proration invoice is created after the clear and paid at once, and it credits the old price's unused time although that period's renewal was voided. Two calls, the clear and then the change, give the same invoice.
- A subscription paused through its upcoming-renewal reminder date and cleared three days before the renewal receives no `invoice.upcoming` and is charged in full at the renewal.
- An `always_invoice` update with `payment_behavior=pending_if_incomplete` that also sends `pause_collection` is refused before any change, whatever the card (400, "When `payment_behavior` is set to `pending_if_incomplete`, you can only pass supported params. `pause_collection` is not supported."). With `error_if_incomplete` the same update is atomic: paid at once, or, on a declining card, refused with 402 and nothing changed. With `allow_incomplete` or `default_incomplete` Stripe applies it whatever the payment does: on a declining card (both), or on one that needs authentication (`allow_incomplete`; `default_incomplete` was not run with one), the subscription moves to the new price unpaid, `past_due`, its pause gone.
- Two calls, the clear and then the update with `pending_if_incomplete`: a declining card leaves the subscription unpaused at the old price with a pending update and an open invoice, and voiding that invoice drops the pending update. Stripe accepts the pause while an update is pending, and paying the pending invoice then applies the update with the pause still set. Left unpaid, a pending update expires after 23 hours, or at the period end if that comes first (Stripe's documentation), and Stripe voids its invoice; a declining card and a card needing authentication, each paused again at once, were charged nothing over 25 hours, and the renewal in that time was voided. An open pending invoice can be voided while the subscription is paused, and a new switch then applies. `cancel_at_period_end=true` keeps a pending update, and paying its invoice applies the update with the cancellation still set.
- Stripe answers no invoice preview for a paused subscription (400, "You cannot create a new invoice for a paused subscription."). On the same subscription unpaused, the preview's `amount_due` equals the switch's invoice.
- The console's restricted key lists a renewal draft made while paused (`GET /v1/invoices` with `subscription` and `status=draft`): `billing_reason=subscription_cycle`, `auto_advance=true`, and `next_payment_attempt` and `automatically_finalizes_at` both its creation plus an hour. A clear and a re-pause inside that hour leave the renewal voided.
- A switch from a monthly price to a yearly one, a third of a day into the period, charged a full year less the unused month ($300 less $29.68, $270.32) and moved the billing anchor to the switch, so the next period ends a year later.
- On a subscription a schedule manages, the clear and then the switch apply and are paid, and the schedule releases.
- Stripe refuses a `proration_date` before the current period's start (400, "Cannot specify proration date outside of current subscription period or current phase.").
## Goals / Non-Goals
@@ -62,6 +69,8 @@ Reason: the reconcile holds the strongest view: the live subscription it just re
- (d) cannot follow an extension, a revocation before the date, an open-ended grant or a holder that changes without the grant ending (D2).
- (e) `internal/integration/outbox.go:20-23` forbids a direct insert, and the desired-state outbox is for providers with connections, which Stripe has none of.
One member move is the exception, ruled with the paused switch's call form (D12): the switch of a subscription carrying the console's pause makes a pause call itself, the clear before its update, because Stripe refuses the update on a paused subscription and refuses the clear inside an update that keeps the approval path. On every outcome but an applied update it then runs the reconcile's collection decision in the request, through `ApplySubscriptionCollection`, so the subscription is paused again within seconds rather than at the relay's next poll. Every set still has one decider, the rule under the pool row: the request never sets the pause by itself, and the restore's error is logged (L7) and never returned, so the member reads the switch's own answer, and a pause failure never reports a successful move as failed; a failed clear answers the generic failure, since no update was sent.
Shape:
- `reconcile(ctx, db, logger, keyMode, stripeSubscriptionID, reason, applyCollection bool) (called bool, err error)` is `ReconcileSubscription`'s body; `ReconcileSubscription` calls it with `false` and drops the flag.
- `ApplySubscriptionCollection(ctx, db, logger, keyMode, coreSubscriptionID, reason) error` is the outbox executor's entry: it reads the subscription's Stripe mapping (none, or no Stripe id: log L3 and return nil) and calls `reconcile` with `true`; when that run made a call (`called`), it runs `reconcile` with `true` once more, so the decision is made again from a fetch taken after its own call, at most twice in all. Every error it returns names the Stripe subscription id, because the Stripe provider page's dead-letter table shows the action type and the error alone. An unreachable mapping is skipped by `skipUnreachableSubscription` (`reconcile.go:146-156`) as today.
@@ -225,11 +234,11 @@ type Paying struct {
}
```
The read: `ListSubscriptionRungsByOrg` (billing), one statement per pass over the organization's ladders, read once per `positionLoader` and cached on it (a switch's re-render after the move builds a new loader and reads again, since it must show the state the move left) as `predictions` is cached (`positions()` is not cached): the organization's live subscriptions (`active`, `trialing`, `past_due`, `unpaid`, `paused`) with their status, period end, `cancel_at_period_end`, the mirror's `pause_collection_behavior` and `pause_collection_marked`, the first scheduled cancellation and plan switch (joined as `ListLadderPositionsByPool` joins them, `internal/entitlements/queries/pool_provision_ladders.sql:212-243`), and each current item's product, its price (id, `unit_amount`, `currency`, `recurring_interval`, so the paid card names the subscription's own price with no read of its own, D10, D20) and the product's tier rows (`core.plan_ladder_tiers`: ladder and rank), ordered by ladder, subscription id and product id. `payingByLadder(rows, positions)` (reading no row) keeps, per ladder, the first subscription that does not hold that ladder's position, and sets `Paused` from the status, `ConsolePaused` from the behavior and the marker together, `MoveScheduled` from `cancel_at_period_end` or either scheduled row, and `OtherLadders` when the product's tier rows name another ladder. `onLadder` gains the ladder's paying row and sets `pos.Paying`; `GrantHolds` is the held position's `grant_id` being set while its `pool_provision_ladders.default_sourced` is false, because a default position is a grant row with `grant_reason = 'default'` and must not read as a grant; `ListLadderPositionsByPool` carries `grant_id` and gains `default_sourced`. The choice of a ladder's paying subscription is one pure function in `entitlements` beside `PositionsByLadder`, which `payingByLadder` and the resolver (D11) both call, so the rule is stated once. Section 9 builds every field; `ConsolePaused`, `MoveScheduled` and `OtherLadders` are read by the rule from section 12 on.
The read: `ListSubscriptionRungsByOrg` (billing), one statement per pass over the organization's ladders, read once per `positionLoader` and cached on it (a switch's re-render after the move builds a new loader and reads again, since it must show the state the move left) as `predictions` is cached (`positions()` is not cached): the organization's live subscriptions (`active`, `trialing`, `past_due`, `unpaid`, `paused`) with their status, period end, `cancel_at_period_end`, the mirror's `pause_collection_behavior` and `pause_collection_marked`, the first scheduled cancellation and plan switch (joined as `ListLadderPositionsByPool` joins them, `internal/entitlements/queries/pool_provision_ladders.sql:212-243`), and each current item's product, its price (id, `unit_amount`, `currency`, `recurring_interval`, so the paid card names the subscription's own price with no read of its own, D10, D20) and the product's tier rows (`core.plan_ladder_tiers`: ladder and rank), ordered by ladder, subscription id and product id. `payingByLadder(rows, positions)` (reading no row) keeps, per ladder, the first subscription that does not hold that ladder's position, and sets `Paused` from the status, `ConsolePaused` from the behavior and the marker together, `MoveScheduled` from `cancel_at_period_end` or either scheduled row, and `OtherLadders` when the product's tier rows name another ladder. `onLadder` gains the ladder's paying row and sets `pos.Paying`; `GrantHolds` is the held position's `grant_id` being set while its `pool_provision_ladders.default_sourced` is false, because a default position is a grant row with `grant_reason = 'default'` and must not read as a grant; `ListLadderPositionsByPool` carries `grant_id` and gains `default_sourced`. The choice of a ladder's paying subscription is one pure function in `entitlements` beside `PositionsByLadder`, which `payingByLadder` and the resolver (D11) both call, so the rule is stated once. Section 9 builds every field; `ConsolePaused` and `MoveScheduled` are read by the rule from section 12 on, and `OtherLadders` from section 11 on (item 2). Section 11 adds `Currency` and `Interval` to `Paying`, its item's price's, which `payingFrom` (`internal/server/plan_offer.go:170-181`) sets from the row, since `ListSubscriptionRungsByOrg` already reads them; adds the same two to `plans.Price` (`internal/plans/offer.go:89-98`), the target's; and adds `ReplacesGrant` and `NewPeriod` to `plans.Move` (`:157-164`). Rule 3a item 2 reads and sets them (D12, "A price of another interval").
The rule, rule 3a, ahead of rule 3 (the target is the current product), applied when `Paying` is set and no subscription backs the held position (`!pos.Subscribed`, the condition of D11's fallback), so a subscription that holds the rung over another one, as a Dashboard-made subscription can, keeps its own moves:
1. the target is the paying product: Resume, enabled, relation `Upgrade`, when D20's conditions hold (a grant ranked below the paying product holds the rung, the subscription is not suspended, its row records the console's pause, nothing is scheduled on it, and its product is a tier of this ladder alone); otherwise none, reason `PaidHere` ("a subscription pays for the target without holding the ladder"). In the section 9 commit this item always answers none, reason `PaidHere`, and the section 12 commit adds Resume;
2. `GrantHolds`, not `Suspended`, the target ranks at or above the current rung and above the paying product: Switch, through the price rule, relation `Upgrade` (D12). The rank test against the paying product is question 3's answer: under a grant ranked below the paid tier, the paid tier resumes (item 1) and only the tiers above it switch, so every switch stays an immediate upgrade. In the section 9 commit this item answers none, reason `PaysOnLadder`, and the section 11 commit makes it a switch, because the preview and the resolver reach a paying subscription only from section 10 and the Stripe update clears the pause only from section 11;
2. `GrantHolds`, not `Suspended`, not `OtherLadders`, the target not a tier of another ladder (`Target.OtherLadders`), the target ranks at or above the current rung and above the paying product: Switch, through the price rule, relation `Upgrade`, with the move's `ReplacesGrant` set (D12). A paying product that is a tier of another ladder too is offered no switch, as D20 refuses Resume for it: the switch moves the subscription off its product's other ladders, which #271 decides (the rule Resume plan follows). A target that is a tier of another ladder too is offered no switch either, because its conferral would supersede whatever holds its other ladders, a grant included (invariant 7), which the confirmation cannot name. Such a position falls to items 3 and 4, reason `Current` for the granted tier and `PaysOnLadder` for every other, and a post is refused with the unavailable-plan sentence. `Paying.OtherLadders` and `Target.OtherLadders` are the two facts read: `Target` gains `OtherLadders bool`, set by `switchOffer` and `buildTier` from `ListLaddersByProduct` (more than one row), read once per product on the `positionLoader`, a failed read returned. The rank test against the paying product is question 3's answer: under a grant ranked below the paid tier, the paid tier resumes (item 1) and only the tiers above it switch, so every switch stays an immediate upgrade. A switch the price rule enables toward a price in another currency than the paying price's answers none, reason `OtherCurrency` ("the target's price is in another currency than the paying subscription's"), because Stripe refuses a change of a subscription's currency; `switchOffer` answers it with the unavailable-plan sentence, as every other refusal of a paying subscription that is not suspended. The currency and interval tests read only an enabled move, so a disabled one keeps its reason and carries no `NewPeriod`. A switch toward a price of another recurring interval is offered as usual, with the move's `NewPeriod` set, which the confirmation reads (D12, "A price of another interval"). In the section 9 commit this item answers none, reason `PaysOnLadder`, and the section 11 commit makes it a switch, because the preview and the resolver reach a paying subscription only from section 10 and the switch clears the pause before its update only from section 11;
3. the target is the current product: none, reason `Current`;
4. any other target: none, reason `PaysOnLadder` ("a subscription pays on the ladder without holding it"). This includes every tier between a lower grant and the paid tier.
@@ -251,7 +260,7 @@ The card's states, read from the paying row and the mirror:
| Pause not confirmed, status `past_due` or `unpaid` | "{price}, payment due" (existing) | none | Pay invoice, then Cancel plan or Keep plan |
| Status `paused` (Stripe's own) | "{price}, paused" (existing) | "Entitlements are suspended." (existing) | none |
{granted} is the product name of the position the grant holds on the card's ladder; {price} is the subscription's own price, from the paying row (D9). M8 wins over M1 when both apply, as the existing current card keeps "moves to" over "payment due" (`member_plans.html:64-69`). The confirmed lines read the mirror, not the rule, as the operator's title does (D16): "Paused" while Stripe still charges would be false, and the card must match the badge (O3). The mirror records a pause whoever set it, so a pause made in the Dashboard also reads as paused, and Stripe collects nothing in either case. The unconfirmed lines are existing strings (M5, reused). Keep plan is offered while a cancellation (`cancel_at_period_end`) or a downgrade is scheduled, and Cancel plan unless a cancellation is scheduled, as `setCurrent` does (`member_plans.go:455-466`); the ladder heading's existing "cancels on {date}" badge carries a scheduled cancellation's date, read from the paying row. The cancelled paused card is M0, Keep plan and that badge, with no new string (decided with the maintainer, question 6). A grant of the subscription's own tier reads M0 with the granted name, which is the paid tier's: "Paused while Plus is granted" (question 8). `TierViewModel` gains `PaidUnderGrant bool`, `GrantedName string` and `CollectionPaused bool`, and in section 12 `CanResume bool`, `ResumeTitle string` and `ResumeBody string` (D20); `PaysInvoice()` also holds for a paid card whose status is `past_due` or `unpaid`.
{granted} is the product name of the position the grant holds on the card's ladder; {price} is the subscription's own price, from the paying row (D9). M8 wins over M1 when both apply, as the existing current card keeps "moves to" over "payment due" (`member_plans.html:64-69`). The confirmed lines read the mirror, not the rule, as the operator's title does (D16): "Paused" while Stripe still charges would be false, and the card must match the badge (O3). The mirror records a pause whoever set it, so a pause made in the Dashboard also reads as paused, and Stripe collects nothing in either case. The unconfirmed lines are existing strings (M5, reused). Keep plan is offered while a cancellation (`cancel_at_period_end`) or a downgrade is scheduled, and Cancel plan unless a cancellation is scheduled, as `setCurrent` does (`member_plans.go:455-466`); the ladder heading's existing "cancels on {date}" badge carries a scheduled cancellation's date, read from the paying row. The cancelled paused card is M0, Keep plan and that badge, with no new string (decided with the maintainer, question 6). A grant of the subscription's own tier reads M0 with the granted name, which is the paid tier's: "Paused while Plus is granted" (question 8). When that grant has an end date, M0, M1 and M8 there insert " until {date}" right after "is granted", {date} the grant's `valid_until` as D21 reads it: "Paused while Plus is granted until Dec 1, 2026" (question 16). Under a grant of another tier the three lines carry no date, because the granted tier's current card states it (M13). `TierViewModel` gains `PaidUnderGrant bool`, `GrantedName string` and `CollectionPaused bool`, and in section 12 `CanResume bool`, `ResumeTitle string` and `ResumeBody string` (D20); `PaysInvoice()` also holds for a paid card whose status is `past_due` or `unpaid`.
The cancel confirmation's body on the card is M2, "{paid} ends on {date}; {granted} stays granted.", when the grant is of another tier, and M9, "Your subscription ends on {date}; {granted} stays granted.", when the grant is of the subscription's own tier, where both tiers have one name; either is followed by the existing " The unpaid invoice stays due." when the status is `past_due` or `unpaid` (M7). {date} is the paying subscription's `current_period_end` in the card's date form.
@@ -265,34 +274,64 @@ Pick: (a), now that the switch is decided. `resolveLadderSubscription` becomes `
Cancel plan at the period end, as everywhere (decided with the maintainer): Stripe accepts `cancel_at_period_end` on the paused subscription. If the grant ends first, D7 delivers the subscription again until its period ends. The endpoint still accepts a posted immediate cancellation of an active paying subscription, which the UI never sends.
**Cancel plan voids an abandoned switch's invoice**, ruled with the paused switch's call form (D12) and listed in the proposal among the paused switch's restructuring outside the switch. A member who leaves a paused switch awaiting approval leaves a pending update and an open invoice on the subscription. Stripe keeps the pending update through `cancel_at_period_end=true`, and paying the invoice later, from Stripe's email or from the Billing page's Pay, applies the switch with the cancellation still set (the sandbox): the new tier would take the ladder and replace the grant on a plan the member chose to end, and M2's "{granted} stays granted." would be false. So `CancelSubscription`, for a subscription that pays without holding its ladder (`!ls.holdsLadder`), fetches it with `latest_invoice` expanded (the fetch at `plan_change.go:647` expands nothing today) and, when `PendingUpdate` is set and the latest invoice is open, voids that invoice before the schedule's release (`:651-655`) and before either timing's call, as `upgradeNow` voids an abandoned upgrade's invoice (`:300-308`); a failed void returns before any other write, and the member reads the generic failure. For such a subscription the expanded fetch and the void run before the commitment's deferral (`:635-641`), which returns before the fetch today, so a cancellation a commitment defers to its boundary voids as well. The latest invoice is the pending update's: a pending update expires after 23 hours or at the period end, whichever comes first (Stripe's documentation), so no renewal replaces its invoice as the latest while the update lives. The sandbox voided it on the paused subscription and then set the flag: the pending update was gone, the flag set, the pause kept. The scope is the subscription that pays without holding, because the void protects M2's promise, and M2 is made on every paid card under a grant, whether or not the pause is confirmed (D10); a holding subscription's cancellation keeps today's form, so a member who pays an abandoned upgrade of a plan set to end gets the higher tier for the rest of that period, as today. Keep plan needs no void: paying later applies a switch the member confirmed, on a plan the member kept.
### D12. The switch of a paused subscription (#278)
Offered by D9's rule on the granted tier and every tier above it, and above the paid tier. Under a grant ranked below the paid tier, the granted tier is not above the paid tier, so only the tiers above the paid tier offer the switch, and the paid tier's own card offers Resume plan (D20). On the granted tier's card, the current card, the control renders below its features with the switch's control markup, labelled with the existing "Upgrade" (M6, decided with the maintainer, question 2).
The Stripe update: `upgradeNow` (`plan_change.go:286-370`) gains `clearPause bool`, true when the subscription it reads first (`:292`) carries the console's pause or a stale marker, whatever holds the ladder. A subscription that holds its ladder is delivering, so clearing the pause there is correct; it stays paused at Stripe for about 15 seconds after an ending act, and for a day or more after a dead-lettered clear, and Stripe refuses its `always_invoice` update. The update then also sends `pause_collection=` (`AddExtra`) and `metadata[member_console_collection]=` (`AddMetadata`), with the existing `items`, `proration_behavior=always_invoice`, `proration_date` and `payment_behavior=pending_if_incomplete`. A foreign pause is not cleared; Stripe then refuses the update with 400, and the member reads the generic failure.
**The call form: two calls**, ruled after the sandbox and an adversarial check of the forms. Stripe refuses `pause_collection` beside `payment_behavior=pending_if_incomplete`, whatever the card (Context), so no single update can both clear the pause and keep the approval path. The switch of a subscription carrying the console's pause is therefore two calls: the console's clear, `collectionParams(collectionClear)` (`pause_collection=` and `metadata[member_console_collection]=`, `collection.go:88-101`), and then `upgradeNow`'s existing update with nothing changed in its form: `items`, `proration_behavior=always_invoice`, `proration_date`, `payment_behavior=pending_if_incomplete`, and `cancel_at_period_end=false` when the flag is set (`plan_change.go:310-328`). The two calls give the invoice the one update gives (Context), so the proration is still not voided, which was the reason the switch was first decided as one Stripe update; the ruling replaces the one update with two calls and keeps the proration. Rejected with the ruling:
- `error_if_incomplete` in one atomic update: Stripe refuses every payment that needs authentication under it (Stripe's documentation), and `upgradeNow` charges on-session (no `OffSession`, `:310-318`), so a member whose card needs 3-D Secure, common under PSD2 for a console deployed anywhere, could never switch away from the paused plan;
- `allow_incomplete` or `default_incomplete` in one update: Stripe applies the switch whatever the payment does, so a declined or unapproved payment moves the subscription to the new tier unpaid, `past_due` and unpaused, and D8 then lets the unpaid tier take the ladder (Context);
- `error_if_incomplete` first, with the two calls only for a payment that needs authentication: a second paused form and a classifier of Stripe's 402, which, reading an authentication refusal as a decline, would bring back the first option's harm, for the gain of a few unpaused seconds that the conditions below already make harmless.
Whether Stripe accepts `pause_collection` and `metadata` beside `payment_behavior=pending_if_incomplete` is unverified; the sandbox showed the one-update form without it. Item 11.1 checks it before the code. If Stripe refuses the combination, item 11.1 also tries `payment_behavior=error_if_incomplete`, which keeps one atomic update and loses the approval path for a payment that needs authentication, so a member whose card needs authentication could not switch from the paused plan at all. The other fallback is two calls, the clear alone and then the existing upgrade unchanged, which the sandbox showed gives the same invoice and which departs from "one Stripe update". Each fallback changes what a member can do, so if item 11.1(a) is refused, both fallbacks, `error_if_incomplete` and the two-call form, go to the maintainer before item 11.2, not only to the commit message. In the two-call form, a decline or a payment awaiting approval leaves the subscription unpaused while the grant holds. No reconcile follows `ErrUpgradeDeclined` (`plan_change.go:348`), so a declined or approval-pending upgrade calls `ReconcileSubscription` itself, which requests the pause again (D1); collection is paused again within seconds, and a renewal finalized in that window is charged.
**The scope.** `upgradeNow` takes the paused form only when the subscription it fetches first (`:296`) carries the console's pause: `pause_collection` set and the marker present (`consolePaused`), whatever holds the ladder. A subscription that holds its ladder while Stripe still carries the pause (F43) is delivering, so clearing there is right; Stripe refuses its `always_invoice` update until the pause is cleared, and the restore below leaves it unpaused. A stale marker with no pause takes today's one call: Stripe does not refuse the update there, and the marker is the reconcile's to clear (D2). A foreign pause takes today's one call, which Stripe refuses with 400 while paused (Context); the member reads the generic failure, and the pause stays as its maker left it. A subscription with no pause takes today's one call. This narrows the earlier `clearPause`, which also cleared a stale marker.
**The order** of the paused form, after the fetch at `:296-299`, which keeps today's form:
1. the proration moment: a `prorationDate` before `current.CurrentPeriodStart` returns `ErrStalePreview` before any write, so the member sees the confirmation again with fresh figures, where Stripe would refuse call 2 after the clear (Context; F75); a check of the fetched subscription, with no call;
2. the finalization guard (below), a read; it refuses before any write;
3. the backstop entry (below), one insert committed before any Stripe write; a failed insert refuses;
4. the void of an abandoned upgrade's invoice, as today (`:300-308`), on the still-paused subscription, where Stripe accepts it and drops the pending update (Context);
5. call 1, the clear; from here a deferred step runs the restore on every exit but an applied update;
6. call 2, the existing update;
7. its answer, read as today (`:334-353`): applied (no error and `PendingUpdate` nil), the existing applied branch (`:355-373`) under `context.WithoutCancel(ctx)`, and no restore; a pending update awaiting approval, `UpgradeNeedsApprovalError`; any other pending update, the void of its invoice and then `ErrUpgradeDeclined`; an error, returned wrapped.
**The restore** is the reconcile's own decision, never a blind set: `ApplySubscriptionCollection(restoreCtx, db, logger, keyMode, ls.coreSubID, "member:switch:restore")`, with `restoreCtx` = `context.WithTimeout(context.WithoutCancel(ctx), pausedSwitchRestoreTimeout)`. It runs on every way out after call 1 is sent except Stripe answering "applied": an error from call 1, which Stripe may have applied anyway; every error from call 2 (a 5xx, a 429, a deadline, a 4xx); the decline, after its void; the approval; and a panic, since the step is deferred. It decides from a fetch taken after call 2, under the pool row, calls Stripe only when the rule disagrees, and decides once more after its own call (`collection.go:146-154`). So it pauses a subscription that does not hold its ladder again, leaves a holding one (F43) unpaused, and stays right when a revocation, an expiry or a Resume commits meanwhile (`reconcile.go:85` fetches before the locks, and the set-to-state decision converges on its second run), and when call 2 applied despite its error, since the rule reads Stripe's items and D8 then lets the new tier take the ladder. Stripe accepts the set while the update is pending, and paying the approval later applies the update with the pause set (Context). The restore's error is logged (L7) and never returned: the member reads call 2's answer, the decline, the approval link or the generic failure (D1). `PostSwitch` passes `r.Context()` (`member_plan_moves.go:220`), so without `context.WithoutCancel` a closed tab would cancel the restore's database steps; when a set fails, the restore writes no outbox entry (with `applyCollection`, the reconcile enqueues nothing), which is why the clear's echo and the backstop carry the retry.
**The backstop entry.** Before any Stripe write, the paused form writes one `reconcile_subscription` entry for the subscription with `next_attempt_at` = now + `pausedSwitchBackstopDelay` (4 minutes), one insert on `db` that commits on its own (`entitlements.RequestSubscriptionReconcileAt`, over a new `integration.EnqueueAt`, since `integration.Enqueue` takes no time, `internal/integration/outbox.go:42-54`). The relay's poll gains `AND next_attempt_at <= NOW()` (`PollPendingOutboxEntries`, `internal/integrations/stripe/workflows/outbox.go:202-225`, listed in the proposal among the paused switch's restructuring outside the switch; the `stripe-integration-infrastructure` delta states the rule): the column exists with default `NOW()` and an index (`internal/db/migrations/00001_init.sql:1091`, `:1098-1100`), and the only other writer sets `NOW()` (`internal/integrations/stripe/catalogsync/catalogsync.go:145`), so every other entry is polled as before; the boot sweep takes only `failed` and `processing` rows. The delay is longer than the request's bounded length from the insert (the void 20 s, the clear 20 s, call 2 60 s, the decline's void 20 s, the restore 60 s: three minutes (the restore's calls are bounded through #285)), so the entry never pauses the subscription between the two calls. What it covers: when the webhook endpoint works, call 1's `customer.subscription.updated` already runs a reconcile that writes an immediate entry (`Enqueue` does no dedupe), and that heals a failed restore within the relay's 15-second poll; the backstop adds a crash between the calls, or a failed restore, while that echo is lost (an endpoint down, a wrong signing secret, a dropped event), a case the drift sweep cannot see, because no reconcile ran after call 1 and the mirror still reads paused (`collectionDrifts`, `collection_sweep.go:96-101`). With Temporal down it waits pending like every other entry, the echo's included (F41). After an applied switch it runs a reconcile that finds agreement. While it waits, the sweep's recent-entry read counts it (D5), and the queue counts on the Overview and the Stripe provider page show it as pending.
**The finalization guard.** A renewal made while paused is a draft for about an hour, and Stripe reads the pause at its finalization: voided while paused, charged in full when not (Context). Before any write, the paused form lists the subscription's drafts (`invoice.List` with `Subscription`, `Status: "draft"` and a limit of 10; the Invoices read the key already holds, `docs/stripe.md:131`) and refuses with `ErrRenewalFinalizing` when one has `billing_reason=subscription_cycle`, `auto_advance=true`, and the later of `next_payment_attempt` and `automatically_finalizes_at` at or before now + `renewalFinalizeMargin` (10 minutes), a time already passed included, `now` taken after the list answers. The later of the two is read so that a field Stripe leaves in the past while it holds a finalization does not refuse on its own; the cost is a miss when Stripe finalizes at the earlier field while the later lies beyond the margin (F66). A time Stripe leaves null is not read; a renewal draft with both null refuses, as one past its time does. A failed list refuses too, as a failed fetch does (`:296-299`). The handler's default answers both with the existing "We couldn't complete that change. Try again in a moment." (`member_plan_moves.go:351-352`). The sandbox read such a draft with the console's key, both fields its creation plus an hour, and a clear and a re-pause inside the hour left that renewal voided (Context). The margin covers the latest moment the pause can be back when the restore fails: the backstop's 4 minutes, the relay's 15-second poll and one outbox attempt (`outboxAttemptTimeout`, 2 minutes, `outbox.go:69`, which bounds that attempt's Stripe calls through #285). Its cost: the paused switch refuses for about the last ten minutes of each paused renewal's draft hour, once per period. In live mode, Stripe documents that it holds a subscription invoice's finalization until the endpoint's `invoice.created` deliveries succeed, for up to 72 hours (not checked in the sandbox; the endpoint subscribes to `invoice.created`, `docs/stripe.md:116`); while those deliveries fail, a draft past its time stays a draft, and the guard refuses every paused switch with "Try again in a moment." for as long. That is safe for money, and its sentence is false for that time.
**The deadlines.** Each Stripe call of the paused form from the guard's list to the restore's last call carries `Params.Context` = `context.WithTimeout(context.WithoutCancel(ctx), d)`: `pausedSwitchCallTimeout` (20 seconds) for the list, the two voids and the clear, and `pausedSwitchChargeTimeout` (60 seconds) for call 2, longer because a deadline that cuts a slow but successful charge answers the generic failure after the member was charged (the restore's reconcile then confers the new tier, D8). Today no call in `upgradeNow` carries a context, and stripe-go's defaults, an 80-second timeout and two retries, can hold one call about four minutes. The restore's calls are bounded by `restoreCtx` once the reconcile's Stripe calls take the reconcile's context: `fetchSubscription` (`reconcile.go:229`), `applyCollectionAction` (`collection.go:109`) and the release in `releaseSpentSchedule` (`reconcile.go:1289`) carry no context today, and with it a reconcile's Stripe calls end when its context does, for every caller (#285, which lands before section 11). Every ambiguous answer a deadline makes is resolved by the restore's fresh fetch. The fetch at `:296` keeps today's form, since it comes before the guard and its time adds nothing to the window. The server's 8-second write timeout (`internal/server/server.go:387`) is shorter than the 32-second request timeout (`:786`) that these deadlines are fitted to: a paused switch whose steps outlast eight seconds still restores and converges, but the member's answer is lost (F83; #290). The applied branch's steps follow an update after which the rule wants no pause (D8), or, under a higher grant that committed meanwhile (F77), write the entry that sets it, with the backstop behind it, so they run under `context.WithoutCancel(ctx)` with today's forms and no deadline.
**The unpaused window** runs from call 1 to the restore's set: a few seconds (4.76 seconds through the CLI in the sandbox, three round trips), at most about three minutes under the deadlines, and up to the backstop's run when the restore fails. Every non-applied outcome the sandbox ran ends paused on the old tier with nothing charged: a decline in the code's order (the clear, the update, the void of its invoice, the reconcile's set), an approval left unpaid until its pending update expired, a SEPA Direct Debit default, which Stripe refuses before any change, and a `proration_date` from before the period refused by Stripe (Context), which the order's first step now answers before any write. A next attempt after an abandoned approval voids the old invoice while paused and then applies once, never with two pending updates. A crash after Stripe applied call 2 is converged by any later reconcile (D8, F34). The costs the sandbox did not remove are D14's and the failure table's (F63 on).
After the update applies, `upgradeNow` reconciles as today: the item now names the target, D8's exception lets it be conferred, `core.confer` supersedes the grant's position (an upgrade, or a transfer at the granted tier), and the pause is already cleared, so the decision finds agreement. The grant is replaced: `core.grant_replacements` pairs it with the subscription, so a grant marked to resume delivers again when the subscription ends, while its own term lasts, and an unmarked one delivers no more.
Money: Stripe's proration of the update. It credits the old price's unused time although that period's renewal was voided, so the member pays the price difference for the rest of the period, not the new price. D14's fifth cost, accepted with the maintainer until #280 (question 9).
Money: Stripe's proration of the update. It credits the old price's unused time although that period's renewal was voided, so the member pays the price difference for the rest of the period, not the new price. Toward a price of another interval, Stripe charges the new price's full period less that same credit (F78). D14's fifth cost, accepted with the maintainer until #280 (question 9).
The preview (`PreviewSwitch`, `:582-596`) previews the paused subscription with `always_invoice`, and the switch sends that preview's `proration_date` back. Whether Stripe answers it for a paused subscription, and whether its `amount_due` equals the total of the one-update switch's proration invoice on the same clock (200 in the sandbox), are unverified; item 11.1(c) records both, and its result fixes the preview's form at build time, because nothing at run time can compare a preview with an update that has not happened. If Stripe refuses the preview or its figure differs from the update's, `PreviewSwitch` returns the unavailable preview for a subscription carrying the console's pause, read from the resolved row's mirror (`ls.sub.PauseCollectionBehavior` set and `ls.sub.PauseCollectionMarked`, which `GetSubscriptionByID` carries from section 2) because `PreviewSwitch` fetches no subscription; the confirmation states that preview with the existing fallback sentence, so the member never reads a charge the update does not make. If the figures are equal, `PreviewSwitch` returns Stripe's figure, and a refusal by Stripe at run time still gives the unavailable preview. The confirmation's message gains one sentence: `planMoveMessage` takes `replacesGrant bool`, and with it the second paragraph opens "Your subscription replaces the grant." before the charge sentence.
The preview: Stripe answers no proration preview for a paused subscription, a foreign pause included (Context), so `PreviewSwitch` (`plan_change.go:542-605`) returns the unavailable preview, with no Stripe call, for a subscription whose row records any pause (`ls.sub.PauseCollectionBehavior` set, which `GetSubscriptionByID` carries since section 2), read from the row because `PreviewSwitch` fetches no subscription. The unavailable preview keeps its `ProrationDate` (`:580-581`), so the switch never answers `ErrStalePreview` for a missing moment; it answers it for a moment older than `PreviewValidFor` (`:292-294`), as today, and for one from before the current period (the order's first step, F75). The confirmation states that preview with the existing fallback sentence, "Your card is charged the prorated difference now, then {price}.", which the two calls make true for a target price of the paying price's interval: the charge is the proration of the old and the new price over the rest of the period (Context). Toward a price of another interval the sentence is false, and the confirmation reads M12 there (below). A paying subscription whose row records no pause yet gets Stripe's figure, which on an unpaused subscription equals the switch's invoice (Context), and Stripe's refusal, when the pause landed after the row was read, still gives the unavailable preview. The confirmation's message gains one sentence: `planMoveMessage` takes `replacesGrant bool`, and with it the second paragraph opens "Your subscription replaces the grant." before the charge sentence. Both facts the confirmation reads are the decision's: rule 3a item 2's Switch sets the move's `ReplacesGrant` and, toward a price of another interval, its `NewPeriod` (D9), and `switchOffer` returns the move it allowed, so the preview and the switch's re-rendered confirmation read them with no read of their own.
**A price of another interval**, ruled after the sandbox's monthly-to-yearly run. The paused switch toward a price of another recurring interval is offered as usual: rule 3a item 2 applies the price rule, which checks no interval (`priceRule`, `internal/plans/offer.go:359-375`). The sandbox switched a $30-a-month subscription to a $300-a-year price a third of a day into its period: Stripe charged $270.32, a full year less $29.68 for the unused month, moved the billing anchor to the moment of the switch, and set the next period's end a year later (Context). Stripe answers no proration preview for a paused subscription, so the console cannot show that figure, and the fallback "Your card is charged the prorated difference now, then {price}." would understate it. When the target price's interval is not the paying price's, the move's `NewPeriod` is set (D9), and the confirmation's charge sentence is M12, "Your card is charged {target price} now, minus a credit for the unused part of your current plan, then {then} from today.", {target price} being the target's own amount and {then} the target's recurring price as `recurringPhrase` words it ("$300.00 a year"). The sentence states what the console knows: the new price's full period is charged now, less the unused time, and the new period starts at the switch. M12 replaces the charge sentence whatever the preview answered, because Stripe's figure, which a subscription whose row records no pause yet gets, would be followed by "for the rest of this period", which is false when the period restarts. From a yearly price to a monthly one, Stripe would credit the unused part of the year beyond the month's price, charging nothing now and leaving a credit balance on the customer (an inference; not run in the sandbox); M12 names the same two terms there, the month's price and a credit that then exceeds it. A target whose price is in another currency is never offered (`OtherCurrency`, D9): Stripe refuses a change of a subscription's currency (Stripe's documentation; not run in the sandbox), so the control could only fail, after the restore paused the subscription again. A subscription that holds its ladder while Stripe still carries the console's pause (F43) is not rule 3a's: its decision carries no paying price, so its confirmation toward a price of another interval reads the fallback sentence, which understates the charge, from the ending act to the outbox's clear, or after a dead-lettered clear (F80). It is accepted with today's upgrade toward another interval, whose sentence says "for the rest of this period" (outside this change). Rejected with the ruling:
- offering only the targets priced at the paying price's interval: a move would go missing with no explanation, and a member on a monthly price could reach a yearly tier only by cancelling and buying after the grant ends;
- the fallback sentence for every interval: it would understate the charge.
### D13. Every Stripe write on a subscription the console may have paused
Stripe refuses an `always_invoice` update on a paused subscription unless the update clears the pause. Each write the console makes on a subscription, against a subscription paused by the console:
Stripe refuses an `always_invoice` update on a paused subscription until the pause is cleared, and refuses the clear inside an update sent with `pending_if_incomplete`, so the console clears first (D12). Each write the console makes on a subscription, against a subscription paused by the console:
| Write | Reachable on a paused subscription | What Stripe does | What the console does |
|---|---|---|---|
| The pause and the clear (D2) | yes | checked: accepted, one echo each | D1 |
| Resume plan (D20) | yes | none of its own: the outbox's clear (D2) follows its commit; the clear changes no item, so Stripe makes no invoice and no proration | D20 |
| The paused switch's update (D12) | yes | checked without `pending_if_incomplete`; with it, unverified (item 11.1) | clears in the same update |
| An ordinary upgrade (`upgradeNow`, `always_invoice`) | yes, for a subscription that holds its ladder while Stripe still carries the console's pause: between the ending act and the outbox's clear, and after a dead-lettered clear | refused with 400 unless the same update clears the pause (S13) | clears in the same update (D12's `clearPause` is read from the fetched subscription); a foreign pause on a holding subscription refuses its upgrade; generic failure, as today |
| Void of an abandoned upgrade's invoice | only from D12's path, before the update | an invoice void, not a subscription update | unchanged |
| The paused switch (D12) | yes | checked: one update carrying `pause_collection` beside `pending_if_incomplete` is refused (400); the clear and then the unchanged update give the one update's invoice; on a schedule-managed subscription both apply and the schedule releases | two calls, the clear and then the existing update, with the guard, the backstop entry, the restore and the deadlines |
| An ordinary upgrade (`upgradeNow`, `always_invoice`) | yes, for a subscription that holds its ladder while Stripe still carries the console's pause: between the ending act and the outbox's clear, and after a dead-lettered clear | refused with 400 unless the pause is cleared first (Context) | D12's paused form, chosen from the fetched subscription; the restore's reconcile leaves a holding subscription unpaused; a foreign pause on a holding subscription refuses its upgrade; generic failure, as today |
| Void of an abandoned upgrade's invoice | from D12's path, before the clear, also on a subscription paused again after an abandoned approval | an invoice void, not a subscription update; checked on a paused subscription: accepted, and the pending update dropped | unchanged; under a deadline in the paused form |
| The finalization guard's draft list (D12) | yes | a read with the Invoices read the key holds; checked with the console's key: it lists a paused renewal's draft with both finalization times | refuses near a finalization |
| The restore's set (D12) | yes, after a paused switch Stripe did not apply | D2's set, made by the reconcile in the request; checked: accepted while an update is pending | `ApplySubscriptionCollection` under `context.WithoutCancel` |
| Downgrade schedule create, update, release (`downgradeAtRenewal`) | yes, for a subscription that holds its ladder while Stripe still carries the pause (between the ending act and the clear, and after a dead-lettered clear); D9 offers no move below the paid tier from a subscription that pays without holding | checked: accepted, and a schedule's phases and release keep the pause | unchanged |
| `cancel_at_period_end=false` before a downgrade | yes, in the same window | creates no invoice; unverified | unchanged |
| Cancel plan: release, then `cancel_at_period_end=true` | yes | checked: both keep the pause | D11 |
| Cancel plan: the void of an abandoned switch's invoice, the release, then `cancel_at_period_end=true` | yes | checked: the void drops the pending update, and the release and the flag keep the pause; without the void, Stripe keeps the pending update, and paying its invoice applies the switch with the cancellation set | D11 |
| Keep plan: release, `cancel_at_period_end=false` | yes | the release is checked; the flag's clear creates no invoice and is unverified | D11 |
| Immediate cancellation (`subscription.Cancel`, no invoice, no proration) | posted only; the UI never sends it | unverified on a paused subscription | as for its status (D11) |
| The sweeper's period-end cancellation (`fireCancellation`, `sweep.go:162-178`) | yes: a member's period-end `cancellation` row fires when the hourly sweep reaches it before the deleted webhook closes it out, on a paused subscription too | Stripe has already canceled the subscription at the period end, so the call is refused | the refusal is logged; unchanged |
@@ -308,11 +347,17 @@ Decided with the maintainer, for this change: none moves. The renewal paid befor
- the deciding instant is a renewal's finalization, about an hour after the renewal: a pause landing after it lets that renewal be charged, a clear landing before it lets a renewal made while paused be charged;
- a pause or clear call that lands late, past that instant, moves a whole period one way or the other, and a void cannot be undone;
- the first charge after a grant comes without Stripe's upcoming-renewal reminder when the clear lands inside the account's reminder window;
- 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 (D12; question 9).
- 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, or, toward a price of another interval, the new period less that credit (D12; question 9).
The paused switch's two calls (D12) add these costs, which the sandbox did not remove, accepted with the ruling on its call form:
- a renewal Stripe finalizes inside the unpaused window despite the guard, because Stripe finalizes it earlier than the later time the guard read, or because the restore fails and the backstop's run is late (a Stripe outage longer than the deadlines, or Temporal down, F41), on a declined or approval-pending attempt, is charged in full for a period the grant covers, or, on a declining card, fails and opens dunning (F66);
- a switch applied inside a paused renewal's draft hour also pays that renewal at the old price in full at its finalization, on top of the proration: about the new price for the period, closer to exact than outside the hour, with the second charge unannounced; when that off-session charge fails, the subscription that has just replaced the grant goes past due and its tier is suspended (F69). Every call form shares it, since an applied switch always clears the pause;
- an approval paid after the operator changed what holds the ladder or how the tiers rank applies the switch and takes the ladder only when the new tier ranks at or above the holder then (D8); otherwise the proration buys nothing until that holder ends (F71); any member of the organization can pay the abandoned approval's invoice from the Billing page without reading M3, and the grant is then replaced (F72); a lost apply event delays the paid tier up to one period (F73);
- 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, as for F43 and F47 (F74).
Resume plan (D20) meets the end edge's cost at a moment the member picks: the rest of the period whose renewal was voided is free, up to one whole period, nearly a year on an annual price. This is accepted until #280, as for the grant's end. Nothing is charged at the resume, the time between the grant's start and the resume is not credited, as at the grant's end, and its clear meets the second and third costs like any clear.
`docs/stripe.md`, in "Plan changes", states these in the bullet **A grant over a paid plan.** (task 4.6; the switch's cost is added with the switch, task 11.5, and Resume's with Resume, task 12.9).
`docs/stripe.md`, in "Plan changes", states these in the bullet **A grant over a paid plan.** (task 4.6; the switch's costs, the fifth and the paused switch's four, are added with the switch, task 11.5, and Resume's with Resume, task 12.9).
### D15. Subscription schedules
@@ -359,7 +404,7 @@ Options for where it runs and its order against the commit:
Pick: (a).
Reason: the switch calls Stripe in the request because its update changes the item and charges a proration, and Stripe refuses that update on a paused subscription unless the same update clears the pause (D12). Resume changes no item and charges nothing, so the only Stripe change it needs is D2's clear, which the sandbox showed makes no invoice and no proration, and which the outbox already makes with its retries, its dead letter and the drift sweep behind it. Under (a) the conferral and the request commit together: a crash after the commit loses nothing, and the decision that clears the pause reads the conferral. Under (b), a crash or a refused conferral after Stripe's clear leaves the grant holding and the subscription unpaused; its echo pauses it again, and the member's act is lost. Under (c), a failed clear has no retry until the next event, and the member's answer would report a pause failure, which D1 keeps out of every member move.
Reason: the switch calls Stripe in the request because its update changes the item and charges a proration, and Stripe refuses that update on a paused subscription until the pause is cleared (D12). Resume changes no item and charges nothing, so the only Stripe change it needs is D2's clear, which the sandbox showed makes no invoice and no proration, and which the outbox already makes with its retries, its dead letter and the drift sweep behind it. Under (a) the conferral and the request commit together: a crash after the commit loses nothing, and the decision that clears the pause reads the conferral. Under (b), a crash or a refused conferral after Stripe's clear leaves the grant holding and the subscription unpaused; its echo pauses it again, and the member's act is lost. Under (c), a failed clear has no retry until the next event, and the member's answer would report a pause failure, which D1 keeps out of every member move but the paused switch's clear (D12).
**The decision.** D9's rule 3a, item 1: the target is the paying product, and the answer is Resume, enabled, relation `Upgrade`, when all of these hold; otherwise none, reason `PaidHere`:
- `GrantHolds`, and the held rung ranks below `Paying.Rank`;
@@ -402,7 +447,31 @@ No price rule applies: Resume keeps the subscription's own item and price, as Ke
**Locks.** The act takes the rendezvous, then the pool row, and no subscription advisory lock, for D7's reason: every position write is under the pool row, and the subscription lock protects the subscription's own rows, which the act only reads. A reconcile of the subscription waiting on the pool row then finds the conferred provision live and its product desired, and writes nothing; one that changed the status to `canceled` ends the provision in its ended branch, as for any live provision. No path takes a pool row and then a subscription's advisory lock (`docs/database-locks.md`).
**What follows.** The outbox's reconcile finds the subscription holding its ladder, the rule not wanting a pause, and the console's pause at Stripe, and clears `pause_collection` and the marker in one update (D2). The next renewal is charged at the subscription's price; the rest of the period whose renewal was voided is free, accepted until #280, as for the grant's end. A grant marked to resume delivers again when the subscription ends while the grant's own term lasts (`grant-resumption`), and an unmarked one delivers no more, as after a switch (D12). The transition's reason renders raw in the Tier changes list, as the switch's `member:switch` does. Between the commit and the clear, about 15 seconds, the subscription delivers while Stripe still pauses it: a renewal finalized in that window is voided, D14's third cost, and an upgrade in it clears the pause in its own update (D13).
**What follows.** The outbox's reconcile finds the subscription holding its ladder, the rule not wanting a pause, and the console's pause at Stripe, and clears `pause_collection` and the marker in one update (D2). The next renewal is charged at the subscription's price; the rest of the period whose renewal was voided is free, accepted until #280, as for the grant's end. A grant marked to resume delivers again when the subscription ends while the grant's own term lasts (`grant-resumption`), and an unmarked one delivers no more, as after a switch (D12). The transition's reason renders raw in the Tier changes list, as the switch's `member:switch` does. Between the commit and the clear, about 15 seconds, the subscription delivers while Stripe still pauses it: a renewal finalized in that window is voided, D14's third cost, and an upgrade in it clears the pause before its update (D12, D13).
### D21. The current card a grant holds states the grant's term
A small fix that rides along, ruled after D10 and D20 and listed in the proposal among the small fixes that ride along. Section 12 builds it, because Resume plan reworks the same cards.
**Today.** The Products page's current card states what the organization is billed, read from the subscription behind the position (`resolveCurrentPlanBilling`, `internal/server/member_plans.go:815`). A position a grant holds has no subscription behind it, so `setCurrent` returns at `:502-504` with no line and no control, and the template's chain for a current card (`internal/embeds/templates/partials/member_plans.html:78-96`) draws nothing. That card draws no cost line and no control, and the member never learns from it when the grant ends. Under this change it sits beside the paused paid card (D10), and the grant's end is when the paused plan is charged again (D2).
**The rule.** The current card whose position a grant holds reads, on its price line, "Granted until {date}" (Copy M13) when the grant has an end date, and "Granted" when it has none. "Granted" is the word the held rung already reads (`grant-resumption` D10). The rule applies to every current card a grant holds, beside a subscription that pays on the ladder or not; the current card that is also the paid plan's card carries the date in its own line (below). It does not apply to:
- a position the organization type's default holds: a default position is a grant row with `grant_reason = 'default'`, and `default_sourced` keeps it from reading as a grant, as it does for `GrantHolds` (D9). That current card, which also draws no cost line, is out of this change: forge issue #289 ("The current plan's card states no cost when the organization type's default plan holds it").
- a position a subscription or a purchase holds.
**The paid plan's card under a grant of its own tier** (question 16). Under a grant of the subscription's own tier the current card is also the paid plan's card (D10, question 8): `setCurrent` returns through `setPaidUnderGrant` at `:492-495`, and the card's one cost line is the paid card's. When the holding grant has an end date, M0, M1 and M8 insert " until {date}" right after "is granted": "Paused while Plus is granted until Dec 1, 2026", "Paused while Plus is granted until Dec 1, 2026, payment due", and "Paused while Plus is granted until Dec 1, 2026, moves to Basic on Nov 15, 2026". {date} is the `valid_until` read below, in the same form. The lines that do not read "Paused" are unchanged: the unconfirmed lines (M5), Stripe's own "{price}, paused" (M4), and the line of a subscription whose period is not known yet. On a page where a grant holds a tier above the paid one, the granted current card reads M13 and the paid card's M0, M1 and M8 are unchanged, since M13 states the date on the same ladder. Rejected: M0 with no date, which leaves no card on the page that names the grant's end, when the paused plan is charged again.
**Which grant, and how the loader reaches it.** The date is the `valid_until` of the grant whose provision holds the current rung's position. The page's positions are `ListLadderPositionsByPool`'s rows (`internal/entitlements/queries/pool_provision_ladders.sql:187-247`), read once per request by the loader (`positions`, `internal/server/plan_offer.go:66-79`), kept by ladder (`held := b.positions[...]`, `member_plans.go:380`) and handed to `setCurrent` as `ladderFacts.Position` (`plan_offer.go:187`, set at `:259`). Each row carries the holding provision's `grant_id` (`p.grant_id`, `pool_provision_ladders.sql:219`) and `default_sourced` (`:217`), and not the grant's `valid_until` (`core.grants`, `internal/db/migrations/00001_init.sql:713`). The query gains `LEFT JOIN core.grants g ON g.grant_id = p.grant_id` and the column `g.valid_until AS grant_valid_until`, so the row the card already reads carries the date and the page makes no statement more. An extension through Manage confers a new grant row, which supersedes the extended grant's position as a transfer (`ExtendGrantTx`, `internal/entitlements/grant_acts.go:100-108`), so the holding provision names the extension and the card reads the extension's end date. A bundle grant gives each ladder it holds the same date, from its one row.
**The date's form** is the card's, `cardDateLayout` ("Jan 2, 2006", `internal/server/member_plans_paid_under_grant.go:28`), the form of the period end (`member_plans.go:828`).
**A grant past its end date whose position has not ended.** `ExpireGrantTx` (`grant_acts.go:355`) ends the position when the grant's expiration workflow fires; the workflow is the only enforcement, and a failed schedule is reported to the operator, and no sweep repairs it (`internal/server/operator_enrollment.go:933-948`). Between `valid_until` and the expiry, because the workflow is late (Temporal down) or was never scheduled, the card keeps reading "Granted until {date}" with the passed date (the paid card under a grant of its own tier, its paused line with that date), and the page compares the date with no clock. Reason: the date is the grant's recorded term and stays true of it; "Granted" alone would say the grant has no end date, which is false; drawing no line would bring back the card this decision fixes. The grant still delivers until the expiry ends it, as the Entitlements panel shows (F81).
**Reason.** The card tells the member when the grant ends, which is when a paused paid plan is charged again (D2) and when the granted tier stops being delivered. Rejected: "Granted" alone, from which the member never learns when the grant ends; and the card as it was, with no cost line and nothing to say why it has no control.
**Shape.** `TierViewModel` gains `Granted bool` and `GrantedUntil string`. `(t *TierViewModel) setGranted(pos entitlements.ListLadderPositionsByPoolRow)`, reading no row and no clock, sets `Granted` when `pos.GrantID` is set and `pos.DefaultSourced` is false, and `GrantedUntil` when `pos.GrantValidUntil` is set as well. `setCurrent` calls it on `facts.Position` before the paid card's return (`:492-495`), so the paid card under a grant of its own tier carries the date; `buildTier` does not call it for a card that is not current (`:470-472`), so the paid card under a grant of another tier carries none. The template's current-card chain draws, after its existing branches, `Granted until {{ .GrantedUntil }}` or `Granted`, and its three paused branches (`member_plans.html:80-85`) draw ` until {{ .GrantedUntil }}` right after "is granted" when `GrantedUntil` is set. The paid card's branches come first in the chain, so `Granted` on that card draws no second line. No new lock, invariant or statement.
**Failure.** The date rides the positions read, whose failure already answers the page's load error, "Failed to load plans" (`member_plans.go:310-312`, `plansLoadFailed` at `:354-358`). No card states a grant's end date the page failed to read (F82).
## Failure table
@@ -440,9 +509,9 @@ No price rule applies: Resume keeps the subscription's own item and price, as Ke
| F28 | The member cancels the paused S | Refused (no subscription) | `cancel_at_period_end` with the pause kept; ends at the period end with no invoice; G keeps the ladder | D11 |
| F29 | G ends before S's cancellation date | n/a | S delivered again until its period end, then ends; M2 said G stays granted | D7, D11 |
| F30 | The member keeps the cancelled paused S | n/a | The flag cleared, the pause kept | D11 |
| F31 | The member switches the paused S to G's tier or above | Checkout sold a second subscription above G | One update: item, clear, marker; proration charged now; S takes the ladder; G replaced | D12, D8 |
| F32 | The paused switch is declined | n/a | Nothing applied; S stays paused (one-update form) or is paused again within seconds (two-call form, where the declined upgrade calls `ReconcileSubscription` itself) | D12 |
| F33 | The paused switch needs approval | n/a | As for an ordinary upgrade; the pending update expires after 23 hours | D12 |
| F31 | The member switches the paused S to G's tier or above | Checkout sold a second subscription above G | Two calls: the clear, then the unchanged update; the proration charged now, the invoice one update would make; S takes the ladder; G replaced | D12, D8 |
| F32 | The paused switch is declined | n/a | The update's invoice is voided, which drops the pending update; the restore's reconcile pauses S again within seconds; nothing charged, and the next renewal is voided (the sandbox) | D12 |
| F33 | The paused switch needs approval | n/a | The approval link, as for an ordinary upgrade; the restore pauses S again while the update is pending; paying the invoice applies the update with the pause set, and the echo's reconcile confers the target (D8) and writes the clear's entry; left unpaid, the update expires after 23 hours or at the period end and Stripe voids its invoice, nothing charged (the sandbox) | D12, D8 |
| F34 | A crash after Stripe applied the switch, before the console's reconcile | n/a | Any later reconcile confers the target over G | D8 |
| F35 | Checkout on a ladder S pays for, any tier | Sold | Refused with 422 before Stripe | D9 |
| F36 | A bundle's vacated ladder | Checkout sold | Refused; the paid tier shows its price and no control; #271 unchanged | D9, D10 |
@@ -450,9 +519,9 @@ No price rule applies: Resume keeps the subscription's own item and price, as Ke
| F38 | A grant of S's own tier (a transfer) | S bills | Paused; the current card is also the paid card; tiers above it offer the switch | D10 |
| F39 | A grant ranked below S's tier | S bills | Paused; S's card offers Resume plan; the tiers above S's tier offer the switch; the tiers between G's and S's offer nothing | D9, D20 |
| F40 | Pay invoice on the paused card | 404 (no subscription behind the position) | The open invoice's hosted page | D10 |
| F41 | Temporal down | n/a | Entries wait `pending`; the boot sweep and the relay pick them up; the delivery in the ending act needs no Temporal | D1, D7 |
| F41 | Temporal down | n/a | Entries wait `pending`, the paused switch's backstop entry among them; the boot sweep and the relay pick them up; the delivery in the ending act needs no Temporal | D1, D7 |
| F42 | The old binary after a rollback | n/a | Pending entries complete without acting; paused subscriptions stay paused (Rollback) | |
| F43 | S holds its ladder again, Stripe still has it paused (between the ending act and the clear, or after a dead-lettered clear) | n/a | An upgrade clears the pause in the same update; a downgrade is accepted; renewals finalized meanwhile are voided (D14's late-call cost) | D12, D13 |
| F43 | S holds its ladder again, Stripe still has it paused (between the ending act and the clear, or after a dead-lettered clear) | n/a | An upgrade takes the paused form: the clear, then its update, and on a non-applied outcome the restore's reconcile leaves S unpaused, since S holds its ladder; a downgrade is accepted; renewals finalized meanwhile are voided (D14's late-call cost) | D12, D13 |
| F44 | A tier reorder, a tier add or a product shape edit flips whether S yields | n/a | S keeps its state until the sweep's reconcile, within the hour | D4 |
| F45 | A switch supersedes a bundle grant, leaving the bundle's other ladder vacant with another subscription displaced there paused | n/a | That subscription stays paused until the sweep, within the hour; the switch inherits #88's whole-bundle exception | D4, D8 |
| F46 | The member resumes S under a lower G | n/a | One transaction under the pool row confers S's tier over G (an upgrade), pairs G with S as replaced, and writes the requests; the outbox's reconcile clears the pause with no invoice and no proration; the next renewal is charged at S's price | D20 |
@@ -472,6 +541,27 @@ No price rule applies: Resume keeps the subscription's own item and price, as Ke
| F60 | The member resumes S under a bundle grant G (G's product is a tier of two ladders) | n/a | G's whole provision is superseded (#88); the act runs no floor, so the default does not return there; the act's own requests reconcile every live subscription, so another subscription displaced on that ladder delivers there and is unpaused within seconds | D4, D20 |
| F61 | The member cancels S in one tab while a resume commits in another | n/a | The resume reads no scheduled cancellation, because `CancelSubscription` calls Stripe (`plan_change.go:671`) before its reconcile records `cancel_at_period_end` and the scheduled change; S takes the ladder from G and ends at its period end, and an unmarked G delivers no more after that; accepted, since both acts are the member's own and D20's refusal cannot see Stripe | D20 |
| F62 | S1 displaced by S2, S2 displaced by G, and the provision that displaced S2 is not one the ending act ended: G was extended through Manage and the extension ends, or a later grant G2, not an extension of G, replaced G and G2 ends; S2 has the higher id (two subscriptions on one ladder, a shape only the Dashboard makes) | n/a | S2's pair names G's provision, not one the ending act ended, so the ordering read misses S2 and both go in id order: S1 takes the ladder, and S2 stays left alone, displaced by S1, billing without delivering; accepted | D7 |
| F63 | A crash between the paused switch's two calls | n/a | No restore runs; the clear's echo writes an entry and the outbox pauses S within seconds; with the echo lost, the backstop entry pauses S about four minutes after the switch began | D12 |
| F64 | The restore's set fails (a 5xx, a deadline) | n/a | L7; the member reads call 2's answer; the clear's echo, or with it lost the backstop entry, pauses S | D12 |
| F65 | A paused renewal's draft finalizes within ten minutes, or is past its time and still a draft | n/a | The guard refuses before any write: "We couldn't complete that change. Try again in a moment."; nothing changes | D12 |
| F66 | A renewal finalizes inside the unpaused window anyway (Stripe finalizing earlier than the later time the guard read, or a failed restore while the backstop waits on a Stripe outage or on Temporal), on a declined or approval-pending attempt | n/a | That renewal is charged in full for a period G covers, or, on a declining card, fails and S goes past due (dunning, F22); accepted | D12, D14 |
| F67 | Call 2 errors or meets its deadline after Stripe applied it | n/a | The restore's reconcile confers the target over G (D8) and finds no pause wanted; the member reads the generic failure though charged, and the page shows the new tier | D12, D8 |
| F68 | The clear's echo, or an earlier attempt's entry, pauses S again before call 2 is sent | n/a | Stripe refuses call 2, an `always_invoice` update on a paused subscription (400); the restore finds agreement; the member reads the generic failure; nothing charged | D12 |
| F69 | A switch applied inside a paused renewal's draft hour | n/a | The old-price draft is charged in full at its finalization on top of the proration, as a clear inside a paused renewal's draft hour lets it be (Context), about the new price for the period, the second charge unannounced; if that charge fails, S goes past due and its new tier is suspended; accepted, as for every call form | D12, D14 |
| F70 | The member leaves a paused switch awaiting approval and cancels S | n/a | Cancel plan voids the open invoice first, which drops the pending update, then sets `cancel_at_period_end`; the pause stays (the sandbox) | D11 |
| F71 | During the approval window (23 hours, or to the period end if sooner) the operator revokes, extends or reorders G, or issues a higher grant, and the member then pays | n/a | The update applies; S takes the ladder only when the new tier ranks at or above the holder then (D8); otherwise the proration buys nothing until that holder ends; accepted | D8, D12 |
| F72 | Another member of the organization pays the abandoned approval's invoice from the Billing page, without reading M3 | n/a | The update applies and S replaces G; accepted, as today's approval path allows (`member_invoices.go:339-341`) | D12 |
| F73 | The apply event of a paid approval is lost | n/a | No reconcile reads the new item, and the drift sweep reads the recorded old one and finds agreement; the paid tier arrives at S's next Stripe event, up to one period late; accepted | D12, D5 |
| F74 | A paid approval, before the outbox's clear | n/a | S delivers the new tier while Stripe still pauses it, about 15 seconds; a renewal finalized then is voided at the operator's cost (D14's third cost) | D12 |
| F75 | A confirmation previewed before a renewal and sent after it | n/a | The switch answers `ErrStalePreview` after its fetch and before any write, and the confirmation is shown again with fresh figures; nothing is sent to Stripe but the fetch, where call 2 would have been refused after the clear (400, a `proration_date` outside the period, the sandbox) | D12 |
| F76 | The transient effects of a paused switch that did not apply | n/a | Two or three extra echoes and reconciles; the mirror and the badge change for seconds; a webhook reconcile that fetched the unpaused state can commit after a fresher one (`reconcile.go:85` fetches before the locks); the next reconcile converges | D1, D12 |
| F77 | An operator's higher grant commits while the switch runs, or the member's own Cancel plan runs in a second tab | n/a | Under the higher grant the switch charges, the new tier does not take the ladder (D8), and S is paused again, the proration buying nothing until that grant ends; the Cancel crosses the switch as any two of the member's moves cross; shared by every call form and accepted, as F61 accepts Resume's race | D8, D11, D12 |
| F78 | The member switches the paused S to a price of another interval (a monthly Plus to a yearly Max) | n/a | Offered as any switch; the confirmation reads M12; Stripe charges the new price's full period less the unused time and moves the billing date to the switch (the sandbox charged $270.32 for a $300-a-year price) | D9, D12 |
| F79 | A tier above G is priced in another currency than S's price | n/a | Offered no move; a post from an older tab is refused with the unavailable-plan sentence before any write | D9, D12 |
| F80 | S holds its ladder while Stripe still carries the console's pause (F43), and the member switches to a price of another interval | n/a | The paused form applies the switch; the confirmation read the fallback sentence, understating the new period's charge; accepted, as for today's upgrade toward another interval | D12 |
| F81 | The grant holding the current rung is past its `valid_until`, and its expiry has not ended the position (the expiration workflow late, or never scheduled) | The card draws no cost line | "Granted until <that date>", or the paid card's paused line with "until <that date>", until the expiry ends the position; the page reads no clock | D21 |
| F82 | The Products page's positions read fails, the read that carries the holding grant's end date | "Failed to load plans" | Unchanged: "Failed to load plans"; no card states a grant's end date the page did not read | D21 |
| F83 | The paused switch's steps outlast the server's 8-second write timeout (`internal/server/server.go:387`) while the request's 32-second timeout (`:786`) has not passed | n/a | The member reads no answer: not the decline's sentence, not the approval link, not the generic failure; every call runs under `WithoutCancel`, so the restore or the applied branch finishes and the state converges, and the next page load shows the outcome; the fix is outside this change (#290) | D12 |
## Locks and transactions
@@ -480,7 +570,8 @@ No price rule applies: Resume keeps the subscription's own item and price, as Ke
- **Ended branch:** `core.end_conferral`, resumption, then, only when something ended, the delivery step under the pool row the resumption took, then `LockPools` and the floor as today, materialization, then the request for the organization's other subscriptions.
- **Revocation and expiry:** unchanged up to the resumption; then the delivery step under the pool row the act holds, the floor, materialization, the requests. No subscription advisory lock is taken.
- **Issue (`IssueGrant`), Manage, the legacy grant, tier removal:** each act's own order, then the requests, which are inserts.
- **The paused switch and Cancel or Keep on a paying subscription:** no transaction of their own; Stripe, then `ReconcileSubscription`.
- **Cancel or Keep on a paying subscription, and an ordinary switch:** no transaction of their own; Stripe, then `ReconcileSubscription`.
- **The paused switch (D12):** its backstop entry is one insert through `db`, committed on its own before any Stripe write, holding no lock; its Stripe calls hold no lock; its restore is an ordinary reconcile run with `applyCollection` (the rendezvous, the subscription advisory lock, the row, the pool row), whose Stripe call follows its commit; the applied branch is today's.
- **Resume plan (`ResumeSubscription`):** the rendezvous, then the pool row, then every read the refusals need under it, the conferral, the requests (inserts) and materialization; no subscription advisory lock and no Stripe call (D20).
- **The drift sweep:** reads with no lock, and one plain transaction per page for its inserts.
@@ -494,20 +585,22 @@ Every sentence a person reads that this change adds or reuses in a new place. Th
| # | Where | Text | Status |
|---|---|---|---|
| M0 | The paid plan's card, price line, pause confirmed, status `active` or `trialing`, with nothing scheduled or a cancellation scheduled; also under a grant of the subscription's own tier | `Paused while <granted> is granted`, for example `Paused while Pro is granted`, and `Paused while Plus is granted` under a grant of Plus over a Plus subscription | approved |
| M1 | The same, status `past_due` or `unpaid`; no "Entitlements are suspended…" line; Pay invoice then Cancel plan | `Paused while <granted> is granted, payment due` | approved |
| M0 | The paid plan's card, price line, pause confirmed, status `active` or `trialing`, with nothing scheduled or a cancellation scheduled; also under a grant of the subscription's own tier, where it is the current card and inserts ` until <date>` after `is granted` when that grant has an end date (D21) | `Paused while <granted> is granted`, for example `Paused while Pro is granted`, and `Paused while Plus is granted` under a grant of Plus over a Plus subscription, `Paused while Plus is granted until Dec 1, 2026` when that grant ends Dec 1, 2026 | approved; the dated form under a grant of the subscription's own tier by ruling (question 16) |
| M1 | The same, status `past_due` or `unpaid`; no "Entitlements are suspended…" line; Pay invoice then Cancel plan | `Paused while <granted> is granted, payment due`, and under a grant of the subscription's own tier with an end date `Paused while Plus is granted until Dec 1, 2026, payment due` | approved; the dated form under a grant of the subscription's own tier by ruling (question 16) |
| M2 | Cancel plan's confirmation body on the paid plan's card, under a grant of another tier | `<paid> ends on <date>; <granted> stays granted.`, for example `Plus ends on Jan 15, 2099; Pro stays granted.` | approved |
| M3 | The switch's confirmation from a paused subscription: the heading, then the second paragraph's first sentence, then the existing charge sentence | `Switch to <target>?`, then `Your subscription replaces the grant.` followed by `Your card is charged <amount> now for the rest of this period, then <price>.` | approved |
| M4 | The paid card of a subscription Stripe holds as `paused` | `<price>, paused` and `Entitlements are suspended.`, both existing | approved (kept) |
| M5 | The paid card when the pause is not confirmed | the existing `<price>, renews <date>`, `<price>, moves to <tier> on <date>`, `<price>, ends <date>`, or `<price>, payment due` | reused (question 1 settled: the mirror decides) |
| M6 | The switch control's label on the granted tier's card | `Upgrade`, the existing label | approved |
| M7 | M2 or M9 on a card whose payment is due | M2 or M9 followed by the existing ` The unpaid invoice stays due.`, for example `Plus ends on Jan 15, 2099; Pro stays granted. The unpaid invoice stays due.` | approved |
| M8 | The paid plan's card, price line, pause confirmed, a downgrade scheduled before the grant | `Paused while <granted> is granted, moves to <tier> on <date>`, for example `Paused while Pro is granted, moves to Basic on Jan 15, 2099` | approved |
| M8 | The paid plan's card, price line, pause confirmed, a downgrade scheduled before the grant | `Paused while <granted> is granted, moves to <tier> on <date>`, for example `Paused while Pro is granted, moves to Basic on Jan 15, 2099`, and under a grant of the subscription's own tier with an end date `Paused while Plus is granted until Dec 1, 2026, moves to Basic on Nov 15, 2026` | approved; the dated form under a grant of the subscription's own tier by ruling (question 16) |
| M9 | Cancel plan's confirmation body under a grant of the subscription's own tier | `Your subscription ends on <date>; <granted> stays granted.`, for example `Your subscription ends on Jan 15, 2099; Plus stays granted.` | approved |
| M10 | The control on the paid plan's card when the grant ranks below it, and its confirmation's confirm label | `Resume plan` | approved |
| M11 | Resume plan's confirmation: the modal's title, then its body | `Resume <paid>?`, then `Your subscription replaces the grant.` followed by the existing `Nothing is charged now; then <price>.`, for example `Resume Pro?` and `Your subscription replaces the grant. Nothing is charged now; then $30.00 a month.` | approved |
| M12 | The switch's confirmation from a paused subscription toward a price of another interval than the paid one: the heading, then the second paragraph's first sentence, then this charge sentence in place of M3's, whatever the preview answered; `<target price>` is the target price's amount, `<then>` the target price as `recurringPhrase` words it | `Switch to <target>?`, then `Your subscription replaces the grant.` followed by `Your card is charged <target price> now, minus a credit for the unused part of your current plan, then <then> from today.`, for example `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.` | approved |
| M13 | The price line of the current card a grant holds, not the organization type's default and not the paid plan's card, which carries the date in M0, M1 and M8 (D21); `<date>` the holding grant's `valid_until` | `Granted until <date>` when the grant has an end date, for example `Granted until Feb 20, 2099`; `Granted` when it has none | approved; `Granted` is the held rung's existing word |
Reused unchanged: "Cancel plan", "Keep plan", "Pay invoice", "Your plan change has been submitted.", "Your cancellation is scheduled.", "Your card was declined; you're still on <tier>.", "Pay the unpaid invoice before changing this plan.", "A paused plan cannot be changed.", "That plan is no longer available. Refresh the page and choose a current plan.", "You're already on that plan.", "There's no active subscription to change on this plan.", "We couldn't complete that change. Try again in a moment.", the M3 fallback "Your card is charged the prorated difference now, then <price>.", and the ladder heading's cancellation badge.
Reused unchanged: "Cancel plan", "Keep plan", "Pay invoice", "Your plan change has been submitted.", "Your cancellation is scheduled.", "Your card was declined; you're still on <tier>.", "Pay the unpaid invoice before changing this plan.", "A paused plan cannot be changed.", "That plan is no longer available. Refresh the page and choose a current plan." (also the answer to a paused switch toward a price in another currency, D9), "You're already on that plan.", "There's no active subscription to change on this plan.", "We couldn't complete that change. Try again in a moment." (also the paused switch's answer when its finalization guard refuses, D12, and Cancel plan's when the void of an abandoned switch's invoice fails, D11), the M3 fallback "Your card is charged the prorated difference now, then <price>.", the approval preview "Your bank must approve the <amount> charge before <tier> starts." with "Approve payment", and the ladder heading's cancellation badge.
### Operator
@@ -537,6 +630,7 @@ Reused unchanged: "Cancel plan", "Keep plan", "Pay invoice", "Your plan change h
| L4 | `DeliverDisplacedSubscriptionsTx`, a refused conferral (Warn) | `subscription delivery refused; the ending proceeds without it`, attributes `subscription_id`, `product_id`, `pool_id`, `error` |
| L5 | `SweepCollectionDrift`, after a run that asked for any (Info) | `collection sweep: subscriptions asked for a reconcile`, attribute `count` |
| L6 | `SweepCollectionDrift`, a failed read (Error) | `collection sweep: read failed`, attribute `error` |
| L7 | The paused switch's restore, a failed `ApplySubscriptionCollection` (Warn) | `switch: the collection pause could not be restored; the outbox entry restores it`, attributes `stripe_subscription_id`, `error` |
## Tests
@@ -554,8 +648,8 @@ Each claim is pinned by a deliberate break (`docs/testing.md`, "Prove a test wit
| 8 | `TestDisplacedProducts` rows, `TestSubscriptionDisplacements_TheHolderRank`, `TestDisplacementHolderRankMigrationUpDownUp`, `TestReconcile_ATierAtOrAboveTheGrantTakesTheLadder`, `TestReconcile_AGrantBelowTheSubscriptionsTierKeepsTheLadder` | D8 |
| 9 | `TestOffer` rows (the switch rows land in section 11), `TestAcross` (unedited), `TestPayingByLadder`, `TestCheckoutRefusesALadderASubscriptionPaysFor`, `TestProductsPageReadsPayingOnce`, the Products page's Checkout absence | D9 |
| 10 | `TestPaidPlanCardUnderAGrant` (rows per state), `TestMemberPlansGolden` states, `TestPlansOfferPaidMoveGate` (the paid card's Pay invoice), `TestCancelAPausedSubscription`, `TestKeepAPausedSubscription`, `TestAPastDuePayingSubscriptionIsNotCancelledImmediately`, `TestPlanUnpaidInvoiceForAPausedPlan`, `TestCancelBodyOnThePaidCard` (M2, M7, M9) | D10, D11 |
| 11 | `TestSwitchAPausedSubscription_*` (seven), `TestPreviewSwitchOfAPausedSubscription`, `TestOffer` rows for the switch (a lower grant's included), `TestPlanMoveMessageStatesTheTiming` row, `TestPostSwitchFromAPausedSubscription` | D12 |
| 12 | `TestOffer` rows for Resume, `TestResumeSubscription_*` (six), `TestResumePlan_*` (four), `TestPostResume`, `TestResumeConfirmation`, `TestPaidPlanCardUnderAGrant` rows, `TestMemberPlansGolden` (`paid-standard-under-granted-basic`), `TestPlansOfferPaidMoveGate` (Resume), `TestEveryMutationRouteIsDeclared` | D20 |
| 11 | `TestSwitchAPausedSubscription_*` (seventeen: the two calls, the scope, the restore on each exit and its failure, the closed request, the guard, the backstop with its failed insert, the abandoned upgrade's void, the deadlines), `TestPreviewSwitchOfAPausedSubscription`, `TestCancelAPausedSubscriptionVoidsAnAbandonedSwitch`, `TestCancelAHoldingSubscriptionVoidsNothing`, `TestOutboxPollSkipsAnEntryNotYetDue`, `TestOffer` rows for the switch (a lower grant's, another interval's, another currency's and a bundle's included), `TestPayingByLadder` row, `TestPlanMoveMessageStatesTheTiming` rows, `TestPostSwitchFromAPausedSubscription`, `TestMemberPlansGolden` (`paid-retired-plus-under-granted-pro`), `TestSwitchPreviewFromAPausedSubscription` | D1, D9, D11, D12 |
| 12 | `TestOffer` rows for Resume, `TestResumeSubscription_*` (six), `TestResumePlan_*` (four), `TestPostResume`, `TestResumeConfirmation`, `TestPaidPlanCardUnderAGrant` rows, `TestMemberPlansGolden` (`paid-standard-under-granted-basic`), `TestPlansOfferPaidMoveGate` (Resume), `TestEveryMutationRouteIsDeclared`; for 12.11, `TestGrantedCurrentCard`, `TestPaidPlanCardUnderAGrant` rows (the dated paused lines under a grant of the subscription's own tier), `TestListLadderPositionsByPoolReadsTheHoldingGrantsEnd`, `TestMemberPlansGolden` (`granted-plus-until`, `granted-plus-own-tier-until`, `paid-plus-under-granted-pro-until`, and the granted current card in the states it names) | D20, D21 |
`TestOperatorPagesReadAFixedNumberOfTimes` passes unedited for the organization page and the subscriptions list: the mirror rides reads they already make. `TestProductsPageReadsPayingOnce` pins that the Products page and Checkout make one statement more per request (`ListSubscriptionRungsByOrg`), never one per ladder.
@@ -563,8 +657,10 @@ A break no test of its section sees is named in that section's commit with the r
## What is unverified
- **`pause_collection` and `metadata` beside `payment_behavior=pending_if_incomplete`** (D12). Item 11.1 runs it on a test clock before the switch's code: one subscription paused with the marker, one update carrying all of D12's parameters, then a declined card on a second subscription. If Stripe refuses that update, both fallbacks, `error_if_incomplete` and the two-call form, go to the maintainer first.
- **Stripe's invoice preview of a paused subscription** (D12). Item 11.1(c) runs it and compares its figure with the update's proration invoice; a refusal or a different figure gives the unavailable preview.
- **When live mode finalizes a paused renewal** (D12, D14). The guard reads the later of a draft's `next_payment_attempt` and `automatically_finalizes_at`, which the sandbox found equal to the draft's creation plus an hour in test mode. Whether live mode finalizes at that time, or later while the endpoint's `invoice.created` deliveries fail (Stripe documents up to 72 hours), no test clock can check; a later finalization makes the guard refuse for longer, and an earlier one is a guard miss (F66).
- **Asynchronous payment methods other than SEPA Direct Debit** (D12). Checkout sets no `payment_method_types` (`internal/server/billing.go:137-150`), so a customer can pay by US bank account (ACH) or BACS Direct Debit. `upgradeNow` reads every pending outcome but `requires_action` as a decline (`plan_change.go:334-353`), so a debit still processing would read "Your card was declined", and one that settles later could apply the switch while paused. The sandbox ran SEPA alone, which Stripe refuses outright (Risks).
- **A currency change through a subscription update** (D9, D12). Stripe refuses it, by Stripe's documentation, not run in the sandbox. The decision never offers the paused switch toward a price in another currency, so the console sends no such update from that path.
- **A switch from a yearly price to a monthly one** (D12). Stripe would credit the unused part of the year beyond the month's price and charge nothing now, an inference not run in the sandbox; the confirmation reads M12 either way.
- **`cancel_at_period_end=false`, and an immediate cancellation, on a paused subscription** (D13). Neither creates an invoice, which is what Stripe refuses on a paused subscription; item 10.1 runs the first on a test clock.
- **`GET /v1/invoices?subscription=…&status=open` on a paused, past-due subscription** (D10). Standard listing; step 12 of the landing walk (item 13.4) opens it.
- **Clearing `pause_collection` on a subscription whose status is `paused`** (D4, D5). The drift sweep asks for it when a Stripe-`paused` subscription's row carries the console's marker. On a refusal, the sweep's entry dead-letters every day.
@@ -596,14 +692,18 @@ A subscription the console paused stays paused under the old binary, which never
- **The switch's proration credits time the member never paid** (D12), accepted with the maintainer until #280 (question 9).
- **A resumed plan's free remainder** of the period whose renewal was voided, at a moment the member picks (D14), accepted until #280, as for the grant's end.
- **A resumed plan delivers while Stripe still pauses it** for the seconds before the outbox's clear, and for a day or more after a dead-lettered one (D20, F47).
- **The paused switch unpauses the subscription for seconds** from its clear to the restore's set (D12): the guard, the restore and the backstop entry keep a paused renewal from finalizing in that window, except for the guard misses and the approval-window costs D14 names (F66, F69, F71 to F74).
- **The guard refuses the paused switch** with "We couldn't complete that change. Try again in a moment." for about the last ten minutes of each paused renewal's draft hour, and in live mode for as long as the endpoint's `invoice.created` deliveries fail, up to 72 hours by Stripe's documentation (D12).
- **A pause made in the Dashboard answers the paused switch's generic failure** on every attempt, since the console never clears it and Stripe refuses the update (D12).
- **A customer whose default payment method is a SEPA Direct Debit cannot switch at all.** Stripe refuses `payment_behavior=pending_if_incomplete` when the default payment method is `sepa_debit` (400, "You cannot set `payment_behavior` to `pending_if_incomplete` when the default payment method is `sepa_debit`."); on a paused subscription the restore then pauses it again and its renewal is voided, and the member reads the generic failure on every attempt (the sandbox, with an offline-mandate SEPA default in euros). The ordinary upgrade fails the same way today for any SEPA default, so the gap is the upgrade path's and exists without this change.
- **The demo seed's acts write outbox entries** that complete without a Stripe call (D4).
- **Rollback needs a hand step** for paused subscriptions (Rollback).
## Open questions
All questions are settled.
**Open:** none.
**Settled** (1 and 11 by the designer, 2 to 10, 12 and 13 with the maintainer):
**Settled** (1 by the designer, 2 to 16 by ruling):
1. The paid card while the pause is pending or failed: the mirror decides, with the existing lines (M5).
2. The switch's label on the granted tier's card: the existing "Upgrade" (M6).
@@ -616,5 +716,8 @@ All questions are settled.
8. A grant of the subscription's own tier: the card reads M0 with the shared name ("Paused while Plus is granted"), and Cancel plan's confirmation reads M9.
9. The switch's proration credit for voided time: accepted until #280, D14's fifth cost, named in `docs/stripe.md` (task 11.5).
10. An item with no entitlement set rides the pause: stated in D2 and in `docs/stripe.md` (task 4.6).
11. The fallbacks of the paused switch's update: if item 11.1(a) finds the one update with `pending_if_incomplete` refused, both fallbacks, `payment_behavior=error_if_incomplete` and the two-call form, go to the maintainer before item 11.2, because each changes what a member can do; no question arises unless 11.1(a) is refused.
11. The paused switch's call form: Stripe refused the one update with `pending_if_incomplete` (item 11.1), and the ruling is two calls, the clear and then the unchanged update, with the restore through the reconcile on every exit but an applied update, the backstop entry, the finalization guard, the deadlines and Cancel plan's void (D12, D11); `error_if_incomplete`, `allow_incomplete`, `default_incomplete` and the `error_if_incomplete`-first hybrid rejected. It supersedes the one Stripe update the switch was first decided with.
12. The free remainder of a resumed period: accepted until #280 and named in docs/stripe.md (option A).
14. The paused switch toward a price of another interval: offered as usual, its confirmation reading M12 when the target's interval differs from the paying price's; a target priced in another currency is never offered; offering only the targets of the paying price's interval, and the fallback sentence for every interval, rejected (D9, D12, "A price of another interval"; task 11.8).
15. The current card a grant holds: "Granted until {date}", or "Granted" for a grant with no end date, on every current card a grant holds, beside a paying subscription or not; "Granted" alone and the card as it was rejected (D21, M13; task 12.11); the paid plan's own card under a grant of its tier is question 16.
16. The current card that is also the paid plan's card, under a grant of the subscription's own tier: M0, M1 and M8 insert " until {date}" right after "is granted" when the holding grant has an end date ("Paused while Plus is granted until Dec 1, 2026"), {date} the `valid_until` M13 reads; under a grant of a tier above the paid one, the granted current card reads M13 and M0, M1 and M8 are unchanged; M0 with no date on the page rejected (D21, D10; task 12.11).
@@ -19,17 +19,20 @@ The decisions are recorded on #269 and #278: while a grant holds a ladder agains
- **No Checkout on a ladder a subscription pays for (#277).** The offer decision gains the fact "a live subscription pays for a tier of this ladder without holding its rung", read from the subscription's current items and the tiers of their products. On such a ladder no tier is offered Checkout, Checkout refuses before any Stripe call, the paid tier's card carries the subscription's own controls, the granted tier and each tier above it, and above the paid tier, offer a switch, and the tiers between a lower grant and the paid tier offer nothing.
- **The paid plan's card (#269).** While a pause is recorded, whoever set it, the card reads "Paused while Pro is granted" and offers Cancel plan, which ends the subscription at its period end with the confirmation "Plus ends on {date}; Pro stays granted.", or "Your subscription ends on {date}; Plus stays granted." under a grant of the subscription's own tier. While an invoice from before the grant is open, the card reads "Paused while Pro is granted, payment due" and offers Pay invoice before Cancel plan, whose confirmation adds the existing "The unpaid invoice stays due.". A scheduled cancellation keeps the paused line with Keep plan and the ladder's existing "cancels on {date}" badge; a downgrade scheduled before the grant reads "Paused while Pro is granted, moves to Basic on {date}". A subscription Stripe holds as `paused` keeps today's "{price}, paused" and "Entitlements are suspended.". Cancel plan, Keep plan and Pay invoice reach the subscription that pays on the ladder, whatever holds the rung. The card is drawn even when its product is no longer a public plan, as the current and held rungs are, so Cancel plan stays reachable.
- **Resume plan under a lower grant.** When the grant ranks below the tier the subscription pays for and the console's pause is confirmed, the paid tier's card offers Resume plan. Its confirmation reads "Resume Pro?", then "Your subscription replaces the grant." and the existing "Nothing is charged now; then {price}.". One transaction under the pool row confers the subscription's tier over the grant and writes the subscription reconciles to the outbox, and the outbox's reconcile then clears the pause at Stripe; the request itself makes no Stripe call, and with no item change Stripe makes no invoice and no proration. Resume plan is not offered while the subscription is past due or paused, while a cancellation or a downgrade is scheduled, for a bundle, or over a pause made in the Dashboard; it applies no member gate, as Keep plan applies none, so a product no longer public can be resumed. A grant marked to resume returns when the subscription ends, while its own term lasts.
- **The switch of a paused subscription (#278).** Its Stripe update changes the item, clears the pause and removes the marker in one call, because Stripe refuses an `always_invoice` update on a paused subscription unless that update also clears the pause. Its confirmation reads "Switch to Max?", then "Your subscription replaces the grant." followed by the existing charge sentence.
- **The switch of a paused subscription (#278).** A subscription whose tier is also a tier of another ladder is offered no switch, as it is offered no Resume (#271), and no switch is offered toward a tier of another ladder too. Two Stripe calls: the console clears the pause and removes the marker, then sends the existing upgrade unchanged (`always_invoice`, `pending_if_incomplete`), because Stripe refuses an `always_invoice` update on a paused subscription and refuses the clear inside an update sent with `pending_if_incomplete`, which the bank's approval needs. On every outcome but an applied update (a decline, a pending approval, an error, a request the member abandons) the request runs the reconcile's own decision, which pauses the subscription again within seconds; an outbox entry written before the clear, due four minutes later, does the same if the process stops between the calls. The switch refuses before any write, with the existing "We couldn't complete that change. Try again in a moment.", while a renewal Stripe made during the pause is about to finalize, so that renewal is still voided, and each of its Stripe calls carries a deadline. Its preview is the existing unavailable one, since Stripe previews nothing for a paused subscription. Its confirmation reads "Switch to Max?", then "Your subscription replaces the grant." followed by the existing charge sentence. A switch toward a price of another interval is offered as usual, and its charge sentence 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, and previews nothing while the subscription is paused. A tier priced in another currency is never offered, since Stripe refuses a change of a subscription's currency.
- **The operator's words.** The Not delivering badge's title reads "Billing paused while a grant holds Demo Ladder." once the pause is confirmed ("grants hold" when several grants hold the ladders), with the several-subscription and mixed forms; a pause not yet confirmed, or failed, keeps "Billing continues while a grant holds {ladder}.". Once the operator chooses a product the grant form marks ⚠, the Product field's hint reads "This grant would supersede a tier a subscription pays for; its billing pauses until the grant ends."; the form shows no warning before that. The Overview's Monthly recurring headline leaves out a subscription whose collection is paused, as it leaves out a trialing one.
- **Money at the grant's edges.** None moves. The renewal paid before the grant is not credited, each renewal Stripe creates while the grant holds is voided at its finalization, about an hour after the renewal, and the rest of the period in which the grant ends is not charged. `docs/stripe.md` states it, with the costs this accepts until #280, among them the switch's proration credit for the voided period, and that an item with no entitlement set stops billing with the paused tier beside it. The rest of a period whose renewal was voided is free after a member's Resume plan, at a moment the member picks; that is accepted until #280, as for the grant's end.
## Restructuring inside this change
Listed so the change carries it openly (`CONTRIBUTING.md`, "How changes are made", item 1). The first two bullets are this change's own work; the third is a small fix that rides along.
Listed so the change carries it openly (`CONTRIBUTING.md`, "How changes are made", item 1). The first two bullets are this change's own work, the third and the fourth are small fixes that ride along, and the last two are the paused switch's own restructuring outside the switch (section 11).
- **Core `entitlements` enqueues a Stripe provider action.** `RequestSubscriptionReconcile` and `ReconcileSubscriptionAction` make the domain package depend on the outbox's Stripe vocabulary, which no act did before; the comment at `internal/server/operator_integrations_stripe.go:263` that says only Stripe enqueues from `operator_billing.go` is updated with it.
- **The displacement rule moves into `internal/entitlements`.** `Rung`, `RungsByProduct`, `Displacement`, `DisplacedProducts` and `chooseTiers` move from `internal/fulfillment/reconcile.go:670-933` to `internal/entitlements`, unchanged. The ending acts that deliver a subscription again live in `entitlements`, which `fulfillment` imports, so they cannot call the rule where it is today. The organization page and the subscriptions list follow the move. No behaviour changes in that commit.
- **Ride-along: the paid card is drawn when its product is no longer a public plan.** `buildTier` (`internal/server/member_plans.go:400-412`) skipped such a tier; it now draws the paid card as it draws the current and held rungs, so Cancel plan stays reachable. One function, no new lock or invariant. Section 10 tests it with the state `paid-retired-plus-under-granted-pro`.
- **Ride-along: the current card a grant holds states the grant's term.** A current card whose position a grant holds draws no cost line and no control (`setCurrent`, `internal/server/member_plans.go:502-504` at `84709d96`), and the member never learns from it when the grant ends; under this change it sits beside the paused plan's card, which is charged again when the grant ends. It now reads "Granted until {date}", the end date of the grant whose provision holds the position, or "Granted" when that grant has none, on every current card a grant holds, beside a paying subscription or not. Under a grant of the subscription's own tier the current card is the paid plan's card, and its paused line carries the same date after "is granted": "Paused while Plus is granted until Dec 1, 2026". The organization type's default is not a grant here; its current card, which also draws no cost line, is left to #289. The date rides the positions the page already reads (`ListLadderPositionsByPool` gains the grant's `valid_until`), so the page makes no statement more. One line, no new lock or invariant. It modifies `member-product-discovery`'s "Every plan card states its cost" and adds the date to the paid card's requirement this change adds, and section 12 builds it, because Resume plan reworks the same cards (design D21).
- **The paused switch: Cancel plan voids an abandoned switch's invoice.** A switch left waiting for the bank's approval leaves a pending update and an open invoice; Stripe keeps the pending update through `cancel_at_period_end`, and paying the invoice later would apply the switch on a plan set to end and take the ladder from the grant. `CancelSubscription` (`internal/fulfillment/plan_change.go:617-687`) therefore voids that invoice first, as `upgradeNow` already voids an abandoned upgrade's invoice before a new one (`:300-308`). It applies only to a subscription that pays without holding its ladder, the path section 10 added; a holding subscription's cancellation is unchanged.
- **The paused switch: the outbox's relay honours `next_attempt_at`.** `PollPendingOutboxEntries` (`internal/integrations/stripe/workflows/outbox.go:202-225`) takes a pending entry only once its `next_attempt_at` has passed, and `integration.EnqueueAt` writes one with a later time, which the paused switch's backstop entry needs. The column exists with default `NOW()` and an index (`internal/db/migrations/00001_init.sql:1091`, `:1098-1100`), and every other writer sets `NOW()`, so every existing entry is polled as before. `stripe-integration-infrastructure` states the rule.
## Out of scope
@@ -49,9 +52,10 @@ None.
### Modified Capabilities
- `stripe-subscription-management`: added "A grant's hold pauses the subscription's collection at Stripe", "A subscription delivers again in the act that ends the plan holding its ladder", "Cancel plan and Keep plan reach a subscription that pays on a ladder without holding it", "A subscription paused for a grant is switched to the granted tier or above" and "A member resumes a plan paused under a grant ranked below it"; modified "Subscription reconcile computes a genuine diff, grouped by product" (the exception at or above a grant, the pause in place of "changes nothing at Stripe", the delivery in the ending act) and "Suspension and resume propagate via sync_source_status" (the pause in place of "SHALL NOT attempt to stop the subscription's billing").
- `member-offer`: modified "One decision says which move a member is offered on a ladder" (the subscription that pays without holding, its rule, and Resume).
- `member-offer`: modified "One decision says which move a member is offered on a ladder" (the subscription that pays without holding, its rule, the switch's currency and interval, and Resume).
- `stripe-integration-infrastructure`: modified "Integration outbox supports transactional outbound actions" and "Temporal workflow polls and executes integration outbox entries" (an entry is taken only once its `next_attempt_at` has passed).
- `stripe-subscription-creation`: added "Checkout refuses a ladder a subscription pays for without holding it".
- `member-product-discovery`: added "A paid plan whose ladder a grant holds shows its pause on its own card"; modified "Plan-move controls reflect built billing mechanics" (a rung held by a grant sells Checkout only on a ladder no subscription pays for, and the switch preview reaches the paying subscription).
- `member-product-discovery`: added "A paid plan whose ladder a grant holds shows its pause on its own card"; modified "Plan-move controls reflect built billing mechanics" (a rung held by a grant sells Checkout only on a ladder no subscription pays for, and the switch preview reaches the paying subscription) and "Every plan card states its cost" (the current card a grant holds reads "Granted until {date}", or "Granted" for a grant with no end date; the organization type's default's current card is left as it is, #289).
- `member-invoice-history`: modified "Stripe's page of an invoice is read when the member opens it" (the plan route reaches the paying subscription's open invoice).
- `plan-enrollment-administration`: modified "Advisory warning when a grant would supersede a subscription-sourced position" (the warning's text, and the grant now pauses the subscription's collection), "The billing card says when a subscription delivers nothing while another plan holds its ladder" (the paused titles) and "Tier changes is the audit list, in operator vocabulary" (the delivery's four reasons).
- `member-console-demo-seed`: modified "The seed plays a ledger demo on Carlos's organization" (the revocation and the expiry deliver Carlos Member's canceling subscription again, so no default is restored and the Active tab is empty).
@@ -63,18 +67,20 @@ None.
## Impact
- **Code:** `internal/fulfillment/reconcile.go`, `plan_change.go`, a new `collection.go` (the decision, the Stripe call) and a new `collection_sweep.go` (the drift sweep); `internal/integrations/stripe/store/queries/subscription_mappings.sql` and `internal/integration/queries/outbox.sql` (the sweep's reads); `internal/entitlements` (the moved rule, the pause rule, the outbox requests, the delivery step, `IssueGrant`, the resume act); `internal/plans/offer.go`; `internal/server/plan_offer.go`, `member_plans.go`, `member_plan_moves.go`, `member_hosted_invoice.go`, `billing.go`, `action_triggers.go`, `operator_enrollment.go`, `operator_enrollment_forms.go`, `operator_billing.go`, `operator_tier_changes.go`; `internal/embeds/templates/partials/member_plans.html`; `internal/integrations/stripe/workflows/outbox.go`; `internal/workflows/billing/`; `internal/stripetest/mock.go` (an invoice list); `test/e2e/screens` (the wait for an empty outbox before the first capture).
- **Code:** `internal/fulfillment/reconcile.go`, `plan_change.go`, a new `collection.go` (the decision, the Stripe call), a new `collection_sweep.go` (the drift sweep) and a new `paused_switch.go` (the paused switch's guard, backstop, restore and deadlines); `internal/integrations/stripe/store/queries/subscription_mappings.sql` and `internal/integration/queries/outbox.sql` (the sweep's reads); `internal/entitlements/queries/pool_provision_ladders.sql` (the holding grant's end date on the position rows); `internal/integration/outbox.go` (`EnqueueAt`); `internal/entitlements` (the moved rule, the pause rule, the outbox requests and the delayed one, the delivery step, `IssueGrant`, the resume act); `internal/plans/offer.go`; `internal/server/plan_offer.go`, `member_plans.go`, `member_plan_moves.go`, `member_hosted_invoice.go`, `billing.go`, `action_triggers.go`, `operator_enrollment.go`, `operator_enrollment_forms.go`, `operator_billing.go`, `operator_tier_changes.go`; `internal/embeds/templates/partials/member_plans.html`; `internal/integrations/stripe/workflows/outbox.go` (the new action, and the poll's `next_attempt_at`); `internal/workflows/billing/`; `internal/stripetest/mock.go` (an invoice list); `internal/testkit/world` (a price in another currency or interval); `test/e2e/screens` (the wait for an empty outbox before the first capture).
- **Schema:** `00036_subscription_collection_pause.sql` (three columns on `core.subscriptions`) and `00037_displacement_holder_rank.sql` (`core.subscription_displacements` returns the holder's product and rank).
- **Stripe calls:** `POST /v1/subscriptions/{id}` setting or clearing `pause_collection` and the marker (the Subscriptions write the moves already need); the paused switch's update; `GET /v1/invoices?subscription=…&status=open` for Pay invoice on a paused card (Invoices read, already held).
- **Stripe calls:** `POST /v1/subscriptions/{id}` setting or clearing `pause_collection` and the marker (the Subscriptions write the moves already need); the paused switch's clear followed by its update, and the reconcile's set after a switch Stripe did not apply; `GET /v1/invoices?subscription=…&status=draft` for the paused switch's finalization guard and `GET /v1/invoices?subscription=…&status=open` for Pay invoice on a paused card (Invoices read, already held); `POST /v1/invoices/{id}/void` from Cancel plan for an abandoned switch's invoice (the void the upgrade already makes).
- **Routes:** one added, `POST /partials/member/plans/resume`, declared as an action trigger.
- **Behaviour a deployment sees:** a grant over a paid tier stops the subscription's charges within seconds and resumes them when the grant ends, with the tier delivered in the same act; the member sees the paused plan, can cancel it, move it to the granted tier or above, or, under a lower grant, resume it; no second subscription on a ladder can be bought.
- **Behaviour a deployment sees:** a grant over a paid tier stops the subscription's charges within seconds and resumes them when the grant ends, with the tier delivered in the same act; the member sees the paused plan, can cancel it, move it to the granted tier or above, or, under a lower grant, resume it; the current plan a grant holds reads when the grant ends; no second subscription on a ladder can be bought. The move from a paused plan unpauses the subscription for a few seconds and refuses, for about the last ten minutes of each paused renewal's draft hour, and, in live mode, for as long as the endpoint's `invoice.created` deliveries fail, with "Try again in a moment."; its outbox entry shows as pending for four minutes on the queue counts.
- **Deploy:** re-run `scripts/db-reader-role.sql` after the migrations so the reader role reads the three new columns (`docs/database-management.md`, "Running the script"). Nothing to backfill: the drift sweep pauses every subscription already displaced by a grant within an hour of the deploy (D19), and a read-only count before the push goes to the maintainer (task 13.3).
- **Tests:** unit tests of the rules, reconcile tests through the Stripe mock, database tests of each act's request and of the delivery step, the trace goldens, handler tests of the Products page, Checkout, the switch, Resume plan and Pay invoice, database tests of the resume act and its refusals, the organization page and subscriptions list titles, the migrations' up-down-up, and the Stripe sandbox walk at landing.
- **Tests:** unit tests of the rules, reconcile tests through the Stripe mock, database tests of each act's request and of the delivery step, the trace goldens, handler tests of the Products page, Checkout, the switch, Resume plan and Pay invoice, the paused switch's calls through the Stripe mock on every exit (their order, the restore, the guard, the backstop entry, the deadlines), the outbox poll's `next_attempt_at`, database tests of the resume act and its refusals, the organization page and subscriptions list titles, the migrations' up-down-up, and the Stripe sandbox walk at landing.
- **Forms library:** `internal/forms` gains a select's hint that waits for the option that shows it (`HintOnChoice`, `ShowsHint`), rendered by the field part and followed by one external script, `internal/embeds/static/hint-on-choice.js`, loaded by the operator shell.
- **Docs:** `docs/design-system.md` (§8 and the progressive-enhancement table), `docs/stripe.md`, `docs/models/payments-billing.md`, `docs/models/plan-ladders-transitions.md`, `docs/plan-management.md`, `docs/database-locks.md`, `test/AGENTS.md`, `test/seed/member-console-demo/README.md`.
- **Docs:** `docs/design-system.md` (§8 and the progressive-enhancement table), `docs/stripe.md`, `docs/models/payments-billing.md`, `docs/models/plan-ladders-transitions.md`, `docs/plan-management.md`, `docs/database-locks.md`, `docs/models/provider-integration.md`, `test/AGENTS.md`, `test/seed/member-console-demo/README.md`.
## Related issues
- **#269** is this change, with **#270**, **#277** and **#278**, which close with it.
- **#271** stays open; this change's refusal of Checkout on a vacated ladder is noted on it at landing.
- **#279** and **#280** stay open and build on this change.
- **#290** (the server's 8-second write timeout) stays open: until it lands, a paused switch whose steps outlast eight seconds still converges, but the member reads no answer (design F83).
- Depends on **#285** (a subscription reconcile's Stripe calls carry its context), which lands before section 11's commit: the paused switch's restore is bounded only through it.
@@ -4,18 +4,18 @@
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; 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, 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, 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 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; 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.
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; and the reason when no move is offered or the move is disabled. It SHALL route moves as `member-product-discovery`'s "Plan-move controls reflect built billing mechanics" states, and in addition:
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; and, for a switch of the paying subscription, that the switch replaces the grant and 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 and the paying subscription is not suspended, 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; 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; and no tier of the ladder SHALL be offered Checkout;
- 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;
- 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.
@@ -51,11 +51,26 @@ The Products page, Checkout, the plan switch, the switch preview and Resume plan
- **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
@@ -11,7 +11,7 @@ On the Products page, on a ladder a grant holds while a live subscription of the
- no `pause_collection` behavior recorded (the pause pending or failed): the existing "{price}, renews {date}", "{price}, moves to {tier} on {date}" or "{price}, ends {date}", or "{price}, payment due" with Pay invoice, with the same controls and no Resume plan;
- status `paused`: the existing "{price}, paused" and "Entitlements are suspended.", and no control.
{granted} SHALL be the name of the product the grant holds on the ladder, the paid tier's own name under a grant of that tier. Cancel plan on that card SHALL cancel the subscription at its period end, and its confirmation's body SHALL read "{paid} ends on {date}; {granted} stays granted." under a grant of another tier and "Your subscription ends on {date}; {granted} stays granted." under a grant of the subscription's own tier, each followed by " The unpaid invoice stays due." when the payment is due, {date} the subscription's period end. Resume plan SHALL open the shared confirmation dialog titled "Resume {paid}?" whose body reads "Your subscription replaces the grant. Nothing is charged now; then {price}.", {price} the subscription's own recurring price, and whose confirm control reads "Resume plan". The granted tier's card and each tier ranked above it, and above the paid tier, SHALL draw the switch the decision offers, labelled "Upgrade"; under a grant ranked below the paid tier, the tiers between them SHALL draw no control. On a ladder the subscription pays for and no plan holds, the paid tier's card SHALL draw no control and none of the paused lines. The page SHALL read the paying subscriptions in one statement per request.
{granted} SHALL be the name of the product the grant holds on the ladder, the paid tier's own name under a grant of that tier. Under a grant of the subscription's own tier, where the paid tier's card is the current card, each of the three lines that read "Paused while {granted} is granted" SHALL insert " until {date}" right after "is granted" when the holding grant has an end date, {date} as "Every plan card states its cost" states it for the current tier a grant holds: "Paused while Plus is granted until Dec 1, 2026", "Paused while Plus is granted until Dec 1, 2026, payment due" and "Paused while Plus is granted until Dec 1, 2026, moves to {tier} on {date}". Under a grant of another tier those lines SHALL carry no date, which the granted tier's current card states. Cancel plan on that card SHALL cancel the subscription at its period end, and its confirmation's body SHALL read "{paid} ends on {date}; {granted} stays granted." under a grant of another tier and "Your subscription ends on {date}; {granted} stays granted." under a grant of the subscription's own tier, each followed by " The unpaid invoice stays due." when the payment is due, {date} the subscription's period end. Resume plan SHALL open the shared confirmation dialog titled "Resume {paid}?" whose body reads "Your subscription replaces the grant. Nothing is charged now; then {price}.", {price} the subscription's own recurring price, and whose confirm control reads "Resume plan". The granted tier's card and each tier ranked above it, and above the paid tier, SHALL draw the switch the decision offers, labelled "Upgrade"; under a grant ranked below the paid tier, the tiers between them SHALL draw no control. On a ladder the subscription pays for and no plan holds, the paid tier's card SHALL draw no control and none of the paused lines. The page SHALL read the paying subscriptions in one statement per request.
#### Scenario: The paid plan's card while the grant holds
@@ -58,6 +58,16 @@ On the Products page, on a ladder a grant holds while a live subscription of the
- **WHEN** a grant of Plus holds the ladder over the paused Plus subscription, and the member chooses Cancel plan on Plus's card, the current card
- **THEN** the card reads "Paused while Plus is granted", and the confirmation reads "Your subscription ends on {date}; Plus stays granted."
#### Scenario: A grant of the subscription's own tier with an end date
- **WHEN** a grant of Plus whose end date is Feb 20, 2099 holds the ladder over the paused Plus subscription
- **THEN** Plus's card, the current card, reads "Paused while Plus is granted until Feb 20, 2099"
#### Scenario: A grant of a higher tier with an end date
- **WHEN** a grant of Pro whose end date is Feb 20, 2099 holds the ladder over the paused Plus subscription
- **THEN** Pro's card, the current card, reads "Granted until Feb 20, 2099", and Plus's card reads "Paused while Pro is granted"
#### Scenario: Resume plan under a lower grant
- **WHEN** a member opens the Products page of an organization whose subscription to Pro is paused with the console's marker for a Basic grant on the same ladder
@@ -206,3 +216,59 @@ Commitment policy (`subscriptions.early_termination_policy`) for a mid-term down
- **WHEN** an add-on product row is rendered
- **THEN** the row SHALL show an enabled "Add" button that submits the product's default `price_id` to Checkout
### Requirement: Every plan card states its cost
Every plan card in the member catalog SHALL state its cost, not only the current tier's: a priced tier shows its price and interval; a tier with no price shows "Included" (a free rung, or a tier only an operator gives); a held rung ("Plan-move controls reflect built billing mechanics") that is not the current tier shows "Granted" in place of its price, because a grant covers it and it returns at no cost when the paid plan ends; the current tier a grant holds shows "Granted until {date}", {date} the end date (`valid_until`) of the grant whose provision holds the position, in the form of the page's other dates, or "Granted" when that grant has no end date, because no subscription bills that tier and the grant's end is when its delivery stops; a non-current tier whose default price exists but cannot be bought (not synced to Stripe, out of the current key's reach, or one-time) shows no price; and when the offer decision (`member-offer`) offers such a tier Checkout or a switch, that control renders disabled with "Not available for purchase yet". "Included" SHALL be reserved for a tier with no price, so one word never states two facts (grant-resumption design D10). A member SHALL never have to move to a tier to learn what it costs.
The current tier's grant line SHALL be drawn on every current card a grant holds, whether or not a subscription of the organization pays for a tier of the ladder. After an extension, {date} SHALL be the extension's end date. A grant whose end date has passed while its position still holds SHALL keep reading "Granted until {date}" with that date until its expiry ends the position. A position the organization type's default holds is not a grant for this rule. This requirement does not change the current card a position of the organization type's default holds, which draws no cost line whatever its tier's price; [forge issue #289](https://git.coopcloud.tech/wiki-cafe/member-console/issues/289) decides what it reads. The current card that is also the paid plan's card, under a grant of the subscription's own tier, SHALL state the subscription's state instead, its paused line carrying the grant's end date ("A paid plan whose ladder a grant holds shows its pause on its own card").
#### Scenario: A non-current priced tier shows its price
- **WHEN** a member views a ladder whose non-current tier carries an active, recurring default price with a synced mapping the key can reach
- **THEN** that tier's card shows the price and interval
#### Scenario: A free rung says so
- **WHEN** a member views a ladder whose rank-0 tier carries no price
- **THEN** that tier's card reads "Included"
#### Scenario: A held rung says it is granted
- **WHEN** a member on Pro views Plus, the rung a grant marked to resume holds under the member's subscription
- **THEN** Plus's card reads "Granted" and shows no price, and does not read "Included"
#### Scenario: A held rung with a price still reads Granted
- **WHEN** a member on Pro views Plus, which carries an active synced price, while a grant marked to resume holds Plus under the member's subscription
- **THEN** Plus's card reads "Granted" in place of its price, and the free rung's card on the same ladder reads "Included"
#### Scenario: A tier whose price cannot be bought shows no price
- **WHEN** a member views a non-current tier whose default price is not synced, is out of the key's reach, or is one-time, and the offer decision offers that tier Checkout or a switch
- **THEN** the card shows no price and its move control renders disabled with "Not available for purchase yet"
#### Scenario: The current plan a grant holds names the grant's end
- **WHEN** a member's organization holds Plus by a grant whose end date is Feb 20, 2099, and no subscription of the organization pays on the ladder
- **THEN** Plus's card, the current card, reads "Granted until Feb 20, 2099" in place of a price
#### Scenario: The current plan a grant with no end date holds
- **WHEN** a member's organization holds Pro by a grant with no end date, while its subscription to Plus is paused for that grant
- **THEN** Pro's card, the current card, reads "Granted", and Plus's card reads "Paused while Pro is granted"
#### Scenario: An extended grant names the extension's end
- **WHEN** an operator extends the grant that holds Plus from Feb 20, 2099 to Mar 20, 2099
- **THEN** Plus's card reads "Granted until Mar 20, 2099"
#### Scenario: A grant past its end date whose position has not ended
- **WHEN** the end date of the grant that holds Plus, Oct 1, 2026, has passed, and the grant's expiry has not yet ended its position
- **THEN** Plus's card still reads "Granted until Oct 1, 2026"
#### Scenario: The organization type's default is not a grant
- **WHEN** a member's organization holds Free as its organization type's default
- **THEN** Free's card, the current card, does not read "Granted"
@@ -0,0 +1,43 @@
## MODIFIED Requirements
### Requirement: Integration outbox supports transactional outbound actions
The system SHALL provide an `integration.integration_outbox` table that captures domain state changes requiring external action, with at-least-once delivery guarantees. Each entry SHALL carry the time it is due, `next_attempt_at`; an entry written without one is due at once. Retries SHALL be Temporal's: each entry is executed by a workflow of its own whose activity is retried on Temporal's schedule within a budget of one day, and the entry SHALL be dead-lettered when the budget is spent or the provider answers that the request itself is wrong.
#### Scenario: Pending outbox entry is dispatched
- **WHEN** an outbox entry exists with status `pending` and its `next_attempt_at` has passed
- **THEN** the relay hands it to a workflow keyed by the entry id, and a second dispatch of a running entry is a no-op
#### Scenario: An entry not yet due waits
- **WHEN** an outbox entry with status `pending` has a `next_attempt_at` in the future
- **THEN** neither the relay nor the boot sweep takes it before that time
#### Scenario: Failed outbox entry is retried by Temporal
- **WHEN** an outbox entry's execution fails retryably
- **THEN** the row reads `failed` with Temporal's attempt number and the error, and Temporal runs the execution again after its backoff
#### Scenario: Exhausted or rejected entry moves to dead letter
- **WHEN** an entry fails through the whole budget, or the provider rejects the request as wrong (a 4xx other than a rate limit or a conflict)
- **THEN** the system transitions the entry status to `dead_letter` for manual investigation and the entry's workflow fails
#### Scenario: A retry never creates a second provider object
- **WHEN** an attempt created the provider object but its result was lost and Temporal runs the attempt again
- **THEN** the create carries the same idempotency key, derived from the entry id, and the provider returns the first attempt's object; an entry already `completed` is not executed again
### Requirement: Temporal workflow polls and executes integration outbox entries
The system SHALL register a relay workflow that polls `integration.integration_outbox` for `pending` entries whose `next_attempt_at` has passed and starts, for each, an entry workflow that dispatches to the action-type-specific executor. Once the console connects to Temporal, the system SHALL start the entry workflow for every Stripe entry left `failed` or `processing`.
#### Scenario: Pending outbox entry is executed
- **WHEN** an `integration_outbox` row exists with status `pending` and its `next_attempt_at` has passed
- **THEN** the relay starts the entry's workflow, which marks the row `processing`, dispatches to the appropriate action executor based on `action_type`, and on success marks it `completed` with the attempt count
#### Scenario: Recognized action types are dispatched to specific executors
- **WHEN** an outbox entry with a recognized `action_type` (e.g., `create_stripe_customer`) is processed
- **THEN** the workflow routes the entry to the corresponding executor which performs the external action
#### Scenario: Unrecognized action types succeed as no-ops
- **WHEN** an outbox entry with an unrecognized `action_type` is processed
- **THEN** the workflow logs the action and marks the entry `completed` without performing an external action
#### Scenario: Unfinished entries are swept once Temporal connects
- **WHEN** the console connects to Temporal with Stripe entries at `failed` or `processing`
- **THEN** it starts each entry's workflow; `pending` entries are the relay's, and finished entries are left alone
@@ -2,11 +2,11 @@
### Requirement: A grant's hold pauses the subscription's collection at Stripe
The console SHALL pause a subscription's payment collection at Stripe while grants hold every ladder the subscription pays for, and SHALL resume it when they no longer do. One pure function, `entitlements.CollectionPauseWanted`, SHALL state the rule, reading no row: collection is wanted paused when the subscription's status is `active`, `trialing`, `past_due` or `unpaid`, at least one of its current items names a product with an entitlement set, every such product is left alone by the displacement rule ("Subscription reconcile computes a genuine diff, grouped by product"), and every displacement that leaves one alone has a grant as its holder. A subscription partly displaced, displaced by another subscription, or whose status is `paused`, `incomplete`, `canceled` or any other SHALL NOT be wanted paused.
The console SHALL pause a subscription's payment collection at Stripe while grants hold every ladder the subscription pays for, and SHALL resume it when they no longer do, except that the switch of a subscription the console paused clears the pause before its update and, unless the update applies, has the reconcile set it again ("A subscription paused for a grant is switched to the granted tier or above"). One pure function, `entitlements.CollectionPauseWanted`, SHALL state the rule, reading no row: collection is wanted paused when the subscription's status is `active`, `trialing`, `past_due` or `unpaid`, at least one of its current items names a product with an entitlement set, every such product is left alone by the displacement rule ("Subscription reconcile computes a genuine diff, grouped by product"), and every displacement that leaves one alone has a grant as its holder. A subscription partly displaced, displaced by another subscription, or whose status is `paused`, `incomplete`, `canceled` or any other SHALL NOT be wanted paused.
The console's pause SHALL be `pause_collection[behavior]=void`, with no `resumes_at`, set in one update together with the subscription metadata `member_console_collection=paused_for_grant` (the marker). The clear SHALL send `pause_collection=` and `metadata[member_console_collection]=` in one update. A pause without the marker was made outside the console: the console SHALL NOT clear or replace it, and SHALL log it when the rule wants a pause. A marker without a pause SHALL be set again while the rule wants a pause and cleared otherwise.
The subscription reconcile SHALL evaluate the rule in its active and suspended branches, from reads it makes under the pool row, and compare it with the subscription it fetched from Stripe. A reconcile run by the transactional outbox's `reconcile_subscription` action (payload: the console's subscription id) SHALL make the pause or clear call after its commit and SHALL return a failed call, with an error that names the Stripe subscription id, to the outbox's workflow, which retries the reconcile on its budget and then dead-letters the entry. After a successful call, that reconcile SHALL run once more, at most twice in all, so the decision is made again from a fetch taken after its own call. Every other reconcile that finds Stripe disagreeing with the rule SHALL write one `reconcile_subscription` entry in its own transaction and SHALL NOT call Stripe for the pause.
The subscription reconcile SHALL evaluate the rule in its active and suspended branches, from reads it makes under the pool row, and compare it with the subscription it fetched from Stripe. A reconcile run by the transactional outbox's `reconcile_subscription` action (payload: the console's subscription id) SHALL make the pause or clear call after its commit and SHALL return a failed call, with an error that names the Stripe subscription id, to the outbox's workflow, which retries the reconcile on its budget and then dead-letters the entry. After a successful call, that reconcile SHALL run once more, at most twice in all, so the decision is made again from a fetch taken after its own call. Every other reconcile that finds Stripe disagreeing with the rule SHALL write one `reconcile_subscription` entry in its own transaction and SHALL NOT call Stripe for the pause. The one exception is the restore of a paused subscription's switch that Stripe did not apply ("A subscription paused for a grant is switched to the granted tier or above"), which runs the outbox's reconcile of the subscription from the member's request.
Each act that can change who holds a ladder SHALL write a `reconcile_subscription` entry, in its own transaction, for each subscription of the pool's organization whose status is `active`, `trialing`, `past_due` or `unpaid`: issuing a grant through the operator's form, extending one through Manage, a revocation, an expiry, the grandfathered legacy grant of a default change, a tier removal, a member's Resume plan, and a subscription reconcile that ended one of its own provisions (for the organization's other subscriptions). An hourly sweep SHALL write the same entry for each live subscription that is mapped to a Stripe subscription the key can reach and whose recorded pause disagrees with the rule, unless that subscription has an entry that is pending, processing or failed, or dead-lettered within the last day.
@@ -72,7 +72,7 @@ Every reconcile SHALL record the subscription's `pause_collection` behavior, its
- **WHEN** S's status is `paused` and a grant holds its only ladder
- **THEN** the rule does not want S paused, and the console sends nothing to Stripe
#### Scenario: A member's move never makes the pause call
#### Scenario: A member's cancellation never makes the pause call
- **WHEN** a member's cancellation of S ends with a reconcile that finds the rule wanting a pause S does not carry
- **THEN** that reconcile writes a `reconcile_subscription` entry, calls Stripe for nothing more, and the cancellation answers as it does today
@@ -139,7 +139,7 @@ A revocation, an expiry, the subscription reconcile's ended branch and its end o
### Requirement: Cancel plan and Keep plan reach a subscription that pays on a ladder without holding it
The cancellation, keep, resume, switch and preview endpoints, and `SubscriptionBacksLadder`, SHALL resolve the subscription for a ladder through the organization's position when a subscription backs it, and otherwise (a position held by a grant, the default or a purchase, or no position on the ladder at all) through the live subscription (status `active`, `trialing`, `past_due`, `unpaid` or `paused`) whose current item names a tier of the ladder, the lowest subscription id first. A subscription found the second way SHALL be refused, released, cancelled and kept by the same rules as one that holds its rung, its suspension read from its status (`past_due`, `unpaid` and `paused` suspend it), so a past-due one refuses an immediate cancellation and a switch, and a paused one refuses every act with "A paused plan cannot be changed.". Its period-end cancellation SHALL set `cancel_at_period_end` and leave the console's pause in place, so the subscription ends at its period end with no final invoice; its keep SHALL clear `cancel_at_period_end` and release the console's schedule and leave the pause in place.
The cancellation, keep, resume, switch and preview endpoints, and `SubscriptionBacksLadder`, SHALL resolve the subscription for a ladder through the organization's position when a subscription backs it, and otherwise (a position held by a grant, the default or a purchase, or no position on the ladder at all) through the live subscription (status `active`, `trialing`, `past_due`, `unpaid` or `paused`) whose current item names a tier of the ladder, the lowest subscription id first. A subscription found the second way SHALL be refused, released, cancelled and kept by the same rules as one that holds its rung, its suspension read from its status (`past_due`, `unpaid` and `paused` suspend it), so a past-due one refuses an immediate cancellation and a switch, and a paused one refuses every act with "A paused plan cannot be changed.". Its period-end cancellation SHALL set `cancel_at_period_end` and leave the console's pause in place, so the subscription ends at its period end with no final invoice; its keep SHALL clear `cancel_at_period_end` and release the console's schedule and leave the pause in place. Before either cancellation of a subscription found the second way, a cancellation a commitment defers to its boundary included, when Stripe's subscription carries a pending update whose invoice is open, the console SHALL void that invoice, so that paying it later cannot apply a switch that takes the ladder from the grant on a plan set to end; a failed void SHALL refuse the cancellation with "We couldn't complete that change. Try again in a moment." before any other write. The cancellation of a subscription that holds its rung SHALL keep its form.
#### Scenario: Cancel plan on a paused subscription
@@ -151,6 +151,12 @@ The cancellation, keep, resume, switch and preview endpoints, and `SubscriptionB
- **WHEN** the same member keeps Plus before its period ends
- **THEN** the console clears `cancel_at_period_end`, supersedes the scheduled cancellation, and the pause stays
#### Scenario: Cancel plan voids an abandoned switch's invoice
- **WHEN** the member left a switch of the paused Plus subscription waiting for the bank's approval, and then cancels Plus
- **THEN** the console voids the switch's open invoice, which drops the pending update, before it sets `cancel_at_period_end`, and the pause stays
- **AND** no later payment can apply the switch
#### Scenario: The grant ends before the cancellation date
- **WHEN** the grant ends while Plus is set to cancel at its period end
@@ -163,17 +169,18 @@ The cancellation, keep, resume, switch and preview endpoints, and `SubscriptionB
### 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, through the existing switch endpoint and its preview. The switch SHALL be one immediate upgrade, charged at once with `proration_behavior=always_invoice`, whose Stripe update also clears `pause_collection` and removes the marker when the subscription Stripe returns carries the console's pause or a stale marker, whether or not the subscription holds its ladder, because Stripe refuses an `always_invoice` update on a paused subscription unless that update clears the pause. A pause without the marker SHALL NOT be cleared, and Stripe's refusal then answers the switch's generic failure. A declined or incomplete payment SHALL leave the subscription as it was, 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 carrying the console's pause SHALL NOT state a charge other than the one the switch's update makes. It SHALL be the existing unavailable preview whenever Stripe refuses the preview, and whenever the change's sandbox check did not find Stripe's preview figure equal to the update's charge. Its confirmation SHALL read "Switch to {target}?", and its second paragraph SHALL open "Your subscription replaces the grant." before the charge sentence.
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}?", 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 read "Your card is charged {target price} now, minus a credit for the unused part of your current plan, then {then} from today.", {target price} the target price's amount and {then} its recurring price ("$300.00 a year"), whatever the preview answered, because Stripe then charges the new price's full period less the unused time and moves the billing date to the switch.
#### 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** one Stripe update changes the item to Max with `always_invoice` and clears `pause_collection` and the marker, the proration is charged now, and the reconcile confers Max over the grant
- **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 same Stripe update clears `pause_collection` and the marker, so Stripe does not refuse it
- **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
@@ -183,12 +190,59 @@ A member SHALL be able to switch a subscription that pays on a ladder a grant ho
#### Scenario: A declined switch leaves the subscription paused
- **WHEN** the card is declined on that switch
- **THEN** the subscription keeps Plus, the console's pause stays or is set again by the reconcile its echo runs, and the grant keeps the ladder
- **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 from the paused Plus subscription and the preview answers an amount
- **THEN** the confirmation reads "Switch to Max?", and its second paragraph reads "Your subscription replaces the grant. Your card is charged {amount} now for the rest of this period, then {price}."
- **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 states the amount while no pause is recorded
- **WHEN** the subscription's row records no pause yet, and the preview answers an amount
- **THEN** the second paragraph reads "Your subscription replaces the grant. Your card is charged {amount} 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 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
@@ -1,6 +1,6 @@
Tests follow `docs/testing.md`: database tests open the database through `dbtest`, build their rows through the test kit (`internal/testkit/world`) or the package's fixtures, filter to the rows their own fixture made, and pass twice in a row without a reset; tests meet the same complexity limit as production code, so a table runs its rows through a named function. A test of a lock holds one transaction open and waits for the block with `dbtest.AwaitLockWait`, never a sleep. A test of an order feeds its input in more than one order. A test of a migration applies it to a `dbtest.Scratch` database. A page's or a refusal's test compares the whole sentence with its entry in the design's Copy table. A test that asserts a log line finds one line that holds the message and each named attribute (`wantInLine`). A test of a Stripe call reads the call's form from `stripetest.MockBackend.Calls`, never the mock's state.
Sections 1 to 12 are one commit each, and their items are that commit's parts; section 13 is not a commit. The sections are ordered so that each passes alone: section 1 moves the rule with no behaviour change; sections 2 to 6 pause and resume collection and say so to the operator; section 7 delivers the subscription in the ending act (#270); section 8 lets a tier at or above the grant take the ladder; sections 9 to 12 are the member's side (#277, #269, #278, and Resume plan under a grant ranked below the paid tier); section 9's offer rule answers no switch and no Resume yet, because the preview and the resolver reach a paying subscription only from section 10 and the Stripe update clears the pause only from section 11, which turns the switch's answer into Switch, and section 12 turns the paid tier's answer into Resume once the act, the route and the card's control exist. Between sections 4 and 7 a grant's end still delivers the subscription within seconds, because the outbox's reconcile that clears the pause confers the tier; section 7 moves that into the ending act. Every commit passes `make test` with the test stack up, which runs `make lint` first (`filler-copy`, `em-dash-copy`, `screen-coverage`, the notebook-citations check and `deadcode` among its checks) and then every package one at a time (`-p 1`). A migration is applied by `test/reset-test-db.sh` before the section's tests run (`make test` runs it). A query change is followed by `make sqlc-generate`, and its diff is read: a change to a generated file the section's items do not name is a stop. No golden, route table or test result changes except where a section's items name it; any other change is a stop. A golden is rewritten with `-update` only where an item says so, and its diff is read before the commit. Sections 5, 7, 9, 10, 11 and 12 change what a page renders (section 7 through the demo seed's ledger acts, 7.9), so each runs `make screens` after its tests pass, and `make screens` resets the app database to the demo state; sections 2, 3, 4, 6 and 8 run it too and expect no change. Every row of the design's Copy table is approved or reuses an existing string; a string that differs from its entry is a stop. A script's usage is learned from its header or source, never by running it. No step calls Stripe except items 10.1, 11.1 and 13.4, which run in a Stripe sandbox with test keys.
Sections 1 to 12 are one commit each, and their items are that commit's parts; section 13 is not a commit. The sections are ordered so that each passes alone: section 1 moves the rule with no behaviour change; sections 2 to 6 pause and resume collection and say so to the operator; section 7 delivers the subscription in the ending act (#270); section 8 lets a tier at or above the grant take the ladder; sections 9 to 12 are the member's side (#277, #269, #278, and Resume plan under a grant ranked below the paid tier); section 9's offer rule answers no switch and no Resume yet, because the preview and the resolver reach a paying subscription only from section 10 and the switch clears the pause before its update only from section 11, which turns the switch's answer into Switch, and section 12 turns the paid tier's answer into Resume once the act, the route and the card's control exist. Between sections 4 and 7 a grant's end still delivers the subscription within seconds, because the outbox's reconcile that clears the pause confers the tier; section 7 moves that into the ending act. Every commit passes `make test` with the test stack up, which runs `make lint` first (`filler-copy`, `em-dash-copy`, `screen-coverage`, the notebook-citations check and `deadcode` among its checks) and then every package one at a time (`-p 1`). A migration is applied by `test/reset-test-db.sh` before the section's tests run (`make test` runs it). A query change is followed by `make sqlc-generate`, and its diff is read: a change to a generated file the section's items do not name is a stop. No golden, route table or test result changes except where a section's items name it; any other change is a stop. A golden is rewritten with `-update` only where an item says so, and its diff is read before the commit. Sections 5, 7, 9, 10, 11 and 12 change what a page renders (section 7 through the demo seed's ledger acts, 7.9), so each runs `make screens` after its tests pass, and `make screens` resets the app database to the demo state; sections 2, 3, 4, 6 and 8 run it too and expect no change. Every row of the design's Copy table is approved or reuses an existing string; a string that differs from its entry is a stop. A script's usage is learned from its header or source, never by running it. No step calls Stripe except items 10.1, 11.1 and 13.4, which run in a Stripe sandbox with test keys.
**Breaks.** Each section's list is written to a file outside the repository and run with `go run ./scripts/mutation-check -list <file> -side new` on the section's commit, from the repository root, after `test/reset-test-db.sh` and 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 or of a generated sqlc file is a test record (`file`, `old`, `new`, `pkg`, `run_new`, `want_new`); a break of a sqlc query edits the generated `.sql.go` file beside the querier, whose constant the test runs, not the `.sql` file. A break of a migration's SQL is a command record: `cmd` is `(cd test && ./reset-test-db.sh) && go test -p 1 -count=1 -run '<pattern>' <pkg>`, `exit` 1, and the list carries `"prepare": "cd test && ./reset-test-db.sh"`, so each record starts from the restored migration and the last `prepare` restores it. Every record's `want_new` is a text the failing test prints only when it fails, never a test or subtest name; for a golden it is `<path> differs from what the code produced`, with the path as the test passes it. An `old` text that occurs more than once in its file is extended with the lines around it until it occurs once. 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. A break that no test of its own section sees is named with the reason, and the commit message says so. The commit message lists every break with its result.
@@ -323,42 +323,143 @@ Sections 1 to 12 are one commit each, and their items are that commit's parts; s
## 11. The switch of a paused subscription (#278)
- [ ] 11.1 Sandbox check, before the code (design D12, What is unverified): on a Stripe sandbox test clock at the pinned version, with the restricted test key: (a) a subscription paused with the marker, then one update carrying `items`, `proration_behavior=always_invoice`, `proration_date`, `payment_behavior=pending_if_incomplete`, `pause_collection=` and `metadata[member_console_collection]=`; (b) the same on a second subscription whose default payment method declines (`pm_card_chargeCustomerFail`), reading whether the clear is held pending with the item; (c) `invoice.CreatePreview` for the paused subscription with the new item and `subscription_details[proration_behavior]=always_invoice`, recording its `amount_due` and comparing it with the total of the one-update switch's proration invoice on the same clock (200 in the sandbox); (d) if (a) is refused, the same update with `payment_behavior=error_if_incomplete`, which keeps one atomic update and loses the approval path. The results are recorded in the commit message and fix the preview's form. If (a) is refused, both fallbacks, `error_if_incomplete` and the two-call form, go to the maintainer before item 11.2.
- [ ] 11.2 `internal/fulfillment/plan_change.go`: `upgradeNow` gains `clearPause bool`, set from the subscription `upgradeNow` fetches first (`:292`) alone: true when it carries the console's pause or a stale marker, whatever holds the ladder, because a subscription that holds its ladder is delivering and clearing its pause is correct; the clear's two parameters ride the one update (with the `payment_behavior` 11.1 and, on a refusal of (a), the maintainer settle), or, if the maintainer picks the two-call form, a separate clear call precedes the unchanged upgrade, its doc comment saying why; in the two-call form, a declined or approval-pending upgrade calls `ReconcileSubscription` (design D12). The preview's form is fixed once, at build time, by 11.1(c): if Stripe refuses the preview or its figure differs from the update's, `PreviewSwitch` returns the unavailable preview for a subscription carrying the console's pause, which it reads from the resolved row's mirror (`ls.sub.PauseCollectionBehavior` set and `ls.sub.PauseCollectionMarked`, carried by `GetSubscriptionByID` from section 2), because it fetches no subscription, and the confirmation uses the existing fallback sentence; if the figures are equal, it returns Stripe's figure, and a refusal by Stripe at run time still gives the unavailable preview.
- [ ] 11.3 `internal/server/member_plan_moves.go`: `planMoveMessage` takes `replacesGrant bool`; with it the second paragraph opens "Your subscription replaces the grant." (Copy M3); `GetSwitchPreview` and `PostSwitch` pass it when the offer's position has `Paying` with `GrantHolds`. `plans.Offer` rule 3a item 2 answers Switch in place of section 9's none, with relation `Upgrade` set by the rule itself, because `Offer` sets the current product's relation to `Downgrade` (`offer.go:210`). The granted tier's current card draws the switch through three changes: `buildTier` reads the current card's default price when `pos.Paying` has `GrantHolds` (it builds the current card's target with no price, `member_plans.go:424-428`, so `Offer`'s price rule would answer `NoPrice` and the control would read "Not available for purchase yet"); the current card carries the fields `setMove` sets (`member_plans.go:478-495`), `Relation` kept "current", because without `MoveEnabled` the template draws the control disabled and `plansOfferPaidMove` does not count it (`member_plans.go:593-601`); and the template draws the switch markup inside the current branch (it draws move controls only under `{{ if ne .Relation "current" }}`, `member_plans.html:124`), labelled with the existing "Upgrade" (Copy M6).
- [ ] 11.4 Tests:
- `TestSwitchAPausedSubscription_OneUpdate` (`internal/fulfillment/plan_change_paid_under_grant_test.go`): the switch to Max sends one update whose form holds the item, `always_invoice`, `pause_collection=` and `metadata[member_console_collection]=`; the reconcile confers Max over the grant. Failure texts: `switch call form = %v, want the item, the clear and the marker removed`; `after the switch, the ladder holds %v, want Max`.
- `TestSwitchAPausedSubscription_ToTheGrantedTier`: Pro, a transfer; the grant recorded as replaced by S. Failure text: `the grant is not recorded as replaced by the subscription`.
- `TestSwitchAPausedSubscription_ADeclineLeavesItPaused`: the mock answers the update with the pending update (`pending_update` set, the invoice open); the abandoned invoice is voided, the subscription keeps Plus, and no conferral is written. Failure text: `a declined switch changed the ladder: %v`.
- `TestSwitchAPausedSubscription_AForeignPauseIsNotCleared`: no `pause_collection` parameter is sent and Stripe's 400 answers the generic failure. Failure text: `a foreign pause was cleared by the switch: %v`.
- `TestSwitchAPausedSubscription_AHoldingSubscriptionStillPausedClearsIt`: S holds its ladder again and the mock's subscription still carries the console's pause; its upgrade sends the clear and the marker's removal. Failure text: `a holding subscription's upgrade sent no clear: %v`.
- `TestSwitchAPausedSubscription_AnOrdinaryUpgradeSendsNoClear`: an upgrade of a subscription whose Stripe object carries no pause sends no `pause_collection`. Failure text: `an ordinary upgrade sent the clear: %v`.
Section 11 is built on #285's commit, which gives a subscription reconcile's Stripe calls the reconcile's context and records on the Stripe mock's `Call` the deadline each call carried (`Deadline`) and whether its context was already done when the mock received it (`ContextDone`); section 11's tests read both. Line numbers in section 11 are at `1d0e2be9`.
- [x] 11.1 Sandbox check, before the code (design D12, What is unverified): on a Stripe sandbox test clock at the pinned version, with the restricted test key: (a) a subscription paused with the marker, then one update carrying `items`, `proration_behavior=always_invoice`, `proration_date`, `payment_behavior=pending_if_incomplete`, `pause_collection=` and `metadata[member_console_collection]=`; (b) the same on a second subscription whose default payment method declines (`pm_card_chargeCustomerFail`); (c) `invoice.CreatePreview` for the paused subscription with the new item and `subscription_details[proration_behavior]=always_invoice`, compared with the switch's invoice; (d) if (a) is refused, the same update with `payment_behavior=error_if_incomplete`. Result: (a) and (b) are refused with 400 before any change ("When `payment_behavior` is set to `pending_if_incomplete`, you can only pass supported params. `pause_collection` is not supported."); (c) is refused with 400 ("You cannot create a new invoice for a paused subscription."), and on the same subscription unpaused the preview's `amount_due`, 267, equals the switch's invoice; (d) is atomic, paid at once ($2.67), and on a declining card answers 402 with nothing changed. Both fallbacks were put to a ruling with these further rows: two calls, the clear and then the existing update, leave a declining card's subscription unpaused at the old price with a pending update and an open invoice, and a card needing authentication with a pending update that Stripe lets the console pause again, paying it then applying the update with the pause set; `allow_incomplete` and `default_incomplete` apply the switch unpaid (`past_due`, the pause gone) on a declining card (both) or on one needing authentication (`allow_incomplete`; `default_incomplete` was not run with one); the two calls with an immediate re-pause on a declining card stay paused on the old price, 4.76 seconds unpaused through the CLI, and 25 hours later the pending update has expired, its invoice and the renewal are voided and nothing is charged. The ruling took the two calls with the restore, the backstop entry, the finalization guard, the deadlines and Cancel plan's void (design D12, D11). The rows run after the ruling: a decline in the code's order (the clear, the update, the void of its invoice, the reconcile's set: the void drops the pending update, and 25 hours later the renewal is voided and nothing is charged); an approval left unpaid (25 hours later the pending update has expired, its invoice and the renewal are voided, nothing is charged); a next attempt after an abandoned approval (the open invoice voided while paused, then the clear and a new switch with a good card, applied and paid, never two pending updates); Cancel plan with the void first (the pending update gone, the flag set, the pause kept) and without it (Stripe keeps the pending update, and paying its invoice applies the switch with the cancellation set); a schedule-managed subscription (the clear and the switch apply, paid, and the schedule releases); monthly to yearly ($270.32 charged, a full year less the unused month, the billing anchor moved; design D12, "A price of another interval", ruled after this row, item 11.8); the guard's read with the console's key (the draft listed with `billing_reason=subscription_cycle`, `auto_advance=true`, `next_payment_attempt` = `automatically_finalizes_at` = its creation + 3600, and the renewal voided after a clear and a re-pause inside its hour); a SEPA Direct Debit default (Stripe refuses `pending_if_incomplete` for it, the restore's set pauses the subscription again, the renewal is voided; design, Risks); a `proration_date` an hour before the period's start (refused with 400, the restore's set pauses again). The results go into section 11's commit message.
- [x] 11.2 The paused switch's code (design D12, D11, D1), in these parts:
- The outbox's delay (proposal, the paused switch's restructuring outside the switch): `internal/integration/outbox.go` gains `EnqueueAt(ctx, db DBTX, providerKey, actionType string, payload any, notBefore time.Time) error`, which inserts the row with `next_attempt_at` = `notBefore`; its doc comment says the relay takes the row only from that time and that `Enqueue`, unchanged, leaves the column's default, `NOW()`. `internal/entitlements/outbox_requests.go` gains `RequestSubscriptionReconcileAt(ctx, tx integration.DBTX, subscriptionID string, notBefore time.Time) error` over it. `PollPendingOutboxEntries` (`internal/integrations/stripe/workflows/outbox.go:202-225`) gains `AND next_attempt_at <= NOW()`; its doc comment says an entry written with a later time waits for it, and that every other writer sets `NOW()` (the column's default, `internal/db/migrations/00001_init.sql:1091`, and `internal/integrations/stripe/catalogsync/catalogsync.go:145`), so every other entry is polled as before. The boot sweep (`unfinishedOutboxEntriesSQL`) is unchanged: it takes only `failed` and `processing` rows. The `stripe-integration-infrastructure` delta states it.
- `internal/fulfillment/paused_switch.go` (new) holds the paused form's parts, so `upgradeNow` stays within the complexity limit: the constants `pausedSwitchCallTimeout = 20 * time.Second`, `pausedSwitchChargeTimeout = 60 * time.Second`, `pausedSwitchRestoreTimeout = 60 * time.Second`, `pausedSwitchBackstopDelay = 4 * time.Minute` and `renewalFinalizeMargin = 10 * time.Minute`, under one doc comment that states design D12's arithmetic (the delay is longer than the request's bounded length from the backstop's insert, 20 + 20 + 60 + 20 + 60 seconds; the margin covers the delay, the relay's 15-second poll and one outbox attempt, `outboxAttemptTimeout`); `consolePaused(sub *stripe.Subscription) bool`, reading no row: `PauseCollection` set and `Metadata[collectionMarkerKey] == collectionMarkerValue`; `stripeCallContext(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc)`: `context.WithTimeout(context.WithoutCancel(ctx), d)`; `renewalFinalizesSoon(drafts []*stripe.Invoice, now time.Time) bool`, reading no row: true when a draft has `BillingReason` `subscription_cycle`, `AutoAdvance`, and the later of `NextPaymentAttempt` and `AutomaticallyFinalizesAt` at or before `now` + `renewalFinalizeMargin`, a time already passed included, so a field Stripe leaves null (zero in stripe-go) is passed over when the other is set, and a draft with both null refuses; `guardRenewalFinalization(ctx context.Context, stripeSubID string) error`: `invoice.List` with `Subscription`, `Status: "draft"` and `Limit: 10` under `stripeCallContext(ctx, pausedSwitchCallTimeout)`, a failed list or iterator returning `fmt.Errorf("switch: list the subscription's draft invoices: %w", err)`, otherwise `ErrRenewalFinalizing` when `renewalFinalizesSoon` holds with `time.Now()` taken after the list answered; the package variable `requestBackstop`, holding `entitlements.RequestSubscriptionReconcileAt`, `TestSwitchAPausedSubscription_TheBackstopIsWrittenBeforeTheClear`'s seam, as its doc comment says; and `restorePause(ctx, db, logger, keyMode, coreSubID, stripeSubID string)`, which calls `ApplySubscriptionCollection` with `stripeCallContext(ctx, pausedSwitchRestoreTimeout)`, the reason `member:switch:restore`, and on an error logs L7 (Warn, `switch: the collection pause could not be restored; the outbox entry restores it`, attributes `stripe_subscription_id`, `error`) and returns nothing. Its doc comments state design D12's reasons: why two calls, why the restore is the reconcile's decision and never a blind set, what the backstop adds over the clear's echo, and why the guard reads the later of the two times and what it does with a null one. `internal/fulfillment/export_test.go` gains `SetRequestBackstop(t testing.TB, f func(context.Context, integration.DBTX, string, time.Time) error)`, which restores the variable in `t.Cleanup`, and exports the four durations the tests read (`pausedSwitchCallTimeout`, `pausedSwitchChargeTimeout`, `pausedSwitchRestoreTimeout`, `pausedSwitchBackstopDelay`) for the tests in `fulfillment_test`; a test that sets the seam does not call `t.Parallel`.
- `internal/fulfillment/plan_change.go`: `ErrRenewalFinalizing = errors.New("switch: a paused renewal is about to finalize")` beside the other refusals, its doc comment saying that the handler's default answers it with the existing "We couldn't complete that change. Try again in a moment." and that nothing was written, and that a failed list returns its own error, which the default answers the same way. `ErrStalePreview`'s doc comment (`:93-97`) gains "or from before the current period", which the paused form answers in its step 1. `upgradeNow` (`:284-374`) keeps its first fetch (`:296-299`) unchanged, and the subscription it returns decides the form: when `consolePaused` is false (no pause, a stale marker, a foreign pause) the upgrade is today's, unchanged; when it is true, the paused form runs, in this order:
1. a `prorationDate` before `time.Unix(current.CurrentPeriodStart, 0)` returns `ErrStalePreview`, before any other call or write, because Stripe refuses a `proration_date` outside the current period (item 11.1) and would refuse call 2 after the clear;
2. `guardRenewalFinalization(ctx, ls.stripeSubID)`; an error returns before any write;
3. `requestBackstop(ctx, db, ls.coreSubID, time.Now().Add(pausedSwitchBackstopDelay))`, one insert on `db`, which commits on its own before any Stripe write; an error returns `fmt.Errorf("switch: write the collection backstop: %w", err)`;
4. the void of an abandoned upgrade's invoice (`:300-308`, `voidAbandonedUpgrade`), its params' `Context` set to `stripeCallContext(ctx, pausedSwitchCallTimeout)`;
5. a deferred step that runs `restorePause` unless `applied` is set, so it runs on every return from here and on a panic, which it does not recover;
6. call 1, `subscription.Update(ls.stripeSubID, collectionParams(collectionClear))` with `stripeCallContext(ctx, pausedSwitchCallTimeout)`; an error returns `fmt.Errorf("switch: clear the collection pause before the upgrade: %w", err)`;
7. call 2, the update `upgradeParams` builds, with nothing changed in its form, its `Context` set to `stripeCallContext(ctx, pausedSwitchChargeTimeout)`;
8. its answer as today: applied (no error and `PendingUpdate` nil) sets `applied` and runs the applied branch (`:355-373`) under `context.WithoutCancel(ctx)`; a pending update whose PaymentIntent is `requires_action` returns `UpgradeNeedsApprovalError`; any other pending update voids its invoice, the void given `stripeCallContext(ctx, pausedSwitchCallTimeout)`, and returns `ErrUpgradeDeclined`; an error returns wrapped as today (`:330-332`).
Today's form and the paused form share the code of `:300-373` through helpers, so call 2's form is written once: `voidAbandonedUpgrade(ls, current, params *stripe.InvoiceVoidInvoiceParams) error` (`:300-308`), `upgradeParams(ls, current, targetStripePriceID, prorationDate) *stripe.SubscriptionParams` (`:310-328`), `upgradeAnswer(logger, ls, updated, voidParams *stripe.InvoiceVoidInvoiceParams) error` (`:334-353`) and `appliedUpgrade(ctx, db, logger, keyMode, billingQ, ls, current, updated, reason) error` (`:355-373`); today's form passes `nil` void params and no `Params.Context`, as today. `upgradeNow`'s doc comment names the two forms and points to `paused_switch.go`.
- `CancelSubscription` (`plan_change.go:617-687`, design D11): for a subscription that pays without holding its ladder (`!ls.holdsLadder`), the fetch expands `latest_invoice`, and, when `PendingUpdate` is set and the latest invoice is open, `voidAbandonedUpgrade` voids it before the schedule's release (`:651-655`) and before either timing's call; for such a subscription the expanded fetch and the void run after the fee check and before the commitment's deferral (`:635-641`), ahead of the fetch at `:647`, so a cancellation a commitment defers to its boundary voids as well and one the fee policy refuses voids nothing; a failed void returns `fmt.Errorf("cancel: void an abandoned switch's invoice: %w", err)` before any other write. A subscription that holds its ladder keeps today's fetch and form. The function's doc comment says why the void comes first and why only a subscription that pays without holding.
- `PreviewSwitch` (`plan_change.go:542-605`): after the downgrade branch, a subscription whose row records any pause (`ls.sub.PauseCollectionBehavior.Valid`) returns the `unavailable` preview (`:580-581`) before the price mapping read and with no Stripe call; its doc comment says Stripe answers no preview for a paused subscription, a foreign pause included, and that the unavailable preview keeps `ProrationDate`, so the switch never answers `ErrStalePreview` for a missing moment.
- [x] 11.3 `internal/server/member_plan_moves.go`: `planMoveMessage` (`:29-47`) takes `replacesGrant bool`; with it the second paragraph opens "Your subscription replaces the grant." (Copy M3). `plans.Move` (`internal/plans/offer.go:157-164`) gains `ReplacesGrant bool`, which rule 3a item 2 sets on its Switch, the position having `Paying` with `GrantHolds`; `switchOffer` (`internal/server/plan_offer.go:469-519`) returns the move it allowed beside its error, `switchOfferFor` (`:425-441`) passes it on, and `renderSwitchPreview` (`member_plan_moves.go:108-140`) takes it, so `GetSwitchPreview` and `PostSwitch`, the re-renders after a decline and after a stale preview included, pass the move's `ReplacesGrant` with no read of their own. `plans.Offer` rule 3a item 2 (`payingRules`, `offer.go:281-295`) answers Switch in place of section 9's none, unless `Paying.OtherLadders` is set (the paying product is a tier of another ladder too) or `Target.OtherLadders` is (the target is; `plans.Target` gains the field, which `switchOffer` and `buildTier` set from `ListLaddersByProduct` through one read per product on the `positionLoader`), when the position falls to items 3 and 4 as D20 refuses Resume for it, with relation `Upgrade` set by the rule itself, because `Offer` sets the current product's relation to `Downgrade` (`offer.go:255`). The granted tier's current card draws the switch through three changes: `buildTier` reads the current card's default price when `pos.Paying` has `GrantHolds` (it builds the current card's target with no price, `member_plans.go:456-461`, so `Offer`'s price rule would answer `NoPrice` and the control would read "Not available for purchase yet"); the current card carries the fields `setMove` sets (`member_plans.go:529-546`), `Relation` kept "current", because without `MoveEnabled` the template draws the control disabled and `plansOfferPaidMove` does not count it (`member_plans.go:643-655`); and the template draws the switch markup inside the current branch (it draws move controls only under `{{ if ne .Relation "current" }}`, `member_plans.html:146`), labelled with the existing "Upgrade" (Copy M6). `PostSwitch` maps no new error: `ErrRenewalFinalizing` reaches `renderPlansAfterMove`'s default (`:351-352`). The test callers change with the signatures: `member_switch_gate_test.go:76` and `grant_resumption_member_test.go:707` take the move beside the error, `grant_resumption_member_test.go:936` passes a move to `renderSwitchPreview`, and `TestPlanMoveMessageStatesTheTiming`'s table carries `then`, `replacesGrant` and `newPeriodCharge` per row in place of its one `then` (`plan_move_message_test.go:19`, `:39`, `:48`).
- [x] 11.4 Tests. The fulfillment tests (`internal/fulfillment/paused_switch_test.go`) build on `plan_change_paid_under_grant_test.go`'s `payingUnderGrant` (through `payingUnderGrantOf`, which returns the second tier and the grant as well) and a helper `pausedStripe(mock, target, outcome)`, which extends `stripeLike`: a subscription POST holding `pause_collection=` and no item clears `mock.Sub`'s pause and marker; one holding `pause_collection[behavior]=void` sets them; one holding `items[0][price]` while `mock.Sub.PauseCollection` is set answers Stripe's 400 ("You cannot create a new invoice for a paused subscription.", as the sandbox answered), so a form that skips the clear fails as at Stripe; otherwise the item update answers the row's outcome: `applied` (the target price on `mock.Sub`, no pending update), `declined` (a subscription carrying `pending_update` and an open `latest_invoice` whose PaymentIntent is `requires_payment_method`, `mock.Sub`'s item unchanged), `approval` (the same with `requires_action` and a hosted URL), or an error; a POST to `/v1/invoices/{id}/void` clears `mock.Sub`'s pending update. "Writes" are the POSTs from the switch on, read as `stripetest.Line` texts. Backstop entries are S's `reconcile_subscription` entries whose `next_attempt_at` lies at least a minute after the switch began; the immediate entry a reconcile writes for a stale marker is not one. `TwoCalls` and `AHoldingSubscriptionStillPausedClearsIt` compare the writes before the error, and carry it in their texts.
- `TestSwitchAPausedSubscription_TwoCalls`: the switch to Max, `applied`; the writes are the clear (its form holds `pause_collection=` and `metadata[member_console_collection]=` and no `items[0][price]`), then the update (its form holds `items[0][id]`, `items[0][price]`, `proration_behavior=always_invoice`, `proration_date`, `payment_behavior=pending_if_incomplete`, and no key that starts with `pause_collection` or names the marker), and nothing after; the reconcile confers Max over the grant. Failure texts: `switch writes = %v (error %v), want the clear and then the unchanged update`; `after the switch, the ladder holds %v, want Max`.
- `TestSwitchAPausedSubscription_ToTheGrantedTier`: Pro, a transfer; the grant recorded as replaced by S. Failure text: `the grant is not recorded as replaced by the subscription (error %v, %d replacements)`.
- `TestSwitchAPausedSubscription_ADeclineIsPausedAgain`: `declined`; `ErrUpgradeDeclined`; the writes are the clear, the update, the void of its invoice, and the restore's set (`pause_collection[behavior]=void` and `metadata[member_console_collection]=paused_for_grant`); S keeps Plus and no conferral is written. Failure texts: `after a declined switch, writes = %v, want the clear, the update, the void and the set`; `a declined switch changed the ladder: %v`. S's row is not read: `payingUnderGrant` records the pause before the switch, so the mirror reads `void` and the marker whether or not the restore ran, and the writes check does the work.
- `TestSwitchAPausedSubscription_AnApprovalIsPausedAgain`: `approval`; `UpgradeNeedsApprovalError` with the hosted URL; the writes are the clear, the update and the restore's set, and no void. Failure text: `after a switch awaiting approval, writes = %v (error %v), want the set after the update and no void`.
- `TestSwitchAPausedSubscription_EveryOtherExitRestores`, rows through `checkRestoreAfter`: `the update answers 500`; `the update answers 400`; `the clear answers 500 after Stripe applied it` (the handler applies the clear to `mock.Sub`, then answers the error); `a panic in the update` (the handler panics on the item update; the test recovers it). In each, the switch fails (or panics) and the last write is the restore's set. Failure text: `%s: writes after the clear = %v, want the restore's set`. Row `the update answers 500 after Stripe applied it` (the handler moves `mock.Sub` to Max, then answers the error): the switch fails, the restore sends no set, and the ladder holds Max (design F67). Failure text: `call 2 applied despite its error: writes after the clear = %v and the ladder holds %v, want no set and Max`.
- `TestSwitchAPausedSubscription_AClosedRequestStillRestores`, rows `declined` and `applied`: the switch runs under a context the handler cancels when the clear lands. Every Stripe call after the clear carries `ContextDone` false; `declined`: the last write is the restore's set (`pause_collection[behavior]=void` and `metadata[member_console_collection]=paused_for_grant`), sent with `ContextDone` false; `applied`: the ladder holds Max. Failure texts: `%s: after the request's context was cancelled, %s was sent with a cancelled context`; `declined: after the request's context was cancelled, writes after the clear = %v, want the restore's set last`; `applied: after the request's context was cancelled, the ladder holds %v, want Max`.
- `TestSwitchAPausedSubscription_AHoldingSubscriptionStillPausedClearsIt`: S holds its ladder (the fixture's `subscribed`, no grant) and `mock.Sub` carries the console's pause; `applied`; the writes are the clear and then the update. Failure text: `a holding subscription's upgrade sent no clear: %v (error %v)`.
- `TestSwitchAPausedSubscription_AHoldingSubscriptionIsNotPausedAgain`: the same S, `declined`; the writes are the clear, the update and the void, and no set. Failure text: `a holding subscription was paused again after a declined upgrade: %v`.
- `TestSwitchAPausedSubscription_AStaleMarkerSendsNoClear`: `mock.Sub` carries the marker and no pause; the writes are today's update alone, no draft list is sent, and no backstop entry is written. Failure text: `with a stale marker, writes = %v, draft lists %d and backstop entries %d, want today's update alone`.
- `TestSwitchAPausedSubscription_AForeignPauseIsNotCleared`: a pause without the marker; no `pause_collection` parameter is sent, no draft list, no backstop entry, and Stripe's 400 answers the generic failure. Failure text: `a foreign pause was cleared by the switch: %v`.
- `TestSwitchAPausedSubscription_AnOrdinaryUpgradeSendsNoClear`: an upgrade of a subscription whose Stripe object carries no pause sends no `pause_collection`, lists no drafts and writes no backstop entry. Failure text: `an ordinary upgrade sent the clear, listed drafts or wrote a backstop: %v, %d`.
- `TestSwitchAPausedSubscription_TheGuardRefusesNearAFinalization`, rows through `checkGuard`, each with one draft of S in `mock.Invoices` (times relative to the test's start): `a renewal draft finalizing within the margin` (both times 5 minutes on: refused); `a renewal draft past its time, still a draft` (both a minute ago: refused); `next_payment_attempt within the margin, automatically_finalizes_at beyond it` (5 and 20 minutes on: switches); `automatically_finalizes_at within the margin, next_payment_attempt beyond it` (switches); `a renewal draft beyond the margin` (both 20 minutes on: switches); `a draft that is not a renewal` (`subscription_update`, both 5 minutes on: switches); `a renewal draft with auto_advance false` (switches); `both times null` (both zero: refused); `next_payment_attempt null, automatically_finalizes_at beyond the margin` (zero and 20 minutes on: switches); `the list answers 500` (`mock.PathErrs["/v1/invoices"]`: refused with the list's error). A refused row returns `ErrRenewalFinalizing` (or the list's error), sends no write and writes no backstop entry; a row that switches sends the clear and the update. Failure text: `%s: switch = %v with writes %v and %d backstop entries, want %s`.
- `TestSwitchAPausedSubscription_TheBackstopIsWrittenBeforeTheClear`: when the clear lands, the handler counts S's `reconcile_subscription` entries written since the switch began, one, and reads its `next_attempt_at`, at least `pausedSwitchBackstopDelay` less a second after the switch began. Rows `the entry` and `a failed backstop insert` (`requestBackstop` set to return an error): the switch returns it and sends no write. Failure texts: `when the clear was sent, the backstop entries were %d, want 1`; `the backstop's next_attempt_at = %v, want at least %v`; `a failed backstop insert = %v with writes %v, want an error and no Stripe write`.
- `TestSwitchAPausedSubscription_EveryCallCarriesADeadline`, rows `declined` and `approval`: `OnCall` records when each call was sent; the draft list, the clear and, in `declined`, the void carry a deadline `pausedSwitchCallTimeout` after they were sent and the update one `pausedSwitchChargeTimeout` after, each within a second; the restore's calls (its fetches and its set) share one deadline at most `pausedSwitchRestoreTimeout` after the first of them. Failure texts: `%s: %s carried no deadline`; `%s: %s's deadline is %v after it was sent, want %v`; `%s: the restore's calls carry deadlines %v, want one within %v of the first`.
- `TestSwitchAPausedSubscription_AnAbandonedUpgradeIsVoidedFirst`: `mock.Sub` carries a pending update and an open `latest_invoice`; the writes are the void, the clear and the update, and the void carries a deadline `pausedSwitchCallTimeout` after it was sent. Failure texts: `with an abandoned upgrade, writes = %v (error %v), want the void, the clear and the update`; `%s: %s carried no deadline`.
- `TestSwitchAPausedSubscription_AFailedRestoreIsLoggedAndTheDeclineIsAnswered`: `declined`, and the restore's set answers 500 (the handler's `setHook`): the switch returns `ErrUpgradeDeclined` and does not panic (F64), and one log line holds `switch: the collection pause could not be restored; the outbox entry restores it`, `stripe_subscription_id=` and `error=` (L7). Failure texts: `a failing restore changed the member's answer: error %v, panic %v, want the decline`; `a failed restore logged no line holding all of %q:\n%s`.
- `TestSwitchAPausedSubscription_AMarkedGrantResumesWhenItEnds`: a grant marked to resume is replaced by the switch, and when S later ends within the grant's term the grant holds the ladder again. Failure text: `after S ended, the marked grant holds %v, want its product`.
- `TestPreviewSwitchOfAPausedSubscription`, in the form 11.1(c) found. Unavailable (Stripe refused the preview, or its figure differs from the update's charge): `PreviewSwitch` of a subscription carrying the console's pause returns the unavailable preview. Equal: it returns the mock's figure. Failure text: `paused preview = %+v, want unavailable` (equal form: `paused preview = %+v, want the mock's figure`).
- `TestOffer` rows (`internal/plans/offer_test.go`): `a tier above a grant that displaced a subscription` (a switch, enabled, relation Upgrade); `the granted tier over a paying subscription` (a switch, enabled, relation Upgrade); `a tier above the paid tier over a lower grant` (a switch, enabled, relation Upgrade); `the granted tier below the paid tier` (none, reason `Current`). Failure text unchanged.
- `TestPreviewSwitchOfAPausedSubscription`, rows `the console's pause`, `a foreign pause` (the row records the behavior and no marker) and `no pause recorded` (the mock answers a preview figure): the first two return the unavailable preview with `ProrationDate` set and send no Stripe call; the third returns the mock's figure. Failure text: `%s: preview = %+v with %d Stripe calls, want %s`.
- `TestCancelAPausedSubscriptionVoidsAnAbandonedSwitch`: `payingUnderGrant`, `mock.Sub` carrying a pending update and an open `latest_invoice`; Cancel plan at the period end sends the fetch carrying an `expand` key that holds `latest_invoice`, then the void of that invoice, then `cancel_at_period_end=true`, and the pause is kept; row `the void refused` (a 500 on the void's path): the cancellation returns the error and sends no `cancel_at_period_end`; row `a cancellation a commitment defers` (S under a 'block' commitment whose end lies after the period end): the void is sent, no `cancel_at_period_end`, and the scheduled cancellation is recorded. Failure texts: `cancel writes = %v, want the void of the abandoned switch's invoice and then cancel_at_period_end`; `a refused void = %v with writes %v, want an error and no cancellation`; `a cancellation a commitment defers: writes = %v and scheduled %v, want the void, no cancel_at_period_end and the cancellation scheduled`.
- `TestCancelAHoldingSubscriptionVoidsNothing`: a subscription that holds its ladder, `mock.Sub` carrying a pending update and an open `latest_invoice`; Cancel plan sends today's fetch, with no expansion, and no void. Failure text: `a holding subscription's cancel sent %v, want today's fetch and no void`.
- `TestOutboxPollSkipsAnEntryNotYetDue` (`internal/integrations/stripe/workflows/outbox_activity_test.go`): one pending entry through `integration.EnqueueAt` an hour on and one through `integration.Enqueue`; the poll takes the second and not the first. `TestOutboxPollAndSweepSelectDisjointStates` passes unedited. Failure texts: `the poll took an entry not due until %v`; `the poll did not take the entry due now`.
- `TestOffer` rows (`internal/plans/offer_test.go`, on its ladder free, basic, standard, plus, pro, `:10-16`): the rows `a tier above the grant with a paying subscription` and `the granted tier over a lower paid tier` (`:213-216`), which want `PaysOnLadder` at HEAD, are renamed `a tier above a grant that displaced a subscription` and `the granted tier over a paying subscription` and want a switch, enabled, relation Upgrade, `ReplacesGrant`; `a tier above the paid tier over a lower grant` (a switch, enabled, relation Upgrade, `ReplacesGrant`); `the granted tier below the paid tier` (none, reason `Current`); `a tier above a grant over a bundle's subscription` (`Paying` has `OtherLadders`: none, reason `PaysOnLadder`), `a bundle tier above a grant that displaced a subscription` (`Target` has `OtherLadders`: none, reason `PaysOnLadder`) and `the granted tier over a bundle's subscription` (none, reason `Current`). Failure text unchanged. `offerCase` (`:281-290`) gains `replacesGrant` and `newPeriod`, which `checkOffer` (`:294-301`) puts in its want, and `payingOn` (`:246-249`) gains the paying price's currency and interval.
- `TestPlanMoveMessageStatesTheTiming` (`internal/server/plan_move_message_test.go`) row `replaces the grant` equals M3's whole text with an amount, and its fallback row with the prorated-difference sentence. Failure text unchanged.
- `TestPostSwitchFromAPausedSubscription` (`internal/server/member_switch_offer_db_test.go`) runs three named fixtures. `a Pro grant over Plus`, rows: `Max` (answers the submitted message); `Plus`, the paid tier (the unavailable-plan sentence); `past due` (`Suspend(sub, "past_due")`, Max posted: "Pay the unpaid invoice before changing this plan."); `paused` (`Suspend(sub, "paused")`, Max posted: "A paused plan cannot be changed."). `a Basic grant over Pro`, rows: `Max` (answers the submitted message); `Plus`, between (the unavailable-plan sentence); `Basic`, the granted tier below the paid tier (the unavailable-plan sentence). `held by another subscription` (the kit's second `Subscribe` on the ladder holds the rung over the paying subscription, as `TestPostResume`'s row of that name builds it, 12.7), row: `the holder's current tier` ("You're already on that plan."). Failure text: `%s: answered %q, want %q`.
- `TestMemberPlansGolden`, six states rewritten with `-update` and read, the `.html` and `.json` of each under `internal/server/testdata/member_plans/`, because rule 3a item 2 has no condition on `ConsolePaused` or `MoveScheduled`: `paid-plus-under-granted-pro`, `paid-plus-under-granted-pro-unconfirmed`, `paid-plus-under-granted-pro-cancellation-scheduled`, `paid-plus-under-granted-pro-downgrade-scheduled` and `paid-retired-plus-under-granted-pro`, in each of which Pro's current card draws "Upgrade" and Standard ranks below the granted Pro and draws nothing (the retired state's own ladder, Basic, Plus and Pro, has no Standard); and `granted-plus-own-tier`, in which Standard's and Pro's cards on Wiki, and Duo Max's card on Duo, draw "Upgrade", because the fixture's Plus grant holds both ladders. Each named card's `.json` gains the switch's move fields. `paid-plus-under-granted-pro-past-due` and `paid-plus-paused-under-granted-pro` do not change, because their subscription is suspended.
- No other state's `.html` or `.json` changes: `TierViewModel` already carries every field `setMove` sets (`internal/server/member_plans.go:108-139`), so the current card's move adds no field; any other diff is a stop.
- `TestProductsPageReadsPayingOnce` passes unedited: its fixture holds no ladder a grant holds against a paying subscription, so 11.3's read of the current card's default price, `computePriceReadiness`'s statements once per such ladder (`internal/server/plan_offer.go:238-261`), adds nothing to its count.
- [ ] 11.5 Docs: `docs/stripe.md`, "Plan changes", gains the paused switch: one update that changes the item and clears the pause, why Stripe needs both in one update, and that its proration credits the unused time of the period whose renewal was voided.
- [ ] 11.6 Breaks:
- the clear not sent: caught by `TestSwitchAPausedSubscription_OneUpdate`, `want_new` `switch call form =`;
- the clear sent for a foreign pause: caught by `TestSwitchAPausedSubscription_AForeignPauseIsNotCleared`, `want_new` `a foreign pause was cleared`;
- the clear sent when Stripe carries no console pause: caught by `TestSwitchAPausedSubscription_AnOrdinaryUpgradeSendsNoClear`, `want_new` `an ordinary upgrade sent the clear`;
- the clear withheld from a subscription that holds its ladder (`clearPause` also tested on the ladder): caught by `TestSwitchAPausedSubscription_AHoldingSubscriptionStillPausedClearsIt`, `want_new` `a holding subscription's upgrade sent no clear`;
- in the unavailable form, the preview returning Stripe's figure for a paused subscription; in the equal form, the preview returning the unavailable preview: caught by `TestPreviewSwitchOfAPausedSubscription`, `want_new` `paused preview =`;
- the marked grant not recorded as replaced: caught by `TestSwitchAPausedSubscription_AMarkedGrantResumesWhenItEnds`, `want_new` `after S ended, the marked grant holds`;
- `TestPostSwitchFromAPausedSubscription` (`internal/server/member_switch_offer_db_test.go`) runs three named fixtures. `a Pro grant over Plus`, rows: `Max` (answers the submitted message); `Plus`, the paid tier (the unavailable-plan sentence); `past due` (Stripe's subscription `past_due`, reconciled, Max posted: "Pay the unpaid invoice before changing this plan."); `paused` (the same with `paused`, Max posted: "A paused plan cannot be changed."); the two rows reconcile the mock's subscription in place of the kit's `Suspend(sub, ...)`, because S exists only through the reconcile against the Stripe mock and the kit builds no such subscription; `a renewal finalizing` (the mock lists a renewal draft of the subscription finalizing in five minutes, Max posted: "We couldn't complete that change. Try again in a moment."); `a proration date before the period` (the mock's subscription has `current_period_start` a minute before the request and the posted `proration_date` is two minutes before it, Max posted: the confirmation again, whose heading reads "Switch to Max?"). `a Basic grant over Pro`, rows: `Max` (answers the submitted message); `Plus`, between (the unavailable-plan sentence); `Basic`, the granted tier below the paid tier (the unavailable-plan sentence). `held by another subscription` (a second subscription on the ladder, reconciled against the mock, holds the rung over the paying subscription; the kit's second `Subscribe` builds no Stripe subscription for the mock), row: `the holder's current tier` ("You're already on that plan."). Failure text: `%s: answered %q, want %q`.
- `TestMemberPlansGolden`, one state rewritten with `-update` and read, the `.html` and `.json` under `internal/server/testdata/member_plans/`, because rule 3a item 2 has no condition on `ConsolePaused` or `MoveScheduled`: `paid-retired-plus-under-granted-pro`, in which Pro's current card draws "Upgrade" (the retired state's own ladder, Basic, Plus and Pro, has no Standard, and Plus there is a tier of that ladder alone), and the card's `.json` gains the switch's move fields; its ladder gains a Max tier above Pro (`newRetiredPaidFixture`), so the state also pins a non-current card's switch (`Relation` "upgrade", "Upgrade"), and both its files gain that card. The other states offer no switch because the paying Plus is a tier of Wiki and Duo (`OtherLadders`): `paid-plus-under-granted-pro`, `-unconfirmed`, `-cancellation-scheduled`, `-downgrade-scheduled` and `granted-plus-own-tier` do not change. `paid-plus-under-granted-pro-past-due` and `paid-plus-paused-under-granted-pro` do not change either, because their subscription is suspended.
- No other state's `.html` or `.json` changes: `TierViewModel` (`internal/server/member_plans.go:95-212`) already carries every field `setMove` sets, so the current card's move adds no field; any other diff is a stop.
- `TestProductsPageReadsPayingOnce` passes unedited: its fixture holds no ladder a grant holds against a paying subscription, so 11.3's read of the current card's default price, `computePriceReadiness`'s statements once per such ladder (`defaultPrice`, `internal/server/plan_offer.go:359-382`), adds nothing to its count. The other fulfillment tests pass unedited: on #285's commit the reconcile's Stripe calls carry their caller's context, and every context those tests pass is live when the mock receives the call, so the mock answers as before.
- [x] 11.5 Docs: `docs/stripe.md`, "Plan changes", gains the paused switch: the two calls, the clear and then the unchanged upgrade, and why (Stripe refuses an `always_invoice` update while paused and refuses the clear beside `pending_if_incomplete`, which the approval path needs); that on a decline, an approval or any failure the console pauses the subscription again at once, and an outbox entry written before the clear does so within minutes if the process stops between the calls; that 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; that Cancel plan voids an abandoned switch's open invoice first; the money costs design D14 lists for the switch (its fifth cost, and the paused switch's four: a guard miss, a switch applied inside a paused renewal's draft hour charged that renewal on top, the approval window's three, and the seconds a paid approval delivers while paused); that a pause made in the Dashboard refuses the switch; that 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, and previews nothing once the console's pause is recorded; and that a tier priced in another currency is never offered, because Stripe refuses a change of a subscription's currency, nor any switch from a subscription whose tier is a tier of another ladder too, because the switch would move the subscription off that ladder (a tier of another ladder too is offered no switch either, because its conferral would supersede whatever holds that ladder, a grant included); invariant 19 of `docs/models/plan-ladders-transitions.md` says it, and `docs/stripe.md`'s paragraph reads "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". The restricted-key paragraph names `GET /v1/invoices` for the guard among the reads the key already holds. `docs/database-locks.md`, "Subscription reconcile", says that the paused switch writes its backstop entry as an insert before its Stripe writes, holding no lock, and that its restore is an ordinary reconcile. `docs/models/provider-integration.md:115` says the relay polls the pending rows whose `next_attempt_at` has passed.
- [x] 11.6 Breaks:
- the clear not sent (today's update alone on a paused subscription): caught by `TestSwitchAPausedSubscription_TwoCalls`, `want_new` `switch writes =`;
- the clear folded into call 2 (`pause_collection=` and the marker's removal added to `upgradeParams` for the paused form, call 1 dropped): caught by the same, `want_new` `switch writes =`;
- call 2's form changed (`payment_behavior` dropped from the paused form's update): caught by the same, `want_new` `switch writes =`;
- the restore not run after a decline: caught by `TestSwitchAPausedSubscription_ADeclineIsPausedAgain`, `want_new` `after a declined switch, writes =`;
- the restore not run after an approval: caught by `TestSwitchAPausedSubscription_AnApprovalIsPausedAgain`, `want_new` `after a switch awaiting approval, writes =`;
- the restore run only in the two pending branches, in place of the deferred step: caught by `TestSwitchAPausedSubscription_EveryOtherExitRestores`, `want_new` `the update answers 500: writes after the clear =`;
- the deferred step set up after call 1 answers, in place of before it: caught by the same, `want_new` `the clear answers 500 after Stripe applied it: writes after the clear =`;
- the restore called before each return, in place of a deferred step: caught by the same, `want_new` `a panic in the update: writes after the clear =`;
- `context.WithoutCancel` dropped from `restorePause`'s context: caught by `TestSwitchAPausedSubscription_AClosedRequestStillRestores`, `want_new` `declined: after the request's context was cancelled, writes after the clear =`;
- `context.WithoutCancel` dropped from `stripeCallContext`: caught by the same, `want_new` `was sent with a cancelled context`;
- the applied branch run under the request's context: caught by the same, `want_new` `applied: after the request's context was cancelled, the ladder holds`;
- the restore made a blind set (`applyCollectionAction(restoreCtx, logger, stripeSubID, collectionSet)` in place of `ApplySubscriptionCollection`): caught by `TestSwitchAPausedSubscription_AHoldingSubscriptionIsNotPausedAgain`, `want_new` `a holding subscription was paused again` (the `EveryOtherExitRestores` row `the update answers 500 after Stripe applied it` fails too);
- the scope widened to a stale marker (`consolePaused` reading the marker alone): caught by `TestSwitchAPausedSubscription_AStaleMarkerSendsNoClear`, `want_new` `with a stale marker, writes =`;
- the scope widened to a foreign pause (`consolePaused` reading the pause alone): caught by `TestSwitchAPausedSubscription_AForeignPauseIsNotCleared`, `want_new` `a foreign pause was cleared`;
- the scope narrowed to a subscription that does not hold its ladder (`&& !ls.holdsLadder` added to the form's choice): caught by `TestSwitchAPausedSubscription_AHoldingSubscriptionStillPausedClearsIt`, `want_new` `a holding subscription's upgrade sent no clear`;
- the paused form taken with no pause (`consolePaused` returning true): caught by `TestSwitchAPausedSubscription_AnOrdinaryUpgradeSendsNoClear`, `want_new` `an ordinary upgrade sent the clear`;
- the guard not called: caught by `TestSwitchAPausedSubscription_TheGuardRefusesNearAFinalization`, `want_new` `a renewal draft finalizing within the margin: switch =`;
- the passed time not refused (`renewalFinalizesSoon` requiring the time to lie after `now`): caught by the same, `want_new` `a renewal draft past its time, still a draft: switch =`;
- the earlier of the two times taken: caught by the same, `want_new` `next_payment_attempt within the margin, automatically_finalizes_at beyond it: switch =`;
- `automatically_finalizes_at` not read: caught by the same row, `want_new` `next_payment_attempt within the margin, automatically_finalizes_at beyond it: switch =`;
- `next_payment_attempt` not read: caught by the same, `want_new` `automatically_finalizes_at within the margin, next_payment_attempt beyond it: switch =`;
- the billing-reason test dropped: caught by the same, `want_new` `a draft that is not a renewal: switch =`;
- the auto-advance test dropped: caught by the same, `want_new` `a renewal draft with auto_advance false: switch =`;
- a failed list let through (its error ignored): caught by the same, `want_new` `the list answers 500: switch =`;
- a draft with both times null let through (a zero time skipped, and a draft left with no time read not refused): caught by the same, `want_new` `both times null: switch =`;
- the backstop written after the clear (steps 3 and 6 swapped): caught by `TestSwitchAPausedSubscription_TheBackstopIsWrittenBeforeTheClear`, `want_new` `when the clear was sent, the backstop entries were 0`;
- the backstop's delay dropped (`time.Now()` passed): caught by the same, `want_new` `the backstop's next_attempt_at =`;
- `EnqueueAt` leaving the column's default (its `next_attempt_at` dropped from the insert): caught by the same, `want_new` `the backstop's next_attempt_at =`;
- a failed backstop insert ignored: caught by the row `a failed backstop insert` of the same test, `want_new` `a failed backstop insert =`;
- the poll's `next_attempt_at` filter dropped: caught by `TestOutboxPollSkipsAnEntryNotYetDue`, `want_new` `the poll took an entry not due until`;
- call 2 sent with no context: caught by `TestSwitchAPausedSubscription_EveryCallCarriesADeadline`, `want_new` `the update carried no deadline`;
- call 2 given `pausedSwitchCallTimeout`: caught by the same, `want_new` `the update's deadline is`;
- the clear sent with no context: caught by the same, `want_new` `the clear carried no deadline`;
- the draft list sent with no context: caught by the same, `want_new` `the draft list carried no deadline`;
- the restore given no deadline (`restorePause` passing `context.WithoutCancel(ctx)` alone): caught by `TestSwitchAPausedSubscription_EveryCallCarriesADeadline`, `want_new` `the restore's calls carry deadlines`;
- the proration moment's check dropped (step 1 deleted): caught by `TestPostSwitchFromAPausedSubscription`, `want_new` `a proration date before the period: answered`;
- the preview calling Stripe for a paused subscription (the recorded-pause test dropped): caught by `TestPreviewSwitchOfAPausedSubscription`, `want_new` `the console's pause: preview =`;
- the preview keyed on the console's pause alone (`&& ls.sub.PauseCollectionMarked` added): caught by the same, `want_new` `a foreign pause: preview =`;
- Cancel's void dropped: caught by `TestCancelAPausedSubscriptionVoidsAnAbandonedSwitch`, `want_new` `cancel writes =`;
- a refused void let through to the cancellation: caught by the same, `want_new` `a refused void =`;
- Cancel's void placed after the commitment's deferral (the fetch and the void moved into the schedule's fetch): caught by the same, `want_new` `a cancellation a commitment defers: writes =`;
- Cancel's void widened to a holding subscription (the `!ls.holdsLadder` test dropped): caught by `TestCancelAHoldingSubscriptionVoidsNothing`, `want_new` `a holding subscription's cancel sent`;
- the replacement not resumed when S ends (`ResumeReplacedGrantsTx` reading no candidates, `internal/entitlements/grant_resumption.go`; no Go write records a replacement, because `core.grant_replacements` is a view over the supersession pairs `core.confer` leaves, so the break stands in for the record by breaking the resumption's read): caught by `TestSwitchAPausedSubscription_AMarkedGrantResumesWhenItEnds`, `want_new` `after S ended, the marked grant holds`;
- step 4 dropped from the paused form: caught by `TestSwitchAPausedSubscription_AnAbandonedUpgradeIsVoidedFirst`, `want_new` `with an abandoned upgrade, writes =`;
- the abandoned void sent with no context: caught by the same, `want_new` `the void carried no deadline`;
- step 4 moved after the clear: caught by the same, `want_new` `with an abandoned upgrade, writes =`;
- the failed restore not logged: caught by `TestSwitchAPausedSubscription_AFailedRestoreIsLoggedAndTheDeclineIsAnswered`, `want_new` `a failed restore logged no line`;
- the failed restore logged without `stripe_subscription_id`: caught by the same, `want_new` `a failed restore logged no line`;
- the restore's failure changing the member's answer (the logged error raised as a panic): caught by the same, `want_new` `a failing restore changed the member's answer`;
- rule 3a applied after rule 3: caught by `TestOffer`, `want_new` `Offer(the granted tier over a paying subscription) =`;
- `ReplacesGrant` not set by rule 3a item 2: caught by `TestOffer`, `want_new` `Offer(a tier above a grant that displaced a subscription) =`;
- the paid-tier rank test dropped (a tier between a lower grant and the paid tier is a switch): caught by `TestOffer`, `want_new` `Offer(a tier between a lower grant and the paid tier) =`;
- the suspension test dropped: caught by `TestOffer`, `want_new` `Offer(a past-due paying subscription) =`;
- the `OtherLadders` test dropped from rule 3a item 2: caught by `TestOffer`, `want_new` `Offer(a tier above a grant over a bundle's subscription) =`;
- the `Target.OtherLadders` test dropped from rule 3a item 2: caught by `TestOffer`, `want_new` `Offer(a bundle tier above a grant that displaced a subscription) =`, and by `TestPostSwitchFromAPausedSubscription`, `want_new` `a bundle tier`;
- `replacesGrant` ignored: caught by `TestPlanMoveMessageStatesTheTiming`, `want_new` `replaces the grant`;
- section 8's exception removed for the switch's product: caught by `TestSwitchAPausedSubscription_OneUpdate`, `want_new` `after the switch, the ladder holds`;
- a paying refusal left to `!pos.Subscribed` (the `Paying`-set, not-suspended case deleted from `switchOffer`, 9.3): caught by `TestPostSwitchFromAPausedSubscription`, `want_new` `answered "There's no active subscription`;
- section 8's exception removed for the switch's product: caught by `TestSwitchAPausedSubscription_TwoCalls`, `want_new` `after the switch, the ladder holds`;
- a paying refusal left to `!pos.Subscribed` (the not-suspended mapping deleted: the case narrowed to a suspended `Paying`, as the next record narrows it the other way): caught by `TestPostSwitchFromAPausedSubscription`, `want_new` `answered "There's no active subscription`;
- the suspended mapping deleted from `switchOffer` (a suspended `Paying` misses the not-suspended case and reaches `!pos.Subscribed`): caught by the `past due` row, `want_new` `past due: answered "There's no active subscription`;
- the `Paying.Paused` test dropped from `switchOffer`: caught by the `paused` row, `want_new` `paused: answered "Pay the unpaid`;
- the `!pos.Subscribed` test dropped from the mappings' condition: caught by the `held by another subscription` fixture's row, `want_new` `answered "That plan is no longer available`.
- [ ] 11.7 `make screens`: no demo state reaches the switch; hand captures at both widths of the switch's confirmation from the paused subscription (M3) are attached to the commit's review.
- [x] 11.7 `make screens`: no demo state reaches the switch; hand captures at both widths of the switch's confirmation from the paused subscription (M3, with the fallback sentence, and M12 toward a yearly price) are attached to the commit's review.
- [x] 11.8 A price of another interval or currency (design D9 rule 3a item 2; D12, "A price of another interval"; Copy M12; Open questions, 14), in these parts:
- `internal/plans/offer.go`: `Price` (`:89-98`) gains `Currency string` and `Interval string`, the price's currency and recurring interval ("" for a one-time price), and `Paying` (`:58-77`) gains the same two, its item's price's. `Move` (`:157-164`) gains `NewPeriod bool`, its doc comment saying that 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. A new reason beside the others (`:118-142`): `OtherCurrency Reason = "the target's price is in another currency than the paying subscription's"`. In `payingRules` (`:281-295`), rule 3a item 2's answer is `priceRule(Switch, a.Target)` with relation `Upgrade` and `ReplacesGrant` (item 11.3); when that move is enabled, it becomes `Move{Reason: OtherCurrency}` if `a.Price.Currency != p.Currency`, and otherwise carries `NewPeriod` if `a.Price.Interval != p.Interval`. Both tests read only an enabled move, so a disabled one keeps its reason and carries no `NewPeriod` (a one-time price, an unreachable one, a tier with no price), and `defaultPrice` need not read the currency of a price it does not read. `Offer`'s doc comment (`:189-242`) states 3a.2 with both.
- `internal/server/plan_offer.go`: `payingFrom` (`:170-181`) sets `Currency` and `Interval` from the row's `Currency` and `RecurringInterval` (`ListSubscriptionRungsByOrgRow` already carries them, `internal/billing/subscriptions.sql.go:246-247`; no query changes); `defaultPrice` (`:359-382`) sets them from the `GetPrice` row it reads for a recurring, purchasable price (`:376-380`); `postedPrice` (`:395-407`) sets them from its `billing.Price`. `refusePayingLadder` (`:523-533`) answers `OtherCurrency` with `errPlanNotOffered`, as it answers every other refusal of a paying subscription that is not suspended, so no mapping is added.
- `internal/server/member_plan_moves.go`: `planMoveMessage` takes `newPeriodCharge string` after `replacesGrant`; when it is set, the charge sentence is M12's, "Your card is charged <newPeriodCharge> now, minus a credit for the unused part of your current plan, then <then> from today.", whatever the preview answered. Its doc comment says why: Stripe charges a new full period and moves the billing date, previews nothing while paused, and its figure would otherwise be followed by "for the rest of this period", which is false when the period restarts. `renderSwitchPreview` passes `formatMoney(int64(target.UnitAmount), target.Currency)` when the allowed move's `NewPeriod` is set, and "" otherwise.
- The test kit (`internal/testkit/world/plans.go`): `createPrice` (`:37-46`) takes the currency; `Price` and `OneTimePrice` (`:26-35`) pass "usd", as today; `RecurringPrice(p Product, cents int32, currency, interval string) Price` is new, for a price in another currency or interval.
- Tests:
- `TestOffer` rows: `a tier of another interval above a grant that displaced a subscription` (a Plus grant, Basic paid monthly, Pro's default price yearly: a switch, enabled, relation Upgrade, `ReplacesGrant` and `NewPeriod`); `a tier priced in another currency above a grant that displaced a subscription` (a Plus grant, Basic paid in usd, Pro's default price in eur: none, reason `OtherCurrency`); `a one-time tier priced in another currency above the grant` (a switch, disabled, reason `OneTime`, relation Upgrade, `ReplacesGrant`, no `NewPeriod`, the paying price monthly). Item 11.4's switch rows give the target and the paying price one currency and one interval, and want no `NewPeriod`. Failure text unchanged (`Offer(%s) = %+v, want %+v`).
- `TestPayingByLadder` (`internal/server/plan_offer_test.go`) row `the paying price's currency and interval` (the row's `Currency` "eur" and `RecurringInterval` "year": `Paying` carries both). Failure text unchanged (`payingFacts(%s) = %v, want %v`).
- `TestPlanMoveMessageStatesTheTiming` rows `a new period with no figure` (the unavailable preview) and `a new period with a figure` (a preview with `NetAmount` 27032), each with `newPeriodCharge` "$300.00" and `then` "$300.00 a year": both equal M12's whole text. Failure text unchanged.
- `TestSwitchPreviewFromAPausedSubscription` (`internal/server/member_switch_offer_db_test.go`), the preview of Max opened from S, whose row records the console's pause, so the preview is the unavailable one with no Stripe call, in two fixtures: `a Pro grant over Plus` (item 11.4's, Plus and Max monthly: M3 with the fallback sentence, then Max's price as `recurringPhrase` words it) and `a Pro grant over Plus, Max priced yearly` (Max's only price `RecurringPrice(max, 30000, "usd", "year")`: "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."). Rows `a monthly Max` and `a yearly Max`; each also counts the mock's calls across the preview and wants none. Failure texts: `%s: the confirmation reads %q, want %q`; `%s: the preview sent %d Stripe calls, want 0`.
- `TestPostSwitchFromAPausedSubscription` gains a fourth fixture, `a Pro grant over Plus, Max priced in euros` (Max's only price `RecurringPrice(max, 5000, "eur", "month")`), row `Max` (the unavailable-plan sentence), and a fifth, `a Pro grant over Plus, Max a tier of another ladder too` (a second ladder holding Max), row `a bundle tier` (the unavailable-plan sentence).
- No golden changes: every state of `TestMemberPlansGolden` prices its tiers through `world.Price`, in one currency and one interval.
- Breaks:
- the currency test dropped: caught by `TestOffer`, `want_new` `Offer(a tier priced in another currency above a grant that displaced a subscription) =`;
- the currency test applied ahead of the price rule (to every move, enabled or not): caught by the same, `want_new` `Offer(a one-time tier priced in another currency above the grant) =`;
- `NewPeriod` not set: caught by the same, `want_new` `Offer(a tier of another interval above a grant that displaced a subscription) =`;
- `NewPeriod` set on a disabled move (the enabled test dropped from the interval comparison): caught by `TestOffer`, `want_new` `Offer(a one-time tier priced in another currency above the grant) =`;
- `NewPeriod` set whatever the interval (the comparison read as true): caught by the same, `want_new` `Offer(a tier above a grant that displaced a subscription) =`;
- `payingFrom` leaving `Currency` and `Interval` unset: caught by `TestPayingByLadder`, `want_new` `payingFacts(the paying price's currency and interval) =`;
- `postedPrice` leaving `Currency` unset (every paying switch then reads another currency): caught by `TestPostSwitchFromAPausedSubscription`, `want_new` `Max: answered "That plan is no longer available`;
- `defaultPrice` leaving `Currency` unset: caught by `TestMemberPlansGolden/paid-retired-plus-under-granted-pro`, `want_new` `testdata/member_plans/paid-retired-plus-under-granted-pro.html differs from what the code produced`;
- `planMoveMessage` ignoring `newPeriodCharge`: caught by `TestPlanMoveMessageStatesTheTiming`, `want_new` `minus a credit for the unused part of your current plan`;
- M12 chosen only when the preview has no figure (the sentence placed in the fallback case alone): caught by the same row `a new period with a figure`, `want_new` `minus a credit for the unused part of your current plan`;
- `renderSwitchPreview` passing no `newPeriodCharge`: caught by `TestSwitchPreviewFromAPausedSubscription`, `want_new` `a yearly Max: the confirmation reads`;
- `renderSwitchPreview` passing no `ReplacesGrant` (item 11.3): caught by the same, `want_new` `a monthly Max: the confirmation reads`;
- the preview calling Stripe for a paused subscription (the recorded-pause test dropped from `PreviewSwitch`): caught by the same, `want_new` `the preview sent`.
## 12. Resume plan when the grant ranks below the paid tier
@@ -383,9 +484,9 @@ Sections 1 to 12 are one commit each, and their items are that commit's parts; s
- `TestPostResume` (`internal/server/member_resume_db_test.go`), rows through `checkPostResume`: `resumes` (the partial draws Pro as the current card, no banner, `HX-Trigger` set); `past due` ("Pay the unpaid invoice before changing this plan."); `paused` ("A paused plan cannot be changed."); `already resumed` ("You're already on that plan."); `a cancellation scheduled` (the unavailable-plan sentence); `a product no longer public` (resumes, as for a listed product: no member gate applies); `held by another subscription` (the kit's second `Subscribe` on the ladder holds the rung over the paying subscription: the unavailable-plan sentence); `no paying subscription` ("There's no active subscription to change on this plan."). Failure text: `%s: answered %q, want %q`.
- `TestPaidPlanCardUnderAGrant` rows: `a lower grant` (M0, controls Resume plan then Cancel plan); `a lower grant, unconfirmed` (M5, Cancel plan alone); `a lower grant, downgrade scheduled` (M8, Keep plan then Cancel plan). Failure text unchanged.
- `TestResumeConfirmation` (`internal/server/member_plans_paid_under_grant_test.go`, no database): the card's `ResumeTitle` and `ResumeBody` equal M11 with the subscription's price. Failure text: `resume confirmation = %q, %q, want %q, %q`.
- `TestMemberPlansGolden`: a state `paid-standard-under-granted-basic` (`Subscribe(o, standard)`, `Issue(o, basic)`, `PauseCollection`), goldens `internal/server/testdata/member_plans/paid-standard-under-granted-basic.html` and `.json` written with `-update` and read: Standard reads M0 with Resume plan and Cancel plan; Basic (current) and Plus draw nothing; Pro draws Upgrade.
- `TestMemberPlansGolden`: a state `paid-standard-under-granted-basic` (`Subscribe(o, standard)`, `Issue(o, basic)`, `PauseCollection`), goldens `internal/server/testdata/member_plans/paid-standard-under-granted-basic.html` and `.json` written with `-update` and read: Standard reads M0 with Resume plan and Cancel plan; Basic (current) and Plus draw no control, and Basic reads "Granted" (item 12.11); Pro draws Upgrade.
- `TestPlansOfferPaidMoveGate` gains the case `resumes`, a region whose only money move is a paid card's enabled Resume, which earns the line. Failure text: `an enabled Resume does not earn the line`.
- The `.json` of each of the 30 states that predate this section (every `.json` under `internal/server/testdata/member_plans/` at section 11's commit) is rewritten with `-update` and read; each gains only `CanResume`, `ResumeTitle` and `ResumeBody` at their zero values, and none of those states' `.html` changes, because none has a grant ranked below its paid tier; any other diff is a stop.
- The `.json` of each of the 30 states that predate this section (every `.json` under `internal/server/testdata/member_plans/` at section 11's commit) is rewritten with `-update` and read; each gains only `CanResume`, `ResumeTitle` and `ResumeBody` at their zero values and item 12.11's fields, and none of those states' `.html` changes but by item 12.11's line in the states that item names, because none has a grant ranked below its paid tier; any other diff is a stop.
- `TestEveryMutationRouteIsDeclared` passes with the declaration.
- [ ] 12.8 Breaks:
- the rank test dropped from item 1: caught by `TestOffer`, `want_new` `Offer(the paid tier under a grant of its own tier) =`;
@@ -413,6 +514,35 @@ Sections 1 to 12 are one commit each, and their items are that commit's parts; s
- Unseen by any test of the section, named in the commit message: the supersession of a bundle grant's other ladder, which `core.confer` owns, so no line of the act can be broken to change it (`TestResumeSubscription_OverABundleGrantEndsItOnItsOtherLadder` pins the outcome).
- [ ] 12.9 Docs: `docs/stripe.md`'s grant bullet says that under a grant ranked below the paid tier the member resumes the paid tier at its own price, that the console then clears the pause through the outbox, and that nothing is charged until the next renewal, so the rest of a period whose renewal was voided is free, up to one whole period (nearly a year on an annual price), at a moment the member picks, the end edge's cost of design D14; `docs/plan-management.md` says what the member sees on the paid card; `docs/models/plan-ladders-transitions.md` invariant 20 says that the member's Resume plan supersedes a lower grant and is the one act that takes a ladder back for the product a grant displaced; `docs/database-locks.md` gains Resume plan's transaction under the pool row, with no subscription advisory lock (design, Locks and transactions).
- [ ] 12.10 `make screens`: no demo state reaches the paid card; hand captures at 1280 desktop width and 390 mobile width (emulated, `innerWidth` checked) of the Products page in `paid-standard-under-granted-basic`, of the Resume confirmation (M11), and of the page after the resume, on the test stack with the kit's states loaded, are attached to the commit's review.
- [ ] 12.11 Ride-along: the current card a grant holds states the grant's term (design D21, Copy M13, M0, M1, M8; the proposal's small fixes that ride along; `member-product-discovery`, "Every plan card states its cost" and "A paid plan whose ladder a grant holds shows its pause on its own card"). Line numbers are at `84709d96`. In these parts:
- Query: `ListLadderPositionsByPool` (`internal/entitlements/queries/pool_provision_ladders.sql:187-247`) gains `LEFT JOIN core.grants g ON g.grant_id = p.grant_id` and selects `g.valid_until AS grant_valid_until` after `p.purchase_id`; its doc comment gains a sentence: the column is the end date of the grant whose provision holds the position, the extension's after Manage, and the Products page's current card reads it. `make sqlc-generate`; `ListLadderPositionsByPoolRow` gains `GrantValidUntil sql.NullTime`, and no other reader changes.
- View model: `TierViewModel` (`internal/server/member_plans.go`) gains `Granted bool` and `GrantedUntil string` under one doc comment: the current card a grant holds, not the organization type's default (`default_sourced`); `GrantedUntil` is the grant's `valid_until` in `cardDateLayout`, "" for a grant with no end date; a card with no other line draws them as its price line, and the paid plan's card under a grant of its own tier draws `GrantedUntil` inside its paused line (design D10, D21). `(t *TierViewModel) setGranted(pos entitlements.ListLadderPositionsByPoolRow)`, reading no row and no clock, sets `Granted` when `pos.GrantID.Valid && !pos.DefaultSourced`, and `GrantedUntil` when `pos.GrantValidUntil.Valid` as well; its doc comment says why a passed date is still drawn (design D21). `setCurrent` calls it on `facts.Position` after it sets `Relation` and before the paid card's return (`:492-495`), so the paid card under a grant of its own tier carries the date; `buildTier`'s paid branch (`:470-472`) does not call it, so the paid card under a grant of another tier carries none. `resolveCurrentPlanBilling`'s doc comment (`:804-807`) stops saying that nothing renders for a grant's tier: its card reads the grant's line (`setGranted`).
- Template: the current-card chain of `member_plans.html` (`:78-96`) gains, after its last branch, `{{ else if .GrantedUntil }}` drawing `Granted until {{ .GrantedUntil }}` and `{{ else if .Granted }}` drawing `Granted`, each in the chain's `<p class="text-muted small mb-2">`; its three paused branches (`:80-85`) draw `{{ if .GrantedUntil }} until {{ .GrantedUntil }}{{ end }}` right after `is granted`; the comment above the chain says that the current card a grant holds states the grant's term, and that the paid card under a grant of its own tier states it inside its paused line.
- Docs: `docs/plan-management.md`, in the grant bullet that names what the member sees on the Products page, says that the granted tier's card reads "Granted until {date}", or "Granted" for a grant with no end date, and that under a grant of the subscription's own tier the paid plan's paused line carries that date; `docs/models/plan-ladders-transitions.md`'s Member position row (`:49`) says the read also carries the holding grant's end date, which the Products page's current card reads.
- Tests:
- `TestGrantedCurrentCard` (`internal/server/member_plans_granted_test.go`, new, no database): a position row in, through `setGranted` on a `current` tier and `member_plans.html`, the price line's whole text out, read as `TestPaidPlanCardUnderAGrant` reads it (`p.text-muted.small.mb-2`). Rows: `an end date` (`valid_until` Feb 20, 2099: "Granted until Feb 20, 2099"); `no end date` ("Granted"); `an end date passed` (`valid_until` Jan 15, 2020: "Granted until Jan 15, 2020"); `the default` (`grant_id` set, `default_sourced` true: no line); `a subscription` (`subscription_id` set, no `grant_id`: no line). Failure text: `%s: price line %q, want %q`.
- `TestPaidPlanCardUnderAGrant` rows (`internal/server/member_plans_paid_under_grant_test.go:42-109`): on a row whose card is current, the harness calls `setGranted` with a grant's position row before `setPaidUnderGrant`, in `setCurrent`'s order, the grant's `valid_until` the row's (none for the existing `own tier`, whose line does not change). New rows, each under a grant of Plus whose `valid_until` is Feb 20, 2099: `own tier, until` ("Paused while Plus is granted until Feb 20, 2099", Cancel plan); `own tier, until, payment due` ("Paused while Plus is granted until Feb 20, 2099, payment due", Pay invoice then Cancel plan); `own tier, until, downgrade scheduled` ("Paused while Plus is granted until Feb 20, 2099, moves to Basic on Jan 15, 2099", Keep plan then Cancel plan); `own tier, until, unconfirmed` ("$10.00/month, renews Jan 15, 2099", Cancel plan). Failure text unchanged.
- `TestListLadderPositionsByPoolReadsTheHoldingGrantsEnd` (`internal/entitlements/ladder_positions_db_test.go`), rows: `a grant with an end date` (`Issue` with `ValidUntil` Feb 20, 2099: that date); `an open-ended grant` (NULL); `an extended grant` (`Issue` with `ValidUntil` Feb 20, 2099, then `Extend` to Mar 20, 2099: Mar 20, 2099, the extension's); `the organization type's default` (`Floor`: NULL); `a subscription` (`Subscribe`: NULL). Failure text: `%s: grant_valid_until = %v, want %v`. A row whose position is missing from the read fails with `%s: no position on the ladder`.
- `TestMemberPlansGolden`, three states whose `.html` and `.json` are written with `-update` and read: `granted-plus-until` (`Issue(o, plus, world.IssueOpts{ValidUntil: noon(2099, time.February, 20)})`): Plus's current card on Wiki and on Duo reads "Granted until Feb 20, 2099"; `granted-plus-own-tier-until` (`granted-plus-own-tier`'s acts, the grant's `ValidUntil` Feb 20, 2099): Plus's current card on Wiki and on Duo reads "Paused while Plus is granted until Feb 20, 2099" with Cancel plan; `paid-plus-under-granted-pro-until` (`paid-plus-under-granted-pro`'s acts, the grant's `ValidUntil` Feb 20, 2099): Pro's current card on Wiki reads "Granted until Feb 20, 2099", and Plus's card there reads "Paused while Pro is granted", as in `paid-plus-under-granted-pro`. With item 12.7's state, the fixture's two comments (`member_plans_golden_db_test.go:7`, `:98`) count thirty-three organizations.
- The goldens, rewritten with `-update` in the same pass as item 12.7's and read: the `.html` and `.json` of the nine states whose current card a grant holds and is not the paid plan's card, each such card gaining "Granted" and `Granted: true`, since none of their grants has an end date: `granted-plus` (Plus on Wiki and Duo), `paid-plus-under-granted-pro`, `paid-plus-under-granted-pro-past-due`, `paid-plus-under-granted-pro-unconfirmed`, `paid-plus-under-granted-pro-cancellation-scheduled`, `paid-plus-under-granted-pro-downgrade-scheduled`, `paid-plus-paused-under-granted-pro` and `paid-retired-plus-under-granted-pro` (Pro on Wiki), and `retired-current-rung` (Gone on Prices); the `.json` of `granted-plus-own-tier`, whose current cards on Wiki and Duo are the paid plan's and gain `Granted: true` with `GrantedUntil` "", while its `.html` does not change, because its grant has no end date; the `.json` of every other state, which gains `Granted` and `GrantedUntil` at their zero values while its `.html` does not change; and item 12.7's `paid-standard-under-granted-basic`, whose current Basic card reads "Granted". Any other diff is a stop.
- `TestProductsPageReadsPayingOnce` passes unedited. It counts `ListSubscriptionRungsByOrg` alone, so no test counts the page's other statements, and none is added: the date is a column of the read the page already makes.
- Breaks:
- `GrantedUntil` never set (the date dropped): caught by `TestGrantedCurrentCard`, `want_new` `an end date: price line`, by `TestPaidPlanCardUnderAGrant`, `want_new` `own tier, until: price line`, and by `TestMemberPlansGolden/granted-plus-until`, `want_new` `testdata/member_plans/granted-plus-until.html differs from what the code produced`;
- the template's two branches removed: caught by `TestMemberPlansGolden/granted-plus`, `want_new` `testdata/member_plans/granted-plus.html differs from what the code produced`;
- `setGranted` called only when a subscription pays on the ladder (`pos.Paying != nil`): caught by the same, `want_new` `testdata/member_plans/granted-plus.html differs from what the code produced`;
- the `default_sourced` test dropped: caught by `TestGrantedCurrentCard`, `want_new` `the default: price line`;
- `pos.GrantID.Valid` dropped from `setGranted`: caught by `TestGrantedCurrentCard`, `want_new` `a subscription: price line`;
- a clock read added (a passed end date drawn as "Granted"): caught by the same, `want_new` `an end date passed: price line`;
- `setGranted` called after the paid card's return (`:492-495`), so the paid card under a grant of its own tier carries no date: caught by `TestMemberPlansGolden/granted-plus-own-tier-until`, `want_new` `testdata/member_plans/granted-plus-own-tier-until.html differs from what the code produced`;
- `setGranted` also called on the paid card under a grant of another tier (in `buildTier`'s paid branch, `:470-472`): caught by `TestMemberPlansGolden/paid-plus-under-granted-pro-until`, `want_new` `testdata/member_plans/paid-plus-under-granted-pro-until.html differs from what the code produced`;
- the date left out of the plain paused branch: caught by `TestPaidPlanCardUnderAGrant`, `want_new` `own tier, until: price line`, and by `TestMemberPlansGolden/granted-plus-own-tier-until`, `want_new` `testdata/member_plans/granted-plus-own-tier-until.html differs from what the code produced`;
- the date left out of the payment-due paused branch: caught by `TestPaidPlanCardUnderAGrant`, `want_new` `own tier, until, payment due: price line`;
- the date left out of the downgrade's paused branch: caught by the same, `want_new` `own tier, until, downgrade scheduled: price line`;
- the date drawn on a line that does not read "Paused" (the renews branch reading `GrantedUntil`): caught by the same, `want_new` `own tier, until, unconfirmed: price line`;
- the query's column selected as `NULL::timestamptz` (the join left out): caught by `TestListLadderPositionsByPoolReadsTheHoldingGrantsEnd`, `want_new` `a grant with an end date: grant_valid_until =`;
- `JOIN core.grants` in place of the `LEFT JOIN`: caught by `TestListLadderPositionsByPoolReadsTheHoldingGrantsEnd`, `want_new` `a subscription: no position on the ladder`, and by `TestListLadderPositionsByPoolReadsLivePositions`, `want_new` `the suspended position on ladder B is missing from`.
- No break for the row `an extended grant`: which grant the holding provision names after Manage is `ExtendGrantTx`'s, so no line of this item can be broken to change it (the row pins the outcome).
- `make screens`: no capture changes. The Products page is captured for Bob alone, whose current plan is the organization type's default (`default_sourced`), which the line leaves out. The merchant-disclosure sheet's `grant-held` capture, opt-in and not a gate, gains the line; it is not re-run. Hand captures at 1280 desktop width and 390 mobile width (emulated, `innerWidth` checked) of the Products page in `granted-plus-until`, `granted-plus-own-tier-until` and `paid-plus-under-granted-pro-until`, on the test stack with the kit's states loaded, are attached to the commit's review.
## 13. Landing
@@ -491,7 +621,7 @@ Sections 1 to 12 are one commit each, and their items are that commit's parts; s
7. the grant is revoked: the Tier changes list shows T1 in the same instant as the revocation, the pause is cleared at Stripe within seconds, and the next forced renewal is paid;
8. steps 2 to 7 again with a grant that expires (a short `valid_until`), the list showing T2;
9. Cancel plan on the paused card sets `cancel_at_period_end` with the pause kept, and Keep plan clears it;
10. the switch to Max from the paused subscription shows M3, charges the proration at once, clears the pause, and the organization holds Max by the subscription; the grant is recorded as replaced;
10. the switch to Max from the paused subscription shows M3 with the fallback sentence, clears the pause and then charges the proration at once (the clear's and the update's `customer.subscription.updated`, read with the Stripe CLI), and the organization holds Max by the subscription; the grant is recorded as replaced; before it, on a second member's paused subscription whose default payment method is `pm_card_chargeCustomerFail`, the same switch answers "Your card was declined; you're still on Plus.", the subscription at Stripe carries `pause_collection.behavior=void` and the marker again within seconds, and its open invoice is void;
11. a second member buys Pro, and an operator issues a Basic grant over it: once the pause is confirmed, Pro's card reads M0 with Resume plan and Cancel plan, Plus's card offers nothing and Max's offers Upgrade; Resume plan shows M11, and after it the organization holds Pro by the subscription at once, the pause is cleared at Stripe within seconds with no invoice and no proration (read with the Stripe CLI), the grant is recorded as replaced, and the member's Entitlements panel shows Pro's entitlements;
12. a third member buys Plus through Checkout; before any grant, the walk attaches `pm_card_chargeCustomerFail` to the customer and sets it as the subscription's `default_payment_method` (`stripe post /v1/subscriptions/sub_… -d default_payment_method=pm_…`), then forces the renewal with `billing_cycle_anchor=now`, so the subscription is `past_due` with an open invoice; an operator then issues a Pro grant and the pause is confirmed; the card reads M1, "Paused while Pro is granted, payment due", and Pay invoice opens the hosted page of that open invoice, not of the voided renewal.
The captures and the Stripe objects' ids go to the maintainer's review.
@@ -502,7 +632,7 @@ Sections 1 to 12 are one commit each, and their items are that commit's parts; s
>
> While grants hold every ladder a subscription pays for, the console pauses the subscription's payment collection at Stripe, with Stripe's "void" behaviour: each renewal made during the grant is voided about an hour after it is created, and nothing is charged. When the grant ends or is revoked, collection resumes and the next renewal is charged as usual. The pause is applied through the console's outbox, so a failed call is retried for a day and then shows on the Stripe provider page, and an hourly check asks again for any subscription whose pause disagrees with its grants. A pause made by hand in the Stripe Dashboard is left alone.
>
> On the Products page, the paid plan's card reads "Paused while Pro is granted" and offers Cancel plan, which ends the subscription at its period end. While an invoice from before the grant is still open, the card reads "Paused while Pro is granted, payment due" and offers Pay invoice first. On the organization page, the Not delivering badge reads "Billing paused while a grant holds {ladder}." once Stripe confirms the pause, and the Overview's Monthly recurring headline leaves a paused subscription out.
> On the Products page, the paid plan's card reads "Paused while Pro is granted" and offers Cancel plan, which ends the subscription at its period end. The current plan a grant holds now reads "Granted until {date}", or "Granted" when the grant has no end date, whether or not a paid plan is paused beside it; when the grant is of the tier the subscription pays for, the one card reads "Paused while Plus is granted until {date}". While an invoice from before the grant is still open, the card reads "Paused while Pro is granted, payment due" and offers Pay invoice first. On the organization page, the Not delivering badge reads "Billing paused while a grant holds {ladder}." once Stripe confirms the pause, and the Overview's Monthly recurring headline leaves a paused subscription out.
>
> Not changed here: no money moves when the grant starts or ends, so a grant that is not a whole number of billing periods can cost the member or the operator up to one period; #280 is the exact rule. An invoice open before the grant stays due, and Stripe's retry settings can still cancel the subscription during the grant.
>
@@ -522,7 +652,7 @@ Sections 1 to 12 are one commit each, and their items are that commit's parts; s
- On #278, then close it:
> A member whose subscription is paused behind a grant can now switch it to the granted tier or a higher one from the Products page. The switch is charged at once for the rest of the period, clears the pause in the same Stripe update, and the subscription takes the ladder from the grant; a grant marked to come back returns when the subscription ends, while its own term lasts. The confirmation reads "Switch to Max?" and "Your subscription replaces the grant." before the amount. Stripe's charge credits the unused part of the period whose renewal was voided, so the member pays the price difference rather than the new price for that period; #280 covers it.
> A member whose subscription is paused behind a grant can now switch it to the granted tier or a higher one from the Products page. The console lifts the pause at Stripe and then sends the usual upgrade, which is charged at once for the rest of the period, and the subscription takes the ladder from the grant; a grant marked to come back returns when the subscription ends, while its own term lasts. Two requests are needed because Stripe refuses an upgrade while the subscription is paused, and refuses lifting the pause in the same request as an upgrade that can wait for the bank's approval. When the card is declined, the bank's approval is pending, or anything else fails, the console pauses the subscription again at once and nothing is charged; a delayed job the console writes before the first request does the same a few minutes later if the console stops between the two requests. While a renewal Stripe made during the pause is about to be finalized, the switch answers "We couldn't complete that change. Try again in a moment.", so that renewal is still voided. Cancel plan on the paused plan voids an upgrade still waiting for approval, so paying it later cannot move a plan that is set to end. The confirmation reads "Switch to Max?" and "Your subscription replaces the grant." before the charge sentence. When the higher tier is priced over another period, monthly to yearly for example, the switch is still offered, and the confirmation 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.": Stripe then charges the new price's full period, less the unused part of the current one, and the billing date moves to the day of the switch. A tier priced in another currency is not offered, since Stripe cannot change a subscription's currency. Stripe's charge credits the unused part of the period whose renewal was voided, so the member pays the price difference rather than the new price for that period, or, on a switch to another billing period, the new period less that credit; #280 covers it.
>
> When the grant is of a lower tier than the paid plan, the paid plan's card offers Resume plan instead, and the tiers between the grant and the paid plan offer nothing. Resuming takes the ladder back from the grant for the plan the subscription already pays for, at its own price: nothing is charged at that moment, the pause is lifted at Stripe within seconds, and the next renewal is charged as usual. A downgrade the member scheduled before the grant, landing at or above the grant's tier, also takes the ladder back, and billing resumes at the lower price.
>