mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-04 04:22:59 +00:00
docs: add paddle migration spec
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user