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
proandplusplans. - Existing paid users are redirected to Stripe Billing Portal for plan updates, cancellation, and payment-method management.
- Subscription provider reads
GET /subscriptions/my-subscriptionand 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_idis empty. - Webhook processing is idempotent via
billing_webhook_events(event_idunique). - Implemented webhook event handlers:
checkout.session.completedinvoice.payment_succeededinvoice.payment_failedinvoice.payment_action_requiredcustomer.subscription.updatedcustomer.subscription.deletedcustomer.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 tohandleSubscriptionUpdated— added in #137)
- Webhook reliability hardening (#137):
- Livemode guard: events are validated against
NODE_ENVbefore being persisted. Events with the wronglivemodeflag are silently 2xx-d and not stored. Events without alivemodefield (older Stripe API versions) pass through unblocked. - Sync fallback: when
BULLMQ_REDIS_URLis 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 toprocessedorfailed.
- Livemode guard: events are validated against
- Status mapping:
- Stripe
active/trialing-> localactive - Stripe
past_due/unpaid/incomplete-> localpast_due - Stripe
canceled-> localcanceled - Stripe
incomplete_expired/paused/ (any other) -> localexpired incompletemaps topast_due(notexpired) because the subscription is awaiting a pending payment action (e.g. 3DS challenge) rather than definitively lapsed. Access is still denied —PlanAccessGuardblocks any non-activestatus.
- Stripe
PlanAccessGuardexplicitly enforcesstatus=activevia subscription read model.- SCA / 3DS explicit opt-in: both checkout session builders (
createCheckoutSessionForUserfor subscriptions,createDocumentPurchaseCheckoutSessionfor one-time document purchases) include an explicitrequest_three_d_secure=automaticparam. Note the parameter path differs by session mode:mode=subscription:payment_method_options[card][request_three_d_secure]=automaticmode=payment:payment_intent_data[payment_method_options][card][request_three_d_secure]=automatic
status_reasonpreservation on subscription updates:syncSubscriptionFromStripe's SQL UPDATE usesCASE WHENlogic to preserve a non-nullstatus_reason(e.g.payment_action_required) whenhandleSubscriptionUpdatedpassesstatusReason: null. The column is only cleared when the subscription transitions toactive(payment resolved). This prevents acustomer.subscription.updatedevent from clobbering apayment_action_requiredvalue written byinvoice.payment_action_required.
Current V2 Data Model (Post-Legacy Cleanup)
subscription_plansis 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_subscriptionsand 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 ID | Description | Steps | Input | Expected Result |
|---|---|---|---|---|
| FEAT-39-01 | Subscribe happy path (hosted checkout) | Login -> open pricing -> choose Pro -> redirect to Stripe Checkout -> complete payment -> webhook delivery | Valid authenticated user, valid Stripe test card | Checkout succeeds, webhook marks local subscription active, my-subscription reflects active status |
| FEAT-39-02 | Unauthorized checkout session request | Call POST /subscriptions/checkout-session without auth cookies | No auth cookies | 401/unauthorized response; no Stripe session created |
| FEAT-39-03 | Invalid checkout payload rejected | Call checkout endpoint with missing/invalid billingInterval | { planSlug: "pro", billingInterval: "weekly" } | 400 validation error |
| FEAT-39-04 | Webhook signature validation | Call webhook endpoint with tampered signature | Valid payload, invalid Stripe-Signature header | Request rejected; event not processed |
| FEAT-39-05 | Webhook idempotency replay safety | Send same Stripe event twice | Same event_id payload twice | First event processed, second ignored without duplicate side effects |
| FEAT-39-06 | Renewal success lifecycle | Trigger invoice.payment_succeeded webhook for active subscription | Valid Stripe test invoice event | Subscription remains/returns active; usage cycle row initialized for new period |
| FEAT-39-07 | Renewal payment failure restriction | Trigger invoice.payment_failed webhook | Failed invoice event | Local status becomes past_due; protected premium endpoints denied |
| FEAT-39-08 | 3DS action-required renewal handling | Trigger invoice.payment_action_required webhook | 3DS-required invoice event | Local status past_due, requiresPaymentAction=true, dunning banner shows "complete 3DS authentication" message and "Complete verification" CTA |
| FEAT-39-09 | 3DS checkout card flow | Checkout with Stripe 3DS test card 4000 0025 0000 3155 | 3DS card in hosted checkout | Authentication challenge shown; successful auth activates subscription |
| FEAT-39-13 | Payment failure dunning banner — generic message | Trigger invoice.payment_failed webhook | Failed invoice event | Dunning banner shows "We couldn't charge your card — update your payment method" with "Update card" CTA (not the 3DS message) |
| FEAT-39-14 | 3DS dunning banner — correct CTA preserved after subscription update | Trigger invoice.payment_action_required then customer.subscription.updated | Both events in sequence | status_reason remains payment_action_required after the subscription update; dunning banner still shows the 3DS message |
| FEAT-39-15 | Document purchase 3DS | Checkout document purchase with Stripe 3DS test card 4000 0027 6000 3184 | 3DS card in hosted payment checkout | Authentication challenge shown; successful auth records the purchase |
| FEAT-39-10 | Logout/revocation path on billing actions | Login then logout, then call billing portal endpoint | Expired/cleared cookies | Unauthorized response after logout/session invalidation |
| FEAT-39-11 | Expired access token refresh path | Call protected subscription endpoint with expired access + valid refresh cookie | Expired access token cookie, valid refresh cookie | Request succeeds after refresh rotation and valid subscription checks |
| FEAT-39-12 | Abuse/rate-limit safety regression check | Burst repeated auth/billing requests | High-frequency repeated requests | Existing 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-CLIwhsec_…to use asSTRIPE_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_duestate. - Webhook event ordering —
status_reasonclobber: Stripe may delivercustomer.subscription.updatedafterinvoice.payment_action_required.handleSubscriptionUpdatedpassesstatusReason: null, butsyncSubscriptionFromStripenow preserves the existingstatus_reasoncolumn value when the incoming value is null and the new local status is notactive. This preventspayment_action_requiredfrom being silently wiped, which would cause the dunning banner to show the wrong CTA. - New subscription
incompletestate: a brand-new checkout that requires 3DS creates a Stripe subscription inincompletestate. This maps to localpast_due(notexpired) so the dunning banner can surface the 3DS prompt. Access remains denied viaPlanAccessGuard. If the user abandons the challenge, Stripe transitions toincomplete_expiredafter ~23 hours which maps to localexpired. - Livemode mismatch — wrong-environment event is silently 2xx-d and not stored; check
BILLING_WEBHOOK_LIVEMODE_MISMATCHin 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
UNIQUEconstraint onevent_idkeeps re-processing idempotent. trial_will_endwith no local trial record —handleSubscriptionUpdatedis 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.