# 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`.

---

## ⚠ REVISED BY SPEC 7 — WHICH HAS SHIPPED

Spec 7 landed on 2026-07-27. The data model this plan assumed no longer exists:
`plans` is keyed on `(deployment, tier)`, every price ID lives in `catalogue`,
and `entitlements` holds what a licence is signed from. `admin/internal/catalogue`
already provides both folds, including `ResolveItems`, which is what replaces this
plan's price-ID-to-tier lookup.

**Re-derive tasks 1, 3, 4, 5, 7, 8 and 10 against the shipped code before
executing them.** Tasks 2, 6 and 9 stand as written.

`admin/internal/paddle` and `admin/internal/billing` still do not exist, so the
Paddle client, webhooks and checkout remain entirely this plan's work. The break
with what was written here is structural: this plan assumes **one price per
subscription**, and a metered plan has three or more — a base fee, a per-server
unit at quantity N, and an item per paid feature. Every place that maps a price
ID to a tier changes shape.

What spec 7 changes:

- Two deployments × three tiers = **six plans**. `plans` is re-keyed on
  `(deployment, tier)`; `Enterprise` is new; Free exists in both deployments.
- **`plans.paddle_price_ids` and `paddle_product_id` are deleted.** Every price ID
  moves to a new `catalogue` collection, one row per priceable component
  (`base` / `limit` / `feature`).
- A new **`entitlements`** collection, one row per instance, holding `desired`
  beside `granted`. A licence is only ever signed from `granted`; a checkout is
  only ever built from `desired`.
- Servers are metered: `3 + N` at Professional, `10 + N` at Enterprise. Increases
  are prorated and immediate; **reductions are scheduled for the next renewal.**
- Console and SSO become per-customer opt-in toggles, and the control plane starts
  actually enforcing `license.HasFeature`, which today it calls from nowhere.

Task by task:

| Task                                              | Status under spec 7                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 — Paddle client, config, price IDs as data      | **Split.** The `paddle` package and the three config variables survive verbatim. Everything about `Plan.PaddlePriceIDs`, `Plan.PriceID` and `models.ResolvePriceID` is replaced by the `catalogue` collection and `catalogue.LineItems` / reverse resolution. `TermMonthly`/`TermAnnual` and the `Sub*` status constants survive.                                                                                                                                                                                            |
| 2 — the webhook: signature, idempotency, dispatch | **Survives unchanged.** Nothing about verification, `paddle_events` or the dispatch switch depends on how many prices a subscription has.                                                                                                                                                                                                                                                                                                                                                                                    |
| 3 — subscription events                           | **Rewritten.** `SubState.PriceID` becomes the full item list. Tier, deployment and term come from the item matching a `kind: "base"` row; the server count from the `kind: "limit"` item's quantity; features from the `kind: "feature"` items. On success it promotes `desired` into `granted` and reissues from the entitlement. `MarkCanceled` and `MarkPastDue` survive verbatim — they take no licence action, and that does not change. The self-hosted-annual-only refusal survives, moved into catalogue resolution. |
| 4 — renewals and payment failures                 | **Extended.** A renewal must now promote a _scheduled reduction_ — `desired` into `granted` — before issuing, and clear `scheduled_change_at`. That is the only mechanism by which a licence ever gets a smaller cap.                                                                                                                                                                                                                                                                                                        |
| 5 — checkout endpoints and the placeholder        | **Reworked.** `GET /api/checkout/options` serves the catalogue: six plans, base allowances, the per-server price, and the feature list with prices where they exist. `POST /api/instances/self-hosted` gains tier, term, server count and features. A new `PUT /api/instances/:id/entitlement` makes admin's first outbound Paddle call beyond the portal session. Placeholder creation and claiming survive verbatim.                                                                                                       |
| 6 — the awaiting-link sweep                       | **Survives**, except backstop issuance reads the entitlement rather than the plan.                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| 7 — checkout in the portal                        | **Reworked.** `UpgradePanel` becomes the configurator: deployment, tier, term, a server stepper and feature checkboxes, with a live price. `CheckoutButton` opens an overlay with several items rather than one price. A pending reduction is shown with the date it takes effect.                                                                                                                                                                                                                                           |
| 8 — staff price-ID editor                         | **Reworked.** Two tables — `plans` (allowances, support level, active) and `catalogue` (price IDs per environment and term) — instead of one nested map per plan row.                                                                                                                                                                                                                                                                                                                                                        |
| 9 — deployment configuration                      | **Survives unchanged.**                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| 10 — the sandbox catalog and the end-to-end pass  | **Grows.** Eight sandbox products, not two: a base and an additional-server product for each of the four paid plans, giving twelve prices (cloud gets monthly and annual, self-hosted annual only). Free is still not a Paddle product — now for both of its deployments. The end-to-end pass gains a server increase, a scheduled reduction, and a feature toggle.                                                                                                                                                          |

And one correction to this plan's own "Not in this plan" list at the foot:
**metered pricing and a self-service downgrade path are now in scope**, by spec 7.
Discounts, coupons, our own proration arithmetic, invoice display and tax remain
out.

---

## 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 <module-dir> <command...>
    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> <command...>
    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. (Under spec 7 the location changes to the `catalogue` collection; the constraint itself does not.)
- **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 `"gitea.hostxtra.co.uk/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 <noreply@anthropic.com>"
```

---

### 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"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/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"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/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 (`gitea.hostxtra.co.uk/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> <json-file>
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 <noreply@anthropic.com>"
```

---

### 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"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/inject"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/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"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/mail"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
	"gitea.hostxtra.co.uk/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 <noreply@anthropic.com>"
```

---

### 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"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
	"gitea.hostxtra.co.uk/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 <noreply@anthropic.com>"
```

---

### 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"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
	"gitea.hostxtra.co.uk/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 <the placeholder>, 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 <noreply@anthropic.com>"
```

---

### 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"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"`, `"gitea.hostxtra.co.uk/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 <noreply@anthropic.com>"
```

---

### 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<Paddle | null>` from `lib/paddle.ts`
    - `<CheckoutButton tier term instanceId label variant />`
    - `<UpgradePanel instance />`

- [ ] **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<Record<Term, string>>;
}

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<CheckoutOptions>("/api/checkout/options"),
    createSelfHostedInstance: (name: string) =>
        post<Instance>("/api/instances/self-hosted", { name }),
    billingPortal: () => post<PortalSession>("/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<Paddle | undefined> | null = null;

export function paddleConfigured(): boolean {
    return TOKEN !== "";
}

export function getPaddle(): Promise<Paddle | undefined> {
    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<string | null>(null);
    const [busy, setBusy] = useState(false);

    if (!paddleConfigured()) {
        return <p className="text-[0.82rem] text-warn">Checkout is not configured in this build. Contact support@hostxtra.co.uk.</p>;
    }

    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 (
            <p className="text-[0.82rem] text-expired">
                Billing is misconfigured: this portal is built for {PADDLE_ENV} and the licensing service is running {opts?.environment}. Nothing has been charged.
            </p>
        );
    }

    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 (
        <div className="grid gap-1.5">
            <Button type="button" variant={variant} onClick={open} disabled={busy || options.isLoading || !priceId}>
                {busy ? "Opening…" : label}
            </Button>
            {!options.isLoading && !priceId && <span className="text-[0.82rem] text-ink-3">This plan has no {term} price configured yet.</span>}
            {error && <span className="text-[0.82rem] text-expired">{error}</span>}
        </div>
    );
}
```

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<Term>("annual");

    return (
        <div className="grid gap-2.5 rounded border border-rule bg-panel-2 p-4">
            <div className="grid gap-1">
                <b className="text-[0.95rem]">Upgrade to Professional</b>
                <span className="text-[0.82rem] text-ink-2">
                    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.
                </span>
            </div>
            <div className="flex flex-wrap items-center gap-2">
                {(["monthly", "annual"] as Term[]).map((t) => (
                    <button
                        key={t}
                        type="button"
                        onClick={() => setTerm(t)}
                        aria-pressed={term === t}
                        className={
                            term === t
                                ? "rounded border border-accent bg-accent px-2.5 py-1 font-mono text-[0.7rem] uppercase tracking-[0.1em] text-accent-ink"
                                : "rounded border border-rule px-2.5 py-1 font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-2"
                        }
                    >
                        {t}
                    </button>
                ))}
            </div>
            <CheckoutButton tier="professional" term={term} instanceId={instance.instance_id} label={`Upgrade — billed ${term}`} />
        </div>
    );
}
```

In `adminsite/components/InstanceRecord.tsx`, add the import and render it inside the open body, immediately before the `<div className="flex flex-wrap items-center gap-2.5">` action row:

```tsx
{
    open && cloud && instance.tier === "free" && <UpgradePanel instance={instance} />;
}
```

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 (
        <div className="grid gap-6">
            <PageHeader title="Buy Vantage Self Hosted" subtitle="Annual only — a licence cannot be revoked once issued, so the term is what bounds it." back={{ href: "/", label: "Overview" }} />
            <PurchaseForm />
        </div>
    );
}
```

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<Instance | null>(null);
    const [error, setError] = useState<string | null>(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 (
            <section className="grid max-w-[46rem] gap-4 rounded border border-rule bg-panel p-5">
                <div className="grid gap-1">
                    <b className="text-[0.95rem]">{placeholder.name}</b>
                    <span className="text-[0.82rem] text-ink-2">
                        Pay for the year, then paste your installation&apos;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.
                    </span>
                </div>
                <CheckoutButton tier="self_hosted" term="annual" instanceId={placeholder.instance_id} label="Pay for a year" />
                <p className="text-[0.82rem] text-ink-3">
                    Already paid? Paste your instance ID on the{" "}
                    <a className="font-semibold text-accent underline" href="/instances/link">
                        link page
                    </a>
                    .
                </p>
            </section>
        );
    }

    return (
        <form
            className="grid max-w-[36rem] gap-3 rounded border border-rule bg-panel p-5"
            onSubmit={(e) => {
                e.preventDefault();
                if (name.trim()) start.mutate();
            }}
        >
            <label className="grid gap-1.5">
                <span className="font-mono text-[0.7rem] uppercase tracking-[0.1em] text-ink-3">What should we call this deployment?</span>
                <input
                    value={name}
                    onChange={(e) => setName(e.target.value)}
                    placeholder="Production, Head office, …"
                    className="rounded border border-rule bg-panel-2 px-3 py-2 text-ink-1 outline-none focus:border-accent"
                />
                <span className="text-[0.82rem] text-ink-3">A label for your own records. It appears on the licence and in support conversations.</span>
            </label>
            {error && <p className="text-[0.82rem] text-expired">{error}</p>}
            <Button type="submit" disabled={!name.trim() || start.isPending}>
                {start.isPending ? "Starting…" : "Continue to payment"}
            </Button>
        </form>
    );
}
```

- [ ] **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
                        <RailCard title="Manage billing">
                            <p className="text-[0.82rem] text-ink-2">
                                Cards, invoices, VAT details and cancellation all live with
                                Paddle, our payment provider.
                            </p>
                            <Button
                                type="button"
                                variant="line"
                                onClick={() => portal.mutate()}
                                disabled={portal.isPending}
                            >
                                {portal.isPending ? "Opening…" : "Manage billing"}
                            </Button>
                            {portal.error instanceof ApiError && (
                                <span className="text-[0.82rem] text-warn">
                                    {portal.error.message}
                                </span>
                            )}
                        </RailCard>
                        <RailCard title="If you cancel">
                            <p className="text-[0.82rem] text-ink-2">
                                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.
                            </p>
                            <p className="text-[0.82rem] text-ink-2">
                                On that date the instance goes read-only: monitors keep
                                running and alerts keep firing, changes stop. Nothing is
                                deleted.
                            </p>
                        </RailCard>
```

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
<LinkButton variant="line" href="/purchase">
    Buy Self Hosted
</LinkButton>
```

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 <noreply@anthropic.com>"
```

---

### 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<string, Partial<Record<Term, string>>>;
```

- [ ] **Step 2: Replace the demo buttons with the price editor**

In `adminsite/app/(staff)/staff/plans/page.tsx`, replace the `<div className="flex flex-wrap gap-2">` 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<string, Partial<Record<Term, string>>>) => 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 (
            <p className="rounded border border-rule bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-3">
                Free is not a Paddle product. Instances are created and renewed from the portal, so there is no price to configure.
            </p>
        );
    }

    // 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 (
        <div className="grid gap-2.5">
            {ENVS.map((env) => (
                <fieldset key={env} className="grid gap-1.5">
                    <legend className="font-mono text-[0.64rem] uppercase tracking-[0.1em] text-ink-3">{env}</legend>
                    {terms.map((term) => (
                        <label key={term} className="grid gap-1">
                            <span className="text-[0.82rem] text-ink-2">{term}</span>
                            <input
                                value={ids[env]?.[term] ?? ""}
                                onChange={(e) => set(env, term, e.target.value)}
                                placeholder="pri_…"
                                className="rounded border border-rule bg-panel-2 px-2.5 py-1.5 font-mono text-[0.82rem] text-ink-1 outline-none focus:border-accent"
                            />
                        </label>
                    ))}
                </fieldset>
            ))}
            <Button type="button" variant="line" onClick={() => onSave(ids)} disabled={saving}>
                {saving ? "Saving…" : "Save price IDs"}
            </Button>
        </div>
    );
}
```

And render it inside each plan `<section>`:

```tsx
<PriceEditor plan={p} saving={savePrices.isPending} onSave={(paddle_price_ids) => 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
<Queue
    title="Failed webhooks"
    tone="expired"
    count={billing.data?.count ?? 0}
    items={(billing.data?.failed ?? []).slice(0, 4).map((e) => ({
        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 <noreply@anthropic.com>"
```

---

### 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 <noreply@anthropic.com>"
```

---

### 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://<admin's public URL>/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 <date> 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.
