Files
Kordant/tasks/kordant-unified-restructure/13-subscription-billing-router.md
2026-05-25 22:49:37 -04:00

97 lines
5.9 KiB
Markdown

# 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.