Read-only per-epic slice. The canonical source of truth is BRD.md — stories are addressed by US-ID, not by this page's line numbers.
Epic 18 — Lifecycle actions (skip, swap, pause, reschedule, cancel) (derived view)
Read-only per-epic slice of
BRD.md§9, lines 6366–7092. The canonical source of truth isBRD.md— edit there, never here. The stable address for a story is its US-ID (US-18.x), not a line number. Regenerates on everydev → mainsync viaderive-state-on-main.
- Stories (11): US-18.1, US-18.2, US-18.3, US-18.4, US-18.5, US-18.6, US-18.7, US-18.8, US-18.9, US-18.10, US-18.11
- Generated: 2026-07-01T17:48:39.076Z · as-of commit:
b083f095
Epic 18 — Lifecycle actions (skip, swap, pause, reschedule, cancel)
<!-- traceability:start:BRD:Epic-18 --><!-- traceability:end:BRD:Epic-18 -->Prototype: Reason Capture · Interventions · Confirmation · A/B Variants · Intervention — Discount · Intervention — Cadence · Skip & Swap · Pause & Resume · Cadence & Reschedule · Reactivate · Renewal screens (term-end / mid-cycle / NTI) · pending_start admin view · Magic Link Login · Subscriptions List · Subscription Detail · Cancel Flow · Update Payment Method · Payment Method Updated — All Subs · Multi-Actor Subscription Detail · Delivery Schedule (cadence ≠ billing) · Pending start (deferred activation)
Value: Self-service for the actions that handle the majority of support tickets.
US-18.1: Skip next charge
<!-- traceability:start:US-18.1 --><!-- traceability:end:US-18.1 -->Prototype: Skip & Swap · Subscription Detail
Phase: MVP · Priority: P0 · Effort: S · Persona: Subscriber
As a Subscriber, I want to skip my next charge, so that I don't get a delivery I don't need this cycle.
Acceptance criteria:
- Given I view a subscription, When I click "Skip next," Then the upcoming charge is marked
abandoned(reason: skipped) and next_charge_at advances one interval. - Given I have already skipped the next charge, When I view the subscription, Then the CTA changes to "Unskip" for up to 24h before the originally scheduled date.
UI states. (rendering contract — ui-states block convention, #1851)
<!-- ui-states US-18.1 -->surface: "/account/subscriptions and /portal/subscriptions/[id] — active-subscription primary action row (ManagePanel.svelte 'Skip next charge' button, mounted by SubscriberPortalApp.svelte:456)"
idle:
render: "An active subscription shows a 'Skip next charge' button with subtext 'Move past {next_charge_at}' (or 'Skip the next scheduled charge' when no date is set)."
primary_action: "Skip next charge → POST /api/v1/portal/subscriptions/{id}/skip; on success the label toggles to 'Unskip next charge'."
loading:
trigger: "POST /api/v1/portal/subscriptions/{id}/skip"
render: "The Skip button label becomes 'Skipping…' and the button — with every sibling manage action — is disabled via disabled={busy}; no double-submit (idempotent per the success metric)."
error:
surfaced_at: "Inline, beneath the Skip action (role=alert), scoped to this subscription — NOT hidden behind the inline form panel that only opens for swap/reschedule/quantity/cadence/pause."
render: "The failure reason from the API (e.g. a closed-window rejection or a network error message)."
recovery: "The button re-enables and the skipped/unskipped state is left unchanged (no optimistic toggle on failure); the subscriber can retry the same click."
empty:
render: "Not a list surface — a single active subscription's action row. The subscriptions-list empty state is owned by the portal home (SubscriberPortalApp list)."
cta: "n/a (single-subscription action row)"
edge_status:
- status: "skipped — within the 24h unskip window, before the originally scheduled charge date"
badge: "Next charge skipped"
affordance: "Unskip next charge → restores next_charge_at to the originally scheduled date and re-arms the upcoming charge"
- status: "skipped — unskip window closed (<24h before, or past, the original charge date)"
badge: "Skipped"
affordance: "Unskip is locked; the subscriber can still swap, reschedule, or skip the FOLLOWING cycle once next_charge_at advances one interval"
- status: "subscription not active (paused / past_due / cancelled)"
badge: "Skip not offered"
affordance: "Resume (paused), 'Update payment method' (past_due), or reactivate (cancelled) — skip becomes available again once the subscription returns to active"
disabled_focus:
keyboard: "Skip is a real <button type='button'> (ManagePanel.svelte:185) reachable in tab order — never a div-onClick; visible focus ring; disabled={busy} only for the duration of the in-flight POST."
focus_move: "On success the button re-renders to 'Unskip next charge' in place and the outcome is announced via the portal toast banner (role=status, aria-live=polite — SubscriberPortalApp.svelte:252)."
guard: "Skip is a single intentional click (non-destructive, reversible within 24h); no typed-confirm. North-star adds a confirm modal showing the original and new next-charge date per the BRD UX note."
# closes — #1851 loop-closer: the error state is bound to a proving render assertion. The
# impl-sweep fixed the silent-skip-error bug (ManagePanel now renders an always-visible error
# region); this binding LOCKS it — the lint ERRORs if @render:US-18.1:skip-error stops existing.
closes:
error: "render:US-18.1:skip-error"
UX notes.
- Surface: subscription detail, primary action row
- CTA states: "Skip next charge" (default) → "Next charge skipped — Unskip" (if skipped, before cutoff) → "Skipped" (locked after cutoff)
- Confirm modal: shows original charge date + new next charge date
Data contract.
- Our API: POST
/api/v1/subscriptions/{id}/skip-next - DB: next upcoming charge transitions to
status=abandoned, reason=skipped; new charge scheduled at +1 interval - Events:
subscription.skip_toggled,charge.abandoned
Success metrics.
- Functional (target): skip action completes and UI reflects new next-charge date in < 2s
- Product (target): skip-usage rate (cycles with a skip) signals engagement — target 10–25% depending on category
- Operational: zero accidental double-skips (idempotent)
Dependencies.
- US-10.3 (anchor math handles schedule shift)
Non-functional.
- Unskip window: 24h before original charge time; after that, locked
US-18.2: Swap product/variant
<!-- traceability:start:US-18.2 --><!-- traceability:end:US-18.2 -->Prototype: Pause & Resume
Phase: MVP · Priority: P0 · Effort: L · Persona: Subscriber
As a Subscriber, I want to swap the product or variant on my subscription, so that I can change flavors/sizes without cancelling.
Acceptance criteria:
- Given plan-eligible alternate products/variants exist, When I click "Swap" and pick an alternate, Then the subscription updates to the new product/variant from the next cycle forward.
- Given the alternate has a different price, When I confirm, Then I see the new estimated charge amount before confirming.
- Given no alternate is eligible (plan locked to single variant), When I click swap, Then the CTA is disabled with an explainer.
UI states. (rendering contract — ui-states block convention, #1851)
<!-- ui-states US-18.2 -->surface: "/portal/subscriptions/[id] + /subscriptions (SubscriberPortalApp.svelte → ManagePanel.svelte — the 'Swap product / variant' inline panel)"
idle:
render: "The 'Manage subscription' grid shows a 'Swap product / variant' card ('Pick a different size, flavor, or pack'). Clicking it opens an inline panel with a dropdown of the plan's eligible alternates; each option shows its price, and selecting one renders a before→after diff with the new estimated charge and an 'Applies from next cycle' note before the subscriber confirms."
primary_action: "'Confirm swap' → POST swap → the subscription ships the new variant from the next cycle forward (the current in-flight cycle is unaffected)."
loading:
trigger: "POST /api/v1/portal/subscriptions/{id}/swap (apiClient.swapVariant, body { bc_variant_id })."
render: "The 'Confirm swap' button shows 'Saving…' and is disabled; the variant select and 'Cancel' are disabled too — no double-submit (ManagePanel.svelte busy-state, lines 281/291-301)."
error:
surfaced_at: "Inline at the foot of the swap panel as role=alert (ManagePanel.svelte:432-434), scoped to this subscription — never a toast that vanishes."
render: "The failure reason returned by the swap call (e.g. 'Variant not eligible for this plan')."
recovery: "The panel stays open and busy resets (ManagePanel.svelte:113-114), so the subscriber re-picks a variant and confirms again; or closes the panel (Cancel / × ) to back out with no change."
empty:
render: "Not a list surface — a single-subscription swap form. The 'no eligible alternate' case is NOT a blank panel; it surfaces as the plan-locked edge_status below (the 'Swap product / variant' CTA is disabled with an explainer)."
cta: "n/a (single-subscription surface — see edge_status 'plan locked to a single eligible variant')"
inputs:
- field: "variant"
control: "select"
allowed_values: "the subscription plan's eligible variants (Epic 26 eligibility), each rendered with its label and price — never a free-text variant-id box."
edge_status:
- status: "plan locked to a single eligible variant (no alternates)"
badge: "Swap unavailable"
affordance: "The 'Swap product / variant' CTA is disabled with an inline explainer ('This plan ships a single variant — there is nothing to swap to'), and the subscriber is pointed to 'Change cadence' / 'Update quantity' as the levers that are available."
- status: "selected alternate priced differently from the current variant"
badge: "Price changes"
affordance: "Before 'Confirm swap' is enabled, show the new estimated charge and the +/- delta with 'Applies from next cycle' — the subscriber reviews the new amount, then confirms or picks a different variant."
- status: "B2B junior buyer — read-only (changes need approval)"
badge: "Approval required"
affordance: "The manage actions are replaced by an approval notice (SubscriberPortalApp.svelte:472-479); the buyer contacts their company admin to request the swap."
disabled_focus:
keyboard: "The 'Swap product / variant' trigger is a real <button type='button'> (ManagePanel.svelte:203-212) reachable in tab order; the variant picker is a real <select> (line 279) operable by keyboard; 'Confirm swap', 'Cancel', and the '×' close are real <button>s — no div-onClick dead-ends."
focus_move: "On open, focus moves into the panel onto the variant select; on close (× / Cancel / success) focus returns to the 'Swap product / variant' trigger so the subscriber is not dropped at the top of the page."
guard: "Swap is non-destructive (applies from next cycle, current cycle unaffected) — a single intentional 'Confirm swap' click; no typed-confirm."
UX notes.
- Surface: subscription detail → "Change product/variant"
- Selection: dropdown of eligible alternates (plan-constrained); per-selection: price preview, "Applies from next cycle"
- Confirmation: before/after diff, price delta
Data contract.
- Our API: POST
/api/v1/subscriptions/{id}/swapwith{new_variant_id} - Eligibility check: plan's eligible variants (Epic 26)
- DB:
subscriptions.bc_variant_id = new, Event row - Events:
subscription.variant_swapped
Success metrics.
- Functional: swap applies from next cycle — current cycle (if mid-shipment) unaffected
- Product: swap usage rate (% of subscribers who swap at least once) — indicates flexibility valued
- Operational (target): P95 swap action latency < 1s
Dependencies.
- Epic 26 (eligibility)
- US-15.2 (price recalc at next renewal)
US-18.3: Pause subscription
<!-- traceability:start:US-18.3 --><!-- traceability:end:US-18.3 -->Prototype: Pause & Resume
Phase: MVP · Persona: Subscriber
As a Subscriber, I want to pause my subscription for a chosen duration, so that I don't have to cancel when life happens.
Acceptance criteria:
- Given I click "Pause," When I pick a resume date, Then subscription status moves to
pausedand no charges occur until resume. - Given I did not pick a resume date (indefinite pause), When I save, Then status is
pauseduntil I manually resume.
UI states. (rendering contract — ui-states block convention, #1851)
<!-- ui-states US-18.3 -->surface: "/account/subscriptions + /portal/subscriptions/[id] — pause form: ManagePanel.svelte (mounted on each active row by SubscriberPortalApp.svelte); resume affordance: SubscriptionStatusActions.svelte"
idle:
render: "An active subscription shows a 'Pause subscription' action card under 'Manage' — 'Stop all charges until you resume'. Selecting it reveals a weeks-duration radio group (4 / 8 / 12 weeks, default 8)."
primary_action: "Pause → POST /api/v1/portal/subscriptions/{id}/pause with {weeks}; on success the form closes, the row re-renders to a 'paused' badge, and a toast confirms 'Paused N weeks. Resume any time from the portal.'"
loading:
trigger: "POST /api/v1/portal/subscriptions/{id}/pause (body {weeks})"
render: "The Pause button shows 'Pausing…' and is disabled; the weeks radios and the Cancel button are disabled too — no double-submit."
error:
surfaced_at: "Inline, beneath the pause form (role=alert), scoped to this subscription — never a page-level banner that disassociates from the failing row."
render: "The failure reason returned by the pause call (the component's localError)."
recovery: "The form stays open (busy clears, no auto-close) so the same weeks selection can be re-submitted; 'Cancel' or the × dismisses without pausing."
empty:
render: "Not a list surface — pause is a per-subscription inline form. The subscriptions-list empty state is owned by the portal home (US-17.1). Documented here so the state is considered, not silently skipped."
cta: "n/a (single-subscription form)"
inputs:
- field: "pause_duration_weeks"
control: "radio"
allowed_values: ["4", "8", "12"]
required: true
note: "Constrained radio group (ManagePanel.svelte:407-421), default 8 weeks. NORTH-STAR adds an 'indefinite — no resume date' option to satisfy AC2; that option is currently absent (gap)."
edge_status:
- status: "paused — auto-resume scheduled (next_charge_at set from the chosen weeks)"
badge: "paused"
affordance: "Resume now → POST /api/v1/portal/subscriptions/{id}/resume → returns to active (sub-text: 'Scheduled to auto-resume {date}.')"
- status: "paused — indefinite, no scheduled resume (next_charge_at null)"
badge: "paused"
affordance: "Resume now → POST /api/v1/portal/subscriptions/{id}/resume → returns to active (sub-text: 'Paused — no scheduled resume date.')"
disabled_focus:
keyboard: "The weeks options are real <input type='radio' name='pause-weeks'> inside <label>s — reachable by Tab into the group then Arrow keys; Pause and Cancel are real <button>s in tab order with a visible focus ring. Never a div-onClick."
focus_move: "On success the form closes and the row re-renders to 'paused' (the confirmation toast is announced via the portal's aria-live region); on a paused row the forward control becomes 'Resume now'."
guard: "Pause is non-destructive — a single intentional click, no typed-confirm; the radio defaults to 8 weeks so every submit carries a valid duration."
US-18.4: Reschedule next charge
<!-- traceability:start:US-18.4 --><!-- traceability:end:US-18.4 -->Prototype: Cadence & Reschedule
Phase: MVP · Persona: Subscriber
As a Subscriber, I want to pick a different date for my next charge, so that I align delivery with travel or events.
Acceptance criteria:
- Given I click "Reschedule," When I pick a date within the allowed window (e.g., +90 days max), Then next_charge_at updates and all future charges shift.
UI states. (rendering contract — ui-states block convention, #1851)
<!-- ui-states US-18.4 -->surface: "/account/subscriptions + /subscriptions portal list (SubscriberPortalApp.svelte → 'Manage' → ManagePanel.svelte 'Reschedule next charge' inline form; active subscriptions only, and only when canManage is true — a B2B Junior Buyer gets a read-only notice instead)."
idle:
render: "An active subscription row exposes a 'Manage' button that opens a responsive action grid (up to 3 columns); the 'Reschedule next charge' card ('Pick a different date — up to 90 days out') reveals an inline native <input type=date> pre-filled to the earliest allowed date (today + 1 day), bounded by min=today+1 and max=today+90."
primary_action: "Reschedule next charge → opens the inline date form; 'Confirm date' commits the chosen date, 'Cancel' / '×' dismisses it without rescheduling."
loading:
trigger: "POST /api/v1/portal/subscriptions/{id}/reschedule with {next_charge_date} (the selected YYYY-MM-DD)."
render: "The 'Confirm date' button shows 'Saving…' and every control in the form (the date input, Confirm date, Cancel, and the × close) is disabled; no double-submit."
error:
surfaced_at: "Inline, beneath the date form controls (role=alert, text-error class), scoped to this subscription's ManagePanel; the form stays open with the picked date intact."
render: "The failure reason returned by the reschedule endpoint (raw err.message today — see gapNotes), e.g. a date outside the allowed window rejected server-side."
recovery: "Re-pick a date within the +1 to +90 day window and press 'Confirm date' again, or press 'Cancel' to dismiss without rescheduling."
empty:
render: "Not a list surface — a single-subscription inline date form. The subscriptions-list empty state ('No subscriptions yet. When you subscribe to a product, it'll show up here.') is owned by the portal list in SubscriberPortalApp."
cta: "n/a (single-subscription form)"
edge_status:
- status: "paused — ManagePanel (and the Reschedule next charge card) is not rendered"
badge: "paused"
affordance: "Resume the subscription first (portal 'Resume now' action) → returns to active, where Reschedule next charge is available again"
- status: "past_due — a charge has failed; management actions are gated to the recovery path"
badge: "past_due"
affordance: "Update payment method (the one surfaced action) → once the charge succeeds and the subscription returns to active, Reschedule next charge returns"
- status: "cancelled within the 14-day grace window — Reschedule next charge is not offered"
badge: "cancelled"
affordance: "Reactivate (US-18.7) → returns to active with a fresh next_charge_at, after which the charge date can be rescheduled"
- status: "active but B2B Junior Buyer (canManage = false) — the management actions are replaced by a read-only notice"
badge: "active"
affordance: "Contact the company admin to request the reschedule — self-service changes require approval (Epic-24)"
inputs:
- field: "next_charge_date"
control: "date"
allowed_values: "a single date between today + 1 day (min) and today + 90 days (max); the native date picker enforces the window via min/max attributes and the form defaults to the earliest allowed date on open"
disabled_focus:
keyboard: "The 'Reschedule next charge' trigger, the native <input type=date> (Tab to focus; the browser date picker is keyboard-operable), and the 'Confirm date' / 'Cancel' buttons are real focusable elements in tab order — never div-onClick; the dismiss control is a <button aria-label='Close'>."
focus_move: "On success the form closes (active=null) and the portal's polite toast (role=status, aria-live=polite) announces 'Next charge moved to <date>.'; north-star also returns focus to the 'Reschedule next charge' trigger (not done today — see gapNotes)."
guard: "'Confirm date' is disabled while busy or when no date is set (disabled={busy || !rescheduleDate}); rescheduling is a single intentional click (non-destructive — next_charge_at updates and all future charges shift forward; no typed-confirm). North-star additionally disables 'Confirm date' when the picked date falls outside the +1..+90 day window rather than relying on a server-side rejection."
US-18.5: Cancel (with churn-prevention flow)
<!-- traceability:start:US-18.5 --><!-- traceability:end:US-18.5 -->Prototype: Cancel Flow
Phase: MVP · Priority: P0 · Effort: L · Persona: Subscriber
As a Subscriber, I want to cancel, but first see options that might address my reason, so that I'm not stuck if cancel isn't quite what I need.
Acceptance criteria:
- Given I click "Cancel," When the flow starts, Then I capture a reason first (see Epic 20).
- Given an intervention is shown and I accept it (e.g., pause), When I confirm, Then the action is applied and I don't cancel.
- Given I decline interventions and confirm cancel, When I save, Then subscription status moves to
cancelledwith cancel reason logged.
UI states. (rendering contract — ui-states block convention, #1851)
<!-- ui-states US-18.5 -->surface: "/portal/subscriptions/[id] detail + /account/subscriptions row — the inline churn-prevention funnel rendered by CancelSubscriptionButton.svelte (parent gates it to status === 'active'); BRD names the multi-step route /portal/subscriptions/{id}/cancel."
idle:
render: "An active subscription shows a single 'Cancel subscription' button (data-demo=portal-cancel-btn); the funnel is closed and no offers/confirm panel is mounted."
primary_action: "'Cancel subscription' opens the funnel — per the north-star it captures a reason first (Epic 20 / US-20.1 radio list) before surfacing the pause and discount offers."
loading:
trigger: "The in-flight intervention or cancel POST — acceptPause → POST /api/v1/portal/subscriptions/{id}/pause, acceptDiscount → POST .../discount, doCancel → POST .../cancel; logInterventionShown → POST .../intervention-shown fires fire-and-forget on funnel open."
render: "The actuating button swaps its label to 'Pausing…' / 'Applying…' / 'Cancelling…' and disables on the busy flag so there is no double-submit; the sibling 'Go back' / dismiss controls disable alongside it."
error:
surfaced_at: "Inline inside the active funnel step as a role=alert paragraph — within the offers panel for a failed pause/discount, within the role=alertdialog confirm panel for a failed cancel — scoped to the step that failed, never a page-level banner or a vanishing toast."
render: "The thrown failure reason (err.message) returned by the cancel/pause/discount endpoint, rendered verbatim beneath the affordance that triggered it."
recovery: "The busy flag clears in the finally block so the same button re-enables for an immediate retry; 'Go back' returns to the offers step without abandoning the flow, and the dismiss (X) returns to idle with the subscription untouched."
empty:
render: "Not a list surface — a single-subscription cancel funnel; the subscriptions-list empty state is owned by the portal home (US-17.1). Documented here so the state is considered, not silently skipped."
cta: "n/a (single-subscription surface)"
inputs:
- field: "cancel_reason"
control: "radio"
allowed_values: "merchant-configured cancellation-reason list (Epic 20 / US-20.1); required before the offers branch is shown — the north-star step 1 the funnel must capture and send as the logged cancel reason."
- field: "confirm_name"
control: "text"
allowed_values: "the subscription/product name the subscriber types to arm the irreversible confirm (BRD UX note step 3); a literal-match free-text confirm, not an enumerable domain."
edge_status:
- status: "intervention accepted — pause (POST .../pause, weeks=4)"
badge: "paused"
affordance: "Funnel exits to a role=status 'Subscription paused for 4 weeks' panel; the cancel is abandoned and the row re-renders with a Resume affordance (SubscriptionStatusActions, US-18.7)."
- status: "intervention accepted — discount (POST .../discount, percent=10)"
badge: "active"
affordance: "Funnel exits to a role=status '10% discount applied to your next renewal' panel; the subscription stays active at the discounted rate and the cancel is abandoned."
- status: "active but inside a minimum-term lock (US-18.10 pre-flight guard)"
badge: "active"
affordance: "Cancel is replaced by a read-only 'Cancellation available from [lock_expires_at]' message (policy=block) or a 'Contact support to cancel early' CTA (policy=contact_us) — never a disabled dead-end button; the churn funnel is not shown."
disabled_focus:
keyboard: "Every funnel affordance — 'Cancel subscription', 'Pause', 'Apply', 'Still want to cancel', 'Yes, cancel subscription', 'Go back', and the X dismiss — is a real <button> reachable in tab order with a visible focus ring; no div-onClick dead-ends."
focus_move: "North-star: on each step transition focus moves to the new panel's heading and the active step carries aria-current='step'; the confirm step is a role=alertdialog that holds focus until 'Yes, cancel subscription' or 'Go back'."
guard: "Final cancel is double-gated — the offers funnel precedes a role=alertdialog confirm requiring an explicit 'Yes, cancel subscription'; the north-star adds a typed-name match (confirm_name) before the destructive POST fires."
UX notes.
- Surface: cancel flow at
/portal/subscriptions/{id}/cancel— multi-step:- Reason — radio list (merchant-configured), required
- Intervention — branch per reason (pause offer / discount offer / interval change / support escalation / "none")
- Confirm — explicit destructive confirm with typed name (only if final cancel)
- Intervention states: "Accept offer → apply action → exit flow" vs. "Decline → proceed to confirm"
- Accessibility: each step uses
aria-current="step", focus managed between steps
Data contract.
- Our API: POST
/api/v1/subscriptions/{id}/cancel(called at final confirm) - Request:
{reason, intervention_offered, intervention_outcome, notes} - DB:
subscriptions.status = cancelled,cancelled_at,cancel_reason,cancel_intervention_outcome; cancel all scheduled future charges - Events:
subscription.cancelled,churn.intervention_shown,churn.intervention_accepted/declined
Success metrics.
- Functional: cancellations are final only after explicit confirm
- Product: intervention save rate ≥ 20% (target; measured per intervention type, revised after 90d)
- Product (target): reason-capture completeness ≥ 95% (merchant-specified reasons not "Other")
Dependencies.
- Epic 20 (intervention flows)
Non-functional.
- Once cancelled, reactivation window is 90 days (US-18.7); after that, subscriber must resubscribe fresh
US-18.6: Update quantity
<!-- traceability:start:US-18.6 --><!-- traceability:end:US-18.6 -->Prototype: Reason Capture
Phase: MVP · Persona: Subscriber
As a Subscriber, I want to change the quantity (e.g., 1 bag to 2 bags), so that I match my consumption pace.
Acceptance criteria:
- Given I view my subscription, When I increment quantity and save, Then the next charge and all subsequent are priced at new quantity.
- Given a requested quantity outside the plan's
[min_qty ?? 1, max_qty ?? 100]bounds, When I save, Then the update is rejected with400 invalid_body(qty_below_minimumorqty_above_maximum) and the subscription is unchanged — the API does not silently coerce the value. This is consistent with the create/subscribe path (subscriptions.ts), the eligibility checker (§US-26.7), and the storefront subscribe widget, all of which block out-of-bounds quantities.
UI states. (rendering contract — ui-states block convention, #1851)
<!-- ui-states US-18.6 -->surface: "/account/subscriptions + /subscriptions — the 'Update quantity' inline form inside the Manage panel (ManagePanel.svelte), rendered only for an active subscription within SubscriberPortalApp.svelte (the only component that mounts ManagePanel; the /portal/subscriptions/[id] detail view renders no quantity affordance)."
idle:
render: "The Manage action grid shows an 'Update quantity' tile ('Change units per delivery cycle'); opening it reveals a number input pre-filled to the subscription's current quantity."
primary_action: "Adjust the quantity, then 'Save quantity' — the new quantity prices the next charge and every subsequent cycle."
loading:
trigger: "POST /api/v1/portal/subscriptions/{id}/quantity"
render: "The 'Save quantity' button reads 'Saving…' and the number input, Save, and Cancel are all disabled; no double-submit."
error:
surfaced_at: "Inline (role=alert) at the foot of the open quantity panel, directly beneath the Save/Cancel controls — never a toast; the panel stays open with the entered value intact."
render: "The rejection reason — for an out-of-bounds quantity, a humanized 'Quantity must be between {min_qty} and {max_qty}' derived from the 400 invalid_body code (qty_below_minimum / qty_above_maximum), not the raw 'API 400: …' string."
recovery: "Correct the quantity to an in-bounds value and re-Save; or Cancel to close the panel with the subscription unchanged."
empty:
render: "Not a list surface — a single-subscription inline form; the subscriptions-list empty state is owned by the portal home (US-17.1)."
cta: "n/a (single-subscription surface)"
inputs:
- field: "quantity"
control: "number"
allowed_values: "integer within the plan's [min_qty ?? 1, max_qty ?? 100] bounds; the input min/max must be derived from the plan, not a hardcoded 99"
edge_status:
- status: "active — the only status that renders the Manage panel, and thus the quantity form"
badge: "active"
affordance: "Update quantity → opens the inline number form; Save applies to the next charge and all subsequent cycles"
- status: "paused"
badge: "paused"
affordance: "Quantity form is not surfaced while paused — 'Resume now' returns the subscription to active, after which quantity is editable"
- status: "past_due"
badge: "Payment issue"
affordance: "Quantity form is not surfaced while past_due — 'Update payment method' clears the dunning state (US-19.1), after which quantity is editable"
- status: "cancelled — within the 14-day grace window"
badge: "cancelled"
affordance: "Quantity form is not surfaced — 'Reactivate' returns the subscription to active first (US-18.7), after which quantity is editable"
disabled_focus:
keyboard: "Every control is a real element reachable in tab order — the 'Update quantity' tile and the panel's number input, 'Save quantity', 'Cancel', and the '×' close are all native <button>/<input>, never div-onClick; Save stays disabled until the value differs from the current quantity and is ≥ 1."
focus_move: "On open, focus moves into the number input; on close or success the panel collapses and focus returns to the 'Update quantity' trigger."
guard: "Quantity change is non-destructive (no typed-confirm); an out-of-bounds value is rejected server-side with a 400 rather than silently coerced, so the user always sees the value they submitted."
Decision lineage: The portal quantity-update endpoint originally silently clamped out-of-range values to the nearest bound (returning
200+ aclampedflag — decisione21ea786, "forgiving self-serve UX"). That was reversed to reject (this AC) on 2026-06-11: silent clamp was the lone outlier — every other quantity surface (create, eligibility, storefront widget) rejects — and an explicit400is the least-astonishing contract (a client sending21againstmax=20learns its input was invalid rather than having it silently changed to20). The clamp option is preserved here for traceability; it is not the chosen behavior.
US-18.7: Reactivate cancelled subscription
<!-- traceability:start:US-18.7 --><!-- traceability:end:US-18.7 -->Prototype: Confirmation
Phase: P2 · Persona: Subscriber
As a Subscriber who previously cancelled, I want to reactivate the same subscription, so that I don't have to rebuild my preferences.
Acceptance criteria:
- Given I have a cancelled subscription < 90 days old, When I click "Reactivate" in the portal, Then status returns to
activewith a fresh next_charge_at set to now + 1 interval. - Given the product or plan is no longer active, When I reactivate, Then I'm prompted to pick an alternate.
UI states. (rendering contract — ui-states block convention, #1851)
<!-- ui-states US-18.7 -->surface: "/portal/subscriptions/[id] + /account/subscriptions row (SubscriptionStatusActions.svelte)"
idle:
render: "A cancelled subscription shows a 'cancelled' status badge with the plan summary."
primary_action: "Reactivate — surfaced only within the grace window (see edge_status)."
loading:
trigger: "POST /api/v1/portal/subscriptions/{id}/reactivate"
render: "The Reactivate button shows 'Reactivating…' and is disabled; no double-submit."
error:
surfaced_at: "Inline, directly beneath the Reactivate affordance (role=alert), scoped to this subscription — never a page-level banner that disassociates from the failing row."
render: "The failure reason (e.g. 'this plan is no longer active')."
recovery: "Retry; or — when the plan/product is inactive (AC2) — a 'pick an alternate plan' CTA."
empty:
render: "Not a list surface — the subscriptions-list empty state is owned by the portal home (US-17.1). Documented here so the state is considered, not silently skipped."
cta: "n/a (single-subscription surface)"
edge_status:
- id: cancelled-in-grace
status: "cancelled — within the 14-day grace window"
badge: "cancelled"
affordance: "Reactivate → returns to active with next_charge_at = now + 1 interval"
- id: grace-expired
status: "cancelled — grace window expired"
badge: "cancelled"
affordance: "Resubscribe fresh — the reactivate window has closed; link to the product/plan to start a new subscription"
inputs: []
disabled_focus:
keyboard: "Reactivate is a real <button> reachable in tab order — never a div-onClick; visible focus ring."
focus_move: "On success the row re-renders to 'active'; the updated status is an aria-live=polite region so a screen reader announces the change."
guard: "Reactivate is a single intentional click (non-destructive); no typed-confirm required."
# closes — the #1851 loop-closer: each binding resolves to a test tagged `@render:<tag>`; the
# lint ERRORs if the proving assertion is missing. grace-expired is deliberately unbound (its
# resubscribe affordance is not yet rendered/tested) — the lint WARNs, surfacing the real gap.
closes:
edge_status.cancelled-in-grace: "render:US-18.7:cancelled-grace"
US-18.8: Change cadence / interval
<!-- traceability:start:US-18.8 --><!-- traceability:end:US-18.8 -->Prototype: Reactivate
Phase: MVP · Persona: Subscriber
As a Subscriber, I want to change my subscription's interval (e.g., monthly → every 2 months), so that I match my actual consumption.
Acceptance criteria:
- Given the plan offers multiple intervals, When I change interval and save, Then the new interval applies from the next cycle onward and next_charge_at is recomputed.
UI states. (rendering contract — ui-states block convention, #1851)
<!-- ui-states US-18.8 -->surface: "/account/subscriptions + /subscriptions portal list (SubscriberPortalApp.svelte → ManagePanel.svelte 'Change cadence' inline form; active subscriptions only)"
idle:
render: "An active subscription row exposes a 'Manage' button that opens a responsive action grid (up to 3 columns); the 'Change cadence' card ('Switch to a different delivery frequency') reveals an inline <select> of six fixed delivery-frequency presets (Every 2 weeks · Monthly · Every 6 weeks · Every 2 months · Quarterly · Yearly)."
primary_action: "Change cadence → opens the inline form; 'Switch cadence' commits the chosen preset, 'Cancel' / '×' dismisses it."
loading:
trigger: "POST /api/v1/portal/subscriptions/{id}/interval with {interval, interval_count} derived from the selected preset"
render: "The 'Switch cadence' button shows 'Saving…' and every control in the form (the frequency select, Switch, Cancel, and the × close) is disabled; no double-submit."
error:
surfaced_at: "Inline, beneath the cadence form controls (role=alert, text-error class), scoped to this subscription; the form stays open."
render: "The failure reason returned by the interval endpoint (raw err.message today — see gapNotes)."
recovery: "Re-pick a frequency and press 'Switch cadence' again, or press 'Cancel' to dismiss without changing cadence."
empty:
render: "Not a list surface — a single-subscription inline cadence form. The subscriptions-list empty state ('No subscriptions yet…') is owned by the portal list in SubscriberPortalApp."
cta: "n/a (single-subscription form)"
edge_status:
- status: "paused — ManagePanel (and the Change cadence card) is not rendered"
badge: "paused"
affordance: "Resume subscription first (portal Resume action) → returns to active, where Change cadence is available again"
- status: "past_due — a charge has failed; management actions are gated"
badge: "past_due"
affordance: "Update payment method / retry the charge → once the subscription returns to active, Change cadence returns"
- status: "cancelled within the 14-day grace window — Change cadence is not offered"
badge: "cancelled"
affordance: "Reactivate (US-18.7) → returns to active, then change cadence applies from the next cycle"
disabled_focus:
keyboard: "The 'Change cadence' trigger, the frequency <select> (Tab to focus, arrow keys to choose a preset), and the 'Switch cadence' / 'Cancel' buttons are real focusable elements in tab order — never div-onClick; the dismiss control is a <button aria-label='Close'>."
focus_move: "On success the form closes (active=null) and the portal's polite toast (role=status, aria-live=polite) announces 'Cadence switched to <preset>.'; north-star also returns focus to the 'Change cadence' trigger (not done today — see gapNotes)."
guard: "Switching cadence is a single intentional click (non-destructive; applies from the next cycle onward, next_charge_at recomputed server-side); no typed-confirm. North-star disables 'Switch cadence' when the picked preset equals the current cadence to prevent a no-op POST."
US-18.9: Term-end renewal nudge — registration-style re-up distinct from MIT renewal
<!-- traceability:start:US-18.9 --><!-- traceability:end:US-18.9 -->Prototype: Renewal screens (term-end / mid-cycle / NTI)
Phase: P1 (pulled forward 2026-05-16) · Persona: Subscriber
As a Subscriber whose subscription has a defined term-end (annual contract with notice-period clause, prepaid 12-cycle exhaustion, B2B negotiated term), I want a registration-style re-up flow at term-end (review what I'm renewing, optionally adjust terms, confirm to extend), so that auto-renewal isn't applied silently to a long-term commitment.
Acceptance criteria:
- Given a subscription has
term_end_atset (B2B contract end, prepaid exhaustion, or merchant-configured fixed term), Whenterm_end_at - merchant.nudge_lead_daysarrives, Then a "renew your subscription" email fires with a portal-link to the re-up flow; the subscription is flaggedpending_renewal_decision. - Given the subscriber clicks the link and confirms re-up, When confirmation is recorded, Then
term_end_atadvances by one term, the subscription returns to standard renewal flow, and the next standard MIT charge fires per the existing cadence. - Given the subscriber edits terms (cadence, plan, quantity) at the re-up step, When confirmation is recorded, Then the edits apply from the re-up date forward (not retroactively); a
subscription.term_renewedevent is emitted with the diff. - Given the subscriber declines or ignores the nudge through
term_end_at, When the term ends, Then the subscription transitions tocompleted(terminal) and a "your subscription has ended" email fires; no renewal MIT is attempted. - Given the merchant config has
nudge_required = false(auto-renew on silent), Whenterm_end_atarrives without action, Then the subscription auto-renews silently using the standard MIT path; the nudge email serves as informational only.
UI states.
<!-- ui-states US-18.9 -->surface: "/account/subscriptions + /subscriptions portal list (SubscriberPortalApp.svelte active-subscription row → RenewalStatePanel.svelte); the registration-style re-up flow it links to is a north-star surface not yet built (see gapNotes)."
idle:
render: "When a subscription is inside its term-end window (pending_renewal_decision = true), the active-subscription row surfaces a 'Renew your subscription' nudge banner (role=status) summarising plan, term length, and the term_end_at date, with a re-up CTA. Outside that window the row shows the standard plan summary + status badge + next-charge line; RenewalStatePanel today derives only NTI vs mid-cycle from next_charge_at and renders nothing for term-end (see gapNotes)."
primary_action: "Review & renew → opens the registration-style re-up flow (review what renews, optionally adjust cadence/plan/quantity, confirm to extend term_end_at by one term)."
loading:
trigger: "POST /api/v1/portal/subscriptions/{id}/renew with the confirmed (optionally edited) terms — north-star endpoint, not yet built (see gapNotes)."
render: "The 'Confirm renewal' button shows 'Renewing…' and is disabled along with every re-up form control (cadence/plan/quantity); no double-submit."
error:
surfaced_at: "Inline, directly beneath the 'Confirm renewal' affordance (role=alert), scoped to this subscription — never a page-level toast that disassociates from the failing row."
render: "The failure reason returned by the renewal endpoint (e.g. 'this plan is no longer available for renewal')."
recovery: "Retry the renewal; or — when the current plan is inactive — pick an alternate plan in the re-up step. The term-end nudge persists until term_end_at, so a failed confirm never silently drops the subscriber into the standard auto-renew path."
empty:
render: "Not a list surface — the term-end nudge attaches to a single active subscription's row. The subscriptions-list empty state ('No subscriptions yet…') is owned by the portal list in SubscriberPortalApp (US-17.1)."
cta: "n/a (single-subscription nudge, not a collection)"
inputs:
- field: "cadence"
control: "select"
allowed_values: "the six fixed delivery-frequency presets reused from Change cadence / US-18.8 (Every 2 weeks · Monthly · Every 6 weeks · Every 2 months · Quarterly · Yearly)"
- field: "plan"
control: "select"
allowed_values: "active plans on the same product the subscription belongs to"
- field: "quantity"
control: "number"
allowed_values: "positive integer within the plan's min/max quantity bounds"
edge_status:
- status: "pending_renewal_decision — term_end_at is approaching and the nudge email has fired (AC1)"
badge: "Renewal due"
affordance: "Review & renew → opens the re-up flow to confirm or adjust terms and advance term_end_at by one term (AC2/AC3)"
- status: "completed — the subscriber declined or ignored the nudge through term_end_at; the term has ended (AC4, terminal)"
badge: "Ended"
affordance: "Resubscribe fresh → link to the product/plan to start a new subscription; no renewal MIT is attempted on a completed term"
- status: "auto-renew on silent — merchant config nudge_required = false; term_end_at passed without action (AC5)"
badge: "Renews automatically"
affordance: "No action required — the subscription auto-renews via the standard MIT path; the standard NTI 'next charge soon' notice and Manage actions remain the way to change or stop it"
- status: "nti — next charge ≤ 5 days away (the one renewal state RenewalStatePanel renders today)"
badge: "Charge soon"
affordance: "Make sure your payment method is up to date → Update payment method (US-19.1), or skip the next charge before it fires"
disabled_focus:
keyboard: "The 'Review & renew' trigger and every re-up form control (cadence select, plan select, quantity number input, 'Confirm renewal' / 'Cancel') are real focusable elements in tab order — never div-onClick; the nudge banner itself is a role=status region, not a focus trap. Today only RenewalStatePanel's static NTI notice exists (no interactive re-up controls — see gapNotes)."
focus_move: "Opening the re-up flow moves focus into the review panel; on a successful confirm the panel closes, the row re-renders to its renewed state, and a polite aria-live region (role=status) announces 'Subscription renewed through <new term_end_at>.'; focus returns to the row's primary action."
guard: "Confirming renewal is a single intentional click (non-destructive — it extends the term and applies any term edits from the re-up date forward, never retroactively); no typed-confirm. Declining is reversible up until term_end_at; once the term completes, resubscribe is the only path."
Data contract. New columns: subscriptions.term_end_at (timestamptz, nullable), subscriptions.term_length (interval), subscriptions.pending_renewal_decision (boolean). New events: subscription.term_nudge_sent, subscription.term_renewed, subscription.term_completed. Email template lives in Epic 23 transactional email pipeline.
Dependencies. Epic 23 transactional email (template rendering). US-17 (portal re-up flow surface). Optional Epic 24 contract-term semantics for B2B (US-24.7 cross-reference).
US-18.10: Cancel lock — portal enforcement and CS-rep admin override
Phase: P2 · Persona: Subscriber / CS Rep
As a Subscriber in a minimum-term lock window, I want to understand when I can cancel and what my options are, so that I'm not confused by a disabled Cancel button. As a CS Rep, I want to cancel a locked subscription on behalf of a subscriber when warranted, so that edge cases (medical, relocation, dispute) can be resolved without API workarounds.
Acceptance criteria:
- Given
lock_expires_at > now()andearly_cancel_policy = 'block', When the subscriber navigates to the cancel flow, Then the Cancel button is disabled and replaced with a read-only message "Cancellation available from [lock_expires_at formatted date]"; the standard churn-prevention step (US-18.5) is not shown. - Given
lock_expires_at > now()andearly_cancel_policy = 'contact_us', When the subscriber navigates to the cancel flow, Then the Cancel button is replaced with a "Contact support to cancel early" CTA linking to the merchant-configured support URL; no cancellation is processed by the app. - Given a direct API call
PATCH /api/v1/subscriptions/:idwith{status: "cancelled"}whilelock_expires_at > now(), When processed, Then the API returns409 Conflictwith body{"error": "subscription_locked", "lock_expires_at": "<ISO 8601 timestamp>"}. - Given a user with role
manageroradmincallsPOST /api/v1/subscriptions/:id/admin-cancelwith a requiredreasonstring, When processed, Then the cancellation proceeds regardless oflock_expires_at; subscription status transitions tocancelled; an audit eventsubscription.force_cancelledis emitted carryingactor_user_id,lock_expires_atat time of override, andreason. - Given
lock_expires_atis null orlock_expires_at ≤ now(), When the subscriber reaches the cancel flow, Then the standard cancel flow (US-18.5) runs unchanged.
UI states. (rendering contract — ui-states block convention, #1851)
<!-- ui-states US-18.10 -->surface: "/account/subscriptions + /subscriptions (SubscriberPortalApp.svelte) and /portal/subscriptions/[id] (SubscriptionDetailView.svelte) — both mount CancelSubscriptionButton.svelte. The minimum-term lock is a PRE-FLIGHT GUARD on the US-18.5 cancel funnel; CS-rep admin override is a separate surface (React admin → POST /api/v1/subscriptions/:id/admin-cancel), not rendered here."
idle:
render: "When lock_expires_at is null or lock_expires_at <= now() the unlocked cancel CTA renders: a single 'Cancel subscription' <button> (data-demo=portal-cancel-btn). Whether the subscription is locked is decided pre-flight from the plan's early_cancel_policy ('block' | 'fee' | 'contact_us') plus the subscription's lock_expires_at; a locked subscription renders one of the edge_status variants instead of this button."
primary_action: "'Cancel subscription' opens the US-18.5 churn-prevention funnel (pause / 10%-off offers) then a role=alertdialog typed confirm; on confirm POST /api/v1/portal/subscriptions/{id}/cancel flips status to 'cancelled' and onCancelled() refreshes the row. Shown only when NOT locked (AC5)."
loading:
trigger: "POST /api/v1/portal/subscriptions/{id}/cancel (the confirm step). The lock pre-flight needs no extra request — lock_expires_at and early_cancel_policy already travel with the subscription + plan loaded for the row."
render: "The confirm button reads 'Cancelling...' and is disabled (busy); the 'Go back' button is also disabled while the request is in flight — no double-submit."
error:
surfaced_at: "Inline inside the cancel card — a role=alert <p> directly beneath the confirm/CTA controls, scoped to this one subscription (the component already renders role=alert at the confirm step). Never a page-level banner, never a vanishing toast."
render: "The lock-aware reason, not a raw 'API 409' string. A 409 the pre-flight missed (lock applied server-side mid-flow) degrades to the spec'd copy: reason 'cancel_locked' renders 'Cancellation available from {lock_expires_at, formatted date}'; reason 'cancel_contact_support' renders 'Contact support to cancel early'; reason 'cancel_requires_fee' surfaces the early_termination_fee_cents amount; a transient failure (network, expired session) renders its specific message."
recovery: "Transient failure -> the confirm button re-enables; retry. A surfaced 'block' lock -> no in-window retry; the formatted lock_expires_at date tells the subscriber when the Cancel control returns. A 'contact_us' policy -> the 'Contact support to cancel early' CTA routes the early-cancel request off-app to the merchant support URL."
empty:
render: "Not a list surface — this is a single-subscription cancel guard. The subscriptions-list empty state is owned by the portal home (US-17.1). Documented here so the state is considered, not silently skipped."
edge_status:
- status: "minimum-term lock active — early_cancel_policy 'block' and lock_expires_at > now()"
badge: "locked until {lock_expires_at}"
affordance: "The 'Cancel subscription' button is disabled and replaced with a read-only message 'Cancellation available from {lock_expires_at, formatted date}'; the US-18.5 churn-prevention funnel is NOT shown (AC1). API guard: cancel.ts returns 409 reason 'cancel_locked'."
- status: "minimum-term lock active — early_cancel_policy 'contact_us' and lock_expires_at > now()"
badge: "contact support"
affordance: "The 'Cancel subscription' button is replaced with a 'Contact support to cancel early' CTA linking to the merchant-configured support URL; the app processes no cancellation (AC2). API guard: cancel.ts returns 409 reason 'cancel_contact_support'."
- status: "minimum-term lock active — early_cancel_policy 'fee' and lock_expires_at > now()"
badge: "early-termination fee"
affordance: "The card surfaces the early_termination_fee_cents amount and asks the subscriber to confirm the fee before cancelling; until the fee charge rail ships, cancel.ts returns 409 reason 'cancel_requires_fee' and the CTA routes to support. ('fee' is a real plan enum value beyond the two AC variants — db.ts early_cancel_policy is 'block' | 'fee' | 'contact_us'.)"
- status: "B2B contract notice period — inside notice_period_days of term_end_at (US-24.7)"
badge: "notice period"
affordance: "Cancel is blocked or routed to the merchant's legal/admin escalation per the store's contract_cancel_guard_mode; the card surfaces the contract end date (term_end_at) the cancel can take effect on. API guard: cancel.ts returns 409 reason 'contract_notice_period' or 'cancel_routed_to_legal'."
inputs: []
disabled_focus:
keyboard: "The 'Cancel subscription' control is a real <button> reachable in tab order with a visible focus ring (never a div-onClick). When locked under 'block', the unavailability is conveyed by an adjacent read-only message rather than a focus-trapping disabled button the keyboard can't explain; the 'contact_us' CTA is a real <a>/<button> in tab order."
focus_move: "Opening the funnel/confirm moves focus into the role=alertdialog; on cancel-success the card collapses and focus returns to the subscription row; the locked read-only message is an aria-live=polite region so a screen reader announces why cancel is unavailable."
guard: "Cancel is a deliberate two-step (funnel -> typed confirm 'Yes, cancel subscription'). The lock pre-flight prevents the funnel from opening at all when early_cancel_policy is 'block', so a locked subscriber never reaches the destructive confirm (AC1)."
Data contract. New API endpoint: POST /api/v1/subscriptions/:id/admin-cancel — requires manager or admin RBAC role; request body {reason: string} required. Standard self-cancel endpoint returns 409 Conflict with lock_expires_at during active lock (see AC above). Audit event subscription.force_cancelled persists to the immutable audit log (Epic 28).
Dependencies. US-5.8 (plan-level lock fields must exist before enforcement is meaningful); US-18.5 (base cancel flow — lock check is a pre-flight guard on this flow); US-1.7 (actor_user_id stamping for admin override audit trail).
Cross-references. A3 spec-comprehensiveness matrix U-17; Spec bf1873db; personas P4 Sam (B2B contract cancellation) + P5 Taylor (commitment-discount early exit); WCSubs feature request #3 (147 votes).
<!-- normative-requirements US-18.10 - artifact: subscription.force_cancelled kind: event fit: audit event when CS rep or admin overrides cancel lock closes: grep:apps/api/src -->US-18.11: Click-to-cancel — same-medium cancellation parity
Phase: P1 · Priority: P0 · Effort: M · Persona: Subscriber
As a Subscriber, I want to cancel my subscription as easily as I started it — through the same medium, without obstruction — so that I am not trapped, and the merchant meets negative-option law (the FTC's ROSCA "at least as easy to cancel" interpretation + state ARLs including California's AB 2863 same-medium "click to quit").
Compliance note (research red team S3, synthesis #1822, ADR-0079). The FTC interprets ROSCA (15 U.S.C. §8403) to require a cancellation mechanism "at least as easy to use as the method the consumer used to initiate." California's ARL (AB 2863, effective 2025-07-01) requires same-medium cancellation and prohibits obstruction/delay. The federal click-to-cancel Rule itself was vacated (8th Circuit, July 2025), but ROSCA and state ARLs still bind. Pairs with the Epic-20 obstruction guard (US-20.7). Final scope counsel-gated (ADR-0079).
Acceptance criteria:
- Given a subscriber enrolled online, When they want to cancel, Then an online cancel path is available in the same medium (the subscriber portal, Epic 17) without requiring a phone call, email, or live agent.
- Given the subscriber initiates cancel, When they confirm, Then cancellation completes in the same session — no mandatory retention call, no multi-day "we'll process it" delay, no forced re-authentication beyond the portal's own login.
- Given a churn-prevention save flow runs (Epic 20), When the subscriber declines every offer, Then the cancel completes immediately — the save flow may present offers but cannot block, gate, or loop the cancel (enforced by US-20.7).
- Given cancellation completes, When the subscriber returns, Then they receive confirmation (email + on-screen) of the effective date and end-of-access semantics (US-18.5).
UI states.
<!-- ui-states US-18.11 -->surface: "/account/subscriptions + /subscriptions (SubscriberPortalApp.svelte, active-status rows) and /portal/subscriptions/[id] (SubscriptionDetailView.svelte, status === 'active') — both mount CancelSubscriptionButton.svelte; the same-medium online cancel path, reusing US-18.5 with no new mechanism."
idle:
render: "An active subscription exposes a single, clearly-labeled 'Cancel subscription' button (data-demo=portal-cancel-btn) reachable in one step from the subscription detail — no phone number, email address, or live-agent gate."
primary_action: "'Cancel subscription' opens the in-session churn-prevention funnel (role=group, 'Before you go — would any of these help?'); the funnel always carries a live 'Still want to cancel' control (data-demo=portal-cancel-proceed) that advances to confirm without accepting an offer, honoring the US-20.7 obstruction guard."
loading:
trigger: "POST /api/v1/portal/subscriptions/{id}/cancel with { declined_intervention_types } (api-client.ts cancelSubscription, line 331)."
render: "On confirm the 'Yes, cancel subscription' button reads 'Cancelling…' and both it and 'Go back' are disabled while busy=true — no double-submit; accepting a save offer instead shows 'Pausing…' / 'Applying…' on that offer's own button."
error:
surfaced_at: "Inline, as a role=alert <p class=text-error> beneath the controls of whichever step failed — the funnel offers (CancelSubscriptionButton.svelte:163-165) or the confirm dialog (:120-122). Scoped to this cancel flow, never a page-level banner or vanishing toast; the flow stays open."
render: "The failure reason returned by the cancel / pause / discount endpoint (raw err.message today — see gapNotes)."
recovery: "The action button re-enables; retry 'Yes, cancel subscription', or press 'Go back' to the funnel — a failure never blocks, loops, or hides the cancel path (US-18.11 / US-20.7)."
empty:
render: "Not a list surface — CancelSubscriptionButton renders one subscription's cancel CTA + funnel. The subscriptions-list empty state ('No subscriptions yet. When you subscribe to a product, it'll show up here.') is owned by SubscriberPortalApp.svelte:365-368."
cta: "n/a (single-subscription cancel control)"
edge_status:
- status: "active — save offer accepted (pause 4 weeks or 10% next-renewal discount); the cancel is averted and the funnel shows a role=status confirmation (CancelSubscriptionButton.svelte:96-105)."
badge: "active"
affordance: "Deliveries continue at the new terms; to still cancel, re-open 'Cancel subscription' and choose 'Still want to cancel' — the retention path never dead-ends the cancel."
- status: "paused — CancelSubscriptionButton is not rendered (active-only gate); the row shows the Resume affordance instead (SubscriberPortalApp.svelte:497-520)."
badge: "paused"
affordance: "'Resume now' returns the subscription to active, where the same-medium 'Cancel subscription' control is available again."
- status: "past_due — a charge has failed; the row surfaces only 'Update payment method' and the cancel control is not rendered (SubscriberPortalApp.svelte:522-538)."
badge: "past_due"
affordance: "Today: 'Update payment method' to recover the subscription. North-star: a same-medium cancel must also be reachable from past_due — see gapNotes (active-only gating undercuts the US-18.11 single-step 100%-coverage metric)."
- status: "cancelled — within the 14-day grace window; cancellation already completed (US-18.5) and the row shows the reactivate affordance (SubscriberPortalApp.svelte:551-569)."
badge: "cancelled"
affordance: "'Reactivate' restores the subscription within the grace window; on-screen + email confirmation of the cancel's effective date and end-of-access semantics is owed per US-18.5 (AC4)."
- status: "active — B2B junior buyer with no manage permission (canManage=false); the cancel control is replaced by an approval notice (SubscriberPortalApp.svelte:447-453)."
badge: "active"
affordance: "'Changes to this subscription require approval from your company admin' — contact the company admin to request cancellation (Epic-24 approval path)."
inputs: []
disabled_focus:
keyboard: "Every cancel affordance is a native <button type=button> reachable in tab order — the 'Cancel subscription' CTA (CancelSubscriptionButton.svelte:215), the funnel's always-live 'Still want to cancel' control (:202), and the confirm 'Yes, cancel subscription' button (:124); no div-onClick, so the obstruction-guard cancel control is keyboard-reachable on every save-flow step (US-20.7 / WCAG)."
focus_move: "Confirm renders a role=alertdialog labelled by its heading + description (CancelSubscriptionButton.svelte:107-119); north-star moves focus into the dialog on open and returns it to the trigger on 'Go back'. Focus is not yet programmatically managed — see gapNotes."
guard: "While the cancel is in flight (busy=true) both confirm controls are disabled (CancelSubscriptionButton.svelte:127,135) — no double-submit. Cancel is a deliberate two-step (open funnel → confirm) but is never typed-confirm-gated, timed, re-auth-gated, or hidden; any such gate would breach US-18.11 same-medium parity."
UX notes.
- Surface: subscriber portal — a single, clearly-labeled "Cancel subscription" action reachable in one step from the subscription detail
- The cancel CTA is not visually de-emphasized relative to "Keep subscription"
- Voice-equivalent parity: a merchant offering phone enrollment owes a phone-cancel of equal ease (out of scope for the online MVP; flagged)
Data contract.
- Reuses US-18.5 (cancel) — adds an accessibility/obstruction contract, not a new cancel mechanism
- Emit
subscription.cancel_requestedwithentry_medium+cancel_mediumso same-medium parity is auditable - No new BC platform primitive
Success metrics.
- Functional: 100% of online-enrolled subscriptions expose an online cancel path reachable in a single step from the portal subscription detail (anything that adds a gate is a defect)
- Operational (target): cancel completes in under 2s P95 once confirmed
- Product (target): median steps-to-cancel does not exceed steps-to-subscribe — parity is the legal bar, not a funnel-optimization target
Dependencies.
- US-18.5 (cancel flow)
- US-20.7 (save-flow obstruction guard — the cancel cannot be blocked)
- US-17.1 / US-17.2 (portal access)
- ADR-0079; counsel attestation
Non-functional.
- The save flow's offer count is capped (US-20.7); the cancel control is live on every save-flow step
- Cancel reachability is part of the keyboard-navigation / WCAG surface
Risks / open questions.
- Phone/offline enrollment requires a same-ease offline cancel — MVP is online-only; offline parity flagged for when phone enrollment ships
- "Effective immediately" vs "end of paid term" cancellation semantics interact with the post-cancel grace window (US-18.7) — ADR-0079 + US-18.5 define which applies