# 13. Backend Router — Subscriptions, Billing, and Stripe Webhooks meta: id: kordant-unified-restructure-13 feature: kordant-unified-restructure priority: P1 depends_on: [kordant-unified-restructure-11] tags: [backend, trpc, billing, stripe, api] objective: - Build the tRPC router for subscription management and Stripe billing integration. Port logic from `packages/shared-billing/` and `packages/api/src/routes/subscription.routes.ts` into a unified `billing` router. deliverables: - `web/src/server/api/routers/billing.ts` — Billing router with procedures: - `billing.getSubscription` — `protectedProcedure` returning current subscription with tier - `billing.createCheckoutSession` — `protectedProcedure` creating Stripe checkout for plan upgrade - `billing.createPortalSession` — `protectedProcedure` creating Stripe Customer Portal - `billing.cancelSubscription` — `protectedProcedure` scheduling cancellation at period end - `billing.reactivateSubscription` — `protectedProcedure` reactivating a canceled subscription - `billing.listInvoices` — `protectedProcedure` returning Stripe invoices for user - `billing.webhook` — `publicProcedure` (or raw endpoint) handling Stripe webhook events - `web/src/server/services/billing.service.ts` — Business logic: - `getOrCreateCustomer(userId, email)` — Stripe customer management - `createCheckoutSession(userId, priceId, successUrl, cancelUrl)` - `createPortalSession(customerId, returnUrl)` - `handleWebhookEvent(event)` — processes `checkout.session.completed`, `invoice.paid`, `invoice.payment_failed`, `customer.subscription.updated`, `customer.subscription.deleted` - `updateSubscriptionInDB(subscriptionId, stripeEventData)` — syncs Stripe state to Drizzle - `web/src/routes/api/stripe/webhook.ts` — Raw SolidStart API route for Stripe webhooks: - Verifies Stripe signature using `stripe.webhooks.constructEvent` - Forwards to billing service - Zod schemas in `web/src/server/api/schemas/billing.ts` - Stripe client initialization in `web/src/server/stripe.ts` steps: 1. Install `stripe` npm package in `web/`. 2. Create `web/src/server/stripe.ts`: - Initialize Stripe client with `process.env.STRIPE_SECRET_KEY` - Export `stripe` instance 3. Create `web/src/server/services/billing.service.ts`: - `getOrCreateCustomer`: query DB for existing `stripeCustomerId`, or create via Stripe API and save to DB - `createCheckoutSession`: create Stripe Checkout session with `subscription` mode, return URL - `createPortalSession`: create Stripe Billing Portal session - `handleWebhookEvent`: switch on event type: - `checkout.session.completed` → create/update subscription record, set tier - `invoice.paid` → update subscription status to active - `invoice.payment_failed` → update status to past_due, send notification - `customer.subscription.updated` → sync tier, status, period dates - `customer.subscription.deleted` → mark as canceled 4. Create `web/src/server/api/routers/billing.ts`: - `getSubscription`: query user's active subscription, include tier details - `createCheckoutSession`: validate price ID against allowed prices, call service, return session URL - `createPortalSession`: call service, return portal URL - `cancelSubscription`: update Stripe subscription to `cancel_at_period_end`, update DB - `reactivateSubscription`: remove `cancel_at_period_end`, update DB - `listInvoices`: fetch Stripe invoices for customer, return paginated list 5. Create `web/src/routes/api/stripe/webhook.ts`: - SolidStart API route (not tRPC — Stripe needs raw body for signature verification) - Read raw body from request - Verify signature with `STRIPE_WEBHOOK_SECRET` - Call `billingService.handleWebhookEvent` - Return 200 for success, 400 for invalid signature, 500 for processing errors 6. Define Zod schemas for all inputs (priceId, successUrl, etc.). 7. Wire router into `web/src/server/api/root.ts`. 8. Write unit tests for billing service (mock Stripe API calls). steps: - Unit: `getOrCreateCustomer` returns existing customer or creates new one - Unit: `handleWebhookEvent` correctly updates subscription status for each event type - Unit: Stripe signature verification rejects invalid signatures - Integration: `billing.getSubscription` returns subscription for authenticated user - Integration: Checkout session creation returns valid Stripe URL acceptance_criteria: - [ ] `billing.getSubscription` returns current subscription with tier and status - [ ] `billing.createCheckoutSession` creates a Stripe checkout session and returns the URL - [ ] `billing.createPortalSession` creates a Stripe customer portal session - [ ] `billing.cancelSubscription` sets `cancelAtPeriodEnd` in Stripe and DB - [ ] `billing.reactivateSubscription` removes cancellation flag - [ ] Stripe webhook endpoint correctly handles all relevant event types - [ ] Webhook signature verification prevents spoofed requests - [ ] Subscription tier changes correctly gate features (enforced in other routers) validation: - Use Stripe CLI to trigger test webhooks: `stripe listen --forward-to localhost:3000/api/stripe/webhook` - Verify webhook handler responds 200 and updates DB correctly - Test checkout flow with Stripe Test Mode - Run `cd web && pnpm test` for billing unit tests notes: - Reference legacy: `packages/shared-billing/src/`, `packages/api/src/routes/subscription.routes.ts` - Store Stripe price IDs in environment variables or a config file: - `STRIPE_PRICE_BASIC`, `STRIPE_PRICE_PLUS`, `STRIPE_PRICE_PREMIUM` - The webhook endpoint MUST receive the raw request body, not parsed JSON. In SolidStart, use `request.text()` before any parsing. - Ensure idempotency: webhook events may be delivered multiple times. Use Stripe event ID to deduplicate. - For the tier enum in Drizzle schema, ensure it matches Stripe product metadata or price IDs. - Consider adding a `billing.history` procedure that returns payment history from Stripe invoices.