StudyBoost Docs

Feature: Stripe Subscription Billing Foundation (FEAT-39)

Metadata

  • Issue ID: FEAT-39
  • Status: In Progress
  • Owner: beansint
  • Related PRs: ticket branch 39, #181 (3DS resilience fixes)

Overview

Integrates Stripe as the subscription billing provider using hosted Checkout for subscription initiation and Stripe Billing Portal for lifecycle management. Webhooks are the authoritative source of subscription state and entitlement synchronization.


Frontend Behavior

  • Pricing page creates Stripe Checkout sessions for pro and plus plans.
  • Existing paid users are redirected to Stripe Billing Portal for plan updates, cancellation, and payment-method management.
  • Subscription provider reads GET /subscriptions/my-subscription and hydrates entitlement state (status, requiresPaymentAction, limits, usage).
  • When requiresPaymentAction=true (3DS required on renewal/off-session), the global dunning banner changes its message and CTA: shows "Your payment requires additional verification (3D Secure) — please complete the authentication to keep access." with a "Complete verification" button instead of the generic "Update card" message. The pricing page also displays a portal CTA.
  • Sidebar billing action routes free users to pricing and paid users to Billing Portal.

Backend Behavior

  • New endpoints:
    • POST /subscriptions/checkout-session (auth required)
    • POST /subscriptions/billing-portal-session (auth required)
    • POST /billing/stripe/webhook (signature-verified public endpoint)
  • Stripe customer creation is automatic on first checkout if users.stripe_customer_id is empty.
  • Webhook processing is idempotent via billing_webhook_events (event_id unique).
  • Implemented webhook event handlers:
    • checkout.session.completed
    • invoice.payment_succeeded
    • invoice.payment_failed
    • invoice.payment_action_required
    • customer.subscription.updated
    • customer.subscription.deleted
    • customer.subscription.paused (extended by FEAT-104)
    • customer.subscription.resumed (extended by FEAT-104)
    • customer.subscription.trial_will_end (fires 3 days before trial end; routes to handleSubscriptionUpdated — added in #137)
  • Webhook reliability hardening (#137):
    • Livemode guard: events are validated against NODE_ENV before being persisted. Events with the wrong livemode flag are silently 2xx-d and not stored. Events without a livemode field (older Stripe API versions) pass through unblocked.
    • Sync fallback: when BULLMQ_REDIS_URL is not configured (e.g. preview deployments), webhooks are processed synchronously inside the HTTP request instead of being queued. The DB row is already persisted; the sync call moves it to processed or failed.
  • Status mapping:
    • Stripe active / trialing -> local active
    • Stripe past_due / unpaid / incomplete -> local past_due
    • Stripe canceled -> local canceled
    • Stripe incomplete_expired / paused / (any other) -> local expired
    • incomplete maps to past_due (not expired) because the subscription is awaiting a pending payment action (e.g. 3DS challenge) rather than definitively lapsed. Access is still denied — PlanAccessGuard blocks any non-active status.
  • PlanAccessGuard explicitly enforces status=active via subscription read model.
  • SCA / 3DS explicit opt-in: both checkout session builders (createCheckoutSessionForUser for subscriptions, createDocumentPurchaseCheckoutSession for one-time document purchases) include an explicit request_three_d_secure=automatic param. Note the parameter path differs by session mode:
    • mode=subscription: payment_method_options[card][request_three_d_secure]=automatic
    • mode=payment: payment_intent_data[payment_method_options][card][request_three_d_secure]=automatic
  • status_reason preservation on subscription updates: syncSubscriptionFromStripe's SQL UPDATE uses CASE WHEN logic to preserve a non-null status_reason (e.g. payment_action_required) when handleSubscriptionUpdated passes statusReason: null. The column is only cleared when the subscription transitions to active (payment resolved). This prevents a customer.subscription.updated event from clobbering a payment_action_required value written by invoice.payment_action_required.

Current V2 Data Model (Post-Legacy Cleanup)

  • subscription_plans is removed from runtime flow.
  • Plan catalog + Stripe price mapping is sourced from subscription_plan_prices.
  • User entitlement state is sourced from user_subscriptions (status, plan slug/name, limits, Stripe refs).
  • Per-cycle consumption is sourced from subscription_usages.

Is this standard?

  • Yes. This follows standard Stripe subscription architecture:
    • Stripe webhooks are source of truth.
    • Checkout + Billing Portal own billing lifecycle UX.
    • Local entitlement/usage read model powers authorization and fast UI hydration.

Production Hardening Checklist

  • Add/verify DB indexes for user_subscriptions and Stripe ID lookup fields.
  • Enforce one-active-subscription-per-user invariant at DB level.
  • Keep webhook retry + alerting path for persistent failures.
  • Monitor status-transition anomalies and webhook replay volume.

QA Test Scenarios

Scenario IDDescriptionStepsInputExpected Result
FEAT-39-01Subscribe happy path (hosted checkout)Login -> open pricing -> choose Pro -> redirect to Stripe Checkout -> complete payment -> webhook deliveryValid authenticated user, valid Stripe test cardCheckout succeeds, webhook marks local subscription active, my-subscription reflects active status
FEAT-39-02Unauthorized checkout session requestCall POST /subscriptions/checkout-session without auth cookiesNo auth cookies401/unauthorized response; no Stripe session created
FEAT-39-03Invalid checkout payload rejectedCall checkout endpoint with missing/invalid billingInterval{ planSlug: "pro", billingInterval: "weekly" }400 validation error
FEAT-39-04Webhook signature validationCall webhook endpoint with tampered signatureValid payload, invalid Stripe-Signature headerRequest rejected; event not processed
FEAT-39-05Webhook idempotency replay safetySend same Stripe event twiceSame event_id payload twiceFirst event processed, second ignored without duplicate side effects
FEAT-39-06Renewal success lifecycleTrigger invoice.payment_succeeded webhook for active subscriptionValid Stripe test invoice eventSubscription remains/returns active; usage cycle row initialized for new period
FEAT-39-07Renewal payment failure restrictionTrigger invoice.payment_failed webhookFailed invoice eventLocal status becomes past_due; protected premium endpoints denied
FEAT-39-083DS action-required renewal handlingTrigger invoice.payment_action_required webhook3DS-required invoice eventLocal status past_due, requiresPaymentAction=true, dunning banner shows "complete 3DS authentication" message and "Complete verification" CTA
FEAT-39-093DS checkout card flowCheckout with Stripe 3DS test card 4000 0025 0000 31553DS card in hosted checkoutAuthentication challenge shown; successful auth activates subscription
FEAT-39-13Payment failure dunning banner — generic messageTrigger invoice.payment_failed webhookFailed invoice eventDunning banner shows "We couldn't charge your card — update your payment method" with "Update card" CTA (not the 3DS message)
FEAT-39-143DS dunning banner — correct CTA preserved after subscription updateTrigger invoice.payment_action_required then customer.subscription.updatedBoth events in sequencestatus_reason remains payment_action_required after the subscription update; dunning banner still shows the 3DS message
FEAT-39-15Document purchase 3DSCheckout document purchase with Stripe 3DS test card 4000 0027 6000 31843DS card in hosted payment checkoutAuthentication challenge shown; successful auth records the purchase
FEAT-39-10Logout/revocation path on billing actionsLogin then logout, then call billing portal endpointExpired/cleared cookiesUnauthorized response after logout/session invalidation
FEAT-39-11Expired access token refresh pathCall protected subscription endpoint with expired access + valid refresh cookieExpired access token cookie, valid refresh cookieRequest succeeds after refresh rotation and valid subscription checks
FEAT-39-12Abuse/rate-limit safety regression checkBurst repeated auth/billing requestsHigh-frequency repeated requestsExisting auth throttle behavior remains intact; billing endpoints do not bypass auth protections

Local Testing & Debugging

Billing runs on Stripe test mode. Two environments cover the full scope:

  • Deployed preview (studyboostv2-preview) — Stripe webhooks are already wired to a real endpoint here. Use it for shared QA, full end-to-end runs, and verifying a feature before merge. No per-dev setup.
  • Local — fast iteration and breakpoint debugging. Reading data (invoices, payment method, plan view) and starting checkout / billing-portal sessions work with the Stripe env vars alone. Inbound webhooks cannot reach localhost, so the full lifecycle needs the Stripe CLI: stripe listen --forward-to localhost:3001/billing/stripe/webhook (it prints a per-CLI whsec_… to use as STRIPE_WEBHOOK_SECRET).

Use stripe trigger <event> (e.g. invoice.payment_failed) to exercise webhook edge cases deterministically, and Stripe test cards — 4242… (succeeds), 4000 0000 0000 0341 (charge fails), 4000 0025 0000 3155 (requires 3DS).

When debugging, check in this order: Stripe Dashboard → Developers → Webhooks (delivery log + per-event Resend), then Events / Logs; then backend logs — local start:dev console, or gcloud run services logs read studyboostv2-preview --region us-east1 for the preview.

Have each dev test with their own user account so subscription state does not collide on the shared Stripe test account. Exact commands, env vars, and the test-card table live in the backend README.md → "Stripe & billing — local testing".


Edge Cases

  • Stripe webhook arrives before checkout redirect returns to frontend.
  • Duplicate or out-of-order webhook delivery from Stripe retries.
  • Plan price mapping missing for requested interval.
  • 3DS required on off-session renewals causing temporary past_due state.
  • Webhook event ordering — status_reason clobber: Stripe may deliver customer.subscription.updated after invoice.payment_action_required. handleSubscriptionUpdated passes statusReason: null, but syncSubscriptionFromStripe now preserves the existing status_reason column value when the incoming value is null and the new local status is not active. This prevents payment_action_required from being silently wiped, which would cause the dunning banner to show the wrong CTA.
  • New subscription incomplete state: a brand-new checkout that requires 3DS creates a Stripe subscription in incomplete state. This maps to local past_due (not expired) so the dunning banner can surface the 3DS prompt. Access remains denied via PlanAccessGuard. If the user abandons the challenge, Stripe transitions to incomplete_expired after ~23 hours which maps to local expired.
  • Livemode mismatch — wrong-environment event is silently 2xx-d and not stored; check BILLING_WEBHOOK_LIVEMODE_MISMATCH in logs if events appear to be dropped.
  • Sync fallback latency — without Redis, processing runs inline in the HTTP request; Stripe may retry if it exceeds 10 s, but the UNIQUE constraint on event_id keeps re-processing idempotent.
  • trial_will_end with no local trial recordhandleSubscriptionUpdated is idempotent; already-active subscriptions are a no-op.

Notes

  • Canonical implementation doc file is maintained at:
    • docs/features/FEAT-39-stripe-subscription-billing.md
  • This docs-site page mirrors FEAT-39 for in-app navigation visibility.