# Paddle Billing Implementation Plan (regenerated against shipped spec 7)

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Supersedes** [`2026-07-26-paddle-billing.md`](2026-07-26-paddle-billing.md), which was written before spec 7 shipped and whose task bodies reference deleted shapes (`Plan.PaddlePriceIDs`, `models.ResolvePriceID`, a single price per subscription). Read that file only for history.

**Goal:** connect Paddle's subscription lifecycle to the shipped licence issuer and metered entitlement model — a customer configures a plan, checks out through Paddle, and a webhook promotes their entitlement and issues the licence, with renewals and cancellation handled as Paddle reports them.

**Architecture:** all Paddle SDK usage is isolated behind one `paddle.Client` interface in `admin/internal/paddle`, so the rest of the code depends on our types, not the SDK's. `admin/internal/billing` claims each webhook event idempotently in `paddle_events`, then dispatches. Subscription events resolve their line items back to a plan and configuration through the already-shipped `catalogue.ResolveItems`, promote the entitlement's `desired` into `granted`, and call `licensing.Issue` — the only signer. Nothing here shortens or revokes a licence.

**Tech Stack:** Go 1.26, gin, MongoDB driver v2, `github.com/PaddleHQ/paddle-go-sdk` (server) + `@paddle/paddle-js` (browser), Next.js 16, TanStack Query, Tailwind 3.

Spec: [`docs/superpowers/specs/2026-07-24-paddle-billing-design.md`](../specs/2026-07-24-paddle-billing-design.md) — but note the deviations in "What spec 7 already settled" below; the spec predates specs 6 and 7.

## Global Constraints

- **No automated Go tests.** Verify by compiler, `grep`, `curl`, and running built images against scratch databases. **Do not add `*_test.go` files.**
- **Never run `go` or `npm` on the host.** Use the container wrappers:
    ```sh
    # /tmp/gorun.sh <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 "$@"
    ```
    ```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.** **Run `go mod tidy` with `GOWORK=off`.**
- **No price ID, product ID or Paddle URL is hard-coded**, in Go or TypeScript. Price IDs live in the `catalogue` collection (shipped), keyed environment→term, edited through the staff catalogue screen (shipped). The only Paddle literals allowed are the SDK's own base URLs and the two public env vars the browser build bakes in.
- **Licences are offline-verified: nothing Paddle says revokes one early.** `subscription.canceled` and `subscription.past_due` take **no licence action**. Never write an earlier `expires_at`.
- **Self-hosted is annual only.** A resolved self-hosted price whose term is not `annual` is a configuration error and must fail the handler loudly. `catalogue` already refuses to store such a price and `ResolveItems`/`LineItems` already return `ErrTermNotSold`.
- **Every webhook is idempotent.** The event ID is claimed in `paddle_events` before processing. A duplicate of a _handled_ event returns `200` and does nothing; a retry of a _failed_ event is reprocessed.
- **`licensing.Issue` stays the only signer.** Billing calls it; billing never touches `licenses` or signs anything.
- **Admin's control-plane writes stay confined to `inject` and `cloudprov`.** This plan adds no third write path. Cloud delivery is `inject.Deliver`; self-hosted delivery is `mail.SendLicense`.
- **Customer endpoints answer 404, never 403,** for another account's resource — every instance handler goes through `ownedInstance`.
- **A licence is signed only from `granted`, never `desired`.** A webhook that confirms payment promotes `desired`→`granted` _then_ issues. Nothing else promotes.
- **Free stays outside Paddle entirely.** No £0 subscription, no Paddle customer at signup. The shipped self-serve Free flow (`POST /api/instances`, `/renew`, `/claim-free`, the lifecycle notices, the reaper) is untouched. `paddle_customer_id` is learned from the first real subscription webhook.

## What spec 7 already settled (do NOT rebuild)

- **Catalogue + price IDs.** `catalogue` collection, `models.CatalogueRow.PriceID(env, term)`, `SeedCatalogue`, the staff catalogue editor at `adminsite/app/(staff)/staff/catalogue/`. Old spec-5 task 8 is **done**.
- **Both folds.** `catalogue.LineItems(ctx, env, term, plan, cfg) ([]catalogue.Item, error)` builds subscription line items; `catalogue.ResolveItems(ctx, env, items) (catalogue.Match, error)` maps a full item list back to `{Deployment, Tier, Term, Servers, Features}`. `catalogue.Item{PriceID string; Quantity int}`. Errors: `ErrUnknownPrice`, `ErrNoBaseItem`, `ErrTermNotSold`, `ErrUnpriced`.
- **Entitlements.** `models.Entitlement{Desired, Granted models.Config, ResolvedLimits, ScheduledChangeAt, ...}`, `models.Config{Servers int; Features models.Features}`, `models.GetEntitlement`, `models.UpsertEntitlement`, `models.ErrNoEntitlement`, `Entitlement.Pending()`. `models.ReasonEntitlementChange`.
- **The configurator component** `adminsite/components/PlanConfigurator.tsx` (`PlanChoice{tier,term,servers,features}`) and the adminsite api client types `Plan`, `CatalogueRow`, `Entitlement`, `Term`, `Deployment`, plus `api.staff.entitlement/setEntitlement`.
- **Issuing from an entitlement.** `licensing.Issue` snapshots the instance's `granted` entitlement (falls back to plan base when none). `licensing.ErrFreeLimit`, `models.GracePeriod` (3 days; add yourself when passing `ExpiresAt`).
- **Signup migration.** Done in spec 6 — signup/verify live in admin, sitesvc has only contact. The spec's "Signup migration off sitesvc" section is **obsolete; skip it entirely.**
- **Two products, not three.** Free is not a Paddle product, in either deployment.

## Deferred — cannot be done in this environment

Live Paddle sandbox work is **out of this plan's automated scope**: creating the sandbox catalog, a real `PADDLE_API_KEY`/`PADDLE_WEBHOOK_SECRET`, a public webhook URL, and test-card checkout. Task 9 documents that manual pass; it is verified by the operator, not here. Everything in tasks 1–8 is written and **compiled**, and exercised by hand-crafted payloads through `curl` against a scratch database where possible.

---

## File Structure

**Created:**

| Path                                                              | Responsibility                                                                                                                   |
| ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `admin/internal/paddle/client.go`                                 | the `Client` interface, our own request/response types, and `Init`                                                               |
| `admin/internal/paddle/sdk.go`                                    | the one adapter binding `Client` to `github.com/PaddleHQ/paddle-go-sdk` — the ONLY file that imports the SDK                     |
| `admin/internal/paddle/webhook.go`                                | signature verification of a raw request body                                                                                     |
| `admin/internal/billing/events.go`                                | idempotent claim of an event ID and the dispatch switch                                                                          |
| `admin/internal/billing/subscription.go`                          | `subscription.*` → entitlement promotion + reissue, as a function of current state                                               |
| `admin/internal/billing/transaction.go`                           | renewal and payment-failure handling                                                                                             |
| `admin/internal/billing/deliver.go`                               | inject for cloud, email the blob for self-hosted, from a background context                                                      |
| `admin/internal/api/paddle.go`                                    | `POST /api/paddle/webhook`: read raw body, verify, claim, dispatch                                                               |
| `admin/internal/api/checkout.go`                                  | `GET /api/checkout/options`, `POST /api/instances/self-hosted`, `PUT /api/instances/:id/entitlement`, `POST /api/billing/portal` |
| `adminsite/lib/paddle.ts`                                         | memoised `initializePaddle`                                                                                                      |
| `adminsite/components/CheckoutButton.tsx`                         | opens the overlay with the resolved line items and `custom_data`                                                                 |
| `adminsite/app/(customer)/purchase/page.tsx` + `PurchaseForm.tsx` | self-hosted purchase: configure, checkout, then link                                                                             |

**Modified:**

| Path                                             | Change                                                                                                            |
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| `admin/go.mod`, `admin/go.sum`                   | the Paddle SDK                                                                                                    |
| `admin/internal/config/config.go`                | `PaddleEnv`, `PaddleAPIKey`, `PaddleWebhookSecret` (all required), read into `Config`                             |
| `admin/internal/models/models.go`                | `PaddleEvent`, term + subscription-status constants, `Subscription` gains `Items`, `Instance` gains `Placeholder` |
| `admin/internal/db/db.go`                        | unique index on `paddle_events.event_id`                                                                          |
| `admin/internal/mail/mail.go`                    | `SendCancelled`, `SendPastDue`, `SendLinkReminder`                                                                |
| `admin/internal/lifecycle/lifecycle.go`          | awaiting-link sweep: backstop issuance from the entitlement, plus 24h/72h reminders                               |
| `admin/internal/api/customer.go`                 | `linkInstance` claims a placeholder + issues when a subscription is already recorded                              |
| `admin/internal/api/staff.go`                    | `staffBillingHealth`                                                                                              |
| `admin/internal/api/routes.go`                   | the webhook route, three customer routes, one staff route                                                         |
| `admin/cmd/main.go`                              | `paddle.Init` before the server starts                                                                            |
| `adminsite/package.json`, `adminsite/Dockerfile` | `@paddle/paddle-js`; `NEXT_PUBLIC_PADDLE_CLIENT_TOKEN` + `NEXT_PUBLIC_PADDLE_ENV` build args                      |
| `adminsite/lib/api.ts`                           | checkout-options + self-hosted-create + entitlement + portal calls and types                                      |
| `adminsite/components/InstanceRecord.tsx`        | an upgrade/configure entry on a cloud instance                                                                    |
| `CLAUDE.md`, `docs/superpowers/specs/README.md`  | the webhook, the new collections and env vars, spec status                                                        |

---

### Task 1: The Paddle client, config, event model, and index

**Files:**

- Create: `admin/internal/paddle/client.go`, `admin/internal/paddle/sdk.go`, `admin/internal/paddle/webhook.go`
- Modify: `admin/internal/config/config.go`, `admin/internal/models/models.go`, `admin/internal/db/db.go`, `admin/cmd/main.go`, `admin/go.mod`

**Interfaces:**

- Produces:
    - `paddle.Client` interface: `UpdateSubscriptionItems(ctx, paddleSubID string, items []paddle.LineItem) error`, `PortalSession(ctx, paddleCustomerID string) (string, error)`, `Env() string`
    - `paddle.LineItem{PriceID string; Quantity int}`
    - `paddle.Init(cfg) (Client, error)` and package var access via `paddle.Get()`
    - `paddle.VerifySignature(secret string, header string, body []byte) bool`
    - `models.PaddleEvent`, `models.ClaimEvent(ctx, eventID, eventType string) (claimed bool, err error)`
    - subscription-status constants `models.SubActive`, `SubCanceled`, `SubPastDue`, `SubTrialing`; term constants `models.TermMonthly = "monthly"`, `TermAnnual = "annual"`
    - `models.Subscription.Items []models.SubItem` where `SubItem{PriceID string; Quantity int}`
    - `models.Instance.Placeholder bool`
    - `config.Config` fields `PaddleEnv`, `PaddleAPIKey`, `PaddleClientToken` (browser token is adminsite build-time, not read here), `PaddleWebhookSecret`

- [ ] **Step 1: Add config fields**

In `admin/internal/config/config.go`, add to `Config`:

```go
	PaddleEnv           string // "sandbox" or "production"
	PaddleAPIKey        string
	PaddleWebhookSecret string
```

In `Load`, read them:

```go
		PaddleEnv:           envOr("PADDLE_ENV", "sandbox"),
		PaddleAPIKey:        os.Getenv("PADDLE_API_KEY"),
		PaddleWebhookSecret: os.Getenv("PADDLE_WEBHOOK_SECRET"),
```

Add both secrets to the existing `missing` required-var check (follow the file's pattern for appending to `missing`), because an unverified webhook endpoint is one anyone can issue licences through:

```go
	if c.PaddleAPIKey == "" {
		missing = append(missing, "PADDLE_API_KEY")
	}
	if c.PaddleWebhookSecret == "" {
		missing = append(missing, "PADDLE_WEBHOOK_SECRET")
	}
```

- [ ] **Step 2: The client interface and our own types**

Create `admin/internal/paddle/client.go`:

```go
// Package paddle is the only place that talks to Paddle. Everything outside it
// depends on the Client interface and our own types, never on the SDK — so a
// change to the SDK surface is confined to sdk.go, and the billing package can
// be reasoned about without knowing Paddle exists.
package paddle

import "context"

// LineItem is one price at a quantity, the shape both a checkout and a
// subscription update are built from.
type LineItem struct {
	PriceID  string
	Quantity int
}

// Client is the narrow slice of Paddle admin needs. Checkout itself happens in
// the browser via paddle-js; the server only updates an existing subscription
// and mints a portal session.
type Client interface {
	// UpdateSubscriptionItems replaces a subscription's items, prorated
	// immediately by Paddle. This is the one outbound mutation, used when a
	// customer changes their server count or features on an existing plan.
	UpdateSubscriptionItems(ctx context.Context, paddleSubscriptionID string, items []LineItem) error
	// PortalSession returns a customer-portal URL for managing billing.
	PortalSession(ctx context.Context, paddleCustomerID string) (string, error)
	// Env is "sandbox" or "production", the same value catalogue price lookups
	// are keyed on.
	Env() string
}

var current Client

// Init constructs the client from config and stores it. Called once at boot.
func Init(apiKey, env string) (Client, error) {
	c, err := newSDKClient(apiKey, env)
	if err != nil {
		return nil, err
	}
	current = c
	return c, nil
}

// Get returns the client initialised at boot. Panics if unset, which can only
// happen if a caller runs before Init — a programming error, not a runtime one.
func Get() Client {
	if current == nil {
		panic("paddle.Get before paddle.Init")
	}
	return current
}
```

- [ ] **Step 3: The SDK adapter (the only file importing the SDK)**

Create `admin/internal/paddle/sdk.go`. **The exact SDK call shapes below must be verified against `github.com/PaddleHQ/paddle-go-sdk`'s current docs during implementation** — this file is deliberately the only place that risk lives. Structure it so the interface it satisfies never changes even if the calls do:

```go
package paddle

import (
	"context"
	"fmt"

	paddlesdk "github.com/PaddleHQ/paddle-go-sdk/v4"
)

type sdkClient struct {
	sdk *paddlesdk.SDK
	env string
}

func newSDKClient(apiKey, env string) (Client, error) {
	base := paddlesdk.SandboxBaseURL
	if env == "production" {
		base = paddlesdk.ProductionBaseURL
	}
	sdk, err := paddlesdk.New(apiKey, paddlesdk.WithBaseURL(base))
	if err != nil {
		return nil, fmt.Errorf("paddle sdk: %w", err)
	}
	return &sdkClient{sdk: sdk, env: env}, nil
}

func (c *sdkClient) Env() string { return c.env }

func (c *sdkClient) UpdateSubscriptionItems(ctx context.Context, subID string, items []LineItem) error {
	reqItems := make([]paddlesdk.UpdateSubscriptionItems, 0, len(items))
	for _, it := range items {
		reqItems = append(reqItems, paddlesdk.NewUpdateSubscriptionItemsCatalogItem(&paddlesdk.CatalogItem{
			PriceID:  it.PriceID,
			Quantity: it.Quantity,
		}))
	}
	_, err := c.sdk.UpdateSubscription(ctx, &paddlesdk.UpdateSubscriptionRequest{
		SubscriptionID:     subID,
		Items:              reqItems,
		ProrationBillingMode: ptr(paddlesdk.ProrationBillingModeProratedImmediately),
	})
	if err != nil {
		return fmt.Errorf("update subscription %s: %w", subID, err)
	}
	return nil
}

func (c *sdkClient) PortalSession(ctx context.Context, customerID string) (string, error) {
	res, err := c.sdk.CreateCustomerPortalSession(ctx, &paddlesdk.CreateCustomerPortalSessionRequest{
		CustomerID: customerID,
	})
	if err != nil {
		return "", fmt.Errorf("portal session for %s: %w", customerID, err)
	}
	return res.URLs.General.Overview, nil
}

func ptr[T any](v T) *T { return &v }
```

If a symbol above does not exist under that exact name in the installed SDK version, adjust _this file only_ until `go build` passes; the `Client` interface must not change.

- [ ] **Step 4: Webhook signature verification**

Create `admin/internal/paddle/webhook.go`. Paddle signs with an HMAC-SHA256 over `ts:body`, carried in the `Paddle-Signature` header as `ts=<unix>;h1=<hex>`:

```go
package paddle

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"strings"
)

// VerifySignature checks a raw webhook body against the Paddle-Signature header.
//
// It uses a constant-time compare and never logs the secret. A false return is
// always a 401 with nothing processed — an unverified body could be anyone
// claiming a subscription was paid for.
func VerifySignature(secret, header string, body []byte) bool {
	if secret == "" || header == "" {
		return false
	}
	var ts, h1 string
	for _, part := range strings.Split(header, ";") {
		k, v, ok := strings.Cut(part, "=")
		if !ok {
			continue
		}
		switch k {
		case "ts":
			ts = v
		case "h1":
			h1 = v
		}
	}
	if ts == "" || h1 == "" {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(ts))
	mac.Write([]byte(":"))
	mac.Write(body)
	want := hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(want), []byte(h1))
}
```

- [ ] **Step 5: The event, subscription, term and instance model changes**

In `admin/internal/models/models.go`, add constants:

```go
// Subscription statuses, mirrored from Paddle. Ours, not the SDK's, so the
// billing package does not import the SDK.
const (
	SubActive   = "active"
	SubCanceled = "canceled"
	SubPastDue  = "past_due"
	SubTrialing = "trialing"
)

// Billing terms. These match catalogue price-ID keys and license.TermsFor.
const (
	TermMonthly = "monthly"
	TermAnnual  = "annual"
)
```

Add `PaddleEvent` and `SubItem`:

```go
// PaddleEvent is the idempotency record for one webhook delivery. The unique
// index on EventID is what makes a retry a no-op rather than a second licence.
type PaddleEvent struct {
	ID          bson.ObjectID `bson:"_id,omitempty" json:"-"`
	EventID     string        `bson:"event_id" json:"event_id"`
	EventType   string        `bson:"event_type" json:"event_type"`
	ReceivedAt  time.Time     `bson:"received_at" json:"received_at"`
	ProcessedAt *time.Time    `bson:"processed_at,omitempty" json:"processed_at,omitempty"`
	Error       string        `bson:"error,omitempty" json:"error,omitempty"`
}

// SubItem is one line of a subscription: a price and its quantity, the shape
// catalogue.ResolveItems reads back into a plan and configuration.
type SubItem struct {
	PriceID  string `bson:"price_id" json:"price_id"`
	Quantity int    `bson:"quantity" json:"quantity"`
}
```

Replace the `Subscription.PaddlePriceID` single-price field with the item list (a metered subscription has several):

```go
	// Items is the full line-item list. Spec 7 made a subscription several
	// prices — a base, a per-server unit at quantity N, an item per paid
	// feature — so a single price ID can no longer describe it.
	Items []SubItem `bson:"items,omitempty" json:"items,omitempty"`
```

Add to `Instance`:

```go
	// Placeholder is true while a self-hosted instance row exists only so a
	// checkout has something to attach custom_data to, before the customer has
	// pasted their install's real UUID. Cleared by ClaimPlaceholder.
	Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
```

- [ ] **Step 6: ClaimEvent and the index**

Create `admin/internal/models/paddle_events.go`:

```go
package models

import (
	"context"
	"errors"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo"
)

// ClaimEvent records an event ID before it is processed and reports whether THIS
// call is the one that claimed it.
//
// The unique index on event_id turns a duplicate insert into a duplicate-key
// error, which is the signal that another delivery of the same event already
// owns it — so this returns (false, nil) and the caller answers 200 without
// acting. A genuine error returns (false, err).
func ClaimEvent(ctx context.Context, eventID, eventType string) (bool, error) {
	_, err := db.Admin("paddle_events").InsertOne(ctx, PaddleEvent{
		EventID:    eventID,
		EventType:  eventType,
		ReceivedAt: time.Now().UTC(),
	})
	if err == nil {
		return true, nil
	}
	if mongo.IsDuplicateKeyError(err) {
		return false, nil
	}
	return false, err
}

// MarkEventProcessed stamps success, or records the error for staff visibility.
// A failed event keeps no processed_at, so a retry re-runs it.
func MarkEventProcessed(ctx context.Context, eventID string, procErr error) error {
	set := bson.M{}
	if procErr != nil {
		set["error"] = procErr.Error()
	} else {
		now := time.Now().UTC()
		set["processed_at"] = now
		set["error"] = ""
	}
	_, err := db.Admin("paddle_events").UpdateOne(ctx,
		bson.M{"event_id": eventID}, bson.M{"$set": set})
	return err
}

// ErrEventClaimed is returned by callers that want to distinguish a benign
// duplicate from a failure.
var ErrEventClaimed = errors.New("event already claimed")
```

In `admin/internal/db/db.go`, add `paddle_events.event_id` to the `unique` slice in `EnsureIndexes`:

```go
		{"paddle_events", "event_id"},
```

- [ ] **Step 7: Init at boot**

In `admin/cmd/main.go`, after config load and before `api.Routes`, add:

```go
	if _, err := paddle.Init(cfg.PaddleAPIKey, cfg.PaddleEnv); err != nil {
		log.Fatalf("paddle init: %v", err)
	}
```

Add the import `"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"`.

- [ ] **Step 8: Add the SDK and confirm it builds**

```bash
GOWORK=off /tmp/gorun.sh admin sh -c "go get github.com/PaddleHQ/paddle-go-sdk/v4 && go mod tidy"
GOWORK=off /tmp/gorun.sh admin go build ./...
```

Expected: no output. If the SDK's major version or symbol names differ, fix `sdk.go` only.

- [ ] **Step 9: Commit**

```bash
git add admin/internal/paddle admin/internal/config admin/internal/models admin/internal/db admin/cmd/main.go admin/go.mod admin/go.sum
git commit -m "feat(admin): Paddle client behind an interface, config, and the event idempotency record"
```

---

### Task 2: The webhook endpoint — verify, claim, dispatch

**Files:**

- Create: `admin/internal/billing/events.go`, `admin/internal/api/paddle.go`
- Modify: `admin/internal/api/routes.go`

**Interfaces:**

- Consumes: `paddle.VerifySignature`, `models.ClaimEvent`, `models.MarkEventProcessed`.
- Produces:
    - `billing.Event` — the decoded envelope `{EventID, EventType string; Data json.RawMessage; OccurredAt time.Time}`
    - `billing.Dispatch(ctx context.Context, ev billing.Event) error`

- [ ] **Step 1: The envelope and dispatch switch**

Create `admin/internal/billing/events.go`:

```go
// Package billing turns verified Paddle webhooks into licence actions. It never
// verifies signatures (that is paddle.VerifySignature at the edge) and never
// signs (that is licensing.Issue); it decides what a subscription's current
// state means and calls the issuer.
package billing

import (
	"context"
	"encoding/json"
	"fmt"
	"time"
)

// Event is the decoded Paddle webhook envelope. Data is left raw so each handler
// decodes only the shape it needs.
type Event struct {
	EventID    string          `json:"event_id"`
	EventType  string          `json:"event_type"`
	OccurredAt time.Time       `json:"occurred_at"`
	Data       json.RawMessage `json:"data"`
}

// Dispatch routes one event to its handler. Unknown event types are a no-op
// success: Paddle sends many we do not care about, and 200 stops it retrying.
func Dispatch(ctx context.Context, ev Event) error {
	switch ev.EventType {
	case "subscription.created", "subscription.updated", "subscription.activated":
		return handleSubscription(ctx, ev)
	case "subscription.canceled":
		return handleCanceled(ctx, ev)
	case "subscription.past_due":
		return handlePastDue(ctx, ev)
	case "transaction.completed":
		return handleTransactionCompleted(ctx, ev)
	case "transaction.payment_failed":
		return handlePaymentFailed(ctx, ev)
	case "customer.updated":
		return handleCustomerUpdated(ctx, ev)
	default:
		return nil
	}
}

// decode is a small helper so every handler decodes Data the same way.
func decode[T any](ev Event) (T, error) {
	var v T
	if err := json.Unmarshal(ev.Data, &v); err != nil {
		return v, fmt.Errorf("decode %s: %w", ev.EventType, err)
	}
	return v, nil
}
```

(The handler functions `handleSubscription`, `handleCanceled`, `handlePastDue`, `handleTransactionCompleted`, `handlePaymentFailed`, `handleCustomerUpdated` are written in tasks 3 and 4. This task stubs them to compile — see step 2.)

- [ ] **Step 2: Temporary compiling stubs**

At the bottom of `events.go`, add stubs so this task builds independently; tasks 3–4 replace them:

```go
// Stubs replaced in tasks 3 and 4.
func handleSubscription(ctx context.Context, ev Event) error         { return nil }
func handleCanceled(ctx context.Context, ev Event) error             { return nil }
func handlePastDue(ctx context.Context, ev Event) error              { return nil }
func handleTransactionCompleted(ctx context.Context, ev Event) error { return nil }
func handlePaymentFailed(ctx context.Context, ev Event) error        { return nil }
func handleCustomerUpdated(ctx context.Context, ev Event) error      { return nil }
```

- [ ] **Step 3: The endpoint**

Create `admin/internal/api/paddle.go`:

```go
package api

import (
	"encoding/json"
	"io"
	"log"
	"net/http"

	"github.com/gin-gonic/gin"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/config"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
)

// paddleWebhook is the ingress for every Paddle event.
//
// Order is load-bearing: read the RAW body first (signature is over the exact
// bytes), verify, THEN claim the event ID, THEN dispatch. A bad signature is
// 401 and processes nothing; a duplicate of a handled event is 200 and does
// nothing; a handler error is 500 so Paddle retries, and is recorded for staff.
func paddleWebhook(cfg config.Config) gin.HandlerFunc {
	return func(c *gin.Context) {
		body, err := io.ReadAll(c.Request.Body)
		if err != nil {
			c.JSON(http.StatusBadRequest, gin.H{"error": "unreadable body"})
			return
		}
		if !paddle.VerifySignature(cfg.PaddleWebhookSecret,
			c.GetHeader("Paddle-Signature"), body) {
			log.Printf("paddle webhook: bad signature from %s", c.ClientIP())
			c.JSON(http.StatusUnauthorized, gin.H{"error": "bad signature"})
			return
		}

		var ev billing.Event
		if err := json.Unmarshal(body, &ev); err != nil || ev.EventID == "" {
			c.JSON(http.StatusBadRequest, gin.H{"error": "malformed event"})
			return
		}

		ctx := c.Request.Context()
		claimed, err := models.ClaimEvent(ctx, ev.EventID, ev.EventType)
		if err != nil {
			c.JSON(http.StatusInternalServerError, gin.H{"error": "claim failed"})
			return
		}
		if !claimed {
			// Already handled (or in flight). 200 so Paddle stops retrying.
			c.JSON(http.StatusOK, gin.H{"duplicate": true})
			return
		}

		if err := billing.Dispatch(ctx, ev); err != nil {
			log.Printf("paddle webhook: handler %s failed for %s: %v",
				ev.EventType, ev.EventID, err)
			_ = models.MarkEventProcessed(ctx, ev.EventID, err)
			c.JSON(http.StatusInternalServerError, gin.H{"error": "handler failed"})
			return
		}
		_ = models.MarkEventProcessed(ctx, ev.EventID, nil)
		c.JSON(http.StatusOK, gin.H{"ok": true})
	}
}
```

- [ ] **Step 4: Mount the route (public, unsigned bodies allowed in)**

In `admin/internal/api/routes.go`, beside the other `r.POST("/auth/...` public routes, add:

```go
	r.POST("/api/paddle/webhook", paddleWebhook(cfg))
```

It must NOT be under the `cust` (`/api`) group's session middleware — Paddle carries no session cookie; its signature is its auth.

- [ ] **Step 5: Confirm**

```bash
GOWORK=off /tmp/gorun.sh admin go build ./...
```

Expected: no output.

Against admin running on a scratch database, a bad signature is rejected and a duplicate is a no-op:

```bash
curl -s -o /dev/null -w "%{http_code}\n" -X POST localhost:8083/api/paddle/webhook \
  -H 'Paddle-Signature: ts=1;h1=deadbeef' -d '{"event_id":"evt_x","event_type":"customer.updated","data":{}}'
```

Expected: `401`.

(A correctly-signed duplicate check needs the real secret; it is exercised in task 9's sandbox pass. The idempotency LOGIC is unit-visible: `ClaimEvent` twice against the scratch DB returns `true` then `false` — confirm with a tiny throwaway `curl` once a valid signature path exists, or by inserting the same `event_id` twice with `mongosh` and watching the second collide.)

- [ ] **Step 6: Commit**

```bash
git add admin/internal/billing/events.go admin/internal/api/paddle.go admin/internal/api/routes.go
git commit -m "feat(admin): Paddle webhook ingress — verify, idempotent claim, dispatch"
```

---

### Task 3: Subscription events → entitlement promotion and reissue

**Files:**

- Modify: `admin/internal/billing/subscription.go` (create), `admin/internal/billing/events.go` (remove the three subscription stubs)
- Create: `admin/internal/billing/deliver.go`

**Interfaces:**

- Consumes: `catalogue.ResolveItems`, `catalogue.Match`, `models.GetPlan`, `models.GetEntitlement`, `models.UpsertEntitlement`, `models.Config`, `licensing.Issue`, `paddle.Get().Env()`, `inject.Deliver`, `mail.SendLicense`.
- Produces:
    - `billing.deliver(ctx, inst *models.Instance, lic *models.License)`
    - the real `handleSubscription`, `handleCanceled`, `handlePastDue`

- [ ] **Step 1: Background delivery**

Create `admin/internal/billing/deliver.go`:

```go
package billing

import (
	"context"

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

// deliver sends a freshly issued licence where it belongs. Cloud is injected;
// self-hosted is emailed the blob (their database is theirs). This mirrors the
// api-side deliver helper but takes no gin context — webhooks have none, and the
// customer is not on the other end of the request.
func deliver(ctx context.Context, inst *models.Instance, lic *models.License, to string) {
	if inst.Deployment == license.DeploymentCloud {
		inject.Deliver(ctx, lic)
		return
	}
	if to != "" && mail.Enabled() {
		_ = mail.SendLicense(to, inst.Name, lic.Blob)
	}
}
```

- [ ] **Step 2: The subscription payload shape and resolution**

Create `admin/internal/billing/subscription.go`. Paddle's `subscription.*` data carries `id`, `customer_id`, `status`, `current_billing_period.ends_at`, `custom_data` and `items[]` each with `price.id` and `quantity`:

```go
package billing

import (
	"context"
	"fmt"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
	"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"
	"go.mongodb.org/mongo-driver/v2/bson"
	"go.mongodb.org/mongo-driver/v2/mongo/options"
)

// subscriptionData is the slice of Paddle's subscription payload we read. Fields
// we ignore are simply absent — encoding/json drops them.
type subscriptionData struct {
	ID         string `json:"id"`
	CustomerID string `json:"customer_id"`
	Status     string `json:"status"`
	CustomData struct {
		AccountID  string `json:"account_id"`
		InstanceID string `json:"instance_id"`
	} `json:"custom_data"`
	CurrentBillingPeriod struct {
		EndsAt time.Time `json:"ends_at"`
	} `json:"current_billing_period"`
	Items []struct {
		Price struct {
			ID string `json:"id"`
		} `json:"price"`
		Quantity int `json:"quantity"`
	} `json:"items"`
}

func (d subscriptionData) lineItems() []catalogue.Item {
	items := make([]catalogue.Item, 0, len(d.Items))
	for _, it := range d.Items {
		items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity})
	}
	return items
}

// handleSubscription is created/updated/activated — all three are folded into
// "make the world match the subscription's CURRENT state", which is what keeps
// out-of-order delivery correct: an updated arriving before its created still
// carries the full item list, so reading all of it is reading current state.
func handleSubscription(ctx context.Context, ev Event) error {
	d, err := decode[subscriptionData](ev)
	if err != nil {
		return err
	}
	if d.CustomData.InstanceID == "" {
		return fmt.Errorf("subscription %s has no instance_id in custom_data", d.ID)
	}

	match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), d.lineItems())
	if err != nil {
		// A price we cannot map is a configuration error, not a customer error.
		// Fail loudly so it is retried and surfaced rather than guessed.
		return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err)
	}

	// Record the subscription first, as current state. This is idempotent: the
	// same event replayed writes the same row.
	sub := models.Subscription{
		AccountID:            d.CustomData.AccountID,
		InstanceID:           d.CustomData.InstanceID,
		PaddleSubscriptionID: d.ID,
		Tier:                 match.Tier,
		Term:                 match.Term,
		Status:               d.Status,
		CurrentPeriodEnd:     d.CurrentBillingPeriod.EndsAt,
		Items:                toSubItems(d.lineItems()),
	}
	if err := upsertSubscription(ctx, sub); err != nil {
		return err
	}

	// Learn the Paddle customer ID onto the account the first time we see it.
	if d.CustomerID != "" && d.CustomData.AccountID != "" {
		_, _ = db.Admin("accounts").UpdateOne(ctx,
			bson.M{"account_id": d.CustomData.AccountID, "paddle_customer_id": bson.M{"$in": bson.A{nil, ""}}},
			bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}})
	}

	// Load the instance. A self-hosted placeholder that has not been linked yet
	// gets its subscription recorded but NO licence — there is no UUID to bind
	// to. The link endpoint (task 6) issues when the customer pastes it.
	var inst models.Instance
	if err := db.Admin("admin_instances").FindOne(ctx,
		bson.M{"instance_id": d.CustomData.InstanceID}).Decode(&inst); err != nil {
		return fmt.Errorf("subscription %s names unknown instance %s: %w",
			d.ID, d.CustomData.InstanceID, err)
	}
	if inst.Placeholder {
		return nil // awaiting link; nothing to issue yet
	}

	return promoteAndIssue(ctx, &inst, match, models.ReasonEntitlementChange)
}

// promoteAndIssue promotes desired→granted from the resolved match, then signs a
// licence from granted. This is the ONLY promotion path other than the staff
// grant, and it exists because a webhook is a confirmed payment.
func promoteAndIssue(ctx context.Context, inst *models.Instance, match catalogue.Match, reason string) error {
	plan, err := models.GetPlan(ctx, inst.Deployment, match.Tier)
	if err != nil {
		return fmt.Errorf("no plan for %s/%s: %w", inst.Deployment, match.Tier, err)
	}
	granted := models.Config{
		Servers:  match.Servers,
		Features: models.Features(match.Features).OrEmpty(),
	}
	limits, _, err := catalogue.Resolve(ctx, plan, granted)
	if err != nil {
		return err
	}
	if err := models.UpsertEntitlement(ctx, models.Entitlement{
		InstanceID:     inst.InstanceID,
		AccountID:      inst.AccountID,
		Deployment:     inst.Deployment,
		Tier:           match.Tier,
		Term:           match.Term,
		Desired:        granted,
		Granted:        granted,
		ResolvedLimits: limits,
	}); err != nil {
		return err
	}

	lic, err := licensing.Issue(ctx, licensing.IssueInput{
		InstanceID: inst.InstanceID,
		Tier:       match.Tier,
		Term:       match.Term,
		Reason:     reason,
		IssuedBy:   "paddle",
	})
	if err != nil {
		return fmt.Errorf("issue for %s: %w", inst.InstanceID, err)
	}
	deliver(ctx, inst, lic, billingEmailFor(ctx, inst.AccountID))
	return nil
}

func toSubItems(items []catalogue.Item) []models.SubItem {
	out := make([]models.SubItem, 0, len(items))
	for _, it := range items {
		out = append(out, models.SubItem{PriceID: it.PriceID, Quantity: it.Quantity})
	}
	return out
}

func upsertSubscription(ctx context.Context, sub models.Subscription) error {
	_, err := db.Admin("subscriptions").UpdateOne(ctx,
		bson.M{"paddle_subscription_id": sub.PaddleSubscriptionID},
		bson.M{"$set": bson.M{
			"account_id":         sub.AccountID,
			"instance_id":        sub.InstanceID,
			"tier":               sub.Tier,
			"term":               sub.Term,
			"status":             sub.Status,
			"current_period_end": sub.CurrentPeriodEnd,
			"items":              sub.Items,
		}, "$setOnInsert": bson.M{
			"subscription_id":        newSubID(),
			"paddle_subscription_id": sub.PaddleSubscriptionID,
		}},
		options.UpdateOne().SetUpsert(true))
	return err
}

// billingEmailFor reads the account's billing email for self-hosted delivery.
func billingEmailFor(ctx context.Context, accountID string) string {
	var acc models.Account
	if err := db.Admin("accounts").FindOne(ctx,
		bson.M{"account_id": accountID}).Decode(&acc); err != nil {
		return ""
	}
	return acc.BillingEmail
}
```

Add a `newSubID` helper (uuid) if one does not already exist in the package; reuse the existing generator the codebase uses for subscription IDs if there is one — check `staff.go`/`customer.go` for how `subscription_id` is currently minted and match it.

- [ ] **Step 3: Cancel and past-due — no licence action**

Append to `subscription.go`:

```go
// handleCanceled marks the SUBSCRIPTION cancelled and takes NO licence action.
//
// The instance stays active until its licence expires, at which point the
// existing lifecycle sweep lapses it. Flipping the instance to cancelled here
// would stop inject.Reconcile and the sweep repairing a licence that is still
// valid — the opposite of "keeps working until it expires".
func handleCanceled(ctx context.Context, ev Event) error {
	d, err := decode[subscriptionData](ev)
	if err != nil {
		return err
	}
	if _, err := db.Admin("subscriptions").UpdateOne(ctx,
		bson.M{"paddle_subscription_id": d.ID},
		bson.M{"$set": bson.M{"status": models.SubCanceled}}); err != nil {
		return err
	}
	if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
		_ = sendCancelled(to, d.CustomData.InstanceID)
	}
	return nil
}

// handlePastDue flags the subscription and notifies, but leaves the licence
// alone. Dunning is Paddle's; ours is not to punish a retryable card failure.
func handlePastDue(ctx context.Context, ev Event) error {
	d, err := decode[subscriptionData](ev)
	if err != nil {
		return err
	}
	if _, err := db.Admin("subscriptions").UpdateOne(ctx,
		bson.M{"paddle_subscription_id": d.ID},
		bson.M{"$set": bson.M{"status": models.SubPastDue}}); err != nil {
		return err
	}
	if to := billingEmailFor(ctx, d.CustomData.AccountID); to != "" {
		_ = sendPastDue(to, d.CustomData.InstanceID)
	}
	return nil
}
```

`sendCancelled`/`sendPastDue` are thin wrappers over the mail functions added in task 6; declare them there. For this task to compile, add the mail functions in task 6 _first_, or add temporary local wrappers — the plan orders task 6's mail additions before this compiles cleanly, so add the three mail funcs now (they are small; see task 6 step 1) if executing strictly in order.

- [ ] **Step 4: `customer.updated`**

Append:

```go
// handleCustomerUpdated syncs the billing email onto the account.
func handleCustomerUpdated(ctx context.Context, ev Event) error {
	d, err := decode[struct {
		ID    string `json:"id"`
		Email string `json:"email"`
	}](ev)
	if err != nil {
		return err
	}
	if d.ID == "" || d.Email == "" {
		return nil
	}
	_, err = db.Admin("accounts").UpdateOne(ctx,
		bson.M{"paddle_customer_id": d.ID},
		bson.M{"$set": bson.M{"billing_email": d.Email}})
	return err
}
```

Remove the corresponding stubs from `events.go`.

- [ ] **Step 5: Confirm**

```bash
GOWORK=off /tmp/gorun.sh admin go build ./...
GOWORK=off /tmp/gorun.sh admin go vet ./...
```

Expected: no output.

`grep` the two guard rails:

```bash
grep -rn "expires_at\|ExpiresAt" admin/internal/billing/
```

Expected: no assignment of an earlier expiry — cancel/past-due touch only `status`.

```bash
grep -rn "\.Desired" admin/internal/billing/
```

Expected: no read of `Desired` — billing signs from `granted` only (`promoteAndIssue` writes both from the confirmed match).

- [ ] **Step 6: Commit**

```bash
git add admin/internal/billing
git commit -m "feat(admin): subscription webhooks promote the entitlement and reissue; cancel and past-due take no licence action"
```

---

### Task 4: Renewals and payment failures

**Files:**

- Modify: `admin/internal/billing/transaction.go` (create), `admin/internal/billing/events.go` (remove the two transaction stubs)

**Interfaces:**

- Consumes: `catalogue.ResolveItems`, `licensing.Issue` with `Reason: models.ReasonRenewal`, `models.GetEntitlement`, `models.UpsertEntitlement`.
- Produces: `handleTransactionCompleted`, `handlePaymentFailed`.

- [ ] **Step 1: Renewal**

Create `admin/internal/billing/transaction.go`. A renewal `transaction.completed` carries `subscription_id` and `origin` (`subscription_recurring` marks a renewal rather than the first charge):

```go
package billing

import (
	"context"
	"fmt"
	"time"

	"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
	"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"
	"go.mongodb.org/mongo-driver/v2/bson"
)

type transactionData struct {
	ID             string `json:"id"`
	SubscriptionID string `json:"subscription_id"`
	Origin         string `json:"origin"`
	CustomData     struct {
		AccountID  string `json:"account_id"`
		InstanceID string `json:"instance_id"`
	} `json:"custom_data"`
	Items []struct {
		Price struct {
			ID string `json:"id"`
		} `json:"price"`
		Quantity int `json:"quantity"`
	} `json:"items"`
	BillingPeriod struct {
		EndsAt time.Time `json:"ends_at"`
	} `json:"billing_period"`
}

// handleTransactionCompleted issues the next term's licence on a renewal.
//
// A renewal is the one moment a scheduled REDUCTION takes effect: the customer's
// desired (smaller) configuration becomes granted, and only now — mid-term
// reductions never shrink a live licence. On a first charge (origin not
// recurring) the subscription.created/updated handler already issued, so this is
// a no-op to avoid a double issue.
func handleTransactionCompleted(ctx context.Context, ev Event) error {
	d, err := decode[transactionData](ev)
	if err != nil {
		return err
	}
	if d.Origin != "subscription_recurring" {
		return nil
	}
	if d.SubscriptionID == "" {
		return fmt.Errorf("renewal transaction %s has no subscription_id", d.ID)
	}

	var sub models.Subscription
	if err := db.Admin("subscriptions").FindOne(ctx,
		bson.M{"paddle_subscription_id": d.SubscriptionID}).Decode(&sub); err != nil {
		return fmt.Errorf("renewal for unknown subscription %s: %w", d.SubscriptionID, err)
	}

	var inst models.Instance
	if err := db.Admin("admin_instances").FindOne(ctx,
		bson.M{"instance_id": sub.InstanceID}).Decode(&inst); err != nil {
		return fmt.Errorf("renewal names unknown instance %s: %w", sub.InstanceID, err)
	}

	// Prefer the transaction's own item list (authoritative for this period);
	// fall back to the subscription's recorded items.
	items := make([]catalogue.Item, 0, len(d.Items))
	for _, it := range d.Items {
		items = append(items, catalogue.Item{PriceID: it.Price.ID, Quantity: it.Quantity})
	}
	if len(items) == 0 {
		for _, it := range sub.Items {
			items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
		}
	}
	match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
	if err != nil {
		return fmt.Errorf("resolve renewal items for %s: %w", d.SubscriptionID, err)
	}

	// Promote a scheduled reduction: desired becomes granted, and the pending
	// marker is cleared, since a new term has begun. This is the only place a
	// licence ever gets a smaller cap.
	if err := promoteScheduledReduction(ctx, inst.InstanceID); err != nil {
		return err
	}

	// Issue the next term. Renewal resets relink_count inside licensing.Issue.
	if err := promoteAndIssue(ctx, &inst, match, models.ReasonRenewal); err != nil {
		return err
	}

	// Clear lifecycle notices so the next term starts the sequence fresh (mirrors
	// the self-serve renew path).
	_, _ = db.Admin("admin_instances").UpdateOne(ctx,
		bson.M{"instance_id": inst.InstanceID},
		bson.M{"$unset": bson.M{"notices_sent": ""}})
	return nil
}

// promoteScheduledReduction collapses a pending reduction into granted at
// renewal and clears scheduled_change_at. If there is no pending reduction it is
// a no-op — the match resolved from the renewal's items is authoritative either
// way, so this only matters for the entitlement's own bookkeeping.
func promoteScheduledReduction(ctx context.Context, instanceID string) error {
	ent, err := models.GetEntitlement(ctx, instanceID)
	if err != nil {
		return nil // no entitlement to reconcile
	}
	if ent.ScheduledChangeAt == nil {
		return nil
	}
	ent.Granted = ent.Desired
	ent.ScheduledChangeAt = nil
	return models.UpsertEntitlement(ctx, *ent)
}

// handlePaymentFailed records the failure for staff visibility. No licence
// action — the licence runs to its (already grace-padded) expiry and Paddle
// retries the charge.
func handlePaymentFailed(ctx context.Context, ev Event) error {
	d, err := decode[transactionData](ev)
	if err != nil {
		return err
	}
	if d.SubscriptionID == "" {
		return nil
	}
	_, err = db.Admin("subscriptions").UpdateOne(ctx,
		bson.M{"paddle_subscription_id": d.SubscriptionID},
		bson.M{"$set": bson.M{"status": models.SubPastDue}})
	return err
}
```

Remove the two transaction stubs from `events.go`.

- [ ] **Step 2: Confirm**

```bash
GOWORK=off /tmp/gorun.sh admin go build ./...
```

Expected: no output.

```bash
grep -rn "ReasonRenewal" admin/internal/billing/transaction.go
```

Expected: one hit — the renewal issue. A renewal that used `ReasonEntitlementChange` would not reset `relink_count`.

- [ ] **Step 3: Commit**

```bash
git add admin/internal/billing/transaction.go admin/internal/billing/events.go
git commit -m "feat(admin): renewals issue the next term and collapse a scheduled reduction; payment-failed flags only"
```

---

### Task 5: Checkout options, self-hosted placeholder, entitlement update, portal

**Files:**

- Create: `admin/internal/api/checkout.go`
- Modify: `admin/internal/api/routes.go`

**Interfaces:**

- Consumes: `models.AllCatalogue`/`CatalogueFor`, `models.GetPlan`, `catalogue.LineItems`, `paddle.Get()`, `ownedInstance`, `auth.Current`, `shared/provision` slug rules (reuse whatever `createInstance` uses to mint an instance id), `models.UpsertEntitlement`, `models.GetEntitlement`.
- Produces:
    - `GET /api/checkout/options` → `{plans: []Plan, catalogue: []CatalogueRow, env: string, client_token_note}` (client token is baked into the browser build, not served)
    - `POST /api/instances/self-hosted` → creates a placeholder instance, returns `{instance_id}`
    - `PUT /api/instances/:id/entitlement` → sets `desired`, computes line items, calls `paddle.UpdateSubscriptionItems`, returns the entitlement
    - `POST /api/billing/portal` → `{url}`

- [ ] **Step 1: Checkout options**

Create `admin/internal/api/checkout.go`:

```go
package api

import (
	"net/http"

	"github.com/gin-gonic/gin"
	"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"
	"go.mongodb.org/mongo-driver/v2/bson"
)

// checkoutOptions serves everything the browser configurator needs to price a
// plan: the six plans (base allowances), the full catalogue (component prices in
// the running environment), and the environment name so the client can refuse a
// mismatch. The client token itself is baked into the adminsite build, never
// served from here.
func checkoutOptions(c *gin.Context) {
	ctx := c.Request.Context()
	plans := []models.Plan{}
	if cur, err := db.Admin("plans").Find(ctx, bson.M{"active": true}); err == nil {
		_ = cur.All(ctx, &plans)
	}
	rows, err := models.AllCatalogue(ctx)
	if err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	c.JSON(http.StatusOK, gin.H{
		"plans":     plans,
		"catalogue": rows,
		"env":       paddle.Get().Env(),
	})
}
```

- [ ] **Step 2: Self-hosted placeholder creation**

Append. Reuse the instance-id minting the shipped `linkInstance`/`createInstance` use — **read `customer.go` and match it exactly** rather than inventing a UUID scheme:

```go
// createSelfHostedPlaceholder makes an instance row that exists only so a
// checkout has something to put in custom_data. It carries no licence and is
// flagged Placeholder until the customer pastes their install's real UUID
// (linkInstance, task 6). Status awaiting_link, deployment self_hosted.
func createSelfHostedPlaceholder(c *gin.Context) {
	s := auth.Current(c)
	var body struct {
		Name string `json:"name"`
	}
	if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" {
		c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
		return
	}
	ctx := c.Request.Context()
	inst := models.Instance{
		InstanceID:  newPlaceholderID(), // match the id scheme used elsewhere
		AccountID:   s.AccountID,
		Name:        body.Name,
		Deployment:  license.DeploymentSelfHosted,
		Status:      models.StatusAwaitingLink,
		Placeholder: true,
		CreatedAt:   time.Now().UTC(),
	}
	if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	audit.Write(ctx, models.AuditEntry{
		Actor: s.Email, Action: "instance.placeholder_created", AccountID: s.AccountID,
		Target: inst.InstanceID, IP: c.ClientIP()})
	c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
}
```

Define `newPlaceholderID()` to match the codebase's existing instance-id generator (find it in `customer.go`/`cloudprov`). Add imports `time`, `license`, `auth`, `audit`, `models`, `db`.

- [ ] **Step 3: Entitlement update — admin's one outbound Paddle call**

Append. This is the only place admin mutates a live subscription:

```go
// updateEntitlement sets an instance's DESIRED configuration and pushes the
// resulting line items to Paddle. It does NOT issue — the resulting
// subscription.updated webhook does, from granted. An increase is prorated
// immediately by Paddle; a reduction is recorded as desired and takes effect at
// renewal (promoteScheduledReduction), so this never shrinks a live licence.
func updateEntitlement(c *gin.Context) {
	inst, ok := ownedInstance(c, c.Param("id"))
	if !ok {
		return
	}
	ctx := c.Request.Context()
	var body struct {
		Tier     string   `json:"tier"`
		Term     string   `json:"term"`
		Servers  int      `json:"servers"`
		Features []string `json:"features"`
	}
	if err := c.ShouldBindJSON(&body); err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "invalid configuration"})
		return
	}

	plan, err := models.GetPlan(ctx, inst.Deployment, body.Tier)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": "no such plan"})
		return
	}
	if body.Servers < plan.BaseLimits.MaxServers && plan.BaseLimits.MaxServers != license.Unlimited {
		c.JSON(http.StatusBadRequest, gin.H{
			"error": fmt.Sprintf("%s includes %d servers", plan.Name, plan.BaseLimits.MaxServers)})
		return
	}

	desired := models.Config{Servers: body.Servers, Features: models.Features(body.Features).OrEmpty()}
	items, err := catalogue.LineItems(ctx, paddle.Get().Env(), body.Term, plan, desired)
	if err != nil {
		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
		return
	}

	// Find the live subscription to update. No subscription yet means this
	// instance has never been paid for — that is a checkout, not an update.
	var sub models.Subscription
	if err := db.Admin("subscriptions").FindOne(ctx,
		bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}).Decode(&sub); err != nil {
		c.JSON(http.StatusConflict, gin.H{"error": "no active subscription; start a checkout instead"})
		return
	}

	pItems := make([]paddle.LineItem, 0, len(items))
	for _, it := range items {
		pItems = append(pItems, paddle.LineItem{PriceID: it.PriceID, Quantity: it.Quantity})
	}
	if err := paddle.Get().UpdateSubscriptionItems(ctx, sub.PaddleSubscriptionID, pItems); err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": "billing update failed; nothing changed"})
		return
	}

	// Record desired now; the webhook that Paddle sends back promotes to granted
	// and reissues. Recording here makes the portal reflect the intent instantly
	// rather than waiting on the webhook round-trip.
	limits, _, _ := catalogue.Resolve(ctx, plan, desired)
	ent, _ := models.GetEntitlement(ctx, inst.InstanceID)
	next := models.Entitlement{
		InstanceID: inst.InstanceID, AccountID: inst.AccountID,
		Deployment: inst.Deployment, Tier: body.Tier, Term: body.Term,
		Desired: desired, ResolvedLimits: limits,
	}
	if ent != nil {
		next.Granted = ent.Granted
		next.GrantedAt = ent.GrantedAt
		if desired.Servers < ent.Granted.Servers {
			now := time.Now().UTC()
			next.ScheduledChangeAt = &now
		}
	} else {
		next.Granted = desired
	}
	if err := models.UpsertEntitlement(ctx, next); err != nil {
		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
		return
	}
	audit.Write(ctx, models.AuditEntry{
		Actor: auth.Current(c).Email, Action: "entitlement.requested",
		AccountID: inst.AccountID, Target: inst.InstanceID})
	c.JSON(http.StatusOK, gin.H{"entitlement": next, "pending": next.Pending()})
}
```

- [ ] **Step 4: Billing portal**

Append:

```go
// billingPortal mints a Paddle customer-portal URL. The account must already
// have a paddle_customer_id, which it learns from its first subscription webhook.
func billingPortal(c *gin.Context) {
	s := auth.Current(c)
	ctx := c.Request.Context()
	var acc models.Account
	if err := db.Admin("accounts").FindOne(ctx,
		bson.M{"account_id": s.AccountID}).Decode(&acc); err != nil {
		c.JSON(http.StatusNotFound, gin.H{"error": "no account"})
		return
	}
	if acc.PaddleCustomerID == "" {
		c.JSON(http.StatusConflict, gin.H{"error": "no billing account yet; buy a paid plan first"})
		return
	}
	url, err := paddle.Get().PortalSession(ctx, acc.PaddleCustomerID)
	if err != nil {
		c.JSON(http.StatusBadGateway, gin.H{"error": "could not open billing portal"})
		return
	}
	c.JSON(http.StatusOK, gin.H{"url": url})
}
```

- [ ] **Step 5: Routes**

In `routes.go`, in the `cust` group:

```go
		cust.GET("/checkout/options", checkoutOptions)
		cust.POST("/instances/self-hosted",
			auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
			createSelfHostedPlaceholder)
		cust.PUT("/instances/:id/entitlement",
			auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
			updateEntitlement)
		cust.POST("/billing/portal", billingPortal)
```

- [ ] **Step 6: Confirm**

```bash
GOWORK=off /tmp/gorun.sh admin go build ./...
```

Expected: no output.

Against a scratch DB with a staff session, `GET /api/checkout/options` returns six plans and sixteen catalogue rows; `POST /api/instances/self-hosted` returns an `instance_id` and leaves a `placeholder:true` row:

```bash
curl -s -b /tmp/cj localhost:8083/api/checkout/options | python -m json.tool | head
curl -s -b /tmp/cj -X POST localhost:8083/api/instances/self-hosted \
  -H 'Content-Type: application/json' -d '{"name":"box"}' | python -m json.tool
```

The Paddle-touching paths (`updateEntitlement`, `billingPortal`) are exercised in task 9's sandbox pass.

- [ ] **Step 7: Commit**

```bash
git add admin/internal/api/checkout.go admin/internal/api/routes.go
git commit -m "feat(admin): checkout options, self-hosted placeholder, entitlement update, and portal endpoints"
```

---

### Task 6: The awaiting-link sweep, reminders, and placeholder claim

**Files:**

- Modify: `admin/internal/mail/mail.go`, `admin/internal/lifecycle/lifecycle.go`, `admin/internal/api/customer.go`, `admin/internal/api/staff.go`, `admin/internal/api/routes.go`, `admin/internal/billing/subscription.go` (wire `sendCancelled`/`sendPastDue`)

**Interfaces:**

- Produces: `mail.SendCancelled`, `mail.SendPastDue`, `mail.SendLinkReminder`; `staffBillingHealth`; `linkInstance` claiming a placeholder and issuing when a subscription exists.

- [ ] **Step 1: Mail functions**

In `admin/internal/mail/mail.go`, following the existing `Send*` style (subject + body, `send(to, ...)`):

```go
func SendCancelled(to, instanceName string) error {
	return send(to, "Your Vantage subscription is cancelled",
		"Your subscription for "+instanceName+" is cancelled. Your instance keeps "+
			"working until the current licence expires; after that, monitors keep "+
			"running but changes are disabled.")
}

func SendPastDue(to, instanceName string) error {
	return send(to, "Payment failed for your Vantage subscription",
		"A payment for "+instanceName+" failed. Your instance is unaffected while "+
			"the card is retried. Update your payment method from the billing portal.")
}

func SendLinkReminder(to, instanceName string) error {
	return send(to, "Finish setting up your Vantage instance",
		"Your subscription for "+instanceName+" is active, but the instance is not "+
			"linked yet. Paste your install's ID in the portal to receive your licence.")
}
```

Wire the billing wrappers in `subscription.go`:

```go
func sendCancelled(to, instanceName string) error { return mail.SendCancelled(to, instanceName) }
func sendPastDue(to, instanceName string) error   { return mail.SendPastDue(to, instanceName) }
```

(Import `mail` in `subscription.go`. `instanceName` — pass the instance name, not the id; adjust the call sites in task 3 to look it up, or pass the id if the name is not to hand. Prefer the name.)

- [ ] **Step 2: The awaiting-link sweep and reminders**

In `admin/internal/lifecycle/lifecycle.go`, add a pass that finds placeholder/awaiting-link instances with a recorded active subscription and (a) backstops issuance if the webhook never linked, (b) sends 24h/72h reminders. Follow the existing sweep's shape and its `notices_sent` bookkeeping. Reminder keys `link_24` / `link_72`:

```go
// sweepAwaitingLink chases self-hosted instances that were paid for but never
// linked. The subscription exists; the instance is still a placeholder. At 24h
// and 72h it emails a reminder; the staff dashboard already flags 48h.
func sweepAwaitingLink(ctx context.Context) {
	cur, err := db.Admin("admin_instances").Find(ctx, bson.M{
		"deployment":  license.DeploymentSelfHosted,
		"placeholder": true,
		"status":      models.StatusAwaitingLink,
	})
	if err != nil {
		return
	}
	var instances []models.Instance
	if err := cur.All(ctx, &instances); err != nil {
		return
	}
	now := time.Now().UTC()
	for _, inst := range instances {
		age := now.Sub(inst.CreatedAt)
		to := billingEmailForAccount(ctx, inst.AccountID)
		if to == "" {
			continue
		}
		if age > 72*time.Hour && !slices.Contains(inst.NoticesSent, "link_72") {
			_ = mail.SendLinkReminder(to, inst.Name)
			markNotice(ctx, inst.InstanceID, "link_72")
		} else if age > 24*time.Hour && !slices.Contains(inst.NoticesSent, "link_24") {
			_ = mail.SendLinkReminder(to, inst.Name)
			markNotice(ctx, inst.InstanceID, "link_24")
		}
	}
}
```

Reuse or add `billingEmailForAccount` and `markNotice` helpers matching the file's existing patterns (the Free lifecycle sweep already reads the account email and records `notices_sent`; factor to one helper if duplicated). Call `sweepAwaitingLink` from the same ticker `lifecycle.Run` uses.

- [ ] **Step 3: linkInstance claims a placeholder and issues**

In `admin/internal/api/customer.go`'s `linkInstance`, after the placeholder's UUID is validated, if the instance being linked is a placeholder with a recorded active subscription, set the real `instance_id`, clear `placeholder`, flip to active, and issue from the subscription's resolved items. **Read the current `linkInstance` first and splice in** — it already creates/links; the addition is: when a subscription row exists for this placeholder, resolve its items and call the same `promoteAndIssue` path (expose a small exported `billing.IssueForInstance(ctx, instanceID) error` that loads the sub, resolves, promotes and issues, so the endpoint does not import catalogue directly).

Add to `admin/internal/billing/subscription.go`:

```go
// IssueForInstance issues from an instance's recorded subscription. Called when
// a self-hosted customer finally links a placeholder they have already paid for.
func IssueForInstance(ctx context.Context, instanceID string) error {
	var sub models.Subscription
	if err := db.Admin("subscriptions").FindOne(ctx,
		bson.M{"instance_id": instanceID, "status": models.SubActive}).Decode(&sub); err != nil {
		return fmt.Errorf("no active subscription for %s: %w", instanceID, err)
	}
	var inst models.Instance
	if err := db.Admin("admin_instances").FindOne(ctx,
		bson.M{"instance_id": instanceID}).Decode(&inst); err != nil {
		return err
	}
	items := make([]catalogue.Item, 0, len(sub.Items))
	for _, it := range sub.Items {
		items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
	}
	match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
	if err != nil {
		return err
	}
	return promoteAndIssue(ctx, &inst, match, models.ReasonNew)
}
```

- [ ] **Step 4: Staff billing health**

In `admin/internal/api/staff.go`, add `staffBillingHealth` returning failed `paddle_events` (unprocessed with an error) and placeholder instances older than 48h, for the staff dashboard. Mount `staff.GET("/health/billing", staffBillingHealth)` in `routes.go`.

```go
func staffBillingHealth(c *gin.Context) {
	ctx := c.Request.Context()
	failed := []models.PaddleEvent{}
	if cur, err := db.Admin("paddle_events").Find(ctx,
		bson.M{"processed_at": bson.M{"$exists": false}, "error": bson.M{"$ne": ""}}); err == nil {
		_ = cur.All(ctx, &failed)
	}
	c.JSON(http.StatusOK, gin.H{"failed_events": failed, "count": len(failed)})
}
```

- [ ] **Step 5: Confirm**

```bash
GOWORK=off /tmp/gorun.sh admin go build ./...
GOWORK=off /tmp/gorun.sh admin go vet ./...
```

Expected: no output.

- [ ] **Step 6: Commit**

```bash
git add admin/internal/mail admin/internal/lifecycle admin/internal/api admin/internal/billing
git commit -m "feat(admin): awaiting-link reminders, placeholder claim-and-issue, and billing health"
```

---

### Task 7: The customer purchase and upgrade UI

**Files:**

- Create: `adminsite/lib/paddle.ts`, `adminsite/components/CheckoutButton.tsx`, `adminsite/app/(customer)/purchase/page.tsx`, `adminsite/app/(customer)/purchase/PurchaseForm.tsx`
- Modify: `adminsite/package.json`, `adminsite/lib/api.ts`, `adminsite/components/InstanceRecord.tsx`

**Interfaces:**

- Consumes: shipped `PlanConfigurator`/`PlanChoice`, `api.staff.plans` shape (reuse types), new `api.checkoutOptions`, `api.createSelfHosted`, `api.updateEntitlement`, `api.billingPortal`.
- Produces: `initPaddle()`, `<CheckoutButton items custom_data />`.

- [ ] **Step 1: Add the dependency and the loader**

```bash
sh /tmp/npmrun.sh adminsite npm install @paddle/paddle-js --no-audit --no-fund
```

Create `adminsite/lib/paddle.ts`:

```ts
import { initializePaddle, type Paddle } from "@paddle/paddle-js";

let cached: Promise<Paddle | undefined> | null = null;

/* One Paddle instance for the app. The token and environment are baked into the
 * build (NEXT_PUBLIC_*), never fetched, so a production build can never load a
 * sandbox token by accident. */
export function initPaddle(): Promise<Paddle | undefined> {
    if (!cached) {
        cached = initializePaddle({
            environment: (process.env.NEXT_PUBLIC_PADDLE_ENV as "sandbox" | "production") ?? "sandbox",
            token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN ?? "",
        });
    }
    return cached;
}
```

- [ ] **Step 2: The checkout button**

Create `adminsite/components/CheckoutButton.tsx`:

```tsx
"use client";

import { useState } from "react";
import { initPaddle } from "@/lib/paddle";

/* Opens the Paddle overlay with the resolved line items and custom_data. The
 * items come from the configurator via catalogue pricing; custom_data is what
 * lets the webhook route without a lookup table. */
export function CheckoutButton({
    items,
    customData,
    disabled,
    label = "Continue to payment",
}: {
    items: { priceId: string; quantity: number }[];
    customData: { account_id: string; instance_id: string };
    disabled?: boolean;
    label?: string;
}) {
    const [busy, setBusy] = useState(false);
    async function open() {
        setBusy(true);
        const paddle = await initPaddle();
        setBusy(false);
        paddle?.Checkout.open({
            items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })),
            customData,
        });
    }
    return (
        <button type="button" disabled={disabled || busy || items.length === 0} onClick={open} className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40">
            {busy ? "Opening…" : label}
        </button>
    );
}
```

- [ ] **Step 3: API client additions**

In `adminsite/lib/api.ts`, add types and calls (reuse shipped `Plan`, `CatalogueRow`, `Entitlement`, `PlanChoice`-equivalent). Add a client-side line-item builder mirroring `catalogue.LineItems` so the button has items without a round-trip, OR add a `GET`-backed resolver — prefer computing client-side from the catalogue already fetched:

```ts
export interface CheckoutOptions {
    plans: Plan[];
    catalogue: CatalogueRow[];
    env: "sandbox" | "production";
}

// on `api`:
    checkoutOptions: () => req<CheckoutOptions>("/api/checkout/options"),
    createSelfHosted: (name: string) =>
        post<{ instance_id: string }>("/api/instances/self-hosted", { name }),
    updateEntitlement: (
        id: string,
        body: { tier: Tier; term: Term; servers: number; features: string[] },
    ) => put<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`, body),
    billingPortal: () => post<{ url: string }>("/api/billing/portal"),
```

Add a helper that turns a `PlanChoice` + catalogue into `{priceId, quantity}[]` for the running env, replicating `catalogue.LineItems`' base-included subtraction (base qty 1; per-server qty = servers − base; feature items only when the row has a price). Keep the subtraction in exactly one TS function, commented to point at the Go `billable`.

- [ ] **Step 4: The self-hosted purchase page**

Create `adminsite/app/(customer)/purchase/page.tsx` + `PurchaseForm.tsx`: name field → `createSelfHosted` → mount `PlanConfigurator` (deployment `self_hosted`) → `CheckoutButton` with computed items and `custom_data {account_id, instance_id}`. After checkout, instruct the customer to paste their install UUID on the instance page (the existing link flow, now claim-and-issue). Match the shell (`PageHeader`/`PageFrame`) of the other customer pages.

- [ ] **Step 5: Cloud upgrade entry on InstanceRecord**

In `adminsite/components/InstanceRecord.tsx`, for a cloud instance add a "Change plan" control that opens `PlanConfigurator` and either `updateEntitlement` (when an active subscription exists) or `CheckoutButton` (first purchase). Show a pending-reduction line when `entitlement.scheduled_change_at` is set, with the date. A "Manage billing" button calls `billingPortal` and opens the returned URL. Keep the cancellation wording panel here (there is no cancellation screen of ours).

- [ ] **Step 6: Dockerfile build args**

In `adminsite/Dockerfile`, add build args and env for `NEXT_PUBLIC_PADDLE_CLIENT_TOKEN` and `NEXT_PUBLIC_PADDLE_ENV`, matching how `NEXT_PUBLIC_ADMIN_API_URL` is already threaded.

- [ ] **Step 7: Confirm build and no hex**

```bash
sh /tmp/npmrun.sh adminsite npm run build
grep -rn "#[0-9a-fA-F]\{3,6\}" adminsite/components/CheckoutButton.tsx adminsite/app/\(customer\)/purchase/
grep -rn "pri_\|sandbox\|production" adminsite/ --include=*.tsx --include=*.ts | grep -v "NEXT_PUBLIC\|process.env\|\"sandbox\"\|\"production\"" | grep -v "pri_…"
```

Expected: successful build; no hex; no hard-coded price IDs.

- [ ] **Step 8: Commit**

```bash
git add adminsite
git commit -m "feat(adminsite): self-hosted purchase, cloud upgrade, checkout overlay and billing portal"
```

---

### Task 8: Deployment configuration and docs

**Files:**

- Modify: `deploy/docker-compose.site.yml`, `CLAUDE.md`, `docs/superpowers/specs/README.md`

- [ ] **Step 1: Compose env**

In `deploy/docker-compose.site.yml`, add to the `admin` service: `PADDLE_ENV`, `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET`. Add the two `NEXT_PUBLIC_PADDLE_*` build args to the `adminsite` service build. Note in a comment that `PADDLE_WEBHOOK_SECRET` is boot-required.

- [ ] **Step 2: CLAUDE.md**

Add `paddle_events` to admin's collection list. Add `POST /api/paddle/webhook` (unauthenticated, signature-verified) to the admin route table, and the four customer routes + `GET /api/staff/health/billing`. Add a short "Billing (Paddle)" paragraph: Free is outside Paddle; paid plans check out in the browser; the webhook is the only issuer path and is idempotent via `paddle_events`; cancel/past-due never touch a licence; the entitlement is promoted `desired`→`granted` only by a confirmed webhook. Add the three `PADDLE_*` vars and the two `NEXT_PUBLIC_PADDLE_*` build args to the env tables.

- [ ] **Step 3: Spec index**

Mark spec 5 shipped (code) in `docs/superpowers/specs/README.md`, noting sandbox verification is the operator's step.

- [ ] **Step 4: Commit**

```bash
git add deploy/docker-compose.site.yml CLAUDE.md docs/superpowers/specs/README.md
git commit -m "docs+deploy: Paddle env, webhook route, collections, and spec status"
```

---

### Task 9: Sandbox catalog and the end-to-end pass (operator-run, deferred)

This task **cannot be automated here** — it needs a real Paddle sandbox, an API key, a webhook secret, and a public webhook URL. It is the operator's, documented so it can be followed exactly.

- [ ] **Step 1: Create the sandbox catalog.** Eight products — a base and an additional-server product for each of the four paid plans — giving twelve prices (cloud monthly+annual, self-hosted annual only), plus feature prices where a feature is to be charged. Free is not a product.
- [ ] **Step 2: Paste the sandbox price IDs** into the staff catalogue screen (shipped). Confirm a self-hosted monthly cell is absent and refused.
- [ ] **Step 3: Set `PADDLE_ENV=sandbox`, `PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET`** on admin; register the webhook URL in Paddle pointing at `/api/paddle/webhook`.
- [ ] **Step 4: Cloud purchase** with a test card. Confirm `subscription.created`/`transaction.completed` issue a licence, the instance reports `valid`, and `max_servers`/features match the configuration.
- [ ] **Step 5: Server increase** on an active subscription via the portal → `updateEntitlement` → confirm the reissued licence grows.
- [ ] **Step 6: Scheduled reduction** → confirm `granted` unchanged until a triggered sandbox renewal, which then collapses it. Confirm expiry is period-end + 3 days.
- [ ] **Step 7: Self-hosted purchase** → placeholder `awaiting_link`, no licence → link the UUID → licence issued and emailed.
- [ ] **Step 8: Cancel** → licence keeps working to expiry, then the instance degrades (monitors still running).
- [ ] **Step 9: Replay every event** from Paddle's dashboard → no duplicate licences (idempotency).
- [ ] **Step 10: Bad signature** → 401, nothing processed.

---

## Done when

- The webhook verifies signatures, is idempotent via `paddle_events`, and dispatches every documented event type.
- A confirmed subscription webhook promotes `desired`→`granted` and issues from `granted`; `grep` finds no `.Desired` read and no earlier-expiry write in `admin/internal/billing/`.
- Cancel and past-due change only `status`; the licence is untouched.
- A renewal issues the next term with `ReasonRenewal` and collapses any scheduled reduction.
- `GET /api/checkout/options` serves six plans and the catalogue; the self-hosted placeholder creates an `awaiting_link` row with no licence.
- All Paddle SDK usage lives only in `admin/internal/paddle/sdk.go`.
- `admin`, `adminsite` build clean; adminsite carries no hex and no hard-coded price IDs.
- The sandbox end-to-end pass (task 9) is the operator's and is not claimed complete by this plan.

## Not in this plan

Discounts, coupons, our own proration arithmetic, invoice/tax display, and anything that revokes or shortens a licence. Metered pricing and self-service downgrade ARE in scope (spec 7 put them there) and are implemented above.
