StudyBoost Docs

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/yy with "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. Flips cancel_at_period_end in Stripe; UI updates to "Plan ends on …" copy.
  • W2 In-app plan switching with proration<Dialog> with live previewProration number from Stripe; on confirm calls changePlan.
  • 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: if requiresPaymentAction=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 by createCheckoutSessionForUser's success_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_trial column, no start-trial endpoint, no trial_will_end webhook 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

PathPurpose
/settings/billingThe new page. Renders R1–R5 + lifecycle controls.
/pricingUpdated: 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

FileRole
frontend/app/(features)/settings/billing/page.tsxPage entry — stacks the 5 sections.
frontend/app/(features)/settings/billing/_components/current-plan-card.tsxR1 + W1 + W4 + inline past-due/paused sub-banners.
frontend/app/(features)/settings/billing/_components/plan-comparison.tsxR2 — 3-card grid + CTA routing (in-app dialog vs. checkout vs. portal).
frontend/app/(features)/settings/billing/_components/payment-method-card.tsxR3 — calls GET /subscriptions/payment-method.
frontend/app/(features)/settings/billing/_components/invoices-table.tsxR4 — calls GET /subscriptions/invoices.
frontend/app/(features)/settings/billing/_components/cancel-plan-link.tsxR5.
frontend/app/(features)/settings/billing/_components/change-plan-dialog.tsxW2 — calls previewProration on mount, changePlan on confirm.
frontend/app/(features)/settings/billing/_components/pause-dialog.tsxW4 confirm dialog.
frontend/components/layout/dunning-banner.tsxW5 — global past-due banner; mounted in features-layout-client.
frontend/app/(features)/_components/upgrade-banner.tsxW7 — Suspense-wrapped, reads ?upgraded=….
frontend/lib/plans.tsShared 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 getMySubscriptionView shape and call setState(parseStateFromPayload(data)) so a single PATCH/POST response replaces local state — no double round-trip.

Strict edge policy (D6)

StatePlan changeAuto-renewPause
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

SectionLoadingEmptyError
R1<Skeleton> rowsn/a (always shows current plan)falls back to defaultState
R2n/a (PLANS is static)n/an/a
R3<Skeleton> row"No payment method on file" + "Add" → portal"Couldn't load payment method" text
R43 <Skeleton> rows"No invoices yet""Couldn't load invoices" + Retry button
W2 dialogpreview skeletonn/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 minimum 16rem wide.
  • 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.

MethodPathService method (new on BillingService)Stripe callIdempotency
GET/subscriptions/payment-methodgetDefaultPaymentMethod(userId)GET /v1/payment_methods?customer=…&type=card&limit=1n/a
GET/subscriptions/invoices?limit=12listInvoices(userId, limit)GET /v1/invoices?customer=…&limit=…n/a
PATCH/subscriptions/auto-renewsetAutoRenew(userId, enabled)POST /v1/subscriptions/:id w/ cancel_at_period_endauto-renew-${userId}-${on|off}-${minute}
POST/subscriptions/preview-prorationpreviewProration(userId, dto)POST /v1/invoices/create_preview (form-encoded; replaces deprecated GET /v1/invoices/upcoming — migrated in #137)n/a
POST/subscriptions/change-planchangePlan(userId, dto)POST /v1/subscriptions/:id w/ items[0][price]=<new>&proration_behavior=create_prorationschange-plan-${userId}-${slug}-${minute}
POST/subscriptions/pausepauseSubscription(userId)POST /v1/subscriptions/:id w/ pause_collection[behavior]=keep_as_draft&pause_collection[resumes_at]=<unix>pause-${userId}-${minute}
POST/subscriptions/resumeresumeSubscription(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:

EventHandler
customer.subscription.pausedRoutes through handleSubscriptionUpdated. syncSubscriptionFromStripe persists pause_collection_resumes_at. UI surfaces "paused" state by checking the resume timestamp.
customer.subscription.resumedSame — 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:

PlanStripe ProductStripe Price (monthly)DB row
Proprod_UXahwNCWUvYke0price_1TYVWXLr9KdZFQwSIRbjqPDS ($10/mo)inserted into subscription_plan_prices
Plusprod_UXahEU7dnx2GACprice_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

FailureBehavior
Stripe previewProration 5xxDialog shows fallback copy; Confirm still enabled (QA-21).
Stripe network failure during mutationrequestStripe 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 renderWebhook is authoritative via stripe_updated_at. syncSubscriptionFromStripe already serializes via existing pattern.
Concurrent auto-renew double-clickSame idempotency key inside the 1-minute window; only one Stripe write.
User in Free tier hits payment-method endpointReturns null (200) without calling Stripe — cheap empty state.

QA Test Scenarios

Scenario IDDescriptionStepsExpected Result
FEAT-104-01Free user opens /settings/billingSign in as Free user → click "Billing & Plan" in settings sub-navR1 shows "Basic" + $0; R2 grid shows all 3 plans with Free as Current; R3 empty state; R4 empty state; cancel link hidden
FEAT-104-02Free user upgrades to Pro via R2Click "Upgrade to Pro" → redirected to Stripe Checkout → pay with test card 4242 4242 4242 4242 → redirected back to /settings/billing?upgraded=proUpgradeBanner renders for 5s; R1 shows "Pro · $10/mo"; R3 populated; new row in user_subscriptions with is_active=true, plan_slug=pro
FEAT-104-03Pro user opens /settings/billingSign in as the Pro user from QA-02R1 shows Pro + renewal date + auto-renew=ON; R3 shows brand+last4; R4 lists the most recent invoice with hosted_invoice_url link
FEAT-104-04Pro user toggles auto-renew offFlip the W1 Switch offPATCH /subscriptions/auto-renew returns 200; Stripe sub has cancel_at_period_end=true; R1 copy updates to "Plan ends on …"; toast confirms
FEAT-104-05Pro user toggles auto-renew back onFlip W1 back onPATCH returns 200; Stripe cancel_at_period_end=false; R1 copy reverts to "Renews on …"
FEAT-104-06Pro user upgrades to Plus mid-cycleClick "Upgrade to Plus" in R2 → ChangePlanDialog shows real proration $X.XX from invoices/upcoming → ConfirmPOST /subscriptions/change-plan returns the new view with planSlug=plus; Stripe sub item swapped; current cycle credits carry forward (no reset)
FEAT-104-07Plus user downgrades to ProClick "Downgrade to Pro" → dialog shows credit-on-next-invoice copy → ConfirmPOST /subscriptions/change-plan returns new view with planSlug=pro; Stripe credit issued for unused Plus portion
FEAT-104-08Pro user clicks "Downgrade to Free"Click "Downgrade to Free" CTA in R2changePlan('free') resolves to setAutoRenew(false) (D6 rule); R1 shows "Plan ends on …"
FEAT-104-09Pro user opens Pause dialogClick "Need a break?" link → confirmPOST /subscriptions/pause returns 200; pause_collection_resumes_at populated; R1 shows paused sub-banner + "Resume now" button
FEAT-104-10Pro user resumes paused subscriptionClick "Resume now" on the inline bannerPOST /subscriptions/resume returns 200; pause_collection_resumes_at=null; R1 shows active state
FEAT-104-11Subscription in past_due (payment failure) — dunning bannerForce user_subscriptions.status='past_due', status_reason='payment_failed' via SQL → reload any features pageDunningBanner visible above header on every features page; shows "We couldn't charge your card — update your payment method"; "Update card" CTA → portal
FEAT-104-12User in past_due dismisses dunning bannerClick the XBanner disappears for the session; persists in sessionStorage under dunning-dismissed-<period_end>
FEAT-104-13Past-due user attempts plan changeClick any R2 upgrade/downgrade CTAButtons are aria-disabled with tooltip "Resolve payment first" — no API call
FEAT-104-14Past-due user toggles auto-renewFlip W1Switch is disabled — no PATCH issued
FEAT-104-15Webhook signature validationReplay an already-processed eventbilling_webhook_events.event_id unique constraint blocks re-processing — no duplicate side effects
FEAT-104-16customer.subscription.paused webhook arrivalstripe trigger customer.subscription.paused while listeningLocal row's pause_collection_resumes_at matches Stripe pause_collection.resumes_at within 2s
FEAT-104-17customer.subscription.resumed webhook arrivalstripe trigger customer.subscription.resumedLocal pause_collection_resumes_at=null
FEAT-104-18Invoices list — empty stateSign in as Free user with no Stripe customerGET /subscriptions/invoices returns []; R4 shows "No invoices yet"
FEAT-104-19Invoices list — Stripe 5xxInject 500 from Stripe via test runnerR4 shows error state with Retry button; clicking Retry recovers
FEAT-104-20Click an invoice PDF linkClick an invoice rowNew tab opens at Stripe's hosted_invoice_url; built-in email + PDF download visible there
FEAT-104-21previewProration Stripe outageForce the upcoming-invoice call to failDialog shows fallback "Approximate charge — see Stripe for exact"; Confirm still enabled
FEAT-104-22Concurrent auto-renew togglePromise.all([click,click,click]) within 1sFinal Stripe sub state matches last click; idempotency key prevents duplicate writes
FEAT-104-23A11y keyboard navTab from page top to bottom on /settings/billingEvery interactive element reachable; focus ring visible; dialog focus trap works; Esc closes dialogs
FEAT-104-24Mobile viewport (375px) on /settings/billingChrome DevTools emulate "iPhone 14"R1+R3+R4 stack vertically; R2 becomes horizontal scroll strip; banners don't break layout
FEAT-104-25UpgradeBanner auto-dismissLand on /settings/billing?upgraded=proBanner visible for ~5s, then disappears; URL param stripped via router.replace
FEAT-104-26Different currency invoiceForce an invoice with currency: 'eur'Row renders with prefix via Intl.NumberFormat, not hardcoded $
FEAT-104-27Unauthorized request to lifecycle endpointHit PATCH /subscriptions/auto-renew without cookies401 from CookieAuthGuard; UI redirects to /login?next=/pricing
FEAT-104-28requiresPaymentAction from 3DS renewal — dunning banner 3DS messageTrigger invoice.payment_action_requiredGlobal dunning banner shows "Your payment requires additional verification (3D Secure)" with "Complete verification" CTA → portal; requiresPaymentAction=true on subscription read model
FEAT-104-28brequiresPaymentAction preserved after subsequent subscription updateTrigger invoice.payment_action_required then customer.subscription.updatedstatus_reason remains payment_action_required; dunning banner still shows 3DS message (not generic "Update card")
FEAT-104-29Backend boots clean after migrationpnpm migrate deploy then startColumn exists; existing rows have NULL; no startup errors
FEAT-104-30Backend unit tests passpnpm test -- billing.service && pnpm test -- subscriptions.serviceAll 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 null hosted_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(), not en-US.
  • Concurrent change-plan + webhook — webhook is authoritative; stripe_updated_at ensures the latest Stripe state wins.
  • customer.subscription.updated after invoice.payment_action_required — Stripe often fires both events for the same payment attempt. syncSubscriptionFromStripe preserves the existing status_reason when the incoming value is null and the new local status is not active, so payment_action_required is 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_due sub — DunningBanner appears on first navigation to any features page even if /settings/billing hasn't been opened yet.
  • Plan price changes upstream — admin updates subscription_plan_prices.stripe_price_id per 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.

  1. 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
    
  2. 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';
    
  3. Archive the old Stripe price (Dashboard → Products → Pro → Prices → ⋯ → Archive).
  4. 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 deprecated GET /v1/invoices/upcoming was replaced in #137 because the old endpoint was removed from the Stripe API after 2024-09-30.