Multi-Item Self-Serve Subscription Management — Acceptance Spec
Status: SHIPPED 2026-05-12 — Phase 7 of FA-082. ENABLE_MULTI_ITEM_ADDS=monthly is live in Vercel prod. All FA-082/087/102/105 issues resolved; PRs #440 (entitlement guard), #441 (preview setup-fee fix), #442 (flag-interval fix), #443 (cancel-flag fixups) merged and deployed. End-to-end verified against Melvindale internal test account. Annual customers see the "email john / switch to monthly" note (Phase 8 scope).
Scope: All UI surfaces where a customer self-serves adding, removing, or resuming a product (Chat / Voice / Website) on their subscription.
This spec is the source of truth. Code that doesn't match this spec is wrong. Tests assert this spec.
Foundational decisions (industry-standard rationale)
Every decision below was anchored against what mature SaaS multi-product companies do (Stripe Billing, HubSpot Hubs, Cal.com Add-ons, Apple App Store Subscriptions, Spotify, Netflix, Notion, Slack). Where ChurchWiseAI's pastoral context matters (e.g., HEAR protocol), we noted whether it applies — billing UI generally does NOT use HEAR (that's for ministry, not commerce).
| Decision | Choice | Why |
|---|---|---|
| Canonical home for add + remove | One Subscription tab (renamed from "Upgrade") | Stripe / HubSpot / Cal.com all funnel both flows through one "Plan & Billing" home. Splitting increases support tickets. |
| Cancel-button visual weight | Secondary text-link, not primary button | Apple App Store + Spotify pattern. Recoverable but de-emphasized to prevent fat-finger churn. |
| Cancel modal tone | Direct + warm, no HEAR/pastoral framing | Pastoral tone in cancellation reads as guilt-tripping. HEAR is for ministry; billing UX is functional. Apple/Google avoid emotional copy in cancel flows. |
| Phone number warning on voice cancel | Surface 30-day Telnyx hold + re-keep window | Twilio + Google Voice surface this. Customers hate losing phone numbers more than losing service. Trust > brevity. |
| LAST_REMAINING_PRODUCT 409 UX | Inline modal error → single CTA to full-account cancel | Stripe Billing pattern. Auto-redirect is jarring; inline-cancel-account skips intent re-confirmation. |
| ANNUAL_MIXING_NOT_SUPPORTED 400 UX | Hide Add CTAs entirely for annual customers | Industry pattern when feature unavailable: hide, don't show-then-error. Avoids broken-flow UX. |
| Pending-cancellation badge color | Amber/yellow-warning (not red) | Cancellation is not an error state. Amber matches Apple/Spotify/Netflix. |
| Pending-cancellation badge text | "Ends [Oct 12]" with concrete date | Scannable, actionable. "Pending cancellation" is vague; "Active until X" is too soft. |
| Resume (un-cancel) before sweep runs | YES, prominent button on the badge | Apple/Spotify/Netflix do this. Published SaaS data: 15–25% of "cancel for now" decisions reverse when offered Resume. Major retention win. |
| Tier gate on self-serve | All tiers (Starter, Pro, Suite) | Starter is highest-churn segment. Friction at manage = lost retention. All major SaaS allow self-serve at every tier. |
| Setup-fee transparency on Add | Itemized line-by-line | Stripe / Cal.com / Apple all itemize. Hidden combined totals → post-purchase regret + chargebacks. |
| Cancellation-reason capture | Optional 1-screen survey post-confirm | Spotify / Netflix / Notion all do this. Highest-leverage data for solo-founder learning why customers churn. |
| Email confirmation on add/remove | Always sent | Stripe Billing sends automatically; we mirror. Trust + paper trail. |
| Settings → Billing entry point | Pointer link to Subscription tab | Customers go to Settings reflexively for billing. One-click forward avoids "where do I cancel?" tickets. |
Section 1 — Subscription tab IA (information architecture)
1.1 Tab name and placement
- The existing "Upgrade" tab in the admin dashboard is renamed to "Subscription".
- Same position in the tab list (last visible tab before any role-locked tabs).
- Tab visibility unchanged from current rules: visible to all admin-role tiers (Starter / Pro / Suite — Chat / Voice / Both / Website).
1.2 Tab section structure (top → bottom)
- Your active products — one card per active product (Chat / Voice / Website). Hidden if customer has zero active products (which won't happen in practice — everyone has at least one).
- Add a service — one card per inactive product. Hidden if customer has all three active. Hidden entirely for annual customers (see §6).
- Compare plans — link to
/pricing(existing). - Need help? — link to email john@churchwiseai.com.
1.3 Settings → Billing pointer
The Settings tab gains a "Billing & Subscription" subsection with one link: "Manage your subscription →" pointing to the Subscription tab. Single source of truth remains the Subscription tab; Settings is just a discoverability hint.
Section 2 — Active product card (per-product)
2.1 Card layout
Each active product (Chat, Voice, Website) renders one card containing:
- Product name (large): "Chat", "Voice", "Website"
- Tier label: "Starter", "Pro", "Suite", "Site Only", "Bundled" (depends on product)
- Status pill (see §2.2)
- Key metric line: per-product summary, e.g.:
- Chat: "200 messages used this month / 1,000 limit"
- Voice: "(317) 555-0123 — 12 calls this month"
- Website: "yourchurch.john316.church"
- Primary action: link to relevant tab (e.g., Chat card → "Manage Chat" links to Training tab)
- Cancel link (small, gray, text-only, NOT a button): "Cancel [Product]" — opens cancel modal (§4)
2.2 Status pill states
| State | Pill color | Pill text | Trigger |
|---|---|---|---|
| Active | Emerald (green) | "Active" with green dot | has_X_subscription = true AND X_cancel_at_period_end = false |
| Pending cancellation | Amber/yellow | "Ends [date]" (e.g., "Ends Oct 12") | has_X_subscription = true AND X_cancel_at_period_end = true |
| Inactive (card not rendered) | — | — | has_X_subscription = false (card moves to "Add a service" section) |
2.3 Pending-cancellation card variant
When status is "Pending cancellation":
- Cancel link is HIDDEN
- A new "Resume [Product]" primary button appears in the card
- An info banner above the metric line: "Voice will keep working through Oct 12. Resume anytime before then to keep your subscription."
- Clicking Resume opens the Resume modal (§7)
Section 3 — Add a service card
3.1 Card layout (per inactive product)
Each inactive product gets a card:
- Product name: "Add Voice", "Add Chat", "Add Website"
- Price line: "$39.95/mo" (Voice Starter) or "$14.95/mo" (Chat Starter / Website Site-Only) etc.
- One-line value prop: e.g., "24/7 phone answering for prayer requests, callbacks, and visitor questions."
- Add button (primary navy button): "Add Voice — $39.95/mo"
3.2 Per-product card content (verbatim copy)
Add Voice card:
- Headline: "Add Voice — Phone Agent"
- Price line: "$39.95/mo + $49.95 one-time setup"
- Value prop: "24/7 voice agent answers prayer requests, callbacks, and visitor inquiries. Routes urgent calls to your pastor."
- Button: "Add Voice"
Add Chat card:
- Headline: "Add Chat — AI Chatbot"
- Price line: "$14.95/mo (Starter)"
- Value prop: "Embeddable chat widget for your website. Answers FAQs, captures prayer requests and visitor info, escalates urgency to your team."
- Button: "Add Chat"
Add Website card:
- Headline: "Add Pro Website"
- Price line: "$14.95/mo site-only or $19.95/mo bundled with Chat Starter"
- Value prop: "AI-generated church website at yourchurch.john316.church. Auto-updates from your church directory. Custom domain available."
- Button: "Add Website"
3.3 Annual-customer variant
If customer's subscription is billed annually (subscription_interval === 'year'):
- The "Add a service" section is HIDDEN entirely.
- Replaced by a single inline note: "Adding services to an annual plan needs a manual switch to monthly billing. Email john@churchwiseai.com — usually same-day."
Section 4 — Add-product confirmation modal
4.1 Modal trigger
Customer clicks an "Add [Product]" button on the Add card.
4.2 Modal content (success path)
- Title: "Add [Product]"
- Price line (line 1, normal weight): "$39.95/mo + $49.95 one-time setup"
- Charged today section (highlighted box):
- Itemized: "$49.95 setup + $18.31 prorated voice for the rest of this billing period"
- Bold total: "Today: $68.26"
- Tooltip "(?)" on "prorated" with explainer: "You started this billing period 17 days ago. Voice will be prorated for the remaining 13 days, then billed at the full $39.95/mo on your next renewal."
- Card line: "Charged to card ending •••• 4242" (last 4 digits from
premium_churches.last_4, if available) - Buttons: secondary "Cancel" + primary navy "Confirm and add Voice"
4.3 On confirm — success outcome
- Modal closes
- Toast appears (top-right): "Voice activated! Your phone number: +1-XXX-XXX-XXXX. Setup instructions emailed to [admin@church.com]."
- Subscription tab refreshes via
router.refresh()— the Voice card now appears in "Your active products" section - Email confirmation is sent (see §8)
4.4 On confirm — error states
| Route response | UX |
|---|---|
200 + success: true | Toast + refresh (§4.3) |
402 + code: CARD_NEEDS_UPDATE + billing_portal_url | Modal A (Add) closes; Modal B (CardNeedsUpdate) opens with single CTA: "Update card →" linking to Stripe billing portal. After update, customer returns and re-tries. |
400 + code: ANNUAL_MIXING_NOT_SUPPORTED | Should never appear in UI because Add CTAs are hidden for annual customers (§3.3). If it ever does (race condition): inline modal error: "Adding services to an annual plan needs a manual switch — email john@churchwiseai.com." |
409 + code: PRODUCT_ALREADY_ACTIVE | Should never appear because the Add card is only rendered for inactive products. Defensive: inline modal error: "You already have [Product]. Refresh the page to see your active subscriptions." |
| 429 + rate limited | Inline modal error: "Too many attempts. Please wait a minute and try again." |
| 500 / unknown | Inline modal error: "Something went wrong. Please try again or email john@churchwiseai.com." |
Section 5 — Remove-product confirmation modal
5.1 Modal trigger
Customer clicks small "Cancel [Product]" text-link on an active-product card.
5.2 Modal content (success path — most common)
- Title: "Cancel [Product]?"
- Body (paragraph 1): "Voice will keep working through Oct 12. You won't be charged again for Voice — your other products continue. You can re-add Voice anytime."
- Voice-only addendum (only for
product === 'voice', paragraph 2): "Your church's phone number ([+1-XXX-XXX-XXXX]) is reserved for 30 days. Re-subscribe within 30 days and you keep the same number. After 30 days the number returns to availability and may be reassigned." - Retention escape-hatch (small text below body): "Issue we could fix? Email john@churchwiseai.com" — link, gray, small.
- Buttons: primary navy "Keep [Product]" (left, default focus) + secondary destructive-red "Cancel [Product]" (right). NOTE the inverted order — Keep is the primary action because we don't want fat-finger churn. Cancel button uses destructive styling (red text on light bg) so the customer is sure of their click.
5.3 On Cancel-button click — success outcome
- Modal closes
- Toast: "[Product] will end Oct 12. We'll email you a confirmation."
- Subscription tab refreshes — the [Product] card now shows "Pending cancellation" state (§2.3)
- Cancellation Reason Survey opens (§9)
- Email confirmation is sent (see §8)
5.4 On Cancel-button click — error states
| Route response | UX |
|---|---|
200 + success: true | Toast + refresh + survey (§5.3) |
409 + code: LAST_REMAINING_PRODUCT + redirect_url | Modal swaps body to: "Voice is your only active product. Cancelling Voice means cancelling your whole subscription." Two buttons: primary navy "Keep [Product]" + secondary outline "Go to Account Cancellation →" (the latter navigates to the redirect_url, which is the existing full-account cancel flow at Settings → Cancellation). |
400 + code: FEATURE_DISABLED | Should never appear — feature flag default is demo_only and demo customers always pass. If it does: inline modal error: "Self-serve cancel isn't available right now. Email john@churchwiseai.com to cancel [Product]." |
| 429 / 500 / unknown | Same handling as Add modal §4.4. |
Section 6 — Annual customer special-case
6.1 What annual customers see
- Subscription tab — "Your active products" section: SAME as monthly customers. Cancel links work normally (per-product cancel is fine for annual customers; it just defers to period-end).
- Subscription tab — "Add a service" section: HIDDEN entirely. Replaced by inline note (§3.3).
- The cancellation flow is identical to monthly — annual customers can cancel-individual-product via the same UI.
6.2 Why this design
The route's ANNUAL_MIXING_NOT_SUPPORTED 400 only fires on ADD attempts. REMOVE attempts on annual subs work fine (the route doesn't gate on interval — it just flips a boolean). So we hide Add CTAs for annual customers (no broken-flow UX) but allow Remove (which works correctly).
Section 7 — Resume-product flow (un-cancel before sweep)
7.1 New API route required
Phase 6 needs a new route: POST /api/stripe/resume-product. Not in the original Phase 5 plan; added per founder approval 2026-04-25.
Behavior:
- POST body:
{ token, product }(same shape as remove-product) - Validates: token resolves to a premium_church, product is valid,
*_cancel_at_period_endis currentlytrue - Action:
UPDATE premium_churches SET {product}_cancel_at_period_end = false WHERE id = ? - No Stripe call (the cron hasn't run yet, item is still in the subscription)
- Returns:
{ success: true, message: "[Product] will continue normally." } - Errors: 400 if product not currently cancelling; 401 invalid token; 429 rate-limited (same 5/min/IP); 403 FEATURE_DISABLED (demo gate)
Idempotency: if customer hits Resume twice, second call sees *_cancel_at_period_end = false and returns 400 "[Product] is already active — no need to resume." (Not destructive; just informative.)
Race vs sweep cron: If the cron fires at :15 past while customer hits Resume at :14:59, Resume wins iff its DB write lands before the cron's row selection. The cron's query filters cancel_at_period_end = true; a Resume that cleared this flag means the row is no longer selected. Convergent. Worst case: customer clicks Resume after the cron already ran — Resume's DB write succeeds (flag flips false) but the Stripe item is already gone. We need to detect this and re-add the item. For Phase 6 v1: simpler — Resume returns 410 "Cancellation already processed. Please re-add [Product] from the Add a service section." Phase 7+ can add auto-recovery.
7.2 Resume button placement
- Primary button on the active-product card when status is "Pending cancellation" (§2.3)
- Label: "Resume [Product]"
7.3 Resume confirmation
- Click triggers a smaller confirmation modal: "Keep Voice active? Your subscription will continue normally. You won't be charged again until your next billing period."
- Buttons: secondary "Never mind" + primary navy "Yes, keep Voice"
7.4 On confirm — success outcome
- Modal closes
- Toast: "Voice resumed. Welcome back!"
- Subscription tab refreshes — card returns to "Active" state (§2.2)
- Email confirmation sent (§8)
Section 8 — Email confirmations
8.1 When emails fire
| Event | Email subject |
|---|---|
| Add succeeds | "[Product] added to your ChurchWiseAI subscription" |
| Cancel succeeds (mark for period-end) | "[Product] cancellation scheduled for [date]" |
| Resume succeeds | "[Product] subscription resumed" |
| Sweep cron drops item (period-end reached) | "[Product] subscription ended" |
8.2 Email content (all four)
- Recipient:
premium_churches.admin_email(the SUBMITTER, perfeedback_submitter_email_privacy.md) - From: john@churchwiseai.com
- Body: 1-paragraph human-voice summary + the relevant action link (manage subscription → Subscription tab)
- Tone: warm but transactional. NOT pastoral/HEAR (same reason as cancel modal copy).
8.3 Implementation
- Resend transactional email (existing pattern)
- One template per event (4 total)
- Lives in
src/lib/email-templates/multi-item-*.ts(TBD)
Section 9 — Cancellation reason survey
9.1 Trigger
Opens automatically after a successful cancellation confirm (§5.3). Modal-style overlay; can be skipped.
9.2 Modal content
- Title: "Help us improve — why are you cancelling [Product]?"
- Subtitle: "Optional — your answer helps us serve churches better. Skip if you'd rather not."
- Multi-select checkboxes (customer can pick multiple):
- Not getting enough use
- Too expensive for our budget
- Found a different tool that fits better
- Our church no longer needs this
- Technical issues / didn't work as expected
- Switching to a different ChurchWiseAI plan
- Other:
[free text input]
- Optional free-text: "Anything else you'd like to share with John (founder)? (Optional)" — multi-line textarea
- Buttons: secondary "Skip" + primary navy "Submit"
9.3 On submit — outcome
- Modal closes
- Toast: "Thank you — your feedback goes straight to John."
- Insert into new
cancellation_reasonstable (Phase 6 migration):CREATE TABLE cancellation_reasons (id uuid PRIMARY KEY DEFAULT gen_random_uuid(),premium_id uuid REFERENCES premium_churches(id),product text NOT NULL CHECK (product IN ('chat', 'voice', 'website')),reasons text[] NOT NULL,other_text text,free_text text,created_at timestamptz NOT NULL DEFAULT now()); - Optional: Slack/email digest to founder weekly summarizing reasons.
9.4 On Skip — outcome
- Modal closes silently. No DB write. No toast.
Section 10 — Tier gating (all tiers can self-serve)
10.1 Rule
Self-serve add, remove, and resume are available at every tier — Starter, Pro, Suite — for both monthly and annual customers (annual customers can remove and resume; they cannot add — see §6).
10.2 Acceptance criteria
- Starter Chat customer can self-serve add Voice → Subscription tab shows Add Voice card → modal flow completes successfully.
- Starter Chat customer can self-serve cancel Chat (only product) → 409 LAST_REMAINING_PRODUCT → inline error redirects to full-account cancel.
- Pro Both customer can self-serve add Website → success.
- Suite Both customer can self-serve cancel Voice → success → "Pending cancellation" badge.
- Annual Chat customer sees NO Add cards but CAN cancel Chat (which is their only product, so 409 redirect).
Section 11 — Test strategy
Tests for this spec live in two layers:
11.1 Component-level (Playwright UI)
File: e2e/multi-item-self-serve.spec.ts (new)
Scenarios to assert (one per acceptance criterion above):
- Subscription tab IA — assert tab name "Subscription" (not "Upgrade") on a fixture church
- Active product card — Active state — emerald pill, "Active" text, green dot, Cancel link visible (small text)
- Active product card — Pending cancellation state — amber pill, "Ends [date]" text, Cancel link HIDDEN, Resume button visible
- Add card — visible only for inactive products — fixture with chat-only sub: Add Voice + Add Website cards visible; Chat card NOT in Add section
- Add card — hidden for annual customers — fixture with chat_annual sub: Add section absent, replaced by annual-mixing note
- Add modal — itemized cost — assert visible "$49.95 setup", "$18.31 prorated voice", "$68.26 total"
- Add modal — success path — POST to
/api/stripe/add-product, assert toast + DB state matches - Add modal — CARD_NEEDS_UPDATE — assert switches to billing portal link modal
- Cancel modal — copy — assert exact body text matches §5.2
- Cancel modal — voice-only addendum — phone number warning paragraph appears for voice cancel, NOT for chat cancel
- Cancel modal — Keep button is primary, Cancel button is secondary destructive-styled
- Cancel modal — LAST_REMAINING_PRODUCT — fixture with voice-only sub: assert modal swaps to account-cancel CTA
- Resume button — flow — fixture with chat+voice, voice cancelled: assert Resume button on voice card → click → toast → card returns to Active
- Cancellation Reason Survey — appears after cancel confirm, can be skipped, persists to DB on submit
- Settings → Billing pointer — link visible, navigates to Subscription tab
11.2 Cross-tier matrix
Run scenarios 1–15 against fixtures for each tier:
chat_starter_monthly— Starter Chatchat_pro_monthly— Pro Chatvoice_starter— Starter Voicechat_starter_plus_voice_starter— Multi-productchat_annual— Annual customer (only the annual-specific scenarios)bundled_website— Pro Website bundled
11.3 Fixture reuse
All fixtures defined in e2e/fixtures/multi-item.fixtures.ts. Demo namespace UUIDs only (per feedback_no_qa_writes_to_prod.md). afterAll destroys all fixtures cleanly.
11.4 Critical-path registration
Update knowledge/tests/registry.yaml:
- name: multi-item-self-serve
critical_path: true
code_files:
- churchwiseai-web/src/app/admin/[token]/components/{AddProductCard,AddProductConfirmModal,RemoveProductCard,RemoveProductConfirmModal,ResumeProductButton,ProductStatusBadge,CancellationReasonSurvey}.tsx
- churchwiseai-web/src/app/admin/[token]/SubscriptionTab.tsx
- churchwiseai-web/src/app/api/stripe/{add-product,remove-product,resume-product}/route.ts
playwright_spec: multi-item-self-serve
notes: customer-money flow; gates merge per CLAUDE.md rule #10
Section 12 — Implementation sequencing (Phase 6 build order)
- Backend first —
POST /api/stripe/resume-productroute (~80 lines, mirrors remove-product shape). - DB migration —
cancellation_reasonstable (~10 lines). - Component primitives —
<ProductStatusBadge>,<CardNeedsUpdateModal>(already in plan §Phase 6). - Add flow components —
<AddProductCard>,<AddProductConfirmModal>(per plan). - Remove flow components —
<RemoveProductCard>(renders cancel link on active card),<RemoveProductConfirmModal>(new — per §5). - Resume flow components —
<ResumeProductButton>(new — per §7). - Cancellation Reason Survey —
<CancellationReasonSurvey>(new — per §9). - Subscription tab page —
<SubscriptionTab>(renames + restructures the existing Upgrade tab page). - Settings → Billing pointer — small link addition.
- Email templates — 4 transactional emails (Resend).
- E2E tests —
multi-item-self-serve.spec.tswith all 15 scenarios across 6 tier fixtures. - Knowledge sync — update existing tier specs (
starter-chat.mdetc.) to reference this spec where they currently say "Upgrade tab".
Section 13 — Out of scope for Phase 6
These deferred to Phase 7 or later:
- Auto-recovery of post-sweep Resume (where the cron already dropped the item — Resume returns 410 instead). Phase 7+ can re-add the item.
- Annual → monthly self-serve migration with credit roll-over. Currently founder-handled via email.
- Cross-tier upgrades within a product (e.g., Starter Chat → Pro Chat in one click). Currently uses the existing
/api/stripe/church-checkoutswap. Resolved 2026-04-26 in Phase B.0 — see Section 14 below. - Trial logic for added products (Phase 4 already correctly skips trial on add-product).
- Bulk/batch operations (no use case at current customer volume).
Section 14 — Change tier (Phase B.0)
Closes the cross-tier-upgrade gap from Section 13.
Surface: ChangeTierModal opened by per-product "Change tier" link on ActiveProductCard (rendered to the LEFT of the existing "Cancel
Available paths:
- Chat: Starter ↔ Pro ↔ Suite (3 rungs)
- Voice: Starter ↔ Pro (2 rungs — no
cwa_suite_voicetier exists) - Website: NOT in scope — reframes to "Add Chat" via existing
/api/stripe/add-productflow
Annual customers: Trigger opens AnnualTierChangeInfoModal pointing at john@churchwiseai.com. No in-app picker. Spec deferred annual self-serve to Section 13 above.
Per-tier observable behavior (founder-verified scenarios)
- Chat Pro → Chat Starter (downgrade): modal opens with Pro marked "Current plan", Starter and Suite selectable. Click Starter → preview shows credit (
-$X.XX) + recurring$14.95/mo+ effective date. Confirm → modal closes after 1500ms success → ActiveProductCard re-renders showing "Starter" label + "$14.95/mo". - Chat Starter → Chat Pro (upgrade): preview shows positive proration → recurring
$34.95/mo. Confirm → success → card shows "Pro" + "$34.95/mo". - Chat Pro → Chat Suite: preview shows positive proration → recurring
$59.95/mo. - Voice Pro → Voice Starter (downgrade): modal opens with 2 tier options only (no Suite). Preview shows credit + recurring
$49.95/mo. - Voice Starter → Voice Pro (upgrade): preview shows positive proration + recurring
$99.95/mo. - Annual customer click:
AnnualTierChangeInfoModalrenders with mailto link tojohn@churchwiseai.com. Esc closes. Picker does NOT render.
Banned outcomes
- Confirm button MUST NOT render with null preview data
- Routes (
/api/stripe/change-tier+/preview) MUST NOT write theplancolumn directly - Routes MUST NOT write
chat_tier/voice_tier/website_tierdirectly (webhook owns) - Modal MUST NOT use
<Link>or<a href>to any/api/...mutation route - Trigger MUST NOT render for
product === 'website' - Trigger MUST NOT render when
pendingCancel === true - Voice tier picker MUST NOT show a Suite option (
cwa_suite_voicedoesn't exist) - Annual customers MUST NOT see the picker (info modal only)
Coverage gaps (for Phase D)
- No
demo-starter-chat-annual-2026fixture exists yet — Scenario 6 istest.skip-gated until Phase D.6 ships the row.
Code references
src/lib/tier-config.ts—tierKeyFor(),tiersAvailableFor(), reusedisSafeLadderTransition()+productFamily()src/app/api/stripe/change-tier/preview/route.ts— read-only previewsrc/app/api/stripe/change-tier/route.ts— mutationsrc/app/admin/[token]/components/ChangeTierModal.tsx— pickersrc/app/admin/[token]/components/AnnualTierChangeInfoModal.tsx— annual infosrc/app/admin/[token]/components/ActiveProductCard.tsx— triggere2e/change-tier-flow.spec.ts— 6 Playwright scenarios (18 tests across 3 viewports)src/app/api/stripe/__tests__/change-tier-preview.contract.test.ts— preview contract testssrc/app/api/stripe/__tests__/change-tier.contract.test.ts— mutation contract tests
Spec + plan
- Spec:
churchwiseai-web/docs/superpowers/specs/2026-04-26-fa-082-change-tier-modal-design.md - Plan:
churchwiseai-web/docs/superpowers/plans/2026-04-26-fa-082-change-tier-modal.md