From f3fa8e74565b510eaf9b7dedd1ea935962bcfaef Mon Sep 17 00:00:00 2001 From: Christian Galo Date: Fri, 30 Oct 2026 04:56:41 -0500 Subject: [PATCH] Confirm a plan switch in the shared dialog #291. A plan switch (Upgrade, Downgrade, or Pay for this plan on a granted tier's card) was confirmed in a panel at the top of the Plans section, above the cards and away from the button that opened it. It now confirms in the same dialog as Cancel plan and Resume plan. The button opens the dialog at once, titled with the panel's question ("Switch to Pro?", or "Pay for Pro?" on the granted tier's own card), and the dialog's body loads from the switch's preview route, because the charge it states comes from Stripe at that moment. Keep current plan closes the dialog and sends nothing. An answer that belongs in the dialog stays there: a declined card, a payment awaiting the bank's approval, stale figures; focus moves to the new form. An answer that lands outside closes it: the switch made or refused, or a plan withdrawn after the page loaded. A load that fails closes the dialog with the error toast; a commit that fails (403 or 5xx, which the pages' htmx config does not swap) leaves it open with the toast. Closing the dialog while its body loads aborts the load, and the aborted load shows no toast. The shared dialog gains a region the loaded form lives in; the forms library renders a preview that leaves by the dialog without a box of its own; the lint accepts `data-action-load` as a trigger's route. No string is added or reworded. docs/design-system.md, docs/operator-ux-conventions.md and docs/stripe.md say the same. Tests: TestSwitchAnswersNameTheirRegion, TestPlanMoveTitle, TestSwitchControlOpensTheSharedDialog, TestConfirmModalCarriesTheLoadedRegion, TestPreviewInTheDialogRendersNoBox, and the walkthrough TestSwitchDialogWalkthrough (seven steps in a browser, among them a load answered offline, a load closed mid-flight, and a commit answered 503 by the browser so nothing reaches Stripe). The walkthrough resets the application database, so it runs only with SWITCH_DIALOG_WALK=1, as the merchant-disclosure sheet runs only when asked; make test skips it. 35 goldens change: every member plans golden loses one blank line, the switch controls of 12 lose their hx-* attributes for the dialog's, and the plan move form's golden loses its box. Full suite on reset databases: 5249 passed, 16 skipped, none failed. Sandbox walk on this commit, all twelve steps passed: the dialog opens at once and loads Stripe's figure ($20.00 equal to the preview's amount_due); Keep current plan sends nothing; Cancel plan shows its own form; offline the dialog closes with the toast; stale figures, a declined card and a payment awaiting approval stay in the dialog with focus on the new form; the made switch closes it and Max is current; a plan withdrawn in another session refuses in the Plans region; Pay for this plan reads "Pay for Max?". make screens: no page this commit touches changed. Three operator captures differ for other reasons: person detail and invoice detail show new ids, because the demo snapshot was rebuilt when the previous landing added a FedWiki migration, and the domains list's age column counts one more day (#120). Not walked by the walkthrough, with the reason: a request in flight at a chosen moment (the dialog closed while the commit runs, another switch opened) and a page restored from the browser's cache; a load answered 400 and a commit the switch refuses answer through the same close rule as the withdrawn plan and the made switch that the walkthrough and the hand walk cover. Breaks, all CAUGHT (19 test records): - own card titled as a switch (title test) - own card titled as a switch (control test) - setMove leaves the title unset - control loads without the price (control test) - control loads without the price (render test) - control loads the switch's route - heading left in the message - current tier's name unescaped - refused preview left in the dialog - confirmation answered into the Plans region (a declined switch) - confirmation answered into the Plans region (a stale switch) - confirmation answered into the Plans region (a switch awaiting approval) - decline answered 200 - way out a link again - commit's description dropped - region renamed in the partial - box rule removed - box rule widened to every kind - box rule widened to every way out Breaks of the walkthrough, all CAUGHT (8 records, each building and booting the app from the edited tree): - the close not deferred - the abort on close dropped - the cancelled mark ignored by the toast - the load not sent - the loaded region left showing under a static trigger - the close on an answer outside the dialog removed - the failed commit closing the dialog - the way out a link again Seen by no test: renderConfirmation without its describeCommit call (the database tests' handlers carry no Stripe key fingerprint, so the line is never shown there; describeCommit itself is pinned); data-action-load dropped from the lint's condition (no template interpolates a route into it); the spinner and aria-busy not set, and the focus after a stay-open answer not moved, which only the hand walk reaches, because the walkthrough stops before any Stripe write. --- docs/design-system.md | 29 +- docs/operator-ux-conventions.md | 2 +- docs/stripe.md | 2 +- .../embeds/static/confirm-action-modal.js | 194 ++++++- internal/embeds/static/error-handler.js | 6 + .../templates/partials/member_plans.html | 18 +- .../partials/shell_confirm_modal.html | 8 + internal/forms/forms.go | 3 +- internal/forms/render.go | 10 +- internal/forms/render_test.go | 35 ++ internal/lint/lint.go | 4 +- internal/server/action_triggers.go | 3 +- internal/server/billing_facts_render_test.go | 9 +- .../server/grant_resumption_member_test.go | 2 +- internal/server/member_lifecycle_gate_test.go | 2 +- internal/server/member_plan_forms.go | 11 +- internal/server/member_plan_moves.go | 127 +++-- internal/server/member_plans.go | 28 +- .../server/member_plans_golden_db_test.go | 1 - internal/server/member_switch_dialog_test.go | 66 +++ .../server/member_switch_offer_db_test.go | 35 +- internal/server/member_upgrade_render_test.go | 11 +- internal/server/plan_move_message_test.go | 41 +- internal/server/plan_switch_timing_db_test.go | 95 +++- internal/server/shell_forms.go | 5 + .../testdata/forms/member.plan.move.html | 22 +- .../granted-plus-own-tier-until.html | 1 - .../member_plans/granted-plus-own-tier.html | 1 - .../member_plans/granted-plus-until.html | 1 - .../testdata/member_plans/granted-plus.html | 1 - .../testdata/member_plans/nothing-held.html | 1 - .../paid-basic-held-standard-above.html | 15 +- .../paid-basic-plus-on-two-ladders.html | 22 +- .../paid-plus-on-default-ladder.html | 29 +- .../paid-plus-paused-under-granted-pro.html | 1 - .../paid-plus-period-unknown.html | 29 +- ...er-granted-pro-cancellation-scheduled.html | 1 - ...under-granted-pro-downgrade-scheduled.html | 1 - .../paid-plus-under-granted-pro-past-due.html | 1 - ...id-plus-under-granted-pro-unconfirmed.html | 1 - .../paid-plus-under-granted-pro-until.html | 1 - .../paid-plus-under-granted-pro.html | 1 - .../paid-pro-block-commitment.html | 22 +- .../paid-pro-cancellation-flag-and-row.html | 8 +- .../paid-pro-cancellation-row.html | 22 +- .../paid-pro-downgrade-scheduled.html | 15 +- .../paid-pro-held-plus-below.html | 8 +- ...d-pro-past-due-cancellation-scheduled.html | 1 - .../paid-pro-past-due-downgrade-landed.html | 1 - ...paid-pro-past-due-downgrade-scheduled.html | 1 - .../paid-pro-past-due-held-plus-below.html | 1 - .../paid-pro-past-due-no-default.html | 1 - .../member_plans/paid-pro-past-due.html | 1 - .../member_plans/paid-pro-paused.html | 1 - .../member_plans/paid-pro-unpaid.html | 1 - .../paid-retired-plus-under-granted-pro.html | 15 +- .../paid-standard-under-granted-basic.html | 8 +- .../member_plans/retired-current-rung.html | 1 - .../unprojected-subscription-past-due.html | 1 - .../unprojected-subscription.html | 29 +- .../.openspec.yaml | 2 + .../design.md | 209 +++++++ .../proposal.md | 51 ++ .../specs/form-library/spec.md | 20 + .../specs/member-product-discovery/spec.md | 49 ++ .../specs/page-anatomy/spec.md | 49 ++ .../stripe-subscription-management/spec.md | 94 ++++ .../tasks.md | 179 ++++++ .../switch_dialog_test.go | 523 ++++++++++++++++++ test/e2e/screens/manifest.go | 40 +- 70 files changed, 1946 insertions(+), 283 deletions(-) create mode 100644 internal/server/member_switch_dialog_test.go create mode 100644 openspec/changes/switch-confirms-in-the-shared-dialog/.openspec.yaml create mode 100644 openspec/changes/switch-confirms-in-the-shared-dialog/design.md create mode 100644 openspec/changes/switch-confirms-in-the-shared-dialog/proposal.md create mode 100644 openspec/changes/switch-confirms-in-the-shared-dialog/specs/form-library/spec.md create mode 100644 openspec/changes/switch-confirms-in-the-shared-dialog/specs/member-product-discovery/spec.md create mode 100644 openspec/changes/switch-confirms-in-the-shared-dialog/specs/page-anatomy/spec.md create mode 100644 openspec/changes/switch-confirms-in-the-shared-dialog/specs/stripe-subscription-management/spec.md create mode 100644 openspec/changes/switch-confirms-in-the-shared-dialog/tasks.md create mode 100644 test/e2e/member-switch-dialog/switch_dialog_test.go diff --git a/docs/design-system.md b/docs/design-system.md index 600c7a07..daf9d947 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -192,26 +192,33 @@ A single shared, accessible confirmation dialog replaces htmx's native (`shell_confirm_modal.html`, `#confirmActionModal`) lives in the operator layout and in every member page shell. `hx-confirm` is banned outright; `member-console lint` fails any use. Member flows that confirm through it: -the plan cancel, domain release and cancel-verification, FedWiki archive -and delete-permanently. +the plan switch, the plan cancel and Resume plan, domain release and +cancel-verification, FedWiki archive and delete-permanently. Trigger contract — `data-*` attributes on any trigger element: | Attribute | Required | Meaning | |---|---|---| | `data-bs-toggle="modal" data-bs-target="#confirmActionModal"` | yes | Opens the modal | -| `data-action-url` | yes | Endpoint to call | -| `data-action-method` | yes | `post` or `delete` | -| `data-action-target` | yes | CSS selector for the HTMX swap target; a `closest ` value resolves against the trigger element (the modal's form is outside the trigger's ancestry) | +| `data-action-url` | yes, unless `data-action-load` | Endpoint to call | +| `data-action-method` | yes, unless `data-action-load` | `post` or `delete` | +| `data-action-target` | yes, unless `data-action-load` | CSS selector for the HTMX swap target; a `closest ` value resolves against the trigger element (the modal's form is outside the trigger's ancestry) | | `data-action-swap` | no | HTMX swap style (default `innerHTML`) | | `data-action-title` | no | Modal title (default `Confirm`) | | `data-action-body` | no | Modal body text/HTML | | `data-action-confirm-label` | no | Submit button label (default `Confirm`) | | `data-action-style` | no | `danger` \| `primary` \| `warning` (default `danger`) | -| `data-action-fields` | no | JSON object of hidden form fields | +| `data-action-load` | no | Route of a GET whose answer is the modal's form; the modal opens with the trigger's title and loads the form into its body in place of the shared confirm form; the other `data-action-*` attributes but the title and the fields are not read | +| `data-action-fields` | no | JSON object of hidden form fields; for a trigger with `data-action-load`, the GET's query | Behavior: a successful (2xx/3xx) response closes the modal; a failed request leaves it open so the operator can read the error in the swap target and retry. +A loaded form's answer that lands outside the modal closes it, and one that +re-renders the form stays in it; the server names the region in `HX-Retarget`. +A load that fails closes the modal, and the error toast says why; a commit that +fails leaves it open with its form. The modal shows the shared confirm form +again when it closes. A load the member closes is aborted and shows no toast; +an answer that re-renders the form moves focus to its first control. ### Error toast @@ -626,6 +633,9 @@ htmx 4 swaps every response except 204/304. The contract: preserve the operator's input; `error-handler.js` reports them as toasts. - A page that needs different per-status routing uses `hx-status` on the element, never a script that steers swaps. +- An answer whose region follows its outcome rather than its status names the + region in `HX-Retarget`: the plan switch sends a confirmation to the confirm + modal and a result to the Plans region. No script steers the swap. ### Progressive enhancement (recorded decision, `htmx-4` change, 2026-08-31) @@ -806,8 +816,13 @@ its label sits on the label line and its box on the control line, so it needs no padding of its own. The family still refuses hints, which would put a second line under some controls and not others. +`member.plan.move` renders in the shared confirm modal without a box, because +the modal is its box, and its way out "Keep current plan" closes the modal; the +library draws no box for a preview whose way out closes the modal +(`form-library`, "A preview in the shared dialog renders no box of its own"). + A `Preview` form (the tier reorder and removal previews, the org-type -default-change card, `member.plan.move`) is Stacked, rendered inside an +default-change card) is Stacked, rendered inside an `alert alert-secondary` box, its explanation (`Binding.Message`) in the same box, its commit labelled to match what it does ("Apply change") and its way out "Discard": nothing was written, so there is nothing to undo, diff --git a/docs/operator-ux-conventions.md b/docs/operator-ux-conventions.md index d9ccd948..91c6a0c3 100644 --- a/docs/operator-ux-conventions.md +++ b/docs/operator-ux-conventions.md @@ -240,7 +240,7 @@ Every mutation waits for server confirmation before the UI updates. HTMX's reque | `http.Redirect(w, r, ...)` as the only answer to a navigating mutation, regardless of `HX-Request` | `redirectToRecord` (`internal/server/redirect.go`, design D9): `HX-Redirect` + 200 with an empty body when the submit is htmx, `http.Redirect(..., http.StatusSeeOther)` when it is native | | Validation in the client only (HTML5 `required` alone) | A declared `forms.FormSpec`, parsed server-side through `spec.Parse(r)` (`form-library`; `docs/design-system.md` §8); `required` stays on the control, generated from the same declaration, for assistive technology | | `for _, g := range grants { if g.Status != "active" { continue } … }` to build an "active grants" list | Use a query that joins to active provisions (`ListDeliveringGrantsByOrgID` / `ListGrantsWithDelivery` with the organization argument) — see §9a | -| `hx-get="/operator/foo/{{ .ID }}"` — interpolated URL string in any `hx-*` or `data-action-url` attribute | `hx-get="{{ routeURL "/operator/foo/{id}" .ID }}"` — `member-console lint` cross-checks the pattern against `mux.HandleFunc` registrations. Helper is `web.RouteURL` in `internal/web/route_url.go`; the surface constructors (`web.MemberTemplates`, `web.OperatorTemplates`) register it on every set. | +| `hx-get="/operator/foo/{{ .ID }}"` — interpolated URL string in any `hx-*`, `data-action-url` or `data-action-load` attribute | `hx-get="{{ routeURL "/operator/foo/{id}" .ID }}"` — `member-console lint` cross-checks the pattern against `mux.HandleFunc` registrations. Helper is `web.RouteURL` in `internal/web/route_url.go`; the surface constructors (`web.MemberTemplates`, `web.OperatorTemplates`) register it on every set. | | `"Failed to save: " + err.Error()` in a render/toast/`http.Error` call | `web.FieldErrorsFromDB` → 422 + `FieldErrors` for constraint violations; `slog.Error` + generic message otherwise (§4; enforced by the `raw-error-render` lint rule) | | Writing the row first and running the invariant guard after it (`CreateGrant` then the same-tier check) | Every guard runs before the first write — a guard that short-circuits after the row exists leaves an orphan no later flow can reach | | Leaving a control visible whose effect nothing implements ("until the behaviour is built") | Hide it, or relabel it to what it actually does; a rendered control is a promise, and the handler is what has to keep it | diff --git a/docs/stripe.md b/docs/stripe.md index 4c705d51..51408f71 100644 --- a/docs/stripe.md +++ b/docs/stripe.md @@ -214,7 +214,7 @@ 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 superseded tier is a tier of several ladders, the grant ends it on every one of them, so the tier delivers nothing anywhere and the console pauses its collection as for one ladder. The title then adds the ladders of that tier no plan holds: "Billing paused while a grant holds {ladder}; nothing is delivered on {ladders}.", with "Billing continues" in its place until Stripe confirms the pause. On each of those ladders the Products page draws the paid tier's card with its pause and no control: "Paused while {granted} is granted on {ladder}", or "Billing continues while {granted} is granted on {ladder}" until Stripe confirms the pause; the controls stay on the card of the ladder the grant holds. When the ladder is left to the default or to nothing in another way, such as a tier removal, the subscription's next reconcile delivers its tier again. The member's Products page draws the card of the tier the subscription pays for with its own price and its pause as the subscription row records it: "Paused while {granted} is granted" once Stripe confirmed the pause, and the ordinary "{price}, renews {date}" line until it has. That card offers Cancel plan, which sets `cancel_at_period_end` on the subscription and leaves the pause as it is, so the subscription ends at its period end with no final invoice and the grant keeps the ladder; Keep plan, which clears that flag and any scheduled downgrade and likewise sends no pause change; and, for a subscription whose payment is due, Pay invoice, which opens the hosted page of the subscription's open invoice rather than its latest invoice, because the latest invoice is a renewal voided while the grant holds. If the grant ends before the cancellation date, the subscription delivers its tier until its period ends. A new purchase over a grant still replaces the grant. A switch to the granted tier or above ends the grant's hold, and the subscription delivers the new tier. Under a grant ranked below the paid tier the paid tier's card also offers **Resume plan**, before Cancel plan: the member's request confers the subscription's own tier over the grant, which the grant's end would do, and makes no Stripe call; the console then clears the pause and the marker through the outbox, and nothing is charged until the next renewal, which is charged at the subscription's own price. The rest of a period whose renewal was voided is therefore free, up to one whole period (nearly a year on an annual price), at a moment the member picks; this is the end edge's cost, accepted until the exact money rule (#280). Resume plan is not offered while the pause is not yet confirmed, while a cancellation or a downgrade is scheduled, while the payment is due or Stripe has paused the subscription, for a pause made outside the console, or for a tier that belongs to another ladder too. What this accepts until the exact money rule (#280): the renewal date decides which way a grant that is not whole periods falls, because a period's renewal is voided or charged whole; a pause landing after a renewal's finalization lets that renewal be charged, and a clear landing before the finalization of a renewal made while paused lets it be charged; a pause or clear that lands late moves a whole period one way or the other, and a void cannot be undone; the first charge after a grant can come without Stripe's upcoming-renewal reminder. A subscription item with no entitlement set, such as a donation line added in the Dashboard, stops billing with the tier beside it while the pause holds. An invoice open before the grant stays due: Stripe keeps retrying it, and the account's dunning setting can cancel the paused subscription. -- **The switch from a paused plan.** A subscription the console paused for a grant switches to a tier at or above the grant (the granted tier's own card offers **Pay for this plan**, the tiers above it **Upgrade**) and above the paid tier, unless the paid tier is also a tier of another ladder, which is offered no switch, because the switch would move the subscription off that ladder, and a target that is a tier of another ladder too is offered none, because its conferral would supersede whatever holds that ladder, a grant included. Stripe refuses every update that would create an invoice on a paused subscription, an `always_invoice` price change and a billing-cycle reset (`billing_cycle_anchor=now`) alike, and it refuses `pause_collection` beside `payment_behavior=pending_if_incomplete`, which the approval path needs, so the switch is two calls: the console clears the pause and its marker, then sends the upgrade unchanged. The confirmation's heading reads "Pay for {tier}?" on the granted tier's own card and "Switch to {tier}?" on the others, and its second paragraph opens "Your subscription replaces the grant.", and once the subscription's row records the pause it states no figure, because Stripe answers no proration preview for a paused subscription. A confirmation shown before a renewal and sent after it is shown again with fresh figures before any change is sent, because Stripe refuses a `proration_date` from before the current period's start. When the card declines, the payment needs approval, or any call fails, the console pauses the subscription again at once, through the same reconcile that decides every pause, and an outbox entry written before the clear and due four minutes later does so if the process stops between the calls. The switch refuses with "Try again in a moment." while a paused renewal is about to finalize, about the last ten minutes of the renewal's draft hour, and, in live mode, for as long as the endpoint's `invoice.created` deliveries fail, up to 72 hours, because Stripe holds a renewal's finalization until they succeed. Cancel plan voids the open invoice of a switch the member left awaiting approval before it sets `cancel_at_period_end`, because paying that invoice later would apply the switch on a plan the member chose to end. A pause made in the Dashboard refuses the switch with the generic failure, and the console leaves that pause as its maker left it. A switch toward a price of another interval is offered; once the subscription's row records the pause, its confirmation reads "Your card is charged {target price} now, minus a credit for the unused part of your current plan, then {then} from today.", and before then it states Stripe's figure as an upgrade across intervals does (the Upgrade bullet). A tier priced in another currency than the subscription's is never offered, because Stripe refuses a change of a subscription's currency. What this accepts until the exact money rule (#280): the switch's proration credits the old price's unused time for a period whose renewal was voided, so the member pays the price difference, not the new price, for the rest of that period (toward a price of another interval, the new period less that credit); a renewal that Stripe finalizes inside the unpaused window despite the guard, because it finalizes earlier than the later of the draft's two times or because the restore is late, is charged in full on a declined or approval-pending attempt, and fails and opens dunning on a declining card; a switch applied inside a paused renewal's draft hour also pays that renewal at the old price in full, on top of the proration, and when that charge fails the subscription that replaced the grant goes past due and its tier is suspended; an approval paid after the operator changed what holds the ladder or how the tiers rank takes the ladder only when the new tier ranks at or above the holder then, any member of the organization can pay the abandoned approval's invoice from the Billing page without reading the confirmation, and a lost apply event delays the paid tier up to one period; and after a paid approval the subscription delivers for about 15 seconds while Stripe still pauses it, so a renewal finalized then is voided at the operator's cost. +- **The switch from a paused plan.** A subscription the console paused for a grant switches to a tier at or above the grant (the granted tier's own card offers **Pay for this plan**, the tiers above it **Upgrade**) and above the paid tier, unless the paid tier is also a tier of another ladder, which is offered no switch, because the switch would move the subscription off that ladder, and a target that is a tier of another ladder too is offered none, because its conferral would supersede whatever holds that ladder, a grant included. Stripe refuses every update that would create an invoice on a paused subscription, an `always_invoice` price change and a billing-cycle reset (`billing_cycle_anchor=now`) alike, and it refuses `pause_collection` beside `payment_behavior=pending_if_incomplete`, which the approval path needs, so the switch is two calls: the console clears the pause and its marker, then sends the upgrade unchanged. The confirmation's title reads "Pay for {tier}?" on the granted tier's own card and "Switch to {tier}?" on the others, and its message opens "Your subscription replaces the grant.", and once the subscription's row records the pause it states no figure, because Stripe answers no proration preview for a paused subscription. A confirmation shown before a renewal and sent after it is shown again with fresh figures before any change is sent, because Stripe refuses a `proration_date` from before the current period's start. When the card declines, the payment needs approval, or any call fails, the console pauses the subscription again at once, through the same reconcile that decides every pause, and an outbox entry written before the clear and due four minutes later does so if the process stops between the calls. The switch refuses with "Try again in a moment." while a paused renewal is about to finalize, about the last ten minutes of the renewal's draft hour, and, in live mode, for as long as the endpoint's `invoice.created` deliveries fail, up to 72 hours, because Stripe holds a renewal's finalization until they succeed. Cancel plan voids the open invoice of a switch the member left awaiting approval before it sets `cancel_at_period_end`, because paying that invoice later would apply the switch on a plan the member chose to end. A pause made in the Dashboard refuses the switch with the generic failure, and the console leaves that pause as its maker left it. A switch toward a price of another interval is offered; once the subscription's row records the pause, its confirmation reads "Your card is charged {target price} now, minus a credit for the unused part of your current plan, then {then} from today.", and before then it states Stripe's figure as an upgrade across intervals does (the Upgrade bullet). A tier priced in another currency than the subscription's is never offered, because Stripe refuses a change of a subscription's currency. What this accepts until the exact money rule (#280): the switch's proration credits the old price's unused time for a period whose renewal was voided, so the member pays the price difference, not the new price, for the rest of that period (toward a price of another interval, the new period less that credit); a renewal that Stripe finalizes inside the unpaused window despite the guard, because it finalizes earlier than the later of the draft's two times or because the restore is late, is charged in full on a declined or approval-pending attempt, and fails and opens dunning on a declining card; a switch applied inside a paused renewal's draft hour also pays that renewal at the old price in full, on top of the proration, and when that charge fails the subscription that replaced the grant goes past due and its tier is suspended; an approval paid after the operator changed what holds the ladder or how the tiers rank takes the ladder only when the new tier ranks at or above the holder then, any member of the organization can pay the abandoned approval's invoice from the Billing page without reading the confirmation, and a lost apply event delays the paid tier up to one period; and after a paid approval the subscription delivers for about 15 seconds while Stripe still pauses it, so a renewal finalized then is voided at the operator's cost. - **Pay invoice.** The card of a plan whose payment is due, and the detail of an open invoice, link to Stripe's hosted invoice page. The console reads the page's URL from Stripe when the member opens the link and redirects there; it never stores or logs the URL, which is a bearer link, and it needs no key permission beyond the reads of subscriptions and invoices that the reconciles already make. Paying there makes the new card the customer's default for invoices but leaves the subscription's own default on the card that failed, which Stripe charges first, so the next renewal charges the failed card again. Stripe's page still takes payment for an invoice Stripe has marked uncollectible, which the console shows as open until the invoice sweep reads it again. A restricted key needs permission for the calls these moves make, beyond those the console already uses: `POST /v1/subscription_schedules`, `POST /v1/subscription_schedules/{id}`, `POST /v1/subscription_schedules/{id}/release` and `POST /v1/invoices/{id}/void`. The paused switch also lists a subscription's draft invoices with `GET /v1/invoices`, among the reads the key already holds for the invoice events. The console also updates a subscription with `POST /v1/subscriptions/{id}` to set `pause_collection` while a grant holds its ladder and to clear it when the grant no longer does, which is the Subscriptions write the plan moves already need. Stripe names the missing permission in the error a key without it receives. diff --git a/internal/embeds/static/confirm-action-modal.js b/internal/embeds/static/confirm-action-modal.js index 2405f618..9bda2747 100644 --- a/internal/embeds/static/confirm-action-modal.js +++ b/internal/embeds/static/confirm-action-modal.js @@ -18,9 +18,12 @@ // // Trigger button contract (any element): // data-bs-toggle="modal" data-bs-target="#confirmActionModal" -// data-action-url Required. The endpoint to call. -// data-action-method Required. "post" or "delete". -// data-action-target Required. CSS selector for the htmx swap target. +// data-action-url Required unless data-action-load is set. The +// endpoint to call. +// data-action-method Required unless data-action-load is set. "post" +// or "delete". +// data-action-target Required unless data-action-load is set. CSS +// selector for the htmx swap target. // A "closest " value is resolved against // the TRIGGER element (the modal's form is outside // the trigger's ancestry, so htmx's own closest @@ -32,13 +35,156 @@ // data-action-confirm-label Submit button label (default: "Confirm"). // data-action-style "danger" | "primary" | "warning" (default: "danger"). // data-action-fields Optional JSON object of hidden form fields, -// e.g. '{"org_id":"abc"}'. +// e.g. '{"org_id":"abc"}'. With data-action-load it +// is the GET's query instead. +// data-action-load Optional. The route of a GET whose answer is the +// dialog's form. The dialog opens with +// data-action-title, shows a region of its own in +// place of the shared confirm form, and loads the +// answer into it. data-action-url, -method, +// -target, -swap, -body, -confirm-label and -style +// are not read: the loaded form carries its own +// route, target, message, commit and way out. +// +// A loaded form's answers (docs/design-system.md "Confirm modal"): the server +// names the region of each answer, in HX-Retarget where it differs from the +// request's own target. This script steers no swap; it only decides whether +// the dialog stays open, from where the answer landed. +// - An answer that lands outside the dialog closes it. +// - An answer that lands inside it stays; a commit's answer moves focus to +// the new form's first control. +// - A load that fails (a response error or a request error) closes it. +// - A commit that fails (403, 5xx, a network error) lands nowhere and leaves +// it open with its form. +// - Every close waits for shown.bs.modal when the dialog is still opening, +// because Bootstrap ignores hide() until then. +// - Closing the dialog aborts a load still in flight and marks it +// cancelled, so its late answer or its aborted fetch closes nothing and +// shows no toast (error-handler.js reads the mark). +// - When the dialog closes, the loaded region is emptied and hidden and the +// shared confirm form shows again, for the next trigger. // // CSP-safe: lives in static/ and is loaded via