ADR-004: Billing Integration Strategy

Status: Accepted (Implemented) Owner: @bilal Date: 2025-12-06 Updated: 2026-03-12

Context

Organisations need a payment flow tied to plan selection during onboarding, with subscription management via Stripe.

Original decision was to defer. As of March 2026, Stripe billing is implemented for the Growth (self-serve) motion.

Decision

Use Stripe Checkout for payment collection and Stripe Webhooks for reliable subscription lifecycle management.

Architecture

Onboarding (client)
  |
  | POST /api/stripe/checkout  { plan, orgName, orgType }
  v
Server creates Stripe Checkout Session
  - metadata: { userId, plan, orgName, orgType }
  - success_url: /onboarding?step=success&session_id={ID}
  |
  v
User pays on Stripe hosted page
  |
  +---> Stripe redirects browser to success_url
  |       |
  |       v
  |     completeOnboarding(sessionId)
  |       - Retrieves session from Stripe API
  |       - Reads plan/name/type from Stripe metadata (NOT client)
  |       - Verifies payment_status === "paid"
  |       - Verifies metadata.userId === authenticated user
  |       - Creates organisation + access (idempotent)
  |       - Proceeds to import step
  |
  +---> Stripe fires checkout.session.completed webhook
          |
          v
        /api/stripe/webhook
          - Verifies signature (STRIPE_WEBHOOK_SECRET)
          - If org exists: attaches Stripe billing fields
          - If org missing: creates org from session metadata (safety net)

Security Model

  • Stripe session metadata is the single source of truth for plan, org name, and org type.
  • The client only provides a sessionId on the return path. It cannot influence what plan or org data gets created.
  • This prevents plan tampering (e.g. paying for Starter but requesting Premium).
  • completeOnboarding is idempotent — if the webhook already created the org, it returns the existing one.

Webhook Events Handled

EventAction
checkout.session.completedCreate org (if missing) + attach stripeCustomerId, stripeSubscriptionId
customer.subscription.updatedUpdate stripeSubscriptionStatus
customer.subscription.deletedSet status to canceled, downgrade plan to starter

Database Schema

-- On organisations table (added via migration 008_stripe_fields.sql)
stripe_customer_id        TEXT
stripe_subscription_id    TEXT
stripe_subscription_status TEXT  -- 'active', 'past_due', 'canceled', etc.

Key Files

FilePurpose
app/api/stripe/checkout/route.tsCreates Stripe Checkout Session
app/api/stripe/webhook/route.tsHandles Stripe webhook events
lib/stripe/index.tsStripe client singleton, plan helpers
lib/actions/onboarding.tscompleteOnboarding(sessionId) — creates org from Stripe metadata
lib/plans.tsPlan limits (property/tenant caps) from DB

Webhook Setup

Local development:

stripe login               # One-time auth
pnpm stripe:webhook         # Forwards to localhost:3000
pnpm stripe:webhook:cf      # Forwards to localhost:8787 (Cloudflare preview)

The CLI prints a whsec_... signing secret — set it as STRIPE_WEBHOOK_SECRET in .env.

Production (Cloudflare Workers):

  1. Stripe Dashboard → Developers → Webhooks → Add endpoint
  2. URL: https://app.ehq.tech/api/stripe/webhook
  3. Events: checkout.session.completed, customer.subscription.updated, customer.subscription.deleted
  4. Copy signing secret → npx wrangler secret put STRIPE_WEBHOOK_SECRET

Consequences

Positive

  • Payment verified server-side before org creation
  • No client-side plan tampering possible
  • Webhook safety net handles dropped connections, browser crashes, etc.
  • Idempotent — safe against retries and race conditions

Negative

  • Requires Stripe CLI running during local dev for full flow testing
  • Webhook must be configured in Stripe Dashboard for each environment