5.9 KiB
5.9 KiB
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/andpackages/api/src/routes/subscription.routes.tsinto a unifiedbillingrouter.
deliverables:
web/src/server/api/routers/billing.ts— Billing router with procedures:billing.getSubscription—protectedProcedurereturning current subscription with tierbilling.createCheckoutSession—protectedProcedurecreating Stripe checkout for plan upgradebilling.createPortalSession—protectedProcedurecreating Stripe Customer Portalbilling.cancelSubscription—protectedProcedurescheduling cancellation at period endbilling.reactivateSubscription—protectedProcedurereactivating a canceled subscriptionbilling.listInvoices—protectedProcedurereturning Stripe invoices for userbilling.webhook—publicProcedure(or raw endpoint) handling Stripe webhook events
web/src/server/services/billing.service.ts— Business logic:getOrCreateCustomer(userId, email)— Stripe customer managementcreateCheckoutSession(userId, priceId, successUrl, cancelUrl)createPortalSession(customerId, returnUrl)handleWebhookEvent(event)— processescheckout.session.completed,invoice.paid,invoice.payment_failed,customer.subscription.updated,customer.subscription.deletedupdateSubscriptionInDB(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
- Verifies Stripe signature using
- Zod schemas in
web/src/server/api/schemas/billing.ts - Stripe client initialization in
web/src/server/stripe.ts
steps:
- Install
stripenpm package inweb/. - Create
web/src/server/stripe.ts:- Initialize Stripe client with
process.env.STRIPE_SECRET_KEY - Export
stripeinstance
- Initialize Stripe client with
- Create
web/src/server/services/billing.service.ts:getOrCreateCustomer: query DB for existingstripeCustomerId, or create via Stripe API and save to DBcreateCheckoutSession: create Stripe Checkout session withsubscriptionmode, return URLcreatePortalSession: create Stripe Billing Portal sessionhandleWebhookEvent: switch on event type:checkout.session.completed→ create/update subscription record, set tierinvoice.paid→ update subscription status to activeinvoice.payment_failed→ update status to past_due, send notificationcustomer.subscription.updated→ sync tier, status, period datescustomer.subscription.deleted→ mark as canceled
- Create
web/src/server/api/routers/billing.ts:getSubscription: query user's active subscription, include tier detailscreateCheckoutSession: validate price ID against allowed prices, call service, return session URLcreatePortalSession: call service, return portal URLcancelSubscription: update Stripe subscription tocancel_at_period_end, update DBreactivateSubscription: removecancel_at_period_end, update DBlistInvoices: fetch Stripe invoices for customer, return paginated list
- 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
- Define Zod schemas for all inputs (priceId, successUrl, etc.).
- Wire router into
web/src/server/api/root.ts. - Write unit tests for billing service (mock Stripe API calls).
steps:
- Unit:
getOrCreateCustomerreturns existing customer or creates new one - Unit:
handleWebhookEventcorrectly updates subscription status for each event type - Unit: Stripe signature verification rejects invalid signatures
- Integration:
billing.getSubscriptionreturns subscription for authenticated user - Integration: Checkout session creation returns valid Stripe URL
acceptance_criteria:
billing.getSubscriptionreturns current subscription with tier and statusbilling.createCheckoutSessioncreates a Stripe checkout session and returns the URLbilling.createPortalSessioncreates a Stripe customer portal sessionbilling.cancelSubscriptionsetscancelAtPeriodEndin Stripe and DBbilling.reactivateSubscriptionremoves 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 testfor 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.historyprocedure that returns payment history from Stripe invoices.