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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user