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.
This commit is contained in:
2026-10-30 05:42:38 -05:00
parent 45a78e882e
commit f3fa8e7456
70 changed files with 1946 additions and 283 deletions
+22 -7
View File
@@ -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 <selector>` 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 <selector>` 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,
+1 -1
View File
@@ -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 |
+1 -1
View File
@@ -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.
+190 -4
View File
@@ -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 <selector>" 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 <script defer src>.
// (CSP `script-src 'self'` blocks inline `hx-on::` attributes, so all close
// behavior is wired here as DOM event listeners.)
(function () {
var DANGER_CLASSES = ["btn-danger", "btn-primary", "btn-warning"];
var dialogShown = false;
function loadedRegion() {
return document.getElementById("confirmActionModalLoaded");
}
// closeDialog is the one way every close goes. Bootstrap ignores hide()
// until shown.bs.modal has fired, so while the dialog is still opening
// the close waits for that event once.
function closeDialog() {
var modalEl = document.getElementById("confirmActionModal");
var inst = modalEl && window.bootstrap && window.bootstrap.Modal.getInstance(modalEl);
if (!inst) {
return;
}
if (dialogShown) {
inst.hide();
return;
}
modalEl.addEventListener("shown.bs.modal", function () { inst.hide(); }, { once: true });
}
// resetLoaded empties and hides the region and shows the shared confirm
// form again.
function resetLoaded() {
var region = loadedRegion();
if (!region) {
return;
}
region.replaceChildren();
region.setAttribute("hidden", "");
region.removeAttribute("aria-busy");
var shared = document.querySelector('[data-form="shell.confirm"]');
if (shared) {
shared.removeAttribute("hidden");
}
}
// endLoad is the dialog's end of a load: mark it cancelled, abort the
// request, empty the region.
function endLoad() {
var region = loadedRegion();
if (!region) {
return;
}
region.dataset.loadCancelled = "true";
window.htmx.trigger(region, "htmx:abort");
resetLoaded();
}
document.addEventListener("shown.bs.modal", function (event) {
if (event.target.id === "confirmActionModal") {
dialogShown = true;
}
});
document.addEventListener("hidden.bs.modal", function (event) {
if (event.target.id === "confirmActionModal") {
endLoad();
}
});
// An answer to a request of the loaded region. A swap into the region
// ends the load's busy state. A swap that landed outside the dialog
// closes it; a commit's answer that stayed inside moves focus to the new
// form, because the swap removed the control that had it.
document.addEventListener("htmx:after:swap", function (event) {
var region = loadedRegion();
var source = event.target;
if (!region || !source || !(source === region || region.contains(source))) {
return;
}
var detail = event.detail || {};
var ctx = detail.ctx || {};
if (source === region) {
region.removeAttribute("aria-busy");
}
// A status in the page's noSwap list (403, 5xx) swaps nothing, yet
// htmx still dispatches htmx:after:swap with the request's own
// target. Nothing landed: a failed commit stays open with its form,
// and onLoadFailure has already closed the dialog on a failed load.
if (ctx.swap === "none") {
return;
}
var landed = ctx.target;
if (typeof landed === "string") {
landed = document.querySelector(landed);
}
var modalEl = document.getElementById("confirmActionModal");
if (landed && modalEl && !modalEl.contains(landed)) {
closeDialog();
return;
}
if (ctx.request && ctx.request.method === "POST") {
var first = region.querySelector("button:not([disabled]), a[href]");
if (first) {
first.focus();
}
}
});
// A load that brought no form leaves nothing to confirm. A commit that
// fails, from the form inside the region, leaves the dialog open.
function onLoadFailure(event) {
var region = loadedRegion();
if (!region || event.target !== region) {
return;
}
if (region.dataset.loadCancelled === "true") {
return;
}
region.removeAttribute("aria-busy");
closeDialog();
}
document.addEventListener("htmx:response:error", onLoadFailure);
document.addEventListener("htmx:error", onLoadFailure);
// Close the modal after a successful (2xx/3xx) htmx response from the
// confirm form. Failed requests leave the modal open so the operator can
@@ -69,11 +215,51 @@
if (modal.id !== "confirmActionModal") {
return;
}
dialogShown = false;
var trigger = event.relatedTarget;
if (!trigger) {
return;
}
var region = modal.querySelector("#confirmActionModalLoaded");
var shared = modal.querySelector('[data-form="shell.confirm"]');
var loadURL = trigger.getAttribute("data-action-load");
if (loadURL && region && shared) {
var loadTitleEl = modal.querySelector("#confirmActionModalLabel");
if (loadTitleEl) {
loadTitleEl.textContent = trigger.getAttribute("data-action-title") || "Confirm";
}
shared.setAttribute("hidden", "");
region.replaceChildren();
region.removeAttribute("hidden");
region.setAttribute("aria-busy", "true");
delete region.dataset.loadCancelled;
var spin = document.createElement("span");
spin.className = "spinner-border spinner-border-sm";
spin.setAttribute("aria-hidden", "true");
region.appendChild(spin);
var loadFields = trigger.getAttribute("data-action-fields");
var query = "";
if (loadFields) {
try {
var parsed = JSON.parse(loadFields);
query = new URLSearchParams(parsed).toString();
} catch (e) {
console.error("confirm-action-modal: invalid data-action-fields JSON", e);
}
}
var url = loadURL + (query ? "?" + query : "");
window.htmx.ajax("GET", url, {source: region, target: region, swap: "innerHTML"});
return;
}
if (region) {
region.replaceChildren();
region.hidden = true;
}
if (shared) {
shared.removeAttribute("hidden");
}
var url = trigger.getAttribute("data-action-url");
var method = (trigger.getAttribute("data-action-method") || "post").toLowerCase();
var target = trigger.getAttribute("data-action-target");
+6
View File
@@ -85,7 +85,13 @@ document.body.addEventListener('htmx:response:error', function(evt) {
});
// Request-level failures: network errors, timeouts (htmx 4 defaults to 60s).
// A load the shared confirm dialog ended is aborted on purpose and carries
// data-load-cancelled, so it shows no toast; the timeout and every other
// htmx:error still do.
document.body.addEventListener('htmx:error', function(evt) {
if (evt.target && evt.target.dataset && evt.target.dataset.loadCancelled === "true") {
return;
}
showErrorToast('Network error. Please check your connection and try again.');
// No swap ever lands, so a drag-to-reorder started here stays frozen too.
signalReorderRecover();
@@ -3,8 +3,8 @@
{{/* member_plans.html
The Products page's Plans region: one bordered group per ladder,
tier cards inside, the switch preview banner, and the plan cancel
and keep controls (cancel through the shared confirm modal;
tier cards inside, the switch's confirmation in the shared dialog, and
the plan cancel and keep controls (cancel through the shared confirm modal;
design-system.md §3 bans hx-confirm). Badges render through statusBadge; the cancel chip
carries the date itself, which is the fact a member needs. */}}
{{ if .Error }}
@@ -24,9 +24,6 @@
{{ .Success }}
</div>
{{ end }}
{{ if .Preview }}
{{ template "form" .Preview }}
{{ end }}
{{ range .Ladders }}
<div class="border-start border-3 ps-3 mb-4{{ if .Enrolled }} border-primary{{ else }} border-secondary-subtle{{ end }}">
<h3 class="fw-bold mb-3 h6{{ if not .Enrolled }} text-muted{{ end }}">
@@ -187,8 +184,8 @@
{{/* chrome-conventions D2 (one dominant primary action per screen):
every purchasable tier across every ladder is a parallel,
equal-weight option, so none carries the filled primary
style; only the switch-preview banner's "Confirm switch"
(above) is a genuine single dominant action, when shown. */}}
style; only the dialog's "Confirm switch"
is a genuine single dominant action, when shown. */}}
{{ if eq .MoveKind "checkout" }}
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
hx-post="/billing/checkout" hx-vals='{"price_id": "{{ .PriceID }}"}'
@@ -196,8 +193,11 @@
<div id="checkout-error-{{ .MoveLadderID }}-{{ .ProductID }}" class="small text-danger mt-1"></div>
{{ else if eq .MoveKind "switch" }}
<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": "{{ .MoveLadderID }}", "price_id": "{{ .PriceID }}"}'
hx-target="#member-plans" hx-swap="innerHTML"{{ if $.BillingFacts.Shown }} aria-describedby="{{ $.BillingFacts.ID }}"{{ end }}>{{ .MoveLabel }}</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="{{ .SwitchTitle }}"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "{{ .MoveLadderID }}", "price_id": "{{ .PriceID }}"}'{{ if $.BillingFacts.Shown }} aria-describedby="{{ $.BillingFacts.ID }}"{{ end }}>{{ .MoveLabel }}</button>
{{ else if eq .MoveKind "cancel" }}
<button type="button" class="btn btn-outline-secondary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
@@ -23,6 +23,13 @@
find the part's own elements instead of a hand-written id; its
behaviour did not.
The body also holds the loaded region, #confirmActionModalLoaded,
hidden while the shared confirm form shows. A trigger naming
data-action-load makes the script hide the shared form and fill the
region with a form the server renders (the plan switch's confirmation);
the dialog's close aborts a load still in flight and puts the shared
form back (design D5 of switch-confirms-in-the-shared-dialog).
Rendered as a sibling of <main> so it survives boosted navigations.
Parsed into every set by the surface constructors of internal/web. */}}
<div class="modal fade" id="confirmActionModal" tabindex="-1" aria-labelledby="confirmActionModalLabel" aria-hidden="true">
@@ -34,6 +41,7 @@
</div>
<div class="modal-body">
{{ template "form" (shellConfirmForm) }}
<div id="confirmActionModalLoaded" hidden></div>
</div>
</div>
</div>
+1 -2
View File
@@ -432,8 +432,7 @@ type WayOut struct {
// of a plain navigation: the response at URL is swapped into HXTarget
// with HXSwap (default "innerHTML") rather than the browser navigating.
// For a form whose container is itself an htmx-swapped region, not a
// routable page (the workspace create panel's "Back" to the list, the
// member plan-move preview's "Keep current plan"), a plain <a href>
// routable page (the workspace create panel's "Back" to the list), a plain <a href>
// would fall through to the page's own hx-boost and swap the wrong
// region. Both empty renders a plain <a href>, which is what a create
// page's Cancel to its own list needs.
+7 -3
View File
@@ -806,7 +806,7 @@ func newFormView(spec FormSpec, b Binding, side renderedSide, formID, target str
Method: side.method,
Action: side.action,
NoValidate: side.kind.ServerValidated(),
Box: boxFor(spec.Family, side.kind),
Box: boxFor(spec.Family, side.kind, side.wayOut.Kind),
Target: target,
Message: b.Message,
MessageAfter: spec.MessageAfter,
@@ -913,11 +913,15 @@ func padCells(cells []template.HTML, columns int) []template.HTML {
// dense row's box is the panel its page already opened, a bar's is the
// list header it sits in, and a table is its own shape; a stacked form
// owns its own, so a page writes no card of its own and every stacked
// form is the same width on every surface.
func boxFor(family Family, kind Kind) string {
// form is the same width on every surface. A preview whose way out closes
// the modal sits in the modal, which is its box.
func boxFor(family Family, kind Kind, wayOut WayOutKind) string {
if family != Stacked {
return ""
}
if kind == KindPreview && wayOut == WayOutDismissModal {
return ""
}
switch kind {
case KindPreview:
return "alert"
+35
View File
@@ -1271,3 +1271,38 @@ func TestRenderHintOnChoiceFollowsTheChosenOption(t *testing.T) {
t.Errorf("with no option that shows it the field renders hint %q attrs %q", f.Hint, f.Attrs)
}
}
// A preview that leaves by closing the dialog sits in the dialog, which is
// its box; every other stacked form keeps its own (design D4).
func TestPreviewInTheDialogRendersNoBox(t *testing.T) {
preview := func(wayOut WayOut) FormSpec {
return FormSpec{
Name: "test.preview", Kind: KindPreview, Family: Stacked,
Method: "POST", Path: "/partials/test",
Fields: []Field{{Name: "ladder_id", Label: "Ladder", Control: Hidden}},
Commit: "Confirm", WayOut: wayOut,
}
}
rows := []struct {
name string
spec FormSpec
want string
}{
{name: "a preview leaving by the dialog", spec: preview(DismissModal("Keep")), want: ""},
{name: "a preview leaving by Discard", spec: preview(Discard("Discard", "/partials/test")), want: "alert"},
{name: "a create form leaving by the dialog", want: "card", spec: FormSpec{
Name: "test.create", Kind: KindCreate, Family: Stacked,
Method: "POST", Path: "/partials/test",
Fields: []Field{{Name: "name", Label: "Name", Control: Text}},
Commit: "Create", WayOut: DismissModal("Cancel"),
}},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
v := Render(row.spec, Binding{Mode: ModeRecord, Message: "<p>Message</p>"})
if v.Box != row.want {
t.Errorf("%s: box = %q, want %q", row.name, v.Box, row.want)
}
})
}
}
+2 -2
View File
@@ -449,7 +449,7 @@ type urlRef struct {
}
type interpolatedLiteralRef struct {
Attr string // hx-get / hx-post / data-action-url
Attr string // hx-get / hx-post / data-action-url / data-action-load
Value string // raw attribute value including {{...}}
File string
Line int
@@ -705,7 +705,7 @@ func scanTemplateHTML(path, src string, info *templateInfo) {
Line: line,
})
}
if (hxAttrRe.MatchString(k) || k == "data-action-url") &&
if (hxAttrRe.MatchString(k) || k == "data-action-url" || k == "data-action-load") &&
strings.Contains(v, "{{") &&
!strings.Contains(v, "routeURL") &&
urlLooksLikeRoute(v) {
+2 -1
View File
@@ -89,7 +89,8 @@ func init() {
Note: "fired beside the card's organizations not moved; returns that default change's failed obligations to pending and redispatches the drain"},
// Member: plans, add-ons and checkout.
{Label: "Confirm switch", Method: "POST", Path: "/partials/member/plans/switch"},
{Label: "Confirm switch", Method: "POST", Path: "/partials/member/plans/switch", Modal: true,
Note: "the commit of the switch's confirmation, which the shared dialog loads from the preview"},
{Label: "Cancel plan", Method: "POST", Path: "/partials/member/plans/cancel", Modal: true},
{Label: "Keep plan", Method: "POST", Path: "/partials/member/plans/keep",
Note: "the current plan's control, and the paid plan's card under a grant, while a downgrade or a cancellation is scheduled"},
+4 -5
View File
@@ -48,7 +48,7 @@ func TestPlansCarryTheBillingLineOnceAndThePaidMovesReferenceIt(t *testing.T) {
if n := strings.Count(body, `aria-describedby="plans-billing-facts"`); n != 2 {
t.Errorf("%d controls reference the line, want the checkout and the switch, not the cancel", n)
}
for _, control := range []string{`hx-post="/billing/checkout"`, `hx-get="/partials/member/plans/switch/preview"`} {
for _, control := range []string{`hx-post="/billing/checkout"`, `data-action-load="/partials/member/plans/switch/preview"`} {
tag := body[strings.Index(body, control):]
tag = tag[:strings.Index(tag, ">")]
if !strings.Contains(tag, `aria-describedby="plans-billing-facts"`) {
@@ -59,7 +59,7 @@ func TestPlansCarryTheBillingLineOnceAndThePaidMovesReferenceIt(t *testing.T) {
// The switch preview's commit, Confirm switch or the Approve payment link,
// is the control that commits the charge, so it references the line too.
// WithPreview is the one path both preview renders take.
// describeCommit is the one path both confirmation renders take.
func TestPlanPreviewCommitReferencesTheLine(t *testing.T) {
values := forms.NewValues()
values.Set("ladder_id", "l1")
@@ -71,9 +71,8 @@ func TestPlanPreviewCommitReferencesTheLine(t *testing.T) {
if link {
view.CommitLink = &forms.CommitLinkView{Label: "Approve payment", URL: "https://invoice.stripe.com/i/test"}
}
data := plansWithMoves(facts)
data.WithPreview(&view)
return Render(t, MemberSet(t), "member_plans.html", data)
describeCommit(&view, facts)
return Render(t, MemberSet(t), "form", view)
}
shown := BillingFacts{Sentences: shownFacts}.WithID(plansBillingFactsID)
@@ -937,7 +937,7 @@ func TestMemberSwitchPreview_CancellationAddsNoSentence(t *testing.T) {
rec := httptest.NewRecorder()
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")+"?") {
if !strings.Contains(body, "Confirm switch") {
t.Fatalf("no preview rendered:\n%s", body)
}
for _, banned := range []string{"This replaces your cancellation", "You also"} {
@@ -350,7 +350,7 @@ func TestCurrentRungSurvivesRetirement(t *testing.T) {
if !strings.Contains(body, "Current plan") {
t.Errorf("enrolled org: expected a 'Current plan' badge on the retired rung")
}
if strings.Contains(body, `hx-post="/billing/checkout"`) || strings.Contains(body, `hx-get="/partials/member/plans/switch/preview"`) {
if strings.Contains(body, `hx-post="/billing/checkout"`) || strings.Contains(body, `data-action-load="/partials/member/plans/switch/preview"`) {
t.Errorf("enrolled org: the retired current rung must not offer a move control")
}
+6 -5
View File
@@ -9,10 +9,11 @@ import "git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
// form-library; design D9, D10, D12). A paid-to-paid switch commits through
// a preview: the target ladder and price ride hidden fields, what the
// move costs and when is prose above them (Binding.Message), and the commit
// is "Confirm switch" beside a "Keep current plan" way out that simply
// re-fetches the plans list. An upgrade's preview also carries the moment
// its figure was prorated at, which the switch sends back to Stripe so the
// charge equals the figure (plan-switch-timing D6).
// is "Confirm switch" beside a "Keep current plan" way out that closes the
// shared dialog, which the form renders in and which is its box. An
// upgrade's preview also carries the moment its figure was prorated at,
// which the switch sends back to Stripe so the charge equals the figure
// (plan-switch-timing D6).
//
// This file is the form's home; the registry is only the index (design
// D1). It sits beside member_plan_moves.go, which parses through the
@@ -34,5 +35,5 @@ var planMoveForm = forms.Register(forms.FormSpec{
{Name: "proration_date", Label: "Prorated at", Control: forms.Hidden},
},
Commit: "Confirm switch",
WayOut: forms.ScopedLinkOut("Keep current plan", "/partials/member/plans", "#member-plans"),
WayOut: forms.DismissModal("Keep current plan"),
})
+76 -51
View File
@@ -19,16 +19,25 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/plans"
)
// planMoveTitle words the question the switch's dialog asks: "Pay for <tier>?"
// when ownTier, the granted tier's own card, and "Switch to <tier>?" otherwise.
// The switch control carries it in data-action-title, which the template
// escapes and the dialog's script writes as text, so it is plain text here.
func planMoveTitle(tierName string, ownTier bool) string {
if ownTier {
return "Pay for " + tierName + "?"
}
return "Switch to " + tierName + "?"
}
// planMoveMessage builds the switch confirmation's prose (plan-switch-timing
// D7): the target tier named up front, then when the move takes effect and
// 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"). replacesGrant opens
// the second paragraph with "Your subscription replaces the grant." for a
// switch of a subscription that pays under a grant. ownTier, true when that
// switch's target is the current rung the grant holds, words the heading
// "Pay for <tier>?" in place of "Switch to <tier>?". newPeriodCharge is set for
// D7), the dialog's body under the title planMoveTitle words: when the move
// takes effect and 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"). replacesGrant
// opens the paragraph with "Your subscription replaces the grant." for a
// switch of a subscription that pays under a grant. newPeriodCharge is set for
// an upgrade the decision marks as a new period and is the target price's
// amount: with the preview's figure the sentence states the figure "after a
// credit", with nothing due it states that nothing is charged, and with no
@@ -38,13 +47,9 @@ import (
// be false.
// Tier names are the one piece here that is not the console's own copy, so
// they alone are escaped.
func planMoveMessage(tierName, currentName, then string, replacesGrant, ownTier bool, newPeriodCharge string, p fulfillment.SwitchPreview) template.HTML {
func planMoveMessage(currentName, then string, replacesGrant bool, newPeriodCharge string, p fulfillment.SwitchPreview) template.HTML {
esc := template.HTMLEscapeString
heading := "Switch to "
if ownTier {
heading = "Pay for "
}
msg := "<p class=\"mb-2\"><strong>" + heading + esc(tierName) + "?</strong></p><p class=\"mb-0\">"
msg := "<p class=\"mb-0\">"
if replacesGrant {
msg += "Your subscription replaces the grant. "
}
@@ -71,10 +76,11 @@ func planMoveMessage(tierName, currentName, then string, replacesGrant, ownTier
return template.HTML(msg + "</p>")
}
// GetSwitchPreview handles GET /partials/member/plans/switch/preview — it renders
// the plans partial with a confirmation banner carrying the proration estimate
// for the proposed paid→paid switch. The estimate is best-effort; the banner
// still lets the member confirm if Stripe could not produce one.
// GetSwitchPreview handles GET /partials/member/plans/switch/preview, the
// shared dialog's load: it answers with the switch's confirmation, a form
// carrying the proration estimate for the proposed paid→paid switch. The
// estimate is best-effort; the form still lets the member confirm if Stripe
// could not produce one.
func (h *MemberProductsHandler) GetSwitchPreview(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
@@ -85,9 +91,10 @@ func (h *MemberProductsHandler) GetSwitchPreview(w http.ResponseWriter, r *http.
http.Error(w, "billing not configured", http.StatusServiceUnavailable)
return
}
// GET, not a submission of planMoveForm: htmx sends hx-vals on a GET as
// the query string, which is where this reads them (never
// planMoveForm.Parse, whose non-Search Kind reads r.PostForm).
// GET, not a submission of planMoveForm: the shared dialog's script sends
// the control's data-action-fields as the query string, which is where
// this reads them (never planMoveForm.Parse, whose non-Search Kind reads
// r.PostForm).
query := r.URL.Query()
postedLadder := query.Get("ladder_id")
postedPrice := query.Get("price_id")
@@ -96,14 +103,13 @@ func (h *MemberProductsHandler) GetSwitchPreview(w http.ResponseWriter, r *http.
return
}
// The gates the switch itself applies, before the target's name is read
// or Stripe is asked: a target the switch would refuse is refused here
// with the same answer, and nothing about it is rendered
// (stripe-subscription-management, "Live proration preview before a
// switch"; offer-enforcement D8).
// The gates the switch itself applies, before Stripe is asked: a target
// the switch would refuse is refused here with the same answer, and
// nothing about it is rendered (stripe-subscription-management, "Live
// proration preview before a switch"; offer-enforcement D8).
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")
h.refusePreview(w, r.Context(), session.OrgID, err)
return
}
// The switch changes the subscription the member's moves on this ladder
@@ -115,14 +121,45 @@ func (h *MemberProductsHandler) GetSwitchPreview(w http.ResponseWriter, r *http.
if err == nil {
err = fulfillment.ErrNoActivePaidSubscription
}
h.renderPlansAfterMove(w, r.Context(), session.OrgID, err, "switch-preview")
h.refusePreview(w, r.Context(), session.OrgID, err)
return
}
h.renderSwitchPreview(w, r.Context(), session.OrgID, ladderID, price, move, http.StatusOK, "")
}
// renderSwitchPreview renders the plans partial with the switch confirmation
// refusePreview answers a preview the console refuses. The preview is loaded
// into the dialog, and a refusal belongs to the Plans region, so it names that
// region in HX-Retarget; the dialog's script sees the answer land outside the
// dialog and closes it.
func (h *MemberProductsHandler) refusePreview(w http.ResponseWriter, ctx context.Context, orgID string, err error) {
w.Header().Set("HX-Retarget", "#member-plans")
h.renderPlansAfterMove(w, ctx, orgID, err, "switch-preview")
}
// describeCommit references the billing line from the commit of a switch's
// confirmation, Confirm switch or the Approve payment link, because it is the
// control that commits the charge. It does nothing when the line is not shown.
func describeCommit(view *forms.FormView, facts BillingFacts) {
if facts.Shown() {
view.CommitDescribedBy = facts.ID
}
}
// renderConfirmation answers every route that renders a switch's
// confirmation: the form alone, for the shared dialog's loaded region, which
// HX-Retarget names because the switch's own form targets the Plans region.
func (h *MemberProductsHandler) renderConfirmation(w http.ResponseWriter, ctx context.Context, view forms.FormView, status int) {
facts := loadBillingFacts(ctx, h.Database, h.StripeKeyFingerprint, h.Logger).WithID(plansBillingFactsID)
describeCommit(&view, facts)
w.Header().Set("HX-Retarget", confirmLoadedRegion)
if status != http.StatusOK {
w.WriteHeader(status)
}
h.Templates.Render(w, "form", view)
}
// renderSwitchPreview answers into the dialog with the switch confirmation
// for target on ladderID (plan-switch-timing D6, D7): for an upgrade, the
// amount charged now, prorated at a moment the form carries back so the
// charge equals it; for a downgrade, the date the current tier ends. It
@@ -133,10 +170,6 @@ func (h *MemberProductsHandler) GetSwitchPreview(w http.ResponseWriter, r *http.
// sentence) and, for its NewPeriod, the new period's charge (Copy N1, N2 or
// M12). The caller has already applied the switch's gates.
func (h *MemberProductsHandler) renderSwitchPreview(w http.ResponseWriter, ctx context.Context, orgID, ladderID string, target billing.Price, move plans.Move, status int, formErr string) {
var tierName string
if product, err := h.BillingQ.GetProductByID(ctx, target.ProductID); err == nil {
tierName = product.Name
}
preview := fulfillment.PreviewSwitch(ctx, h.Database, h.Logger, orgID, ladderID, target.PriceID)
newPeriodCharge := ""
if move.NewPeriod {
@@ -152,7 +185,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), move.ReplacesGrant, move.OwnTier, newPeriodCharge, preview),
Message: planMoveMessage(h.currentTierName(ctx, orgID, ladderID), recurringPhrase(target), move.ReplacesGrant, newPeriodCharge, preview),
}
if formErr != "" {
errs := forms.NewErrors()
@@ -161,21 +194,15 @@ func (h *MemberProductsHandler) renderSwitchPreview(w http.ResponseWriter, ctx c
binding.Errors = errs
}
view := forms.Render(planMoveForm, binding)
data := h.buildPlansData(ctx, orgID)
data.WithPreview(&view)
if status != http.StatusOK {
w.WriteHeader(status)
}
h.Templates.Render(w, "member_plans.html", data)
h.renderConfirmation(w, ctx, view, status)
}
// renderApprovalPreview renders the plans partial with an upgrade awaiting
// renderApprovalPreview answers into the dialog with an upgrade awaiting
// the cardholder's approval (plan-switch-timing D1, D7): the amount to
// approve as the message, and in the commit's place a link to the invoice
// Stripe hosts, where the member approves the payment or changes the card.
// The way out stays; the pending update expires on its own if left.
func (h *MemberProductsHandler) renderApprovalPreview(w http.ResponseWriter, ctx context.Context, orgID, ladderID string, target billing.Price, approval *fulfillment.UpgradeNeedsApprovalError) {
func (h *MemberProductsHandler) renderApprovalPreview(w http.ResponseWriter, ctx context.Context, ladderID string, target billing.Price, approval *fulfillment.UpgradeNeedsApprovalError) {
var tierName string
if product, err := h.BillingQ.GetProductByID(ctx, target.ProductID); err == nil {
tierName = product.Name
@@ -191,10 +218,7 @@ func (h *MemberProductsHandler) renderApprovalPreview(w http.ResponseWriter, ctx
" charge before " + template.HTMLEscapeString(tierName) + " starts.</p>"),
})
view.CommitLink = &forms.CommitLinkView{Label: "Approve payment", URL: approval.HostedInvoiceURL}
data := h.buildPlansData(ctx, orgID)
data.WithPreview(&view)
h.Templates.Render(w, "member_plans.html", data)
h.renderConfirmation(w, ctx, view, http.StatusOK)
}
// currentTierName names the tier the organization holds on ladderID, for the
@@ -210,8 +234,9 @@ func (h *MemberProductsHandler) currentTierName(ctx context.Context, orgID, ladd
}
// PostSwitch handles POST /partials/member/plans/switch — an in-place paid→paid
// move to the target tier's price. On success or a handled error it re-renders
// the plans partial (swapped into #member-plans).
// move to the target tier's price. A switch made or refused re-renders the
// plans partial into #member-plans; its confirmation answers (approval,
// decline, stale moment) go to the dialog's loaded region.
func (h *MemberProductsHandler) PostSwitch(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
if session == nil {
@@ -254,7 +279,7 @@ func (h *MemberProductsHandler) PostSwitch(w http.ResponseWriter, r *http.Reques
case errors.Is(err, fulfillment.ErrStalePreview), errors.Is(err, fulfillment.ErrUpgradeDeclined), errors.As(err, &approval):
switch {
case approval != nil:
h.renderApprovalPreview(w, r.Context(), session.OrgID, ladderID, target, approval)
h.renderApprovalPreview(w, r.Context(), ladderID, target, approval)
case errors.Is(err, fulfillment.ErrUpgradeDeclined):
h.Logger.Info("plan move declined", slog.String("action", "switch"), slog.Any("error", err))
msg := "Your card was declined."
+11 -17
View File
@@ -15,7 +15,6 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"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/forms"
"git.coopcloud.tech/wiki-cafe/member-console/internal/plans"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"github.com/sqlc-dev/pqtype"
@@ -138,6 +137,10 @@ type TierViewModel struct {
// MoveLabel is the control's caption ("Upgrade" / "Downgrade" / "Subscribe").
// Empty for the current rung, which shows no control.
MoveLabel string
// switchTitle is the title of the dialog the switch control opens, set by
// setMove for a Switch move. It is unexported so that the view model's
// JSON goldens do not list it; the template reads it through SwitchTitle.
switchTitle string
// DisabledReason explains why a shown-but-disabled control cannot be used.
// Empty when MoveEnabled or for the current rung.
DisabledReason string
@@ -278,29 +281,14 @@ type PlansData struct {
// after a switch/cancel move completes — without it a successful
// cancel-at-period-end re-renders pixel-identical to a no-op.
Success string
// Preview, when set, renders the member.plan.move declaration as a
// switch confirmation banner above the ladders, with the proration
// estimate as its Message and Confirm / Keep-current as its commit and
// way out (spec form-library; design D9, D10).
Preview *forms.FormView
// BillingFacts names who bills the member and what their card statement
// says, under the Plans heading, when either would not be recognised and
// at least one tier offers an enabled move that costs money
// (merchant-disclosure D5). Those controls, checkouts and switches,
// reference it in aria-describedby, and so does the preview's commit.
// reference it in aria-describedby.
BillingFacts BillingFacts
}
// WithPreview sets the switch preview, and makes its commit, the control
// that confirms the switch or leaves to approve its payment, reference the
// billing line when the line is shown.
func (d *PlansData) WithPreview(view *forms.FormView) {
if d.BillingFacts.Shown() {
view.CommitDescribedBy = d.BillingFacts.ID
}
d.Preview = view
}
// GetPlans handles GET /partials/member/plans
func (h *MemberProductsHandler) GetPlans(w http.ResponseWriter, r *http.Request) {
session := h.AuthConfig.GetUserSession(r.Context())
@@ -595,6 +583,9 @@ func (t *TierViewModel) setGranted(pos entitlements.ListLadderPositionsByPoolRow
}
}
// SwitchTitle is the title of the dialog the switch control opens.
func (t TierViewModel) SwitchTitle() string { return t.switchTitle }
// moveDisabledReason is what a move the decision offers disabled reads.
const moveDisabledReason = "Not available for purchase yet"
@@ -611,6 +602,9 @@ func (t *TierViewModel) setMove(m plans.Move, ladderID string, p priceFacts) {
if m.Kind == plans.None {
return
}
if m.Kind == plans.Switch {
t.switchTitle = planMoveTitle(t.Name, m.OwnTier)
}
t.MoveLadderID = ladderID
t.MoveKind = string(m.Kind)
t.MoveEnabled = m.Enabled
@@ -389,7 +389,6 @@ func compareGoldenPlans(ctx context.Context, t *testing.T, h *MemberProductsHand
}
data.Ladders = ours
data.BillingFacts = BillingFacts{}
data.Preview = nil
base := filepath.Join("testdata", "member_plans", o.state)
view := plansGoldenView{Ladders: data.Ladders, Error: data.Error, Success: data.Success}
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/plans"
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
)
// switch-confirms-in-the-shared-dialog D2, D3: the switch control is a trigger
// of the shared dialog that titles it and names the route to load, and it
// sends no request of its own.
func TestSwitchControlOpensTheSharedDialog(t *testing.T) {
rows := []struct {
name string
move plans.Move
title string
}{
{name: "an upgrade", move: plans.Move{Kind: plans.Switch, Enabled: true, Relation: plans.Upgrade}, title: "Switch to Pro?"},
{name: "the granted tier's own card", move: plans.Move{Kind: plans.Switch, Enabled: true, Relation: plans.Upgrade, ReplacesGrant: true, OwnTier: true}, title: "Pay for Pro?"},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
tier := TierViewModel{ProductID: "prod_pro", Name: "Pro"}
tier.setMove(row.move, "ladder_1", priceFacts{Price: plans.Price{ID: "price_pro", Purchasable: true}, Text: "$30.00/month"})
body := Render(t, MemberSet(t), "member_plans.html", PlansData{
Ladders: []LadderViewModel{{Name: "Plans", Enrolled: true, Tiers: []TierViewModel{tier}}},
})
controls := htmltest.Parse(t, body).All("button[data-action-load]")
if len(controls) != 1 {
t.Fatalf("%s: %d controls load a confirmation, want 1:\n%s", row.name, len(controls), body)
}
control := controls[0]
for _, check := range []struct{ attr, want string }{
{"data-bs-toggle", "modal"},
{"data-bs-target", "#confirmActionModal"},
{"data-action-title", row.title},
{"data-action-load", "/partials/member/plans/switch/preview"},
{"data-action-fields", `{"ladder_id": "ladder_1", "price_id": "price_pro"}`},
} {
if got, _ := control.Attr(check.attr); got != check.want {
t.Errorf("%s: the switch control's %s = %q, want %q", row.name, check.attr, got, check.want)
}
}
if got, ok := control.Attr("hx-get"); ok {
t.Errorf("%s: the switch control carries hx-get %q, want the dialog's load alone", row.name, got)
}
})
}
}
// The shared dialog holds the region the switch's answers retarget to, hidden
// at rest.
func TestConfirmModalCarriesTheLoadedRegion(t *testing.T) {
body := Render(t, MemberSet(t), "shell_confirm_modal.html", nil)
regions := htmltest.Parse(t, body).All(".modal-body " + confirmLoadedRegion)
if len(regions) != 1 {
t.Fatalf("the shared dialog has no element %s in its body, which the switch's answers retarget to:\n%s", confirmLoadedRegion, body)
}
if _, hidden := regions[0].Attr("hidden"); !hidden {
t.Errorf("the loaded region is not hidden at rest:\n%s", body)
}
}
+9 -26
View File
@@ -197,11 +197,11 @@ func refused(t *testing.T, a answer, sentence string) {
}
// allowed fails unless the request was not refused: the preview renders the
// confirmation of the switch to name, and the switch reaches the mock.
func allowed(t *testing.T, a answer, preview bool, name string) {
// confirmation's form, and the switch reaches the mock.
func allowed(t *testing.T, a answer, preview bool) {
t.Helper()
if preview {
for _, want := range []string{"Switch to " + name + "?", "Confirm switch"} {
for _, want := range []string{"Confirm switch"} {
if !strings.Contains(a.body, want) {
t.Errorf("want %q in:\n%s", want, a.body)
}
@@ -281,22 +281,12 @@ func TestSwitchRefusesWhatTheOfferDoesNotOffer(t *testing.T) {
ladderID, priceID, control := tc.setup(t, m)
both(t, func(t *testing.T, preview bool) {
refused(t, m.ask(t, m.h, preview, ladderID, priceID), offerUnavailable)
allowed(t, m.ask(t, m.h, preview, m.ladderID, control), preview, m.nameOf(t, control))
allowed(t, m.ask(t, m.h, preview, m.ladderID, control), preview)
})
})
}
}
// nameOf is the name of the product a console price belongs to.
func (m offerMember) nameOf(t *testing.T, priceID string) string {
t.Helper()
product, err := billing.New(m.db).GetProductByID(context.Background(), m.productOf(t, priceID))
if err != nil {
t.Fatalf("read product: %v", err)
}
return product.Name
}
// TestSwitchOfferJudgesTheNamedLadder pins the order of the switch's answers on
// a second ladder the member holds nothing on: a tier of the first ladder is
// not a tier of this one, an unavailable plan, and the second ladder's own tier
@@ -309,7 +299,7 @@ func TestSwitchOfferJudgesTheNamedLadder(t *testing.T) {
both(t, func(t *testing.T, preview bool) {
refused(t, m.ask(t, m.h, preview, second, m.prices[2]), offerUnavailable)
refused(t, m.ask(t, m.h, preview, second, ownPrice), offerNoSub)
allowed(t, m.ask(t, m.h, preview, m.ladderID, m.prices[2]), preview, m.nameOf(t, m.prices[2]))
allowed(t, m.ask(t, m.h, preview, m.ladderID, m.prices[2]), preview)
})
}
@@ -377,7 +367,6 @@ func TestSwitchRefusesWhenAReadFails(t *testing.T) {
return h
}
realEnts, realBilling := entitlements.New(m.db), billing.New(m.db)
name := m.nameOf(t, m.prices[2])
cases := []struct {
fail string
@@ -393,7 +382,7 @@ func TestSwitchRefusesWhenAReadFails(t *testing.T) {
t.Run(tc.fail, func(t *testing.T) {
both(t, func(t *testing.T, preview bool) {
refused(t, m.ask(t, tc.h, preview, m.ladderID, m.prices[2]), offerMoveFailed)
allowed(t, m.ask(t, m.h, preview, m.ladderID, m.prices[2]), preview, name)
allowed(t, m.ask(t, m.h, preview, m.ladderID, m.prices[2]), preview)
})
})
}
@@ -799,7 +788,7 @@ func (pm pausedMember) stripeApplies() {
// 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.
// it shows the confirmation, its message.
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)}}
@@ -811,7 +800,7 @@ func (pm pausedMember) post(t *testing.T, tier string, when time.Time) string {
if alerts := doc.Texts(".alert"); len(alerts) > 0 {
return strings.TrimPrefix(strings.Join(alerts, " "), "Error: ")
}
return strings.Join(doc.Texts("strong"), " ")
return strings.Join(doc.Texts(".app-form-message"), " ")
}
// pausedPostRow is one posted switch: the fixture's change, the tier posted,
@@ -822,9 +811,6 @@ type pausedPostRow struct {
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) {
@@ -833,9 +819,6 @@ func checkPausedPost(t *testing.T, pm pausedMember, row pausedPostRow) {
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)
}
@@ -877,7 +860,7 @@ func TestPostSwitchFromAPausedSubscription(t *testing.T) {
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) {
{name: "a proration date before the period", tier: "Max", before: 2 * time.Minute, want: "Your subscription replaces the grant.", prep: func(t *testing.T, pm pausedMember) {
pm.mock.Sub.CurrentPeriodStart = time.Now().Add(-time.Minute).Unix()
}},
}
@@ -73,11 +73,12 @@ func TestMemberPlansUpgradeControlRendering(t *testing.T) {
t.Errorf("expected exactly 1 checkout control, got %d", got)
}
// paid → paid: a control that fetches the proration preview (the actual switch
// POST lives on the preview banner's Confirm button) carrying ladder_id + price.
if !strings.Contains(body, `hx-get="/partials/member/plans/switch/preview"`) ||
!strings.Contains(body, `"ladder_id": "ladder_1"`) || !strings.Contains(body, `"price_id": "price_prem"`) {
t.Error("switch move: expected an hx-get preview control carrying ladder_id + price_prem")
// paid → paid: a control that loads the proration preview into the shared
// dialog (the actual switch POST lives on the dialog's Confirm button)
// carrying ladder_id + price.
if !strings.Contains(body, `data-action-load="/partials/member/plans/switch/preview"`) ||
!strings.Contains(body, `data-action-fields='{"ladder_id": "ladder_1", "price_id": "price_prem"}'`) {
t.Error("switch move: expected a control that loads the switch's confirmation into the shared dialog, carrying ladder_id + price_prem")
}
// paid → free: a cancel control carrying ladder_id, confirming
+27 -14
View File
@@ -26,7 +26,6 @@ func TestPlanMoveMessageStatesTheTiming(t *testing.T) {
p fulfillment.SwitchPreview
then string
replacesGrant bool
ownTier bool
newPeriodCharge string
want string
}{
@@ -52,27 +51,41 @@ func TestPlanMoveMessageStatesTheTiming(t *testing.T) {
want: "Nothing is charged now; then $30.00 a month from today."},
{name: "a new period with no figure, no grant", p: fulfillment.SwitchPreview{}, then: yearly, newPeriodCharge: "$300.00",
want: "Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today."},
{name: "the granted tier's own card", p: fulfillment.SwitchPreview{Available: true, NetAmount: 900, Currency: "usd"}, then: then, replacesGrant: true, ownTier: true,
want: "Your subscription replaces the grant. Your card is charged $9.00 now for the rest of this period, then $30.00 a month."},
{name: "the granted tier's own card, a new period", p: fulfillment.SwitchPreview{}, then: yearly, replacesGrant: true, ownTier: true, newPeriodCharge: "$300.00",
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", tc.then, tc.replacesGrant, tc.ownTier, tc.newPeriodCharge, tc.p))
heading := "<strong>Switch to Plus?</strong>"
if tc.ownTier {
heading = "<strong>Pay for Plus?</strong>"
}
if !strings.Contains(got, heading) || !strings.Contains(got, `<p class="mb-0">`+tc.want+"</p>") {
t.Errorf("%s: message = %s, want the heading %s and the second paragraph %q", tc.name, got, heading, tc.want)
got := string(planMoveMessage("Pro", tc.then, tc.replacesGrant, tc.newPeriodCharge, tc.p))
if got != "<p class=\"mb-0\">"+tc.want+"</p>" {
t.Errorf("%s: message = %s, want the paragraph %q alone", tc.name, 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, false, false, "", fulfillment.SwitchPreview{})); strings.Contains(got, "<b>?") {
t.Errorf("tier names must be escaped: %s", got)
downgrade := fulfillment.SwitchPreview{Available: true, Downgrade: true, EffectiveAt: time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC)}
if got := string(planMoveMessage("<b>", then, false, "", downgrade)); strings.Contains(got, "<b>") {
t.Errorf("the current tier's name must be escaped: %s", got)
}
}
// The question the switch's dialog asks is its title, which the control
// carries (design D3).
func TestPlanMoveTitle(t *testing.T) {
cases := []struct {
name string
tier string
ownTier bool
want string
}{
{name: "a switch", tier: "Plus", want: "Switch to Plus?"},
{name: "the granted tier's own card", tier: "Plus", ownTier: true, want: "Pay for Plus?"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := planMoveTitle(tc.tier, tc.ownTier); got != tc.want {
t.Errorf("%s: title = %q, want %q", tc.name, got, tc.want)
}
})
}
}
+93 -2
View File
@@ -195,7 +195,7 @@ func TestPaidLowestTierIsADowngradeNotACancellation(t *testing.T) {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, `"price_id": "`+m.prices[0]+`"`) || !strings.Contains(body, `hx-get="/partials/member/plans/switch/preview"`) {
if !strings.Contains(body, `"price_id": "`+m.prices[0]+`"`) || !strings.Contains(body, `data-action-load="/partials/member/plans/switch/preview"`) {
t.Errorf("the paid lowest tier must offer a switch carrying its price:\n%s", body)
}
if n := strings.Count(body, `data-action-url="/partials/member/plans/cancel"`); n != 1 {
@@ -220,7 +220,6 @@ func TestDeclinedUpgradeKeepsThePreview(t *testing.T) {
body := rec.Body.String()
for _, want := range []string{
"Your card was declined; you&#39;re still on " + m.names[1] + ".",
"Switch to " + m.names[2] + "?",
"Confirm switch",
} {
if !strings.Contains(body, want) {
@@ -380,3 +379,95 @@ func checkPreviewAcrossIntervals(t *testing.T, m switchTimingMember, name string
t.Errorf("%s: the confirmation reads %q, want %q", name, got, want)
}
}
// switch-confirms-in-the-shared-dialog D5: each answer that renders a switch's
// confirmation names the dialog's region, and the preview's refusal names the
// Plans region. A refusal of the switch itself keeps the form's own target.
func TestSwitchAnswersNameTheirRegion(t *testing.T) {
const dialog = "#confirmActionModalLoaded"
const stale = "Your card is charged $18.00 now for the rest of this period, then $30.00 a month."
rows := []struct {
name string
status int
region string
// alone: the answer is the confirmation's form and no plan card,
// because its region is the dialog.
alone bool
wants func(m switchTimingMember) []string
run func(t *testing.T, m switchTimingMember) *httptest.ResponseRecorder
}{
{name: "a preview offered", status: http.StatusOK, region: dialog, alone: true,
wants: func(switchTimingMember) []string {
return []string{`data-form="member.plan.move"`, stale, `data-bs-dismiss="modal">Keep current plan</button>`}
},
run: func(t *testing.T, m switchTimingMember) *httptest.ResponseRecorder {
m.mock.Invoice = &stripe.Invoice{Currency: stripe.CurrencyUSD, AmountDue: 1800}
return previewOf(m, m.prices[2])
}},
{name: "a preview refused", status: http.StatusOK, region: "#member-plans",
wants: func(switchTimingMember) []string { return []string{"You&#39;re already on that plan."} },
run: func(t *testing.T, m switchTimingMember) *httptest.ResponseRecorder {
return previewOf(m, m.prices[1])
}},
{name: "a declined switch", status: http.StatusUnprocessableEntity, region: dialog, alone: true,
wants: func(m switchTimingMember) []string {
return []string{"Your card was declined; you&#39;re still on " + m.names[1] + "."}
},
run: func(t *testing.T, m switchTimingMember) *httptest.ResponseRecorder {
m.mock.UpdateSub = pendingUpgrade(m, "in_declined", stripe.PaymentIntentStatusRequiresPaymentMethod)
m.mock.Invoice = &stripe.Invoice{Currency: stripe.CurrencyUSD, AmountDue: 900}
return m.post(t, m.prices[2], time.Now())
}},
{name: "a stale switch", status: http.StatusOK, region: dialog, alone: true,
wants: func(switchTimingMember) []string { return []string{stale} },
run: func(t *testing.T, m switchTimingMember) *httptest.ResponseRecorder {
m.mock.Invoice = &stripe.Invoice{Currency: stripe.CurrencyUSD, AmountDue: 1800}
return m.post(t, m.prices[2], time.Time{})
}},
{name: "a switch awaiting approval", status: http.StatusOK, region: dialog, alone: true,
wants: func(switchTimingMember) []string { return []string{">Approve payment</a>"} },
run: func(t *testing.T, m switchTimingMember) *httptest.ResponseRecorder {
m.mock.UpdateSub = pendingUpgrade(m, "in_3ds", stripe.PaymentIntentStatusRequiresAction)
return m.post(t, m.prices[2], time.Now())
}},
{name: "a switch to the current tier", status: http.StatusOK, region: "",
wants: func(switchTimingMember) []string { return []string{"You&#39;re already on that plan."} },
run: func(t *testing.T, m switchTimingMember) *httptest.ResponseRecorder {
return m.post(t, m.prices[1], time.Now())
}},
}
for _, row := range rows {
t.Run(row.name, func(t *testing.T) {
m := seedSwitchTimingMember(t)
checkSwitchAnswerRegion(t, row.name, row.status, row.region, row.alone, row.wants(m), m, row.run)
})
}
}
// previewOf sends the preview of the price, as the dialog's load does.
func previewOf(m switchTimingMember, priceID string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "/partials/member/plans/switch/preview?"+url.Values{"ladder_id": {m.ladderID}, "price_id": {priceID}}.Encode(), nil).WithContext(m.sctx)
rec := httptest.NewRecorder()
m.h.GetSwitchPreview(rec, req)
return rec
}
func checkSwitchAnswerRegion(t *testing.T, name string, status int, region string, alone bool, wants []string, m switchTimingMember, run func(*testing.T, switchTimingMember) *httptest.ResponseRecorder) {
t.Helper()
rec := run(t, m)
body := rec.Body.String()
if rec.Code != status {
t.Errorf("%s: status %d, want %d", name, rec.Code, status)
}
if got := rec.Header().Get("HX-Retarget"); got != region {
t.Errorf("%s: HX-Retarget %q, want %q", name, got, region)
}
for _, want := range wants {
if !strings.Contains(body, want) {
t.Errorf("%s: the answer does not carry %q:\n%s", name, want, body)
}
}
if alone && htmltest.Parse(t, body).Count("[data-action-load], [data-action-url]") > 0 {
t.Errorf("%s: the answer renders a plan card, want the confirmation's form alone", name)
}
}
+5
View File
@@ -45,3 +45,8 @@ var _ = forms.Register(forms.FormSpec{
Commit: "Confirm",
WayOut: forms.DismissModal("Cancel"),
})
// confirmLoadedRegion is the selector of the shared dialog's loaded region
// (shell_confirm_modal.html), which an answer that renders a loaded
// confirmation names in HX-Retarget.
const confirmLoadedRegion = "#confirmActionModalLoaded"
+11 -11
View File
@@ -2,7 +2,7 @@
===== member.plan.move unbound
<div class="alert alert-secondary app-form-box mb-3" role="status">
<form id="form-member.plan.move" data-form="member.plan.move" data-form-kind="preview" class="app-form" novalidate hx-post="/partials/member/plans/switch" hx-target="#member-plans" hx-swap="innerHTML" hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator">
<div id="form-member.plan.move-error" class="app-form-error" role="alert"></div>
@@ -16,7 +16,7 @@
</form>
</div>
===== member.plan.move unbound edit side
@@ -42,7 +42,7 @@
===== member.plan.move create side
<div class="alert alert-secondary app-form-box mb-3" role="status">
<form id="form-member.plan.move" data-form="member.plan.move" data-form-kind="preview" class="app-form" novalidate hx-post="/partials/member/plans/switch" hx-target="#member-plans" hx-swap="innerHTML" hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator">
<div id="form-member.plan.move-error" class="app-form-error" role="alert"></div>
@@ -56,13 +56,13 @@
</form>
</div>
===== member.plan.move unbound with values
<div class="alert alert-secondary app-form-box mb-3" role="status">
<form id="form-member.plan.move" data-form="member.plan.move" data-form-kind="preview" class="app-form" novalidate hx-post="/partials/member/plans/switch" hx-target="#member-plans" hx-swap="innerHTML" hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator">
<div id="form-member.plan.move-error" class="app-form-error" role="alert"></div>
@@ -76,13 +76,13 @@
</form>
</div>
===== member.plan.move record
<div class="alert alert-secondary app-form-box mb-3" role="status">
<form id="form-member.plan.move-i1" data-form="member.plan.move" data-form-kind="preview" class="app-form" novalidate hx-post="/partials/member/plans/switch" hx-target="#member-plans" hx-swap="innerHTML" hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator">
<div id="form-member.plan.move-i1-error" class="app-form-error" role="alert"></div>
@@ -98,19 +98,19 @@
<button type="submit" class="btn btn-primary"><span class="spinner-border spinner-border-sm htmx-indicator me-1" aria-hidden="true"></span>Confirm switch</button>
<button type="button" class="btn btn-outline-secondary" hx-get="/partials/member/plans" hx-target="#member-plans" hx-swap="innerHTML">Keep current plan</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Keep current plan</button>
</div>
</form>
</div>
===== member.plan.move refused [ladder_id price_id proration_date]
<div class="alert alert-secondary app-form-box mb-3" role="status">
<form id="form-member.plan.move" data-form="member.plan.move" data-form-kind="preview" class="app-form" novalidate hx-post="/partials/member/plans/switch" hx-target="#member-plans" hx-swap="innerHTML" hx-disable="find button[type=submit]" hx-indicator="find .htmx-indicator">
<div id="form-member.plan.move-error" class="app-form-error" role="alert"></div>
@@ -124,7 +124,7 @@
</form>
</div>
===== member.plan.move bad-values refusals
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -103,8 +102,11 @@
<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:Plus>"}'
hx-target="#member-plans" hx-swap="innerHTML">Upgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Plus?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Plus>"}'>Upgrade</button>
@@ -164,8 +166,11 @@
<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>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Pro?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Pro>"}'>Upgrade</button>
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -120,8 +119,11 @@
<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:Plus>"}'
hx-target="#member-plans" hx-swap="innerHTML">Upgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Plus?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Plus>"}'>Upgrade</button>
@@ -154,8 +156,11 @@
<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:Standard>"}'
hx-target="#member-plans" hx-swap="innerHTML">Upgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Standard?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Standard>"}'>Upgrade</button>
@@ -190,8 +195,11 @@
<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>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Pro?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Pro>"}'>Upgrade</button>
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -78,8 +77,11 @@
<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:Basic>"}'
hx-target="#member-plans" hx-swap="innerHTML">Downgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Basic?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Basic>"}'>Downgrade</button>
@@ -154,8 +156,11 @@
<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:Standard>"}'
hx-target="#member-plans" hx-swap="innerHTML">Upgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Standard?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Standard>"}'>Upgrade</button>
@@ -190,8 +195,11 @@
<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>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Pro?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Pro>"}'>Upgrade</button>
@@ -276,8 +284,11 @@
<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:Duo>", "price_id": "<price:Duo Max>"}'
hx-target="#member-plans" hx-swap="innerHTML">Upgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Duo Max?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Duo>", "price_id": "<price:Duo Max>"}'>Upgrade</button>
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -78,8 +77,11 @@
<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:Basic>"}'
hx-target="#member-plans" hx-swap="innerHTML">Downgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Basic?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Basic>"}'>Downgrade</button>
@@ -142,8 +144,11 @@
<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:Standard>"}'
hx-target="#member-plans" hx-swap="innerHTML">Upgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Standard?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Standard>"}'>Upgrade</button>
@@ -178,8 +183,11 @@
<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>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Pro?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Pro>"}'>Upgrade</button>
@@ -252,8 +260,11 @@
<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:Duo>", "price_id": "<price:Duo Max>"}'
hx-target="#member-plans" hx-swap="innerHTML">Upgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Duo Max?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Duo>", "price_id": "<price:Duo Max>"}'>Upgrade</button>
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -78,8 +77,11 @@
<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:Basic>"}'
hx-target="#member-plans" hx-swap="innerHTML">Downgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Basic?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Basic>"}'>Downgrade</button>
@@ -114,8 +116,11 @@
<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:Plus>"}'
hx-target="#member-plans" hx-swap="innerHTML">Downgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Plus?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Plus>"}'>Downgrade</button>
@@ -148,8 +153,11 @@
<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:Standard>"}'
hx-target="#member-plans" hx-swap="innerHTML">Downgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Standard?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Standard>"}'>Downgrade</button>
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -113,8 +112,11 @@
<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:Standard>"}'
hx-target="#member-plans" hx-swap="innerHTML">Downgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Standard?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Standard>"}'>Downgrade</button>
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -61,8 +60,11 @@
<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:Basic>"}'
hx-target="#member-plans" hx-swap="innerHTML">Downgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Basic?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Basic>"}'>Downgrade</button>
@@ -97,8 +99,11 @@
<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:Plus>"}'
hx-target="#member-plans" hx-swap="innerHTML">Downgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Plus?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Plus>"}'>Downgrade</button>
@@ -131,8 +136,11 @@
<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:Standard>"}'
hx-target="#member-plans" hx-swap="innerHTML">Downgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Standard?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Standard>"}'>Downgrade</button>
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -78,8 +77,11 @@
<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:Basic>"}'
hx-target="#member-plans" hx-swap="innerHTML">Downgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Basic?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Basic>"}'>Downgrade</button>
@@ -114,8 +116,11 @@
<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:Plus>"}'
hx-target="#member-plans" hx-swap="innerHTML">Downgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Plus?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Plus>"}'>Downgrade</button>
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -130,8 +129,11 @@
<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:Standard>"}'
hx-target="#member-plans" hx-swap="innerHTML">Downgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Standard?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Standard>"}'>Downgrade</button>
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -108,8 +107,11 @@
<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">Pay for this plan</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Pay for Pro?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Pro>"}'>Pay for this plan</button>
@@ -142,8 +144,11 @@
<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>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Max?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Max>"}'>Upgrade</button>
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -171,8 +170,11 @@
<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>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Pro?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Pro>"}'>Upgrade</button>
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-secondary-subtle">
<h3 class="fw-bold mb-3 h6 text-muted">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -4,7 +4,6 @@
<div class="border-start border-3 ps-3 mb-4 border-primary">
<h3 class="fw-bold mb-3 h6">
Wiki
@@ -78,8 +77,11 @@
<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:Basic>"}'
hx-target="#member-plans" hx-swap="innerHTML">Downgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Basic?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Basic>"}'>Downgrade</button>
@@ -142,8 +144,11 @@
<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:Standard>"}'
hx-target="#member-plans" hx-swap="innerHTML">Upgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Standard?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Standard>"}'>Upgrade</button>
@@ -178,8 +183,11 @@
<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>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Pro?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Wiki>", "price_id": "<price:Pro>"}'>Upgrade</button>
@@ -252,8 +260,11 @@
<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:Duo>", "price_id": "<price:Duo Max>"}'
hx-target="#member-plans" hx-swap="innerHTML">Upgrade</button>
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="Switch to Duo Max?"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "<ladder:Duo>", "price_id": "<price:Duo Max>"}'>Upgrade</button>
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-30
@@ -0,0 +1,209 @@
## Context
**The shared dialog.** `#confirmActionModal` is one partial of the application shell (`internal/embeds/templates/partials/shell_confirm_modal.html:28-40`), rendered beside `<main>` on the Products page (`internal/embeds/templates/products.html:87`). Its header holds the title (`#confirmActionModalLabel`, `shell_confirm_modal.html:32`) and a close button. Its body holds the shared confirm form, the declaration `shell.confirm` of kind Confirm with no fields, the commit "Confirm" and the way out "Cancel", which closes the modal (`internal/server/shell_forms.go:39-47`). A trigger is any element with `data-bs-toggle="modal" data-bs-target="#confirmActionModal"` and `data-action-*` attributes. On Bootstrap's `show.bs.modal`, `internal/embeds/static/confirm-action-modal.js` reads the trigger's attributes (`:77-84`), stops unless the trigger names `data-action-url` (`:90-92`), rewrites the form's `hx-post` or `hx-delete`, `hx-target` and `hx-swap` (`:105-118`), writes the title and the body as text (`:120-121`), sets the commit's label and colour (`:123-136`), appends the hidden fields of `data-action-fields` (`:138-158`) and re-processes the form (`:160-163`). It closes the modal when the shared form's request answers 2xx or 3xx and leaves it open otherwise (`:46-65`).
No trigger of this dialog loads anything from the server: every title and body is in the trigger's attributes, and the body is written with `textContent`, so it is one run of text. One modal on the member surface does load its body: FedWiki's create-site modal, a modal of its own (`internal/integrations/fedwiki/templates/fedwiki_sites.html:267-283`), opens on its button's click and swaps the answer of the button's `hx-get` into its body (`:54-56`), and a server-named event closes it (`internal/integrations/fedwiki/static/fedwiki-create-form.js:43-53`).
On the plan cards, Resume plan (`internal/embeds/templates/partials/member_plans.html:157-169`) and Cancel plan (`:170-182`, and the free rung's or held rung's Downgrade, `:201-212`) open the shared dialog with a title, a body, a label, a colour and `{"ladder_id": …}`. Keep plan posts at once (`:152-156`), and Pay invoice is a link (`:149-151`). The rule is `page-anatomy`, "Destructive actions confirm through the shared modal on every surface" (`openspec/specs/page-anatomy/spec.md:123-136`), and `docs/design-system.md` §3, "Confirm modal" (`:187-214`). The lint checks no trigger attribute beyond `hx-confirm` (`internal/lint/anatomy.go:430`) and interpolated route literals in `hx-*` and `data-action-url` (`internal/lint/lint.go:708-718`).
**The switch's confirmation today.** The switch control, drawn on every tier the decision offers a switch and on the current card a grant holds (`member_plans.html:184`, `:197-200`), sends `hx-get="/partials/member/plans/switch/preview"` with `ladder_id` and `price_id` and swaps the answer into `#member-plans`. `GetSwitchPreview` (`internal/server/member_plan_moves.go:74-123`) applies the switch's gates (`:104-108`, `:114-120`). A refusal re-renders the Plans region with the refusal's sentence in place of the ladders (`renderPlansAfterMove`, `:386-445`; `member_plans.html:10-13`). Otherwise `renderSwitchPreview` (`member_plan_moves.go:125-171`) reads the target's name (`:136-139`), calls `fulfillment.PreviewSwitch` (`:140`), which asks Stripe's invoice preview for an upgrade and reads the console's records for a downgrade, and renders `member.plan.move` (`internal/server/member_plan_forms.go:23-38`) with the hidden `ladder_id`, `price_id` and `proration_date` (`member_plan_moves.go:146-151`) and `planMoveMessage`'s HTML as its message (`:155`). It then builds the whole Plans data (`:165`) and sets the form as `PlansData.Preview` (`:166`; `internal/server/member_plans.go:285`, `:297-302`), which the template renders above the first ladder (`member_plans.html:27-29`). The form is of kind Preview (`member_plan_forms.go:25`), so the library wraps it in an `alert alert-secondary` box (`internal/forms/render.go:917-926`; `internal/embeds/templates/partials/ui_form.html:67`), and its way out is a scoped link that re-fetches the Plans region (`member_plan_forms.go:37`). `planMoveMessage` (`member_plan_moves.go:41-72`) writes a bold heading, "Switch to {tier}?" or "Pay for {tier}?" (`:43-47`), then one `<p class="mb-0">` with the charge sentence.
Confirm switch posts `/partials/member/plans/switch` into `#member-plans` (`member_plan_forms.go:28-30`). `PostSwitch` (`member_plan_moves.go:215-273`) answers a payment awaiting approval with `renderApprovalPreview` (`:256-257`, `:173-198`): 200, the form with "Your bank must approve the {amount} charge before {tier} starts." and an "Approve payment" link in the commit's place. It answers a decline with `renderSwitchPreview` at 422 and "Your card was declined; you're still on {current}." in the form-level slot (`:258-264`), and a stale moment with `renderSwitchPreview` at 200 and fresh figures (`:265-268`). Everything else goes to `renderPlansAfterMove` (`:272`): the Plans region with "Your plan change has been submitted." and an `HX-Trigger` for `entitlements-changed` (`:422-428`, `:442`), or with a refusal's sentence in place of the ladders (`:398-419`), at 200 in both cases. Every answer that renders the confirmation re-renders the whole Plans region with it.
The commit and the Approve payment link reference the billing line when it is shown (`WithPreview`, `member_plans.go:294-302`). `buildPlansData` shows it when a tier offers an enabled move that costs money (`plansOfferPaidMove`, `member_plans.go:723-738`), an enabled switch among them, and the public-details record read by `loadBillingFacts` (`internal/server/billing_facts.go:148`) yields a sentence (`member_plans.go:371-373`).
**Constraints.**
- Strict CSP: no inline script; the dialog's behaviour lives in `confirm-action-modal.js`, loaded with `<script defer src>` (`docs/htmx-setup.md`, "External JavaScript").
- htmx 4.0.0 (`docs/htmx-setup.md:17`), with explicit inheritance. Each page's `htmx-config` sets `noSwap` to 204, 304, 403 and 5xx (`products.html:14`), so a 422 swaps without a toast and a 403 or 5xx swaps nothing and shows the error toast (`internal/embeds/static/error-handler.js:44-91`). Per-status routing uses `hx-status`, never a script that steers swaps (`docs/design-system.md:627-628`). Read from the vendored `internal/embeds/static/htmx.min.js` (minified, so cited by behaviour): every response header that starts `HX-` lands in the request's `ctx.hx`; `HX-Retarget` is applied (in `#se`) before the `noSwap` check, at any status, so a 422 and a 200 retarget alike; after it `ctx.target` is the header's selector string, which `htmx:after:swap` carries, and an element otherwise; `htmx.ajax(verb, path, {source, target, swap})` issues a request with the given source and target and the `htmx:abort` event on an element aborts that element's request in flight, whose fetch then rejects and fires `htmx:error` on the element. No handler sends `HX-Retarget` today.
- Bootstrap 5.3.6 (`internal/embeds/static/bootstrap.bundle.js`): `Modal.hide()` does nothing while the modal is still animating open (`:4311-4314`, `if (!this._isShown || this._isTransitioning) return`), which lasts until `shown.bs.modal` fires, about 450 ms after `show.bs.modal`. The load starts on `show.bs.modal`, so an answer that arrives inside that window cannot close the dialog by a bare `hide()`: offline the fetch fails at once, and a refusal often answers within it.
- CSRF is `net/http`'s CrossOriginProtection, so a POST from the dialog carries no token.
- Tests. Seven test files find the switch control by its `hx-get` or read the preview's answer: `billing_facts_render_test.go:51`, `:63-89`; `member_lifecycle_gate_test.go:353`; `member_upgrade_render_test.go:78`; `plan_switch_timing_db_test.go:198`, `:223-226`; `member_switch_offer_db_test.go:201-207`; `grant_resumption_member_test.go:938-942`; `plan_move_message_test.go:60-79` (all under `internal/server/`). `member_plans_golden_db_test.go:392` clears `data.Preview`. Twelve goldens under `internal/server/testdata/member_plans/` carry 30 switch controls, and `internal/server/testdata/forms/member.plan.move.html` renders the form in each mode. No golden renders the dialog.
- Screens and walkthroughs. `make screens` reaches no switch, because the demo database seeds no member onto a paid tier (`test/e2e/screens/manifest.go:193-203`); its modal capture opens the first trigger that carries `data-action-url` (`test/e2e/screens/screens_test.go:475-482`). A browser walkthrough can reach one: `test/e2e/merchant-disclosure/sheet_test.go:531-575` seeds a paid subscription from database rows alone ("so every other priced rung is a switch"), the operator walkthroughs seed rows through `browsertest.AppDB` (`test/e2e/browsertest/session.go:32`), `browsertest.OpenAs` signs in any user (`:85`), and they run under `make test`. A downgrade's preview makes no Stripe call (`internal/fulfillment/plan_change.go:647-651`), so a walkthrough can open it with no Stripe key. No walkthrough opens a switch's confirmation today, and `browsertest.OpenConfirm` reads the shared confirm form (`test/e2e/browsertest/shell.go:111-136`); the switch's confirmation has been captured by hand only.
## Goals / Non-Goals
**Goals:**
- Every plan switch on the Products page (upgrades, downgrades, and Pay for this plan on a paused plan) confirms in the shared dialog, with the sentences it reads today, and no confirmation renders in the Plans region.
- Every answer after the dialog opens lands where the member reads it: an answer that renders the confirmation in the dialog, an answer that renders the Plans region in the Plans region with the dialog closed.
- Every other trigger of the shared dialog works as before.
**Non-Goals:**
- Keep plan, Pay invoice and the operator's previews (proposal, Out of scope).
- New copy. No string is added; the question becomes the dialog's title.
- A browser test of the dialog's Stripe-writing answers (a decline, an approval, a made switch): the walkthrough stops before any Stripe write (What is unverified).
## Decisions
### D1. The dialog loads the switch's form from the server
Pick: the switch control is a trigger of the shared dialog that names a route to load instead of a route to post. The dialog opens at the click with the control's title, the script sends `GET /partials/member/plans/switch/preview` with the control's `ladder_id` and `price_id`, and the answer, the preview form rendered by the server, becomes the dialog's body in place of the shared confirm form. The form's own commit posts the switch.
Options weighed:
- **Rejected: the page computes each switch's confirmation and carries it in the control's `data-action-*` attributes**, so the dialog needs no load. An upgrade's figure is a Stripe invoice preview (`fulfillment.PreviewSwitch`, `internal/fulfillment/plan_change.go:656-729`): the Products page, which renders its sections in its first response, would call Stripe once per enabled upgrade on every render and on every re-render after a move. The moment the figure was prorated at would be the page's, and the switch charges nothing for a moment older than an hour (`openspec/specs/stripe-subscription-management/spec.md:114`), so a page left open would always meet the stale answer. A decline, a stale moment and an approval each answer with a new confirmation, which the attributes cannot carry, and the attributes carry text, while the approval's link and the form-level error are markup.
- **Rejected: the answer opens the dialog.** The control would send the GET itself, and the script would open the dialog when the answer arrives, from an event the server names in `HX-Trigger`, with the title in the event's payload. A refused preview would then never open the dialog. But the dialog would open outside Bootstrap's data API, which returns focus to the control when the dialog closes only when the dialog was opened through it, and the open would hang on a custom event. A refused preview needs a page older than an operator's change, so it is rare, and the dialog that opens and closes on it shows the refusal where the member looks next.
- **Rejected: the server renders the whole dialog content, header and body, and the script rebuilds the shared confirm form when the dialog closes.** The script would keep a copy of the shared form to restore, and the title's id would move with each answer.
- **Settled before this change: the panel beside the card.** The issue's ruling moves the confirmation into the shared dialog.
The pick follows the one modal that already loads its body (Context): it opens at the click through Bootstrap and fills from a GET. It differs in two ways. The shared dialog keeps its shared form for every other trigger, so the loaded form takes a region of its own beside it. And the request is the script's, not the control's, so the script owns the region's whole life: it empties it when the dialog opens, loads into it, and empties it when the dialog closes.
### D2. The trigger contract gains `data-action-load`
A trigger that names `data-action-load` names the route of a GET whose answer is the dialog's form. Its `data-action-fields`, the hidden fields of a posting trigger, become the GET's query. Its `data-action-title` titles the dialog, as on every trigger. `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.
The switch control becomes:
```html
<button type="button" class="btn btn-outline-primary btn-sm w-100 mt-auto"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-title="{{ .SwitchTitle }}"
data-action-load="/partials/member/plans/switch/preview"
data-action-fields='{"ladder_id": "{{ .MoveLadderID }}", "price_id": "{{ .PriceID }}"}'{{ if $.BillingFacts.Shown }} aria-describedby="{{ $.BillingFacts.ID }}"{{ end }}>{{ .MoveLabel }}</button>
```
The route is a literal, as every `data-action-url` on the page is, and its values ride in `data-action-fields`, as Cancel plan's `ladder_id` does, so the interpolated-route lint has nothing to read. That lint's attribute list (`internal/lint/lint.go:708`, `k == "data-action-url"`) gains `data-action-load`, so a route interpolated into the new attribute is refused as it is in the old. The control keeps its reference to the billing line.
The script, on `show.bs.modal` for a trigger that names `data-action-load`: sets the title from `data-action-title`; hides the shared confirm form; empties the loaded region, shows it, sets `aria-busy="true"` on it, clears its cancelled mark (D5), and puts in it a spinner (`spinner-border spinner-border-sm`, `aria-hidden="true"`, no text); and calls `htmx.ajax("GET", route + "?" + the fields as a query, {source: region, target: region, swap: "innerHTML"})`. The region's `aria-busy` goes when the answer swaps in, fails, or the dialog closes. For a trigger without it, the script hides and empties the loaded region, shows the shared confirm form, and runs as today. On `hidden.bs.modal` it ends the load (D5), empties and hides the loaded region and shows the shared confirm form, so the next trigger finds the dialog as every trigger found it before this change.
The loaded region is `<div id="confirmActionModalLoaded" hidden></div>`, after the shared confirm form inside the dialog's `.modal-body`. No load is in flight when another starts, because a second switch needs the first dialog closed and the close aborts the first load (D5), so a first answer never lands under a second switch's title. Go names the region in one constant beside `shell.confirm` (`shell_forms.go`), and a test reads the partial for it (task 3.5).
### D3. The title is the control's, and `planMoveMessage` loses its heading
`planMoveTitle(tierName string, ownTier bool) string` returns "Pay for {tier}?" when `ownTier` and "Switch to {tier}?" otherwise, as plain text: the template escapes it into the attribute and the script writes it with `textContent`. `setMove` (`member_plans.go:604-620`) sets it on the tier for a Switch move, from the tier's name and the move's `OwnTier`, after its early return for a move of kind None; the view exposes it through a method `SwitchTitle()`. Resume plan's title is built the same way, at render, from the tier's name (`internal/server/member_plans_paid_under_grant.go:136`). The title is an unexported field read through a method, so the view model's JSON goldens, which list every exported field of every tier, do not change.
`planMoveMessage(currentName, then string, replacesGrant bool, newPeriodCharge string, p fulfillment.SwitchPreview) template.HTML` returns the charge paragraph alone, `<p class="mb-0">…</p>`, its sentences unchanged. It drops `tierName` and `ownTier`, which only the heading read. `renderSwitchPreview` stops reading the target's name, which only the heading needed; `renderApprovalPreview` keeps its read for "before {tier} starts".
Rejected: the server's answer titles the dialog. The script would have to move a title from the answer into the header, and until the answer arrived the dialog would show the previous trigger's title or "Confirm". The control knows the question at render: the tier is its own card's, and `OwnTier` is the decision's (`internal/plans/offer.go:376`), the one `moveLabel` already reads for "Pay for this plan" (`member_plans.go:627-640`).
### D4. The form stays a preview and loses its box in the dialog
`member.plan.move` keeps the kind Preview, its fields, its commit "Confirm switch", its target `#member-plans` and its 422 contract. Its way out becomes `forms.DismissModal("Keep current plan")`: inside the dialog, leaving means closing it, and nothing needs re-fetching because the Plans region never changed.
`boxFor` (`render.go:917-926`) takes the rendered way out's kind and returns no box for a Preview whose way out closes the modal. The rule is the library's, stated in `form-library` and pinned by a test: a preview that leaves by closing the dialog sits in the dialog, and the dialog is its box. A Preview with any other way out keeps its `alert alert-secondary` box, so the operator's previews do not change, and a Create form whose way out closes a modal keeps its card, so FedWiki's create-site form does not change. `render.go:809` passes `side.wayOut.Kind`, the way out after a binding's override.
Rejected: the kind Confirm. A Confirm form's commit is `btn-danger` (`render.go:676-688`), because the shared form's commit is recoloured by the script for each trigger; the loaded form has no script to recolour it, and Confirm switch is the form's one filled `btn-primary`. A Confirm form also renders its message as a `<p>` for the script to write into (`ui_form.html:212`), which cannot hold the charge paragraph.
Rejected: a binding flag that drops the box. The declaration already says where the form lives, by its way out; a flag would let a render contradict it.
### D5. Each answer names its region, and the dialog closes on an answer outside it
The preview's answer and the switch's answer each go to one of two regions. The server names the region in `HX-Retarget` wherever it differs from the request's own target, and the script closes the dialog whenever a request from the loaded region is answered by a swap outside the dialog.
- `renderSwitchPreview` and `renderApprovalPreview` render the form alone and set `HX-Retarget: #confirmActionModalLoaded`, through one function, `renderConfirmation(w, ctx, view, status)`. On the preview's GET this is the load's own target. On the switch's POST, whose form targets `#member-plans`, it sends a stale moment's fresh figures, a decline and an approval back into the dialog.
- `GetSwitchPreview`'s two refusals go through one function that sets `HX-Retarget: #member-plans` before `renderPlansAfterMove`, because the load targets the dialog and a refusal is the Plans region's.
- `PostSwitch`'s other answers, a switch made or refused, keep the form's target, `#member-plans`, and set nothing.
The script, on `htmx:after:swap`, takes the request's source from the event's target; when the source is the loaded region or inside it, it resolves the swap's target (a selector string after `HX-Retarget`, an element otherwise) and closes the dialog when the target is outside `#confirmActionModal`. On `htmx:response:error` and `htmx:error` whose source is the loaded region itself, that is a load and not a commit, and whose load carries no cancelled mark, it closes the dialog: a load that brought no form leaves nothing to confirm, and the error toast says why. A commit that fails without an answer, a 403, a 5xx or a network error, leaves the dialog open with its form, as the shared form's failures do. For a 403 or a 5xx, which the page's `noSwap` list keeps from swapping, htmx 4 still dispatches `htmx:after:swap` with `ctx.swap === "none"` and the form's own target; the listener returns on `ctx.swap === "none"`, after it has cleared the region's busy state, so this listener closes nothing on an answer that landed nowhere; the error listeners close a failed load.
Every close goes through one helper, `closeDialog()`. Bootstrap ignores `hide()` until `shown.bs.modal` has fired (Context), so before that event the helper waits for it once and then hides; after it, the helper hides at once. The script tracks whether the dialog has shown (set on `shown.bs.modal`, cleared on `show.bs.modal`). The close on an answer outside the dialog, the close on a failed load, and the close of a load the answer of which F3 refuses all call it.
A load the member ends is aborted and ignored. On `hidden.bs.modal`, `endLoad()` sets `data-load-cancelled="true"` on the region and aborts the load with `htmx.trigger(region, "htmx:abort")`. The aborted fetch rejects and htmx fires `htmx:error` on the region; the new listeners and `error-handler.js` (`:88-91`, whose `htmx:error` handler shows "Network error") return at once for an `htmx:error` whose target carries the mark, and for no other: the 60 s timeout is an `htmx:error` too, and a timed-out load still closes the dialog and shows the toast. The next load clears the mark when it starts. Without the abort, a load closed mid-flight would answer later, and a refusal's answer (an answer outside the dialog from the loaded region) would close whatever dialog the member opened since, a Cancel plan dialog included.
An answer that keeps the dialog open moves focus to the new form's first control, its first enabled button or link. The `innerHTML` swap removes the control that had focus (Confirm switch), and htmx restores focus only to an element with an `id`, so without this focus would fall to the page body. The script tells a commit's answer from the load by `event.detail.ctx.request.method`, `"POST"` for the commit and `"GET"` for the load, and moves focus only for a commit's answer (F10, F11, F12). The event's target cannot tell them apart: the commit's form leaves the page in the swap, so htmx dispatches `htmx:after:swap` on the region, as it does for the load.
The script steers no swap: the server picks every answer's region, and the script only decides whether the dialog stays open, from where the answer landed. `docs/design-system.md` §7 says so beside its `hx-status` rule.
The `HX-Trigger` of a made switch is dispatched on the form after the swap. The dialog empties the region on `hidden.bs.modal`, after Bootstrap's fade, so the form is still in the page when htmx dispatches `entitlements-changed`, and the event bubbles to `body`, where the Entitlements panel listens (`products.html:48`).
Rejected: routing by status with `hx-status` on the form. The stale moment, the approval and a made switch all answer 200, and only the first two belong in the dialog.
### D6. The answer is the form alone
`renderConfirmation` renders the `form` part alone (`h.Templates.Render(w, "form", view)`), not the Plans region, so the preview's answer no longer builds the Plans data. It sets the commit's description itself: `loadBillingFacts(...).WithID(plansBillingFactsID)`, and when the facts are shown, `describeCommit(&view, facts)` names the line in `CommitDescribedBy`. `describeCommit` is `WithPreview` without `PlansData`, which loses `Preview` and `WithPreview`. The line is in the page when the facts are shown, because the switch that opened the dialog is an enabled move that costs money, the condition `buildPlansData` adds to the facts (Context).
### D7. Specs, documents and comments
- `page-anatomy`: the shared modal's requirement states the loaded confirmation and its answers (spec delta).
- `member-product-discovery`: "The plan-move preview is a preview form in the shared idiom" is renamed "The plan switch confirms in the shared dialog", because the old name states the panel's alert tone. Its scenario "A refused move keeps the preview" says a refusal under a `fee` commitment answers 422 with the preview re-rendered; the code answers 200 with the Plans region and the refusal in place of the ladders (`member_plan_moves.go:251`, `:272`, `:399-400`), and no code writes a commitment policy, so the refusal is unreachable. The rewritten requirement states what the code does: every refusal but the decline closes the dialog and shows its sentence in the Plans region. "The Products page names who bills the member…" keeps its text: the switch preview's commit, Confirm switch or Approve payment, still references the line.
- `form-library`: the box rule of D4, added.
- `stripe-subscription-management`: "A subscription paused for a grant is switched to the granted tier or above" says "Its confirmation SHALL read "Switch to {target}?" … and its second paragraph SHALL open "Your subscription replaces the grant."", and four scenarios read the heading and "the second paragraph". They read the confirmation's title and its message. The delta is written against the main spec as it will stand after #287's archive, which comes first (task 6.3), and copies the requirement as #287's delta leaves it (`openspec/changes/switch-across-intervals-starts-a-period/specs/stripe-subscription-management/spec.md:192-283`), with only those words changed.
- `internal/lint/lint.go:708`: the interpolated-route check reads `data-action-load` as it reads `data-action-url` (D2).
- `docs/operator-ux-conventions.md:243`: the interpolated-route row names `data-action-load` (D2).
- `docs/design-system.md`: §3 "Confirm modal" lists the plan switch and Resume plan among the member's flows (Resume plan was missing), adds `data-action-load` to the trigger contract and widens `data-action-fields` to the load's query, and its Behavior paragraph states the loaded form's two outcomes the load's failure and the commit's. The Preview paragraph (`:809-815`) says that `member.plan.move` renders in the shared dialog without a box and leaves by Keep current plan, which closes it. §7 "Error responses" adds that an answer whose region follows its outcome rather than its status names the region in `HX-Retarget`.
- `docs/stripe.md:217`: "The confirmation's heading reads … and its second paragraph opens" reads "The confirmation's title reads … and its message opens".
- Comments: the headers of `confirm-action-modal.js` and `shell_confirm_modal.html`; `member_plans.html:4-9` and `:187-191`; `member_plan_forms.go:8-15`; `member_plan_moves.go`'s doc comments of `planMoveMessage`, `GetSwitchPreview`, `renderSwitchPreview` and `renderApprovalPreview` and `GetSwitchPreview`'s two inline comments (where the query comes from, and the gates); `PlansData.BillingFacts`'s (`member_plans.go:286-290`); `forms.go:431-439`, whose example of a scoped way out names the plan-move preview; `action_triggers.go:92`, whose Confirm switch becomes guarded by the dialog (`Modal: true`); and `test/e2e/screens/manifest.go:114-120`, `:193-203`: the comment says "preview" and a "Switch" control, and the reason of `member.plan.move` becomes the entry of the walkthrough that now drives it (task 4.5).
## Failure table
The load is the dialog's GET of the preview; the commit is Confirm switch.
| # | State | Today | After |
|---|---|---|---|
| F1 | Load: an offered switch, Stripe answers the preview, or a downgrade read from the records | 200; the Plans region re-renders with the panel above the cards | 200; the form alone into the dialog; the dialog shows the sentences, Confirm switch and Keep current plan |
| F2 | Load: an offered upgrade whose Stripe preview fails, or whose subscription row records a pause | The panel with the sentence without a figure | The same sentence in the dialog; Confirm switch stays enabled |
| F3 | Load: a gate refuses (a tier no longer offered, retired after the page rendered, a suspended or paused plan, no subscription on the ladder, a read that fails) | 200; the Plans region with the refusal's sentence in place of the ladders | The same answer with `HX-Retarget: #member-plans`; it lands outside the dialog, which closes, once it has finished opening when the answer came first (D5) |
| F4 | Load: 403 or 5xx, or the network fails | No swap; the error toast | No swap; the error toast; the dialog closes, once it has finished opening when the failure came first (offline it comes at once) |
| F5 | Load: 400 (a request without its values) | The body swaps into the Plans region; the error toast | The body swaps into the loaded region; the error toast; the dialog closes and the region empties |
| F6 | The member closes the dialog while the load runs | Not possible: there is no dialog | `hidden.bs.modal` aborts the load and marks it cancelled; no answer lands, the aborted fetch's `htmx:error` shows no toast and closes nothing, and a dialog opened next (a Cancel plan dialog, a second switch) is unaffected |
| F7 | The member opens a second switch before the first load answers | Not possible: one panel | Not reachable: a second load needs the first dialog closed, and the close aborts the first load (F6) before the second starts; the second load starts with the mark cleared and only its answer lands |
| F8 | Commit: an upgrade paid, or a downgrade scheduled | 200; the Plans region with "Your plan change has been submitted." and `entitlements-changed` | The same answer into `#member-plans`; it lands outside the dialog, which closes; `entitlements-changed` bubbles to `body` (D5) |
| F9 | Commit: the switch refuses (not offered, a suspended or paused plan, no subscription, the current tier, a scheduled move already applied, a `fee` commitment, the paused switch's guards, any other failure) | 200; the Plans region with the refusal's sentence in place of the ladders | The same answer; the dialog closes |
| F10 | Commit: the card declines | 422; the Plans region with the panel's fresh figures and the decline in its form-level slot | 422; the form alone with fresh figures and the decline in its form-level slot, `HX-Retarget` into the dialog; the dialog stays open; no toast; focus moves to the new form's first control |
| F11 | Commit: the moment is missing, in the future, more than an hour old, or before the paused subscription's current period | 200; the Plans region with the panel's fresh figures and a fresh moment | 200; the form alone with fresh figures and a fresh moment, into the dialog; the dialog stays open; focus moves to the new form's first control |
| F12 | Commit: the payment awaits the bank's approval | 200; the panel with the amount to approve, Approve payment and Keep current plan | 200; the same form, into the dialog; the dialog stays open; focus moves to the new form's first control; Approve payment opens Stripe's page in a new tab |
| F13 | Commit: 403 or 5xx, or the network fails | No swap; the error toast; the panel stays | No swap; the error toast; the dialog stays open with its form, the commit enabled again. For 403 and 5xx, `htmx:after:swap` still fires with `ctx.swap === "none"` and the listener returns on it |
| F14 | The member closes the dialog while the commit runs | The panel could be left by Keep current plan, whose GET raced the POST | The switch runs to its end; F8 and F9 land in the Plans region; a decline or an approval lands in the hidden region, and the member sees the plan unchanged; when the member opens another switch before the commit answers, the answer lands under the second switch's title (Risks) |
| F15 | Keep current plan, the close button, Esc, or a click outside the dialog | Keep current plan re-fetched the Plans region | The dialog closes; nothing is sent; the region empties |
| F16 | A static trigger (Cancel plan, Resume plan) after a switch's dialog closed | Not reachable | The shared confirm form shows with that trigger's title, body and label, and the loaded region is hidden and empty |
| F17 | The browser's back button, or a page restored from the browser's cache with the dialog open | Not possible: no dialog | The load pushes no history entry, because the region is not boosted, so back leaves the page; a restored page's dialog sends the moment it was rendered with, which a commit answers as F11 |
| F18 | Load: 401 (the session ended) | The answer carries `HX-Redirect: /login` (`internal/auth/auth.go:383-386`); htmx goes to the login page before any swap | The same; the dialog's load is a request like any other, and nothing swaps |
## Copy
No string is added, and none changes its words. The switch's strings move from the panel to the dialog.
| ID | String | Today | After | Status |
|---|---|---|---|---|
| C1 | "Switch to {tier}?" | The panel's bold first line, written by `planMoveMessage` | The dialog's title, written by `planMoveTitle` and carried by the control | unchanged wording; placement moves |
| C2 | "Pay for {tier}?" (the granted tier's own card) | As C1 | As C1 | unchanged wording; placement moves |
| C3 | "Your subscription replaces the grant." | Opens the panel's second paragraph | Opens the dialog's message | unchanged |
| C4 | "Your card is charged {net} now for the rest of this period, then {recurring}." | The panel's paragraph | The dialog's message | unchanged |
| C5 | "Nothing is charged now; then {recurring}." | As C4 | As C4 | unchanged |
| C6 | "Your card is charged the prorated difference now, then {recurring}." | As C4 | As C4 | unchanged |
| C7 | "You keep {current} until {date}, then pay {recurring}." ("You keep your current plan until …" when the name cannot be read) | As C4 | As C4 | unchanged |
| C8 | "Your card is charged {net} now, after a credit for the unused part of your current plan, then {recurring} from today." | As C4 | As C4 | unchanged |
| C9 | "Nothing is charged now; then {recurring} from today." | As C4 | As C4 | unchanged |
| C10 | "Your card is charged {target price} now, minus a credit for the unused part of your current plan, then {recurring} from today." | As C4 | As C4 | unchanged |
| C11 | "Confirm switch" | The panel's commit | The dialog's commit, its one filled button | unchanged |
| C12 | "Keep current plan" | The panel's way out; re-fetches the Plans region | The dialog's way out; closes the dialog | unchanged wording |
| C13 | "Your card was declined; you're still on {current}." ("Your card was declined." when the name cannot be read) | The panel's form-level slot | The dialog's form-level slot | unchanged |
| C14 | "Your bank must approve the {amount} charge before {tier} starts." | The panel's message | The dialog's message | unchanged |
| C15 | "Approve payment" | The panel's commit, a link to Stripe | The dialog's commit, the same link | unchanged |
| C16 | "Your plan change has been submitted." | The Plans region's banner | The same banner, after the dialog closes | unchanged |
| C17 | The refusals: "That plan is no longer available. Refresh the page and choose a current plan.", "There's no active subscription to change on this plan.", "Pay the unpaid invoice before changing this plan.", "A paused plan cannot be changed.", "You're already on that plan.", "That plan change has already taken effect.", "This change isn't available under your current commitment terms.", "We couldn't complete that change. Try again in a moment." | The Plans region, in place of the ladders | The same, after the dialog closes | unchanged |
| C18 | "Upgrade", "Downgrade", "Pay for this plan" | The switch control | The same control, now a trigger of the dialog | unchanged |
| C19 | The error toasts of `error-handler.js` | Shown when the preview or the switch fails without an answer | Also shown when the dialog's load fails, as the dialog closes | unchanged |
The loading state is a spinner with no text, hidden from assistive technology; the dialog's title is what a screen reader announces as it opens. The dialog's default title "Confirm" never shows for a switch, because every switch control carries its title.
## Locks and transactions
None. No query, transaction or Stripe call changes. The preview's answer reads less (no Plans data) and reads the billing line's record once.
## Tests
- The answers' regions: a database test drives the preview and the switch through the handlers and reads each answer's status, `HX-Retarget` and body, one row per region-deciding path (F1, F3, F8 or F9, F10, F11, F12).
- The title: `planMoveTitle`'s two forms, and the control the Products page draws for an upgrade and for the granted tier's own card, read through `setMove` and the template: the dialog it opens, its title, its load and its fields, and no `hx-get`.
- The message: `TestPlanMoveMessageStatesTheTiming` reads each charge paragraph as the whole message, so a heading left in it fails.
- The dialog's region: the shell partial renders the region the Go constant names, hidden.
- The box rule: a preview that leaves by the dialog has no box; a preview that leaves otherwise, and a create form that leaves by the dialog, keep theirs.
- Existing tests that find the control by its `hx-get`, read the heading in the preview's answer, or render the form through `WithPreview` change that much and keep their results. The goldens of the Plans region and of the form are rewritten.
- The dialog in a browser: a walkthrough seeds a paid member from database rows and drives the dialog through a downgrade's confirmation, its way out, the static triggers, a refused load, a failed load, a load closed mid-flight and a commit answered 503 (task 3.11). It runs only when asked, with `SWITCH_DIALOG_WALK=1`, as the merchant-disclosure sheet does, so `make test` skips it and never resets the application database (`test/AGENTS.md`).
- The walkthrough's seed. The demo seed prices only `demo_plan` (`internal/demoseed/demoseed.go:900-906`), so no priced tier sits above another and every downgrade would read as unavailable; the walkthrough seeds prices and `synced` mappings for `demo_plan_2` and `demo_plan_3` itself, as the merchant-disclosure sheet does (`test/e2e/merchant-disclosure/sheet_test.go:390-391`, `:426-432`). Its cleanup cannot delete rows: the application's database role has no INSERT, UPDATE or DELETE on `pool_provisions`, `pool_provision_ladders` or `pool_provision_transitions` (`internal/db/migrations/00005_doc41_conferral_functions.sql:637-639`), and the seeded subscription displaces Bob's default plan, which deleting rows would not bring back. The walkthrough runs `test/reset-app-db.sh` before it seeds and in `t.Cleanup`, as the sheet does (`sheet_test.go:139-147`), and gates itself as the sheet does (`sheet_test.go:134-136`): it skips with the reason unless `SWITCH_DIALOG_WALK=1`, and when `MC_DB_MIGRATE_DSN` is missing from `test/.env`.
- The walkthrough's breaks are command records. The walkthrough drives the running app, which serves its scripts and templates from files compiled into its binary (`internal/embeds/embeds.go:10-14`), and `mutation-check` edits files and runs `go test` without rebuilding or restarting the app (`test/AGENTS.md:241-244`), so a test record would run the unbroken binary and never catch a break. Each record's `cmd` rebuilds the app from the edited tree, restarts it, runs the walkthrough and stops the app; the stack's own app is stopped meanwhile, because the new binary starts on the same port (task 3.11).
Each test claim is pinned by a break (`docs/testing.md`, "Prove a test with deliberate breaks"); the tasks list each break with the text its failing test prints. The script's breaks that the walkthrough catches are listed with it; the others are named (What is unverified).
## What is unverified
- **The script's Stripe-writing answers.** The walkthrough (task 3.11) drives the dialog's open, its form, Keep current plan, the static triggers after it, a refused load, a failed load, a load closed mid-flight and a commit the browser answers 503 (F1 for a downgrade, F3, F4, F6, F7, F13 for a 5xx, F15, F16). It sends no Stripe write, because Confirm switch is pressed only under an intercepted answer that never reaches the server, so the commit's answers are checked by the hand walk of task 5.1 only: the close on a made or refused switch (F8, F9), the stay-open answers and their focus (F10, F11, F12), the spinner and `aria-busy`.
- **Not run by any walk:** F14's variant (a commit answering under a second switch's title) and F17's cache restore, which need a request in flight at a chosen moment or a restored page.
## Risks / Trade-offs
- **A decline or an approval answered into a closed dialog is not read** (F14). The commit is disabled while it runs, but the close button and Esc still close the dialog. The member sees the plan unchanged, which is true for a decline; for an approval, the pending update expires on its own (`docs/stripe.md:211`). Cancel plan's dialog has the same property today. A variant is worse: when the member closes the dialog during a commit and opens another switch, the first commit's decline or approval lands under the second switch's title, over the second switch's form, with the first switch's figures. The commit is the first dialog's own request, so the cancelled mark of F6 does not touch it. The form carries the first switch's own ladder, price and moment, so Confirm switch on it commits the first switch while the title names the second; the member reads the first switch's tier in the message. The window is the first commit's latency, and the second switch's own load, when it answers later, replaces the form.
- **The dialog shows only its title and a spinner while Stripe answers the preview**, up to the preview's own latency, where the panel kept the page as it was until the answer arrived.
- **The title is fixed at the page's render.** A tier renamed between the render and the click shows its old name in the title and its new name in an approval's message.
- **`HX-Retarget` is new to the repository.** A future answer of these routes that renders the confirmation without `renderConfirmation` lands in the Plans region. One function sets it for every confirmation, and the region test names the paths.
@@ -0,0 +1,51 @@
## Why
On the Products page, a plan switch is confirmed in a panel at the top of the Plans section. Upgrade or Downgrade on a plan card, and Pay for this plan on the card of a tier a grant holds, send `GET /partials/member/plans/switch/preview` with the card's ladder and price (`internal/embeds/templates/partials/member_plans.html:197-200`). The answer re-renders the whole Plans region with the confirmation, the preview form `member.plan.move` in an `alert alert-secondary` box, above the first ladder (`member_plans.html:27-29`; `internal/server/member_plan_moves.go:163-170`). On a long page the member presses a button near the bottom and the question appears above every card.
Cancel plan and Resume plan on the same cards confirm in the shared dialog (`#confirmActionModal`), whose title, body, confirm label and hidden fields the card's button carries in `data-action-*` attributes (`member_plans.html:157-181`). Keep plan commits at once, because it charges nothing (`member_plans.html:152-156`), and Pay invoice is a link to the invoice's page at Stripe (`:149-151`). The page anatomy requires a consequential control to open the shared dialog (`openspec/specs/page-anatomy/spec.md:123-126`).
The switch cannot carry its confirmation in attributes the way Cancel plan does. An upgrade's figure is Stripe's invoice preview, read when the member asks, and the confirmation sends the moment of that read back to the switch so that the amount charged is the amount shown (`member_plan_moves.go:140`, `:149-151`, `:243-249`). A decline, a stale preview and a payment awaiting the bank's approval each answer with a new confirmation. So the dialog has to take its body from the server.
## What Changes
- **The switch control opens the shared dialog.** The dialog opens at once, titled with the confirmation's question, "Switch to {tier}?" or "Pay for {tier}?", which the control carries. Its body loads from the preview route: the confirmation's sentences, the commit Confirm switch and the way out Keep current plan. No confirmation renders in the Plans region.
- **The shared dialog loads a form from the server.** The trigger contract of `static/confirm-action-modal.js` gains `data-action-load`, the route of a GET whose answer is the dialog's form, with the values that route reads in `data-action-fields`. The script shows a region for that form in place of the shared confirm form, loads it, closes the dialog when an answer lands outside it or when the load fails, aborts a load the member closed, and puts the shared confirm form back when the dialog closes. Every other trigger works as before.
- **Each answer names its region.** An answer that renders the confirmation (the preview, a stale preview shown again, a declined upgrade, an upgrade awaiting approval) goes to the dialog through the `HX-Retarget` response header, so the dialog stays open. A preview the console refuses goes to the Plans region and closes the dialog. A switch that is made or refused re-renders the Plans region, as today, and closes the dialog.
- **The form keeps its kind and its words.** `member.plan.move` stays a preview form. Its way out, Keep current plan, closes the dialog instead of re-fetching the Plans region. The forms library renders a preview whose way out closes the dialog without its alert box, because the dialog is its box.
- **The question moves to the title.** `planMoveMessage` no longer writes the heading; a new `planMoveTitle` words "Switch to {tier}?" or "Pay for {tier}?", and the card's control carries it. Every sentence the member reads today reads the same; no string is added.
- **Two spec statements that the move makes false are corrected.** The paused switch's requirement says the confirmation's "second paragraph" opens "Your subscription replaces the grant."; it becomes the confirmation's message under its title. The preview requirement's scenario "A refused move keeps the preview" says a refusal under a `fee` commitment answers 422 with the preview, which the code has never done: it re-renders the Plans region with the refusal at 200 (`member_plan_moves.go:251`, `:272`, `:399-400`). The rewritten requirement states what the code does.
- **A browser walkthrough.** A member walkthrough seeds a paid subscription from database rows, as the merchant-disclosure sheet does, and drives the dialog through a downgrade's confirmation (a read of the console's records, no Stripe call), its way out, the static triggers, a refused load, a failed load and a load closed mid-flight. `member.plan.move` moves in the screens manifest from a reason to that walkthrough.
- **Docs.** `docs/design-system.md` (the confirm modal's contract and behaviour, the preview family, the error-response routing) and `docs/stripe.md` (the paused switch's heading and paragraph).
## Out of scope
- **Keep plan and Pay invoice.** Keep plan charges nothing and commits without a confirmation (`openspec/specs/member-product-discovery/spec.md:317`); Pay invoice leaves for Stripe.
- **The operator's previews** (tier reorder, tier removal, the org-type default change). They stay panels in their cards.
- **A walkthrough of the switch's Stripe-writing answers.** The new walkthrough opens the dialog and stops before any Stripe write (design, What is unverified); a decline, an approval and a made switch are checked by the hand walk of the tasks.
- **The commitment policy's controls** (#18). A `fee` refusal stays unreachable, as no code writes a commitment policy.
## Capabilities
### New Capabilities
None.
### Modified Capabilities
- `page-anatomy`: "Destructive actions confirm through the shared modal on every surface" states the confirmation the modal loads from the server and where each of its answers goes.
- `member-product-discovery`: "The plan-move preview is a preview form in the shared idiom" is renamed "The plan switch confirms in the shared dialog" and states the dialog, the title, the form without a box, the answers that stay in the dialog and the answers that close it; the fee scenario reads what the code does.
- `form-library`: added "A preview in the shared dialog renders no box of its own".
- `stripe-subscription-management`: "A subscription paused for a grant is switched to the granted tier or above" names the confirmation's title and message in place of its heading and second paragraph. The delta is written against the main spec after the switch-across-intervals change (#287) archives, which comes first.
## Impact
- **Code:** `internal/embeds/static/confirm-action-modal.js` (the load, the region, the close rules, the restore), `internal/embeds/templates/partials/shell_confirm_modal.html` (the region), `internal/embeds/templates/partials/member_plans.html` (the switch control, the preview block removed), `internal/server/member_plan_moves.go` (`planMoveTitle`, `planMoveMessage`, `GetSwitchPreview`, `renderSwitchPreview`, `renderApprovalPreview`, a confirmation render that names the dialog), `internal/server/member_plan_forms.go` (the way out), `internal/server/member_plans.go` (`PlansData.Preview` and `WithPreview` removed, the control's title), `internal/server/shell_forms.go` (the region's selector), `internal/server/action_triggers.go` (Confirm switch is guarded by the dialog), `internal/forms/render.go` (the box rule), `internal/forms/forms.go` (a comment), `internal/embeds/static/error-handler.js` (a load the dialog ended shows no toast), `internal/lint/lint.go` (the interpolated-route check reads `data-action-load`).
- **Routes, schema, Stripe calls:** none change. The preview's Stripe read runs when the dialog opens, which is the moment the member presses the control, as today.
- **Reads:** the preview's answer no longer builds the Plans region; it reads the billing line's record once, for the commit's description.
- **Tests:** new tests for the answers' regions, the title, the control, the dialog's region and the box rule, and the walkthrough `TestSwitchDialogWalkthrough` (`test/e2e/member-switch-dialog/`). Existing tests that find the switch control by its `hx-get`, read the heading in the preview's answer, or render the preview through `WithPreview` change that much and keep their results (tasks 3.8). **Goldens:** `internal/server/testdata/forms/member.plan.move.html` (the modes that have a way out), twelve files under `internal/server/testdata/member_plans/` that lose the switch controls' `hx-*` attributes, and the other 22 `.html` files there, which lose one blank line with the removed preview block.
- **Docs:** `docs/design-system.md`, `docs/stripe.md`; the comment of `member.plan.move` and its entry in `WalkthroughVerifiedForms` in `test/e2e/screens/manifest.go`.
## Related issues
- **#291** is this change; it closes with it.
- **#287**, the switch across intervals, is committed and awaits its archive. This change's delta of the paused switch's requirement is written against the main spec as it will stand after that archive, so #287 archives first.
@@ -0,0 +1,20 @@
## ADDED Requirements
### Requirement: A preview in the shared dialog renders no box of its own
A Stacked form of the preview kind whose rendered way out closes the modal it sits in (`forms.DismissModal`) SHALL render without the `alert alert-secondary` box, because the modal is its box. Its form-level error slot, message, fields and commit row SHALL render as on every preview. A preview whose way out is anything else SHALL keep its box, and a form of another kind whose way out closes a modal SHALL keep the box its kind names. The rule SHALL read the way out the render uses, after a binding's override.
#### Scenario: The plan switch's form renders bare in the dialog
- **WHEN** `member.plan.move`, whose way out "Keep current plan" closes the modal, renders with its message
- **THEN** no alert box wraps the form, and its way out is a button that closes the modal
#### Scenario: An operator's preview keeps its box
- **WHEN** a preview whose way out is Discard renders
- **THEN** it renders inside the `alert alert-secondary` box
#### Scenario: A create form in a modal keeps its card
- **WHEN** a create form whose way out closes the modal renders
- **THEN** it renders inside its card
@@ -0,0 +1,49 @@
## RENAMED Requirements
- FROM: `### Requirement: The plan-move preview is a preview form in the shared idiom`
- TO: `### Requirement: The plan switch confirms in the shared dialog`
## MODIFIED Requirements
### Requirement: The plan switch confirms in the shared dialog
The confirmation a member sees before a plan switch, which carries the switch's cost and timing and its consequences, SHALL open in the shared confirm modal (`page-anatomy`, "Destructive actions confirm through the shared modal on every surface") from the switch's control on the tier's card, for an upgrade, a downgrade and the "Pay for this plan" of a paid plan a grant holds alike, and it SHALL NOT render in the Plans region. The modal's title SHALL be the confirmation's question, "Switch to {tier}?", or "Pay for {tier}?" on the granted tier's own current card, which the control carries. The modal's body SHALL be a declared form of the preview kind, rendered by the server through the `form` part in the stacked family when the modal opens (`form-library`, design D10), and the member templates SHALL NOT build it by hand: the confirmation's sentences as its message, the commit "Confirm switch" as the modal's one filled button, and the way out "Keep current plan" beside it as `btn-outline-secondary`, which closes the modal and sends nothing. The way out reads "Keep current plan" because on the member surface the way out states a consequence, not a form action. The form SHALL render no box of its own, because the modal is its box (`form-library`, "A preview in the shared dialog renders no box of its own").
The answers after the commit SHALL go where the member reads them. A declined upgrade SHALL answer 422 with the form re-rendered in the modal, with fresh figures, every submitted value carried back, and the reason in the form-level slot. A commit whose proration moment is stale SHALL re-render the form in the modal with fresh figures and a fresh moment. An upgrade whose payment awaits the cardholder's approval SHALL re-render the form in the modal with the amount to approve as its message and, in the commit's place, a link to the invoice's hosted page labelled "Approve payment", keeping the way out. A switch that is made or scheduled SHALL re-render the Plans region with "Your plan change has been submitted." and close the modal; a switch the endpoint refuses for any other reason, and a preview it refuses, SHALL re-render the Plans region with the refusal's sentence in place of the ladders and close the modal. The behaviour of the requirement "Plan-move controls reflect built billing mechanics" is unchanged: which moves are enabled, where each is routed, and when the commitment policy is enforced are as that requirement states; only where the confirmation renders changes here.
#### Scenario: A switch confirms in the shared modal
- **WHEN** a member on Plus presses Upgrade on Pro's card
- **THEN** the shared modal opens titled "Switch to Pro?", and its body holds the confirmation's sentences, a filled "Confirm switch" and an outline "Keep current plan" beside it, with no alert box around them
- **AND** no confirmation renders in the Plans region
#### Scenario: The paused plan's switch confirms in the shared modal
- **WHEN** a grant holds Pro over the organization's paused Plus subscription and the member presses "Pay for this plan" on Pro's card
- **THEN** the shared modal opens titled "Pay for Pro?", and its message opens "Your subscription replaces the grant."
#### Scenario: Keep current plan sends nothing
- **WHEN** the member presses "Keep current plan" in the modal
- **THEN** the modal closes, no request is sent, and the Plans region is as it was
#### Scenario: A declined upgrade keeps the modal
- **WHEN** the card on file declines a member's upgrade from Plus
- **THEN** the response is 422 with the form re-rendered in the modal and "Your card was declined; you're still on Plus." in the form-level slot, and the modal stays open
#### Scenario: An upgrade awaiting approval links to Stripe
- **WHEN** a member's upgrade to Pro needs the bank's approval for $9.00
- **THEN** the modal reads "Your bank must approve the $9.00 charge before Pro starts." with an "Approve payment" link to the invoice's hosted page in the commit's place, and "Keep current plan" beside it
#### Scenario: A made switch closes the modal
- **WHEN** a member confirms a downgrade from Pro to Plus
- **THEN** the modal closes and the Plans region reads "Your plan change has been submitted."
#### Scenario: A refused move closes the modal
- **WHEN** a member applies a move the `stripe-subscription-management` endpoint refuses under a `fee` commitment policy
- **THEN** the response re-renders the Plans region with the refusal's sentence in place of the ladders, and the modal closes
- **AND** the enablement of the move's control is unchanged, as "Plan-move controls reflect built billing mechanics" states
@@ -0,0 +1,49 @@
## MODIFIED Requirements
### Requirement: Destructive actions confirm through the shared modal on every surface
The shared confirm modal (`#confirmActionModal`, driven by `static/confirm-action-modal.js` through `data-action-*` attributes) SHALL be part of the application shell on both surfaces, rendered from one shared partial. A destructive or consequential control on any page SHALL open it, with the same copy the control carried before; no page SHALL use `hx-confirm` or the browser's `confirm()`.
A control whose confirmation needs a read on the server before it can ask, such as a plan switch, whose charge comes from the payment provider's preview at that moment, SHALL open the same modal and SHALL name in `data-action-load` the route of a GET whose answer is the modal's form, with the values that route reads in `data-action-fields`. The modal SHALL open at once with the control's `data-action-title`, and the answer SHALL render the form in the modal's body in place of the shared confirm form. The server SHALL name, in the `HX-Retarget` response header, the region of every answer that does not belong in its request's own target. An answer that renders the form again SHALL stay in the modal and SHALL move focus to the new form's first control; an answer that lands outside the modal SHALL close it, also when it arrives while the modal is still opening; a load that brings no answer, or that the server answers with an error, SHALL close it. A load that the member ends by closing the modal SHALL be aborted, and its abort SHALL show no error and close no modal that opens later. When the modal closes, it SHALL show the shared confirm form again, so the next control that carries its copy in its attributes finds the modal as before.
#### Scenario: A member confirms through the modal
- **WHEN** a member cancels a plan, releases a domain, cancels a verification, archives a site, or deletes a site
- **THEN** the shared modal opens with the action's title, body, and confirm label, and the action runs only on confirm
- **AND** no `hx-confirm` attribute renders anywhere on the member surface
#### Scenario: The modal is one partial
- **WHEN** the operator shell and the member root pages render
- **THEN** each renders the modal from the same shared partial and loads the same script
#### Scenario: A confirmation the server renders loads into the modal
- **WHEN** a member presses Upgrade on a plan card
- **THEN** the shared modal opens titled by the control, its body loads the switch's form from the server in place of the shared confirm form, and the switch runs only on that form's commit
#### Scenario: An answer outside the modal closes it, an answer inside keeps it
- **WHEN** the loaded form's answer re-renders the page's Plans region
- **THEN** the modal closes
- **AND** when an answer renders the form again, the modal stays open with the new form
#### Scenario: A failed load closes the modal
- **WHEN** the server answers the load with an error, or the network fails
- **THEN** the error toast shows and the modal closes
#### Scenario: A refusal that answers while the modal opens still closes it
- **WHEN** a member presses a control whose load the server refuses, and the refusal arrives before the modal has finished opening
- **THEN** the modal closes as soon as it has opened
#### Scenario: A load the member closed leaves no trace
- **WHEN** a member closes the modal while its load runs and opens another modal from another control
- **THEN** no error toast shows, and the other modal stays open with its own title and form
#### Scenario: The shared form returns after a loaded confirmation
- **WHEN** a member closes a switch's confirmation and then presses Cancel plan
- **THEN** the modal shows Cancel plan's title, body and confirm label in the shared confirm form, and no part of the switch's form
@@ -0,0 +1,94 @@
## MODIFIED Requirements
### Requirement: A subscription paused for a grant is switched to the granted tier or above
A member SHALL be able to switch a subscription that pays on a ladder a grant holds to the granted tier or to any tier ranked above it, when that tier is also ranked above the tier the subscription pays for and the subscription is not suspended, and the subscription's product and that tier are tiers of that ladder alone, through the existing switch endpoint and its preview. A tier whose price is in another currency than the subscription's price SHALL NOT be offered, because Stripe refuses a change of a subscription's currency, and the switch endpoint SHALL refuse it with "That plan is no longer available. Refresh the page and choose a current plan.". A tier priced at another recurring interval SHALL be offered as usual. The switch SHALL be an immediate upgrade, charged at once with `proration_behavior=always_invoice` and `payment_behavior=pending_if_incomplete`, so that a payment that needs the cardholder's approval keeps its approval link. When the subscription Stripe returns carries the console's pause (`pause_collection` set and the marker), whether or not the subscription holds its ladder, the console SHALL clear the pause and remove the marker in a call of its own and SHALL then send the upgrade's update with its form unchanged, because Stripe refuses an `always_invoice` update on a paused subscription and refuses `pause_collection` in an update sent with `pending_if_incomplete`. In that case, before any Stripe write, the console SHALL show the confirmation again with fresh figures when the confirmation's proration moment lies before the subscription's current period, which Stripe refuses; SHALL refuse the switch with "We couldn't complete that change. Try again in a moment." when the subscription's drafts include a renewal Stripe finalizes automatically, the later of its `next_payment_attempt` and `automatically_finalizes_at` falling within ten minutes or already past, or when the drafts cannot be read; and SHALL write a `reconcile_subscription` entry for the subscription whose `next_attempt_at` lies four minutes on, refusing the switch the same way when the entry cannot be written. On every outcome after the clear but an applied update (an error from either call, a declined payment once its invoice is voided, a payment awaiting approval, and a request that ends early), the request SHALL run the outbox's reconcile of the subscription, whose decision pauses the subscription again unless the rule no longer wants it paused; its failure SHALL be logged and SHALL NOT change the member's answer. Each Stripe call from the draft read to that reconcile's last SHALL carry a deadline, the reconcile's as "A reconcile's Stripe calls end when its context does" states, and SHALL NOT end when the member's request does. A marker without a pause SHALL NOT be cleared by the switch, which then sends the upgrade's update alone. A pause without the marker SHALL NOT be cleared, and Stripe's refusal then answers the switch's generic failure. A declined payment SHALL leave the subscription on its tier with nothing charged, as for every upgrade. Once Stripe applies the update, the reconcile SHALL confer the new tier over the grant, recording the grant as replaced by the subscription. The preview of a subscription whose row records any pause SHALL be the existing unavailable preview, with no Stripe call, because Stripe answers no preview for a paused subscription. Its confirmation's title SHALL read "Switch to {target}?", or "Pay for {target}?" when the target is the granted tier's own current card, and its message SHALL open "Your subscription replaces the grant." before the charge sentence. Toward a price of another recurring interval than the subscription's, the charge sentence SHALL be the new period's, as "Live proration preview before a switch" states it for every upgrade across intervals, because Stripe then charges the new price's full period less the unused time and moves the billing date to the switch: while the row records the pause, the preview answers no figure and the sentence SHALL read "Your card is charged {target price} now, minus a credit for the unused part of your current plan, then {recurring} from today.", {target price} the target price's amount and {recurring} its recurring price ("$300.00 a year"); while the row records no pause yet and Stripe answers an amount, it SHALL read "Your card is charged {net} now, after a credit for the unused part of your current plan, then {recurring} from today.", or "Nothing is charged now; then {recurring} from today." when the amount is zero.
#### Scenario: A switch to the granted tier's superior
- **WHEN** a grant holds Pro, the organization's subscription to Plus is paused for it, and the member switches to Max
- **THEN** the console clears `pause_collection` and the marker, then sends the update that changes the item to Max with `always_invoice` and `pending_if_incomplete`, the proration is charged now, and the reconcile confers Max over the grant
#### Scenario: An upgrade of a holding subscription Stripe still has paused
- **WHEN** the grant that displaced S was revoked, S holds Plus again, the outbox has not yet cleared S's pause, and the member upgrades S to Max
- **THEN** the console clears `pause_collection` and the marker before the update, so Stripe does not refuse it
- **AND** when the card is declined, the request's reconcile leaves S unpaused, because S holds its ladder
#### Scenario: A switch to the granted tier itself
- **WHEN** the same member switches to Pro
- **THEN** the subscription's item becomes Pro, the reconcile confers Pro as a transfer over the grant, and the grant is recorded as replaced by the subscription
#### Scenario: A declined switch leaves the subscription paused
- **WHEN** the card is declined on that switch
- **THEN** the console voids the upgrade's invoice, the request's reconcile pauses the subscription again with the marker, the member reads "Your card was declined; you're still on Plus.", and the grant keeps the ladder
- **AND** nothing is charged, and the renewal made while the grant holds is voided
#### Scenario: A switch awaiting approval is paused again
- **WHEN** the payment of that switch needs the cardholder's approval
- **THEN** the member gets the approval link, and the request's reconcile pauses the subscription again while the update is pending
- **AND** paying the invoice later applies the update, and the reconcile confers Max over the grant; left unpaid, the update expires and nothing is charged
#### Scenario: A failure after the clear restores the pause
- **WHEN** Stripe answers the update with a server error after the clear, or the member's request ends while the switch runs
- **THEN** the request's reconcile pauses the subscription again, and the member reads "We couldn't complete that change. Try again in a moment." when the request still answers
#### Scenario: A crash between the two calls is healed
- **WHEN** the process stops after the clear and before the update, and no webhook from the clear arrives
- **THEN** the `reconcile_subscription` entry written before the clear runs about four minutes later, and its reconcile pauses the subscription again
#### Scenario: A renewal about to finalize refuses the switch
- **WHEN** a renewal Stripe made while the subscription was paused is a draft that finalizes within ten minutes, and the member switches to Max
- **THEN** the switch is refused with "We couldn't complete that change. Try again in a moment." before any Stripe write, and the subscription stays paused, so the renewal is voided
#### Scenario: A confirmation sent after a renewal is shown again
- **WHEN** the member opened the switch to Max before the paused subscription renewed and confirms it after the renewal
- **THEN** the console shows the confirmation again with fresh figures, and sends Stripe no write
#### Scenario: A stale marker is left to the reconcile
- **WHEN** Stripe's subscription carries the marker and no pause, and the member switches to Max
- **THEN** the console sends the upgrade's update alone
#### Scenario: The confirmation says the subscription replaces the grant
- **WHEN** the member opens the switch to Max, priced monthly as Plus is, from the paused Plus subscription
- **THEN** the confirmation's title reads "Switch to Max?", and its message reads "Your subscription replaces the grant. Your card is charged the prorated difference now, then {price}.", because Stripe answers no preview for a paused subscription
#### Scenario: The confirmation on the granted tier's own card reads Pay for
- **WHEN** the member opens the switch from the paused Plus subscription on the card of Pro, the tier the grant holds
- **THEN** the confirmation's title reads "Pay for Pro?", and its message reads "Your subscription replaces the grant." before the charge sentence
#### Scenario: The confirmation states the amount while no pause is recorded
- **WHEN** the subscription's row records no pause yet, the preview answers an amount, and Max is priced monthly as Plus is
- **THEN** the confirmation's message reads "Your subscription replaces the grant. Your card is charged {net} now for the rest of this period, then {price}."
#### Scenario: A switch to a price of another interval states the new period
- **WHEN** the paused subscription pays for Plus monthly, Max's price is $300.00 a year, and the member opens the switch to Max
- **THEN** the confirmation's title reads "Switch to Max?", and its message reads "Your subscription replaces the grant. Your card is charged $300.00 now, minus a credit for the unused part of your current plan, then $300.00 a year from today."
#### Scenario: A switch to a price of another interval states Stripe's figure while no pause is recorded
- **WHEN** the subscription's row records no pause yet, Plus is priced monthly, Max is priced $300.00 a year, and the preview answers $270.32
- **THEN** the confirmation's message reads "Your subscription replaces the grant. Your card is charged $270.32 now, after a credit for the unused part of your current plan, then $300.00 a year from today."
#### Scenario: A tier priced in another currency is not offered
- **WHEN** the paused subscription pays for Plus in US dollars and Max's price is in euros
- **THEN** Max's card offers no switch, and a switch to Max posted from an older page is refused with "That plan is no longer available. Refresh the page and choose a current plan." before any Stripe call
#### Scenario: Under a lower grant only the tiers above the paid tier switch
- **WHEN** a grant holds Basic, the organization's subscription to Pro is paused for it, and the member asks for Plus, ranked between them, and for Max, ranked above Pro
- **THEN** the switch endpoint refuses Plus with the unavailable-plan sentence and switches to Max as above
@@ -0,0 +1,179 @@
Tests follow `docs/testing.md`: database tests open the database through `dbtest`, build their rows through the test kit or the package's fixtures, filter to the rows their own fixture made, and pass twice in a row without a reset. A Stripe test drives `stripetest.MockBackend` with the API state Stripe would return, never a hand-built event payload, and runs without `t.Parallel()`. A test that reads markup reads it through `htmltest` where it reads more than one substring.
Sections 1 to 4 are the parts of one commit; sections 5 and 6 are not commits. The commit passes `make test` with the test stack up, which runs `make lint` first and then every package one at a time (`-p 1`). The goldens that change are the ones item 3.9 names, and only in the lines it names; the view model's JSON goldens and the route goldens do not change. The existing tests that change are the ones item 3.8 names, each only as it says, and each keeps its result. Any other change of a golden or of an existing test's result is a stop. A script's usage is learned from its header or source, never by running it.
No string is added and none is reworded (design, Copy). A string the commit would add reads "pending approval" in the Copy table and stops the commit until it is approved.
**Breaks.** The list of item 3.10 is written to a file outside the repository and run with `go run ./scripts/mutation-check -list <file> -side new` on the commit, from the repository root, with `test/.env` loaded as `make test` loads it (without it the database tests skip and every record reports SKIPPED), after its green control run. A break is a test record (`file`, `old`, `new`, `pkg`, `run_new`, `want_new`), except the breaks of item 3.11, which are command records (`cmd` and `exit` in place of `pkg` and `run_new`) in a list of their own, run as stated in that item; a template is a file like any other, and its package is the one that embeds and tests it. A break caught by two tests is two records, one per test, with the same `file`, `old` and `new`. Every record's `want_new` is a text the failing test prints only when it fails, never a test or subtest name alone. Every record must be CAUGHT. An `old` text below is the code as the section's items write it, one whole line with its indentation unless it says otherwise, and occurs exactly once in its `file` (check each with `grep -c` before the run); when the committed code words it differently, the record is corrected to the committed text before the run, never dropped, and its `new` text keeps the package compiling. Every `run_new` is `^Test$/^subtest$` of the test named, with the subtest's spaces written as underscores, or `^Test$` for a check outside a subtest. A break that no test of the commit sees is named with the reason, and the commit message says so. The commit message lists every break with its result.
## 1. The shared dialog loads a form
- [x] 1.1 `internal/embeds/templates/partials/shell_confirm_modal.html` (design D2): inside `.modal-body`, after `{{ template "form" (shellConfirmForm) }}` (`:36`), `<div id="confirmActionModalLoaded" hidden></div>`. The header comment (`:4-27`) adds that the body also holds the region a trigger naming `data-action-load` fills with a form the server renders, hidden while the shared confirm form shows, and that the dialog's close aborts a load still in flight (design D5).
- [x] 1.2 `internal/server/shell_forms.go`: `const confirmLoadedRegion = "#confirmActionModalLoaded"`, after the `shell.confirm` declaration. Its doc comment: the selector of the shared dialog's loaded region (`shell_confirm_modal.html`), which an answer that renders a loaded confirmation names in `HX-Retarget`.
- [x] 1.3 `internal/embeds/static/confirm-action-modal.js` (design D2, D5). The code is written as the lines below, because the breaks of 3.11 quote them; the whole file keeps its 4-space indentation inside the existing IIFE.
- The header's trigger contract (`:19-35`) adds `data-action-load`: the route of a GET whose answer is the dialog's form; with it, `data-action-fields` is the GET's query, `data-action-title` titles the dialog, and `data-action-url`, `-method`, `-target`, `-swap`, `-body`, `-confirm-label` and `-style` are not read. The header states the close rules, the abort and the restore below.
- State and helpers, at the top of the IIFE: ` var dialogShown = false;`, set to true on `shown.bs.modal` and to false on `show.bs.modal`; and `closeDialog()`, the one way every close goes:
```js
function closeDialog() {
var modalEl = document.getElementById("confirmActionModal");
var inst = modalEl && window.bootstrap && window.bootstrap.Modal.getInstance(modalEl);
if (!inst) {
return;
}
if (dialogShown) {
inst.hide();
return;
}
modalEl.addEventListener("shown.bs.modal", function () { inst.hide(); }, { once: true });
}
```
`resetLoaded()` empties the region, sets it hidden with `region.setAttribute("hidden", "")`, removes `aria-busy`, and removes the shared form's `hidden`. `endLoad()` is the dialog's end of a load: `region.dataset.loadCancelled = "true";`, then `window.htmx.trigger(region, "htmx:abort");`, then `resetLoaded();`.
- `show.bs.modal` (`:67-164`): after reading the trigger, it finds the loaded region (`#confirmActionModalLoaded`) beside the shared form. For a trigger that names `data-action-load`, it sets the title from `data-action-title` (default "Confirm"), sets `hidden` on the shared form, empties the region, removes its `hidden`, sets `aria-busy="true"` on it, removes `data-load-cancelled`, appends a spinner element it creates (`class="spinner-border spinner-border-sm"`, `aria-hidden="true"`, no text), and calls `window.htmx.ajax("GET", url, {source: region, target: region, swap: "innerHTML"})` with `url` the route, then "?" and the fields of `data-action-fields` encoded with URLSearchParams when there are any, then returns. For every other trigger it first empties the region, sets its `hidden` with ` region.hidden = true;` (one statement of its own, not `resetLoaded()`) and removes the shared form's `hidden`, then runs as today. Invalid JSON in `data-action-fields` logs as today and loads without a query.
- `shown.bs.modal` sets `dialogShown = true`. A new `hidden.bs.modal` listener for `#confirmActionModal` calls `endLoad()`.
- A new `htmx:after:swap` listener on `document`: when the event's target is the region or inside it (the load's source, or the region a commit's answer replaced), it removes `aria-busy` when the target is the region (the load's answer), returns when `event.detail.ctx.swap` is `"none"` (a status in the page's `noSwap` list, 403 or 5xx: nothing landed, though htmx still dispatches the event with the request's own target), written as ` if (ctx.swap === "none") {` then ` return;` then ` }`, after the `aria-busy` removal and before the target is resolved; resolves `event.detail.ctx.target` (a selector string after `HX-Retarget`, an element otherwise; `document.querySelector` for a string) and, when the resolved element is not inside `#confirmActionModal`, calls `closeDialog()`; when it is inside and `event.detail.ctx.request.method` is `"POST"` (a commit's answer; the load is a GET; design D5), it focuses the region's first enabled `button` or `a[href]`.
- New `htmx:response:error` and `htmx:error` listeners on `document`: when the event's target is the region itself (the load, not a commit from the form inside it) and the region carries no `data-load-cancelled`, they call `closeDialog()`; with the mark, they return. `error-handler.js` keeps showing the toast, except as item 1.5 says.
- The existing 2xx listener (`:46-65`) is unchanged: it answers the shared form only.
- [x] 1.4 `internal/server/action_triggers.go:92`: the Confirm switch trigger carries `Modal: true` and the Note "the commit of the switch's confirmation, which the shared dialog loads from the preview".
- [x] 1.5 `internal/embeds/static/error-handler.js` (design D5): the `htmx:error` handler (`:88-91`) starts ` if (evt.target && evt.target.dataset && evt.target.dataset.loadCancelled === "true") {` and returns from it, before the toast and `signalReorderRecover()`; its comment says that a load the shared dialog ended is aborted on purpose, and that the timeout and every other `htmx:error` still show the toast.
## 2. The switch confirms in the dialog
- [x] 2.1 `internal/server/member_plan_moves.go` (design D3): `planMoveTitle(tierName string, ownTier bool) string`, new above `planMoveMessage`, returns `"Pay for " + tierName + "?"` when `ownTier` and `"Switch to " + tierName + "?"` otherwise, unescaped; its doc comment says that the switch control carries it in `data-action-title`, which the template escapes and the dialog's script writes as text. `planMoveMessage` (`:41-72`) becomes `planMoveMessage(currentName, then string, replacesGrant bool, newPeriodCharge string, p fulfillment.SwitchPreview) template.HTML`: the heading (`:43-47`) goes, and the message starts `msg := "<p class=\"mb-0\">"`; every case of the switch (`:51-70`) is unchanged. Its doc comment (`:22-40`) drops the heading's sentences and says that the message is the dialog's body under the title `planMoveTitle` words.
- [x] 2.2 `internal/server/member_plans.go` (design D3, D6):
- `TierViewModel` (`:96`) gains the unexported `switchTitle string` and the method `SwitchTitle() string`, which returns it; the doc comment says that it is the title of the dialog the switch control opens, set by `setMove` for a Switch move, and unexported so that the view model's JSON goldens do not list it.
- `setMove` (`:604-620`), after its early return for a move of kind None: `if m.Kind == plans.Switch {` then ` t.switchTitle = planMoveTitle(t.Name, m.OwnTier)`.
- `PlansData.Preview` (`:281-285`) and `WithPreview` (`:294-302`) are removed. `describeCommit(view *forms.FormView, facts BillingFacts)`, new in `member_plan_moves.go`, sets `view.CommitDescribedBy = facts.ID` when `facts.Shown()`; its doc comment says that the commit of a switch's confirmation, Confirm switch or the Approve payment link, is the control that commits the charge, so it references the billing line. `PlansData.BillingFacts`'s comment (`:286-290`) drops "and so does the preview's commit".
- [x] 2.3 `internal/embeds/templates/partials/member_plans.html` (design D2): the `{{ if .Preview }}` block (`:27-29`) is removed. The switch control (`:198-200`) becomes the button of design D2: `data-bs-toggle="modal"`, `data-bs-target="#confirmActionModal"`, `data-action-title="{{ .SwitchTitle }}"`, `data-action-load="/partials/member/plans/switch/preview"`, `data-action-fields='{"ladder_id": "{{ .MoveLadderID }}", "price_id": "{{ .PriceID }}"}'`, the `aria-describedby` it carries today, and no `hx-*` attribute. The header comment (`:4-9`) names the switch's confirmation in the shared dialog in place of "the switch preview banner", and the comment at `:187-191` names the dialog's Confirm switch in place of "the switch-preview banner's "Confirm switch" (above)".
- [x] 2.4 `internal/server/member_plan_moves.go` (design D5, D6):
- `renderConfirmation(w http.ResponseWriter, ctx context.Context, view forms.FormView, status int)`, new: `facts := loadBillingFacts(ctx, h.Database, h.StripeKeyFingerprint, h.Logger).WithID(plansBillingFactsID)`, `describeCommit(&view, facts)`, ` w.Header().Set("HX-Retarget", confirmLoadedRegion)`, then ` if status != http.StatusOK {` writing the status, then `h.Templates.Render(w, "form", view)`. Its doc comment: the answer of every route that renders a switch's confirmation is the form alone, for the shared dialog's loaded region, which `HX-Retarget` names because the switch's own form targets the Plans region.
- `renderSwitchPreview` (`:125-171`) stops reading the target's name (`:136-139`), calls `planMoveMessage(h.currentTierName(ctx, orgID, ladderID), recurringPhrase(target), move.ReplacesGrant, newPeriodCharge, preview)`, and ends with `h.renderConfirmation(w, ctx, view, status)` in place of `:165-170`. `renderApprovalPreview` (`:173-198`) ends with `h.renderConfirmation(w, ctx, view, http.StatusOK)` in place of `:195-197`. Their doc comments say that they answer into the dialog.
- `refusePreview(w http.ResponseWriter, ctx context.Context, orgID string, err error)`, new: ` w.Header().Set("HX-Retarget", "#member-plans")`, then `h.renderPlansAfterMove(w, ctx, orgID, err, "switch-preview")`. `GetSwitchPreview`'s two refusals (`:105-107`, `:114-120`) call it. Its doc comment: the preview is loaded into the dialog, and a refusal belongs to the Plans region, where the dialog's script sees it land and closes the dialog. `GetSwitchPreview`'s doc comment (`:74-77`) says that it answers the shared dialog's load with the switch's confirmation.
- `PostSwitch`'s code is unchanged. Its doc comment (`:212-214`) adds that its confirmation answers (approval, decline, stale moment) go to the dialog and its other answers to the Plans region.
- [x] 2.5 `internal/server/member_plan_forms.go` (design D4): ` WayOut: forms.DismissModal("Keep current plan"),` in place of `:37`. The doc comment (`:8-15`) says that the form renders in the shared dialog, which is its box, and that "Keep current plan" closes the dialog.
- [x] 2.6 `internal/forms/render.go` (design D4): `boxFor(family Family, kind Kind, wayOut WayOutKind) string`; after its `family != Stacked` return, ` if kind == KindPreview && wayOut == WayOutDismissModal {` returns `""`. Its doc comment adds that a preview whose way out closes the modal sits in the modal, which is its box. `newFormView` passes `side.wayOut.Kind` (`:809`). `internal/forms/forms.go:431-439`: the example of a scoped way out names the workspace panel's "Back" alone.
- [x] 2.7 `internal/lint/lint.go:708` (design D2): `if (hxAttrRe.MatchString(k) || k == "data-action-url" || k == "data-action-load") &&`, so a `{{` in a route literal of `data-action-load` is refused as in `data-action-url`. Its comment (`:775-812` region, where the rule is described, if it names `data-action-url`) names the new attribute. No test reads this condition: `make lint` over the templates keeps passing because the control's route is a literal, and the commit message names the break as seen by no test.
## 3. The tests and their breaks
- [x] 3.1 `TestSwitchAnswersNameTheirRegion` (`internal/server/plan_switch_timing_db_test.go`, design D5), rows through a named function (`checkSwitchAnswerRegion`). Each row seeds its own member with `seedSwitchTimingMember(t)` (the member holds Plus, rank 1, of Basic $5.00, Plus $12.00 and Pro $30.00 a month), sets the mock as it names, sends its request (the preview as `TestSwitchPreviewAcrossIntervals` sends it, `:372-374`; the switch through `m.post`), and checks the status, `rec.Header().Get("HX-Retarget")` and each wanted text in the body.
- `a preview offered`: `m.mock.Invoice` answers `AmountDue: 1800` in `usd`; the preview of `m.prices[2]`; 200; `#confirmActionModalLoaded`; wants `data-form="member.plan.move"`, `Your card is charged $18.00 now for the rest of this period, then $30.00 a month.` and `data-bs-dismiss="modal">Keep current plan</button>`.
- `a preview refused`: the preview of `m.prices[1]`, the current tier; 200; `#member-plans`; wants `You&#39;re already on that plan.`.
- `a declined switch`: `m.mock.UpdateSub = pendingUpgrade(m, "in_declined", stripe.PaymentIntentStatusRequiresPaymentMethod)`, `m.mock.Invoice` answers `AmountDue: 900`; `m.post(t, m.prices[2], time.Now())`; 422; `#confirmActionModalLoaded`; wants `Your card was declined; you&#39;re still on ` + `m.names[1]` + `.`.
- `a stale switch`: `m.mock.Invoice` answers `AmountDue: 1800`; `m.post(t, m.prices[2], time.Time{})`; 200; `#confirmActionModalLoaded`; wants the $18.00 sentence of the first row.
- `a switch awaiting approval`: `m.mock.UpdateSub = pendingUpgrade(m, "in_3ds", stripe.PaymentIntentStatusRequiresAction)`; `m.post(t, m.prices[2], time.Now())`; 200; `#confirmActionModalLoaded`; wants `>Approve payment</a>`.
- `a switch to the current tier`: `m.post(t, m.prices[1], time.Now())`; 200; no `HX-Retarget` (`""`), because the form's own target is the Plans region; wants `You&#39;re already on that plan.`. A guard row: no code of this change sets a header on that path, so it has no break, and the commit message says so.
The rows whose region is `#confirmActionModalLoaded` (the first, third, fourth and fifth) also check that the answer is the form alone: `htmltest.Parse(t, body).Count("[data-action-load], [data-action-url]")` is 0.
Failure texts: `%s: status %d, want %d`; `%s: HX-Retarget %q, want %q`; `%s: the answer does not carry %q:\n%s`; `%s: the answer renders a plan card, want the confirmation's form alone`.
- [x] 3.2 `TestPlanMoveTitle` (`internal/server/plan_move_message_test.go`), rows `a switch` (`"Plus", false`, wants `Switch to Plus?`) and `the granted tier's own card` (`"Plus", true`, wants `Pay for Plus?`). Failure text: `%s: title = %q, want %q`.
- [x] 3.3 `TestPlanMoveMessageStatesTheTiming` (`plan_move_message_test.go:18-79`): each row calls `planMoveMessage("Pro", tc.then, tc.replacesGrant, tc.newPeriodCharge, tc.p)`, and the case struct loses `ownTier`. The rows `the granted tier's own card` and `the granted tier's own card, a new period` (`:55-58`) are removed: their inputs, without `ownTier`, are the rows `replaces the grant` and `a new period with no figure`, and `TestPlanMoveTitle` reads the own card. Each row's check becomes `got == "<p class=\"mb-0\">" + tc.want + "</p>"`, failure text `%s: message = %s, want the paragraph %q alone`. The estimate check stays. The escaping check (`:75-77`) sends a downgrade, `SwitchPreview{Available: true, Downgrade: true, EffectiveAt: time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC)}`, with `"<b>"` as the current tier's name and fails when the message contains `<b>`, failure text `the current tier's name must be escaped: %s`.
- [x] 3.4 `TestSwitchControlOpensTheSharedDialog` (new `internal/server/member_switch_dialog_test.go`, package `server`), rows `an upgrade` (`plans.Move{Kind: plans.Switch, Enabled: true, Relation: plans.Upgrade}`, wants the title `Switch to Pro?`) and `the granted tier's own card` (the same with `ReplacesGrant: true, OwnTier: true`, wants `Pay for Pro?`). Each row builds `TierViewModel{ProductID: "prod_pro", Name: "Pro"}`, calls `setMove(row.move, "ladder_1", priceFacts{Price: plans.Price{ID: "price_pro", Purchasable: true}, Text: "$30.00/month"})`, renders `member_plans.html` with one enrolled ladder holding that tier through `Render(t, MemberSet(t), …)`, and reads `htmltest.Parse(t, body).All("button[data-action-load]")`: exactly one, failure text `%s: %d controls load a confirmation, want 1:\n%s`. Its attributes: `data-bs-toggle` `modal`, `data-bs-target` `#confirmActionModal`, `data-action-title` the row's title, `data-action-load` `/partials/member/plans/switch/preview`, `data-action-fields` `{"ladder_id": "ladder_1", "price_id": "price_pro"}`; failure text `%s: the switch control's %s = %q, want %q`. It carries no `hx-get`; failure text `%s: the switch control carries hx-get %q, want the dialog's load alone`.
- [x] 3.5 `TestConfirmModalCarriesTheLoadedRegion` (same file): renders `shell_confirm_modal.html` from `MemberSet(t)` with no data and reads `htmltest.Parse(t, body).All(".modal-body " + confirmLoadedRegion)`: exactly one, failure text `the shared dialog has no element %s in its body, which the switch's answers retarget to:\n%s`. The element carries `hidden`, failure text `the loaded region is not hidden at rest:\n%s`.
- [x] 3.6 `TestPreviewInTheDialogRendersNoBox` (`internal/forms/render_test.go`, design D4), rows of inline Stacked declarations rendered in record mode with a message, reading `FormView.Box`:
- `a preview leaving by the dialog`: kind Preview, one hidden field, commit "Confirm", `DismissModal("Keep")`; wants `""`.
- `a preview leaving by Discard`: the same with `Discard("Discard", "/partials/test")`; wants `alert`.
- `a create form leaving by the dialog`: kind Create, one text field, commit "Create", `DismissModal("Cancel")`; wants `card`.
Failure text: `%s: box = %q, want %q`.
- [x] 3.7 `internal/server/billing_facts_render_test.go`: `TestPlansCarryTheBillingLineOnceAndThePaidMovesReferenceIt` (`:35-58`) finds the switch control by `data-action-load="/partials/member/plans/switch/preview"` in place of its `hx-get` (`:51`). `TestPlanPreviewCommitReferencesTheLine` (`:63-89`) builds the view as today, calls `describeCommit(&view, facts)` in place of `data.WithPreview(&view)`, and renders `Render(t, MemberSet(t), "form", view)` in place of the Plans region; its checks and failure texts are unchanged, and its comment (`:60-62`) names `describeCommit` as the one path both confirmation renders take.
- [x] 3.8 Existing tests that change, each keeping its result:
- `TestPaidLowestTierIsADowngradeNotACancellation` (`plan_switch_timing_db_test.go:198`): the marker `data-action-load="/partials/member/plans/switch/preview"` in place of the `hx-get`.
- `TestDeclinedUpgradeKeepsThePreview` (`plan_switch_timing_db_test.go:222-227`): drops the want `"Switch to " + m.names[2] + "?"`, because the title is the control's.
- `allowed` (`member_switch_offer_db_test.go:199-211`), which `TestSwitchRefusesWhatTheOfferDoesNotOffer`, `TestSwitchOfferJudgesTheNamedLadder` and `TestSwitchRefusesWhenAReadFails` call: the preview wants `Confirm switch` alone, and its comment says that the preview renders the confirmation's form.
- `TestMemberSwitchPreview_CancellationAddsNoSentence` (`grant_resumption_member_test.go:940-942`): its fatal check reads `Confirm switch` in place of the heading.
- `TestCurrentRungSurvivesRetirement` (`member_lifecycle_gate_test.go:353`): the negative check reads the new marker, so it keeps checking that the retired rung draws no switch.
- `TestMemberPlansUpgradeControlRendering` (`member_upgrade_render_test.go:76-81`): the switch check reads `data-action-load="/partials/member/plans/switch/preview"` and `data-action-fields='{"ladder_id": "ladder_1", "price_id": "price_prem"}'`, failure text `switch move: expected a control that loads the switch's confirmation into the shared dialog, carrying ladder_id + price_prem`, and its comment (`:74-75`) names the dialog.
- `compareGoldenPlans` (`member_plans_golden_db_test.go:392`): `data.Preview = nil` is removed with the field.
- `TestPostSwitchFromAPausedSubscription`, its row `a proration date before the period` (`member_switch_offer_db_test.go:880`, `heading: true`). `pausedMember.post` (`:803-815`) reads the answer's `.alert` texts and, with none, its `strong` texts (`:811-814`), and `checkPausedPost` (`:836-838`) wants "Switch to {name}?" from the row's `heading`; the form alone has neither an `.alert` nor a `strong`, so the row would fail. The row wants `Your subscription replaces the grant.` in place of the heading, read from the form's message: `post` returns the `.app-form-message` texts when there is no `.alert`, in place of the `strong` texts, its comment (`:800-802`) says "or, when it shows the confirmation, its message", and the `heading` field and its comment (`:825-827`), its branch (`:836-838`) and the row's `heading: true` (`:880`) go; the row's `want` is `Your subscription replaces the grant.`. The row keeps its result: the answer is the confirmation again, and the test fails when it is anything else.
Unchanged, and kept green: `TestSwitchPreviewAcrossIntervals`, `TestSwitchPreviewFromAPausedSubscription` (both read `p.mb-0`, which the message keeps), `TestUpgradeAwaitingApprovalLinksToStripe`, `TestSwitchWithoutItsMomentShowsThePreviewAgain`, `TestSwitchOverASubscriptionTheKeyCannotReachIsRefused`, `TestSwitchRefusesADraftTierThroughTheHandler`, the forms registry's doc test (the table keeps `member.plan.move` as Preview, Stacked), and the walkthrough `TestModalMechanicsWalkthrough`, whose triggers carry `data-action-url`.
- [x] 3.9 Goldens, rewritten with `go test ./internal/server -run '^(TestMemberPlansGolden|TestFormsHTML)$' -update` and `test/.env` loaded, then read line by line:
- `internal/server/testdata/forms/member.plan.move.html`: each mode that has a way out loses the `alert alert-secondary app-form-box` wrapper, and the way out of those modes reads `<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Keep current plan</button>`. The mode `unbound edit side` is unchanged: the edit side clears the way out (`internal/forms/render.go:781`, `s.wayOut = WayOutView{}`), so the box rule (a Preview whose way out closes the modal) never applies to it; it keeps its `alert alert-secondary` box and renders no way out.
- `internal/server/testdata/member_plans/`: `paid-basic-held-standard-above.html`, `paid-basic-plus-on-two-ladders.html`, `paid-plus-on-default-ladder.html`, `paid-plus-period-unknown.html`, `paid-pro-block-commitment.html`, `paid-pro-cancellation-flag-and-row.html`, `paid-pro-cancellation-row.html`, `paid-pro-downgrade-scheduled.html`, `paid-pro-held-plus-below.html`, `paid-retired-plus-under-granted-pro.html`, `paid-standard-under-granted-basic.html` and `unprojected-subscription.html`: each of the 30 switch controls loses `hx-get`, `hx-vals`, `hx-target` and `hx-swap` and gains `data-bs-toggle`, `data-bs-target`, `data-action-title`, `data-action-load` and `data-action-fields`, the title reading "Switch to {tier}?" for the card's tier, or "Pay for {tier}?" on a granted tier's own current card.
- The other 22 `.html` files under `internal/server/testdata/member_plans/` lose one blank line each: removing `{{ if .Preview }}…{{ end }}` (`member_plans.html:27-29`) removes the newline that block rendered when it was empty, in all 34 `.html` goldens of the directory. That blank line is the only difference in those 22 files, and in the 12 above it is the first.
Any other difference, and any change to a `.json` golden, is a stop.
- [x] 3.10 Breaks. `pkg` is `./internal/server` for every file under `internal/server/` and `internal/embeds/templates/`, and `./internal/forms` for `internal/forms/render.go`.
- the own card titled as a switch: `file` `internal/server/member_plan_moves.go`, `old` ` return "Pay for " + tierName + "?"`, `new` ` return "Switch to " + tierName + "?"`; caught by `run_new` `^TestPlanMoveTitle$/^the_granted_tier's_own_card$`, `want_new` `the granted tier's own card: title =`, and by `run_new` `^TestSwitchControlOpensTheSharedDialog$/^the_granted_tier's_own_card$`, `want_new` `the granted tier's own card: the switch control's data-action-title`;
- `setMove` leaving the title unset: `file` `internal/server/member_plans.go`, `old` ` t.switchTitle = planMoveTitle(t.Name, m.OwnTier)`, `new` ` t.switchTitle = ""`; caught by `run_new` `^TestSwitchControlOpensTheSharedDialog$/^an_upgrade$`, `want_new` `an upgrade: the switch control's data-action-title`;
- the control loading without the price: `file` `internal/embeds/templates/partials/member_plans.html`, `old` `data-action-fields='{"ladder_id": "{{ .MoveLadderID }}", "price_id": "{{ .PriceID }}"}'` (a fragment, unique in the file), `new` `data-action-fields='{"ladder_id": "{{ .MoveLadderID }}"}'`; caught by `run_new` `^TestSwitchControlOpensTheSharedDialog$/^an_upgrade$`, `want_new` `an upgrade: the switch control's data-action-fields`, and by `run_new` `^TestMemberPlansUpgradeControlRendering$`, `want_new` `switch move: expected a control that loads`;
- the control loading the switch's route: `file` `internal/embeds/templates/partials/member_plans.html`, `old` `data-action-load="/partials/member/plans/switch/preview"` (a fragment), `new` `data-action-load="/partials/member/plans/switch"`; caught by `run_new` `^TestSwitchControlOpensTheSharedDialog$/^an_upgrade$`, `want_new` `an upgrade: the switch control's data-action-load`;
- a heading left in the message: `file` `internal/server/member_plan_moves.go`, `old` ` msg := "<p class=\"mb-0\">"`, `new` ` msg := "<p class=\"mb-2\"><strong>?</strong></p><p class=\"mb-0\">"`; caught by `run_new` `^TestPlanMoveMessageStatesTheTiming$/^upgrade$`, `want_new` `upgrade: message =`;
- the current tier's name unescaped: `file` `internal/server/member_plan_moves.go`, `old` ` keep = "You keep " + esc(currentName)`, `new` ` keep = "You keep " + currentName`; caught by `run_new` `^TestPlanMoveMessageStatesTheTiming$`, `want_new` `the current tier's name must be escaped`;
- a refused preview left in the dialog: `file` `internal/server/member_plan_moves.go`, `old` ` w.Header().Set("HX-Retarget", "#member-plans")`, `new` ` _ = w`; caught by `run_new` `^TestSwitchAnswersNameTheirRegion$/^a_preview_refused$`, `want_new` `a preview refused: HX-Retarget`;
- a confirmation answered into the Plans region: `file` `internal/server/member_plan_moves.go`, `old` ` w.Header().Set("HX-Retarget", confirmLoadedRegion)`, `new` ` _ = confirmLoadedRegion`; caught by `run_new` `^TestSwitchAnswersNameTheirRegion$/^a_declined_switch$`, `want_new` `a declined switch: HX-Retarget`, by `run_new` `^TestSwitchAnswersNameTheirRegion$/^a_stale_switch$`, `want_new` `a stale switch: HX-Retarget`, and by `run_new` `^TestSwitchAnswersNameTheirRegion$/^a_switch_awaiting_approval$`, `want_new` `a switch awaiting approval: HX-Retarget`;
- a decline answered 200: `file` `internal/server/member_plan_moves.go`, `old` ` if status != http.StatusOK {`, `new` ` if false {`; caught by `run_new` `^TestSwitchAnswersNameTheirRegion$/^a_declined_switch$`, `want_new` `a declined switch: status 200, want 422`;
- the way out a link again: `file` `internal/server/member_plan_forms.go`, `old` ` WayOut: forms.DismissModal("Keep current plan"),`, `new` ` WayOut: forms.ScopedLinkOut("Keep current plan", "/partials/member/plans", "#member-plans"),`; caught by `run_new` `^TestSwitchAnswersNameTheirRegion$/^a_preview_offered$`, `want_new` `a preview offered: the answer does not carry`;
- the commit's description dropped: `file` `internal/server/member_plan_moves.go`, `old` ` view.CommitDescribedBy = facts.ID`, `new` ` _ = facts`; caught by `run_new` `^TestPlanPreviewCommitReferencesTheLine$`, `want_new` `Confirm switch does not reference the line`;
- the region renamed in the partial: `file` `internal/embeds/templates/partials/shell_confirm_modal.html`, `old` `id="confirmActionModalLoaded"` (a fragment), `new` `id="confirmActionModalRegion"`; caught by `run_new` `^TestConfirmModalCarriesTheLoadedRegion$`, `want_new` `the shared dialog has no element #confirmActionModalLoaded`;
- the box rule removed: `file` `internal/forms/render.go`, `old` ` if kind == KindPreview && wayOut == WayOutDismissModal {`, `new` ` if false {`; caught by `run_new` `^TestPreviewInTheDialogRendersNoBox$/^a_preview_leaving_by_the_dialog$`, `want_new` `a preview leaving by the dialog: box =`;
- the box rule widened to every kind: same `old`, `new` ` if wayOut == WayOutDismissModal {`; caught by `run_new` `^TestPreviewInTheDialogRendersNoBox$/^a_create_form_leaving_by_the_dialog$`, `want_new` `a create form leaving by the dialog: box =`;
- the box rule widened to every way out: same `old`, `new` ` if kind == KindPreview {`; caught by `run_new` `^TestPreviewInTheDialogRendersNoBox$/^a_preview_leaving_by_Discard$`, `want_new` `a preview leaving by Discard: box =`.
Seen by no test, named in the commit message with the reason: `renderConfirmation` without its `describeCommit` call (the database tests' handlers carry no Stripe key fingerprint and their databases hold no public-details record under one, so the line is never shown there; the item above pins `describeCommit` itself); `data-action-load` dropped from the lint's condition at `lint.go:708` (no template interpolates a route into it); and the breaks of `confirm-action-modal.js` that item 3.11 does not list (the spinner and `aria-busy` not set, the focus after a stay-open answer not moved), which only the hand walk of item 5.1 reaches, because the walkthrough stops before any Stripe write.
- [x] 3.11 `TestSwitchDialogWalkthrough` (new `test/e2e/member-switch-dialog/switch_dialog_test.go`, package `memberswitchdialog_test`; design D5, F1, F3, F4, F6, F7, F13, F15, F16). Header, as `modal_mechanics_test.go`'s: `// walkthrough: member-switch-dialog` and a `covers:` line naming the dialog's load, `GET /partials/member/plans/switch/preview`. It starts `defer browsertest.Recover(t)`; then it skips with the reason unless `SWITCH_DIALOG_WALK=1`, as `TestMerchantDisclosureSheet` skips unless `MERCHANT_SHEET=1` (`sheet_test.go:134-136`), and skips when `browsertest.EnvFromTestDir(t)["MC_DB_MIGRATE_DSN"]` is empty, because `reset-app-db.sh` refuses without it; so the walkthrough runs only when asked and `make test` never resets the application database (`test/AGENTS.md`). It opens the stack's database with `browsertest.AppDB(t)`, runs `test/reset-app-db.sh` from `test/` and again in a `t.Cleanup` that closes its pool first, as `resetAppDB` does in the merchant-disclosure sheet (`test/e2e/merchant-disclosure/sheet_test.go:313-319`, `:139-147`), seeds a `core.prices` row (1200 and 3000 cents, monthly) and a `synced` `stripe.price_mappings` row for `demo_plan_2` and `demo_plan_3`, as the sheet's `price` does (`:390-391`, `:426-432`; design, Tests), and seeds a paying member from rows as `subscribePlus` does (`test/e2e/merchant-disclosure/sheet_test.go:531-575`: a billing account, an active subscription whose period spans today, a subscription item on the tier's product and price, and the pool's `Confer` by subscription), for the demo member `browsertest.Bob` on the highest-ranked priced tier of the personal organization's ladder that has a priced tier below it, so that the tier below is a downgrade, a switch and not a cancellation. It also skips with the reason when the stack lacks `MC_DB_DSN` (`AppDB` does); its cleanup is the reset, and it deletes no row. A failed assertion of a step is `t.Errorf`, and each step ends by closing the dialog and restoring what it changed, so a later step still runs and a record reads its own step's text. No Stripe key is needed: a downgrade's preview reads the console's records (`internal/fulfillment/plan_change.go:647-651`). `page, base := browsertest.OpenAs(t, browsertest.Bob, "/products")` signs in after the seed, and each step logs `t.Logf("step N: …")` as `TestModalMechanicsWalkthrough` does. The downgrade control is `button[data-action-load]` of the tier below, Cancel plan's is the card's `button[data-action-url]` that opens the shared form; the dialog is `#confirmActionModal`, its title `#confirmActionModalLabel`, its region `#confirmActionModalLoaded`. A latency or an offline state is Chrome's network emulation, `proto.NetworkEmulateNetworkConditions`, applied to the page and lifted at the step's end.
1. Step 1, a downgrade opens the confirmation: press the downgrade control; wait for `#confirmActionModal.show` and for `#confirmActionModalLoaded form[data-form="member.plan.move"]`. It asserts the title equals the control's `data-action-title` and starts "Switch to ", the form's commit reads "Confirm switch" and its way out reads "Keep current plan", the form has no `.alert` ancestor, and the region has no `aria-busy`. Failure texts: `step 1: the dialog title = %q, want the control's %q`; `step 1: no member.plan.move form in #confirmActionModalLoaded after the load`; `step 1: the form's commit reads %q and its way out %q, want "Confirm switch" and "Keep current plan"`; `step 1: the form sits in a %s box, want none`; `step 1: the loaded region still carries aria-busy after its answer`.
2. Step 2, Keep current plan closes it: press the way out (the element in the region whose text is "Keep current plan"); wait for the dialog to hide. It asserts the dialog is not `.show`, the region holds no child and is `hidden`, and `performance.getEntriesByType("resource")` holds no new entry whose name contains `/partials/member/plans` after the click. Failure texts: `step 2: the dialog is still open after Keep current plan`; `step 2: the loaded region still holds %d children, want none`; `step 2: Keep current plan sent a request to %s, want none`.
3. Step 3, Cancel plan shows the shared form (F16): press Cancel plan on the paid card; it asserts the title equals that trigger's `data-action-title`, `[data-form="shell.confirm"]` is visible, and `#confirmActionModalLoaded` is empty with `region.hidden` true (the property, because an empty region has zero height, so a visibility check passes either way). Then the close button closes the dialog. Failure texts: `step 3: the dialog title = %q, want Cancel plan's %q`; `step 3: the shared confirm form is hidden under Cancel plan's dialog`; `step 3: the loaded region is visible under Cancel plan's dialog`; `step 3: the dialog is still open after the close button`.
4. Step 4, the tier retired before the click (F3): with the page rendered, `UPDATE core.prices SET is_active = false` for the downgrade's target price through `AppDB`, then press the control. It asserts the dialog is not `.show` within 10 seconds and `#member-plans` reads "That plan is no longer available. Refresh the page and choose a current plan.". The price is restored and `/products` is loaded again, because the refusal re-rendered the Plans region while the price was inactive, and the downgrade control is gone from it. Failure texts: `step 4: the dialog is still open after a refused preview`; `step 4: #member-plans reads %q, want the refusal %q`.
5. Step 5, the load run offline (F4): emulate offline, press the control. It asserts the dialog is not `.show` within 10 seconds and the page shows the toast text "Network error. Please check your connection and try again.". The network is restored. Failure texts: `step 5: the dialog is still open after a failed load`; `step 5: no error toast after a failed load`.
6. Step 6, a load closed mid-flight leaves no trace (F6, F7): deactivate the target price again; emulate 5000 ms of latency; register a listener for `shown.bs.modal` on the dialog; press the control; wait for that event, because Bootstrap ignores a close until it has fired (design, Context); press the close button; wait for the dialog to hide; lift the latency; wait until 6 seconds have passed since the control was pressed, so that the first load's answer would have landed. It asserts `#member-plans` does not read the refusal and the Cancel plan control is still in the page (an answer released when the latency lifts would re-render the Plans region); then it presses Cancel plan through an error-returning lookup, waits for the dialog to show, and asserts its title is Cancel plan's, the shared form is visible, and the page shows no error toast. The price is restored and the dialog closed. Failure texts: `step 6: the load the member had ended answered into the Plans region`; `step 6: Cancel plan cannot be pressed: %v`; `step 6: Cancel plan's dialog never opened: %v`; `step 6: the dialog title = %q, want Cancel plan's %q`; `step 6: an error toast showed for a load the member had closed`.
7. Step 7, a commit answered 503 leaves the dialog open (F13): open the downgrade's dialog and wait for its form; register `page.HijackRequests()` for `*/partials/member/plans/switch` answering a POST with 503 (the request never reaches the server, so no Stripe write, and the preview's GET, sent earlier to `/switch/preview`, does not match); press Confirm switch; wait for the error toast; assert the dialog is still `.show`. The hijack is stopped and the dialog closed at the step's end. Failure texts: `step 7: no error toast after a failed commit`; `step 7: the dialog closed after a failed commit`.
Breaks, written as command records to a second file outside the repository, a list of their own (design, Tests), and run on the commit from the repository root, with `test/.env` loaded, by `go run ./scripts/mutation-check -list <file> -side new`: `file`, `old`, `new`, `cmd`, `exit` and `want_new`, with `side` unset (`scripts/mutation-check/main.go`, header). Every record has `exit` 1 and this `cmd`, one `bash` command run from the repository root, written as the text of a JSON string:
```
set -a; . ./test/.env; set +a; curl -fsS -m 2 "$MC_BASE_URL" >/dev/null 2>&1 && exit 2; D=$(mktemp -d); trap 'pkill -f "^$D/mc-app start"; rm -rf "$D"' EXIT; go build -o "$D/mc-app" . || exit 2; ( cd test && exec setsid "$D/mc-app" start --config mc-config.yaml >"$D/app.log" 2>&1 ) & pid=$!; up=1; for i in $(seq 1 90); do python3 -I -c 'import sys,urllib.request;urllib.request.urlopen(sys.argv[1],timeout=2)' "$MC_BASE_URL" 2>/dev/null && { up=0; break; }; sleep 2; done; [ $up = 0 ] || exit 2; kill -0 "$pid" || exit 2; SWITCH_DIALOG_WALK=1 go test -count=1 -v -run '^TestSwitchDialogWalkthrough$' ./test/e2e/member-switch-dialog >"$D/out" 2>&1; st=$?; cat "$D/out"; grep -q -e '--- SKIP' "$D/out" && exit 3; exit $st
```
`cmd` loads `test/.env`, exits 2 when `MC_BASE_URL` already answers, builds the app from the edited tree into a temporary directory, starts it from `test/`, waits up to 180 seconds until `MC_BASE_URL` answers, checks that the app it started is still running, runs the walkthrough, prints its output, and stops the app in an `EXIT` trap. It exits 2 when the build fails, the app never answers or the app it started has exited, 3 when the walkthrough skipped, and otherwise with `go test`'s status: 0 unbroken, 1 for a failed step. The control run needs 0, so a skipped walkthrough stops the run at the control (`scripts/mutation-check/main.go:258-260`; the list has no `prepare`) and a skip is never a pass. The new binary starts on the same `MC_PORT` as the stack's app; were that app running, the new binary could not bind the port, the old app would answer the probe and every record would read MISSED. So the stack's app is stopped before `mutation-check` runs these records and started again afterwards (the `EXIT` trap stops only the app `cmd` started). The run takes about 20 to 35 minutes: `controlKey` includes `want_new` (`scripts/mutation-check/main.go:267`), so the eight records make eight control runs and eight break runs, each building and booting the app, resetting the app database twice, signing in and walking all seven steps. Each record below names only its `want_new`, the text of the failed step, which the walkthrough's output carries. Each `old` is one line of item 1.3 or 1.5 with its indentation; check each with `grep -c` once the code exists.
- the close not deferred: `file` `internal/embeds/static/confirm-action-modal.js`, `old` ` if (dialogShown) {`, `new` ` if (true) {`; caught by step 5, `want_new` `step 5: the dialog is still open after a failed load` (offline the failure comes inside Bootstrap's opening window; step 4's refusal answers inside it only when the app is fast, so step 4 has no record of this break);
- the abort on close dropped: `file` same, `old` ` window.htmx.trigger(region, "htmx:abort");`, `new` ` void region;`; caught by step 6, `want_new` `step 6: the load the member had ended answered into the Plans region` (the closed load's answer, released when the latency lifts, is the refusal and re-renders the Plans region);
- the cancelled mark ignored by the toast: `file` `internal/embeds/static/error-handler.js`, `old` ` if (evt.target && evt.target.dataset && evt.target.dataset.loadCancelled === "true") {`, `new` ` if (false) {`; caught by step 6, `want_new` `step 6: an error toast showed for a load the member had closed`;
- the load not sent: `file` `internal/embeds/static/confirm-action-modal.js`, `old` ` window.htmx.ajax("GET", url, {source: region, target: region, swap: "innerHTML"});`, `new` ` void url;`; caught by step 1, `want_new` `step 1: no member.plan.move form in #confirmActionModalLoaded after the load`;
- the loaded region left showing under a static trigger: same `file`, `old` ` region.hidden = true;`, `new` ` region.hidden = false;`; caught by step 3, `want_new` `step 3: the loaded region is visible under Cancel plan's dialog` (the check reads `region.hidden`);
- the close on an answer outside the dialog removed: `file` `internal/embeds/static/confirm-action-modal.js`, `old` ` if (landed && modalEl && !modalEl.contains(landed)) {`, `new` ` if (false) {`; caught by step 4, `want_new` `step 4: the dialog is still open after a refused preview` (the refusal lands in the Plans region, and nothing else closes the dialog);
- the failed commit closing the dialog: `file` `internal/embeds/static/confirm-action-modal.js`, `old` ` if (ctx.swap === "none") {`, `new` ` if (false) {`; caught by step 7, `want_new` `step 7: the dialog closed after a failed commit` (a 503 swaps nothing, yet its `htmx:after:swap` names the form's own target outside the dialog);
- the way out a link again: `file` `internal/server/member_plan_forms.go`, `old` ` WayOut: forms.DismissModal("Keep current plan"),`, `new` ` WayOut: forms.ScopedLinkOut("Keep current plan", "/partials/member/plans", "#member-plans"),`; caught by step 2, `want_new` `step 2: Keep current plan sent a request to` (a link re-fetches the Plans region, whose answer lands outside the dialog and closes it, so "still open" never prints; the step's request check does). The same break is a record of item 3.10, with the `pkg` and test named there.
## 4. The documents
- [x] 4.1 `docs/design-system.md`, §3 "Confirm modal" (`:187-214`, design D7): the member flows (`:194-196`) add the plan switch and Resume plan. The trigger contract's table adds the row `data-action-load` | no | the 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. The `data-action-fields` row adds: for a trigger with `data-action-load`, the GET's query. The `data-action-url`, `-method` and `-target` rows read "yes, unless `data-action-load`" in the Required column. The Behavior paragraph (`:213-214`) adds: 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.
- [x] 4.2 `docs/design-system.md`, the Preview paragraph (`:809-815`): `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").
- [x] 4.3 `docs/design-system.md`, §7 "Error responses" (`:618-628`): after the `hx-status` bullet, a bullet saying that an answer whose region follows its outcome rather than its status names the region in `HX-Retarget` (the plan switch: a confirmation goes to the confirm modal, a result to the Plans region), and that no script steers the swap.
- [x] 4.4 `docs/stripe.md:217`: "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."" reads "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."".
- [x] 4.5 `test/e2e/screens/manifest.go`: the comment (`:114-120`) names the switch's confirmation, which opens in the shared confirm modal from an Upgrade or Downgrade control, in place of "the plan-switch preview" and a "Switch" control, and says that `member.plan.move` is verified by a walkthrough. The reason `"member.plan.move": "no walkthrough: …"` and its comment (`:193-203`) are replaced by the entry `"member.plan.move": "test/e2e/member-switch-dialog/switch_dialog_test.go: TestSwitchDialogWalkthrough steps 1 and 2 (a downgrade's confirmation opens in the shared dialog, and Keep current plan closes it)",`, in the format of the entries above it, with a comment that the walkthrough seeds a paid member from rows because the demo database seeds none. `forms_coverage_test.go` (`:60-81`) reads the entry: it fails an entry for a form the capture now shows, so the entry stays only while `make screens` reaches no switch (task 5.2).
- [x] 4.6 No other document changes but `docs/operator-ux-conventions.md:243`, whose interpolated-route row names `data-action-load` beside `data-action-url`, as the lint's condition does (item 2.7): `docs/htmx-setup.md` describes `confirm-action-modal.js` as the shared confirm dialog, which stays true, and `docs/plan-management.md` and `docs/models/` state no confirmation's placement.
## 5. The sandbox walk and the captures
- [x] 5.1 The console sandbox walk, for the caller, on the test stack with the app built from the commit (`cd test && ./bootstrap-stack.sh`, then `docker compose up -d` in `test/`, as `test/AGENTS.md` states), Stripe sandbox test keys, and `stripe listen --all-snapshot --forward-to localhost:<the console's port in test/.env>/webhooks/stripe`. Every `stripe` read passes `--stripe-version 2025-02-24.acacia`. Each state is captured at 1280 desktop width and at 390 mobile width (emulated, `innerWidth` checked), into `test/screens/switch-confirms-in-the-shared-dialog/` with a sheet that names each capture's state and row of the failure table.
1. As the operator, make a ladder of Basic at $10.00, Plus at $30.00 and Max at $50.00 a month, each a published public product with one default price synced to Stripe.
2. A first member buys Plus through Checkout with `4242 4242 4242 4242`.
3. With the network emulated as slow, the member presses Max's Upgrade: the dialog opens at once, titled "Switch to Max?", with the spinner and no panel in the Plans region (`loading`). With the network restored, the dialog shows the charge sentence with Stripe's figure, Confirm switch and Keep current plan (`upgrade-confirm`, F1).
4. Keep current plan closes the dialog, and the network log shows no request. The member presses Cancel plan: the dialog shows Cancel plan's title, body and label in the shared confirm form, and no switch form (`cancel-after-switch`, F16). The close button closes it.
5. The member presses Basic's Downgrade: "Switch to Basic?" with "You keep Plus until {date}, then pay $10.00 a month." (`downgrade-confirm`, F1). Keep current plan.
6. With the network emulated offline, the member presses Max's Upgrade: the error toast shows and the dialog closes (`load-failed`, F4). The network is restored.
7. The member opens Max's Upgrade, clears the form's `proration_date` through the browser's console (`document.querySelector('#confirmActionModalLoaded input[name=proration_date]').value = ''`) and presses Confirm switch: the dialog stays open with fresh figures, focus is on the new form's first control (`document.activeElement`, read in the browser's console, is a button or link inside `#confirmActionModalLoaded`), and `stripe invoices list --subscription <sub id> --limit 1` lists no new invoice (`upgrade-stale`, F11).
8. The customer's default payment method becomes the card that attaches and then declines (`pm_card_chargeCustomerFail`, `4000 0000 0000 0341`, attached and set as `invoice_settings[default_payment_method]` with the Stripe CLI). Max's Upgrade, Confirm switch: the dialog stays open with "Your card was declined; you're still on Plus." above fresh figures, focus is on the new form's first control as in step 7, and no toast shows (`upgrade-declined`, F10).
9. The default payment method becomes the card that always requires authentication (`pm_card_authenticationRequired`, `4000 0027 6000 3184`). Max's Upgrade, Confirm switch: the dialog reads "Your bank must approve the {amount} charge before Max starts." with Approve payment and Keep current plan, and focus is on the new form's first control as in step 7 (`upgrade-approval`, F12). Keep current plan closes the dialog.
10. The default payment method becomes `pm_card_visa`. Max's Upgrade, Confirm switch: the dialog closes, the Plans region reads "Your plan change has been submitted.", Max is current, and the network log shows the Entitlements panel's GET after the switch (`submitted`, F8).
11. With the member's page open on Plus's Downgrade, the operator makes Plus not public in another session. The member presses Plus's Downgrade: the dialog opens and closes, and the Plans region reads "That plan is no longer available. Refresh the page and choose a current plan." (`preview-refused`, F3). The operator makes Plus public again.
12. A second member buys Plus through Checkout with `4242 4242 4242 4242`. The operator issues that member's organization a grant of Max. Once the Products page shows Plus's card paused while Max is granted, the member presses Pay for this plan on Max's card: the dialog is titled "Pay for Max?" and reads "Your subscription replaces the grant. Your card is charged the prorated difference now, then $50.00 a month." (`pay-for-this-plan`, F2). Keep current plan; nothing is sent to Stripe.
A dialog that does not open at the click, an answer that lands in the other region, a dialog that stays open on F3, F4 or F8, one that closes on F10, F11 or F12, focus that falls to the page after an answer that stays open, a panel in the Plans region, or a Cancel plan dialog that shows a switch's form stops the landing and returns to the design.
- [x] 5.2 `make screens`: no demo state reaches a switch control, so no capture changes; a change is read and named.
- [x] 5.3 Not run by hand, with the reason, in the review: F14 and its variant (the dialog closed while the commit runs, another switch opened) need a request in flight at a chosen moment; F17's cache restore needs a restored page; F5 and F9 answer as F3 and F8 do, through the same close rule. F6 and F7 are the walkthrough's (item 3.11, step 6).
## 6. Landing
- [x] 6.1 `openspec validate switch-confirms-in-the-shared-dialog --strict` passes, and with the test stack up, `make test` passes (it runs `make lint` first).
- [x] 6.2 The break lists of items 3.10 and 3.11 read CAUGHT in the commit message, beside the breaks it names as seen by no test.
- [x] 6.3 Before the archive, `switch-across-intervals-starts-a-period` (#287) is archived; the delta of "A subscription paused for a grant is switched to the granted tier or above" is written against the main spec as it stands after that archive (design D7).
- [ ] 6.4 After the push, post on #291 under `CONTRIBUTING.md`'s rules for forge text (item 1: it reads for someone who has never opened the repository, explains the console's terms once, and names no decision number, change name, date as narrative, deployment or count), and close it. Draft:
> On the Products page, a plan switch now confirms in the same dialog as Cancel plan and Resume plan, instead of in a panel at the top of the Plans section. A plan switch moves an organization's paid subscription from one plan to another on the same ladder (the ordered set of plans an organization moves up and down): Upgrade or Downgrade on a plan's card, or Pay for this plan on the card of a plan an operator has granted while the organization still pays for a lower one. Pressing the button opens the dialog at once, titled with the question the panel opened with, such as "Switch to Pro?" or "Pay for Pro?". The dialog's body then loads from the server, because the charge it states comes from Stripe at that moment: the same sentences as before, Confirm switch, and Keep current plan, which closes the dialog and sends nothing. When the card is declined, when the payment needs the bank's approval, or when the figures are more than an hour old, the answer replaces the dialog's body and the dialog stays open. When the switch is made or refused, the dialog closes and the Plans section shows the result, as before; a plan withdrawn after the page loaded closes the dialog the same way, with the sentence that the plan is no longer available. Keep plan still applies at once, since it charges nothing, and Pay invoice still opens the invoice's page at Stripe.
>
> Commit: …
- [ ] 6.5 The archive waits for the maintainer's go; the deltas carry the spec changes, and no Purpose needs an edit a delta cannot make.
@@ -0,0 +1,523 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
// walkthrough: member-switch-dialog
// covers: the shared confirm dialog's load, GET
// /partials/member/plans/switch/preview: a downgrade's confirmation opens in
// the dialog and Keep current plan closes it, the static triggers show the
// shared confirm form after it, a refused load, a failed load and a load the
// member closed mid-flight each leave the dialog as the script's close rules
// say, and a commit answered 503 leaves it open.
//
// A paying member is seeded from database rows, as the merchant-disclosure
// sheet seeds one, because the demo database seeds none. The walkthrough
// sends no Stripe write: a downgrade's confirmation reads the console's
// records, and the browser answers 503 to the one Confirm switch it presses
// (step 7) before the request reaches the server, so no Stripe key is
// needed. It resets the application database before it seeds and when it
// ends, because the application's database role cannot delete the rows it
// seeds, so it runs only when asked (SWITCH_DIALOG_WALK=1), as the
// merchant-disclosure sheet does, and make test skips it.
package memberswitchdialog_test
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/proto"
"github.com/google/uuid"
"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/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/test/e2e/browsertest"
)
const (
dialogShown = `() => !!document.querySelector("#confirmActionModal.show")`
// The backdrop is appended when the dialog starts to open and removed
// when it has finished closing, so a dialog that has not yet gained its
// show class is not mistaken for a closed one.
dialogHidden = `() => !document.querySelector("#confirmActionModal.show") && !document.querySelector(".modal-backdrop")`
loaded = "#confirmActionModalLoaded"
retired = "That plan is no longer available. Refresh the page and choose a current plan."
networkToast = "Network error. Please check your connection and try again."
loadedMessage = `() => !!document.querySelector('#confirmActionModalLoaded form[data-form="member.plan.move"]')`
)
func TestSwitchDialogWalkthrough(t *testing.T) {
defer browsertest.Recover(t)
if os.Getenv("SWITCH_DIALOG_WALK") != "1" {
t.Skip("set SWITCH_DIALOG_WALK=1 to walk the switch dialog; the run resets the application database")
}
if browsertest.EnvFromTestDir(t)["MC_DB_MIGRATE_DSN"] == "" {
t.Skip("MC_DB_MIGRATE_DSN is not set in test/.env; the reset of the application database needs it")
}
base := browsertest.BaseURL(t)
db := browsertest.AppDB(t)
resetAppDB(t)
t.Cleanup(func() {
// The reset drops the database, so the pool closes first; closing
// it again when AppDB's cleanup runs is a no-op.
_ = db.Close()
resetAppDB(t)
})
ctx := context.Background()
targetPrice := seed(t, ctx, db)
page, _ := browsertest.OpenAs(t, browsertest.Bob, "/products")
page.Timeout(15 * time.Second).MustElement("#member-plans")
control := `button[data-action-load][data-action-fields*="` + targetPrice + `"]`
cancel := `button[data-action-url="/partials/member/plans/cancel"]`
if !page.MustHas(control) {
t.Fatalf("the Products page draws no switch control for the tier below Bob's: %s", browsertest.PageState(page))
}
t.Logf("step 1: a downgrade opens the confirmation in the dialog")
step1(t, page, control)
closeDialog(t, page)
t.Logf("step 2: Keep current plan closes it")
step2(t, page, control)
closeDialog(t, page)
t.Logf("step 3: Cancel plan shows the shared confirm form")
step3(t, page, cancel)
closeDialog(t, page)
t.Logf("step 4: a tier retired before the click refuses the load")
setActive(t, ctx, db, targetPrice, false)
step4(t, page, control)
setActive(t, ctx, db, targetPrice, true)
browsertest.Goto(t, page, base+"/products")
closeDialog(t, page)
t.Logf("step 5: a load run offline closes the dialog")
step5(t, page, control)
setNetwork(t, page, false, 0)
closeDialog(t, page)
t.Logf("step 6: a load closed mid-flight leaves no trace")
setActive(t, ctx, db, targetPrice, false)
step6(t, page, control, cancel)
setNetwork(t, page, false, 0)
setActive(t, ctx, db, targetPrice, true)
closeDialog(t, page)
t.Logf("step 7: a commit answered 503 leaves the dialog open")
step7(t, page, control)
closeDialog(t, page)
}
func step1(t *testing.T, page *rod.Page, control string) {
want := attr(page, control, "data-action-title")
page.Timeout(10 * time.Second).MustElement(control).MustClick()
if err := browsertest.TryWaitTrue(page, 10*time.Second, dialogShown); err != nil {
t.Errorf("step 1: the dialog did not open: %v", err)
return
}
if err := browsertest.TryWaitTrue(page, 15*time.Second, loadedMessage); err != nil {
t.Errorf("step 1: no member.plan.move form in #confirmActionModalLoaded after the load")
t.Logf("step 1: the page reads %s", browsertest.PageState(page))
return
}
title := text(page, "#confirmActionModalLabel")
if title != want || !strings.HasPrefix(title, "Switch to ") {
t.Errorf("step 1: the dialog title = %q, want the control's %q", title, want)
}
got := page.MustEval(`() => {
const f = document.querySelector('#confirmActionModalLoaded form[data-form="member.plan.move"]');
const clean = (el) => el.textContent.replace(/\s+/g, " ").trim();
const submit = f.querySelector('button[type="submit"]');
const out = [...f.querySelectorAll('button:not([type="submit"]), a')].map(clean);
const box = f.closest(".alert, .app-form-box, .card");
return JSON.stringify({commit: submit ? clean(submit) : "", wayOut: out.join(" | "), box: box ? box.className : ""});
}`).Str()
var form struct{ Commit, WayOut, Box string }
if err := json.Unmarshal([]byte(got), &form); err != nil {
t.Fatalf("step 1: read the form: %v", err)
}
if form.Commit != "Confirm switch" || form.WayOut != "Keep current plan" {
t.Errorf("step 1: the form's commit reads %q and its way out %q, want \"Confirm switch\" and \"Keep current plan\"", form.Commit, form.WayOut)
}
if form.Box != "" {
t.Errorf("step 1: the form sits in a %s box, want none", form.Box)
}
if page.MustEval(`() => document.querySelector("#confirmActionModalLoaded").hasAttribute("aria-busy")`).Bool() {
t.Errorf("step 1: the loaded region still carries aria-busy after its answer")
}
}
func step2(t *testing.T, page *rod.Page, control string) {
page.Timeout(10 * time.Second).MustElement(control).MustClick()
if err := browsertest.TryWaitTrue(page, 15*time.Second, loadedMessage); err != nil {
t.Errorf("step 2: no confirmation to leave: %v", err)
return
}
n := page.MustEval(`() => performance.getEntriesByType("resource").length`).Int()
page.Timeout(10*time.Second).MustElementR(loaded+" button, "+loaded+" a", "Keep current plan").MustClick()
if err := browsertest.TryWaitTrue(page, 10*time.Second, dialogHidden); err != nil {
t.Errorf("step 2: the dialog is still open after Keep current plan")
}
children := page.MustEval(`() => document.querySelector("#confirmActionModalLoaded").childElementCount`).Int()
hidden := page.MustEval(`() => document.querySelector("#confirmActionModalLoaded").hidden`).Bool()
if children != 0 || !hidden {
t.Errorf("step 2: the loaded region still holds %d children, want none", children)
}
sent := page.MustEval(`(n) => performance.getEntriesByType("resource").slice(n)
.map((e) => e.name).filter((name) => name.includes("/partials/member/plans")).join(" ")`, n).Str()
if sent != "" {
t.Errorf("step 2: Keep current plan sent a request to %s, want none", sent)
}
}
func step3(t *testing.T, page *rod.Page, cancel string) {
want := attr(page, cancel, "data-action-title")
page.Timeout(10 * time.Second).MustElement(cancel).MustClick()
if err := browsertest.TryWaitTrue(page, 10*time.Second, dialogShown); err != nil {
t.Errorf("step 3: the dialog did not open: %v", err)
return
}
if title := text(page, "#confirmActionModalLabel"); title != want {
t.Errorf("step 3: the dialog title = %q, want Cancel plan's %q", title, want)
}
if !page.MustEval(`() => { const f = document.querySelector('[data-form="shell.confirm"]'); return !f.hidden && f.offsetParent !== null; }`).Bool() {
t.Errorf("step 3: the shared confirm form is hidden under Cancel plan's dialog")
}
if page.MustEval(`() => { const r = document.querySelector("#confirmActionModalLoaded"); return !r.hidden || r.childElementCount > 0; }`).Bool() {
t.Errorf("step 3: the loaded region is visible under Cancel plan's dialog")
}
page.Timeout(10 * time.Second).MustElement("#confirmActionModal .btn-close").MustClick()
if err := browsertest.TryWaitTrue(page, 10*time.Second, dialogHidden); err != nil {
t.Errorf("step 3: the dialog is still open after the close button")
}
}
func step4(t *testing.T, page *rod.Page, control string) {
page.Timeout(10 * time.Second).MustElement(control).MustClick()
err := browsertest.TryWaitTrue(page, 10*time.Second,
`(want) => document.querySelector("#member-plans").innerText.includes(want)`, retired)
if err != nil {
t.Errorf("step 4: #member-plans reads %q, want the refusal %q", text(page, "#member-plans"), retired)
}
if err := browsertest.TryWaitTrue(page, 10*time.Second, dialogHidden); err != nil {
t.Errorf("step 4: the dialog is still open after a refused preview")
}
}
func step5(t *testing.T, page *rod.Page, control string) {
browsertest.ClearToasts(page)
setNetwork(t, page, true, 0)
page.Timeout(10 * time.Second).MustElement(control).MustClick()
if err := browsertest.TryWaitTrue(page, 10*time.Second, dialogHidden); err != nil {
t.Errorf("step 5: the dialog is still open after a failed load")
}
if err := browsertest.TryWaitTrue(page, 10*time.Second, `() => !!document.querySelector("#errorToast.show")`); err != nil {
t.Errorf("step 5: no error toast after a failed load")
return
}
if got := text(page, "#errorToastBody"); got != networkToast {
t.Errorf("step 5: no error toast after a failed load: the toast reads %q, want %q", got, networkToast)
}
}
// step6 closes the dialog while its load is held by 5000 ms of latency, lifts
// the latency, and waits until 6 seconds after the press, when the closed
// load's answer would have landed. The deactivated price makes that answer a
// refusal, so the step proves the close aborted the load: no answer re-rendered
// the Plans region, and Cancel plan then opens its own dialog with its own
// title, the shared form and no error toast.
func step6(t *testing.T, page *rod.Page, control, cancel string) {
browsertest.ClearToasts(page)
want := attr(page, cancel, "data-action-title")
setNetwork(t, page, false, 5000)
page.MustEval(`() => {
window.__shown = false;
document.querySelector("#confirmActionModal").addEventListener("shown.bs.modal", () => { window.__shown = true; }, { once: true });
}`)
pressed := time.Now()
page.Timeout(10 * time.Second).MustElement(control).MustClick()
if err := browsertest.TryWaitTrue(page, 10*time.Second, `() => window.__shown === true`); err != nil {
t.Errorf("step 6: the dialog never finished opening: %v", err)
return
}
page.Timeout(10 * time.Second).MustElement("#confirmActionModal .btn-close").MustClick()
if err := browsertest.TryWaitTrue(page, 10*time.Second, dialogHidden); err != nil {
t.Errorf("step 6: the dialog is still open after the close button: %v", err)
return
}
setNetwork(t, page, false, 0)
if wait := 6*time.Second - time.Since(pressed); wait > 0 {
time.Sleep(wait)
}
if page.MustEval(`(want, cancel) => document.querySelector("#member-plans").innerText.includes(want) || !document.querySelector(cancel)`, retired, cancel).Bool() {
t.Errorf("step 6: the load the member had ended answered into the Plans region")
return
}
btn, err := page.Timeout(10 * time.Second).Element(cancel)
if err == nil {
err = btn.Click(proto.InputMouseButtonLeft, 1)
}
if err != nil {
t.Errorf("step 6: Cancel plan cannot be pressed: %v", err)
return
}
if err := browsertest.TryWaitTrue(page, 10*time.Second, dialogShown); err != nil {
t.Errorf("step 6: Cancel plan's dialog never opened: %v", err)
return
}
if title := text(page, "#confirmActionModalLabel"); title != want {
t.Errorf("step 6: the dialog title = %q, want Cancel plan's %q", title, want)
}
if !page.MustEval(`() => { const f = document.querySelector('[data-form="shell.confirm"]'); return !f.hidden && f.offsetParent !== null; }`).Bool() {
t.Errorf("step 6: the shared confirm form is hidden under Cancel plan's dialog")
}
if page.MustEval(`() => !!document.querySelector("#errorToast.show")`).Bool() {
t.Errorf("step 6: an error toast showed for a load the member had closed")
}
}
// step7 presses Confirm switch while the commit's POST is answered 503 by the
// browser's own interception, so the request never reaches the server and no
// Stripe write happens. The dialog stays open with its form.
func step7(t *testing.T, page *rod.Page, control string) {
page.Timeout(10 * time.Second).MustElement(control).MustClick()
if err := browsertest.TryWaitTrue(page, 15*time.Second, loadedMessage); err != nil {
t.Errorf("step 7: no confirmation to commit: %v", err)
return
}
browsertest.ClearToasts(page)
router := page.HijackRequests()
defer router.MustStop()
router.MustAdd("*/partials/member/plans/switch", func(ctx *rod.Hijack) {
ctx.Response.SetHeader("Content-Type", "text/plain")
ctx.Response.Payload().ResponseCode = http.StatusServiceUnavailable
ctx.Response.SetBody("service unavailable")
})
go router.Run()
page.Timeout(10*time.Second).MustElementR(loaded+` button[type="submit"]`, "Confirm switch").MustClick()
if err := browsertest.TryWaitTrue(page, 10*time.Second, `() => !!document.querySelector("#errorToast.show")`); err != nil {
t.Errorf("step 7: no error toast after a failed commit")
return
}
// A close, were it to come, follows the toast: htmx:after:swap comes after
// htmx:response:error for the same answer, and a close before
// shown.bs.modal waits for that event.
time.Sleep(time.Second)
if !page.MustEval(dialogShown).Bool() {
t.Errorf("step 7: the dialog closed after a failed commit")
}
}
// closeDialog closes the dialog when it is open and waits until it and its
// backdrop are gone, so the next step starts on a page at rest. A dialog that
// stays open is reported, and the walkthrough goes on.
func closeDialog(t *testing.T, page *rod.Page) {
t.Helper()
if page.MustEval(dialogShown).Bool() {
page.Timeout(10 * time.Second).MustElement("#confirmActionModal .btn-close").MustClick()
}
if err := browsertest.TryWaitTrue(page, 10*time.Second, dialogHidden); err != nil {
t.Errorf("the dialog is still open before the next step: %v", err)
return
}
browsertest.ClearToasts(page)
}
// setNetwork emulates the page's network: offline, or online with latency
// milliseconds added to every request.
func setNetwork(t *testing.T, page *rod.Page, offline bool, latency float64) {
t.Helper()
if err := (proto.NetworkEnable{}).Call(page); err != nil {
t.Fatalf("enable the network domain: %v", err)
}
err := proto.NetworkEmulateNetworkConditions{
Offline: offline, Latency: latency, DownloadThroughput: -1, UploadThroughput: -1,
}.Call(page)
if err != nil {
t.Fatalf("emulate the network: %v", err)
}
}
func attr(page *rod.Page, selector, name string) string {
v := page.Timeout(10 * time.Second).MustElement(selector).MustAttribute(name)
if v == nil {
return ""
}
return strings.Join(strings.Fields(*v), " ")
}
func text(page *rod.Page, selector string) string {
return strings.Join(strings.Fields(page.Timeout(10*time.Second).MustElement(selector).MustText()), " ")
}
// resetAppDB puts the application database at the demo snapshot. It ends
// every browser session.
func resetAppDB(t *testing.T) {
t.Helper()
cmd := exec.Command("./reset-app-db.sh")
cmd.Dir = filepath.Join("..", "..")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("reset the application database: %v\n%s", err, out)
}
}
func setActive(t *testing.T, ctx context.Context, db *sql.DB, priceID string, active bool) {
t.Helper()
if _, err := db.ExecContext(ctx, `UPDATE core.prices SET is_active = $2 WHERE price_id = $1`, priceID, active); err != nil {
t.Fatalf("set price %s active=%v: %v", priceID, active, err)
}
}
func id(t *testing.T, ctx context.Context, db *sql.DB, query string, args ...any) string {
t.Helper()
var v string
if err := db.QueryRowContext(ctx, query, args...).Scan(&v); err != nil {
t.Fatalf("%v\n%s", err, query)
}
return v
}
// price gives the product a monthly default price and its synced mapping, as
// the merchant-disclosure sheet does.
func price(t *testing.T, ctx context.Context, db *sql.DB, key string, cents int) {
t.Helper()
productID := id(t, ctx, db, `SELECT product_id FROM core.products WHERE key = $1`, key)
priceID := id(t, ctx, db, `INSERT INTO core.prices (product_id, key, currency, unit_amount, recurring_interval, is_default)
VALUES ($1, 'switch_dialog_monthly', COALESCE((SELECT currency FROM core.prices LIMIT 1), 'usd'), $2, 'month', TRUE) RETURNING price_id`, productID, cents)
if _, err := db.ExecContext(ctx, `INSERT INTO stripe.price_mappings (price_id, stripe_price_id, sync_status, livemode) VALUES ($1, $2, 'synced', FALSE)`,
priceID, "price_switch_dialog_"+key); err != nil {
t.Fatalf("map the price of %s: %v", key, err)
}
}
// seed prices the demo ladder's upper two tiers and subscribes Bob's personal
// organization to the highest-ranked priced tier that has a priced tier below
// it, through a projected subscription: the rows the reconcile writes after a
// checkout. The tier below is then a downgrade, a switch and not a cancellation.
// It returns the console price id of that tier below, the downgrade's target.
func seed(t *testing.T, ctx context.Context, db *sql.DB) string {
t.Helper()
// The demo ladder's tiers are not public, and only public tiers draw a
// card beside the member's own.
if _, err := db.ExecContext(ctx, `UPDATE core.products SET is_public = TRUE WHERE key IN ('demo_plan', 'demo_plan_2', 'demo_plan_3')`); err != nil {
t.Fatalf("publish the demo tiers: %v", err)
}
price(t, ctx, db, "demo_plan_2", 1200)
price(t, ctx, db, "demo_plan_3", 3000)
rows, err := db.QueryContext(ctx, `SELECT p.key, p.product_id, pr.price_id
FROM core.plan_ladders l
JOIN core.plan_ladder_tiers t ON t.plan_ladder_id = l.plan_ladder_id
JOIN core.products p ON p.product_id = t.product_id
JOIN core.prices pr ON pr.product_id = p.product_id AND pr.is_active AND pr.is_default
JOIN stripe.price_mappings m ON m.price_id = pr.price_id AND m.sync_status = 'synced'
WHERE l.key = 'demo_ladder'
ORDER BY t.rank DESC`)
if err != nil {
t.Fatalf("read the priced tiers: %v", err)
}
defer func() { _ = rows.Close() }()
type tier struct{ key, product, price string }
var priced []tier
for rows.Next() {
var x tier
if err := rows.Scan(&x.key, &x.product, &x.price); err != nil {
t.Fatalf("scan a tier: %v", err)
}
priced = append(priced, x)
}
if err := rows.Err(); err != nil {
t.Fatalf("read the priced tiers: %v", err)
}
if len(priced) < 2 {
t.Fatalf("the demo ladder holds %d priced tiers, want 2 or more", len(priced))
}
top, below := priced[0], priced[1]
persons, err := identity.New(db).ListPersons(ctx)
if err != nil {
t.Fatalf("list persons: %v", err)
}
var bob string
for _, p := range persons {
if p.PrimaryEmail == "bob@example.com" {
bob = p.PersonID
}
}
if bob == "" {
t.Fatalf("the demo snapshot lacks bob")
}
orgs, err := organization.New(db).GetOrganizationsByOwner(ctx, bob)
if err != nil {
t.Fatalf("list bob's organizations: %v", err)
}
var org string
for _, o := range orgs {
if o.OrgType == "personal" {
org = o.OrgID
}
}
if org == "" {
t.Fatalf("bob has no personal organization")
}
tx, err := entitlements.BeginMaterializing(ctx, db)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer func() { _ = tx.Rollback() }()
bq := billing.New(tx)
account, err := bq.CreateBillingAccount(ctx, billing.CreateBillingAccountParams{
OrgID: org, Name: "Switch dialog walkthrough", Status: "active", Metadata: json.RawMessage("{}"),
})
if err != nil {
t.Fatalf("create billing account: %v", err)
}
now := time.Now().UTC()
sub, err := bq.CreateSubscription(ctx, billing.CreateSubscriptionParams{
BillingAccountID: account.BillingAccountID, Status: "active",
CurrentPeriodStart: sql.NullTime{Time: now.AddDate(0, 0, -10), Valid: true},
CurrentPeriodEnd: sql.NullTime{Time: now.AddDate(0, 0, 20), Valid: true},
})
if err != nil {
t.Fatalf("create subscription: %v", err)
}
item, err := bq.CreateSubscriptionItem(ctx, billing.CreateSubscriptionItemParams{
SubscriptionID: sub.SubscriptionID, ProductID: top.product, PriceID: top.price, Quantity: 1,
})
if err != nil {
t.Fatalf("create subscription item: %v", err)
}
pool := id(t, ctx, db, `SELECT pool_id FROM core.resource_pools WHERE org_id = $1 ORDER BY created_at LIMIT 1`, org)
if _, _, err := entitlements.New(tx).Confer(ctx, entitlements.ConferParams{
PoolID: pool, ProductID: top.product, Quantity: 1, ActorType: "system",
SubscriptionID: uuid.NullUUID{UUID: uuid.MustParse(sub.SubscriptionID), Valid: true},
}); err != nil {
t.Fatalf("confer %s by subscription: %v", top.key, err)
}
if err := tx.Commit(); err != nil {
t.Fatalf("commit: %v", err)
}
// The confirmation resolves the subscription and its item at Stripe from
// these mappings, as a checkout's reconcile writes them. Nothing reaches
// Stripe: the ids are never sent.
if _, err := db.ExecContext(ctx, `INSERT INTO stripe.subscription_mappings (subscription_id, stripe_subscription_id, sync_status, livemode) VALUES ($1, 'sub_switch_dialog', 'synced', FALSE)`, sub.SubscriptionID); err != nil {
t.Fatalf("map the subscription: %v", err)
}
if _, err := db.ExecContext(ctx, `INSERT INTO stripe.subscription_item_mappings (subscription_item_id, stripe_subscription_item_id, sync_status, livemode) VALUES ($1, 'si_switch_dialog', 'synced', FALSE)`, item.SubscriptionItemID); err != nil {
t.Fatalf("map the subscription item: %v", err)
}
return below.price
}
+20 -20
View File
@@ -111,15 +111,16 @@ var Manifest = []Screen{
// (fedwiki_sites.html, the card that embeds it): FA-46's own
// evidence is that no baseline capture shows either, because the
// demo deployment the captures run against does not mount FedWiki.
// member.workspace.create and member.plan.move (the plan-switch
// preview, reachable only after a "Switch" click) are consequently
// unseen by the capture; both are declared in WalkthroughVerifiedForms
// below with a reason instead of a walkthrough, since this lane never
// runs one (task's own constraint: never touch Chrome, never rebuild
// or restart the app). Closing the gap for real needs either a seeded
// multi-workspace/FedWiki-mounted demo database or a new member
// walkthrough, both beyond a template migration and left for the
// maintainer to schedule.
// member.workspace.create and member.plan.move (the switch's
// confirmation, which opens in the shared confirm modal from an Upgrade
// or Downgrade control) are consequently unseen by the capture; both are
// declared in WalkthroughVerifiedForms below, member.plan.move with the
// walkthrough that drives it and member.workspace.create with a reason,
// since this lane never runs one (task's own constraint: never touch
// Chrome, never rebuild or restart the app). Closing the workspace gap
// for real needs either a seeded multi-workspace/FedWiki-mounted demo
// database or a new member walkthrough, both beyond a template
// migration and left for the maintainer to schedule.
// Routes that are not screens.
{Route: "/domains", Skip: "redirects to the dashboard's domains section (dissolve-member-domains)"},
@@ -190,17 +191,16 @@ var WalkthroughVerifiedForms = map[string]string{
// behind a gate nothing in the product turns on yet.
"member.workspace.create": "no walkthrough: the workspace container is hidden until the feature ships (ux-ia-naming, 2026-08-23); .HasMultipleWorkspaces has no seed path in the demo database this lane could add without going beyond a template migration",
// member.plan.move: also a reason, not a walkthrough. Its preview
// renders only for a member already on a paid tier switching to
// another paid tier (member_plans.go's MoveKind == "switch"); the
// demo seed (internal/demoseed) puts Bob on the personal org type's
// default ladder's rank-0 (free) tier and never advances him onto a
// paid one through the pool-provisioning/grant path a real subscriber
// would use (seedBilling seeds invoices for the billing pages, not a
// plan-ladder position), so no rod step in a fresh demo database can
// reach a "Switch" control to click. Confirmed by reading demoseed.go
// and member_plans.go rather than by running anything.
"member.plan.move": "no walkthrough: the demo database never seeds a member onto a paid plan tier, so no rod step can reach the \"Switch\" control that opens this preview",
// member.plan.move: a walkthrough. Its confirmation opens only for a
// member already on a paid tier switching to another paid tier
// (member_plans.go's MoveKind == "switch"), and the demo seed
// (internal/demoseed) puts Bob on the personal org type's default
// ladder's rank-0 tier and advances him onto no paid one, so the
// capture reaches no switch control. The walkthrough seeds a paid
// member from database rows instead, and runs only when asked
// (SWITCH_DIALOG_WALK=1), as the merchant-disclosure sheet does,
// because it resets the application database; make test skips it.
"member.plan.move": "test/e2e/member-switch-dialog/switch_dialog_test.go: TestSwitchDialogWalkthrough steps 1 and 2 (a downgrade's confirmation opens in the shared dialog, and Keep current plan closes it)",
// operator.enrollment.grant.manage is not here: the grants ledger is the
// form (grant-resumption D15), so the operator-organization-detail-grant