Files
Bookra/docs/superpowers/specs/2026-04-21-paddle-billing-migration-design.md
T
2026-04-21 10:16:36 +02:00

14 KiB

Paddle Billing Migration Design

Date: 2026-04-21 Project: Bookra Status: Proposed

Goal

Replace all Stripe billing with Paddle, remove duplicate billing logic from apps/auth-service, and simplify runtime architecture to two deployables:

  • apps/frontend
  • apps/backend

Target outcome:

  • one billing system only: Paddle
  • one backend only: apps/backend
  • frontend launches Paddle checkout directly through Paddle.js
  • backend owns all billing truth, webhook processing, and tenant entitlement sync
  • auth uses Neon Auth as primary production auth path
  • local and production env files match actual runtime needs

Current State

Current repo has duplicated auth and billing responsibilities:

  • apps/backend already serves product APIs, tenant logic, scheduling, booking, and active dashboard billing APIs
  • apps/auth-service still carries standalone auth plus duplicate Stripe billing APIs and admin screens
  • frontend dashboard already talks to backend billing routes, not auth-service billing routes
  • backend billing implementation is fully Stripe-specific in config, DB columns, repository names, webhook route, OpenAPI, generated API client, and UI copy

This creates three problems:

  1. duplicated billing implementations
  2. Stripe-specific naming embedded into core data model
  3. deploy/runtime confusion from keeping auth-service alive when production auth should use Neon Auth directly

Non-Goals

This migration does not add:

  • end-customer booking checkout
  • multi-provider billing abstraction
  • migration support for existing live Stripe subscribers

There are no published users yet, so clean cutover is correct.

Do full hard cutover in single migration:

  • delete Stripe integration
  • add Paddle integration
  • move any still-needed auth-service auth capabilities into backend only if frontend/backend still require them
  • otherwise decommission auth-service entirely from runtime and docs

Rationale:

  • project is not live
  • no customer migration burden
  • avoids carrying dead provider compatibility code
  • reduces long-term maintenance and deployment risk

Target Architecture

Runtime

  • frontend:
    • Neon Auth client
    • Paddle.js client-side token
    • generated API client for backend
  • backend:
    • Neon Auth JWT verification
    • Paddle API integration
    • Paddle webhook receiver
    • billing state persistence
    • customer portal session generation

apps/auth-service becomes retired code. Preferred end state: remove it from active project workflows and docs. If any still-useful auth handlers remain, port only those needed pieces into backend first, then remove auth-service.

Billing Source of Truth

Backend is sole source of truth for:

  • current plan
  • subscription status
  • billing customer ID
  • billing subscription ID
  • latest normalized billing snapshot
  • entitlement updates after Paddle webhooks or explicit refresh

Frontend never treats checkout success redirect as proof of paid state. Redirect only triggers refetch.

Paddle Integration Model

Frontend

Frontend loads Paddle.js using official hosted script or package wrapper and initializes once with VITE_PADDLE_CLIENT_TOKEN.

When user starts checkout:

  • dashboard gets plan/price readiness from backend
  • frontend calls backend POST /v1/billing/checkout
  • backend returns resolved priceId, customer hints, and checkout metadata
  • frontend opens Paddle.Checkout.open() with:
    • items
    • customer email or Paddle customer ID if already known
    • customData
    • success/cancel URLs pointing back to dashboard

Reason for backend-generated checkout input:

  • keeps plan/price resolution on server
  • prevents frontend drift from env/config
  • makes auditing easier

customData must include at least:

  • tenantId
  • tenantSlug
  • userId
  • userEmail
  • planCode
  • source=bookra-dashboard

Paddle docs state checkout customData is copied to resulting subscription for recurring purchases, making webhook reconciliation deterministic.

Backend

Backend uses Paddle API key over Bearer auth.

Backend responsibilities:

  • resolve plan/currency to Paddle price ID
  • return checkout launch payload
  • verify Paddle-Signature
  • dedupe webhook events
  • fetch and normalize Paddle customer/subscription data
  • update tenant billing state
  • create customer portal sessions for authenticated users

Customer Portal

Do not build custom card-update or cancel-subscription UI.

Backend adds POST /v1/billing/portal:

  • find tenant billing customer
  • create Paddle customer portal session
  • optionally include known subscription ID for deep links
  • return temporary portal URL

Frontend opens returned URL.

Data Model Changes

Current schema uses Stripe-specific names. Replace with provider-neutral billing names.

Tenants table

Rename:

  • stripe_customer_id -> billing_customer_id
  • stripe_subscription_id -> billing_subscription_id

Add:

  • billing_provider text not null default 'paddle'

billing_snapshots table

Rename:

  • stripe_customer_id -> billing_customer_id
  • stripe_subscription_id -> billing_subscription_id

Keep:

  • tenant_id
  • status
  • plan_code
  • currency
  • price_id
  • current_period_start
  • current_period_end
  • cancel_at_period_end
  • updated_at

Add if absent:

  • billing_provider text not null default 'paddle'

subscription_events table

Rename:

  • stripe_event_id -> billing_provider_event_id

Add if absent:

  • billing_provider text not null default 'paddle'

Webhook dedupe unique key becomes provider-aware:

  • unique (billing_provider, billing_provider_event_id)

Repository/API naming

Rename repository methods away from Stripe:

  • GetTenantByStripeCustomerID -> GetTenantByBillingCustomerID
  • UpdateTenantStripeCustomerID -> UpdateTenantBillingCustomerID
  • RecordStripeEvent -> RecordBillingEvent

Struct field names must match.

API Contract Changes

Keep

Keep frontend-facing routes where practical to reduce frontend churn:

  • GET /v1/billing/subscription
  • POST /v1/billing/checkout
  • POST /v1/billing/refresh

Add

  • POST /v1/billing/portal

Replace

  • remove POST /v1/webhooks/stripe
  • add POST /v1/webhooks/paddle

Response shape updates

Billing snapshot payload should remain mostly stable, but provider-specific wording changes:

  • checkoutUrlAvailable now means Paddle checkout ready
  • syncAvailable now means Paddle API configured

Checkout creation response should carry data frontend needs for Paddle.js, not a Stripe hosted session URL:

  • priceId
  • customerId optional
  • customerAuthToken optional if needed later for saved payment methods
  • successRedirectUrl
  • cancelRedirectUrl
  • customData

OpenAPI operation and backend type should use explicit name CheckoutLaunchResponse.

OpenAPI and generated client

Must update:

  • apps/backend/openapi/bookra.openapi.yaml
  • generated package in packages/api-client

No handwritten duplicated TS billing types after migration.

Auth-Service Collapse

Decision

Move project to frontend + backend only.

Practical meaning

  • production auth stays Neon Auth
  • backend verifies Neon Auth JWTs
  • auth-service billing removed fully
  • auth-service docs/env/examples removed or replaced with deprecation note

Implementation path

  1. inspect whether frontend still depends on any auth-service-only endpoints
  2. move any still-needed endpoint behavior into backend if required
  3. remove auth-service billing code
  4. remove auth-service from root docs, env examples, verification flow, and deployment assumptions

If no remaining required auth-service endpoint exists after Neon Auth-first changes already landed, app can retire auth-service immediately.

Config Contract

Frontend env

Required:

  • VITE_API_BASE_URL
  • VITE_NEON_AUTH_URL
  • VITE_PADDLE_CLIENT_TOKEN

Optional:

  • existing non-billing frontend vars

Backend env

Required for billing:

  • BOOKRA_PADDLE_API_KEY
  • BOOKRA_PADDLE_WEBHOOK_SECRET
  • BOOKRA_PADDLE_ENV
  • BOOKRA_PADDLE_STARTER_CZK_PRICE_ID
  • BOOKRA_PADDLE_STARTER_USD_PRICE_ID
  • BOOKRA_PADDLE_PRO_CZK_PRICE_ID
  • BOOKRA_PADDLE_PRO_USD_PRICE_ID
  • BOOKRA_PADDLE_BUSINESS_CZK_PRICE_ID
  • BOOKRA_PADDLE_BUSINESS_USD_PRICE_ID

Required for auth/runtime:

  • BOOKRA_DATABASE_URL
  • BOOKRA_NEON_AUTH_URL
  • existing backend app config vars already used for frontend URL, jobs, email, etc.

Remove from active env examples

  • all STRIPE_*
  • all BOOKRA_STRIPE_*
  • auth-service billing env

Billing State Mapping

Normalize Paddle subscription states into internal states used by Bookra.

Initial mapping:

  • Paddle active/trialing -> active
  • Paddle paused -> past_due or paused depending on internal enum support
  • Paddle canceled -> canceled
  • missing subscription with no billing customer -> inactive
  • incomplete/failed transaction states -> past_due

If internal model lacks paused, keep current internal enum and map conservatively, documented in code comments.

Plan mapping:

  • Paddle price ID -> internal plan_code
  • backend owns exact mapping through env-configured price matrix
  • no plan inference from frontend text labels

Webhook Handling

Subscribe at minimum to:

  • subscription.created
  • subscription.updated
  • subscription.activated
  • subscription.canceled
  • subscription.paused
  • subscription.resumed
  • transaction.completed
  • transaction.updated
  • transaction.payment_failed if used by Paddle event catalog for current account

Handler flow:

  1. read raw body
  2. verify Paddle-Signature
  3. extract event ID, type, customer/subscription IDs, and custom_data
  4. idempotency insert into subscription_events
  5. resolve tenant by:
    • custom_data.tenantId first if present and trustworthy
    • else billing_customer_id
  6. fetch latest subscription/customer state from Paddle API when needed
  7. write normalized billing snapshot
  8. update tenant plan/status
  9. return 200

Webhook payload is trigger, not final source of truth. Backend may fetch fresh Paddle entity state before persisting.

Error Handling

Backend returns honest states only.

Rules:

  • missing Paddle API key => checkout disabled, refresh unavailable, 503 for checkout/refresh attempts
  • missing price ID for selected plan => 400
  • invalid webhook signature => 400
  • duplicate webhook => 200 no-op
  • unknown tenant mapping => 202 or 200 with logged warning, depending on handler style
  • Paddle API failure during sync => 503 for user-triggered refresh, retryable log path for webhook

Frontend rules:

  • no fake “paid” notice after redirect
  • success query param triggers backend refresh + refetch only
  • if checkout not ready, button disabled and message says Paddle not configured
  • “Manage billing” hidden or disabled until portal available

Testing Plan

Unit

Backend unit tests for:

  • plan/currency -> price ID resolution
  • config readiness helpers
  • checkout launch payload creation
  • webhook signature validation
  • webhook idempotency
  • subscription normalization
  • customer portal session creation
  • repository rename/regression coverage

Integration

Backend integration tests for:

  • DB migrations on fresh database
  • DB migrations from old Stripe-shaped schema
  • webhook event persistence
  • entitlement updates after simulated Paddle event payloads

Frontend verification

  • typecheck/build passes
  • billing UI opens Paddle checkout through injected abstraction or browser global
  • billing refresh/portal actions behave correctly for ready/not-ready states
  • copy no longer says Stripe anywhere

Live sandbox smoke

Using Paddle sandbox:

  1. create sandbox API key
  2. create client-side token
  3. create sandbox prices for starter/pro/business in CZK/USD
  4. configure webhook destination to backend
  5. complete checkout from dashboard
  6. verify webhook updates tenant state
  7. verify refresh endpoint returns normalized active subscription
  8. verify customer portal session opens
  9. cancel/pause through portal if supported, then verify webhook sync

Documentation Changes

Update:

  • root README.md
  • project.md
  • apps/backend/README.md
  • root .env.example
  • apps/backend/.env.example
  • apps/frontend/.env.example

Remove or deprecate:

  • apps/auth-service/README.md
  • apps/auth-service/.env.example

Frontend/legal copy must replace “Stripe” with “Paddle” or provider-neutral wording.

Implementation Order

  1. schema migration from Stripe names to billing names
  2. repository/model rename
  3. backend config rename from Stripe env to Paddle env
  4. backend Paddle service integration
  5. webhook route swap
  6. portal session endpoint
  7. OpenAPI update + client regeneration
  8. frontend Paddle.js integration + dashboard action changes
  9. auth-service billing removal
  10. docs/env cleanup
  11. full test + sandbox smoke

Risks

Risk: auth-service still secretly needed

Mitigation:

  • inspect frontend/runtime references before deleting
  • port only remaining necessary auth endpoint behavior into backend

Risk: Paddle event/state model differs from current internal assumptions

Mitigation:

  • normalize through dedicated mapping layer
  • use webhook-triggered sync against live Paddle API instead of trusting event payload alone

Risk: schema rename breaks old queries

Mitigation:

  • update sqlc SQL and repository code in same change set
  • run all backend tests plus migration smoke

Risk: frontend Paddle global initialization issues

Mitigation:

  • wrap Paddle.js loading/init in dedicated provider/module
  • initialize once
  • fail closed when token missing

Definition of Done

Migration done when all conditions true:

  • no Stripe dependency in active codepaths
  • no frontend user-facing Stripe copy remains
  • backend billing routes use Paddle only
  • webhook route is Paddle only
  • auth-service not needed for production runtime
  • env examples document only actual required vars
  • OpenAPI and generated TS client match backend
  • local tests pass
  • Paddle sandbox checkout + webhook + portal smoke pass