docs: spec 7, metered licensing and the two-by-three plan matrix

Two deployments times three tiers, servers metered per month, console and
SSO opted into individually. plans is re-keyed on (deployment, tier); every
Paddle price ID moves out of plans into a new catalogue collection; a new
entitlements collection holds desired beside granted, and a licence is only
ever signed from granted.

Spec 5's plan is revised rather than followed: it assumes one price per
subscription and a metered plan has three or more. Nothing of it has
shipped, so the revision costs a rewrite of an unstarted plan.

Free stops being cloud-only by construction, which means the plan/instance
deployment comparison in licensing.Issue no longer enforces it and
checkFreeLimit has to count per deployment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mrhid6
2026-07-26 23:16:35 +01:00
co-authored by Claude Opus 5
parent 87dc9fc858
commit ff0caf5a90
3 changed files with 522 additions and 3 deletions
@@ -8,6 +8,58 @@
**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 — DO NOT EXECUTE AS WRITTEN
[Spec 7, metered-licensing](../specs/2026-07-26-metered-licensing-design.md), was
designed on 2026-07-26, after this plan and before any of it was implemented.
`admin/internal/paddle` and `admin/internal/billing` do not exist, so nothing here
has shipped and nothing needs unpicking.
**Spec 7 must be built first, and this plan is then regenerated against it.** The
break is structural rather than cosmetic: 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.**
@@ -28,7 +80,7 @@
```
- **`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.
- **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.