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/frontendapps/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/backendalready serves product APIs, tenant logic, scheduling, booking, and active dashboard billing APIsapps/auth-servicestill 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:
- duplicated billing implementations
- Stripe-specific naming embedded into core data model
- deploy/runtime confusion from keeping
auth-servicealive 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:
tenantIdtenantSluguserIduserEmailplanCodesource=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_idstripe_subscription_id->billing_subscription_id
Add:
billing_provider text not null default 'paddle'
billing_snapshots table
Rename:
stripe_customer_id->billing_customer_idstripe_subscription_id->billing_subscription_id
Keep:
tenant_idstatusplan_codecurrencyprice_idcurrent_period_startcurrent_period_endcancel_at_period_endupdated_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->GetTenantByBillingCustomerIDUpdateTenantStripeCustomerID->UpdateTenantBillingCustomerIDRecordStripeEvent->RecordBillingEvent
Struct field names must match.
API Contract Changes
Keep
Keep frontend-facing routes where practical to reduce frontend churn:
GET /v1/billing/subscriptionPOST /v1/billing/checkoutPOST /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:
checkoutUrlAvailablenow means Paddle checkout readysyncAvailablenow means Paddle API configured
Checkout creation response should carry data frontend needs for Paddle.js, not a Stripe hosted session URL:
priceIdcustomerIdoptionalcustomerAuthTokenoptional if needed later for saved payment methodssuccessRedirectUrlcancelRedirectUrlcustomData
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
- inspect whether frontend still depends on any auth-service-only endpoints
- move any still-needed endpoint behavior into backend if required
- remove auth-service billing code
- 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_URLVITE_NEON_AUTH_URLVITE_PADDLE_CLIENT_TOKEN
Optional:
- existing non-billing frontend vars
Backend env
Required for billing:
BOOKRA_PADDLE_API_KEYBOOKRA_PADDLE_WEBHOOK_SECRETBOOKRA_PADDLE_ENVBOOKRA_PADDLE_STARTER_CZK_PRICE_IDBOOKRA_PADDLE_STARTER_USD_PRICE_IDBOOKRA_PADDLE_PRO_CZK_PRICE_IDBOOKRA_PADDLE_PRO_USD_PRICE_IDBOOKRA_PADDLE_BUSINESS_CZK_PRICE_IDBOOKRA_PADDLE_BUSINESS_USD_PRICE_ID
Required for auth/runtime:
BOOKRA_DATABASE_URLBOOKRA_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_dueorpauseddepending 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.createdsubscription.updatedsubscription.activatedsubscription.canceledsubscription.pausedsubscription.resumedtransaction.completedtransaction.updatedtransaction.payment_failedif used by Paddle event catalog for current account
Handler flow:
- read raw body
- verify
Paddle-Signature - extract event ID, type, customer/subscription IDs, and
custom_data - idempotency insert into
subscription_events - resolve tenant by:
custom_data.tenantIdfirst if present and trustworthy- else
billing_customer_id
- fetch latest subscription/customer state from Paddle API when needed
- write normalized billing snapshot
- update tenant plan/status
- 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,
503for checkout/refresh attempts - missing price ID for selected plan =>
400 - invalid webhook signature =>
400 - duplicate webhook =>
200no-op - unknown tenant mapping =>
202or200with logged warning, depending on handler style - Paddle API failure during sync =>
503for 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:
- create sandbox API key
- create client-side token
- create sandbox prices for starter/pro/business in CZK/USD
- configure webhook destination to backend
- complete checkout from dashboard
- verify webhook updates tenant state
- verify refresh endpoint returns normalized active subscription
- verify customer portal session opens
- cancel/pause through portal if supported, then verify webhook sync
Documentation Changes
Update:
- root
README.md project.mdapps/backend/README.md- root
.env.example apps/backend/.env.exampleapps/frontend/.env.example
Remove or deprecate:
apps/auth-service/README.mdapps/auth-service/.env.example
Frontend/legal copy must replace “Stripe” with “Paddle” or provider-neutral wording.
Implementation Order
- schema migration from Stripe names to billing names
- repository/model rename
- backend config rename from Stripe env to Paddle env
- backend Paddle service integration
- webhook route swap
- portal session endpoint
- OpenAPI update + client regeneration
- frontend Paddle.js integration + dashboard action changes
- auth-service billing removal
- docs/env cleanup
- 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