From 87dc9fc858df8e21e296979bfa371ad17f29e160 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Sun, 26 Jul 2026 22:57:57 +0100 Subject: [PATCH] inital paddle billing docs --- .../plans/2026-07-26-paddle-billing.md | 3524 +++++++++++++++++ 1 file changed, 3524 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-26-paddle-billing.md diff --git a/docs/superpowers/plans/2026-07-26-paddle-billing.md b/docs/superpowers/plans/2026-07-26-paddle-billing.md new file mode 100644 index 0000000..242b61f --- /dev/null +++ b/docs/superpowers/plans/2026-07-26-paddle-billing.md @@ -0,0 +1,3524 @@ +# Paddle Billing Implementation Plan + +> **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. + +**Goal:** Connect Paddle's subscription lifecycle to admin's licence issuance so a customer can buy Professional or Self Hosted from the portal, renew automatically, and manage their card and cancellation in Paddle's own portal — with every webhook idempotent, signature-verified, and visible to staff when it fails. + +**Architecture:** One new package (`admin/internal/paddle`) owns the SDK client and the sandbox/production decision; one new package (`admin/internal/billing`) turns a verified webhook into a licence action by calling the existing `licensing.Issue` and `inject.Deliver`. Every handler is a function of the subscription's *current* state as the event reports it, never of the transition, so out-of-order delivery is handled by construction. Checkout is Paddle's overlay in `adminsite/`, and price IDs are read from the `plans` table for the running `PADDLE_ENV` — never from code. + +**Tech Stack:** Go 1.26, gin, MongoDB driver v2.8.0, `github.com/PaddleHQ/paddle-go-sdk/v4` v4.2.0, Next.js 16, TanStack Query, `@paddle/paddle-js`. + +## Global Constraints + +- **No automated Go tests.** This repo has no Go test suite (one exception: `agent/internal/config/config_test.go`). Verification is by compiler, `grep`, `curl` and running built images against scratch databases. Every "confirm" step below is a command with expected output. **Do not add `*_test.go` files.** +- **Never run `go` or `npm` on the host.** Everything runs in a container. Create the wrapper if it is missing: + ```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 "$@" + ``` + For npm: + ```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.** Git Bash rewrites container paths otherwise. +- **Run `go mod tidy` with `GOWORK=off`.** In workspace mode it drops `require` lines and the Docker build then fails with "missing go.sum entry". +- **No price ID, product ID or Paddle URL is ever hard-coded** in Go or TypeScript. Price IDs live in `plans.paddle_price_ids`, keyed by environment, and are edited through the staff UI. The only Paddle literals allowed in code are the two base URLs inside the SDK. +- **Licences are offline-verified: nothing Paddle says revokes one early.** `subscription.canceled` and `subscription.past_due` take **no licence action** whatsoever. If you find yourself shortening an expiry, stop — that is not this system. +- **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 rather than issue a monthly self-hosted licence. +- **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 itself. +- **Admin's control-plane writes stay confined to `inject` (three licence fields) and `cloudprov` (instances and users).** This plan adds no third write path. +- **Customer endpoints answer 404, never 403,** for another account's resource. Every handler naming an instance goes through `ownedInstance`. +- **Free stays outside Paddle entirely.** No £0 subscription, no Paddle customer at signup. The shipped self-serve Free flow — `POST /api/instances`, `POST /api/instances/:id/renew`, the four lifecycle notices, the control-plane reaper — is untouched. `paddle_customer_id` is filled in the first time a real subscription's webhook arrives. + +## Decisions this plan makes, and why they differ from the spec + +The spec was written before spec 6 shipped. Three deliberate deviations: + +1. **Free is not a Paddle product.** Spec 5 wanted a real £0 monthly subscription "with no special-case code". Spec 6 then shipped a Free lifecycle that works without Paddle at all: manual renewal is the reclaim signal, and the notice emails and reaper hang off it. Putting Free in Paddle now would mean two competing renewal writers for the same licence. The catalog therefore has **two** products, not three, and `paddle_customer_id` is learned from the first paid subscription's webhook rather than created at signup. +2. **`subscription.canceled` marks the *subscription* cancelled, not the instance.** `inject.Reconcile` and the lifecycle sweep both filter on `status: active`; flipping the instance to `cancelled` would silently stop repairing an injection for a licence that is still valid — the exact opposite of "their current licence keeps working until it expires". The instance stays `active` until its licence expires and the existing sweep lapses it. +3. **There is no cancellation confirmation screen of ours to write on.** Cancellation happens inside Paddle's customer portal. The same words go in two places we do control: a permanent panel next to "Manage billing", and the email `subscription.canceled` triggers. + +## Context this plan inherits + +Shipped and load-bearing here: + +- `licensing.Issue(ctx, IssueInput)` signs, records, supersedes the previous licence, sets `tier`/`status: active`/`current_license` on the instance, and resets `relink_count` when `Reason == ReasonRenewal`. `IssueInput.ExpiresAt` overrides `Term`. It does **not** deliver. +- `inject.Deliver(ctx, lic)` injects for cloud and never fails the caller; `inject.Reconcile` repairs every 15 minutes. +- `models.GracePeriod` is 3 days and is already added by `Issue` when it derives an expiry from `Term`. **When you pass `ExpiresAt` you must add the grace yourself.** +- `models.Plan.PaddleProductID` and `PaddlePriceIDs` exist but are empty everywhere and unread by any code. This plan changes the shape of `PaddlePriceIDs` — that is safe precisely because nothing has ever written to it. +- `db.EnsureIndexes` already declares `subscriptions.paddle_subscription_id` unique+sparse. +- The staff dashboard already has an `awaiting_link` queue that flags rows older than 48 hours (`adminsite/app/(staff)/staff/page.tsx`, `HOURS_48`). This plan adds the reminder *emails*, not that alert. +- `lifecycle.Run` sweeps Free cloud instances and records sent notices in `admin_instances.notices_sent`. + +Spec: [`docs/superpowers/specs/2026-07-24-paddle-billing-design.md`](../specs/2026-07-24-paddle-billing-design.md). + +--- + +## File Structure + +**Created:** + +| Path | Responsibility | +|---|---| +| `admin/internal/paddle/paddle.go` | the single Paddle client and webhook verifier; owns the sandbox/production decision | +| `admin/internal/billing/events.go` | idempotent claim of an event ID, and the dispatch switch | +| `admin/internal/billing/subscription.go` | `subscription.*` → licence action, 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`: verify, claim, dispatch | +| `admin/internal/api/checkout.go` | `GET /api/checkout/options`, `POST /api/instances/self-hosted`, `POST /api/billing/portal` | +| `adminsite/lib/paddle.ts` | memoised `initializePaddle` | +| `adminsite/components/CheckoutButton.tsx` | opens the overlay with `custom_data`, refuses on an environment mismatch | +| `adminsite/components/UpgradePanel.tsx` | tier/term choice for a cloud instance | +| `adminsite/app/(customer)/purchase/page.tsx` | self-hosted purchase: name, checkout, then link | +| `adminsite/app/(customer)/purchase/PurchaseForm.tsx` | the client component | + +**Modified:** + +| Path | Change | +|---|---| +| `admin/go.mod`, `admin/go.sum` | the Paddle SDK | +| `admin/internal/config/config.go` | `PaddleEnv`, `PaddleAPIKey`, `PaddleWebhookSecret`, all required | +| `admin/internal/models/models.go` | `PaddleEvent`, term and subscription-status constants, `Subscription` fields, `Instance.Placeholder` | +| `admin/internal/models/plans.go` | `PaddlePriceIDs` becomes env→term→id; `PriceID`, `ResolvePriceID` | +| `admin/internal/db/db.go` | unique index on `paddle_events.event_id` | +| `admin/internal/mail/mail.go` | `SendCancelled`, `SendPastDue`, `SendLinkReminder` | +| `admin/internal/licensing/link.go` | `ClaimPlaceholder` | +| `admin/internal/lifecycle/lifecycle.go` | the awaiting-link sweep: backstop issuance plus 24h/72h reminders | +| `admin/internal/api/customer.go` | `linkInstance` claims a placeholder when one exists | +| `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` | `@paddle/paddle-js` | +| `adminsite/Dockerfile` | `NEXT_PUBLIC_PADDLE_CLIENT_TOKEN`, `NEXT_PUBLIC_PADDLE_ENV` build args | +| `adminsite/lib/api.ts` | the new endpoints and types | +| `adminsite/components/InstanceRecord.tsx` | the upgrade panel on a cloud instance | +| `adminsite/app/(customer)/billing/page.tsx` | Manage billing, and what cancelling actually does | +| `adminsite/app/(customer)/page.tsx` | a self-hosted purchase call to action | +| `adminsite/app/(staff)/staff/plans/page.tsx` | the price-ID editor replaces the two demo buttons | +| `adminsite/app/(staff)/staff/page.tsx` | a failed-webhooks queue | +| `deploy/docker-compose.site.yml` | the three admin variables | +| `.gitea/workflows/server-deploy.yml` | two build args for `adminsite` | +| `CLAUDE.md` | the admin env table, the route table, the collection list, the variables table | +| `docs/superpowers/specs/README.md` | spec 5 status | + +--- + +### Task 1: The Paddle client, the config, and price IDs as data + +**Files:** +- Create: `admin/internal/paddle/paddle.go` +- Modify: `admin/internal/config/config.go`, `admin/internal/models/plans.go`, `admin/internal/models/models.go`, `admin/cmd/main.go`, `admin/go.mod` + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: + - `config.Config.PaddleEnv`, `.PaddleAPIKey`, `.PaddleWebhookSecret string` + - `paddle.Init(environment, apiKey, webhookSecret string) error` + - `paddle.Env() string`, `paddle.Verifier() *paddlesdk.WebhookVerifier`, `paddle.SDK() *paddlesdk.SDK` + - `paddle.EnvSandbox`, `paddle.EnvProduction` constants + - `models.TermMonthly`, `models.TermAnnual` constants + - `models.Plan.PaddlePriceIDs map[string]map[string]string` + - `func (p Plan) PriceID(env, term string) string` + - `models.ResolvePriceID(ctx context.Context, env, priceID string) (tier, term string, err error)` + - `models.ErrUnknownPrice error` + +- [ ] **Step 1: Add the SDK dependency** + +```bash +GOWORK=off /tmp/gorun.sh admin go get github.com/PaddleHQ/paddle-go-sdk/v4@v4.2.0 +GOWORK=off /tmp/gorun.sh admin go mod tidy +``` + +Expected: `admin/go.mod` gains `github.com/PaddleHQ/paddle-go-sdk/v4 v4.2.0` in the direct require block, and `github.com/ggicci/httpin`, `github.com/ggicci/owl`, `github.com/hashicorp/go-cleanhttp` as indirects. + +- [ ] **Step 2: Create the client package** + +Create `admin/internal/paddle/paddle.go`: + +```go +// Package paddle holds admin's single Paddle client and its webhook verifier. +// +// It exists so that exactly one place decides sandbox versus production. Every +// other package asks this one, which is what makes it impossible for a +// production process to serve a sandbox price ID: the environment that built +// the client is the same string the plans table is indexed by. +package paddle + +import ( + "errors" + "fmt" + "time" + + paddlesdk "github.com/PaddleHQ/paddle-go-sdk/v4" +) + +const ( + EnvSandbox = "sandbox" + EnvProduction = "production" +) + +var ( + sdk *paddlesdk.SDK + verifier *paddlesdk.WebhookVerifier + env string +) + +// SignatureTolerance bounds how stale a signature timestamp may be. +// +// This is replay protection at the transport layer, and it is the cheap half of +// the pair: idempotency in paddle_events is what actually stops a replayed +// event acting twice. Five minutes is generous enough for a retry that queued +// behind an outage and tight enough that a captured request is not reusable +// tomorrow. +const SignatureTolerance = 5 * time.Minute + +// Init builds the client for the configured environment. +// +// An unrecognised PADDLE_ENV is refused rather than defaulted. Defaulting it +// either way is the sandbox-IDs-in-production risk the spec names, and a +// misconfigured billing service that looks healthy is worse than one that +// refuses to boot. +func Init(environment, apiKey, webhookSecret string) error { + var err error + switch environment { + case EnvSandbox: + sdk, err = paddlesdk.NewSandbox(apiKey) + case EnvProduction: + sdk, err = paddlesdk.New(apiKey) + default: + return fmt.Errorf("PADDLE_ENV must be %q or %q, got %q", + EnvSandbox, EnvProduction, environment) + } + if err != nil { + return fmt.Errorf("paddle client: %w", err) + } + if webhookSecret == "" { + return errors.New("PADDLE_WEBHOOK_SECRET is required") + } + verifier = paddlesdk.NewWebhookVerifier(webhookSecret, + paddlesdk.VerifierWithTimestampTolerance(SignatureTolerance)) + env = environment + return nil +} + +// Env is the string the plans table keys its price IDs by. +func Env() string { return env } + +// Verifier verifies inbound webhook signatures. +func Verifier() *paddlesdk.WebhookVerifier { return verifier } + +// SDK is the outbound API client. Admin's only outbound call is the customer +// portal session — everything else about a subscription arrives by webhook. +func SDK() *paddlesdk.SDK { return sdk } +``` + +- [ ] **Step 3: Add the three configuration variables** + +In `admin/internal/config/config.go`, add to the `Config` struct after `ReapAfter`: + +```go + PaddleEnv string + PaddleAPIKey string + PaddleWebhookSecret string +``` + +In `Load()`, inside the struct literal after `SMTPPassword`: + +```go + PaddleEnv: os.Getenv("PADDLE_ENV"), + PaddleAPIKey: os.Getenv("PADDLE_API_KEY"), + PaddleWebhookSecret: os.Getenv("PADDLE_WEBHOOK_SECRET"), +``` + +And add three entries to the required map, keeping the existing comment style: + +```go + "ADMIN_ORIGIN": os.Getenv("ADMIN_ORIGIN"), + "PADDLE_ENV": c.PaddleEnv, + "PADDLE_API_KEY": c.PaddleAPIKey, + // An unverified webhook endpoint is an endpoint anyone can issue + // licences through, so this is fatal rather than a warning. + "PADDLE_WEBHOOK_SECRET": c.PaddleWebhookSecret, +``` + +- [ ] **Step 4: Reshape the price-ID map and add the resolvers** + +In `admin/internal/models/models.go`, add after the `MaxRelinksPerTerm` block: + +```go +// Billing terms. These are the keys inside a plan's price-ID map and the values +// licensing.IssueInput.Term accepts, so they are one vocabulary rather than two. +const ( + TermMonthly = "monthly" + TermAnnual = "annual" +) + +// Subscription statuses are stored exactly as Paddle reports them, including +// Paddle's American "canceled". +// +// Instance statuses are ours and stay British (StatusCancelled). Normalising +// Paddle's field would mean a reader could no longer compare what we stored +// against what the Paddle dashboard shows, and that comparison is the first +// thing anyone does when a subscription looks wrong. +const ( + SubActive = "active" + SubTrialing = "trialing" + SubPastDue = "past_due" + SubPaused = "paused" + SubCanceled = "canceled" +) +``` + +Replace the `Subscription` struct with: + +```go +type Subscription struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + SubscriptionID string `bson:"subscription_id" json:"subscription_id"` + AccountID string `bson:"account_id" json:"account_id"` + InstanceID string `bson:"instance_id,omitempty" json:"instance_id,omitempty"` + PaddleSubscriptionID string `bson:"paddle_subscription_id,omitempty" json:"paddle_subscription_id,omitempty"` + PaddleCustomerID string `bson:"paddle_customer_id,omitempty" json:"paddle_customer_id,omitempty"` + PaddlePriceID string `bson:"paddle_price_id,omitempty" json:"paddle_price_id,omitempty"` + Tier string `bson:"tier" json:"tier"` + Term string `bson:"term" json:"term"` + Status string `bson:"status" json:"status"` + CurrentPeriodEnd time.Time `bson:"current_period_end" json:"current_period_end"` + CanceledAt *time.Time `bson:"canceled_at,omitempty" json:"canceled_at,omitempty"` + // UpdatedAt is the occurred_at of the last event applied. Events can arrive + // out of order, and this is what a human compares against Paddle's own + // timeline when the two disagree. + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} +``` + +In the `Instance` struct, add after `NoticesSent`: + +```go + // Placeholder marks an instance row that exists only so a self-hosted + // checkout has something to attach a subscription to. Its InstanceID is a + // UUID we generated, not one the customer's install reports, so it must + // never be licensed — a licence binds to a UUID, and this is not theirs. + Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"` +``` + +Change `Plan.PaddlePriceIDs` to: + +```go + // PaddlePriceIDs is environment -> term -> Paddle price ID, e.g. + // {"sandbox": {"monthly": "pri_…"}, "production": {"annual": "pri_…"}}. + // + // Nested by environment rather than kept in two collections, because + // promoting sandbox to production must be a configuration change and not a + // data migration. The running PADDLE_ENV picks the inner map. + PaddlePriceIDs map[string]map[string]string `bson:"paddle_price_ids,omitempty" json:"paddle_price_ids,omitempty"` +``` + +Then in `admin/internal/models/plans.go`, add: + +```go +// ErrUnknownPrice means a webhook named a price ID no plan claims. +// +// This is a configuration error, never a customer error: someone bought +// something at a price we cannot map to a tier. It must fail loudly and land on +// the staff dashboard rather than guess a tier. +var ErrUnknownPrice = errors.New("no plan claims that Paddle price ID") + +// PriceID returns the price ID for one environment and term, or "". +func (p Plan) PriceID(env, term string) string { + if p.PaddlePriceIDs == nil { + return "" + } + return p.PaddlePriceIDs[env][term] +} + +// ResolvePriceID maps a Paddle price ID back to a tier and term. +// +// Only the running environment's IDs are consulted. A production process +// therefore cannot be talked into issuing against a sandbox price by a forged +// or misrouted webhook, which is the other half of PADDLE_ENV's job. +func ResolvePriceID(ctx context.Context, env, priceID string) (string, string, error) { + if priceID == "" { + return "", "", ErrUnknownPrice + } + cur, err := db.Admin("plans").Find(ctx, bson.M{}) + if err != nil { + return "", "", err + } + var plans []Plan + if err := cur.All(ctx, &plans); err != nil { + return "", "", err + } + for _, p := range plans { + for _, term := range []string{TermMonthly, TermAnnual} { + if p.PriceID(env, term) == priceID { + return p.Tier, term, nil + } + } + } + return "", "", fmt.Errorf("%w: %s (environment %s)", ErrUnknownPrice, priceID, env) +} +``` + +Add `"errors"` and `"fmt"` to that file's imports. + +- [ ] **Step 5: Initialise Paddle at boot** + +In `admin/cmd/main.go`, directly after the `licensing.SetSigningKey` / `api.SetAppLoginURL` pair: + +```go + if err := paddle.Init(cfg.PaddleEnv, cfg.PaddleAPIKey, cfg.PaddleWebhookSecret); err != nil { + log.Fatalf("paddle: %v", err) + } + log.Printf("paddle: %s environment", paddle.Env()) +``` + +Add `"github.com/mrhid6/vantage/admin/internal/paddle"` to the imports. + +- [ ] **Step 6: Confirm it compiles and that boot refuses without the secret** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... +``` +Expected: no output. + +```bash +GOWORK=off /tmp/gorun.sh admin go vet ./... +``` +Expected: no output. + +Then confirm the refusal is real, without any database: + +```bash +MSYS_NO_PATHCONV=1 docker run --rm -v "$(pwd)":/src -w /src/admin \ + -e ADMIN_MONGO_URI=mongodb://x:27017/a -e CONTROL_MONGO_URI=mongodb://x:27017/b \ + -e REDIS_ADDR=x:6379 -e LICENSE_SIGNING_KEY=k -e PUBLIC_URL=http://x \ + -e ADMIN_ORIGIN=http://x -e PADDLE_ENV=sandbox -e PADDLE_API_KEY=k \ + golang:1.26 go run ./cmd +``` +Expected: `configuration error: missing required environment: PADDLE_WEBHOOK_SECRET`. + +Repeat with `-e PADDLE_WEBHOOK_SECRET=s -e PADDLE_ENV=staging`: +Expected: `paddle: PADDLE_ENV must be "sandbox" or "production", got "staging"`. + +- [ ] **Step 7: Commit** + +```bash +git add admin/go.mod admin/go.sum admin/internal/paddle admin/internal/config admin/internal/models admin/cmd/main.go +git commit -m "feat(admin): the Paddle client, and price IDs as environment-keyed data + +PADDLE_ENV picks which of a plan's price-ID maps is served, so promoting +sandbox to production is a configuration change rather than a data +migration, and a production process cannot resolve a sandbox price at all. + +PADDLE_WEBHOOK_SECRET is fatal at boot: an unverified webhook endpoint is +an endpoint anyone can issue licences through. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 2: The webhook endpoint — signature, idempotency, dispatch + +**Files:** +- Create: `admin/internal/billing/events.go`, `admin/internal/api/paddle.go` +- Modify: `admin/internal/models/models.go`, `admin/internal/db/db.go`, `admin/internal/api/routes.go` + +**Interfaces:** +- Consumes: `paddle.Verifier()`, `paddle.Env()`, `audit.Write`, `db.Admin`. +- Produces: + - `models.PaddleEvent` and `models.EventReceived`, `EventHandled`, `EventFailed`, `EventIgnored` + - `billing.Envelope` with `EventID`, `EventType`, `OccurredAt` + - `billing.Claim(ctx context.Context, env Envelope, raw []byte) (bool, error)` + - `billing.Finish(ctx context.Context, eventID, status string, handlerErr error, accountID, instanceID string)` + - `billing.Handle(ctx context.Context, env Envelope, raw []byte) (status string, accountID string, instanceID string, err error)` + +- [ ] **Step 1: Add the event document and its index** + +In `admin/internal/models/models.go`, add after the `AuditEntry` struct: + +```go +// Paddle event processing states. +const ( + EventReceived = "received" + EventHandled = "handled" + EventFailed = "failed" + EventIgnored = "ignored" +) + +// PaddleEvent is the idempotency record for one webhook, and the paper trail +// for what we did with it. +// +// The raw payload is kept: when a customer's licence is wrong, the question is +// always "what exactly did Paddle tell us", and Paddle's dashboard only keeps +// notification logs for a limited window. There is deliberately no TTL index — +// dropping an event_id would reopen the duplicate window for a late replay, and +// this table is append-only for the same reason `licenses` is. +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"` + OccurredAt time.Time `bson:"occurred_at" json:"occurred_at"` + ReceivedAt time.Time `bson:"received_at" json:"received_at"` + HandledAt *time.Time `bson:"handled_at,omitempty" json:"handled_at,omitempty"` + Status string `bson:"status" json:"status"` + Error string `bson:"error,omitempty" json:"error,omitempty"` + Attempts int `bson:"attempts" json:"attempts"` + AccountID string `bson:"account_id,omitempty" json:"account_id,omitempty"` + InstanceID string `bson:"instance_id,omitempty" json:"instance_id,omitempty"` + Payload string `bson:"payload" json:"payload"` +} +``` + +In `admin/internal/db/db.go`, add `{"paddle_events", "event_id"}` to the `unique` slice in `EnsureIndexes`, and add to the secondary index list: + +```go + {"paddle_events", bson.D{{Key: "status", Value: 1}, {Key: "received_at", Value: -1}}}, +``` + +- [ ] **Step 2: Write the idempotency and dispatch layer** + +Create `admin/internal/billing/events.go`: + +```go +// Package billing turns a verified Paddle webhook into a licence action. +// +// Two rules shape everything here. First, every handler is a function of the +// subscription's CURRENT state as the event reports it, never of the +// transition — so an `updated` that overtakes its `created` still produces the +// right answer. Second, no event ever shortens or revokes a licence: licences +// are offline-verified, so cancellation and dunning take effect at expiry and +// nowhere else. +package billing + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/PaddleHQ/paddle-go-sdk/v4/pkg/paddlenotification" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +// Envelope is the little of a webhook we read before deciding what to do. +type Envelope struct { + EventID string `json:"event_id"` + EventType paddlenotification.EventTypeName `json:"event_type"` + OccurredAt string `json:"occurred_at"` +} + +// Claim records the event and reports whether this delivery should be processed. +// +// A duplicate of an already-handled or ignored event returns false, and the +// caller answers 200 without acting: returning an error there would make Paddle +// retry a message we have already handled, forever. +// +// A retry of a FAILED event returns true. That is the deliberate asymmetry — a +// handler that failed on a transient database error should get another go, and +// Paddle's own retry schedule is the cheapest retry mechanism available. +func Claim(ctx context.Context, env Envelope, raw []byte) (bool, error) { + occurred, _ := time.Parse(time.RFC3339, env.OccurredAt) + now := time.Now().UTC() + + _, err := db.Admin("paddle_events").InsertOne(ctx, models.PaddleEvent{ + EventID: env.EventID, + EventType: string(env.EventType), + OccurredAt: occurred, + ReceivedAt: now, + Status: models.EventReceived, + Attempts: 1, + Payload: string(raw), + }) + if err == nil { + return true, nil + } + if !mongo.IsDuplicateKeyError(err) { + return false, fmt.Errorf("claim event: %w", err) + } + + res, uErr := db.Admin("paddle_events").UpdateOne(ctx, + bson.M{"event_id": env.EventID, "status": models.EventFailed}, + bson.M{ + "$set": bson.M{"status": models.EventReceived, "received_at": now}, + "$inc": bson.M{"attempts": 1}, + }) + if uErr != nil { + return false, fmt.Errorf("reclaim failed event: %w", uErr) + } + if res.ModifiedCount == 1 { + log.Printf("billing: retrying previously failed event %s", env.EventID) + return true, nil + } + return false, nil +} + +// Finish records the outcome. A failure is written to the event row AND to +// admin_audit, because a licence that silently failed to issue is a customer +// who paid and got nothing. +func Finish(ctx context.Context, eventID, status string, handlerErr error, accountID, instanceID string) { + now := time.Now().UTC() + set := bson.M{"status": status, "handled_at": now} + if accountID != "" { + set["account_id"] = accountID + } + if instanceID != "" { + set["instance_id"] = instanceID + } + if handlerErr != nil { + set["error"] = handlerErr.Error() + } else { + set["error"] = "" + } + if _, err := db.Admin("paddle_events").UpdateOne(ctx, + bson.M{"event_id": eventID}, bson.M{"$set": set}); err != nil { + log.Printf("billing: record outcome for %s: %v", eventID, err) + } + + if status == models.EventFailed { + log.Printf("BILLING HANDLER FAILED event=%s: %v", eventID, handlerErr) + audit.Write(ctx, models.AuditEntry{ + Actor: "paddle:" + eventID, + Action: "billing.webhook_failed", + AccountID: accountID, + Target: instanceID, + Detail: handlerErr.Error(), + }) + } +} + +// Handle dispatches one verified event. +// +// The returned status is what to record: handled, ignored, or failed. Unknown +// event types are IGNORED rather than failed — Paddle sends more than we +// subscribe to, and a queue full of "we do not care about this" would hide the +// events that matter. +func Handle(ctx context.Context, env Envelope, raw []byte) (string, string, string, error) { + switch env.EventType { + default: + return models.EventIgnored, "", "", nil + } +} + +// errNoInstance means the event carried no instance we can act on. It is a +// configuration or checkout problem, not a transient one, so it fails the event +// and lands on the staff dashboard rather than being retried silently. +var errNoInstance = errors.New("event carries no instance_id in custom_data") +``` + +- [ ] **Step 3: Write the HTTP handler** + +Create `admin/internal/api/paddle.go`: + +```go +package api + +import ( + "encoding/json" + "errors" + "io" + "log" + "net/http" + + paddlesdk "github.com/PaddleHQ/paddle-go-sdk/v4" + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/admin/internal/billing" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/admin/internal/paddle" +) + +// maxWebhookBody bounds what we will read. Paddle's own verifier caps at 2 MB; +// this is the same ceiling stated where the route is, so nobody has to read the +// SDK to know it. +const maxWebhookBody = 2 << 20 + +// paddleWebhook is the only unauthenticated mutating endpoint admin has, so its +// order is deliberate: verify the signature FIRST, before parsing, claiming, or +// logging anything an attacker chose. An unsigned or badly signed request is +// rejected 401 and processed no further. +func paddleWebhook(c *gin.Context) { + ok, err := paddle.Verifier().Verify(c.Request) + if err != nil { + if errors.Is(err, paddlesdk.ErrMissingSignature) || + errors.Is(err, paddlesdk.ErrInvalidSignatureFormat) || + errors.Is(err, paddlesdk.ErrReplayAttack) { + log.Printf("paddle webhook: rejected: %v", err) + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"}) + return + } + log.Printf("paddle webhook: verification error: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not verify signature"}) + return + } + if !ok { + log.Printf("paddle webhook: signature mismatch from %s", c.ClientIP()) + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid signature"}) + return + } + + raw, err := io.ReadAll(io.LimitReader(c.Request.Body, maxWebhookBody)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "could not read body"}) + return + } + + var env billing.Envelope + if err := json.Unmarshal(raw, &env); err != nil || env.EventID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "malformed event"}) + return + } + + // The request context dies with the response, and a handler that issues a + // licence must not be cancelled halfway. Use a background context with its + // own timeout instead. + ctx, cancel := db.Ctx() + defer cancel() + + fresh, err := billing.Claim(ctx, env, raw) + if err != nil { + // Could not even record it: ask Paddle to retry. + log.Printf("paddle webhook: claim %s: %v", env.EventID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not record event"}) + return + } + if !fresh { + // Already handled. 200 is mandatory here. + c.JSON(http.StatusOK, gin.H{"duplicate": true}) + return + } + + status, accountID, instanceID, hErr := billing.Handle(ctx, env, raw) + if hErr != nil { + status = models.EventFailed + } + billing.Finish(ctx, env.EventID, status, hErr, accountID, instanceID) + + if hErr != nil { + // 500 so Paddle retries; Claim will let the retry through because the + // row is now marked failed. + c.JSON(http.StatusInternalServerError, gin.H{"error": "handler failed"}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true, "status": status}) +} +``` + +Add the `db` import (`github.com/mrhid6/vantage/admin/internal/db`) — `db.Ctx()` is the existing 10-second background context helper. + +- [ ] **Step 4: Mount the route** + +In `admin/internal/api/routes.go`, after the `r.POST("/auth/accept-invite", ...)` line: + +```go + // Paddle's webhook. Unauthenticated by necessity and signature-verified in + // the handler before anything else happens — it is deliberately outside + // every group, because a session guard here would reject Paddle and a CORS + // origin means nothing to a server-to-server call. + r.POST("/api/paddle/webhook", paddleWebhook) +``` + +- [ ] **Step 5: Confirm signature rejection and idempotency by hand** + +Build first: + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... +``` +Expected: no output. + +Bring up scratch databases and run admin (adapt the compose/scratch procedure used in plan 3 — two Mongo databases, Redis, `PADDLE_ENV=sandbox`, `PADDLE_WEBHOOK_SECRET=testsecret`). Then: + +```bash +curl -si -X POST localhost:8083/api/paddle/webhook \ + -H 'Content-Type: application/json' -d '{"event_id":"evt_1"}' | head -1 +``` +Expected: `HTTP/1.1 401 Unauthorized`. + +```bash +curl -si -X POST localhost:8083/api/paddle/webhook \ + -H 'Paddle-Signature: ts=1;h1=0000000000000000000000000000000000000000000000000000000000000000' \ + -d '{"event_id":"evt_1"}' | head -1 +``` +Expected: `HTTP/1.1 401 Unauthorized` (the tolerance rejects `ts=1` before the HMAC is even compared). + +Now a correctly signed request. Save this helper as `/tmp/pdlsend.sh`: + +```sh +#!/bin/sh +# /tmp/pdlsend.sh +SECRET="$1"; FILE="$2" +TS=$(date +%s) +SIG=$(printf '%s:%s' "$TS" "$(cat "$FILE")" | \ + openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/.*= //') +curl -si -X POST localhost:8083/api/paddle/webhook \ + -H "Paddle-Signature: ts=$TS;h1=$SIG" \ + -H 'Content-Type: application/json' \ + --data-binary "@$FILE" +``` + +```bash +echo '{"event_id":"evt_ignore_me","event_type":"price.created","occurred_at":"2026-07-26T10:00:00Z","data":{}}' > /tmp/ev.json +sh /tmp/pdlsend.sh testsecret /tmp/ev.json | tail -1 +``` +Expected: `{"ok":true,"status":"ignored"}`. + +Send the identical file again: +Expected: `{"duplicate":true}` with a `200`. + +Confirm exactly one row exists: + +```bash +mongosh "$ADMIN_MONGO_URI" --quiet --eval \ + 'db.paddle_events.find({},{event_id:1,status:1,attempts:1,_id:0}).toArray()' +``` +Expected: one document, `status: "ignored"`, `attempts: 1`. + +- [ ] **Step 6: Commit** + +```bash +git add admin/internal/billing admin/internal/api/paddle.go admin/internal/api/routes.go admin/internal/models/models.go admin/internal/db/db.go +git commit -m "feat(admin): the Paddle webhook, verified and idempotent + +Signature verification happens before parsing, claiming or logging +anything the caller chose; a bad signature is 401 and nothing else. + +Every event ID is claimed in paddle_events before processing. A duplicate +of a handled event answers 200 without acting, because an error there +would make Paddle retry a message we have already handled forever. A +retry of a FAILED event is deliberately let through — Paddle's retry +schedule is the cheapest retry mechanism we have. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 3: Subscription events — created, updated, canceled, past_due + +**Files:** +- Create: `admin/internal/billing/subscription.go`, `admin/internal/billing/deliver.go` +- Modify: `admin/internal/billing/events.go`, `admin/internal/mail/mail.go` + +**Interfaces:** +- Consumes: `models.ResolvePriceID`, `models.TermMonthly/TermAnnual`, `models.Sub*`, `licensing.Issue`, `inject.Deliver`, `paddle.Env()`, `errNoInstance`. +- Produces: + - `billing.SubState` struct + - `billing.ApplySubscription(ctx context.Context, st SubState) (accountID, instanceID string, err error)` + - `billing.MarkCanceled(ctx context.Context, st SubState) (string, string, error)` + - `billing.MarkPastDue(ctx context.Context, st SubState) (string, string, error)` + - `billing.Deliver(ctx context.Context, inst *models.Instance, lic *models.License)` + - `mail.SendCancelled(to, instanceName string, expires time.Time) error` + - `mail.SendPastDue(to, instanceName, portalURL string) error` + +- [ ] **Step 1: Write the delivery helper** + +Create `admin/internal/billing/deliver.go`: + +```go +package billing + +import ( + "context" + "log" + + "github.com/mrhid6/vantage/admin/internal/db" + "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" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// Deliver sends a freshly issued licence where it needs to go. +// +// This is api.deliver's twin for a caller that has no session: the recipient is +// the account's billing address rather than whoever is signed in, because +// nobody is signed in when a renewal webhook arrives at 03:00. +// +// Failures are logged and never returned. The licence is already recorded, +// which is the part that must not be lost, and inject's reconciler repairs a +// missed cloud injection within fifteen minutes. +func Deliver(ctx context.Context, inst *models.Instance, lic *models.License) { + if inst.Deployment == license.DeploymentCloud { + inject.Deliver(ctx, lic) + return + } + if !mail.Enabled() { + log.Printf("billing: SMTP disabled; licence %s not emailed", lic.LicenseID) + return + } + to, err := billingEmail(ctx, inst.AccountID) + if err != nil { + log.Printf("billing: no billing email for account %s: %v", inst.AccountID, err) + return + } + if err := mail.SendLicense(to, inst.Name, lic.Blob); err != nil { + log.Printf("billing: licence email to %s: %v", to, err) + } +} + +func billingEmail(ctx context.Context, accountID string) (string, error) { + var acct models.Account + if err := db.Admin("accounts").FindOne(ctx, + bson.M{"account_id": accountID}).Decode(&acct); err != nil { + return "", err + } + return acct.BillingEmail, nil +} +``` + +- [ ] **Step 2: Add the two emails** + +In `admin/internal/mail/mail.go`, after `SendRenewed`: + +```go +// SendCancelled confirms a cancellation and states plainly that the licence +// keeps working until it expires. +// +// This is the same text the portal shows beside Manage billing. A customer who +// cancels and finds their instance still running must not conclude the +// cancellation failed and cancel again — or worse, chargeback. +func SendCancelled(to, instanceName string, expires time.Time) error { + return send(to, "Your Vantage subscription is cancelled", + "Your subscription for "+instanceName+" is cancelled and you will not be billed again.\r\n\r\n"+ + "Your licence keeps working until "+expires.Format("2 January 2006")+". On that date the\r\n"+ + "instance goes read-only: monitors keep running and alerts keep firing, but\r\n"+ + "changes stop. Nothing is deleted.\r\n\r\n"+ + "If you cancelled by mistake, subscribe again from the portal before that date.\r\n") +} + +// SendPastDue tells a customer a payment failed, without threatening anything. +// +// Dunning is Paddle's job and it will retry the card. Ours is not to punish a +// retryable failure, so this names no cut-off date the licence does not have. +func SendPastDue(to, instanceName, portalURL string) error { + body := "A payment for " + instanceName + " could not be taken.\r\n\r\n" + + "We will try again automatically over the next few days. Your instance is\r\n" + + "unaffected in the meantime.\r\n" + if portalURL != "" { + body += "\r\nTo update your card now: " + portalURL + "/billing\r\n" + } + return send(to, "A payment for Vantage failed", body) +} +``` + +- [ ] **Step 3: Write the subscription handlers** + +Create `admin/internal/billing/subscription.go`: + +```go +package billing + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/google/uuid" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/licensing" + "github.com/mrhid6/vantage/admin/internal/mail" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/admin/internal/paddle" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// SubState is one subscription's current state, as an event reported it. +// +// Handlers take this rather than an event type on purpose: `created` and +// `updated` differ only in which struct Paddle serialises, and treating both as +// "here is the state now" is what makes an out-of-order `updated` correct +// instead of a special case. +type SubState struct { + EventID string + OccurredAt time.Time + + PaddleSubscriptionID string + PaddleCustomerID string + Status string // Paddle's own vocabulary + PriceID string + PeriodEnd time.Time + CanceledAt *time.Time + + // From custom_data, set on the checkout. + AccountID string + InstanceID string + TierHint string +} + +// ApplySubscription records the subscription and brings the licence into line +// with it. +// +// It never shortens an expiry and never revokes. The only licence action it +// takes is issuing one that is missing, at a tier that changed, or for a period +// that now ends later than the current licence covers. +func ApplySubscription(ctx context.Context, st SubState) (string, string, error) { + tier, term, err := models.ResolvePriceID(ctx, paddle.Env(), st.PriceID) + if err != nil { + if st.TierHint == "" { + return st.AccountID, st.InstanceID, err + } + // custom_data named a tier, so the purchase is routable even though the + // price is not in the plans table. Record and fail: someone must paste + // the price ID in, and until they do, the term is a guess. + return st.AccountID, st.InstanceID, + fmt.Errorf("%w (checkout claimed tier %s)", err, st.TierHint) + } + if tier == license.TierSelfHosted && term != models.TermAnnual { + return st.AccountID, st.InstanceID, fmt.Errorf( + "price %s resolves to self-hosted %s, but Self Hosted is annual only; "+ + "remove that price from the plan", st.PriceID, term) + } + + if err := upsertSubscription(ctx, st, tier, term); err != nil { + return st.AccountID, st.InstanceID, err + } + if err := adoptCustomerID(ctx, st); err != nil { + log.Printf("billing: adopt customer %s onto account %s: %v", + st.PaddleCustomerID, st.AccountID, err) + } + + if st.InstanceID == "" { + return st.AccountID, "", errNoInstance + } + + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": st.InstanceID}).Decode(&inst); err != nil { + return st.AccountID, st.InstanceID, + fmt.Errorf("subscription names unknown instance %s: %w", st.InstanceID, err) + } + + // A placeholder row is a self-hosted purchase whose install has not been + // linked yet. There is no UUID to bind a licence to, and spec 1 has no + // unbound licence, so record the subscription and stop. The customer's link + // step issues; the lifecycle sweep chases them if they never take it. + if inst.Placeholder { + audit.Write(ctx, models.AuditEntry{ + Actor: "paddle:" + st.EventID, + Action: "billing.awaiting_link", + AccountID: inst.AccountID, + Target: inst.InstanceID, + Detail: fmt.Sprintf("tier=%s term=%s paid, waiting for the install UUID", tier, term), + }) + return inst.AccountID, inst.InstanceID, nil + } + + if st.Status != models.SubActive && st.Status != models.SubTrialing { + // paused, past_due or canceled: no licence action, by design. + return inst.AccountID, inst.InstanceID, nil + } + + target := st.PeriodEnd.Add(models.GracePeriod) + var current *models.License + if inst.CurrentLicense != "" { + var lic models.License + if err := db.Admin("licenses").FindOne(ctx, + bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err == nil { + current = &lic + } + } + + reason := models.ReasonNew + switch { + case current == nil: + case current.Tier != tier: + reason = models.ReasonTierChange + case st.PeriodEnd.IsZero() || !target.After(current.ExpiresAt.Add(time.Hour)): + // Same tier, no later coverage: this update was about a card, an address + // or a scheduled change. Issuing here would churn a licence per event. + return inst.AccountID, inst.InstanceID, nil + default: + reason = models.ReasonRenewal + } + + in := licensing.IssueInput{ + InstanceID: inst.InstanceID, + Tier: tier, + Term: term, + Reason: reason, + IssuedBy: "paddle:" + st.EventID, + } + if !st.PeriodEnd.IsZero() { + in.ExpiresAt = target + } + lic, err := licensing.Issue(ctx, in) + if err != nil { + return inst.AccountID, inst.InstanceID, fmt.Errorf("issue: %w", err) + } + Deliver(ctx, &inst, lic) + return inst.AccountID, inst.InstanceID, nil +} + +// MarkCanceled records the cancellation and takes NO licence action. +// +// It marks the SUBSCRIPTION cancelled and leaves the instance active. Flipping +// the instance to cancelled would drop it out of inject.Reconcile's filter and +// out of the lifecycle sweep, so a still-valid licence would stop being +// repaired — the opposite of "it keeps working until it expires". The existing +// sweep lapses the instance when the licence actually expires. +func MarkCanceled(ctx context.Context, st SubState) (string, string, error) { + set := bson.M{"status": models.SubCanceled, "updated_at": st.OccurredAt} + if st.CanceledAt != nil { + set["canceled_at"] = *st.CanceledAt + } + if _, err := db.Admin("subscriptions").UpdateOne(ctx, + bson.M{"paddle_subscription_id": st.PaddleSubscriptionID}, + bson.M{"$set": set}); err != nil { + return st.AccountID, st.InstanceID, err + } + + accountID, instanceID := st.AccountID, st.InstanceID + var sub models.Subscription + if err := db.Admin("subscriptions").FindOne(ctx, + bson.M{"paddle_subscription_id": st.PaddleSubscriptionID}).Decode(&sub); err == nil { + accountID, instanceID = sub.AccountID, sub.InstanceID + } + + audit.Write(ctx, models.AuditEntry{ + Actor: "paddle:" + st.EventID, Action: "billing.cancelled", + AccountID: accountID, Target: instanceID, + Detail: "licence runs to expiry; no licence action taken"}) + + if instanceID != "" && mail.Enabled() { + var inst models.Instance + var lic models.License + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": instanceID}).Decode(&inst); err == nil { + if err := db.Admin("licenses").FindOne(ctx, + bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err == nil { + if to, err := billingEmail(ctx, inst.AccountID); err == nil { + if err := mail.SendCancelled(to, inst.Name, lic.ExpiresAt); err != nil { + log.Printf("billing: cancellation email to %s: %v", to, err) + } + } + } + } + } + return accountID, instanceID, nil +} + +// MarkPastDue records a failed payment. The licence is untouched: dunning is +// Paddle's job, and ours is not to punish a retryable card failure. +func MarkPastDue(ctx context.Context, st SubState) (string, string, error) { + if _, err := db.Admin("subscriptions").UpdateOne(ctx, + bson.M{"paddle_subscription_id": st.PaddleSubscriptionID}, + bson.M{"$set": bson.M{"status": models.SubPastDue, "updated_at": st.OccurredAt}}); err != nil { + return st.AccountID, st.InstanceID, err + } + + accountID, instanceID := st.AccountID, st.InstanceID + var sub models.Subscription + if err := db.Admin("subscriptions").FindOne(ctx, + bson.M{"paddle_subscription_id": st.PaddleSubscriptionID}).Decode(&sub); err == nil { + accountID, instanceID = sub.AccountID, sub.InstanceID + } + + audit.Write(ctx, models.AuditEntry{ + Actor: "paddle:" + st.EventID, Action: "billing.past_due", + AccountID: accountID, Target: instanceID, Detail: "licence untouched"}) + + if instanceID != "" && mail.Enabled() { + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": instanceID}).Decode(&inst); err == nil { + if to, err := billingEmail(ctx, inst.AccountID); err == nil { + if err := mail.SendPastDue(to, inst.Name, PortalURL()); err != nil { + log.Printf("billing: past-due email to %s: %v", to, err) + } + } + } + } + return accountID, instanceID, nil +} + +// upsertSubscription writes the row, creating it if this is an `updated` that +// overtook its `created`. Keyed on the Paddle ID, which is unique+sparse. +func upsertSubscription(ctx context.Context, st SubState, tier, term string) error { + set := bson.M{ + "status": st.Status, + "tier": tier, + "term": term, + "updated_at": st.OccurredAt, + } + if st.PriceID != "" { + set["paddle_price_id"] = st.PriceID + } + if st.PaddleCustomerID != "" { + set["paddle_customer_id"] = st.PaddleCustomerID + } + if !st.PeriodEnd.IsZero() { + set["current_period_end"] = st.PeriodEnd + } + if st.AccountID != "" { + set["account_id"] = st.AccountID + } + if st.InstanceID != "" { + set["instance_id"] = st.InstanceID + } + _, err := db.Admin("subscriptions").UpdateOne(ctx, + bson.M{"paddle_subscription_id": st.PaddleSubscriptionID}, + bson.M{ + "$set": set, + "$setOnInsert": bson.M{ + "subscription_id": uuid.NewString(), + "paddle_subscription_id": st.PaddleSubscriptionID, + }, + }, + options.UpdateOne().SetUpsert(true)) + if err != nil { + return fmt.Errorf("record subscription: %w", err) + } + return nil +} + +// adoptCustomerID fills an account's Paddle customer ID the first time we see +// one. +// +// This is the whole of "create a Paddle customer": checkout creates it, the +// webhook tells us its ID. There is no API call to fail, and therefore no +// signup that can be blocked by a billing-system hiccup. +func adoptCustomerID(ctx context.Context, st SubState) error { + if st.AccountID == "" || st.PaddleCustomerID == "" { + return nil + } + _, err := db.Admin("accounts").UpdateOne(ctx, + bson.M{"account_id": st.AccountID, + "$or": []bson.M{{"paddle_customer_id": ""}, {"paddle_customer_id": bson.M{"$exists": false}}}}, + bson.M{"$set": bson.M{"paddle_customer_id": st.PaddleCustomerID}}) + return err +} + +// portalURL is admin's own PUBLIC_URL, used in emails that link to billing. +var portalURL string + +// SetPortalURL is called once at boot. +func SetPortalURL(v string) { portalURL = v } + +// PortalURL is the HQ portal's base URL, or "" when unset. +func PortalURL() string { return portalURL } +``` + +This file does not use `errors` — drop it from the import block if you copied it in. `errNoInstance` lives in `events.go`, same package. + +- [ ] **Step 4: Wire the four event types into the dispatch switch** + +In `admin/internal/billing/events.go`, replace the body of `Handle` with: + +```go +func Handle(ctx context.Context, env Envelope, raw []byte) (string, string, string, error) { + switch env.EventType { + case paddlenotification.EventTypeNameSubscriptionCreated: + var ev paddlenotification.SubscriptionCreated + if err := json.Unmarshal(raw, &ev); err != nil { + return models.EventFailed, "", "", fmt.Errorf("decode: %w", err) + } + acct, inst, err := ApplySubscription(ctx, stateFromCreated(env, ev)) + return models.EventHandled, acct, inst, err + + case paddlenotification.EventTypeNameSubscriptionUpdated: + var ev paddlenotification.SubscriptionUpdated + if err := json.Unmarshal(raw, &ev); err != nil { + return models.EventFailed, "", "", fmt.Errorf("decode: %w", err) + } + acct, inst, err := ApplySubscription(ctx, stateFromNotification(env, ev.Data)) + return models.EventHandled, acct, inst, err + + case paddlenotification.EventTypeNameSubscriptionCanceled: + var ev paddlenotification.SubscriptionCanceled + if err := json.Unmarshal(raw, &ev); err != nil { + return models.EventFailed, "", "", fmt.Errorf("decode: %w", err) + } + acct, inst, err := MarkCanceled(ctx, stateFromNotification(env, ev.Data)) + return models.EventHandled, acct, inst, err + + case paddlenotification.EventTypeNameSubscriptionPastDue: + var ev paddlenotification.SubscriptionPastDue + if err := json.Unmarshal(raw, &ev); err != nil { + return models.EventFailed, "", "", fmt.Errorf("decode: %w", err) + } + acct, inst, err := MarkPastDue(ctx, stateFromNotification(env, ev.Data)) + return models.EventHandled, acct, inst, err + + default: + return models.EventIgnored, "", "", nil + } +} + +// stateFromNotification adapts the shape Paddle uses for every subscription +// event except `created`. +func stateFromNotification(env Envelope, d paddlenotification.SubscriptionNotification) SubState { + st := SubState{ + EventID: env.EventID, + OccurredAt: parseTime(env.OccurredAt), + PaddleSubscriptionID: d.ID, + PaddleCustomerID: d.CustomerID, + Status: string(d.Status), + } + if d.CurrentBillingPeriod != nil { + st.PeriodEnd = parseTime(d.CurrentBillingPeriod.EndsAt) + } + if d.CanceledAt != nil { + t := parseTime(*d.CanceledAt) + st.CanceledAt = &t + } + if len(d.Items) > 0 { + st.PriceID = d.Items[0].Price.ID + } + readCustomData(&st, d.CustomData) + return st +} + +// stateFromCreated adapts `created`, which Paddle serialises with its own +// struct because it also carries the originating transaction ID. +func stateFromCreated(env Envelope, ev paddlenotification.SubscriptionCreated) SubState { + d := ev.Data + st := SubState{ + EventID: env.EventID, + OccurredAt: parseTime(env.OccurredAt), + PaddleSubscriptionID: d.ID, + PaddleCustomerID: d.CustomerID, + Status: string(d.Status), + } + if d.CurrentBillingPeriod != nil { + st.PeriodEnd = parseTime(d.CurrentBillingPeriod.EndsAt) + } + if len(d.Items) > 0 { + st.PriceID = d.Items[0].Price.ID + } + readCustomData(&st, d.CustomData) + return st +} + +// readCustomData pulls the three keys every checkout sets. This is what lets a +// handler route without a lookup table, and it is why the self-hosted flow +// creates its instance row before checkout opens. +func readCustomData(st *SubState, cd paddlenotification.CustomData) { + if cd == nil { + return + } + if v, ok := cd["account_id"].(string); ok { + st.AccountID = v + } + if v, ok := cd["instance_id"].(string); ok { + st.InstanceID = v + } + if v, ok := cd["tier"].(string); ok { + st.TierHint = v + } +} + +func parseTime(s string) time.Time { + t, _ := time.Parse(time.RFC3339, s) + return t.UTC() +} +``` + +Add `"encoding/json"` to that file's imports. + +- [ ] **Step 5: Set the portal URL at boot** + +In `admin/cmd/main.go`, next to the existing `lifecycle.SetPortalURL(cfg.PublicURL)`: + +```go + billing.SetPortalURL(cfg.PublicURL) +``` + +Add the `billing` import. + +- [ ] **Step 6: Confirm each event type does what the spec's table says** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... && GOWORK=off /tmp/gorun.sh admin go vet ./... +``` +Expected: no output. + +Against the scratch databases, seed a cloud instance and a Professional price ID: + +```bash +mongosh "$ADMIN_MONGO_URI" --quiet --eval ' +db.plans.updateOne({tier:"professional"},{$set:{paddle_price_ids:{sandbox:{monthly:"pri_test_m",annual:"pri_test_a"}}}}); +db.accounts.insertOne({account_id:"acc_1",name:"Test",billing_email:"a@example.com",status:"active",created_at:new Date()}); +db.admin_instances.insertOne({instance_id:"11111111-1111-1111-1111-111111111111",account_id:"acc_1",name:"Prod",slug:"prod",deployment:"cloud",status:"active",relink_count:0,created_at:new Date()});' +``` + +Write `/tmp/created.json` (note `custom_data`, and a period end a month out): + +```json +{"event_id":"evt_sub_created","event_type":"subscription.created","occurred_at":"2026-07-26T10:00:00Z", + "data":{"id":"sub_1","status":"active","customer_id":"ctm_1","address_id":"add_1", + "currency_code":"GBP","created_at":"2026-07-26T10:00:00Z","updated_at":"2026-07-26T10:00:00Z", + "transaction_id":"txn_1","collection_mode":"automatic","billing_cycle":{"interval":"month","frequency":1}, + "current_billing_period":{"starts_at":"2026-07-26T10:00:00Z","ends_at":"2026-08-26T10:00:00Z"}, + "items":[{"status":"active","quantity":1,"recurring":true,"price":{"id":"pri_test_m"}}], + "custom_data":{"account_id":"acc_1","instance_id":"11111111-1111-1111-1111-111111111111","tier":"professional"}}} +``` + +```bash +sh /tmp/pdlsend.sh testsecret /tmp/created.json | tail -1 +``` +Expected: `{"ok":true,"status":"handled"}`. + +```bash +mongosh "$ADMIN_MONGO_URI" --quiet --eval ' +print(JSON.stringify(db.subscriptions.findOne({},{_id:0,tier:1,term:1,status:1,current_period_end:1,instance_id:1}))); +print(JSON.stringify(db.licenses.findOne({},{_id:0,tier:1,reason:1,expires_at:1,issued_by:1}))); +print(JSON.stringify(db.accounts.findOne({},{_id:0,paddle_customer_id:1})));' +``` +Expected: subscription `tier: professional`, `term: monthly`, `status: active`; one licence, `tier: professional`, `reason: new`, `issued_by: paddle:evt_sub_created`, `expires_at` = **2026-08-29T10:00:00Z** (period end plus the 3-day grace); account `paddle_customer_id: ctm_1`. + +Now the out-of-order case. Delete everything and send an `updated` with no prior `created` — copy `/tmp/created.json` to `/tmp/updated.json`, change `event_id` to `evt_sub_updated`, `event_type` to `subscription.updated`, and remove `transaction_id`: + +```bash +mongosh "$ADMIN_MONGO_URI" --quiet --eval 'db.subscriptions.deleteMany({});db.licenses.deleteMany({});db.admin_instances.updateOne({},{$unset:{current_license:"",tier:""}})' +sh /tmp/pdlsend.sh testsecret /tmp/updated.json | tail -1 +mongosh "$ADMIN_MONGO_URI" --quiet --eval 'print(db.subscriptions.countDocuments({}), db.licenses.countDocuments({}))' +``` +Expected: `handled`, then `1 1` — the subscription row was created by the update and the licence was issued. + +Send the same `updated` file again with a fresh `event_id` (`evt_sub_updated2`): +Expected: `handled`, and `db.licenses.countDocuments({})` is still **1** — same tier, no later coverage, so no churn. + +Tier change: edit `/tmp/updated.json` to `pri_test_a` (annual), period end a year out, `event_id` `evt_sub_upgrade`: +Expected: two licences, the newer with `reason: renewal` (same tier, later coverage) and the older carrying `superseded_by`. + +Cancellation: copy to `/tmp/cancel.json`, `event_type: subscription.canceled`, `status: canceled`, add `"canceled_at":"2026-07-27T10:00:00Z"`, `event_id: evt_sub_cancel`: + +```bash +sh /tmp/pdlsend.sh testsecret /tmp/cancel.json | tail -1 +mongosh "$ADMIN_MONGO_URI" --quiet --eval ' +print(JSON.stringify(db.subscriptions.findOne({},{_id:0,status:1,canceled_at:1}))); +print(JSON.stringify(db.admin_instances.findOne({},{_id:0,status:1,current_license:1}))); +print(db.licenses.countDocuments({}));' +``` +Expected: subscription `canceled` with `canceled_at`; **instance still `active` with its `current_license` intact**; licence count unchanged. + +Past due: same shape with `event_type: subscription.past_due`, `status: past_due`, `event_id: evt_sub_pastdue`: +Expected: subscription `past_due`, licence count unchanged, and an `admin_audit` entry with `action: billing.past_due`. + +Self-hosted-monthly refusal: set `db.plans.updateOne({tier:"self_hosted"},{$set:{paddle_price_ids:{sandbox:{monthly:"pri_bad_m"}}}})`, send a created event with `pri_bad_m`: +Expected: HTTP `500`, and + +```bash +mongosh "$ADMIN_MONGO_URI" --quiet --eval 'print(JSON.stringify(db.paddle_events.findOne({status:"failed"},{_id:0,event_id:1,error:1})))' +``` +shows `Self Hosted is annual only` in `error`, with a matching `billing.webhook_failed` row in `admin_audit`. + +- [ ] **Step 7: Commit** + +```bash +git add admin/internal/billing admin/internal/mail/mail.go admin/cmd/main.go +git commit -m "feat(admin): subscription events issue, and never revoke + +Handlers are functions of the subscription's current state rather than of +the transition, so an updated that overtakes its created still lands the +right licence — it creates the subscription row and proceeds. + +canceled marks the SUBSCRIPTION cancelled and leaves the instance active. +Marking the instance would drop it out of inject.Reconcile's filter and +the lifecycle sweep, so a licence that is still valid would stop being +repaired. past_due touches nothing: dunning is Paddle's job. + +A self-hosted price with a monthly term fails the event loudly rather +than issuing a licence that would take a year to expire. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 4: Renewals, payment failures, and the billing email + +**Files:** +- Create: `admin/internal/billing/transaction.go` +- Modify: `admin/internal/billing/events.go` + +**Interfaces:** +- Consumes: `SubState`, `Deliver`, `licensing.Issue`, `models.ResolvePriceID`, `billingEmail`. +- Produces: + - `billing.ApplyRenewal(ctx context.Context, tx TxState) (string, string, error)` + - `billing.RecordPaymentFailure(ctx context.Context, tx TxState) (string, string, error)` + - `billing.SyncCustomer(ctx context.Context, paddleCustomerID, email string) (string, string, error)` + - `billing.TxState` struct + +- [ ] **Step 1: Write the transaction handlers** + +Create `admin/internal/billing/transaction.go`: + +```go +package billing + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/mrhid6/vantage/admin/internal/audit" + "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" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// TxState is one transaction as an event reported it. +type TxState struct { + EventID string + OccurredAt time.Time + TransactionID string + Origin string + Status string + PaddleSubscriptionID string + PaddleCustomerID string + PriceID string + PeriodEnd time.Time + AccountID string + InstanceID string +} + +// ApplyRenewal issues the next term's licence. +// +// Only a recurring subscription charge counts. An upgrade mid-term also produces +// a completed transaction, but subscription.updated already carried the new +// period and tier, and issuing from both would cut two licences for one change. +func ApplyRenewal(ctx context.Context, tx TxState) (string, string, error) { + if tx.PaddleSubscriptionID == "" { + return "", "", nil // a one-off purchase; nothing subscription-shaped to do + } + if tx.Origin != "subscription_recurring" { + return tx.AccountID, tx.InstanceID, nil + } + + sub, err := subscriptionFor(ctx, tx.PaddleSubscriptionID) + if err != nil { + return tx.AccountID, tx.InstanceID, err + } + + accountID := firstNonEmpty(tx.AccountID, sub.AccountID) + instanceID := firstNonEmpty(tx.InstanceID, sub.InstanceID) + if instanceID == "" { + return accountID, "", errNoInstance + } + + tier, term := sub.Tier, sub.Term + if tx.PriceID != "" { + if t, tm, err := models.ResolvePriceID(ctx, paddle.Env(), tx.PriceID); err == nil { + tier, term = t, tm + } + } + if tier == license.TierSelfHosted && term != models.TermAnnual { + return accountID, instanceID, fmt.Errorf( + "renewal resolves to self-hosted %s, but Self Hosted is annual only", term) + } + + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": instanceID}).Decode(&inst); err != nil { + return accountID, instanceID, fmt.Errorf("renewal names unknown instance %s: %w", instanceID, err) + } + if inst.Placeholder { + // Paid again, still never linked. Nothing to bind to; the sweep chases. + return accountID, instanceID, nil + } + + in := licensing.IssueInput{ + InstanceID: instanceID, + Tier: tier, + Term: term, + Reason: models.ReasonRenewal, + IssuedBy: "paddle:" + tx.EventID, + } + // The 3-day grace is what makes a webhook delayed by hours harmless: the + // new licence starts covering before the old one runs out. + if !tx.PeriodEnd.IsZero() { + in.ExpiresAt = tx.PeriodEnd.Add(models.GracePeriod) + } + lic, err := licensing.Issue(ctx, in) + if err != nil { + return accountID, instanceID, fmt.Errorf("issue renewal: %w", err) + } + Deliver(ctx, &inst, lic) + + // Issue has already reset relink_count, because the cap is per term. + set := bson.M{"status": models.SubActive, "updated_at": tx.OccurredAt} + if !tx.PeriodEnd.IsZero() { + set["current_period_end"] = tx.PeriodEnd + } + if _, err := db.Admin("subscriptions").UpdateOne(ctx, + bson.M{"paddle_subscription_id": tx.PaddleSubscriptionID}, + bson.M{"$set": set}); err != nil { + log.Printf("billing: update subscription after renewal: %v", err) + } + return accountID, instanceID, nil +} + +// RecordPaymentFailure exists for staff visibility only. No licence action, and +// no email — subscription.past_due is the event that tells the customer, and two +// messages for one failed card is one too many. +func RecordPaymentFailure(ctx context.Context, tx TxState) (string, string, error) { + accountID, instanceID := tx.AccountID, tx.InstanceID + if tx.PaddleSubscriptionID != "" { + if sub, err := subscriptionFor(ctx, tx.PaddleSubscriptionID); err == nil { + accountID = firstNonEmpty(accountID, sub.AccountID) + instanceID = firstNonEmpty(instanceID, sub.InstanceID) + } + } + audit.Write(ctx, models.AuditEntry{ + Actor: "paddle:" + tx.EventID, Action: "billing.payment_failed", + AccountID: accountID, Target: instanceID, + Detail: "transaction " + tx.TransactionID + "; licence untouched"}) + return accountID, instanceID, nil +} + +// SyncCustomer copies a changed billing address onto the account. +// +// Matched on the Paddle customer ID, never on the email: the email is the thing +// that just changed. +func SyncCustomer(ctx context.Context, paddleCustomerID, email string) (string, string, error) { + if paddleCustomerID == "" || email == "" { + return "", "", nil + } + res, err := db.Admin("accounts").UpdateOne(ctx, + bson.M{"paddle_customer_id": paddleCustomerID}, + bson.M{"$set": bson.M{"billing_email": email}}) + if err != nil { + return "", "", err + } + if res.MatchedCount == 0 { + // A Paddle customer we have never seen a subscription for. Not an error: + // the subscription webhook that adopts the ID may still be in flight. + return "", "", nil + } + var acct models.Account + _ = db.Admin("accounts").FindOne(ctx, + bson.M{"paddle_customer_id": paddleCustomerID}).Decode(&acct) + return acct.AccountID, "", nil +} + +func subscriptionFor(ctx context.Context, paddleSubID string) (models.Subscription, error) { + var sub models.Subscription + err := db.Admin("subscriptions").FindOne(ctx, + bson.M{"paddle_subscription_id": paddleSubID}).Decode(&sub) + if err != nil { + return sub, fmt.Errorf("no subscription recorded for %s: %w", paddleSubID, err) + } + return sub, nil +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} +``` + +- [ ] **Step 2: Add the three event types to the switch** + +In `admin/internal/billing/events.go`, add before `default:`: + +```go + case paddlenotification.EventTypeNameTransactionCompleted: + var ev paddlenotification.TransactionCompleted + if err := json.Unmarshal(raw, &ev); err != nil { + return models.EventFailed, "", "", fmt.Errorf("decode: %w", err) + } + acct, inst, err := ApplyRenewal(ctx, stateFromTransaction(env, ev.Data)) + return models.EventHandled, acct, inst, err + + case paddlenotification.EventTypeNameTransactionPaymentFailed: + var ev paddlenotification.TransactionPaymentFailed + if err := json.Unmarshal(raw, &ev); err != nil { + return models.EventFailed, "", "", fmt.Errorf("decode: %w", err) + } + acct, inst, err := RecordPaymentFailure(ctx, stateFromTransaction(env, ev.Data)) + return models.EventHandled, acct, inst, err + + case paddlenotification.EventTypeNameCustomerUpdated: + var ev paddlenotification.CustomerUpdated + if err := json.Unmarshal(raw, &ev); err != nil { + return models.EventFailed, "", "", fmt.Errorf("decode: %w", err) + } + acct, inst, err := SyncCustomer(ctx, ev.Data.ID, ev.Data.Email) + return models.EventHandled, acct, inst, err +``` + +And add the adapter at the bottom of the file: + +```go +// stateFromTransaction reads the fields a renewal needs. Paddle copies a +// subscription's custom_data onto the transactions it generates, which is why a +// renewal can route to an instance without consulting the subscription row +// first — though it falls back to that row when the copy is absent. +func stateFromTransaction(env Envelope, d paddlenotification.TransactionNotification) TxState { + tx := TxState{ + EventID: env.EventID, + OccurredAt: parseTime(env.OccurredAt), + TransactionID: d.ID, + Origin: string(d.Origin), + Status: string(d.Status), + } + if d.SubscriptionID != nil { + tx.PaddleSubscriptionID = *d.SubscriptionID + } + if d.CustomerID != nil { + tx.PaddleCustomerID = *d.CustomerID + } + if d.BillingPeriod != nil { + tx.PeriodEnd = parseTime(d.BillingPeriod.EndsAt) + } + if len(d.Items) > 0 && d.Items[0].Price != nil { + tx.PriceID = d.Items[0].Price.ID + } + if d.CustomData != nil { + if v, ok := d.CustomData["account_id"].(string); ok { + tx.AccountID = v + } + if v, ok := d.CustomData["instance_id"].(string); ok { + tx.InstanceID = v + } + } + return tx +} +``` + +If the compiler reports that `d.Items[0].Price` is a value rather than a pointer, drop the `!= nil` check accordingly — check `pkg/paddlenotification/transactions.go` for the exact `TransactionItem` shape rather than guessing. + +- [ ] **Step 3: Confirm a renewal issues the next term and resets the relink count** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... && GOWORK=off /tmp/gorun.sh admin go vet ./... +``` +Expected: no output. + +Reset to one active monthly subscription plus one issued licence (rerun the `created` event from task 3), then set a relink count and send a renewal. `/tmp/renewal.json`: + +```json +{"event_id":"evt_txn_renew","event_type":"transaction.completed","occurred_at":"2026-08-26T10:05:00Z", + "data":{"id":"txn_2","status":"completed","customer_id":"ctm_1","origin":"subscription_recurring", + "subscription_id":"sub_1","currency_code":"GBP", + "billing_period":{"starts_at":"2026-08-26T10:00:00Z","ends_at":"2026-09-26T10:00:00Z"}, + "items":[{"price":{"id":"pri_test_m"},"quantity":1}], + "custom_data":{"account_id":"acc_1","instance_id":"11111111-1111-1111-1111-111111111111","tier":"professional"}}} +``` + +```bash +mongosh "$ADMIN_MONGO_URI" --quiet --eval 'db.admin_instances.updateOne({},{$set:{relink_count:2}})' +sh /tmp/pdlsend.sh testsecret /tmp/renewal.json | tail -1 +mongosh "$ADMIN_MONGO_URI" --quiet --eval ' +db.licenses.find({},{_id:0,reason:1,expires_at:1,superseded_by:1}).sort({issued_at:1}).forEach(l=>print(JSON.stringify(l))); +print(JSON.stringify(db.admin_instances.findOne({},{_id:0,relink_count:1,status:1}))); +print(JSON.stringify(db.subscriptions.findOne({},{_id:0,current_period_end:1,status:1})));' +``` +Expected: the first licence now carries `superseded_by`; the second has `reason: renewal` and `expires_at` **2026-09-29T10:00:00Z** (period end plus grace); `relink_count: 0`; subscription `current_period_end` 2026-09-26 and `status: active`. + +Now confirm a non-recurring completed transaction is a no-op — change `origin` to `subscription_update` and `event_id` to `evt_txn_update`: +Expected: `handled`, and `db.licenses.countDocuments({})` unchanged at 2. + +Customer email sync — `/tmp/cust.json`: + +```json +{"event_id":"evt_cust_1","event_type":"customer.updated","occurred_at":"2026-08-27T10:00:00Z", + "data":{"id":"ctm_1","email":"billing@example.com","status":"active","marketing_consent":false, + "locale":"en","created_at":"2026-07-26T10:00:00Z","updated_at":"2026-08-27T10:00:00Z"}} +``` + +```bash +sh /tmp/pdlsend.sh testsecret /tmp/cust.json | tail -1 +mongosh "$ADMIN_MONGO_URI" --quiet --eval 'print(db.accounts.findOne({},{_id:0,billing_email:1}).billing_email)' +``` +Expected: `billing@example.com`. + +- [ ] **Step 4: Commit** + +```bash +git add admin/internal/billing +git commit -m "feat(admin): renewals issue the next term, plus the customer sync + +Only origin subscription_recurring renews. An upgrade mid-term also +completes a transaction, but subscription.updated already carried the new +period and tier, and issuing from both would cut two licences for one +change. + +The new expiry is the billing period end plus the existing three-day +grace, so a webhook delayed by hours never leaves a gap in coverage. +Issue resets relink_count, because the cap is per term. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 5: Checkout endpoints, the self-hosted placeholder, and claiming it + +**Files:** +- Create: `admin/internal/api/checkout.go` +- Modify: `admin/internal/licensing/link.go`, `admin/internal/api/customer.go`, `admin/internal/api/routes.go`, `admin/internal/api/staff.go` + +**Interfaces:** +- Consumes: `ownedInstance`, `auth.Current`, `models.Plan.PriceID`, `paddle.Env()`, `paddle.SDK()`, `licensing.Issue`, `billing.Deliver`. +- Produces: + - `GET /api/checkout/options` + - `POST /api/instances/self-hosted` + - `POST /api/billing/portal` + - `GET /api/staff/health/billing` + - `licensing.ClaimPlaceholder(ctx context.Context, accountID, placeholderID, realID string) (*models.Instance, error)` + - `licensing.LicenceForSubscription(ctx context.Context, inst *models.Instance) (*models.License, error)` + - `licensing.ErrNoSubscription` + +- [ ] **Step 1: Add placeholder claiming to licensing** + +In `admin/internal/licensing/link.go`, add `ErrNoSubscription` to the `var` block: + +```go + ErrNoSubscription = errors.New("no active subscription for that instance yet") +``` + +And append: + +```go +// ClaimPlaceholder turns a paid-but-unlinked row into the customer's real +// instance. +// +// A self-hosted checkout has to attach its subscription to something, so the +// purchase creates a placeholder row with a UUID we generated. That UUID is not +// the customer's, so it must never be licensed — this is where the real one +// arrives and the placeholder flag comes off. +func ClaimPlaceholder(ctx context.Context, accountID, placeholderID, realID string) (*models.Instance, error) { + if _, err := uuid.Parse(realID); err != nil { + return nil, ErrBadUUID + } + + // The same two collision checks LinkInstance makes, for the same reason: a + // self-hosted UUID must collide with neither a cloud instance nor another + // account's linked one. + if n, err := db.Control("instances").CountDocuments(ctx, + bson.M{"instance_id": realID}); err == nil && n > 0 { + return nil, ErrAlreadyLinked + } + if n, err := db.Admin("admin_instances").CountDocuments(ctx, + bson.M{"instance_id": realID}); err == nil && n > 0 { + return nil, ErrAlreadyLinked + } + + res := db.Admin("admin_instances").FindOneAndUpdate(ctx, + bson.M{"instance_id": placeholderID, "account_id": accountID, "placeholder": true}, + bson.M{ + "$set": bson.M{"instance_id": realID}, + "$unset": bson.M{"placeholder": ""}, + }, + options.FindOneAndUpdate().SetReturnDocument(options.After)) + var inst models.Instance + if err := res.Decode(&inst); err != nil { + if mongo.IsDuplicateKeyError(err) { + return nil, ErrAlreadyLinked + } + return nil, ErrUnknownInstance + } + + // The subscription row points at the placeholder ID; move it too, or the + // next renewal cannot find the instance it pays for. + if _, err := db.Admin("subscriptions").UpdateMany(ctx, + bson.M{"instance_id": placeholderID}, + bson.M{"$set": bson.M{"instance_id": realID}}); err != nil { + return nil, fmt.Errorf("move subscription to %s: %w", realID, err) + } + + audit.Write(ctx, models.AuditEntry{ + Actor: accountID, Action: "instance.claimed", AccountID: accountID, + Target: realID, Detail: "was placeholder " + placeholderID}) + return &inst, nil +} + +// LicenceForSubscription issues the licence a paid, just-linked instance is +// owed, covering the period the customer has already paid for. +// +// It is called from the link handler and again from the lifecycle sweep, so +// either order of "webhook arrives" and "customer links" ends the same way. +// Issue supersedes, so a second call is a new licence rather than a duplicate — +// which is why the sweep only calls it when there is no current licence at all. +func LicenceForSubscription(ctx context.Context, inst *models.Instance) (*models.License, error) { + var sub models.Subscription + err := db.Admin("subscriptions").FindOne(ctx, bson.M{ + "instance_id": inst.InstanceID, + "status": bson.M{"$in": []string{models.SubActive, models.SubTrialing}}, + }).Decode(&sub) + if err != nil { + return nil, ErrNoSubscription + } + in := IssueInput{ + InstanceID: inst.InstanceID, + Tier: sub.Tier, + Term: sub.Term, + Reason: models.ReasonNew, + IssuedBy: "paddle:" + sub.PaddleSubscriptionID, + } + if !sub.CurrentPeriodEnd.IsZero() { + in.ExpiresAt = sub.CurrentPeriodEnd.Add(models.GracePeriod) + } + return Issue(ctx, in) +} +``` + +Add `"go.mongodb.org/mongo-driver/v2/mongo/options"` to the imports. + +- [ ] **Step 2: Write the three customer endpoints** + +Create `admin/internal/api/checkout.go`: + +```go +package api + +import ( + "log" + "net/http" + "strings" + "time" + + paddlesdk "github.com/PaddleHQ/paddle-go-sdk/v4" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/auth" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/admin/internal/paddle" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// checkoutOptions tells the browser what it may buy and at which price IDs. +// +// The IDs come from the plans table for the RUNNING environment, so the overlay +// cannot be opened against a sandbox price by a production build. The +// environment is returned alongside them precisely so the client can compare it +// against its own build-time PADDLE_ENV and refuse on a mismatch — the two are +// configured separately and this is the only place they meet. +func checkoutOptions(c *gin.Context) { + ctx := c.Request.Context() + s := auth.Current(c) + + cur, err := db.Admin("plans").Find(ctx, bson.M{"active": true}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + plans := []models.Plan{} + if err := cur.All(ctx, &plans); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + env := paddle.Env() + out := []gin.H{} + for _, p := range plans { + // Free is not a Paddle product, so it simply has no prices and drops out + // here without a tier check. + prices := gin.H{} + for _, term := range []string{models.TermMonthly, models.TermAnnual} { + if id := p.PriceID(env, term); id != "" { + prices[term] = id + } + } + if len(prices) == 0 { + continue + } + out = append(out, gin.H{ + "tier": p.Tier, "name": p.Name, "deployment": p.Deployment, + "limits": p.Limits, "features": p.Features, "prices": prices, + }) + } + + var acct models.Account + _ = db.Admin("accounts").FindOne(ctx, bson.M{"account_id": s.AccountID}).Decode(&acct) + + c.JSON(http.StatusOK, gin.H{ + "environment": env, + "account_id": s.AccountID, + "customer_email": firstNonEmptyStr(acct.BillingEmail, s.Email), + "paddle_customer_id": acct.PaddleCustomerID, + "plans": out, + }) +} + +// createSelfHostedInstance makes the row a self-hosted checkout attaches to. +// +// The row exists BEFORE payment so subscription.created has something to +// record. It is deliberately not licensed: a licence binds to the install's own +// UUID and we do not know it yet. Repeating the call returns the existing +// placeholder rather than making a second one, so a customer who reloads the +// purchase page does not end up with two. +func createSelfHostedInstance(c *gin.Context) { + var body struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Name) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + ctx := c.Request.Context() + s := auth.Current(c) + + var existing models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, bson.M{ + "account_id": s.AccountID, "placeholder": true, + }).Decode(&existing); err == nil { + c.JSON(http.StatusOK, existing) + return + } + + rec := models.Instance{ + InstanceID: uuid.NewString(), + AccountID: s.AccountID, + Name: strings.TrimSpace(body.Name), + Deployment: license.DeploymentSelfHosted, + Status: models.StatusAwaitingLink, + Placeholder: true, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start the purchase"}) + return + } + audit.Write(ctx, models.AuditEntry{ + Actor: s.Email, Action: "instance.placeholder_created", AccountID: s.AccountID, + Target: rec.InstanceID, IP: c.ClientIP()}) + c.JSON(http.StatusCreated, rec) +} + +// billingPortal mints a Paddle customer portal link. +// +// Cards, invoices, tax details and cancellation are all Paddle's, which is the +// point of a merchant of record. We hand over a signed deep link and get out of +// the way. +func billingPortal(c *gin.Context) { + ctx := c.Request.Context() + s := auth.Current(c) + + var acct models.Account + if err := db.Admin("accounts").FindOne(ctx, + bson.M{"account_id": s.AccountID}).Decode(&acct); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if acct.PaddleCustomerID == "" { + c.JSON(http.StatusConflict, gin.H{ + "error": "there is nothing to manage yet — this account has no paid subscription"}) + return + } + + cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"account_id": s.AccountID}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + subs := []models.Subscription{} + if err := cur.All(ctx, &subs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + ids := []string{} + for _, sub := range subs { + if sub.PaddleSubscriptionID != "" { + ids = append(ids, sub.PaddleSubscriptionID) + } + } + + session, err := paddle.SDK().CreateCustomerPortalSession(ctx, + &paddlesdk.CreateCustomerPortalSessionRequest{ + CustomerID: acct.PaddleCustomerID, + SubscriptionIDs: ids, + }) + if err != nil { + log.Printf("billingPortal: account %s: %v", s.AccountID, err) + c.JSON(http.StatusBadGateway, gin.H{ + "error": "Paddle could not open the billing portal; try again shortly"}) + return + } + + out := []gin.H{} + for _, u := range session.URLs.Subscriptions { + out = append(out, gin.H{ + "paddle_subscription_id": u.ID, + "cancel": u.CancelSubscription, + "update_payment_method": u.UpdateSubscriptionPaymentMethod, + }) + } + // The links carry a temporary token, so they are returned and never stored. + c.JSON(http.StatusOK, gin.H{ + "overview_url": session.URLs.General.Overview, + "subscriptions": out, + }) +} + +func firstNonEmptyStr(a, b string) string { + if a != "" { + return a + } + return b +} +``` + +- [ ] **Step 3: Teach `linkInstance` to claim a placeholder** + +In `admin/internal/api/customer.go`, replace the body of `linkInstance` after the JSON bind with: + +```go + ctx := c.Request.Context() + s := auth.Current(c) + + // A paid self-hosted purchase left a placeholder row waiting for this UUID. + // Claiming it is not the same as linking a new install: the subscription + // already exists, so the licence is issued here and now. + var placeholder models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, bson.M{ + "account_id": s.AccountID, "placeholder": true, + }).Decode(&placeholder); err == nil { + inst, err := licensing.ClaimPlaceholder(ctx, s.AccountID, + placeholder.InstanceID, body.InstanceID) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, licensing.ErrAlreadyLinked) { + status = http.StatusConflict + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + lic, err := licensing.LicenceForSubscription(ctx, inst) + if err != nil { + // The webhook has not landed yet. The instance is claimed, which is + // the part that cannot be repeated; the lifecycle sweep issues. + log.Printf("linkInstance: %s claimed but not yet licensed: %v", inst.InstanceID, err) + c.JSON(http.StatusCreated, inst) + return + } + deliver(c, inst, lic) + c.JSON(http.StatusCreated, inst) + return + } + + inst, err := licensing.LinkInstance(ctx, s.AccountID, body.InstanceID, body.Name) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, licensing.ErrAlreadyLinked) { + status = http.StatusConflict + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, inst) +``` + +- [ ] **Step 4: Add the staff billing-health endpoint** + +In `admin/internal/api/staff.go`, after `staffInjectionHealth`: + +```go +// staffBillingHealth lists webhooks whose handler failed. +// +// A licence that silently failed to issue is a customer who paid and got +// nothing, so this is the queue that must never be quietly non-empty. Every row +// keeps its raw payload, which is what makes a replay from Paddle's dashboard a +// comparison rather than a guess. +func staffBillingHealth(c *gin.Context) { + ctx := c.Request.Context() + cur, err := db.Admin("paddle_events").Find(ctx, + bson.M{"status": models.EventFailed}, + options.Find().SetSort(bson.D{{Key: "received_at", Value: -1}}).SetLimit(50)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + failed := []models.PaddleEvent{} + if err := cur.All(ctx, &failed); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + // Placeholders that have been paid for and never linked. The dashboard's + // awaiting_link queue counts rows; this counts money sitting in one. + unlinked, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{"placeholder": true}) + if err != nil { + unlinked = -1 + } + c.JSON(http.StatusOK, gin.H{ + "failed": failed, "count": len(failed), "unlinked_placeholders": unlinked}) +} +``` + +- [ ] **Step 5: Mount the routes** + +In `admin/internal/api/routes.go`, inside the `cust` group after `cust.POST("/instances", …)`: + +```go + cust.GET("/checkout/options", checkoutOptions) + cust.POST("/instances/self-hosted", + auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), + createSelfHostedInstance) + // Billing is owner-only, the same rule as everywhere else money is. + cust.POST("/billing/portal", + auth.RequireAccountRole(models.AccountRoleOwner), + billingPortal) +``` + +And in the `staff` group after the injection health route: + +```go + staff.GET("/health/billing", staffBillingHealth) +``` + +- [ ] **Step 6: Confirm the purchase-then-link path end to end** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... && GOWORK=off /tmp/gorun.sh admin go vet ./... +``` +Expected: no output. + +With admin running against the scratch databases and a signed-in customer cookie in `/tmp/cj`: + +```bash +curl -s -b /tmp/cj localhost:8083/api/checkout/options | python -m json.tool +``` +Expected: `environment: "sandbox"`, and a `plans` array containing `professional` with the two seeded price IDs. Free must **not** appear. + +```bash +curl -s -b /tmp/cj -X POST localhost:8083/api/instances/self-hosted \ + -H 'Content-Type: application/json' -d '{"name":"Their datacentre"}' | python -m json.tool +``` +Expected: `201`, `placeholder: true`, `status: "awaiting_link"`, a generated `instance_id`. Call it again — expected `200` with the **same** `instance_id`. + +Seed the self-hosted annual price, then send a `subscription.created` whose `custom_data.instance_id` is that placeholder: + +```bash +mongosh "$ADMIN_MONGO_URI" --quiet --eval \ + 'db.plans.updateOne({tier:"self_hosted"},{$set:{paddle_price_ids:{sandbox:{annual:"pri_sh_a"}}}})' +# edit /tmp/created.json: event_id evt_sh_1, price pri_sh_a, tier self_hosted, +# instance_id , billing_cycle interval "year", period end a year out +sh /tmp/pdlsend.sh testsecret /tmp/created.json | tail -1 +mongosh "$ADMIN_MONGO_URI" --quiet --eval ' +print(db.licenses.countDocuments({})); +print(JSON.stringify(db.subscriptions.findOne({tier:"self_hosted"},{_id:0,status:1,instance_id:1})));' +``` +Expected: `handled`; **no licence issued**; the subscription points at the placeholder ID. An `admin_audit` row with `action: billing.awaiting_link` is present. + +Now link the real UUID: + +```bash +curl -s -b /tmp/cj -X POST localhost:8083/api/instances/link \ + -H 'Content-Type: application/json' \ + -d '{"instance_id":"22222222-2222-2222-2222-222222222222"}' | python -m json.tool +mongosh "$ADMIN_MONGO_URI" --quiet --eval ' +print(JSON.stringify(db.admin_instances.findOne({instance_id:"22222222-2222-2222-2222-222222222222"},{_id:0,status:1,tier:1,placeholder:1,current_license:1}))); +print(JSON.stringify(db.subscriptions.findOne({tier:"self_hosted"},{_id:0,instance_id:1})));' +``` +Expected: `201`; the instance has `status: active`, `tier: self_hosted`, **no** `placeholder` field, a `current_license`; the subscription now names the real UUID. The licence's `expires_at` is the subscription's period end plus three days. + +Portal refusal before any Paddle customer exists: + +```bash +mongosh "$ADMIN_MONGO_URI" --quiet --eval 'db.accounts.updateOne({},{$unset:{paddle_customer_id:""}})' +curl -si -b /tmp/cj -X POST localhost:8083/api/billing/portal | head -1 +``` +Expected: `HTTP/1.1 409 Conflict`. + +- [ ] **Step 7: Commit** + +```bash +git add admin/internal/api admin/internal/licensing/link.go +git commit -m "feat(admin): self-hosted checkout, and the link that licences it + +A self-hosted purchase has nothing to attach a subscription to, so it +creates a placeholder row first. That row is never licensed: a licence +binds to the install's own UUID, and until the customer pastes it there is +no unbound licence to sign. Claiming moves both the instance ID and the +subscription that pays for it. + +checkout/options serves price IDs from the plans table for the running +PADDLE_ENV and returns that environment, so the browser can refuse a +build whose client token belongs to the other one. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 6: The awaiting-link sweep — backstop issuance and two reminders + +**Files:** +- Modify: `admin/internal/lifecycle/lifecycle.go`, `admin/internal/mail/mail.go` + +**Interfaces:** +- Consumes: `licensing.LicenceForSubscription`, `licensing.ErrNoSubscription`, `billing.Deliver`, `models.Instance.Placeholder`. +- Produces: + - `mail.SendLinkReminder(to, instanceName, portalURL string, hoursWaiting int) error` + - `lifecycle.noticeLink24`, `noticeLink72` constants + - `lifecycle.sweepAwaitingLink(ctx context.Context) error`, called from `Run` + +- [ ] **Step 1: Add the reminder email** + +In `admin/internal/mail/mail.go`, after `SendLicense`: + +```go +// SendLinkReminder chases a customer who has paid and never told us where to +// send the licence. +// +// This is the most likely place for a paying customer to get stuck, so it gets +// active chasing rather than a support queue. It says what is missing and why, +// because "action required" without a reason reads as a phishing attempt. +func SendLinkReminder(to, instanceName, portalURL string, hoursWaiting int) error { + body := "Your Vantage Self Hosted subscription for " + instanceName + " is paid and active,\r\n" + + "but we cannot issue your licence yet.\r\n\r\n" + + "A licence is signed against one instance ID, so we need the ID your own\r\n" + + "installation reports. Open Settings, then Licence, in your Vantage install and\r\n" + + "copy the instance ID.\r\n" + if portalURL != "" { + body += "\r\nPaste it here: " + portalURL + "/instances/link\r\n" + } + body += "\r\nIt has been about " + strconv.Itoa(hoursWaiting) + " hours. If you are stuck, reply to this\r\n" + + "email and a person will help.\r\n" + return send(to, "One step left: your Vantage licence", body) +} +``` + +Add `"strconv"` to that file's imports. + +- [ ] **Step 2: Add the sweep** + +In `admin/internal/lifecycle/lifecycle.go`, add to the notice-key constants: + +```go + // Awaiting-link reminders. They live in the same notices_sent list as the + // renewal notices, so a restart cannot re-send one, and a claimed instance + // simply stops matching the sweep. + noticeLink24 = "link_24" + noticeLink72 = "link_72" +``` + +At the end of `Run`, before `return nil`: + +```go + if err := sweepAwaitingLink(ctx); err != nil { + log.Printf("lifecycle: awaiting-link sweep: %v", err) + } +``` + +And add: + +```go +// sweepAwaitingLink handles both halves of the paid-but-unlinked problem. +// +// A claimed instance with a paid subscription and no licence gets one: the link +// handler issues on the happy path, but if the webhook landed after the +// customer linked, nothing else would ever issue. This is the backstop that +// makes the two orderings equivalent. +// +// A still-unclaimed placeholder gets chased at 24 and 72 hours, which is what +// the spec asks for; the staff dashboard's own 48-hour alert is separate and +// unchanged. +func sweepAwaitingLink(ctx context.Context) error { + now := time.Now().UTC() + + // Half one: claimed, paid, unlicensed. + cur, err := db.Admin("admin_instances").Find(ctx, bson.M{ + "deployment": license.DeploymentSelfHosted, + "placeholder": bson.M{"$ne": true}, + "current_license": bson.M{"$in": []any{nil, ""}}, + }) + if err != nil { + return err + } + var unlicensed []models.Instance + if err := cur.All(ctx, &unlicensed); err != nil { + return err + } + for _, inst := range unlicensed { + lic, err := licensing.LicenceForSubscription(ctx, &inst) + if errors.Is(err, licensing.ErrNoSubscription) { + continue // linked by hand, never bought; staff issue those + } + if err != nil { + log.Printf("lifecycle: backstop issue for %s: %v", inst.InstanceID, err) + continue + } + log.Printf("lifecycle: issued the backstop licence for %s", inst.InstanceID) + billing.Deliver(ctx, &inst, lic) + } + + // Half two: paid and still not claimed. + cur, err = db.Admin("admin_instances").Find(ctx, bson.M{"placeholder": true}) + if err != nil { + return err + } + var placeholders []models.Instance + if err := cur.All(ctx, &placeholders); err != nil { + return err + } + for _, inst := range placeholders { + var sub models.Subscription + if err := db.Admin("subscriptions").FindOne(ctx, bson.M{ + "instance_id": inst.InstanceID, + "status": bson.M{"$in": []string{models.SubActive, models.SubTrialing}}, + }).Decode(&sub); err != nil { + continue // not paid; nothing to chase + } + + waited := now.Sub(inst.CreatedAt) + key, hours := "", 0 + switch { + case waited > 72*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink72): + key, hours = noticeLink72, 72 + case waited > 24*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink24): + key, hours = noticeLink24, 24 + } + if key == "" { + continue + } + if mail.Enabled() { + var acct models.Account + if err := db.Admin("accounts").FindOne(ctx, + bson.M{"account_id": inst.AccountID}).Decode(&acct); err != nil { + continue + } + if err := mail.SendLinkReminder(acct.BillingEmail, inst.Name, portalURL, hours); err != nil { + log.Printf("lifecycle: link reminder for %s: %v", inst.InstanceID, err) + continue + } + } + if _, err := db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": inst.InstanceID}, + bson.M{"$addToSet": bson.M{"notices_sent": key}}); err != nil { + log.Printf("lifecycle: record %s for %s: %v", key, inst.InstanceID, err) + } + } + return nil +} +``` + +Add the imports this needs: `"errors"`, `"github.com/mrhid6/vantage/admin/internal/billing"`, `"github.com/mrhid6/vantage/admin/internal/licensing"`. `slices`, `time`, `log`, `db`, `mail`, `models`, `license` and `bson` are already there. + +**Watch for an import cycle:** `billing` must not import `lifecycle`. It does not, and it must not be made to. + +- [ ] **Step 3: Confirm both halves** + +```bash +GOWORK=off /tmp/gorun.sh admin go build ./... && GOWORK=off /tmp/gorun.sh admin go vet ./... +``` +Expected: no output. If `go vet` reports an import cycle, the fix is to move `Deliver` out of `billing` — not to have `billing` import `lifecycle`. + +Reverse the order deliberately: link first, then let the webhook land. + +```bash +# fresh placeholder, claim it with no subscription in existence +curl -s -b /tmp/cj -X POST localhost:8083/api/instances/self-hosted \ + -H 'Content-Type: application/json' -d '{"name":"Late webhook"}' +curl -s -b /tmp/cj -X POST localhost:8083/api/instances/link \ + -H 'Content-Type: application/json' \ + -d '{"instance_id":"33333333-3333-3333-3333-333333333333"}' +mongosh "$ADMIN_MONGO_URI" --quiet --eval \ + 'print(JSON.stringify(db.admin_instances.findOne({instance_id:"33333333-3333-3333-3333-333333333333"},{_id:0,current_license:1,placeholder:1})))' +``` +Expected: claimed (no `placeholder`), **no** `current_license`. + +Now send the `subscription.created` naming the *real* UUID, then restart admin (the sweep runs at boot): + +```bash +mongosh "$ADMIN_MONGO_URI" --quiet --eval \ + 'print(JSON.stringify(db.admin_instances.findOne({instance_id:"33333333-3333-3333-3333-333333333333"},{_id:0,current_license:1,status:1})))' +``` +Expected: a `current_license` and `status: active` — the webhook path issued it directly. Then verify the true backstop by clearing `current_license` and the licence row by hand and restarting: expected log line `lifecycle: issued the backstop licence for 3333…`. + +Reminder selection, without SMTP: + +```bash +mongosh "$ADMIN_MONGO_URI" --quiet --eval ' +db.admin_instances.updateOne({placeholder:true},{$set:{created_at:new Date(Date.now()-80*3600*1000)}})' +# restart admin, then: +mongosh "$ADMIN_MONGO_URI" --quiet --eval \ + 'print(JSON.stringify(db.admin_instances.findOne({placeholder:true},{_id:0,notices_sent:1})))' +``` +Expected: `notices_sent: ["link_72"]` — the most urgent unsent reminder, not both. + +- [ ] **Step 4: Commit** + +```bash +git add admin/internal/lifecycle admin/internal/mail/mail.go +git commit -m "feat(admin): chase a paid licence nobody linked, and issue it late + +Two orderings had to end the same way: the webhook can land before the +customer links, or after. The link handler covers the first; this sweep +covers the second, so neither ordering leaves a paying customer with a +subscription and no licence. + +Placeholders that have been paid for and not claimed are chased at 24 and +72 hours, recorded in the same notices_sent list as the renewal notices so +a restart cannot re-send one. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 7: Checkout in the portal + +**Files:** +- Create: `adminsite/lib/paddle.ts`, `adminsite/components/CheckoutButton.tsx`, `adminsite/components/UpgradePanel.tsx`, `adminsite/app/(customer)/purchase/page.tsx`, `adminsite/app/(customer)/purchase/PurchaseForm.tsx` +- Modify: `adminsite/package.json`, `adminsite/Dockerfile`, `adminsite/lib/api.ts`, `adminsite/components/InstanceRecord.tsx`, `adminsite/app/(customer)/billing/page.tsx`, `adminsite/app/(customer)/page.tsx` + +**Interfaces:** +- Consumes: `GET /api/checkout/options`, `POST /api/instances/self-hosted`, `POST /api/billing/portal`. +- Produces: + - `api.checkoutOptions()`, `api.createSelfHostedInstance(name)`, `api.billingPortal()` + - types `CheckoutOptions`, `CheckoutPlan`, `PortalSession` + - `getPaddle(): Promise` from `lib/paddle.ts` + - `` + - `` + +- [ ] **Step 1: Add the dependency and the build args** + +```bash +sh /tmp/npmrun.sh adminsite npm install @paddle/paddle-js@^1.4.2 +``` +Expected: `package.json` gains `"@paddle/paddle-js"` and `package-lock.json` is updated. + +In `adminsite/Dockerfile`, after the `NEXT_PUBLIC_ADMIN_ENV` pair: + +```dockerfile +# Paddle's client-side token and environment. Both are baked in, and both must +# match admin's own PADDLE_ENV: the token is issued per environment, so a +# production token cannot open a sandbox price and the overlay simply fails. +# CheckoutButton compares the two at runtime and refuses visibly rather than +# opening a checkout that cannot complete. +ARG NEXT_PUBLIC_PADDLE_CLIENT_TOKEN= +ENV NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=$NEXT_PUBLIC_PADDLE_CLIENT_TOKEN +ARG NEXT_PUBLIC_PADDLE_ENV=sandbox +ENV NEXT_PUBLIC_PADDLE_ENV=$NEXT_PUBLIC_PADDLE_ENV +``` + +- [ ] **Step 2: Add the API client methods and types** + +In `adminsite/lib/api.ts`, add to the types section: + +```ts +export type Term = "monthly" | "annual"; + +export interface CheckoutPlan { + tier: Tier; + name: string; + deployment: Deployment; + limits: Limits; + features: string[]; + /* Only the terms this tier actually sells. Self Hosted has annual alone. */ + prices: Partial>; +} + +export interface CheckoutOptions { + environment: "sandbox" | "production"; + account_id: string; + customer_email: string; + paddle_customer_id?: string; + plans: CheckoutPlan[]; +} + +export interface PortalSession { + overview_url: string; + subscriptions: { + paddle_subscription_id: string; + cancel: string; + update_payment_method: string; + }[]; +} +``` + +And to the `api` object: + +```ts + checkoutOptions: () => req("/api/checkout/options"), + createSelfHostedInstance: (name: string) => + post("/api/instances/self-hosted", { name }), + billingPortal: () => post("/api/billing/portal"), +``` + +In the `staff` object: + +```ts + billingHealth: () => + req<{ failed: PaddleEvent[]; count: number; unlinked_placeholders: number }>( + "/api/staff/health/billing", + ), +``` + +with the type: + +```ts +export interface PaddleEvent { + event_id: string; + event_type: string; + occurred_at: string; + received_at: string; + status: "received" | "handled" | "failed" | "ignored"; + error?: string; + attempts: number; + account_id?: string; + instance_id?: string; +} +``` + +Also add `placeholder?: boolean;` to the `Instance` interface. + +- [ ] **Step 3: Write the Paddle loader** + +Create `adminsite/lib/paddle.ts`: + +```ts +/* + * One Paddle instance per page load. + * + * initializePaddle injects a script; calling it per component would load the + * overlay several times and the second checkout would open behind the first. + * The promise is memoised rather than the resolved value so two components + * mounting in the same tick still share one load. + */ +import { initializePaddle, type Paddle } from "@paddle/paddle-js"; + +export const PADDLE_ENV = (process.env.NEXT_PUBLIC_PADDLE_ENV ?? "sandbox") as + | "sandbox" + | "production"; + +const TOKEN = process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN ?? ""; + +let pending: Promise | null = null; + +export function paddleConfigured(): boolean { + return TOKEN !== ""; +} + +export function getPaddle(): Promise { + if (!TOKEN) return Promise.resolve(undefined); + if (!pending) { + pending = initializePaddle({ environment: PADDLE_ENV, token: TOKEN }); + } + return pending; +} +``` + +- [ ] **Step 4: Write the checkout button** + +Create `adminsite/components/CheckoutButton.tsx`: + +```tsx +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { api, type Term, type Tier } from "@/lib/api"; +import { PADDLE_ENV, getPaddle, paddleConfigured } from "@/lib/paddle"; +import { Button } from "./Button"; + +interface Props { + tier: Tier; + term: Term; + /* The instance the subscription pays for. Always set: a cloud upgrade names + * the live instance, a self-hosted purchase names its placeholder. */ + instanceId: string; + label: string; + variant?: "solid" | "line"; +} + +/* + * Opens Paddle's overlay with the custom_data the webhook routes on. + * + * Three things can be wrong before a click is worth allowing, and each gets its + * own message rather than a dead button: no client token in this build, an + * environment that disagrees with admin's, or a tier with no price ID pasted in + * yet. The third is the common one — a plan is configured in the staff UI, not + * in this code. + */ +export function CheckoutButton({ tier, term, instanceId, label, variant = "solid" }: Props) { + const options = useQuery({ queryKey: ["checkout-options"], queryFn: api.checkoutOptions }); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + if (!paddleConfigured()) { + return ( +

+ Checkout is not configured in this build. Contact support@hostxtra.co.uk. +

+ ); + } + + const opts = options.data; + const mismatch = opts && opts.environment !== PADDLE_ENV; + const priceId = opts?.plans.find((p) => p.tier === tier)?.prices[term]; + + if (mismatch) { + return ( +

+ Billing is misconfigured: this portal is built for {PADDLE_ENV} and the + licensing service is running {opts?.environment}. Nothing has been charged. +

+ ); + } + + async function open() { + setError(null); + if (!opts || !priceId) return; + setBusy(true); + try { + const paddle = await getPaddle(); + if (!paddle) { + setError("Checkout could not load. Try again, or contact support."); + return; + } + paddle.Checkout.open({ + items: [{ priceId, quantity: 1 }], + /* Reuse the Paddle customer when we know it, so a second purchase + * lands on the same billing account rather than a duplicate. */ + customer: opts.paddle_customer_id + ? { id: opts.paddle_customer_id } + : { email: opts.customer_email }, + customData: { + account_id: opts.account_id, + instance_id: instanceId, + tier, + }, + settings: { displayMode: "overlay", theme: "light" }, + }); + } catch (e) { + setError(e instanceof Error ? e.message : "Checkout failed to open."); + } finally { + setBusy(false); + } + } + + return ( +
+ + {!options.isLoading && !priceId && ( + + This plan has no {term} price configured yet. + + )} + {error && {error}} +
+ ); +} +``` + +If `Button`'s prop is not named `variant` with those values, match whatever `adminsite/components/Button.tsx` actually declares — read it before writing this file. + +- [ ] **Step 5: Write the upgrade panel and put it on cloud instances** + +Create `adminsite/components/UpgradePanel.tsx`: + +```tsx +"use client"; + +import { useState } from "react"; +import { type Instance, type Term } from "@/lib/api"; +import { CheckoutButton } from "./CheckoutButton"; + +/* + * The upgrade path off Free. + * + * Term is a choice here rather than two buttons, because the two prices are the + * same product and presenting them as separate purchases invites buying twice. + */ +export function UpgradePanel({ instance }: { instance: Instance }) { + const [term, setTerm] = useState("annual"); + + return ( +
+
+ Upgrade to Professional + + Unlimited servers, secret groups and channels, plus the browser console + and single sign-on. Your current licence keeps working until the new one + is issued. + +
+
+ {(["monthly", "annual"] as Term[]).map((t) => ( + + ))} +
+ +
+ ); +} +``` + +In `adminsite/components/InstanceRecord.tsx`, add the import and render it inside the open body, immediately before the `
` action row: + +```tsx + {open && cloud && instance.tier === "free" && ( + + )} +``` + +Use whatever the component's existing "is expanded" variable is called — read the top of the file; do not invent `open` if it is named something else. + +- [ ] **Step 6: Write the self-hosted purchase page** + +Create `adminsite/app/(customer)/purchase/page.tsx`: + +```tsx +import { PageHeader } from "@/components/PageHeader"; +import { PurchaseForm } from "./PurchaseForm"; + +export default function PurchasePage() { + return ( +
+ + +
+ ); +} +``` + +Match `PageHeader`'s real prop names by reading `adminsite/components/PageHeader.tsx` first. + +Create `adminsite/app/(customer)/purchase/PurchaseForm.tsx`: + +```tsx +"use client"; + +import { useMutation } from "@tanstack/react-query"; +import { useState } from "react"; +import { ApiError, api, type Instance } from "@/lib/api"; +import { Button } from "@/components/Button"; +import { CheckoutButton } from "@/components/CheckoutButton"; + +/* + * Two steps, in this order for a reason: the instance row must exist before + * checkout opens, so subscription.created has something to attach to. It is a + * placeholder — the licence is issued later, against the UUID the customer's own + * install reports, because that is what a licence is bound to. + */ +export function PurchaseForm() { + const [name, setName] = useState(""); + const [placeholder, setPlaceholder] = useState(null); + const [error, setError] = useState(null); + + const start = useMutation({ + mutationFn: () => api.createSelfHostedInstance(name.trim()), + onSuccess: (inst) => { + setPlaceholder(inst); + setError(null); + }, + onError: (e) => + setError(e instanceof ApiError ? e.message : "Could not start the purchase."), + }); + + if (placeholder) { + return ( +
+
+ {placeholder.name} + + Pay for the year, then paste your installation's instance ID. We + cannot sign your licence before that: every licence is bound to one + instance ID, and only your install knows its own. + +
+ +

+ Already paid? Paste your instance ID on the{" "} + + link page + + . +

+
+ ); + } + + return ( +
{ + e.preventDefault(); + if (name.trim()) start.mutate(); + }} + > + + {error &&

{error}

} + +
+ ); +} +``` + +- [ ] **Step 7: Rework the billing page and add the overview call to action** + +In `adminsite/app/(customer)/billing/page.tsx`, replace the "Need a change?" rail card with a real portal button plus the cancellation explanation: + +```tsx + +

+ Cards, invoices, VAT details and cancellation all live with + Paddle, our payment provider. +

+ + {portal.error instanceof ApiError && ( + + {portal.error.message} + + )} +
+ +

+ Billing stops at the end of the period you have paid for, and + your licence keeps working until it expires — up to a month on + a monthly plan, up to a year on Self Hosted. +

+

+ On that date the instance goes read-only: monitors keep + running and alerts keep firing, changes stop. Nothing is + deleted. +

+
+``` + +with the mutation at the top of the component: + +```tsx + const portal = useMutation({ + mutationFn: api.billingPortal, + onSuccess: (s) => { + // A new tab, not a redirect: the customer is mid-task in the portal + // and the Paddle link's token is short-lived, so a back button that + // returns to a dead link would be worse than a second tab. + window.open(s.overview_url, "_blank", "noopener,noreferrer"); + }, + }); +``` + +Add the imports `useMutation`, `Button`, `ApiError`. + +Then in `adminsite/app/(customer)/page.tsx`, add a call to action alongside the existing create-instance one: + +```tsx + + Buy Self Hosted + +``` + +Place it where the existing overview actions are; read the file and match its structure rather than guessing a location. + +- [ ] **Step 8: Confirm the build and the guards** + +```bash +sh /tmp/npmrun.sh adminsite npm run build +``` +Expected: `Compiled successfully`, and `/purchase` in the route list. + +Confirm no hex colours crept in and no Paddle IDs are hard-coded: + +```bash +grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/components/CheckoutButton.tsx adminsite/components/UpgradePanel.tsx "adminsite/app/(customer)/purchase/" +grep -rn "pri_\|pro_\|ctm_" adminsite/ --include=*.tsx --include=*.ts | grep -v "\.test\." +``` +Expected: no output from either. + +Run the built image with an empty token and confirm the visible refusal rather than a broken button: + +```bash +MSYS_NO_PATHCONV=1 docker build --build-arg NEXT_PUBLIC_ADMIN_API_URL=http://localhost:8083 \ + --build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN= -t adminsite-test -f adminsite/Dockerfile adminsite/ +MSYS_NO_PATHCONV=1 docker run --rm -p 3004:3000 adminsite-test +``` +Expected: signed in, `/purchase` shows "Checkout is not configured in this build" and no enabled buy button. + +- [ ] **Step 9: Commit** + +```bash +git add adminsite/package.json adminsite/package-lock.json adminsite/Dockerfile adminsite/lib adminsite/components adminsite/app +git commit -m "feat(adminsite): Paddle checkout, and billing that manages itself + +The overlay carries account_id, instance_id and tier in custom_data, +which is what lets a webhook route without a lookup table. Price IDs come +from the API, never from this code: a plan is configured in the staff UI. + +CheckoutButton compares admin's PADDLE_ENV against the token this build +was compiled with and refuses visibly on a mismatch, because a sandbox +token cannot open a production price and the failure would otherwise be a +silent dead button. + +Cancellation is Paddle's screen, so the two things we control say the same +words: a permanent panel beside Manage billing, and the email the +canceled webhook sends. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 8: Staff — the price-ID editor and the failed-webhook queue + +**Files:** +- Modify: `adminsite/app/(staff)/staff/plans/page.tsx`, `adminsite/app/(staff)/staff/page.tsx`, `adminsite/lib/api.ts` + +**Interfaces:** +- Consumes: `api.staff.plans`, `api.staff.updatePlan`, `api.staff.billingHealth`. +- Produces: a per-plan price-ID form; a "Failed webhooks" queue on the operations dashboard. + +- [ ] **Step 1: Update the Plan type** + +In `adminsite/lib/api.ts`, change the `Plan` interface's price field: + +```ts + /* environment -> term -> Paddle price ID. The running PADDLE_ENV picks the + * inner map; both environments are stored so promotion is a config change. */ + paddle_price_ids?: Record>>; +``` + +- [ ] **Step 2: Replace the demo buttons with the price editor** + +In `adminsite/app/(staff)/staff/plans/page.tsx`, replace the `
` block containing "Cap servers at 7" and "Remove OIDC" with a price-ID editor. Add above the component: + +```tsx +const ENVS = ["sandbox", "production"] as const; + +/* + * Price IDs are the one part of a plan a human pastes in from another system, + * so they get a form rather than the confirm-dialog treatment: changing one + * cannot affect an issued licence, only what the next checkout charges. + * + * Both environments are editable from one screen on purpose. Promoting to + * production means pasting the production IDs here, not a deploy, and seeing + * them side by side is what makes "did I paste the sandbox one" answerable. + */ +function PriceEditor({ + plan, + onSave, + saving, +}: { + plan: Plan; + onSave: (ids: Record>>) => void; + saving: boolean; +}) { + const [ids, setIds] = useState(plan.paddle_price_ids ?? {}); + + // Free is not sold through Paddle, so it has nothing to configure. + if (plan.tier === "free") { + return ( +

+ Free is not a Paddle product. Instances are created and renewed from the + portal, so there is no price to configure. +

+ ); + } + + // Self Hosted is annual only — the monthly field is absent rather than + // disabled, because a monthly self-hosted licence would be unrevokable for + // a month and the backend refuses to issue one at all. + const terms: Term[] = plan.tier === "self_hosted" ? ["annual"] : ["monthly", "annual"]; + + const set = (env: string, term: Term, v: string) => + setIds((prev) => ({ ...prev, [env]: { ...(prev[env] ?? {}), [term]: v.trim() } })); + + return ( +
+ {ENVS.map((env) => ( +
+ + {env} + + {terms.map((term) => ( + + ))} +
+ ))} + +
+ ); +} +``` + +And render it inside each plan `
`: + +```tsx + + savePrices.mutate({ ...p, paddle_price_ids }) + } + /> +``` + +with a second mutation that skips the confirmation dialog, since prices do not alter issued licences: + +```tsx + const savePrices = useMutation({ + mutationFn: (p: Plan) => + api.staff.updatePlan(p.tier, { + name: p.name, + limits: p.limits, + features: p.features, + paddle_product_id: p.paddle_product_id, + paddle_price_ids: p.paddle_price_ids, + active: p.active, + }), + onSuccess: () => qc.invalidateQueries({ queryKey: ["plans"] }), + }); +``` + +Import `useState` and `Term`. + +- [ ] **Step 3: Add the failed-webhook queue** + +In `adminsite/app/(staff)/staff/page.tsx`, add the query: + +```tsx + const billing = useQuery({ queryKey: ["billing-health"], queryFn: api.staff.billingHealth }); +``` + +Include it in the "something is failing" test, since a failed webhook is the same class of problem as a failed injection — a customer who paid and got nothing: + +```tsx + const failed = (injection.data?.count ?? 0) + (billing.data?.count ?? 0); +``` + +Keep the injection queue's own count separate where it renders (`injection.data?.count ?? 0`), and add a fourth queue: + +```tsx + ({ + label: e.event_type, + href: e.instance_id ? `/staff/instances/${e.instance_id}` : "/staff/audit", + meta: e.received_at.slice(11, 16), + }))} + /> +``` + +Update the header subtitle so it is still true with two failure sources: + +```tsx + failed > 0 + ? "Failures come first — those customers are paying for a licence they have not received." + : "Nothing failing. Queues below are routine chasing." +``` + +- [ ] **Step 4: Confirm** + +```bash +sh /tmp/npmrun.sh adminsite npm run build +``` +Expected: `Compiled successfully`. + +With admin running and a staff cookie in `/tmp/sj`: + +```bash +curl -s -b /tmp/sj -X PUT localhost:8083/api/staff/plans/professional \ + -H 'Content-Type: application/json' \ + -d '{"name":"Vantage Professional","limits":{"max_servers":0,"max_secret_groups":0,"max_channels":0},"features":["console","oidc"],"paddle_price_ids":{"sandbox":{"monthly":"pri_a","annual":"pri_b"},"production":{"monthly":"pri_c"}},"active":true}' +curl -s -b /tmp/sj localhost:8083/api/staff/plans | python -m json.tool | grep -A6 paddle_price_ids +``` +Expected: the nested map round-trips, both environments intact. + +```bash +curl -s -b /tmp/sj localhost:8083/api/staff/health/billing | python -m json.tool +``` +Expected: `count`, `failed`, `unlinked_placeholders`. + +- [ ] **Step 5: Commit** + +```bash +git add adminsite +git commit -m "feat(adminsite): paste price IDs, and see the webhooks that failed + +Both environments are editable side by side, because promoting sandbox to +production is pasting IDs here rather than a deploy, and seeing them +together is what makes 'did I paste the sandbox one' answerable. + +Self Hosted shows no monthly field at all: the backend refuses to issue a +monthly self-hosted licence, and a disabled input would suggest it is a +setting rather than a rule. + +Failed webhooks join failed injections in the same first-position tone. +Both mean a customer paid and got nothing. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 9: Deployment configuration and documentation + +**Files:** +- Modify: `deploy/docker-compose.site.yml`, `.gitea/workflows/server-deploy.yml`, `CLAUDE.md`, `docs/superpowers/specs/README.md` + +**Interfaces:** +- Consumes: everything above. +- Produces: the runnable configuration, and documentation that matches it. + +- [ ] **Step 1: Add admin's three variables** + +In `deploy/docker-compose.site.yml`, in the `admin` service's environment, after `FREE_INSTANCE_REAP_AFTER`: + +```yaml + # sandbox or production. This picks which of a plan's two price-ID + # maps is served, so promotion is a config change — but it must match + # the token adminsite was BUILT with, or checkout refuses visibly. + PADDLE_ENV: ${PADDLE_ENV:-sandbox} + PADDLE_API_KEY: ${PADDLE_API_KEY:-} + # Boot fails without this. An unverified webhook endpoint is an + # endpoint anyone can issue licences through. + PADDLE_WEBHOOK_SECRET: ${PADDLE_WEBHOOK_SECRET:-} +``` + +**Do not add any of these to `deploy/docker-compose.yml`.** A self-hosted deployment has no `admin` service at all, and `LICENSE_SIGNING_KEY`'s rule applies identically here. + +- [ ] **Step 2: Add adminsite's two build args** + +In `.gitea/workflows/server-deploy.yml`, in the adminsite build step: + +```yaml + docker build \ + --build-arg NEXT_PUBLIC_ADMIN_API_URL="${{ vars.ADMIN_API_URL }}" \ + --build-arg NEXT_PUBLIC_ADMIN_ENV="${{ vars.ADMIN_ENV }}" \ + --build-arg NEXT_PUBLIC_PADDLE_CLIENT_TOKEN="${{ vars.PADDLE_CLIENT_TOKEN }}" \ + --build-arg NEXT_PUBLIC_PADDLE_ENV="${{ vars.PADDLE_ENV }}" \ + -t "$IMAGE" \ + -f adminsite/Dockerfile adminsite/ +``` + +- [ ] **Step 3: Update `CLAUDE.md`** + +Four edits, all in existing tables and sections: + +In the **Admin REST API** section, add to the unauthenticated list: + +``` +POST /api/paddle/webhook # Paddle; signature-verified, idempotent +``` + +and to the customer-session list: + +``` +GET /checkout/options # tiers and price IDs for the running PADDLE_ENV +POST /instances/self-hosted # placeholder row a self-hosted checkout attaches to +POST /billing/portal # Paddle customer portal deep links (owner only) +``` + +and to the staff list: + +``` +GET /health/billing +``` + +In the **MongoDB Collections** paragraph about admin's own database, add `paddle_events` to the list and a note: + +``` +`paddle_events` is admin's webhook idempotency record and paper trail: unique on +`event_id`, holding the raw payload, and deliberately without a TTL — dropping an +event ID would reopen the duplicate window for a late replay. A duplicate of a +handled event answers `200` and does nothing; a retry of a *failed* one is +reprocessed, which is what makes Paddle's own retry schedule our retry mechanism. +``` + +Add a **Billing** subsection under Subsystems, after "Notification channels": + +```markdown +### Billing + +Paddle is merchant of record: it owns checkout, tax, invoices, dunning and the +customer billing portal. Two products only — **Professional** (cloud, monthly or +annual) and **Self Hosted** (annual only). **Free is not a Paddle product**: it +is created and renewed from the portal by `POST /api/instances[/:id/renew]`, and +an account gets a `paddle_customer_id` the first time a paid subscription's +webhook arrives. + +Self Hosted is annual-only because licences are offline-verified and cannot be +revoked. Cancellation therefore takes effect at **expiry**: `subscription.canceled` +and `subscription.past_due` take no licence action at all, and the instance stays +`active` — marking it cancelled would drop it out of `inject.Reconcile`'s filter +and stop repairing a licence that is still valid. + +Price IDs live in `plans.paddle_price_ids` as environment → term → ID and are +pasted in through the staff Plans screen. `PADDLE_ENV` picks the inner map, so +promoting sandbox to production is a configuration change. **The same value must +be baked into `adminsite` as `NEXT_PUBLIC_PADDLE_ENV`** alongside a matching +`PADDLE_CLIENT_TOKEN`; `CheckoutButton` compares the two and refuses visibly +rather than opening a checkout that cannot complete. + +A self-hosted purchase creates a **placeholder** `admin_instances` row before +checkout, because `subscription.created` needs something to attach to. It is +never licensed — a licence binds to the install's own UUID. Linking claims the +row, moves the subscription onto the real UUID and issues. If the webhook lands +*after* the link instead, the hourly lifecycle sweep issues the same licence, so +both orderings end identically; unclaimed paid placeholders are chased at 24 and +72 hours. +``` + +Add to the server-side environment table's neighbourhood — specifically the admin variables need a home, so add a short table after the sitesvc one: + +```markdown +**admin** (`deploy/docker-compose.site.yml` only, in addition to the existing variables): + +| Name | Required | Notes | +| --- | --- | --- | +| `PADDLE_ENV` | **yes** | `sandbox` or `production`. Selects which of a plan's price-ID maps is served; an unrecognised value refuses to boot rather than defaulting. Must match `adminsite`'s build-time `NEXT_PUBLIC_PADDLE_ENV`. | +| `PADDLE_API_KEY` | **yes** | server-side API. Only outbound call is the customer portal session. | +| `PADDLE_WEBHOOK_SECRET` | **yes** | signature verification. **Boot fails without it** — an unverified webhook endpoint is an endpoint anyone can issue licences through. | +``` + +And add two rows to the CI **Secrets / variables** table: + +```markdown +| `PADDLE_CLIENT_TOKEN` | Variable | Paddle's **client-side** token, baked into `adminsite`. Environment-specific: a sandbox token cannot open a production price. | +| `PADDLE_ENV` | Variable | `sandbox` or `production`, baked into `adminsite` and set on `admin`. The two must agree or checkout refuses. | +``` + +- [ ] **Step 4: Update the spec index** + +In `docs/superpowers/specs/README.md`, change spec 5's row: + +```markdown +| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | [plan](../plans/2026-07-26-paddle-billing.md) | **shipped**, with three documented deviations: Free stays outside Paddle, `canceled` marks the subscription rather than the instance, and the signup-migration section was already superseded by 6 | +``` + +- [ ] **Step 5: Confirm the configuration is where it should be and nowhere else** + +```bash +grep -n "PADDLE" deploy/docker-compose.yml +``` +Expected: **no output**. If anything matches, remove it — a self-hosted deployment must never carry billing configuration. + +```bash +grep -c "PADDLE" deploy/docker-compose.site.yml .gitea/workflows/server-deploy.yml +``` +Expected: `3` and `2` respectively. + +```bash +grep -rn "pri_\|pro_01\|ctm_" admin/internal --include=*.go | grep -v "_test" +``` +Expected: no output — no Paddle entity ID is hard-coded in Go. + +- [ ] **Step 6: Commit** + +```bash +git add deploy .gitea CLAUDE.md docs/superpowers/specs/README.md +git commit -m "doc: Paddle's configuration, and the three rules it must obey + +PADDLE_WEBHOOK_SECRET is fatal at boot, PADDLE_ENV must match the token +baked into adminsite, and none of the three appear in the self-hosted +compose file — the same rule LICENSE_SIGNING_KEY follows, for the same +reason. + +Records why Free is not a Paddle product and why subscription.canceled +marks the subscription rather than the instance. + +Co-Authored-By: Claude Opus 5 " +``` + +--- + +### Task 10: The sandbox catalog, and the end-to-end pass + +This task creates no code. It is the runbook that turns a working build into a +working product, and it is the only place a Paddle dashboard is touched. + +**Files:** none. Record the resulting IDs in the staff Plans screen, not in the repo. + +**Interfaces:** +- Consumes: everything above, deployed to a host Paddle can reach. +- Produces: two sandbox products with prices, a webhook destination, and a signed-off verification list. + +- [ ] **Step 1: Create the catalog in Paddle sandbox** + +The Paddle MCP server is connected in the user's environment; use it if it is available in your session, otherwise do this in the sandbox dashboard. Create **two** products — Free is not a Paddle product: + +| Product | Prices | Notes | +|---|---|---| +| Vantage Professional | monthly and annual, GBP | cloud | +| Vantage Self Hosted | **annual only**, GBP | create no monthly price; the backend refuses one and the staff UI has no field for it | + +Copy each price's `pri_…` ID. + +- [ ] **Step 2: Paste the IDs into the staff Plans screen** + +Sign in to `vantage-hq` as staff, open **Plans**, and paste each ID under **sandbox**. Leave production empty. + +Confirm the running service sees them: + +```bash +curl -s -b /tmp/cj https://vantage-hq.hostxtra.co.uk/api/checkout/options | python -m json.tool +``` +Expected: `environment: "sandbox"`, `professional` with two prices, `self_hosted` with `annual` only, and **no** `free` entry. + +- [ ] **Step 3: Point Paddle's webhook at admin** + +In the sandbox dashboard, create a notification destination at +`https:///api/paddle/webhook`, subscribed to exactly: + +`subscription.created`, `subscription.updated`, `subscription.canceled`, +`subscription.past_due`, `transaction.completed`, `transaction.payment_failed`, +`customer.updated`. + +Copy the signing secret into `PADDLE_WEBHOOK_SECRET` and restart `admin`. + +Confirm the destination is live by sending a test event from the dashboard: +Expected: `200` in Paddle's log, and a row in `paddle_events` with +`status: "ignored"` (a test event is not one of the seven). + +- [ ] **Step 4: Cloud upgrade, end to end** + +As a customer with a Free cloud instance, open the instance and use **Upgrade to +Professional**, annual, paying with Paddle's test card. + +Confirm, in order: +1. `paddle_events` holds `subscription.created` with `status: "handled"`. +2. `licenses` holds a new `professional` licence whose `expires_at` is the period end plus three days, and the previous Free licence now carries `superseded_by`. +3. `admin_instances` shows `tier: professional`. +4. The **control plane** instance document's `license_tier` is `professional` — that is `inject`, and it is what the customer's instance actually reads. +5. In `web/`, the instance reports a valid Professional licence and the console and OIDC are available. + +- [ ] **Step 5: Self-hosted purchase, end to end** + +As a customer, `/purchase` → name → pay annually with the test card. + +Confirm: +1. `admin_instances` has a `placeholder: true` row, `status: awaiting_link`, and **no licence**. +2. `admin_audit` holds `billing.awaiting_link`. +3. Pasting a real install's instance ID on `/instances/link` issues the licence, clears `placeholder`, and moves the subscription's `instance_id`. +4. The licence blob emailed and downloaded are byte-identical, and pasting it into a self-hosted install reports `valid`. + +- [ ] **Step 6: Renewal, cancellation and replay** + +1. Trigger a renewal in the sandbox dashboard. Expected: a second licence with `reason: renewal`, the first superseded, `relink_count` back to `0`, and the cloud instance re-injected. +2. Cancel the subscription in Paddle. Expected: `subscriptions.status: canceled`; **`admin_instances.status` still `active`**; licence count unchanged; the cancellation email states the expiry date. +3. Let a Free instance's licence lapse (or set an expiry by hand) and confirm degradation is unchanged: monitors still executing, mutations refused. +4. **Replay every event** from Paddle's dashboard notification log. Expected: every replay answers `200`, `paddle_events` gains no rows, and `licenses.countDocuments({})` is **identical before and after**. This is the single most important check in the plan. +5. Simulate a failure: stop MongoDB's admin database briefly and replay one event. Expected: `500` to Paddle, `paddle_events.status: "failed"`, a `billing.webhook_failed` row in `admin_audit`, the event on the staff dashboard's Failed webhooks queue — and Paddle's own retry then succeeding, because a failed event is reclaimable. + +- [ ] **Step 7: Record the outcome** + +Append a short "verified on against Paddle sandbox" note to the spec +index row for spec 5, listing anything the pass could not cover. Commit that +alone. + +--- + +## Done when + +- A customer upgrades a Free cloud instance to Professional in the portal and their instance is serving a Professional licence within seconds, with the whole chain — event, licence, injection, control-plane document — agreeing. +- A customer buys Self Hosted, links their install, and the licence they download verifies on it. +- Every one of the seven subscribed event types produces the action the spec's table names, and nothing else does. +- Replaying every event from Paddle's dashboard creates **zero** duplicate licences. +- A bad signature is `401` and leaves no row in `paddle_events`. +- `subscription.canceled` and `subscription.past_due` change no licence and no expiry, anywhere. +- A handler failure is `500` to Paddle, is visible on the staff dashboard, and is fixed by Paddle's own retry once the cause is gone. +- `grep -n "PADDLE" deploy/docker-compose.yml` returns nothing. +- `grep -rn "pri_" admin/ adminsite/ --include=*.go --include=*.ts --include=*.tsx` returns nothing outside documentation. +- Boot refuses without `PADDLE_WEBHOOK_SECRET`, and refuses an unrecognised `PADDLE_ENV`. + +**Not proven by this plan:** production Paddle. Everything is verified in sandbox, and promotion is pasting production price IDs into the staff screen, setting `PADDLE_ENV=production` on `admin`, and rebuilding `adminsite` with the production client token. Do the first real production purchase yourself and watch `paddle_events` while you do it. + +Also not proven: the three new emails, because the verification harness has no SMTP. `SendCancelled`, `SendPastDue` and `SendLinkReminder` are selected and their recipients resolved, but nothing is delivered. Watch the first real send. + +## Deployment order + +1. Deploy `admin` with `PADDLE_ENV=sandbox`, the API key and the webhook secret set. Nothing can be bought yet — no price IDs exist, so `checkout/options` serves an empty plan list and every checkout button is disabled with a reason. +2. Create the sandbox catalog and paste the IDs (task 10, steps 1–2). +3. Create the webhook destination and restart `admin` with its secret (task 10, step 3). +4. Rebuild and deploy `adminsite` with `PADDLE_CLIENT_TOKEN` and `PADDLE_ENV`. **Because a repo variable pushes no commit, run the workflow manually** — this is exactly the gap `CLAUDE.md` names. +5. Run task 10's verification. +6. Only then repeat 1–4 with production values, in the same order. + +## Not in this plan + +Discounts and coupons, seat-based or metered pricing, proration arithmetic of +our own, invoice display inside the portal, a self-service downgrade path, and +tax handling — all of which are either Paddle's or deliberately out of scope per +the spec's non-goals. Free stays outside Paddle; if that is ever revisited, it is +a spec change, not a follow-up task.