feat: Changes to self hosted purchase
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 9m18s

This commit is contained in:
2026-08-11 09:30:19 +01:00
parent 3acbf01d46
commit a228ab24a0
20 changed files with 150 additions and 433 deletions
+2 -3
View File
@@ -695,8 +695,7 @@ POST /instances/:id/claim-free # issue Free on a linked self-hos
POST /instances/link · /instances/:id/relink
GET /instances/:id/entitlement
GET /checkout/options # active plans + catalogue prices for the running PADDLE_ENV
POST /instances/self-hosted # create a paid-checkout placeholder (awaiting_link, no licence)
POST /instances/:id/claim-link # bind a paid placeholder to the real UUID and issue
POST /instances/self-hosted # link (or reuse) the install's real UUID for a paid checkout
PUT /instances/:id/entitlement # set desired config; pushes line items to Paddle (owner|admin)
POST /billing/portal # mint a Paddle customer-portal URL
GET /instances/:id/license · /instances/:id/license/download
@@ -730,7 +729,7 @@ GET /health/injection · /health/billing
Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no vendor SDK) and the only place that talks to it. **Free is entirely outside Paddle** — the shipped self-serve Free flow owns its own renewal, so no £0 subscription exists; an account learns its `paddle_customer_id` from its first paid webhook. Checkout happens in the browser (`@paddle/paddle-js`, token baked into the adminsite build); the server only updates a live subscription (`PUT /instances/:id/entitlement`) and mints a portal session.
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's _current_ line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired``granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID.
`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's _current_ line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired``granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. **Self-hosted purchase requires a standing control plane**: the customer pastes their install's real instance ID, `POST /instances/self-hosted` links it (or reuses one this account already owns, which is how Free upgrades to paid in place), and the checkout's `custom_data` names that UUID from the first event — so the webhook issues with no claim step and there is **no self-hosted placeholder**. A licence binds to the install's UUID, so buying before the install exists only ever deferred the same requirement behind a second identity to rewrite. `Placeholder` is now a cloud-only flag; a non-cloud placeholder reaching `handleSubscription` is a pre-change row and fails loudly rather than being guessed at.
## MongoDB Collections