Skip to main content

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).

DecisionChoiceWhy
Canonical home for add + removeOne 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 weightSecondary text-link, not primary buttonApple App Store + Spotify pattern. Recoverable but de-emphasized to prevent fat-finger churn.
Cancel modal toneDirect + warm, no HEAR/pastoral framingPastoral 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 cancelSurface 30-day Telnyx hold + re-keep windowTwilio + Google Voice surface this. Customers hate losing phone numbers more than losing service. Trust > brevity.
LAST_REMAINING_PRODUCT 409 UXInline modal error → single CTA to full-account cancelStripe Billing pattern. Auto-redirect is jarring; inline-cancel-account skips intent re-confirmation.
ANNUAL_MIXING_NOT_SUPPORTED 400 UXHide Add CTAs entirely for annual customersIndustry pattern when feature unavailable: hide, don't show-then-error. Avoids broken-flow UX.
Pending-cancellation badge colorAmber/yellow-warning (not red)Cancellation is not an error state. Amber matches Apple/Spotify/Netflix.
Pending-cancellation badge text"Ends [Oct 12]" with concrete dateScannable, actionable. "Pending cancellation" is vague; "Active until X" is too soft.
Resume (un-cancel) before sweep runsYES, prominent button on the badgeApple/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-serveAll 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 AddItemized line-by-lineStripe / Cal.com / Apple all itemize. Hidden combined totals → post-purchase regret + chargebacks.
Cancellation-reason captureOptional 1-screen survey post-confirmSpotify / Netflix / Notion all do this. Highest-leverage data for solo-founder learning why customers churn.
Email confirmation on add/removeAlways sentStripe Billing sends automatically; we mirror. Trust + paper trail.
Settings → Billing entry pointPointer link to Subscription tabCustomers 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)

  1. 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).
  2. Add a service — one card per inactive product. Hidden if customer has all three active. Hidden entirely for annual customers (see §6).
  3. Compare plans — link to /pricing (existing).
  4. 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

StatePill colorPill textTrigger
ActiveEmerald (green)"Active" with green dothas_X_subscription = true AND X_cancel_at_period_end = false
Pending cancellationAmber/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 responseUX
200 + success: trueToast + refresh (§4.3)
402 + code: CARD_NEEDS_UPDATE + billing_portal_urlModal 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_SUPPORTEDShould 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_ACTIVEShould 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 limitedInline modal error: "Too many attempts. Please wait a minute and try again."
500 / unknownInline 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 responseUX
200 + success: trueToast + refresh + survey (§5.3)
409 + code: LAST_REMAINING_PRODUCT + redirect_urlModal 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_DISABLEDShould 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 / unknownSame 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_end is currently true
  • 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

EventEmail 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, per feedback_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_reasons table (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):

  1. Subscription tab IA — assert tab name "Subscription" (not "Upgrade") on a fixture church
  2. Active product card — Active state — emerald pill, "Active" text, green dot, Cancel link visible (small text)
  3. Active product card — Pending cancellation state — amber pill, "Ends [date]" text, Cancel link HIDDEN, Resume button visible
  4. Add card — visible only for inactive products — fixture with chat-only sub: Add Voice + Add Website cards visible; Chat card NOT in Add section
  5. Add card — hidden for annual customers — fixture with chat_annual sub: Add section absent, replaced by annual-mixing note
  6. Add modal — itemized cost — assert visible "$49.95 setup", "$18.31 prorated voice", "$68.26 total"
  7. Add modal — success path — POST to /api/stripe/add-product, assert toast + DB state matches
  8. Add modal — CARD_NEEDS_UPDATE — assert switches to billing portal link modal
  9. Cancel modal — copy — assert exact body text matches §5.2
  10. Cancel modal — voice-only addendum — phone number warning paragraph appears for voice cancel, NOT for chat cancel
  11. Cancel modal — Keep button is primary, Cancel button is secondary destructive-styled
  12. Cancel modal — LAST_REMAINING_PRODUCT — fixture with voice-only sub: assert modal swaps to account-cancel CTA
  13. Resume button — flow — fixture with chat+voice, voice cancelled: assert Resume button on voice card → click → toast → card returns to Active
  14. Cancellation Reason Survey — appears after cancel confirm, can be skipped, persists to DB on submit
  15. 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 Chat
  • chat_pro_monthly — Pro Chat
  • voice_starter — Starter Voice
  • chat_starter_plus_voice_starter — Multi-product
  • chat_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)

  1. Backend firstPOST /api/stripe/resume-product route (~80 lines, mirrors remove-product shape).
  2. DB migrationcancellation_reasons table (~10 lines).
  3. Component primitives<ProductStatusBadge>, <CardNeedsUpdateModal> (already in plan §Phase 6).
  4. Add flow components<AddProductCard>, <AddProductConfirmModal> (per plan).
  5. Remove flow components<RemoveProductCard> (renders cancel link on active card), <RemoveProductConfirmModal> (new — per §5).
  6. Resume flow components<ResumeProductButton> (new — per §7).
  7. Cancellation Reason Survey<CancellationReasonSurvey> (new — per §9).
  8. Subscription tab page<SubscriptionTab> (renames + restructures the existing Upgrade tab page).
  9. Settings → Billing pointer — small link addition.
  10. Email templates — 4 transactional emails (Resend).
  11. E2E testsmulti-item-self-serve.spec.ts with all 15 scenarios across 6 tier fixtures.
  12. Knowledge sync — update existing tier specs (starter-chat.md etc.) 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-checkout swap. 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 " link).

Available paths:

  • Chat: Starter ↔ Pro ↔ Suite (3 rungs)
  • Voice: Starter ↔ Pro (2 rungs — no cwa_suite_voice tier exists)
  • Website: NOT in scope — reframes to "Add Chat" via existing /api/stripe/add-product flow

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)

  1. 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".
  2. Chat Starter → Chat Pro (upgrade): preview shows positive proration → recurring $34.95/mo. Confirm → success → card shows "Pro" + "$34.95/mo".
  3. Chat Pro → Chat Suite: preview shows positive proration → recurring $59.95/mo.
  4. Voice Pro → Voice Starter (downgrade): modal opens with 2 tier options only (no Suite). Preview shows credit + recurring $49.95/mo.
  5. Voice Starter → Voice Pro (upgrade): preview shows positive proration + recurring $99.95/mo.
  6. Annual customer click: AnnualTierChangeInfoModal renders with mailto link to john@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 the plan column directly
  • Routes MUST NOT write chat_tier/voice_tier/website_tier directly (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_voice doesn't exist)
  • Annual customers MUST NOT see the picker (info modal only)

Coverage gaps (for Phase D)

  • No demo-starter-chat-annual-2026 fixture exists yet — Scenario 6 is test.skip-gated until Phase D.6 ships the row.

Code references

  • src/lib/tier-config.tstierKeyFor(), tiersAvailableFor(), reused isSafeLadderTransition() + productFamily()
  • src/app/api/stripe/change-tier/preview/route.ts — read-only preview
  • src/app/api/stripe/change-tier/route.ts — mutation
  • src/app/admin/[token]/components/ChangeTierModal.tsx — picker
  • src/app/admin/[token]/components/AnnualTierChangeInfoModal.tsx — annual info
  • src/app/admin/[token]/components/ActiveProductCard.tsx — trigger
  • e2e/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 tests
  • src/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