From 0a86167c4470181d0f55cc5461031f77681c69a5 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 27 Jul 2026 10:57:19 +0100 Subject: [PATCH] docs: updated docs --- claude.md | 20 +- .../plans/2026-07-27-paddle-billing.md | 1780 +++++++++++++++++ 2 files changed, 1798 insertions(+), 2 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-27-paddle-billing.md diff --git a/claude.md b/claude.md index 9031e53..cf133f2 100644 --- a/claude.md +++ b/claude.md @@ -327,6 +327,7 @@ POST /auth/staff/login /auth/login /auth/logout POST /auth/signup # self-hosted only; honeypot + rate limited GET /auth/verify?token=… POST /auth/accept-invite # an invitee sets their own password +POST /api/paddle/webhook # Paddle events; signature-verified, idempotent, no session ``` Customer-session (`/api`), every instance resolved through `ownedInstance`: @@ -338,6 +339,11 @@ POST /instances/:id/renew # Free renewal; refuses outside t POST /instances/:id/claim-free # issue Free on a linked self-hosted instance 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 +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 GET /subscriptions GET,POST /account/users · PUT /account/users/:id/role · DELETE /account/users/:id @@ -360,11 +366,17 @@ POST /instances/:id/issue · /instances/:id/relink GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:deployment/:tier GET,PUT /catalogue GET,PUT /instances/:id/entitlement -GET /health/injection +GET /health/injection · /health/billing ``` **Customer endpoints answer 404, never 403, for another account's resource** — a 403 confirms the resource exists. Route-group guards in `adminsite/` mirror this, but the backend is the layer that matters. +### Billing (Paddle) + +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. + ## MongoDB Collections `servers` · `keys` · `assignments` · `orgs` · `users` · `org_oidc` · `settings` · `secrets` · `workflows` · `workflow_steps` · `workflow_runs` · `monitors` · `incidents` · `monitor_rollups` · `notification_channels` · `console_sessions` · `audit_logs` · `migrations` @@ -381,7 +393,7 @@ Notes that are not obvious from the structs: - `console_sessions.token_consumed_at` is set atomically to enforce one-time use. - `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence. -Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an *index* of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes. +Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `paddle_events` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `paddle_events` is the webhook idempotency log, unique on `event_id`: an event is claimed there before processing, and a duplicate of a handled event is a 200 no-op. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an *index* of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes. `plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle. @@ -613,6 +625,10 @@ git push origin main # server + web deploy | `ADMIN_API_URL` | Variable | **browser-reachable** admin URL, baked into **both** the `adminsite` and `site` images — `site/start` posts account signups straight to admin. Same footgun as `SITE_API_URL`: wrong here and every request fails at runtime with the not-connected panel. | | `ADMIN_ENV` | Variable | `production` or `sandbox`; drives the persistent environment badge. Anything but `sandbox` reads as production. | | `HQ_URL` | Variable | optional; browser URL of the HQ portal, baked into `web` so an `hq`-sourced member links to where they are managed. Empty on self-hosted, which renders a plain label instead. | +| `PADDLE_CLIENT_TOKEN`| Variable | **browser** Paddle token, baked into the `adminsite` image for checkout. A repo-variable change pushes no commit, so rebuild `adminsite` manually via `workflow_dispatch` after editing it. | +| `PADDLE_ENV` | Variable | `sandbox` or `production`; baked into `adminsite` AND read by `admin` at runtime. Selects which `catalogue` price IDs are served, and must match on both sides. | +| `PADDLE_API_KEY` | Secret | server-side Paddle key, read by `admin` at runtime. Boot-required. | +| `PADDLE_WEBHOOK_SECRET` | Secret | webhook signature verification, read by `admin`. Boot-required — an unverified endpoint is one anyone can issue licences through. | --- diff --git a/docs/superpowers/plans/2026-07-27-paddle-billing.md b/docs/superpowers/plans/2026-07-27-paddle-billing.md new file mode 100644 index 0000000..f76e556 --- /dev/null +++ b/docs/superpowers/plans/2026-07-27-paddle-billing.md @@ -0,0 +1,1780 @@ +# Paddle Billing Implementation Plan (regenerated against shipped spec 7) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Supersedes** [`2026-07-26-paddle-billing.md`](2026-07-26-paddle-billing.md), which was written before spec 7 shipped and whose task bodies reference deleted shapes (`Plan.PaddlePriceIDs`, `models.ResolvePriceID`, a single price per subscription). Read that file only for history. + +**Goal:** connect Paddle's subscription lifecycle to the shipped licence issuer and metered entitlement model — a customer configures a plan, checks out through Paddle, and a webhook promotes their entitlement and issues the licence, with renewals and cancellation handled as Paddle reports them. + +**Architecture:** all Paddle SDK usage is isolated behind one `paddle.Client` interface in `admin/internal/paddle`, so the rest of the code depends on our types, not the SDK's. `admin/internal/billing` claims each webhook event idempotently in `paddle_events`, then dispatches. Subscription events resolve their line items back to a plan and configuration through the already-shipped `catalogue.ResolveItems`, promote the entitlement's `desired` into `granted`, and call `licensing.Issue` — the only signer. Nothing here shortens or revokes a licence. + +**Tech Stack:** Go 1.26, gin, MongoDB driver v2, `github.com/PaddleHQ/paddle-go-sdk` (server) + `@paddle/paddle-js` (browser), Next.js 16, TanStack Query, Tailwind 3. + +Spec: [`docs/superpowers/specs/2026-07-24-paddle-billing-design.md`](../specs/2026-07-24-paddle-billing-design.md) — but note the deviations in "What spec 7 already settled" below; the spec predates specs 6 and 7. + +## Global Constraints + +- **No automated Go tests.** Verify by compiler, `grep`, `curl`, and running built images against scratch databases. **Do not add `*_test.go` files.** +- **Never run `go` or `npm` on the host.** Use the container wrappers: + ```sh + # /tmp/gorun.sh + DIR="$1"; shift + MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-gomod:/go/pkg/mod \ + -v vantage-gocache:/root/.cache/go-build -w "/src/$DIR" golang:1.26 "$@" + ``` + ```sh + # /tmp/npmrun.sh + DIR="$1"; shift + MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -v vantage-npm:/root/.npm \ + -w "/src/$DIR" node:26-alpine "$@" + ``` +- **`MSYS_NO_PATHCONV=1` on every `docker` call.** **Run `go mod tidy` with `GOWORK=off`.** +- **No price ID, product ID or Paddle URL is hard-coded**, in Go or TypeScript. Price IDs live in the `catalogue` collection (shipped), keyed environment→term, edited through the staff catalogue screen (shipped). The only Paddle literals allowed are the SDK's own base URLs and the two public env vars the browser build bakes in. +- **Licences are offline-verified: nothing Paddle says revokes one early.** `subscription.canceled` and `subscription.past_due` take **no licence action**. Never write an earlier `expires_at`. +- **Self-hosted is annual only.** A resolved self-hosted price whose term is not `annual` is a configuration error and must fail the handler loudly. `catalogue` already refuses to store such a price and `ResolveItems`/`LineItems` already return `ErrTermNotSold`. +- **Every webhook is idempotent.** The event ID is claimed in `paddle_events` before processing. A duplicate of a *handled* event returns `200` and does nothing; a retry of a *failed* event is reprocessed. +- **`licensing.Issue` stays the only signer.** Billing calls it; billing never touches `licenses` or signs anything. +- **Admin's control-plane writes stay confined to `inject` and `cloudprov`.** This plan adds no third write path. Cloud delivery is `inject.Deliver`; self-hosted delivery is `mail.SendLicense`. +- **Customer endpoints answer 404, never 403,** for another account's resource — every instance handler goes through `ownedInstance`. +- **A licence is signed only from `granted`, never `desired`.** A webhook that confirms payment promotes `desired`→`granted` *then* issues. Nothing else promotes. +- **Free stays outside Paddle entirely.** No £0 subscription, no Paddle customer at signup. The shipped self-serve Free flow (`POST /api/instances`, `/renew`, `/claim-free`, the lifecycle notices, the reaper) is untouched. `paddle_customer_id` is learned from the first real subscription webhook. + +## What spec 7 already settled (do NOT rebuild) + +- **Catalogue + price IDs.** `catalogue` collection, `models.CatalogueRow.PriceID(env, term)`, `SeedCatalogue`, the staff catalogue editor at `adminsite/app/(staff)/staff/catalogue/`. Old spec-5 task 8 is **done**. +- **Both folds.** `catalogue.LineItems(ctx, env, term, plan, cfg) ([]catalogue.Item, error)` builds subscription line items; `catalogue.ResolveItems(ctx, env, items) (catalogue.Match, error)` maps a full item list back to `{Deployment, Tier, Term, Servers, Features}`. `catalogue.Item{PriceID string; Quantity int}`. Errors: `ErrUnknownPrice`, `ErrNoBaseItem`, `ErrTermNotSold`, `ErrUnpriced`. +- **Entitlements.** `models.Entitlement{Desired, Granted models.Config, ResolvedLimits, ScheduledChangeAt, ...}`, `models.Config{Servers int; Features models.Features}`, `models.GetEntitlement`, `models.UpsertEntitlement`, `models.ErrNoEntitlement`, `Entitlement.Pending()`. `models.ReasonEntitlementChange`. +- **The configurator component** `adminsite/components/PlanConfigurator.tsx` (`PlanChoice{tier,term,servers,features}`) and the adminsite api client types `Plan`, `CatalogueRow`, `Entitlement`, `Term`, `Deployment`, plus `api.staff.entitlement/setEntitlement`. +- **Issuing from an entitlement.** `licensing.Issue` snapshots the instance's `granted` entitlement (falls back to plan base when none). `licensing.ErrFreeLimit`, `models.GracePeriod` (3 days; add yourself when passing `ExpiresAt`). +- **Signup migration.** Done in spec 6 — signup/verify live in admin, sitesvc has only contact. The spec's "Signup migration off sitesvc" section is **obsolete; skip it entirely.** +- **Two products, not three.** Free is not a Paddle product, in either deployment. + +## Deferred — cannot be done in this environment + +Live Paddle sandbox work is **out of this plan's automated scope**: creating the sandbox catalog, a real `PADDLE_API_KEY`/`PADDLE_WEBHOOK_SECRET`, a public webhook URL, and test-card checkout. Task 9 documents that manual pass; it is verified by the operator, not here. Everything in tasks 1–8 is written and **compiled**, and exercised by hand-crafted payloads through `curl` against a scratch database where possible. + +--- + +## File Structure + +**Created:** + +| Path | Responsibility | +|---|---| +| `admin/internal/paddle/client.go` | the `Client` interface, our own request/response types, and `Init` | +| `admin/internal/paddle/sdk.go` | the one adapter binding `Client` to `github.com/PaddleHQ/paddle-go-sdk` — the ONLY file that imports the SDK | +| `admin/internal/paddle/webhook.go` | signature verification of a raw request body | +| `admin/internal/billing/events.go` | idempotent claim of an event ID and the dispatch switch | +| `admin/internal/billing/subscription.go` | `subscription.*` → entitlement promotion + reissue, as a function of current state | +| `admin/internal/billing/transaction.go` | renewal and payment-failure handling | +| `admin/internal/billing/deliver.go` | inject for cloud, email the blob for self-hosted, from a background context | +| `admin/internal/api/paddle.go` | `POST /api/paddle/webhook`: read raw body, verify, claim, dispatch | +| `admin/internal/api/checkout.go` | `GET /api/checkout/options`, `POST /api/instances/self-hosted`, `PUT /api/instances/:id/entitlement`, `POST /api/billing/portal` | +| `adminsite/lib/paddle.ts` | memoised `initializePaddle` | +| `adminsite/components/CheckoutButton.tsx` | opens the overlay with the resolved line items and `custom_data` | +| `adminsite/app/(customer)/purchase/page.tsx` + `PurchaseForm.tsx` | self-hosted purchase: configure, checkout, then link | + +**Modified:** + +| Path | Change | +|---|---| +| `admin/go.mod`, `admin/go.sum` | the Paddle SDK | +| `admin/internal/config/config.go` | `PaddleEnv`, `PaddleAPIKey`, `PaddleWebhookSecret` (all required), read into `Config` | +| `admin/internal/models/models.go` | `PaddleEvent`, term + subscription-status constants, `Subscription` gains `Items`, `Instance` gains `Placeholder` | +| `admin/internal/db/db.go` | unique index on `paddle_events.event_id` | +| `admin/internal/mail/mail.go` | `SendCancelled`, `SendPastDue`, `SendLinkReminder` | +| `admin/internal/lifecycle/lifecycle.go` | awaiting-link sweep: backstop issuance from the entitlement, plus 24h/72h reminders | +| `admin/internal/api/customer.go` | `linkInstance` claims a placeholder + issues when a subscription is already recorded | +| `admin/internal/api/staff.go` | `staffBillingHealth` | +| `admin/internal/api/routes.go` | the webhook route, three customer routes, one staff route | +| `admin/cmd/main.go` | `paddle.Init` before the server starts | +| `adminsite/package.json`, `adminsite/Dockerfile` | `@paddle/paddle-js`; `NEXT_PUBLIC_PADDLE_CLIENT_TOKEN` + `NEXT_PUBLIC_PADDLE_ENV` build args | +| `adminsite/lib/api.ts` | checkout-options + self-hosted-create + entitlement + portal calls and types | +| `adminsite/components/InstanceRecord.tsx` | an upgrade/configure entry on a cloud instance | +| `CLAUDE.md`, `docs/superpowers/specs/README.md` | the webhook, the new collections and env vars, spec status | + +--- + +### Task 1: The Paddle client, config, event model, and index + +**Files:** +- Create: `admin/internal/paddle/client.go`, `admin/internal/paddle/sdk.go`, `admin/internal/paddle/webhook.go` +- Modify: `admin/internal/config/config.go`, `admin/internal/models/models.go`, `admin/internal/db/db.go`, `admin/cmd/main.go`, `admin/go.mod` + +**Interfaces:** +- Produces: + - `paddle.Client` interface: `UpdateSubscriptionItems(ctx, paddleSubID string, items []paddle.LineItem) error`, `PortalSession(ctx, paddleCustomerID string) (string, error)`, `Env() string` + - `paddle.LineItem{PriceID string; Quantity int}` + - `paddle.Init(cfg) (Client, error)` and package var access via `paddle.Get()` + - `paddle.VerifySignature(secret string, header string, body []byte) bool` + - `models.PaddleEvent`, `models.ClaimEvent(ctx, eventID, eventType string) (claimed bool, err error)` + - subscription-status constants `models.SubActive`, `SubCanceled`, `SubPastDue`, `SubTrialing`; term constants `models.TermMonthly = "monthly"`, `TermAnnual = "annual"` + - `models.Subscription.Items []models.SubItem` where `SubItem{PriceID string; Quantity int}` + - `models.Instance.Placeholder bool` + - `config.Config` fields `PaddleEnv`, `PaddleAPIKey`, `PaddleClientToken` (browser token is adminsite build-time, not read here), `PaddleWebhookSecret` + +- [ ] **Step 1: Add config fields** + +In `admin/internal/config/config.go`, add to `Config`: + +```go + PaddleEnv string // "sandbox" or "production" + PaddleAPIKey string + PaddleWebhookSecret string +``` + +In `Load`, read them: + +```go + PaddleEnv: envOr("PADDLE_ENV", "sandbox"), + PaddleAPIKey: os.Getenv("PADDLE_API_KEY"), + PaddleWebhookSecret: os.Getenv("PADDLE_WEBHOOK_SECRET"), +``` + +Add both secrets to the existing `missing` required-var check (follow the file's pattern for appending to `missing`), because an unverified webhook endpoint is one anyone can issue licences through: + +```go + if c.PaddleAPIKey == "" { + missing = append(missing, "PADDLE_API_KEY") + } + if c.PaddleWebhookSecret == "" { + missing = append(missing, "PADDLE_WEBHOOK_SECRET") + } +``` + +- [ ] **Step 2: The client interface and our own types** + +Create `admin/internal/paddle/client.go`: + +```go +// Package paddle is the only place that talks to Paddle. Everything outside it +// depends on the Client interface and our own types, never on the SDK — so a +// change to the SDK surface is confined to sdk.go, and the billing package can +// be reasoned about without knowing Paddle exists. +package paddle + +import "context" + +// LineItem is one price at a quantity, the shape both a checkout and a +// subscription update are built from. +type LineItem struct { + PriceID string + Quantity int +} + +// Client is the narrow slice of Paddle admin needs. Checkout itself happens in +// the browser via paddle-js; the server only updates an existing subscription +// and mints a portal session. +type Client interface { + // UpdateSubscriptionItems replaces a subscription's items, prorated + // immediately by Paddle. This is the one outbound mutation, used when a + // customer changes their server count or features on an existing plan. + UpdateSubscriptionItems(ctx context.Context, paddleSubscriptionID string, items []LineItem) error + // PortalSession returns a customer-portal URL for managing billing. + PortalSession(ctx context.Context, paddleCustomerID string) (string, error) + // Env is "sandbox" or "production", the same value catalogue price lookups + // are keyed on. + Env() string +} + +var current Client + +// Init constructs the client from config and stores it. Called once at boot. +func Init(apiKey, env string) (Client, error) { + c, err := newSDKClient(apiKey, env) + if err != nil { + return nil, err + } + current = c + return c, nil +} + +// Get returns the client initialised at boot. Panics if unset, which can only +// happen if a caller runs before Init — a programming error, not a runtime one. +func Get() Client { + if current == nil { + panic("paddle.Get before paddle.Init") + } + return current +} +``` + +- [ ] **Step 3: The SDK adapter (the only file importing the SDK)** + +Create `admin/internal/paddle/sdk.go`. **The exact SDK call shapes below must be verified against `github.com/PaddleHQ/paddle-go-sdk`'s current docs during implementation** — this file is deliberately the only place that risk lives. Structure it so the interface it satisfies never changes even if the calls do: + +```go +package paddle + +import ( + "context" + "fmt" + + paddlesdk "github.com/PaddleHQ/paddle-go-sdk/v4" +) + +type sdkClient struct { + sdk *paddlesdk.SDK + env string +} + +func newSDKClient(apiKey, env string) (Client, error) { + base := paddlesdk.SandboxBaseURL + if env == "production" { + base = paddlesdk.ProductionBaseURL + } + sdk, err := paddlesdk.New(apiKey, paddlesdk.WithBaseURL(base)) + if err != nil { + return nil, fmt.Errorf("paddle sdk: %w", err) + } + return &sdkClient{sdk: sdk, env: env}, nil +} + +func (c *sdkClient) Env() string { return c.env } + +func (c *sdkClient) UpdateSubscriptionItems(ctx context.Context, subID string, items []LineItem) error { + reqItems := make([]paddlesdk.UpdateSubscriptionItems, 0, len(items)) + for _, it := range items { + reqItems = append(reqItems, paddlesdk.NewUpdateSubscriptionItemsCatalogItem(&paddlesdk.CatalogItem{ + PriceID: it.PriceID, + Quantity: it.Quantity, + })) + } + _, err := c.sdk.UpdateSubscription(ctx, &paddlesdk.UpdateSubscriptionRequest{ + SubscriptionID: subID, + Items: reqItems, + ProrationBillingMode: ptr(paddlesdk.ProrationBillingModeProratedImmediately), + }) + if err != nil { + return fmt.Errorf("update subscription %s: %w", subID, err) + } + return nil +} + +func (c *sdkClient) PortalSession(ctx context.Context, customerID string) (string, error) { + res, err := c.sdk.CreateCustomerPortalSession(ctx, &paddlesdk.CreateCustomerPortalSessionRequest{ + CustomerID: customerID, + }) + if err != nil { + return "", fmt.Errorf("portal session for %s: %w", customerID, err) + } + return res.URLs.General.Overview, nil +} + +func ptr[T any](v T) *T { return &v } +``` + +If a symbol above does not exist under that exact name in the installed SDK version, adjust *this file only* until `go build` passes; the `Client` interface must not change. + +- [ ] **Step 4: Webhook signature verification** + +Create `admin/internal/paddle/webhook.go`. Paddle signs with an HMAC-SHA256 over `ts:body`, carried in the `Paddle-Signature` header as `ts=;h1=`: + +```go +package paddle + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "strings" +) + +// VerifySignature checks a raw webhook body against the Paddle-Signature header. +// +// It uses a constant-time compare and never logs the secret. A false return is +// always a 401 with nothing processed — an unverified body could be anyone +// claiming a subscription was paid for. +func VerifySignature(secret, header string, body []byte) bool { + if secret == "" || header == "" { + return false + } + var ts, h1 string + for _, part := range strings.Split(header, ";") { + k, v, ok := strings.Cut(part, "=") + if !ok { + continue + } + switch k { + case "ts": + ts = v + case "h1": + h1 = v + } + } + if ts == "" || h1 == "" { + return false + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(ts)) + mac.Write([]byte(":")) + mac.Write(body) + want := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(want), []byte(h1)) +} +``` + +- [ ] **Step 5: The event, subscription, term and instance model changes** + +In `admin/internal/models/models.go`, add constants: + +```go +// Subscription statuses, mirrored from Paddle. Ours, not the SDK's, so the +// billing package does not import the SDK. +const ( + SubActive = "active" + SubCanceled = "canceled" + SubPastDue = "past_due" + SubTrialing = "trialing" +) + +// Billing terms. These match catalogue price-ID keys and license.TermsFor. +const ( + TermMonthly = "monthly" + TermAnnual = "annual" +) +``` + +Add `PaddleEvent` and `SubItem`: + +```go +// PaddleEvent is the idempotency record for one webhook delivery. The unique +// index on EventID is what makes a retry a no-op rather than a second licence. +type PaddleEvent struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + EventID string `bson:"event_id" json:"event_id"` + EventType string `bson:"event_type" json:"event_type"` + ReceivedAt time.Time `bson:"received_at" json:"received_at"` + ProcessedAt *time.Time `bson:"processed_at,omitempty" json:"processed_at,omitempty"` + Error string `bson:"error,omitempty" json:"error,omitempty"` +} + +// SubItem is one line of a subscription: a price and its quantity, the shape +// catalogue.ResolveItems reads back into a plan and configuration. +type SubItem struct { + PriceID string `bson:"price_id" json:"price_id"` + Quantity int `bson:"quantity" json:"quantity"` +} +``` + +Replace the `Subscription.PaddlePriceID` single-price field with the item list (a metered subscription has several): + +```go + // Items is the full line-item list. Spec 7 made a subscription several + // prices — a base, a per-server unit at quantity N, an item per paid + // feature — so a single price ID can no longer describe it. + Items []SubItem `bson:"items,omitempty" json:"items,omitempty"` +``` + +Add to `Instance`: + +```go + // Placeholder is true while a self-hosted instance row exists only so a + // checkout has something to attach custom_data to, before the customer has + // pasted their install's real UUID. Cleared by ClaimPlaceholder. + Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"` +``` + +- [ ] **Step 6: ClaimEvent and the index** + +Create `admin/internal/models/paddle_events.go`: + +```go +package models + +import ( + "context" + "errors" + "time" + + "github.com/mrhid6/vantage/admin/internal/db" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// ClaimEvent records an event ID before it is processed and reports whether THIS +// call is the one that claimed it. +// +// The unique index on event_id turns a duplicate insert into a duplicate-key +// error, which is the signal that another delivery of the same event already +// owns it — so this returns (false, nil) and the caller answers 200 without +// acting. A genuine error returns (false, err). +func ClaimEvent(ctx context.Context, eventID, eventType string) (bool, error) { + _, err := db.Admin("paddle_events").InsertOne(ctx, PaddleEvent{ + EventID: eventID, + EventType: eventType, + ReceivedAt: time.Now().UTC(), + }) + if err == nil { + return true, nil + } + if mongo.IsDuplicateKeyError(err) { + return false, nil + } + return false, err +} + +// MarkEventProcessed stamps success, or records the error for staff visibility. +// A failed event keeps no processed_at, so a retry re-runs it. +func MarkEventProcessed(ctx context.Context, eventID string, procErr error) error { + set := bson.M{} + if procErr != nil { + set["error"] = procErr.Error() + } else { + now := time.Now().UTC() + set["processed_at"] = now + set["error"] = "" + } + _, err := db.Admin("paddle_events").UpdateOne(ctx, + bson.M{"event_id": eventID}, bson.M{"$set": set}) + return err +} + +// ErrEventClaimed is returned by callers that want to distinguish a benign +// duplicate from a failure. +var ErrEventClaimed = errors.New("event already claimed") +``` + +In `admin/internal/db/db.go`, add `paddle_events.event_id` to the `unique` slice in `EnsureIndexes`: + +```go + {"paddle_events", "event_id"}, +``` + +- [ ] **Step 7: Init at boot** + +In `admin/cmd/main.go`, after config load and before `api.Routes`, add: + +```go + if _, err := paddle.Init(cfg.PaddleAPIKey, cfg.PaddleEnv); err != nil { + log.Fatalf("paddle init: %v", err) + } +``` + +Add the import `"github.com/mrhid6/vantage/admin/internal/paddle"`. + +- [ ] **Step 8: Add the SDK and confirm it builds** + +```bash +GOWORK=off /tmp/gorun.sh admin sh -c "go get github.com/PaddleHQ/paddle-go-sdk/v4 && go mod tidy" +GOWORK=off /tmp/gorun.sh admin go build ./... +``` +Expected: no output. If the SDK's major version or symbol names differ, fix `sdk.go` only. + +- [ ] **Step 9: Commit** + +```bash +git add admin/internal/paddle admin/internal/config admin/internal/models admin/internal/db admin/cmd/main.go admin/go.mod admin/go.sum +git commit -m "feat(admin): Paddle client behind an interface, config, and the event idempotency record" +``` + +--- + +### Task 2: The webhook endpoint — verify, claim, dispatch + +**Files:** +- Create: `admin/internal/billing/events.go`, `admin/internal/api/paddle.go` +- Modify: `admin/internal/api/routes.go` + +**Interfaces:** +- Consumes: `paddle.VerifySignature`, `models.ClaimEvent`, `models.MarkEventProcessed`. +- Produces: + - `billing.Event` — the decoded envelope `{EventID, EventType string; Data json.RawMessage; OccurredAt time.Time}` + - `billing.Dispatch(ctx context.Context, ev billing.Event) error` + +- [ ] **Step 1: The envelope and dispatch switch** + +Create `admin/internal/billing/events.go`: + +```go +// Package billing turns verified Paddle webhooks into licence actions. It never +// verifies signatures (that is paddle.VerifySignature at the edge) and never +// signs (that is licensing.Issue); it decides what a subscription's current +// state means and calls the issuer. +package billing + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// Event is the decoded Paddle webhook envelope. Data is left raw so each handler +// decodes only the shape it needs. +type Event struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + OccurredAt time.Time `json:"occurred_at"` + Data json.RawMessage `json:"data"` +} + +// Dispatch routes one event to its handler. Unknown event types are a no-op +// success: Paddle sends many we do not care about, and 200 stops it retrying. +func Dispatch(ctx context.Context, ev Event) error { + switch ev.EventType { + case "subscription.created", "subscription.updated", "subscription.activated": + return handleSubscription(ctx, ev) + case "subscription.canceled": + return handleCanceled(ctx, ev) + case "subscription.past_due": + return handlePastDue(ctx, ev) + case "transaction.completed": + return handleTransactionCompleted(ctx, ev) + case "transaction.payment_failed": + return handlePaymentFailed(ctx, ev) + case "customer.updated": + return handleCustomerUpdated(ctx, ev) + default: + return nil + } +} + +// decode is a small helper so every handler decodes Data the same way. +func decode[T any](ev Event) (T, error) { + var v T + if err := json.Unmarshal(ev.Data, &v); err != nil { + return v, fmt.Errorf("decode %s: %w", ev.EventType, err) + } + return v, nil +} +``` + +(The handler functions `handleSubscription`, `handleCanceled`, `handlePastDue`, `handleTransactionCompleted`, `handlePaymentFailed`, `handleCustomerUpdated` are written in tasks 3 and 4. This task stubs them to compile — see step 2.) + +- [ ] **Step 2: Temporary compiling stubs** + +At the bottom of `events.go`, add stubs so this task builds independently; tasks 3–4 replace them: + +```go +// Stubs replaced in tasks 3 and 4. +func handleSubscription(ctx context.Context, ev Event) error { return nil } +func handleCanceled(ctx context.Context, ev Event) error { return nil } +func handlePastDue(ctx context.Context, ev Event) error { return nil } +func handleTransactionCompleted(ctx context.Context, ev Event) error { return nil } +func handlePaymentFailed(ctx context.Context, ev Event) error { return nil } +func handleCustomerUpdated(ctx context.Context, ev Event) error { return nil } +``` + +- [ ] **Step 3: The endpoint** + +Create `admin/internal/api/paddle.go`: + +```go +package api + +import ( + "encoding/json" + "io" + "log" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/admin/internal/billing" + "github.com/mrhid6/vantage/admin/internal/config" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/admin/internal/paddle" +) + +// paddleWebhook is the ingress for every Paddle event. +// +// Order is load-bearing: read the RAW body first (signature is over the exact +// bytes), verify, THEN claim the event ID, THEN dispatch. A bad signature is +// 401 and processes nothing; a duplicate of a handled event is 200 and does +// nothing; a handler error is 500 so Paddle retries, and is recorded for staff. +func paddleWebhook(cfg config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "unreadable body"}) + return + } + if !paddle.VerifySignature(cfg.PaddleWebhookSecret, + c.GetHeader("Paddle-Signature"), body) { + log.Printf("paddle webhook: bad signature from %s", c.ClientIP()) + c.JSON(http.StatusUnauthorized, gin.H{"error": "bad signature"}) + return + } + + var ev billing.Event + if err := json.Unmarshal(body, &ev); err != nil || ev.EventID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "malformed event"}) + return + } + + ctx := c.Request.Context() + claimed, err := models.ClaimEvent(ctx, ev.EventID, ev.EventType) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"}) + return + } + if !claimed { + // Already handled (or in flight). 200 so Paddle stops retrying. + c.JSON(http.StatusOK, gin.H{"duplicate": true}) + return + } + + if err := billing.Dispatch(ctx, ev); err != nil { + log.Printf("paddle webhook: handler %s failed for %s: %v", + ev.EventType, ev.EventID, err) + _ = models.MarkEventProcessed(ctx, ev.EventID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "handler failed"}) + return + } + _ = models.MarkEventProcessed(ctx, ev.EventID, nil) + c.JSON(http.StatusOK, gin.H{"ok": true}) + } +} +``` + +- [ ] **Step 4: Mount the route (public, unsigned bodies allowed in)** + +In `admin/internal/api/routes.go`, beside the other `r.POST("/auth/...` public routes, add: + +```go + r.POST("/api/paddle/webhook", paddleWebhook(cfg)) +``` + +It must NOT be under the `cust` (`/api`) group's session middleware — Paddle carries no session cookie; its signature is its auth. + +- [ ] **Step 5: Confirm** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... +``` +Expected: no output. + +Against admin running on a scratch database, a bad signature is rejected and a duplicate is a no-op: + +```bash +curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8083/api/paddle/webhook \ + -H 'Paddle-Signature: ts=1;h1=deadbeef' -d '{"event_id":"evt_x","event_type":"customer.updated","data":{}}' +``` +Expected: `401`. + +(A correctly-signed duplicate check needs the real secret; it is exercised in task 9's sandbox pass. The idempotency LOGIC is unit-visible: `ClaimEvent` twice against the scratch DB returns `true` then `false` — confirm with a tiny throwaway `curl` once a valid signature path exists, or by inserting the same `event_id` twice with `mongosh` and watching the second collide.) + +- [ ] **Step 6: Commit** + +```bash +git add admin/internal/billing/events.go admin/internal/api/paddle.go admin/internal/api/routes.go +git commit -m "feat(admin): Paddle webhook ingress — verify, idempotent claim, dispatch" +``` + +--- + +### Task 3: Subscription events → entitlement promotion and reissue + +**Files:** +- Modify: `admin/internal/billing/subscription.go` (create), `admin/internal/billing/events.go` (remove the three subscription stubs) +- Create: `admin/internal/billing/deliver.go` + +**Interfaces:** +- Consumes: `catalogue.ResolveItems`, `catalogue.Match`, `models.GetPlan`, `models.GetEntitlement`, `models.UpsertEntitlement`, `models.Config`, `licensing.Issue`, `paddle.Get().Env()`, `inject.Deliver`, `mail.SendLicense`. +- Produces: + - `billing.deliver(ctx, inst *models.Instance, lic *models.License)` + - the real `handleSubscription`, `handleCanceled`, `handlePastDue` + +- [ ] **Step 1: Background delivery** + +Create `admin/internal/billing/deliver.go`: + +```go +package billing + +import ( + "context" + + "github.com/mrhid6/vantage/admin/internal/inject" + "github.com/mrhid6/vantage/admin/internal/mail" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" +) + +// deliver sends a freshly issued licence where it belongs. Cloud is injected; +// self-hosted is emailed the blob (their database is theirs). This mirrors the +// api-side deliver helper but takes no gin context — webhooks have none, and the +// customer is not on the other end of the request. +func deliver(ctx context.Context, inst *models.Instance, lic *models.License, to string) { + if inst.Deployment == license.DeploymentCloud { + inject.Deliver(ctx, lic) + return + } + if to != "" && mail.Enabled() { + _ = mail.SendLicense(to, inst.Name, lic.Blob) + } +} +``` + +- [ ] **Step 2: The subscription payload shape and resolution** + +Create `admin/internal/billing/subscription.go`. Paddle's `subscription.*` data carries `id`, `customer_id`, `status`, `current_billing_period.ends_at`, `custom_data` and `items[]` each with `price.id` and `quantity`: + +```go +package billing + +import ( + "context" + "fmt" + "time" + + "github.com/mrhid6/vantage/admin/internal/catalogue" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/licensing" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/admin/internal/paddle" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// subscriptionData is the slice of Paddle's subscription payload we read. Fields +// we ignore are simply absent — encoding/json drops them. +type subscriptionData struct { + ID string `json:"id"` + CustomerID string `json:"customer_id"` + Status string `json:"status"` + CustomData struct { + AccountID string `json:"account_id"` + InstanceID string `json:"instance_id"` + } `json:"custom_data"` + CurrentBillingPeriod struct { + EndsAt time.Time `json:"ends_at"` + } `json:"current_billing_period"` + Items []struct { + Price struct { + ID string `json:"id"` + } `json:"price"` + Quantity int `json:"quantity"` + } `json:"items"` +} + +func (d subscriptionData) lineItems() []catalogue.Item { + items := make([]catalogue.Item, 0, len(d.Items)) + for _, it := range d.Items { + items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity}) + } + return items +} + +// handleSubscription is created/updated/activated — all three are folded into +// "make the world match the subscription's CURRENT state", which is what keeps +// out-of-order delivery correct: an updated arriving before its created still +// carries the full item list, so reading all of it is reading current state. +func handleSubscription(ctx context.Context, ev Event) error { + d, err := decode[subscriptionData](ev) + if err != nil { + return err + } + if d.CustomData.InstanceID == "" { + return fmt.Errorf("subscription %s has no instance_id in custom_data", d.ID) + } + + match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), d.lineItems()) + if err != nil { + // A price we cannot map is a configuration error, not a customer error. + // Fail loudly so it is retried and surfaced rather than guessed. + return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err) + } + + // Record the subscription first, as current state. This is idempotent: the + // same event replayed writes the same row. + sub := models.Subscription{ + AccountID: d.CustomData.AccountID, + InstanceID: d.CustomData.InstanceID, + PaddleSubscriptionID: d.ID, + Tier: match.Tier, + Term: match.Term, + Status: d.Status, + CurrentPeriodEnd: d.CurrentBillingPeriod.EndsAt, + Items: toSubItems(d.lineItems()), + } + if err := upsertSubscription(ctx, sub); err != nil { + return err + } + + // Learn the Paddle customer ID onto the account the first time we see it. + if d.CustomerID != "" && d.CustomData.AccountID != "" { + _, _ = db.Admin("accounts").UpdateOne(ctx, + bson.M{"account_id": d.CustomData.AccountID, "paddle_customer_id": bson.M{"$in": bson.A{nil, ""}}}, + bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}}) + } + + // Load the instance. A self-hosted placeholder that has not been linked yet + // gets its subscription recorded but NO licence — there is no UUID to bind + // to. The link endpoint (task 6) issues when the customer pastes it. + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": d.CustomData.InstanceID}).Decode(&inst); err != nil { + return fmt.Errorf("subscription %s names unknown instance %s: %w", + d.ID, d.CustomData.InstanceID, err) + } + if inst.Placeholder { + return nil // awaiting link; nothing to issue yet + } + + return promoteAndIssue(ctx, &inst, match, models.ReasonEntitlementChange) +} + +// promoteAndIssue promotes desired→granted from the resolved match, then signs a +// licence from granted. This is the ONLY promotion path other than the staff +// grant, and it exists because a webhook is a confirmed payment. +func promoteAndIssue(ctx context.Context, inst *models.Instance, match catalogue.Match, reason string) error { + plan, err := models.GetPlan(ctx, inst.Deployment, match.Tier) + if err != nil { + return fmt.Errorf("no plan for %s/%s: %w", inst.Deployment, match.Tier, err) + } + granted := models.Config{ + Servers: match.Servers, + Features: models.Features(match.Features).OrEmpty(), + } + limits, _, err := catalogue.Resolve(ctx, plan, granted) + if err != nil { + return err + } + if err := models.UpsertEntitlement(ctx, models.Entitlement{ + InstanceID: inst.InstanceID, + AccountID: inst.AccountID, + Deployment: inst.Deployment, + Tier: match.Tier, + Term: match.Term, + Desired: granted, + Granted: granted, + ResolvedLimits: limits, + }); err != nil { + return err + } + + lic, err := licensing.Issue(ctx, licensing.IssueInput{ + InstanceID: inst.InstanceID, + Tier: match.Tier, + Term: match.Term, + Reason: reason, + IssuedBy: "paddle", + }) + if err != nil { + return fmt.Errorf("issue for %s: %w", inst.InstanceID, err) + } + deliver(ctx, inst, lic, billingEmailFor(ctx, inst.AccountID)) + return nil +} + +func toSubItems(items []catalogue.Item) []models.SubItem { + out := make([]models.SubItem, 0, len(items)) + for _, it := range items { + out = append(out, models.SubItem{PriceID: it.PriceID, Quantity: it.Quantity}) + } + return out +} + +func upsertSubscription(ctx context.Context, sub models.Subscription) error { + _, err := db.Admin("subscriptions").UpdateOne(ctx, + bson.M{"paddle_subscription_id": sub.PaddleSubscriptionID}, + bson.M{"$set": bson.M{ + "account_id": sub.AccountID, + "instance_id": sub.InstanceID, + "tier": sub.Tier, + "term": sub.Term, + "status": sub.Status, + "current_period_end": sub.CurrentPeriodEnd, + "items": sub.Items, + }, "$setOnInsert": bson.M{ + "subscription_id": newSubID(), + "paddle_subscription_id": sub.PaddleSubscriptionID, + }}, + options.UpdateOne().SetUpsert(true)) + return err +} + +// billingEmailFor reads the account's billing email for self-hosted delivery. +func billingEmailFor(ctx context.Context, accountID string) string { + var acc models.Account + if err := db.Admin("accounts").FindOne(ctx, + bson.M{"account_id": accountID}).Decode(&acc); err != nil { + return "" + } + return acc.BillingEmail +} +``` + +Add a `newSubID` helper (uuid) if one does not already exist in the package; reuse the existing generator the codebase uses for subscription IDs if there is one — check `staff.go`/`customer.go` for how `subscription_id` is currently minted and match it. + +- [ ] **Step 3: Cancel and past-due — no licence action** + +Append to `subscription.go`: + +```go +// handleCanceled marks the SUBSCRIPTION cancelled and takes NO licence action. +// +// The instance stays active until its licence expires, at which point the +// existing lifecycle sweep lapses it. Flipping the instance to cancelled here +// would stop inject.Reconcile and the sweep repairing a licence that is still +// valid — the opposite of "keeps working until it expires". +func handleCanceled(ctx context.Context, ev Event) error { + d, err := decode[subscriptionData](ev) + if err != nil { + return err + } + if _, err := db.Admin("subscriptions").UpdateOne(ctx, + bson.M{"paddle_subscription_id": d.ID}, + bson.M{"$set": bson.M{"status": models.SubCanceled}}); err != nil { + return err + } + if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" { + _ = sendCancelled(to, d.CustomData.InstanceID) + } + return nil +} + +// handlePastDue flags the subscription and notifies, but leaves the licence +// alone. Dunning is Paddle's; ours is not to punish a retryable card failure. +func handlePastDue(ctx context.Context, ev Event) error { + d, err := decode[subscriptionData](ev) + if err != nil { + return err + } + if _, err := db.Admin("subscriptions").UpdateOne(ctx, + bson.M{"paddle_subscription_id": d.ID}, + bson.M{"$set": bson.M{"status": models.SubPastDue}}); err != nil { + return err + } + if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" { + _ = sendPastDue(to, d.CustomData.InstanceID) + } + return nil +} +``` + +`sendCancelled`/`sendPastDue` are thin wrappers over the mail functions added in task 6; declare them there. For this task to compile, add the mail functions in task 6 *first*, or add temporary local wrappers — the plan orders task 6's mail additions before this compiles cleanly, so add the three mail funcs now (they are small; see task 6 step 1) if executing strictly in order. + +- [ ] **Step 4: `customer.updated`** + +Append: + +```go +// handleCustomerUpdated syncs the billing email onto the account. +func handleCustomerUpdated(ctx context.Context, ev Event) error { + d, err := decode[struct { + ID string `json:"id"` + Email string `json:"email"` + }](ev) + if err != nil { + return err + } + if d.ID == "" || d.Email == "" { + return nil + } + _, err = db.Admin("accounts").UpdateOne(ctx, + bson.M{"paddle_customer_id": d.ID}, + bson.M{"$set": bson.M{"billing_email": d.Email}}) + return err +} +``` + +Remove the corresponding stubs from `events.go`. + +- [ ] **Step 5: Confirm** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... +GOWORK=off /tmp/gorun.sh admin go vet ./... +``` +Expected: no output. + +`grep` the two guard rails: + +```bash +grep -rn "expires_at\|ExpiresAt" admin/internal/billing/ +``` +Expected: no assignment of an earlier expiry — cancel/past-due touch only `status`. + +```bash +grep -rn "\.Desired" admin/internal/billing/ +``` +Expected: no read of `Desired` — billing signs from `granted` only (`promoteAndIssue` writes both from the confirmed match). + +- [ ] **Step 6: Commit** + +```bash +git add admin/internal/billing +git commit -m "feat(admin): subscription webhooks promote the entitlement and reissue; cancel and past-due take no licence action" +``` + +--- + +### Task 4: Renewals and payment failures + +**Files:** +- Modify: `admin/internal/billing/transaction.go` (create), `admin/internal/billing/events.go` (remove the two transaction stubs) + +**Interfaces:** +- Consumes: `catalogue.ResolveItems`, `licensing.Issue` with `Reason: models.ReasonRenewal`, `models.GetEntitlement`, `models.UpsertEntitlement`. +- Produces: `handleTransactionCompleted`, `handlePaymentFailed`. + +- [ ] **Step 1: Renewal** + +Create `admin/internal/billing/transaction.go`. A renewal `transaction.completed` carries `subscription_id` and `origin` (`subscription_recurring` marks a renewal rather than the first charge): + +```go +package billing + +import ( + "context" + "fmt" + "time" + + "github.com/mrhid6/vantage/admin/internal/catalogue" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/licensing" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/admin/internal/paddle" + "go.mongodb.org/mongo-driver/v2/bson" +) + +type transactionData struct { + ID string `json:"id"` + SubscriptionID string `json:"subscription_id"` + Origin string `json:"origin"` + CustomData struct { + AccountID string `json:"account_id"` + InstanceID string `json:"instance_id"` + } `json:"custom_data"` + Items []struct { + Price struct { + ID string `json:"id"` + } `json:"price"` + Quantity int `json:"quantity"` + } `json:"items"` + BillingPeriod struct { + EndsAt time.Time `json:"ends_at"` + } `json:"billing_period"` +} + +// handleTransactionCompleted issues the next term's licence on a renewal. +// +// A renewal is the one moment a scheduled REDUCTION takes effect: the customer's +// desired (smaller) configuration becomes granted, and only now — mid-term +// reductions never shrink a live licence. On a first charge (origin not +// recurring) the subscription.created/updated handler already issued, so this is +// a no-op to avoid a double issue. +func handleTransactionCompleted(ctx context.Context, ev Event) error { + d, err := decode[transactionData](ev) + if err != nil { + return err + } + if d.Origin != "subscription_recurring" { + return nil + } + if d.SubscriptionID == "" { + return fmt.Errorf("renewal transaction %s has no subscription_id", d.ID) + } + + var sub models.Subscription + if err := db.Admin("subscriptions").FindOne(ctx, + bson.M{"paddle_subscription_id": d.SubscriptionID}).Decode(&sub); err != nil { + return fmt.Errorf("renewal for unknown subscription %s: %w", d.SubscriptionID, err) + } + + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": sub.InstanceID}).Decode(&inst); err != nil { + return fmt.Errorf("renewal names unknown instance %s: %w", sub.InstanceID, err) + } + + // Prefer the transaction's own item list (authoritative for this period); + // fall back to the subscription's recorded items. + items := make([]catalogue.Item, 0, len(d.Items)) + for _, it := range d.Items { + items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity}) + } + if len(items) == 0 { + for _, it := range sub.Items { + items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity}) + } + } + match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items) + if err != nil { + return fmt.Errorf("resolve renewal items for %s: %w", d.SubscriptionID, err) + } + + // Promote a scheduled reduction: desired becomes granted, and the pending + // marker is cleared, since a new term has begun. This is the only place a + // licence ever gets a smaller cap. + if err := promoteScheduledReduction(ctx, inst.InstanceID); err != nil { + return err + } + + // Issue the next term. Renewal resets relink_count inside licensing.Issue. + if err := promoteAndIssue(ctx, &inst, match, models.ReasonRenewal); err != nil { + return err + } + + // Clear lifecycle notices so the next term starts the sequence fresh (mirrors + // the self-serve renew path). + _, _ = db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, + bson.M{"$unset": bson.M{"notices_sent": ""}}) + return nil +} + +// promoteScheduledReduction collapses a pending reduction into granted at +// renewal and clears scheduled_change_at. If there is no pending reduction it is +// a no-op — the match resolved from the renewal's items is authoritative either +// way, so this only matters for the entitlement's own bookkeeping. +func promoteScheduledReduction(ctx context.Context, instanceID string) error { + ent, err := models.GetEntitlement(ctx, instanceID) + if err != nil { + return nil // no entitlement to reconcile + } + if ent.ScheduledChangeAt == nil { + return nil + } + ent.Granted = ent.Desired + ent.ScheduledChangeAt = nil + return models.UpsertEntitlement(ctx, *ent) +} + +// handlePaymentFailed records the failure for staff visibility. No licence +// action — the licence runs to its (already grace-padded) expiry and Paddle +// retries the charge. +func handlePaymentFailed(ctx context.Context, ev Event) error { + d, err := decode[transactionData](ev) + if err != nil { + return err + } + if d.SubscriptionID == "" { + return nil + } + _, err = db.Admin("subscriptions").UpdateOne(ctx, + bson.M{"paddle_subscription_id": d.SubscriptionID}, + bson.M{"$set": bson.M{"status": models.SubPastDue}}) + return err +} +``` + +Remove the two transaction stubs from `events.go`. + +- [ ] **Step 2: Confirm** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... +``` +Expected: no output. + +```bash +grep -rn "ReasonRenewal" admin/internal/billing/transaction.go +``` +Expected: one hit — the renewal issue. A renewal that used `ReasonEntitlementChange` would not reset `relink_count`. + +- [ ] **Step 3: Commit** + +```bash +git add admin/internal/billing/transaction.go admin/internal/billing/events.go +git commit -m "feat(admin): renewals issue the next term and collapse a scheduled reduction; payment-failed flags only" +``` + +--- + +### Task 5: Checkout options, self-hosted placeholder, entitlement update, portal + +**Files:** +- Create: `admin/internal/api/checkout.go` +- Modify: `admin/internal/api/routes.go` + +**Interfaces:** +- Consumes: `models.AllCatalogue`/`CatalogueFor`, `models.GetPlan`, `catalogue.LineItems`, `paddle.Get()`, `ownedInstance`, `auth.Current`, `shared/provision` slug rules (reuse whatever `createInstance` uses to mint an instance id), `models.UpsertEntitlement`, `models.GetEntitlement`. +- Produces: + - `GET /api/checkout/options` → `{plans: []Plan, catalogue: []CatalogueRow, env: string, client_token_note}` (client token is baked into the browser build, not served) + - `POST /api/instances/self-hosted` → creates a placeholder instance, returns `{instance_id}` + - `PUT /api/instances/:id/entitlement` → sets `desired`, computes line items, calls `paddle.UpdateSubscriptionItems`, returns the entitlement + - `POST /api/billing/portal` → `{url}` + +- [ ] **Step 1: Checkout options** + +Create `admin/internal/api/checkout.go`: + +```go +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/admin/internal/paddle" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// checkoutOptions serves everything the browser configurator needs to price a +// plan: the six plans (base allowances), the full catalogue (component prices in +// the running environment), and the environment name so the client can refuse a +// mismatch. The client token itself is baked into the adminsite build, never +// served from here. +func checkoutOptions(c *gin.Context) { + ctx := c.Request.Context() + plans := []models.Plan{} + if cur, err := db.Admin("plans").Find(ctx, bson.M{"active": true}); err == nil { + _ = cur.All(ctx, &plans) + } + rows, err := models.AllCatalogue(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "plans": plans, + "catalogue": rows, + "env": paddle.Get().Env(), + }) +} +``` + +- [ ] **Step 2: Self-hosted placeholder creation** + +Append. Reuse the instance-id minting the shipped `linkInstance`/`createInstance` use — **read `customer.go` and match it exactly** rather than inventing a UUID scheme: + +```go +// createSelfHostedPlaceholder makes an instance row that exists only so a +// checkout has something to put in custom_data. It carries no licence and is +// flagged Placeholder until the customer pastes their install's real UUID +// (linkInstance, task 6). Status awaiting_link, deployment self_hosted. +func createSelfHostedPlaceholder(c *gin.Context) { + s := auth.Current(c) + var body struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"}) + return + } + ctx := c.Request.Context() + inst := models.Instance{ + InstanceID: newPlaceholderID(), // match the id scheme used elsewhere + AccountID: s.AccountID, + Name: body.Name, + Deployment: license.DeploymentSelfHosted, + Status: models.StatusAwaitingLink, + Placeholder: true, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + audit.Write(ctx, models.AuditEntry{ + Actor: s.Email, Action: "instance.placeholder_created", AccountID: s.AccountID, + Target: inst.InstanceID, IP: c.ClientIP()}) + c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID}) +} +``` + +Define `newPlaceholderID()` to match the codebase's existing instance-id generator (find it in `customer.go`/`cloudprov`). Add imports `time`, `license`, `auth`, `audit`, `models`, `db`. + +- [ ] **Step 3: Entitlement update — admin's one outbound Paddle call** + +Append. This is the only place admin mutates a live subscription: + +```go +// updateEntitlement sets an instance's DESIRED configuration and pushes the +// resulting line items to Paddle. It does NOT issue — the resulting +// subscription.updated webhook does, from granted. An increase is prorated +// immediately by Paddle; a reduction is recorded as desired and takes effect at +// renewal (promoteScheduledReduction), so this never shrinks a live licence. +func updateEntitlement(c *gin.Context) { + inst, ok := ownedInstance(c, c.Param("id")) + if !ok { + return + } + ctx := c.Request.Context() + var body struct { + Tier string `json:"tier"` + Term string `json:"term"` + Servers int `json:"servers"` + Features []string `json:"features"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid configuration"}) + return + } + + plan, err := models.GetPlan(ctx, inst.Deployment, body.Tier) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "no such plan"}) + return + } + if body.Servers < plan.BaseLimits.MaxServers && plan.BaseLimits.MaxServers != license.Unlimited { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("%s includes %d servers", plan.Name, plan.BaseLimits.MaxServers)}) + return + } + + desired := models.Config{Servers: body.Servers, Features: models.Features(body.Features).OrEmpty()} + items, err := catalogue.LineItems(ctx, paddle.Get().Env(), body.Term, plan, desired) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Find the live subscription to update. No subscription yet means this + // instance has never been paid for — that is a checkout, not an update. + var sub models.Subscription + if err := db.Admin("subscriptions").FindOne(ctx, + bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}).Decode(&sub); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "no active subscription; start a checkout instead"}) + return + } + + pItems := make([]paddle.LineItem, 0, len(items)) + for _, it := range items { + pItems = append(pItems, paddle.LineItem{PriceID: it.PriceID, Quantity: it.Quantity}) + } + if err := paddle.Get().UpdateSubscriptionItems(ctx, sub.PaddleSubscriptionID, pItems); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "billing update failed; nothing changed"}) + return + } + + // Record desired now; the webhook that Paddle sends back promotes to granted + // and reissues. Recording here makes the portal reflect the intent instantly + // rather than waiting on the webhook round-trip. + limits, _, _ := catalogue.Resolve(ctx, plan, desired) + ent, _ := models.GetEntitlement(ctx, inst.InstanceID) + next := models.Entitlement{ + InstanceID: inst.InstanceID, AccountID: inst.AccountID, + Deployment: inst.Deployment, Tier: body.Tier, Term: body.Term, + Desired: desired, ResolvedLimits: limits, + } + if ent != nil { + next.Granted = ent.Granted + next.GrantedAt = ent.GrantedAt + if desired.Servers < ent.Granted.Servers { + now := time.Now().UTC() + next.ScheduledChangeAt = &now + } + } else { + next.Granted = desired + } + if err := models.UpsertEntitlement(ctx, next); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + audit.Write(ctx, models.AuditEntry{ + Actor: auth.Current(c).Email, Action: "entitlement.requested", + AccountID: inst.AccountID, Target: inst.InstanceID}) + c.JSON(http.StatusOK, gin.H{"entitlement": next, "pending": next.Pending()}) +} +``` + +- [ ] **Step 4: Billing portal** + +Append: + +```go +// billingPortal mints a Paddle customer-portal URL. The account must already +// have a paddle_customer_id, which it learns from its first subscription webhook. +func billingPortal(c *gin.Context) { + s := auth.Current(c) + ctx := c.Request.Context() + var acc models.Account + if err := db.Admin("accounts").FindOne(ctx, + bson.M{"account_id": s.AccountID}).Decode(&acc); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no account"}) + return + } + if acc.PaddleCustomerID == "" { + c.JSON(http.StatusConflict, gin.H{"error": "no billing account yet; buy a paid plan first"}) + return + } + url, err := paddle.Get().PortalSession(ctx, acc.PaddleCustomerID) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "could not open billing portal"}) + return + } + c.JSON(http.StatusOK, gin.H{"url": url}) +} +``` + +- [ ] **Step 5: Routes** + +In `routes.go`, in the `cust` group: + +```go + cust.GET("/checkout/options", checkoutOptions) + cust.POST("/instances/self-hosted", + auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), + createSelfHostedPlaceholder) + cust.PUT("/instances/:id/entitlement", + auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), + updateEntitlement) + cust.POST("/billing/portal", billingPortal) +``` + +- [ ] **Step 6: Confirm** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... +``` +Expected: no output. + +Against a scratch DB with a staff session, `GET /api/checkout/options` returns six plans and sixteen catalogue rows; `POST /api/instances/self-hosted` returns an `instance_id` and leaves a `placeholder:true` row: + +```bash +curl -s -b /tmp/cj localhost:8083/api/checkout/options | python -m json.tool | head +curl -s -b /tmp/cj -X POST localhost:8083/api/instances/self-hosted \ + -H 'Content-Type: application/json' -d '{"name":"box"}' | python -m json.tool +``` + +The Paddle-touching paths (`updateEntitlement`, `billingPortal`) are exercised in task 9's sandbox pass. + +- [ ] **Step 7: Commit** + +```bash +git add admin/internal/api/checkout.go admin/internal/api/routes.go +git commit -m "feat(admin): checkout options, self-hosted placeholder, entitlement update, and portal endpoints" +``` + +--- + +### Task 6: The awaiting-link sweep, reminders, and placeholder claim + +**Files:** +- Modify: `admin/internal/mail/mail.go`, `admin/internal/lifecycle/lifecycle.go`, `admin/internal/api/customer.go`, `admin/internal/api/staff.go`, `admin/internal/api/routes.go`, `admin/internal/billing/subscription.go` (wire `sendCancelled`/`sendPastDue`) + +**Interfaces:** +- Produces: `mail.SendCancelled`, `mail.SendPastDue`, `mail.SendLinkReminder`; `staffBillingHealth`; `linkInstance` claiming a placeholder and issuing when a subscription exists. + +- [ ] **Step 1: Mail functions** + +In `admin/internal/mail/mail.go`, following the existing `Send*` style (subject + body, `send(to, ...)`): + +```go +func SendCancelled(to, instanceName string) error { + return send(to, "Your Vantage subscription is cancelled", + "Your subscription for "+instanceName+" is cancelled. Your instance keeps "+ + "working until the current licence expires; after that, monitors keep "+ + "running but changes are disabled.") +} + +func SendPastDue(to, instanceName string) error { + return send(to, "Payment failed for your Vantage subscription", + "A payment for "+instanceName+" failed. Your instance is unaffected while "+ + "the card is retried. Update your payment method from the billing portal.") +} + +func SendLinkReminder(to, instanceName string) error { + return send(to, "Finish setting up your Vantage instance", + "Your subscription for "+instanceName+" is active, but the instance is not "+ + "linked yet. Paste your install's ID in the portal to receive your licence.") +} +``` + +Wire the billing wrappers in `subscription.go`: + +```go +func sendCancelled(to, instanceName string) error { return mail.SendCancelled(to, instanceName) } +func sendPastDue(to, instanceName string) error { return mail.SendPastDue(to, instanceName) } +``` + +(Import `mail` in `subscription.go`. `instanceName` — pass the instance name, not the id; adjust the call sites in task 3 to look it up, or pass the id if the name is not to hand. Prefer the name.) + +- [ ] **Step 2: The awaiting-link sweep and reminders** + +In `admin/internal/lifecycle/lifecycle.go`, add a pass that finds placeholder/awaiting-link instances with a recorded active subscription and (a) backstops issuance if the webhook never linked, (b) sends 24h/72h reminders. Follow the existing sweep's shape and its `notices_sent` bookkeeping. Reminder keys `link_24` / `link_72`: + +```go +// sweepAwaitingLink chases self-hosted instances that were paid for but never +// linked. The subscription exists; the instance is still a placeholder. At 24h +// and 72h it emails a reminder; the staff dashboard already flags 48h. +func sweepAwaitingLink(ctx context.Context) { + cur, err := db.Admin("admin_instances").Find(ctx, bson.M{ + "deployment": license.DeploymentSelfHosted, + "placeholder": true, + "status": models.StatusAwaitingLink, + }) + if err != nil { + return + } + var instances []models.Instance + if err := cur.All(ctx, &instances); err != nil { + return + } + now := time.Now().UTC() + for _, inst := range instances { + age := now.Sub(inst.CreatedAt) + to := billingEmailForAccount(ctx, inst.AccountID) + if to == "" { + continue + } + if age > 72*time.Hour && !slices.Contains(inst.NoticesSent, "link_72") { + _ = mail.SendLinkReminder(to, inst.Name) + markNotice(ctx, inst.InstanceID, "link_72") + } else if age > 24*time.Hour && !slices.Contains(inst.NoticesSent, "link_24") { + _ = mail.SendLinkReminder(to, inst.Name) + markNotice(ctx, inst.InstanceID, "link_24") + } + } +} +``` + +Reuse or add `billingEmailForAccount` and `markNotice` helpers matching the file's existing patterns (the Free lifecycle sweep already reads the account email and records `notices_sent`; factor to one helper if duplicated). Call `sweepAwaitingLink` from the same ticker `lifecycle.Run` uses. + +- [ ] **Step 3: linkInstance claims a placeholder and issues** + +In `admin/internal/api/customer.go`'s `linkInstance`, after the placeholder's UUID is validated, if the instance being linked is a placeholder with a recorded active subscription, set the real `instance_id`, clear `placeholder`, flip to active, and issue from the subscription's resolved items. **Read the current `linkInstance` first and splice in** — it already creates/links; the addition is: when a subscription row exists for this placeholder, resolve its items and call the same `promoteAndIssue` path (expose a small exported `billing.IssueForInstance(ctx, instanceID) error` that loads the sub, resolves, promotes and issues, so the endpoint does not import catalogue directly). + +Add to `admin/internal/billing/subscription.go`: + +```go +// IssueForInstance issues from an instance's recorded subscription. Called when +// a self-hosted customer finally links a placeholder they have already paid for. +func IssueForInstance(ctx context.Context, instanceID string) error { + var sub models.Subscription + if err := db.Admin("subscriptions").FindOne(ctx, + bson.M{"instance_id": instanceID, "status": models.SubActive}).Decode(&sub); err != nil { + return fmt.Errorf("no active subscription for %s: %w", instanceID, err) + } + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": instanceID}).Decode(&inst); err != nil { + return err + } + items := make([]catalogue.Item, 0, len(sub.Items)) + for _, it := range sub.Items { + items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity}) + } + match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items) + if err != nil { + return err + } + return promoteAndIssue(ctx, &inst, match, models.ReasonNew) +} +``` + +- [ ] **Step 4: Staff billing health** + +In `admin/internal/api/staff.go`, add `staffBillingHealth` returning failed `paddle_events` (unprocessed with an error) and placeholder instances older than 48h, for the staff dashboard. Mount `staff.GET("/health/billing", staffBillingHealth)` in `routes.go`. + +```go +func staffBillingHealth(c *gin.Context) { + ctx := c.Request.Context() + failed := []models.PaddleEvent{} + if cur, err := db.Admin("paddle_events").Find(ctx, + bson.M{"processed_at": bson.M{"$exists": false}, "error": bson.M{"$ne": ""}}); err == nil { + _ = cur.All(ctx, &failed) + } + c.JSON(http.StatusOK, gin.H{"failed_events": failed, "count": len(failed)}) +} +``` + +- [ ] **Step 5: Confirm** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... +GOWORK=off /tmp/gorun.sh admin go vet ./... +``` +Expected: no output. + +- [ ] **Step 6: Commit** + +```bash +git add admin/internal/mail admin/internal/lifecycle admin/internal/api admin/internal/billing +git commit -m "feat(admin): awaiting-link reminders, placeholder claim-and-issue, and billing health" +``` + +--- + +### Task 7: The customer purchase and upgrade UI + +**Files:** +- Create: `adminsite/lib/paddle.ts`, `adminsite/components/CheckoutButton.tsx`, `adminsite/app/(customer)/purchase/page.tsx`, `adminsite/app/(customer)/purchase/PurchaseForm.tsx` +- Modify: `adminsite/package.json`, `adminsite/lib/api.ts`, `adminsite/components/InstanceRecord.tsx` + +**Interfaces:** +- Consumes: shipped `PlanConfigurator`/`PlanChoice`, `api.staff.plans` shape (reuse types), new `api.checkoutOptions`, `api.createSelfHosted`, `api.updateEntitlement`, `api.billingPortal`. +- Produces: `initPaddle()`, ``. + +- [ ] **Step 1: Add the dependency and the loader** + +```bash +sh /tmp/npmrun.sh adminsite npm install @paddle/paddle-js --no-audit --no-fund +``` + +Create `adminsite/lib/paddle.ts`: + +```ts +import { initializePaddle, type Paddle } from "@paddle/paddle-js"; + +let cached: Promise | null = null; + +/* One Paddle instance for the app. The token and environment are baked into the + * build (NEXT_PUBLIC_*), never fetched, so a production build can never load a + * sandbox token by accident. */ +export function initPaddle(): Promise { + if (!cached) { + cached = initializePaddle({ + environment: + (process.env.NEXT_PUBLIC_PADDLE_ENV as "sandbox" | "production") ?? "sandbox", + token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN ?? "", + }); + } + return cached; +} +``` + +- [ ] **Step 2: The checkout button** + +Create `adminsite/components/CheckoutButton.tsx`: + +```tsx +"use client"; + +import { useState } from "react"; +import { initPaddle } from "@/lib/paddle"; + +/* Opens the Paddle overlay with the resolved line items and custom_data. The + * items come from the configurator via catalogue pricing; custom_data is what + * lets the webhook route without a lookup table. */ +export function CheckoutButton({ + items, + customData, + disabled, + label = "Continue to payment", +}: { + items: { priceId: string; quantity: number }[]; + customData: { account_id: string; instance_id: string }; + disabled?: boolean; + label?: string; +}) { + const [busy, setBusy] = useState(false); + async function open() { + setBusy(true); + const paddle = await initPaddle(); + setBusy(false); + paddle?.Checkout.open({ + items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })), + customData, + }); + } + return ( + + ); +} +``` + +- [ ] **Step 3: API client additions** + +In `adminsite/lib/api.ts`, add types and calls (reuse shipped `Plan`, `CatalogueRow`, `Entitlement`, `PlanChoice`-equivalent). Add a client-side line-item builder mirroring `catalogue.LineItems` so the button has items without a round-trip, OR add a `GET`-backed resolver — prefer computing client-side from the catalogue already fetched: + +```ts +export interface CheckoutOptions { + plans: Plan[]; + catalogue: CatalogueRow[]; + env: "sandbox" | "production"; +} + +// on `api`: + checkoutOptions: () => req("/api/checkout/options"), + createSelfHosted: (name: string) => + post<{ instance_id: string }>("/api/instances/self-hosted", { name }), + updateEntitlement: ( + id: string, + body: { tier: Tier; term: Term; servers: number; features: string[] }, + ) => put<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`, body), + billingPortal: () => post<{ url: string }>("/api/billing/portal"), +``` + +Add a helper that turns a `PlanChoice` + catalogue into `{priceId, quantity}[]` for the running env, replicating `catalogue.LineItems`' base-included subtraction (base qty 1; per-server qty = servers − base; feature items only when the row has a price). Keep the subtraction in exactly one TS function, commented to point at the Go `billable`. + +- [ ] **Step 4: The self-hosted purchase page** + +Create `adminsite/app/(customer)/purchase/page.tsx` + `PurchaseForm.tsx`: name field → `createSelfHosted` → mount `PlanConfigurator` (deployment `self_hosted`) → `CheckoutButton` with computed items and `custom_data {account_id, instance_id}`. After checkout, instruct the customer to paste their install UUID on the instance page (the existing link flow, now claim-and-issue). Match the shell (`PageHeader`/`PageFrame`) of the other customer pages. + +- [ ] **Step 5: Cloud upgrade entry on InstanceRecord** + +In `adminsite/components/InstanceRecord.tsx`, for a cloud instance add a "Change plan" control that opens `PlanConfigurator` and either `updateEntitlement` (when an active subscription exists) or `CheckoutButton` (first purchase). Show a pending-reduction line when `entitlement.scheduled_change_at` is set, with the date. A "Manage billing" button calls `billingPortal` and opens the returned URL. Keep the cancellation wording panel here (there is no cancellation screen of ours). + +- [ ] **Step 6: Dockerfile build args** + +In `adminsite/Dockerfile`, add build args and env for `NEXT_PUBLIC_PADDLE_CLIENT_TOKEN` and `NEXT_PUBLIC_PADDLE_ENV`, matching how `NEXT_PUBLIC_ADMIN_API_URL` is already threaded. + +- [ ] **Step 7: Confirm build and no hex** + +```bash +sh /tmp/npmrun.sh adminsite npm run build +grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/components/CheckoutButton.tsx adminsite/app/\(customer\)/purchase/ +grep -rn "pri_\|sandbox\|production" adminsite/ --include=*.tsx --include=*.ts | grep -v "NEXT_PUBLIC\|process.env\|\"sandbox\"\|\"production\"" | grep -v "pri_…" +``` +Expected: successful build; no hex; no hard-coded price IDs. + +- [ ] **Step 8: Commit** + +```bash +git add adminsite +git commit -m "feat(adminsite): self-hosted purchase, cloud upgrade, checkout overlay and billing portal" +``` + +--- + +### Task 8: Deployment configuration and docs + +**Files:** +- Modify: `deploy/docker-compose.site.yml`, `CLAUDE.md`, `docs/superpowers/specs/README.md` + +- [ ] **Step 1: Compose env** + +In `deploy/docker-compose.site.yml`, add to the `admin` service: `PADDLE_ENV`, `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET`. Add the two `NEXT_PUBLIC_PADDLE_*` build args to the `adminsite` service build. Note in a comment that `PADDLE_WEBHOOK_SECRET` is boot-required. + +- [ ] **Step 2: CLAUDE.md** + +Add `paddle_events` to admin's collection list. Add `POST /api/paddle/webhook` (unauthenticated, signature-verified) to the admin route table, and the four customer routes + `GET /api/staff/health/billing`. Add a short "Billing (Paddle)" paragraph: Free is outside Paddle; paid plans check out in the browser; the webhook is the only issuer path and is idempotent via `paddle_events`; cancel/past-due never touch a licence; the entitlement is promoted `desired`→`granted` only by a confirmed webhook. Add the three `PADDLE_*` vars and the two `NEXT_PUBLIC_PADDLE_*` build args to the env tables. + +- [ ] **Step 3: Spec index** + +Mark spec 5 shipped (code) in `docs/superpowers/specs/README.md`, noting sandbox verification is the operator's step. + +- [ ] **Step 4: Commit** + +```bash +git add deploy/docker-compose.site.yml CLAUDE.md docs/superpowers/specs/README.md +git commit -m "docs+deploy: Paddle env, webhook route, collections, and spec status" +``` + +--- + +### Task 9: Sandbox catalog and the end-to-end pass (operator-run, deferred) + +This task **cannot be automated here** — it needs a real Paddle sandbox, an API key, a webhook secret, and a public webhook URL. It is the operator's, documented so it can be followed exactly. + +- [ ] **Step 1: Create the sandbox catalog.** Eight products — a base and an additional-server product for each of the four paid plans — giving twelve prices (cloud monthly+annual, self-hosted annual only), plus feature prices where a feature is to be charged. Free is not a product. +- [ ] **Step 2: Paste the sandbox price IDs** into the staff catalogue screen (shipped). Confirm a self-hosted monthly cell is absent and refused. +- [ ] **Step 3: Set `PADDLE_ENV=sandbox`, `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET`** on admin; register the webhook URL in Paddle pointing at `/api/paddle/webhook`. +- [ ] **Step 4: Cloud purchase** with a test card. Confirm `subscription.created`/`transaction.completed` issue a licence, the instance reports `valid`, and `max_servers`/features match the configuration. +- [ ] **Step 5: Server increase** on an active subscription via the portal → `updateEntitlement` → confirm the reissued licence grows. +- [ ] **Step 6: Scheduled reduction** → confirm `granted` unchanged until a triggered sandbox renewal, which then collapses it. Confirm expiry is period-end + 3 days. +- [ ] **Step 7: Self-hosted purchase** → placeholder `awaiting_link`, no licence → link the UUID → licence issued and emailed. +- [ ] **Step 8: Cancel** → licence keeps working to expiry, then the instance degrades (monitors still running). +- [ ] **Step 9: Replay every event** from Paddle's dashboard → no duplicate licences (idempotency). +- [ ] **Step 10: Bad signature** → 401, nothing processed. + +--- + +## Done when + +- The webhook verifies signatures, is idempotent via `paddle_events`, and dispatches every documented event type. +- A confirmed subscription webhook promotes `desired`→`granted` and issues from `granted`; `grep` finds no `.Desired` read and no earlier-expiry write in `admin/internal/billing/`. +- Cancel and past-due change only `status`; the licence is untouched. +- A renewal issues the next term with `ReasonRenewal` and collapses any scheduled reduction. +- `GET /api/checkout/options` serves six plans and the catalogue; the self-hosted placeholder creates an `awaiting_link` row with no licence. +- All Paddle SDK usage lives only in `admin/internal/paddle/sdk.go`. +- `admin`, `adminsite` build clean; adminsite carries no hex and no hard-coded price IDs. +- The sandbox end-to-end pass (task 9) is the operator's and is not claimed complete by this plan. + +## Not in this plan + +Discounts, coupons, our own proration arithmetic, invoice/tax display, and anything that revokes or shortens a licence. Metered pricing and self-service downgrade ARE in scope (spec 7 put them there) and are implemented above.