Feature: Settings → Billing & Plan page (FEAT-104)
Metadata
- Issue ID: FEAT-104
- Status: Done (PR open against
dev) - Owner: beansint
- Related issues: #104
- Builds on: FEAT-31 (cookie auth), FEAT-39 (Stripe + webhooks — the foundation this issue extends), FEAT-40, FEAT-103 Phase 1 (settings shell +
useSubscription()provider).
Overview
Replaces the <ComingSoon /> placeholder at /settings/billing with the full read + lifecycle surface every modern subscription product ships:
- R1 Current plan card — plan name + price, status pill (
active | past_due | canceled | paused | ending), renewal copy, "Manage subscription" via Stripe portal, inline past-due and paused sub-banners. - R2 Plan comparison — 3-card grid (Free / Pro / Plus) with "Current" badge, mobile-friendly horizontal scroll, in-app upgrade/downgrade CTAs that route through proration preview + confirmation dialogs.
- R3 Payment method —
<brand> •••• <last4> · exp mm/yywith "Update" → Stripe portal. Empty state for Free / never-charged users. - R4 Invoices — last 12 invoices; each row links to Stripe's
hosted_invoice_url(built-in email + PDF download). - R5 Cancel plan link — subtle text link routing to the Stripe portal for the destructive cancel flow.
- W1 Auto-renew toggle —
<Switch>on the Current plan card. Flipscancel_at_period_endin Stripe; UI updates to "Plan ends on …" copy. - W2 In-app plan switching with proration —
<Dialog>with livepreviewProrationnumber from Stripe; on confirm callschangePlan. - W4 Pause subscription — 1-cycle fixed pause via
pause_collection.resumes_at. Resume button on the inline paused banner. - W5 Global past-due dunning banner — sticky red banner across every features page when
status === 'past_due'. Dismissable per-session. Message and CTA are conditional: ifrequiresPaymentAction=true(3DS pending), shows "Your payment requires additional verification (3D Secure) — please complete the authentication to keep access." with a "Complete verification" CTA; otherwise shows "We couldn't charge your card — update your payment method to keep access." with an "Update card" CTA. Both CTAs open the Stripe Billing Portal. - W6 Receipt access — invoice rows open Stripe's
hosted_invoice_url(email + download natively). No per-row "Email me" button — Stripe has no clean API to resend a paid invoice receipt, and the hosted page already covers it. - W7 Post-upgrade confirmation banner — 5-second auto-dismissing banner when URL has
?upgraded=<plan_slug>(set bycreateCheckoutSessionForUser'ssuccess_url).
Out of scope (deferred to a later issue):
- W3 Trial conversion — per product review, the free trial is descoped from FEAT-104 and will ship as its own change. No
has_used_trialcolumn, nostart-trialendpoint, notrial_will_endwebhook handler. - W6 per-row email button — Stripe does not expose a clean API to re-send a paid invoice receipt; the hosted invoice page already covers it.
The entire feature layers on top of the existing BillingService pipeline at backend/src/billing/billing.service.ts — no new Stripe client, no parallel cache, no duplicate webhook dispatcher. Every mutation resyncs via the existing syncSubscriptionFromStripe so the local DB row matches Stripe before the controller returns; webhooks remain authoritative.
Frontend Behavior
Routes
| Path | Purpose |
|---|---|
/settings/billing | The new page. Renders R1–R5 + lifecycle controls. |
/pricing | Updated: monthly-only (annual toggle removed) — Pro $10/mo, Plus $20/mo. Paid users now route to /settings/billing instead of the Stripe portal. |
/settings/billing?upgraded=<slug> | Where Stripe Checkout redirects after subscription success. The UpgradeBanner detects the param and shows a 5-second toast-style banner. |
Components
| File | Role |
|---|---|
frontend/app/(features)/settings/billing/page.tsx | Page entry — stacks the 5 sections. |
frontend/app/(features)/settings/billing/_components/current-plan-card.tsx | R1 + W1 + W4 + inline past-due/paused sub-banners. |
frontend/app/(features)/settings/billing/_components/plan-comparison.tsx | R2 — 3-card grid + CTA routing (in-app dialog vs. checkout vs. portal). |
frontend/app/(features)/settings/billing/_components/payment-method-card.tsx | R3 — calls GET /subscriptions/payment-method. |
frontend/app/(features)/settings/billing/_components/invoices-table.tsx | R4 — calls GET /subscriptions/invoices. |
frontend/app/(features)/settings/billing/_components/cancel-plan-link.tsx | R5. |
frontend/app/(features)/settings/billing/_components/change-plan-dialog.tsx | W2 — calls previewProration on mount, changePlan on confirm. |
frontend/app/(features)/settings/billing/_components/pause-dialog.tsx | W4 confirm dialog. |
frontend/components/layout/dunning-banner.tsx | W5 — global past-due banner; mounted in features-layout-client. |
frontend/app/(features)/_components/upgrade-banner.tsx | W7 — Suspense-wrapped, reads ?upgraded=…. |
frontend/lib/plans.ts | Shared PLANS constant — aligned with credits-subscription-plan.md. |
Subscription provider additions
frontend/components/providers/subscription-provider.tsx gains:
- New state field:
pauseCollectionResumesAt: string | null - New context methods:
toggleAutoRenew,previewProration,changePlan,pauseSubscription,resumeSubscription,fetchPaymentMethod,fetchInvoices - All mutations return the refreshed
getMySubscriptionViewshape and callsetState(parseStateFromPayload(data))so a single PATCH/POST response replaces local state — no double round-trip.
Strict edge policy (D6)
| State | Plan change | Auto-renew | Pause |
|---|---|---|---|
active (cancel_at_period_end=false) | ✅ allowed | ✅ allowed | ✅ allowed |
active (cancel_at_period_end=true) | ❌ disabled (must resume first) | ✅ allowed (re-enable) | ❌ disabled |
past_due | ❌ disabled (dunning banner CTA) | ❌ disabled | ❌ disabled |
paused | ❌ disabled | ✅ allowed | ❌ already paused; Resume button shown instead |
canceled / expired | ❌ — must re-subscribe via /pricing | ❌ | ❌ |
Disabled CTAs render with aria-disabled + a title tooltip explaining why ("Resolve payment first").
Loading / empty / error states
| Section | Loading | Empty | Error |
|---|---|---|---|
| R1 | <Skeleton> rows | n/a (always shows current plan) | falls back to defaultState |
| R2 | n/a (PLANS is static) | n/a | n/a |
| R3 | <Skeleton> row | "No payment method on file" + "Add" → portal | "Couldn't load payment method" text |
| R4 | 3 <Skeleton> rows | "No invoices yet" | "Couldn't load invoices" + Retry button |
| W2 dialog | preview skeleton | n/a | "Approximate charge — see Stripe for exact" fallback copy; Confirm still allowed |
Mobile layout (375px viewport)
- All sections stack vertically.
- The plan-comparison grid becomes a horizontal scroll strip (
flex overflow-x-auto snap-x), each card minimum16remwide. - Dunning banner and UpgradeBanner sit above the FeatureHeader and don't push content into a broken state (per QA-25).
Backend Behavior
New endpoints
All on SubscriptionsController under @UseGuards(CookieAuthGuard). All mutations return the refreshed getMySubscriptionView shape.
| Method | Path | Service method (new on BillingService) | Stripe call | Idempotency |
|---|---|---|---|---|
| GET | /subscriptions/payment-method | getDefaultPaymentMethod(userId) | GET /v1/payment_methods?customer=…&type=card&limit=1 | n/a |
| GET | /subscriptions/invoices?limit=12 | listInvoices(userId, limit) | GET /v1/invoices?customer=…&limit=… | n/a |
| PATCH | /subscriptions/auto-renew | setAutoRenew(userId, enabled) | POST /v1/subscriptions/:id w/ cancel_at_period_end | auto-renew-${userId}-${on|off}-${minute} |
| POST | /subscriptions/preview-proration | previewProration(userId, dto) | POST /v1/invoices/create_preview (form-encoded; replaces deprecated GET /v1/invoices/upcoming — migrated in #137) | n/a |
| POST | /subscriptions/change-plan | changePlan(userId, dto) | POST /v1/subscriptions/:id w/ items[0][price]=<new>&proration_behavior=create_prorations | change-plan-${userId}-${slug}-${minute} |
| POST | /subscriptions/pause | pauseSubscription(userId) | POST /v1/subscriptions/:id w/ pause_collection[behavior]=keep_as_draft&pause_collection[resumes_at]=<unix> | pause-${userId}-${minute} |
| POST | /subscriptions/resume | resumeSubscription(userId) | POST /v1/subscriptions/:id w/ pause_collection= (clear) | resume-${userId}-${minute} |
Webhook dispatcher additions
In processStripeEvent the existing switch picks up two new cases:
| Event | Handler |
|---|---|
customer.subscription.paused | Routes through handleSubscriptionUpdated. syncSubscriptionFromStripe persists pause_collection_resumes_at. UI surfaces "paused" state by checking the resume timestamp. |
customer.subscription.resumed | Same — syncSubscriptionFromStripe clears pause_collection_resumes_at from the Stripe payload. |
customer.subscription.updated, invoice.payment_succeeded, invoice.payment_failed, and invoice.payment_action_required were already handled by FEAT-39 — no changes there.
Pre-condition guards (D6)
setAutoRenew, changePlan, pauseSubscription throw BadRequestException when the local row's status !== 'active'. The only allowed flow on a non-active sub is setAutoRenew(true) to revert an end-of-period cancel.
getMySubscriptionView extension
The shape now includes pauseCollectionResumesAt: string | null so the provider can render the inline paused banner without an extra round-trip.
Checkout success URL — W7 wiring
createCheckoutSessionForUser now appends &upgraded=<plan_slug> to whatever STRIPE_CHECKOUT_SUCCESS_URL is configured to. Recommended success URL: ${FRONTEND_URL}/settings/billing so the new UpgradeBanner mounts on the features layout right next to the new R1 card.
Data model
ALTER TABLE user_subscriptions
ADD COLUMN IF NOT EXISTS pause_collection_resumes_at TIMESTAMP NULL;
Migration: backend/prisma/migrations/202605190001_add_subscription_pause_field/migration.sql. Nullable, no FK — safe to drop on revert.
Catalog seeding (one-time ops)
Stripe test mode and the v2 Neon branch ship with the Free plan only. FEAT-104 seeds Pro + Plus during the PR:
| Plan | Stripe Product | Stripe Price (monthly) | DB row |
|---|---|---|---|
| Pro | prod_UXahwNCWUvYke0 | price_1TYVWXLr9KdZFQwSIRbjqPDS ($10/mo) | inserted into subscription_plan_prices |
| Plus | prod_UXahEU7dnx2GAC | price_1TYVWZLr9KdZFQwS2669QZyr ($20/mo) | inserted into subscription_plan_prices |
See Notes → How to add or re-price a plan below for the live-mode runbook.
Failure modes
| Failure | Behavior |
|---|---|
Stripe previewProration 5xx | Dialog shows fallback copy; Confirm still enabled (QA-21). |
| Stripe network failure during mutation | requestStripe retries up to 3× with backoff. After exhaustion, controller returns 500 and the UI surfaces a toast.error with the message. |
| Webhook arrives between PATCH response and frontend render | Webhook is authoritative via stripe_updated_at. syncSubscriptionFromStripe already serializes via existing pattern. |
| Concurrent auto-renew double-click | Same idempotency key inside the 1-minute window; only one Stripe write. |
| User in Free tier hits payment-method endpoint | Returns null (200) without calling Stripe — cheap empty state. |
QA Test Scenarios
| Scenario ID | Description | Steps | Expected Result |
|---|---|---|---|
| FEAT-104-01 | Free user opens /settings/billing | Sign in as Free user → click "Billing & Plan" in settings sub-nav | R1 shows "Basic" + $0; R2 grid shows all 3 plans with Free as Current; R3 empty state; R4 empty state; cancel link hidden |
| FEAT-104-02 | Free user upgrades to Pro via R2 | Click "Upgrade to Pro" → redirected to Stripe Checkout → pay with test card 4242 4242 4242 4242 → redirected back to /settings/billing?upgraded=pro | UpgradeBanner renders for 5s; R1 shows "Pro · $10/mo"; R3 populated; new row in user_subscriptions with is_active=true, plan_slug=pro |
| FEAT-104-03 | Pro user opens /settings/billing | Sign in as the Pro user from QA-02 | R1 shows Pro + renewal date + auto-renew=ON; R3 shows brand+last4; R4 lists the most recent invoice with hosted_invoice_url link |
| FEAT-104-04 | Pro user toggles auto-renew off | Flip the W1 Switch off | PATCH /subscriptions/auto-renew returns 200; Stripe sub has cancel_at_period_end=true; R1 copy updates to "Plan ends on …"; toast confirms |
| FEAT-104-05 | Pro user toggles auto-renew back on | Flip W1 back on | PATCH returns 200; Stripe cancel_at_period_end=false; R1 copy reverts to "Renews on …" |
| FEAT-104-06 | Pro user upgrades to Plus mid-cycle | Click "Upgrade to Plus" in R2 → ChangePlanDialog shows real proration $X.XX from invoices/upcoming → Confirm | POST /subscriptions/change-plan returns the new view with planSlug=plus; Stripe sub item swapped; current cycle credits carry forward (no reset) |
| FEAT-104-07 | Plus user downgrades to Pro | Click "Downgrade to Pro" → dialog shows credit-on-next-invoice copy → Confirm | POST /subscriptions/change-plan returns new view with planSlug=pro; Stripe credit issued for unused Plus portion |
| FEAT-104-08 | Pro user clicks "Downgrade to Free" | Click "Downgrade to Free" CTA in R2 | changePlan('free') resolves to setAutoRenew(false) (D6 rule); R1 shows "Plan ends on …" |
| FEAT-104-09 | Pro user opens Pause dialog | Click "Need a break?" link → confirm | POST /subscriptions/pause returns 200; pause_collection_resumes_at populated; R1 shows paused sub-banner + "Resume now" button |
| FEAT-104-10 | Pro user resumes paused subscription | Click "Resume now" on the inline banner | POST /subscriptions/resume returns 200; pause_collection_resumes_at=null; R1 shows active state |
| FEAT-104-11 | Subscription in past_due (payment failure) — dunning banner | Force user_subscriptions.status='past_due', status_reason='payment_failed' via SQL → reload any features page | DunningBanner visible above header on every features page; shows "We couldn't charge your card — update your payment method"; "Update card" CTA → portal |
| FEAT-104-12 | User in past_due dismisses dunning banner | Click the X | Banner disappears for the session; persists in sessionStorage under dunning-dismissed-<period_end> |
| FEAT-104-13 | Past-due user attempts plan change | Click any R2 upgrade/downgrade CTA | Buttons are aria-disabled with tooltip "Resolve payment first" — no API call |
| FEAT-104-14 | Past-due user toggles auto-renew | Flip W1 | Switch is disabled — no PATCH issued |
| FEAT-104-15 | Webhook signature validation | Replay an already-processed event | billing_webhook_events.event_id unique constraint blocks re-processing — no duplicate side effects |
| FEAT-104-16 | customer.subscription.paused webhook arrival | stripe trigger customer.subscription.paused while listening | Local row's pause_collection_resumes_at matches Stripe pause_collection.resumes_at within 2s |
| FEAT-104-17 | customer.subscription.resumed webhook arrival | stripe trigger customer.subscription.resumed | Local pause_collection_resumes_at=null |
| FEAT-104-18 | Invoices list — empty state | Sign in as Free user with no Stripe customer | GET /subscriptions/invoices returns []; R4 shows "No invoices yet" |
| FEAT-104-19 | Invoices list — Stripe 5xx | Inject 500 from Stripe via test runner | R4 shows error state with Retry button; clicking Retry recovers |
| FEAT-104-20 | Click an invoice PDF link | Click an invoice row | New tab opens at Stripe's hosted_invoice_url; built-in email + PDF download visible there |
| FEAT-104-21 | previewProration Stripe outage | Force the upcoming-invoice call to fail | Dialog shows fallback "Approximate charge — see Stripe for exact"; Confirm still enabled |
| FEAT-104-22 | Concurrent auto-renew toggle | Promise.all([click,click,click]) within 1s | Final Stripe sub state matches last click; idempotency key prevents duplicate writes |
| FEAT-104-23 | A11y keyboard nav | Tab from page top to bottom on /settings/billing | Every interactive element reachable; focus ring visible; dialog focus trap works; Esc closes dialogs |
| FEAT-104-24 | Mobile viewport (375px) on /settings/billing | Chrome DevTools emulate "iPhone 14" | R1+R3+R4 stack vertically; R2 becomes horizontal scroll strip; banners don't break layout |
| FEAT-104-25 | UpgradeBanner auto-dismiss | Land on /settings/billing?upgraded=pro | Banner visible for ~5s, then disappears; URL param stripped via router.replace |
| FEAT-104-26 | Different currency invoice | Force an invoice with currency: 'eur' | Row renders with € prefix via Intl.NumberFormat, not hardcoded $ |
| FEAT-104-27 | Unauthorized request to lifecycle endpoint | Hit PATCH /subscriptions/auto-renew without cookies | 401 from CookieAuthGuard; UI redirects to /login?next=/pricing |
| FEAT-104-28 | requiresPaymentAction from 3DS renewal — dunning banner 3DS message | Trigger invoice.payment_action_required | Global dunning banner shows "Your payment requires additional verification (3D Secure)" with "Complete verification" CTA → portal; requiresPaymentAction=true on subscription read model |
| FEAT-104-28b | requiresPaymentAction preserved after subsequent subscription update | Trigger invoice.payment_action_required then customer.subscription.updated | status_reason remains payment_action_required; dunning banner still shows 3DS message (not generic "Update card") |
| FEAT-104-29 | Backend boots clean after migration | pnpm migrate deploy then start | Column exists; existing rows have NULL; no startup errors |
| FEAT-104-30 | Backend unit tests pass | pnpm test -- billing.service && pnpm test -- subscriptions.service | All cases for new methods green incl. strict guards |
Edge Cases
- No Stripe customer record (Free tier, never checked out) — both payment-method and invoices endpoints return
null/[]without calling Stripe. UI shows empty states. - Stripe outage on read endpoints — graceful "Couldn't load …" copy with Retry; page still renders.
- Invoice with
nullhosted_invoice_url` — row is non-clickable (no broken anchor). - Different currency — invoice rows format via
Intl.NumberFormat(currency); no hardcoded$. - Localised renewal date — uses the browser's
toLocaleDateString(), noten-US. - Concurrent change-plan + webhook — webhook is authoritative;
stripe_updated_atensures the latest Stripe state wins. customer.subscription.updatedafterinvoice.payment_action_required— Stripe often fires both events for the same payment attempt.syncSubscriptionFromStripepreserves the existingstatus_reasonwhen the incoming value is null and the new local status is notactive, sopayment_action_requiredis not silently overwritten. The dunning banner continues to show the 3DS message rather than reverting to the generic payment-failure copy.- Pause + auto-renew off attempted together — the W1 toggle is hidden during pause and the pause link is hidden when
cancel_at_period_end=true, preventing the dual state by construction. - Server-side retry of
change-plan— idempotent in Stripe via the 1-minute idempotency-key window. - User signs in mid-billing-cycle on a
past_duesub — DunningBanner appears on first navigation to any features page even if/settings/billinghasn't been opened yet. - Plan price changes upstream — admin updates
subscription_plan_prices.stripe_price_idper the runbook below; new subscriptions land on the new price, existing subs keep theirs until they switch.
Notes
How to add or re-price a plan (admin runbook)
Stripe Price objects are immutable — you don't edit them, you create a new one and archive the old one. The DB row in subscription_plan_prices is the link.
- Create a new Stripe price for an existing product:
stripe prices create --product=prod_XXX --unit-amount=1500 --currency=usd --recurring="interval=month" # → price_NEW_ID - Update the DB:
UPDATE subscription_plan_prices SET stripe_price_id = 'price_NEW_ID', price = 15.00 WHERE plan_slug = 'pro' AND billing_interval = 'month'; - Archive the old Stripe price (Dashboard → Products → Pro → Prices → ⋯ → Archive).
- Existing customers stay on their old price until they switch plans (via in-app upgrade/downgrade) — this is intentional and matches Notion/Vercel-style price grandfathering.
To change Pro/Plus credit caps, see credits-subscription-plan.md.
Stripe Dashboard receipt setting (one-time ops)
The "Email me this receipt" expectation is satisfied by Stripe Dashboard's Settings → Customer emails → Successful payments / Refunds. Enable once per account so every successful charge auto-emails a receipt. The invoice row in R4 also opens Stripe's hosted invoice page where users can self-serve another copy.
Webhook events needing forwarding for local dev
stripe listen --forward-to localhost:3001/billing/stripe/webhook \
--events customer.subscription.updated,customer.subscription.paused,customer.subscription.resumed,invoice.payment_succeeded,invoice.payment_failed,invoice.payment_action_required,checkout.session.completed,customer.subscription.deleted
Feature flags
None — FEAT-104 ships behind no flag. The strict-guard policy is the gating mechanism (operations on non-active subs throw BadRequestException).
Known limitations
- Annual billing intervals are not exposed in the UI; the schema column exists for future use.
- Free trials are descoped from this issue and will ship as a separate change.
- The proration preview calls
POST /v1/invoices/create_preview(form-encoded). The deprecatedGET /v1/invoices/upcomingwas replaced in #137 because the old endpoint was removed from the Stripe API after 2024-09-30.