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

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/ 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.getSubscriptionprotectedProcedure returning current subscription with tier
    • billing.createCheckoutSessionprotectedProcedure creating Stripe checkout for plan upgrade
    • billing.createPortalSessionprotectedProcedure creating Stripe Customer Portal
    • billing.cancelSubscriptionprotectedProcedure scheduling cancellation at period end
    • billing.reactivateSubscriptionprotectedProcedure reactivating a canceled subscription
    • billing.listInvoicesprotectedProcedure returning Stripe invoices for user
    • billing.webhookpublicProcedure (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.