mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +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