docs: add paddle migration spec

This commit is contained in:
Tomas Dvorak
2026-04-21 10:16:36 +02:00
parent 035ac8ddb5
commit d854614a87
@@ -0,0 +1,515 @@
# 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.
## Recommended Approach
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