From c4e6ad54850339b66369a201c683527f4846f10a Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 27 Jul 2026 09:37:39 +0100 Subject: [PATCH] =?UTF-8?q?feat(license):=20metered=20licensing=20?= =?UTF-8?q?=E2=80=94=20catalogue,=20entitlements,=20and=20enforcement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements spec 7 tasks 2-10 on top of the six-plan payload from task 1. Admin: plans re-keyed on (deployment, tier); new catalogue collection holds every Paddle price ID (one row per priceable component); new entitlements collection holds desired beside granted. admin/internal/catalogue owns both folds — entitlement to licence limits, and entitlement to Paddle line items — so the base allowance is subtracted in exactly one place. licensing.Issue now snapshots the instance's granted entitlement, never desired. Free is enforced per account AND deployment. Staff endpoints for plans, catalogue and entitlements; Free self-hosted can be claimed and renewed on its annual term; the reaper stays cloud-only. Server: enforces the monitor cap, audit-log retention (daily sweep, skips Unlimited and lapsed instances), and gates the OIDC callback. Unset limits are filled from the seed plan at the single decode site so old blobs never read as zero. Frontends: adminsite gains a catalogue price-ID editor, six-plan allowance screen, and a catalogue-driven PlanConfigurator mounted on the staff instance page. web shows monitors, audit retention and support level on the licence page. Docs: CLAUDE.md, spec index and plan 5 preamble updated. Co-Authored-By: Claude Opus 5 --- admin/cmd/main.go | 4 + admin/internal/api/customer.go | 103 +++++++- admin/internal/api/entitlement.go | 187 ++++++++++++++ admin/internal/api/routes.go | 14 +- admin/internal/api/staff.go | 122 ++++++++- admin/internal/catalogue/catalogue.go | 120 +++++++++ admin/internal/catalogue/items.go | 206 ++++++++++++++++ admin/internal/db/db.go | 30 ++- admin/internal/licensing/issue.go | 58 ++++- admin/internal/models/backfill.go | 147 +++++++++++ admin/internal/models/catalogue.go | 140 +++++++++++ admin/internal/models/entitlements.go | 139 +++++++++++ admin/internal/models/models.go | 34 ++- admin/internal/models/plans.go | 58 +++-- .../app/(staff)/staff/catalogue/page.tsx | 184 ++++++++++++++ .../staff/instances/[id]/IssuePanel.tsx | 6 +- .../app/(staff)/staff/instances/[id]/page.tsx | 115 ++++++++- adminsite/app/(staff)/staff/layout.tsx | 1 + adminsite/app/(staff)/staff/licenses/page.tsx | 3 +- adminsite/app/(staff)/staff/plans/page.tsx | 232 +++++++++++------- adminsite/components/ConfirmPlanChange.tsx | 41 ++-- adminsite/components/PlanConfigurator.tsx | 173 +++++++++++++ adminsite/lib/api.ts | 67 ++++- claude.md | 14 +- .../plans/2026-07-26-paddle-billing.md | 19 +- docs/superpowers/specs/README.md | 2 +- server/cmd/main.go | 1 + server/internal/api/licence.go | 25 +- server/internal/api/monitors.go | 7 + server/internal/auth/oidc.go | 14 ++ server/internal/services/audit_retention.go | 78 ++++++ server/internal/services/licence.go | 35 ++- server/internal/services/licence_limits.go | 26 +- web/app/(app)/settings/license/page.tsx | 42 ++++ web/lib/api.ts | 16 +- 35 files changed, 2241 insertions(+), 222 deletions(-) create mode 100644 admin/internal/api/entitlement.go create mode 100644 admin/internal/catalogue/catalogue.go create mode 100644 admin/internal/catalogue/items.go create mode 100644 admin/internal/models/catalogue.go create mode 100644 admin/internal/models/entitlements.go create mode 100644 adminsite/app/(staff)/staff/catalogue/page.tsx create mode 100644 adminsite/components/PlanConfigurator.tsx create mode 100644 server/internal/services/audit_retention.go diff --git a/admin/cmd/main.go b/admin/cmd/main.go index e2b1ffc..2eb80e7 100644 --- a/admin/cmd/main.go +++ b/admin/cmd/main.go @@ -68,6 +68,10 @@ func main() { idxCancel() log.Fatalf("plan seed: %v", err) } + if err := models.SeedCatalogue(idxCtx); err != nil { + idxCancel() + log.Fatalf("seed catalogue: %v", err) + } if err := models.Backfill(idxCtx); err != nil { idxCancel() log.Fatalf("backfill: %v", err) diff --git a/admin/internal/api/customer.go b/admin/internal/api/customer.go index 2daa622..ca3f211 100644 --- a/admin/internal/api/customer.go +++ b/admin/internal/api/customer.go @@ -221,8 +221,13 @@ func createInstance(c *gin.Context) { // Pre-check the Free rule so we never create an instance we then cannot // licence. licensing.Issue enforces it too; this is the friendly refusal. + // + // Scoped to cloud because that is what this endpoint creates. It MUST match + // checkFreeLimit's scoping — a pre-check stricter than the issuer refuses + // something that would have worked. n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{ "account_id": s.AccountID, + "deployment": license.DeploymentCloud, "tier": license.TierFree, "status": bson.M{"$ne": models.StatusCancelled}, }) @@ -232,7 +237,7 @@ func createInstance(c *gin.Context) { } if n > 0 { c.JSON(http.StatusConflict, gin.H{ - "error": "this account already has a Free instance"}) + "error": "this account already has a Free cloud instance"}) return } @@ -364,10 +369,16 @@ func renewInstance(c *gin.Context) { return } + // Free renews on its deployment's only term: monthly for cloud, annual for + // self-hosted. Reading it from TermsFor rather than hardcoding is what stops + // a self-hosted instance being handed a one-month licence. + terms := license.TermsFor(inst.Deployment) + term := terms[len(terms)-1] + lic, err := licensing.Issue(ctx, licensing.IssueInput{ InstanceID: inst.InstanceID, Tier: license.TierFree, - Term: "monthly", + Term: term, Reason: models.ReasonRenewal, IssuedBy: "self-serve", }) @@ -375,7 +386,9 @@ func renewInstance(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - inject.Deliver(ctx, lic) + // Cloud is injected; self-hosted is delivered to the customer, because their + // database is theirs and we cannot write to it. + deliver(c, inst, lic) // Clear the notice log so the next term starts the sequence again. Issue has // already set status back to active. @@ -414,6 +427,90 @@ var appLoginURL string // SetAppLoginURL is called from main. func SetAppLoginURL(v string) { appLoginURL = v } +// claimFree issues a Free licence on a linked self-hosted instance. +// +// The link step creates the row; this gives it a licence. They are separate +// because linking is about identity — proving which install is yours — and +// claiming is about entitlement, and a customer who links an install and then +// changes their mind should not have consumed their one Free allowance. +// +// Free is outside Paddle entirely, so there is no checkout, no subscription and +// nothing to reconcile. licensing.Issue's own checkFreeLimit is the real guard; +// the count here exists to refuse politely before anything is written. +func claimFree(c *gin.Context) { + inst, ok := ownedInstance(c, c.Param("id")) + if !ok { + return + } + ctx := c.Request.Context() + + // Cloud Free is claimed at creation by POST /api/instances. Allowing it here + // too would be a second way to reach the same state, with its own bugs. + if inst.Deployment != license.DeploymentSelfHosted { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "cloud instances get their Free licence when they are created"}) + return + } + if inst.CurrentLicense != "" { + c.JSON(http.StatusConflict, gin.H{ + "error": "this instance already has a licence"}) + return + } + + plan, err := models.GetPlan(ctx, license.DeploymentSelfHosted, license.TierFree) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "no Free plan configured"}) + return + } + if !plan.Active { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Free self-hosted is not currently offered"}) + return + } + + // The entitlement is written BEFORE the licence, so Issue snapshots it rather + // than falling back to the plan base. They are the same numbers today, but + // the ordering is what makes that a coincidence rather than a dependency. + if err := models.UpsertEntitlement(ctx, models.Entitlement{ + InstanceID: inst.InstanceID, + AccountID: inst.AccountID, + Deployment: license.DeploymentSelfHosted, + Tier: license.TierFree, + Term: "annual", + Desired: models.Config{Servers: plan.BaseLimits.MaxServers, Features: models.Features{}}, + Granted: models.Config{Servers: plan.BaseLimits.MaxServers, Features: models.Features{}}, + ResolvedLimits: plan.BaseLimits, + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + s := auth.Current(c) + lic, err := licensing.Issue(ctx, licensing.IssueInput{ + InstanceID: inst.InstanceID, + Tier: license.TierFree, + // Annual, and not a choice. Self-hosted sells annual only because the + // term length is the revocation window for an offline licence. + Term: "annual", + Reason: models.ReasonNew, + IssuedBy: s.Email, + }) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, licensing.ErrFreeLimit) { + status = http.StatusConflict + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + + deliver(c, inst, lic) + audit.Write(ctx, models.AuditEntry{ + Actor: s.Email, Action: "instance.claimed_free", AccountID: s.AccountID, + Target: inst.InstanceID, Detail: "self-hosted Free, annual", IP: c.ClientIP()}) + c.JSON(http.StatusCreated, lic) +} + // deliver sends a freshly issued licence where it needs to go. Cloud instances // are injected; self-hosted customers are emailed and can download. // diff --git a/admin/internal/api/entitlement.go b/admin/internal/api/entitlement.go new file mode 100644 index 0000000..27679df --- /dev/null +++ b/admin/internal/api/entitlement.go @@ -0,0 +1,187 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/auth" + "github.com/mrhid6/vantage/admin/internal/catalogue" + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/admin/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// entitlementBody is what a caller may set. +// +// Only Desired is writable. Granted is what a payment confirmed, and letting a +// form set it would let the portal grant itself a licence — which is the one +// thing this whole split exists to prevent. Staff promote Granted explicitly +// through a separate flag, because staff issuing a licence to somebody who has +// not paid is a real operation with a real reason, and it should be one they +// took on purpose and left an audit row for. +type entitlementBody struct { + Tier string `json:"tier"` + Term string `json:"term"` + Servers int `json:"servers"` + Features []string `json:"features"` + // Grant promotes Desired into Granted in the same write. Staff only. + Grant bool `json:"grant"` +} + +// getEntitlement serves the customer's own view of one instance's configuration. +func getEntitlement(c *gin.Context) { + inst, ok := ownedInstance(c, c.Param("id")) + if !ok { + return + } + ent, err := models.GetEntitlement(c.Request.Context(), inst.InstanceID) + if errors.Is(err, models.ErrNoEntitlement) { + c.JSON(http.StatusNotFound, gin.H{"error": "no entitlement"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()}) +} + +func staffGetEntitlement(c *gin.Context) { + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(c.Request.Context(), + bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no such instance"}) + return + } + ent, err := models.GetEntitlement(c.Request.Context(), inst.InstanceID) + if errors.Is(err, models.ErrNoEntitlement) { + c.JSON(http.StatusNotFound, gin.H{"error": "no entitlement"}) + return + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()}) +} + +// staffSetEntitlement writes an instance's configuration. +// +// This is the endpoint that makes metering usable before Paddle exists: staff +// configure, then issue. It does NOT issue — recording what an instance is +// allowed and signing a licence for it stay separate, so a bad configuration is +// a row to correct rather than a licence to supersede. +func staffSetEntitlement(c *gin.Context) { + ctx := c.Request.Context() + var body entitlementBody + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid entitlement"}) + return + } + + var inst models.Instance + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": c.Param("id")}).Decode(&inst); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no such instance"}) + return + } + + tier := body.Tier + if tier == "" { + tier = inst.Tier + } + plan, err := models.GetPlan(ctx, inst.Deployment, tier) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("no plan for %s/%s", inst.Deployment, tier)}) + return + } + if !termSold(inst.Deployment, body.Term) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("%s does not sell %s", inst.Deployment, body.Term)}) + return + } + if body.Servers < plan.BaseLimits.MaxServers && + plan.BaseLimits.MaxServers != -1 { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("%s includes %d servers; cannot configure fewer", + plan.Name, plan.BaseLimits.MaxServers)}) + return + } + + desired := models.Config{ + Servers: body.Servers, + Features: models.Features(body.Features).OrEmpty(), + } + + // Start from whatever is already granted, so writing a desired change never + // silently alters what the instance is currently allowed. + granted := desired + existing, err := models.GetEntitlement(ctx, inst.InstanceID) + switch { + case err == nil: + if !body.Grant { + granted = existing.Granted + } + case errors.Is(err, models.ErrNoEntitlement): + // First write. There is nothing granted to preserve, so desired becomes + // granted — an instance with an entitlement nobody has granted would + // fall back to the plan base at issue time and confuse everyone. + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Only the limits are stored. Features are NOT snapshotted onto the + // entitlement: they live in Granted.Features, and Issue resolves them again + // against the catalogue at signing time. Storing a second copy here would + // give two answers to "which features does this instance have". + limits, _, err := catalogue.Resolve(ctx, plan, granted) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ent := models.Entitlement{ + InstanceID: inst.InstanceID, + AccountID: inst.AccountID, + Deployment: inst.Deployment, + Tier: tier, + Term: body.Term, + Desired: desired, + Granted: granted, + ResolvedLimits: limits, + } + // A reduction is a fact about the future, so it carries a date. There is no + // billing period to read yet — plan 5 sets this from the subscription — so + // staff-set reductions are marked as pending without one. + if desired.Servers < granted.Servers { + now := time.Now().UTC() + ent.ScheduledChangeAt = &now + } + if existing != nil { + ent.GrantedAt = existing.GrantedAt + } + if body.Grant { + ent.GrantedAt = time.Now().UTC() + } + + if err := models.UpsertEntitlement(ctx, ent); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + audit.Write(ctx, models.AuditEntry{ + Actor: auth.Current(c).Email, + Action: "entitlement.updated", + AccountID: inst.AccountID, + Target: inst.InstanceID, + Detail: fmt.Sprintf("tier=%s term=%s desired_servers=%d granted_servers=%d granted=%t", + tier, body.Term, desired.Servers, granted.Servers, body.Grant), + }) + c.JSON(http.StatusOK, gin.H{"entitlement": ent, "pending": ent.Pending()}) +} diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index 4f1d4ea..661ee22 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -68,6 +68,10 @@ func Routes(cfg config.Config) http.Handler { cust.POST("/instances/link", linkInstance) cust.POST("/instances/:id/relink", relinkInstance) cust.POST("/instances/:id/renew", renewInstance) + cust.POST("/instances/:id/claim-free", + auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), + claimFree) + cust.GET("/instances/:id/entitlement", getEntitlement) cust.GET("/instances/:id/license", getInstanceLicense) cust.GET("/instances/:id/license/download", downloadInstanceLicense) cust.GET("/instances/:id/members", listInstanceMembers) @@ -98,7 +102,15 @@ func Routes(cfg config.Config) http.Handler { staff.POST("/instances/:id/relink", staffRelink) staff.GET("/licenses", staffListLicenses) staff.GET("/plans", staffListPlans) - staff.PUT("/plans/:tier", staffUpdatePlan) + // Plans are keyed on the pair now, so the path is too. A single :tier + // segment could name three rows. + staff.PUT("/plans/:deployment/:tier", staffUpdatePlan) + + staff.GET("/catalogue", staffListCatalogue) + staff.PUT("/catalogue", staffUpdateCatalogue) + + staff.GET("/instances/:id/entitlement", staffGetEntitlement) + staff.PUT("/instances/:id/entitlement", staffSetEntitlement) staff.GET("/audit", staffAudit) staff.GET("/health/injection", staffInjectionHealth) } diff --git a/admin/internal/api/staff.go b/admin/internal/api/staff.go index 72b3ed9..33718ab 100644 --- a/admin/internal/api/staff.go +++ b/admin/internal/api/staff.go @@ -1,6 +1,7 @@ package api import ( + "fmt" "net/http" "strings" "time" @@ -393,31 +394,130 @@ func staffListPlans(c *gin.Context) { c.JSON(http.StatusOK, plans) } -// staffUpdatePlan changes what a tier grants FROM NOW ON. Existing licences -// snapshotted their plan at issue time and are unaffected — the same rule as -// workflow_runs.steps_snapshot. +// staffUpdatePlan changes what a (deployment, tier) pair grants FROM NOW ON. +// Existing licences snapshotted their plan at issue time and are unaffected — +// the same rule as workflow_runs.steps_snapshot. +// +// It writes no Paddle identifiers: those live in the catalogue, because a +// metered plan is priced by several components. func staffUpdatePlan(c *gin.Context) { var body models.Plan if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid plan"}) return } + deployment, tier := c.Param("deployment"), c.Param("tier") set := bson.M{ - "name": body.Name, - "limits": body.Limits, - "features": body.Features.OrEmpty(), - "paddle_product_id": body.PaddleProductID, - "paddle_price_ids": body.PaddlePriceIDs, - "active": body.Active, + "name": body.Name, + "base_limits": body.BaseLimits, + "base_features": body.BaseFeatures.OrEmpty(), + "support_level": body.SupportLevel, + "active": body.Active, } - if _, err := db.Admin("plans").UpdateOne(c.Request.Context(), - bson.M{"tier": c.Param("tier")}, bson.M{"$set": set}); err != nil { + res, err := db.Admin("plans").UpdateOne(c.Request.Context(), + bson.M{"deployment": deployment, "tier": tier}, bson.M{"$set": set}) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if res.MatchedCount == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "no such plan"}) + return + } + audit.Write(c.Request.Context(), models.AuditEntry{ + Actor: auth.Current(c).Email, + Action: "plan.updated", + Target: deployment + "/" + tier, + Detail: fmt.Sprintf("servers=%d monitors=%d support=%s active=%t", + body.BaseLimits.MaxServers, body.BaseLimits.MaxMonitors, + body.SupportLevel, body.Active), + }) c.JSON(http.StatusOK, gin.H{"updated": true}) } +func staffListCatalogue(c *gin.Context) { + rows, err := models.AllCatalogue(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, rows) +} + +// staffUpdateCatalogue sets the price IDs on one component. +// +// The component is addressed by its natural key rather than by an ObjectID, so +// the staff UI never has to hold a Mongo identifier and a seeded row can be +// updated the moment it exists. Only price IDs are writable: a row's kind, plan +// and key are seeded by SeedCatalogue, and letting a form invent a limit_key +// would let it invent a limit nothing enforces. +func staffUpdateCatalogue(c *gin.Context) { + var body struct { + Kind string `json:"kind"` + Deployment string `json:"deployment"` + Tier string `json:"tier"` + LimitKey string `json:"limit_key"` + FeatureKey string `json:"feature_key"` + PriceIDs map[string]map[string]string `json:"price_ids"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid component"}) + return + } + + // Refuse a price on a term the deployment does not sell. Storing one would + // mean a resolved self-hosted monthly price later, which the resolver treats + // as a configuration error — better to refuse it at the point somebody + // pastes it, while they are looking at the screen. + for env, byTerm := range body.PriceIDs { + for term, id := range byTerm { + if id == "" { + continue + } + if !termSold(body.Deployment, term) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("%s does not sell %s (environment %s)", + body.Deployment, term, env)}) + return + } + } + } + + filter := bson.M{ + "kind": body.Kind, + "deployment": body.Deployment, + "tier": body.Tier, + "limit_key": body.LimitKey, + "feature_key": body.FeatureKey, + } + res, err := db.Admin("catalogue").UpdateOne(c.Request.Context(), filter, + bson.M{"$set": bson.M{"price_ids": body.PriceIDs}}) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if res.MatchedCount == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "no such component"}) + return + } + audit.Write(c.Request.Context(), models.AuditEntry{ + Actor: auth.Current(c).Email, + Action: "catalogue.updated", + Target: body.Deployment + "/" + body.Tier + "/" + body.Kind, + Detail: body.LimitKey + body.FeatureKey, + }) + c.JSON(http.StatusOK, gin.H{"updated": true}) +} + +func termSold(deployment, term string) bool { + for _, t := range license.TermsFor(deployment) { + if t == term { + return true + } + } + return false +} + func staffAudit(c *gin.Context) { filter := bson.M{} if v := c.Query("account_id"); v != "" { diff --git a/admin/internal/catalogue/catalogue.go b/admin/internal/catalogue/catalogue.go new file mode 100644 index 0000000..6313e31 --- /dev/null +++ b/admin/internal/catalogue/catalogue.go @@ -0,0 +1,120 @@ +// Package catalogue turns an entitlement into the two things derived from it: +// the limits and features a licence grants, and the Paddle line items a +// subscription is made of. +// +// Both folds live here so the arithmetic exists once. The temptation is to +// compute limits in the issuer and quantities in the checkout, and then the two +// disagree about whether the base allowance is included in the number — which is +// a bug that bills a customer for three servers they were given. +package catalogue + +import ( + "context" + "errors" + "fmt" + + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" +) + +var ( + // ErrUnknownPrice means an item named a price ID no catalogue row claims. + // + // This is always a configuration error and never a customer error: someone + // bought something at a price we cannot map to a plan. It must fail loudly + // rather than guess a tier — a guessed tier is a wrong licence with no + // record of why. + ErrUnknownPrice = errors.New("no catalogue row claims that price ID") + + // ErrNoBaseItem means no item matched a base row, so the subscription names + // no plan. Quantities are meaningless without one. + ErrNoBaseItem = errors.New("no item matches a base price; the subscription names no plan") + + // ErrTermNotSold means a price resolved to a term its deployment does not + // sell — in practice a self-hosted monthly price. + ErrTermNotSold = errors.New("that deployment does not sell that term") + + // ErrUnpriced means a component needed for this configuration has no price + // ID in this environment. Refusing is correct: a checkout that silently + // drops a paid line item gives away the thing it was meant to charge for. + ErrUnpriced = errors.New("component has no price in this environment") +) + +// Resolve folds a configuration into the limits and features a licence grants. +// +// Limits start at the plan's base and each metered component adds its configured +// amount. Features are the plan's base features plus the configured ones, +// deduplicated and filtered to keys the catalogue actually offers — a stale +// feature key in a stored entitlement must not survive into a signed payload. +func Resolve(ctx context.Context, plan *models.Plan, cfg models.Config) (license.Limits, []string, error) { + rows, err := models.CatalogueFor(ctx, plan.Deployment, plan.Tier) + if err != nil { + return license.Limits{}, nil, err + } + + limits := plan.BaseLimits + offered := map[string]bool{} + for _, r := range rows { + switch r.Kind { + case models.KindLimit: + if err := addLimit(&limits, r.LimitKey, configured(cfg, r.LimitKey), plan.BaseLimits); err != nil { + return license.Limits{}, nil, err + } + case models.KindFeature: + offered[r.FeatureKey] = true + } + } + + seen := map[string]bool{} + features := []string{} + for _, f := range plan.BaseFeatures { + if !seen[f] { + seen[f] = true + features = append(features, f) + } + } + for _, f := range cfg.Features { + if seen[f] || !offered[f] { + continue + } + seen[f] = true + features = append(features, f) + } + return limits, features, nil +} + +// configured reads the configured total for one metered limit key. +// +// A switch rather than reflection, so every metered dimension is greppable and +// adding one is a visible edit here as well as a catalogue row. +func configured(cfg models.Config, limitKey string) int { + switch limitKey { + case models.LimitKeyServers: + return cfg.Servers + default: + return 0 + } +} + +// addLimit sets a metered limit to its configured total. +// +// The configured value is a TOTAL, not an increment, so this assigns rather than +// adds. A base of Unlimited is left alone: nothing can be added to no cap, and a +// plan that meters an already-unlimited dimension is a configuration mistake +// rather than something to compute around. +func addLimit(l *license.Limits, limitKey string, total int, base license.Limits) error { + switch limitKey { + case models.LimitKeyServers: + if base.MaxServers == license.Unlimited { + return nil + } + if total > base.MaxServers { + l.MaxServers = total + } + return nil + case "": + return fmt.Errorf("catalogue limit row has no limit_key") + default: + return fmt.Errorf("%w: limit_key %q", ErrUnknownPrice, limitKey) + } +} diff --git a/admin/internal/catalogue/items.go b/admin/internal/catalogue/items.go new file mode 100644 index 0000000..476e823 --- /dev/null +++ b/admin/internal/catalogue/items.go @@ -0,0 +1,206 @@ +package catalogue + +import ( + "context" + "fmt" + + "github.com/mrhid6/vantage/admin/internal/models" + "github.com/mrhid6/vantage/shared/license" +) + +// Item is one Paddle line item: a price and how many of it. +type Item struct { + PriceID string `json:"price_id"` + Quantity int `json:"quantity"` +} + +// LineItems builds the subscription items for a configuration. +// +// The base row is always quantity 1. A metered row's quantity is the configured +// TOTAL minus the plan's base allowance, so a Professional customer at exactly +// three servers has a single-item subscription rather than one with a zero +// quantity Paddle would reject. A feature with no price in this environment +// produces no item and is granted free. +func LineItems(ctx context.Context, env, term string, plan *models.Plan, cfg models.Config) ([]Item, error) { + if !sells(plan.Deployment, term) { + return nil, fmt.Errorf("%w: %s does not sell %s", ErrTermNotSold, plan.Deployment, term) + } + rows, err := models.CatalogueFor(ctx, plan.Deployment, plan.Tier) + if err != nil { + return nil, err + } + if len(rows) == 0 { + return nil, fmt.Errorf("%w: %s/%s is priced by nothing", + ErrUnpriced, plan.Deployment, plan.Tier) + } + + wanted := map[string]bool{} + for _, f := range cfg.Features { + wanted[f] = true + } + + items := []Item{} + for _, r := range rows { + switch r.Kind { + case models.KindBase: + id := r.PriceID(env, term) + if id == "" { + return nil, fmt.Errorf("%w: base price for %s/%s in %s", + ErrUnpriced, plan.Deployment, plan.Tier, env) + } + items = append(items, Item{PriceID: id, Quantity: 1}) + + case models.KindLimit: + qty := billable(cfg, r.LimitKey, plan.BaseLimits) + if qty <= 0 { + continue + } + id := r.PriceID(env, term) + if id == "" { + return nil, fmt.Errorf("%w: %s price for %s/%s in %s", + ErrUnpriced, r.LimitKey, plan.Deployment, plan.Tier, env) + } + items = append(items, Item{PriceID: id, Quantity: qty}) + + case models.KindFeature: + if !wanted[r.FeatureKey] { + continue + } + id := r.PriceID(env, term) + if id == "" { + // Free to toggle. Resolve() still grants it. + continue + } + items = append(items, Item{PriceID: id, Quantity: 1}) + } + } + return items, nil +} + +// billable is how many UNITS to charge for a metered dimension. +// +// The configured value is the total the customer sees, which includes the base +// allowance they were given. Charging for that base is the single most likely +// bug in this file, so the subtraction lives here and nowhere else. +func billable(cfg models.Config, limitKey string, base license.Limits) int { + switch limitKey { + case models.LimitKeyServers: + if base.MaxServers == license.Unlimited { + return 0 + } + return cfg.Servers - base.MaxServers + default: + return 0 + } +} + +// Match is what an item list says about itself. +type Match struct { + Deployment string + Tier string + Term string + Servers int + Features []string +} + +// ResolveItems maps a full item list back to a plan and a configuration. +// +// This replaces a price-ID-to-tier lookup, which cannot work once a subscription +// has several prices. The base item identifies the plan and the term; everything +// else is read relative to it. An item matching nothing fails the whole list. +// +// Only the running environment's IDs are consulted, so a production process +// cannot be talked into resolving a sandbox price by a forged or misrouted +// event. +// +// It is a function of the COMPLETE list, which is what keeps out-of-order +// delivery correct by construction: Paddle sends every item on every +// subscription event, so reading all of them is reading current state rather +// than a transition. +func ResolveItems(ctx context.Context, env string, items []Item) (Match, error) { + all, err := models.AllCatalogue(ctx) + if err != nil { + return Match{}, err + } + + // Pass 1: find the base item. Until we know the plan, no other item means + // anything — a quantity of 7 is 7 of what? + var m Match + found := false + for _, it := range items { + for _, r := range all { + if r.Kind != models.KindBase { + continue + } + for _, term := range []string{"monthly", "annual"} { + if r.PriceID(env, term) != it.PriceID || it.PriceID == "" { + continue + } + if found { + return Match{}, fmt.Errorf( + "item list names two plans: %s/%s and %s/%s", + m.Deployment, m.Tier, r.Deployment, r.Tier) + } + m.Deployment, m.Tier, m.Term = r.Deployment, r.Tier, term + found = true + } + } + } + if !found { + return Match{}, ErrNoBaseItem + } + if !sells(m.Deployment, m.Term) { + return Match{}, fmt.Errorf("%w: price resolves to %s %s; remove it from the catalogue", + ErrTermNotSold, m.Deployment, m.Term) + } + + plan, err := models.GetPlan(ctx, m.Deployment, m.Tier) + if err != nil { + return Match{}, fmt.Errorf("item list names plan %s/%s, which does not exist: %w", + m.Deployment, m.Tier, err) + } + m.Servers = plan.BaseLimits.MaxServers + m.Features = []string{} + + // Pass 2: everything else, relative to that plan. An item matching no row of + // this plan is a configuration error even if it matches some other plan's + // row — mixing two plans in one subscription is not a thing we sell. + rows, err := models.CatalogueFor(ctx, m.Deployment, m.Tier) + if err != nil { + return Match{}, err + } + for _, it := range items { + matched := false + for _, r := range rows { + if r.PriceID(env, m.Term) != it.PriceID { + continue + } + matched = true + switch r.Kind { + case models.KindBase: + // Already handled. + case models.KindLimit: + if r.LimitKey == models.LimitKeyServers { + m.Servers = plan.BaseLimits.MaxServers + it.Quantity + } + case models.KindFeature: + m.Features = append(m.Features, r.FeatureKey) + } + } + if !matched { + return Match{}, fmt.Errorf("%w: %s (environment %s, plan %s/%s)", + ErrUnknownPrice, it.PriceID, env, m.Deployment, m.Tier) + } + } + return m, nil +} + +// sells reports whether a deployment offers a term. +func sells(deployment, term string) bool { + for _, t := range license.TermsFor(deployment) { + if t == term { + return true + } + } + return false +} diff --git a/admin/internal/db/db.go b/admin/internal/db/db.go index 49fc5c2..67e4b19 100644 --- a/admin/internal/db/db.go +++ b/admin/internal/db/db.go @@ -11,6 +11,7 @@ package db import ( "context" "fmt" + "log" "time" "github.com/mrhid6/vantage/admin/internal/config" @@ -84,7 +85,6 @@ func EnsureIndexes(ctx context.Context) error { {"accounts", "account_id"}, {"admin_instances", "instance_id"}, {"licenses", "license_id"}, - {"plans", "tier"}, {"staff_users", "email"}, {"customer_users", "email"}, } @@ -106,6 +106,34 @@ func EnsureIndexes(ctx context.Context) error { return fmt.Errorf("index subscriptions.paddle_subscription_id: %w", err) } + // plans was unique on tier alone until spec 7. Mongo will not replace an + // index implicitly, and the old one would refuse the second row of every + // tier, so it is dropped by name here. Dropping a missing index is not an + // error worth failing boot over — a fresh database has never had it. + if err := Admin("plans").Indexes().DropOne(ctx, "tier_unique"); err != nil { + log.Printf("index plans.tier_unique: not dropped (%v); expected on a fresh database", err) + } + + for _, u := range []struct { + coll string + keys bson.D + name string + }{ + {"plans", bson.D{{Key: "deployment", Value: 1}, {Key: "tier", Value: 1}}, "deployment_tier_unique"}, + {"catalogue", bson.D{ + {Key: "deployment", Value: 1}, {Key: "tier", Value: 1}, {Key: "kind", Value: 1}, + {Key: "limit_key", Value: 1}, {Key: "feature_key", Value: 1}, + }, "component_unique"}, + {"entitlements", bson.D{{Key: "instance_id", Value: 1}}, "instance_id_unique"}, + } { + if _, err := Admin(u.coll).Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: u.keys, + Options: options.Index().SetUnique(true).SetName(u.name), + }); err != nil { + return fmt.Errorf("index %s.%s: %w", u.coll, u.name, err) + } + } + for _, idx := range []struct { coll string keys bson.D diff --git a/admin/internal/licensing/issue.go b/admin/internal/licensing/issue.go index acc8b9a..fa95994 100644 --- a/admin/internal/licensing/issue.go +++ b/admin/internal/licensing/issue.go @@ -5,10 +5,12 @@ import ( "context" "errors" "fmt" + "log" "time" "github.com/google/uuid" "github.com/mrhid6/vantage/admin/internal/audit" + "github.com/mrhid6/vantage/admin/internal/catalogue" "github.com/mrhid6/vantage/admin/internal/db" "github.com/mrhid6/vantage/admin/internal/models" "github.com/mrhid6/vantage/shared/license" @@ -18,7 +20,7 @@ import ( var ( ErrUnknownTier = errors.New("unknown tier") ErrDeploymentMismatch = errors.New("that plan is not available for this deployment type") - ErrFreeLimit = errors.New("this account already has a Free instance") + ErrFreeLimit = errors.New("this account already has a Free instance of that deployment type") ErrUnknownInstance = errors.New("instance not found") ) @@ -54,7 +56,10 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) { return nil, ErrUnknownInstance } - plan, err := models.GetPlan(ctx, in.Tier) + // The plan is looked up by the INSTANCE's deployment, not by a caller's + // guess. That is what makes the deployment comparison below a consistency + // check rather than the thing that decides which plan applies. + plan, err := models.GetPlan(ctx, inst.Deployment, in.Tier) if err != nil { return nil, ErrUnknownTier } @@ -67,11 +72,35 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) { } if plan.Tier == license.TierFree { - if err := checkFreeLimit(ctx, inst.AccountID, inst.InstanceID); err != nil { + if err := checkFreeLimit(ctx, inst.AccountID, inst.Deployment, inst.InstanceID); err != nil { return nil, err } } + // What this licence grants comes from the instance's entitlement, not from + // the plan. The plan is only the base. + // + // An instance with no entitlement gets the plan's base, which covers staff + // manual issuance and anything predating the backfill. Falling back is + // deliberate: refusing here would make a missing row an outage rather than a + // default. + limits, features := plan.BaseLimits, []string(plan.BaseFeatures.OrEmpty()) + ent, entErr := models.GetEntitlement(ctx, inst.InstanceID) + switch { + case entErr == nil: + // Granted, never Desired. A configuration nobody has paid for must not + // reach a signed payload. + limits, features, err = catalogue.Resolve(ctx, plan, ent.Granted) + if err != nil { + return nil, fmt.Errorf("resolve entitlement: %w", err) + } + case errors.Is(entErr, models.ErrNoEntitlement): + log.Printf("licensing: instance %s has no entitlement; issuing plan base", + inst.InstanceID) + default: + return nil, fmt.Errorf("read entitlement: %w", entErr) + } + now := time.Now().UTC() expires := in.ExpiresAt if expires.IsZero() { @@ -94,10 +123,11 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) { Deployment: plan.Deployment, IssuedAt: now, ExpiresAt: expires, - // Snapshotted, not referenced: editing a plan tomorrow must not change - // what this licence grants. - Limits: plan.Limits, - Features: plan.Features.OrEmpty(), + // Snapshotted, not referenced: editing a plan or an entitlement tomorrow + // must not change what this licence grants. + Limits: limits, + Features: features, + SupportLevel: plan.SupportLevel, } blob, err := license.Sign(payload, signingKey) @@ -111,8 +141,8 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) { AccountID: inst.AccountID, Tier: plan.Tier, Deployment: plan.Deployment, - Limits: plan.Limits, - Features: plan.Features.OrEmpty(), + Limits: limits, + Features: models.Features(features).OrEmpty(), IssuedAt: now, ExpiresAt: expires, Blob: blob, @@ -157,13 +187,19 @@ func Issue(ctx context.Context, in IssueInput) (*models.License, error) { return &rec, nil } -// checkFreeLimit enforces one Free instance per account. +// checkFreeLimit enforces one Free instance per account PER DEPLOYMENT. +// +// It used to be one per account, which was sufficient while Free existed only on +// cloud. With a self-hosted Free plan, an account-wide count would refuse a +// self-hosted Free instance to anyone holding a cloud one, and tell them about a +// limit they have not reached. // // Cancelled instances do not count: a customer who cancelled their Free instance // is allowed another one. -func checkFreeLimit(ctx context.Context, accountID, exceptInstanceID string) error { +func checkFreeLimit(ctx context.Context, accountID, deployment, exceptInstanceID string) error { n, err := db.Admin("admin_instances").CountDocuments(ctx, bson.M{ "account_id": accountID, + "deployment": deployment, "tier": license.TierFree, "status": bson.M{"$ne": models.StatusCancelled}, "instance_id": bson.M{"$ne": exceptInstanceID}, diff --git a/admin/internal/models/backfill.go b/admin/internal/models/backfill.go index 14db2ca..6c3d8d5 100644 --- a/admin/internal/models/backfill.go +++ b/admin/internal/models/backfill.go @@ -97,5 +97,152 @@ func Backfill(ctx context.Context) error { if created > 0 { log.Printf("backfill: recorded %d pre-existing instance owners", created) } + + // Pass 3: plans were keyed on tier alone. Give the two cloud tiers their + // deployment, and turn the self_hosted TIER row into the self-hosted + // Professional row it always was. Field names move too: limits/features + // become base_limits/base_features, because "base" is a different claim. + if _, err := db.Admin("plans").UpdateMany(ctx, + bson.M{"deployment": bson.M{"$exists": false}, + "tier": bson.M{"$in": bson.A{license.TierFree, license.TierProfessional}}}, + bson.M{"$set": bson.M{"deployment": license.DeploymentCloud}}); err != nil { + return err + } + if _, err := db.Admin("plans").UpdateOne(ctx, + bson.M{"tier": license.TierSelfHosted}, + bson.M{"$set": bson.M{ + "deployment": license.DeploymentSelfHosted, + "tier": license.TierProfessional, + "name": "Professional", + }}); err != nil { + return err + } + if _, err := db.Admin("plans").UpdateMany(ctx, + bson.M{"limits": bson.M{"$exists": true}}, + bson.M{"$rename": bson.M{"limits": "base_limits", "features": "base_features"}}); err != nil { + return err + } + // Support level is new, so nothing has one. Fill from the seed table rather + // than guessing: a plan row a human edited keeps every other field. + for _, deployment := range license.Deployments() { + for _, tier := range license.Tiers() { + p, ok := license.PlanFor(deployment, tier) + if !ok { + continue + } + if _, err := db.Admin("plans").UpdateOne(ctx, + bson.M{"deployment": deployment, "tier": tier, + "support_level": bson.M{"$in": bson.A{nil, ""}}}, + bson.M{"$set": bson.M{"support_level": p.SupportLevel}}); err != nil { + return err + } + } + } + + // Pass 4: instances carrying the self_hosted TIER move to Professional. + // Their deployment already says self_hosted, so only the tier is wrong. + res, err = db.Admin("admin_instances").UpdateMany(ctx, + bson.M{"tier": license.TierSelfHosted}, + bson.M{"$set": bson.M{"tier": license.TierProfessional}}) + if err != nil { + return err + } + if res.ModifiedCount > 0 { + log.Printf("backfill: re-tiered %d self-hosted instances to professional", res.ModifiedCount) + } + + // Pass 5: give every instance an entitlement, reconstructed from its current + // licence. Filtering on the absence of a row is what makes this idempotent, + // and it means an entitlement a customer has since edited is never + // overwritten by a stale licence. + if err := backfillEntitlements(ctx); err != nil { + return err + } return nil } + +// backfillEntitlements reconstructs an entitlement per instance from its licence. +// +// An Unlimited max_servers maps back to the plan's BASE allowance rather than to +// a huge number: an unlimited licence bought no server units, so the honest +// reconstruction of "how many did they pay for" is none. This makes a +// pre-metering Professional instance read as 3 servers, which is a REDUCTION in +// what it is allowed. That is deliberate and it is why this is a plan step and +// not a silent fix — see the task's confirmation step. +func backfillEntitlements(ctx context.Context) error { + cur, err := db.Admin("admin_instances").Find(ctx, + bson.M{"status": bson.M{"$ne": StatusDeleted}}) + if err != nil { + return err + } + var instances []Instance + if err := cur.All(ctx, &instances); err != nil { + return err + } + + created := 0 + for _, inst := range instances { + n, err := db.Admin("entitlements").CountDocuments(ctx, + bson.M{"instance_id": inst.InstanceID}) + if err != nil { + return err + } + if n > 0 { + continue + } + + deployment, tier := license.NormaliseTier(inst.Deployment, inst.Tier) + if tier == "" { + // An instance awaiting its first licence has no tier. It gets an + // entitlement when one is issued, not before. + continue + } + plan, err := GetPlan(ctx, deployment, tier) + if err != nil { + log.Printf("backfill: instance %s names unknown plan %s/%s; skipped", + inst.InstanceID, deployment, tier) + continue + } + + cfg := Config{Servers: plan.BaseLimits.MaxServers, Features: Features{}} + if inst.CurrentLicense != "" { + var lic License + if err := db.Admin("licenses").FindOne(ctx, + bson.M{"license_id": inst.CurrentLicense}).Decode(&lic); err == nil { + if lic.Limits.MaxServers != license.Unlimited && lic.Limits.MaxServers > 0 { + cfg.Servers = lic.Limits.MaxServers + } + cfg.Features = lic.Features.OrEmpty() + } + } + + limits := plan.BaseLimits + limits.MaxServers = cfg.Servers + if err := UpsertEntitlement(ctx, Entitlement{ + InstanceID: inst.InstanceID, + AccountID: inst.AccountID, + Deployment: deployment, + Tier: tier, + Term: defaultTerm(deployment), + Desired: cfg, + Granted: cfg, + ResolvedLimits: limits, + }); err != nil { + return err + } + created++ + } + if created > 0 { + log.Printf("backfill: created %d entitlements from current licences", created) + } + return nil +} + +// defaultTerm is the term to assume for a reconstructed entitlement. Self-hosted +// sells annual only, so there is nothing to guess there. +func defaultTerm(deployment string) string { + if deployment == license.DeploymentSelfHosted { + return "annual" + } + return "monthly" +} diff --git a/admin/internal/models/catalogue.go b/admin/internal/models/catalogue.go new file mode 100644 index 0000000..d7c5a4a --- /dev/null +++ b/admin/internal/models/catalogue.go @@ -0,0 +1,140 @@ +package models + +import ( + "context" + + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// Component kinds. +const ( + // KindBase is the plan's own fee, always quantity 1. + KindBase = "base" + // KindLimit raises a named limit by one per unit of quantity. + KindLimit = "limit" + // KindFeature is an on/off feature key. + KindFeature = "feature" +) + +// LimitKeyServers is the only metered limit today. +// +// A limit_key is a field name in license.Limits, which is what lets a second +// metered dimension be a catalogue row rather than a code change. There is +// deliberately no block size: with secret-group blocks dropped from the spec it +// would be 1 in every row that will ever exist. +const LimitKeyServers = "max_servers" + +// CatalogueRow is one priceable component of one plan. +// +// This is the ONLY place a Paddle price ID appears anywhere in Vantage. An empty +// PriceIDs means the component is free — a feature with no price is a toggle a +// customer may take at no charge, and giving it a price later is a staff edit +// rather than a migration or a deploy. +type CatalogueRow struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + Kind string `bson:"kind" json:"kind"` + Deployment string `bson:"deployment" json:"deployment"` + Tier string `bson:"tier" json:"tier"` + LimitKey string `bson:"limit_key,omitempty" json:"limit_key,omitempty"` + FeatureKey string `bson:"feature_key,omitempty" json:"feature_key,omitempty"` + // PriceIDs 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. + PriceIDs map[string]map[string]string `bson:"price_ids,omitempty" json:"price_ids,omitempty"` +} + +// PriceID returns the price for one environment and term, or "". +func (r CatalogueRow) PriceID(env, term string) string { + if r.PriceIDs == nil { + return "" + } + return r.PriceIDs[env][term] +} + +// Priced reports whether this component costs anything in an environment. +func (r CatalogueRow) Priced(env string) bool { + for _, term := range []string{"monthly", "annual"} { + if r.PriceID(env, term) != "" { + return true + } + } + return false +} + +// SeedCatalogue inserts the sixteen rows the four PAID plans need. +// +// The two Free plans get no rows at all, and that absence is what keeps Free +// outside Paddle: with nothing to price, no checkout can be built for it. Do not +// "fix" this by adding zero-priced Free rows. +// +// $setOnInsert only, for the same reason as SeedPlans: the price IDs are pasted +// in by staff and a redeploy must not blank them. +func SeedCatalogue(ctx context.Context) error { + paid := []string{license.TierProfessional, license.TierEnterprise} + for _, deployment := range license.Deployments() { + for _, tier := range paid { + rows := []CatalogueRow{ + {Kind: KindBase, Deployment: deployment, Tier: tier}, + {Kind: KindLimit, Deployment: deployment, Tier: tier, LimitKey: LimitKeyServers}, + {Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureConsole}, + {Kind: KindFeature, Deployment: deployment, Tier: tier, FeatureKey: license.FeatureOIDC}, + } + for _, r := range rows { + filter := bson.M{ + "kind": r.Kind, + "deployment": r.Deployment, + "tier": r.Tier, + "limit_key": r.LimitKey, + "feature_key": r.FeatureKey, + } + if _, err := db.Admin("catalogue").UpdateOne(ctx, filter, + bson.M{"$setOnInsert": bson.M{ + "kind": r.Kind, + "deployment": r.Deployment, + "tier": r.Tier, + "limit_key": r.LimitKey, + "feature_key": r.FeatureKey, + "price_ids": map[string]map[string]string{}, + }}, + options.UpdateOne().SetUpsert(true)); err != nil { + return err + } + } + } + } + return nil +} + +// CatalogueFor returns every component of one plan. +func CatalogueFor(ctx context.Context, deployment, tier string) ([]CatalogueRow, error) { + deployment, tier = license.NormaliseTier(deployment, tier) + cur, err := db.Admin("catalogue").Find(ctx, + bson.M{"deployment": deployment, "tier": tier}) + if err != nil { + return nil, err + } + rows := []CatalogueRow{} + if err := cur.All(ctx, &rows); err != nil { + return nil, err + } + return rows, nil +} + +// AllCatalogue returns every row, for the staff editor. +func AllCatalogue(ctx context.Context) ([]CatalogueRow, error) { + cur, err := db.Admin("catalogue").Find(ctx, bson.M{}) + if err != nil { + return nil, err + } + rows := []CatalogueRow{} + if err := cur.All(ctx, &rows); err != nil { + return nil, err + } + return rows, nil +} diff --git a/admin/internal/models/entitlements.go b/admin/internal/models/entitlements.go new file mode 100644 index 0000000..dd822ec --- /dev/null +++ b/admin/internal/models/entitlements.go @@ -0,0 +1,139 @@ +package models + +import ( + "context" + "errors" + "time" + + "github.com/mrhid6/vantage/admin/internal/db" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +// ErrNoEntitlement means the instance has no configuration row. +// +// Callers fall back to the plan's base rather than failing: staff manual +// issuance and any instance predating the backfill legitimately have none. +var ErrNoEntitlement = errors.New("instance has no entitlement") + +// Config is one side of an entitlement — a complete statement of what an +// instance is allowed. +// +// Servers is the TOTAL the customer sees, not the number of units billed. The +// billed quantity is Servers minus the plan's base allowance, and it is computed +// where the line items are built rather than stored, so the two can never +// disagree about which of them included the base. +type Config struct { + Servers int `bson:"servers" json:"servers"` + Features Features `bson:"features" json:"features"` +} + +// Entitlement is what one instance's customer configured. +// +// Both the subscription and the licence are derived from it; it is derived from +// nothing. Desired is what they last asked for; Granted is what a payment +// confirmed. A licence is only ever signed from Granted, so an abandoned +// checkout leaves a Desired that reached nothing. +type Entitlement struct { + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + InstanceID string `bson:"instance_id" json:"instance_id"` + AccountID string `bson:"account_id" json:"account_id"` + Deployment string `bson:"deployment" json:"deployment"` + Tier string `bson:"tier" json:"tier"` + Term string `bson:"term" json:"term"` + + Desired Config `bson:"desired" json:"desired"` + Granted Config `bson:"granted" json:"granted"` + + // ResolvedLimits is BaseLimits with Granted folded in. It is stored rather + // than derived on read so the fold lives in exactly one place — deriving it + // at every read would put the arithmetic in the issuer, the portal and the + // staff console. + ResolvedLimits license.Limits `bson:"resolved_limits" json:"resolved_limits"` + + // ScheduledChangeAt is when a pending REDUCTION takes effect. It is set only + // when Desired grants less than Granted, and it is what lets the portal say + // "drops to 5 on 12 August" instead of guessing. + ScheduledChangeAt *time.Time `bson:"scheduled_change_at,omitempty" json:"scheduled_change_at,omitempty"` + + GrantedAt time.Time `bson:"granted_at" json:"granted_at"` + UpdatedAt time.Time `bson:"updated_at" json:"updated_at"` +} + +// Pending reports whether Desired and Granted disagree. +func (e Entitlement) Pending() bool { + if e.Desired.Servers != e.Granted.Servers { + return true + } + if len(e.Desired.Features) != len(e.Granted.Features) { + return true + } + have := map[string]bool{} + for _, f := range e.Granted.Features { + have[f] = true + } + for _, f := range e.Desired.Features { + if !have[f] { + return true + } + } + return false +} + +// GetEntitlement reads one instance's configuration. +func GetEntitlement(ctx context.Context, instanceID string) (*Entitlement, error) { + var e Entitlement + err := db.Admin("entitlements").FindOne(ctx, + bson.M{"instance_id": instanceID}).Decode(&e) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrNoEntitlement + } + if err != nil { + return nil, err + } + return &e, nil +} + +// UpsertEntitlement writes an entitlement, creating it if absent. +// +// GrantedAt is only touched when Granted actually changes, which is what makes +// it answer "since when has this instance been allowed this" rather than "when +// was this row last written". +func UpsertEntitlement(ctx context.Context, e Entitlement) error { + now := time.Now().UTC() + set := bson.M{ + "account_id": e.AccountID, + "deployment": e.Deployment, + "tier": e.Tier, + "term": e.Term, + "desired": e.Desired, + "granted": e.Granted, + "resolved_limits": e.ResolvedLimits, + "updated_at": now, + } + if e.ScheduledChangeAt != nil { + set["scheduled_change_at"] = *e.ScheduledChangeAt + } + if !e.GrantedAt.IsZero() { + set["granted_at"] = e.GrantedAt + } else { + set["granted_at"] = now + } + update := bson.M{"$set": set} + if e.ScheduledChangeAt == nil { + update["$unset"] = bson.M{"scheduled_change_at": ""} + } + _, err := db.Admin("entitlements").UpdateOne(ctx, + bson.M{"instance_id": e.InstanceID}, + mergeSetOnInsert(update, bson.M{"instance_id": e.InstanceID}), + options.UpdateOne().SetUpsert(true)) + return err +} + +// mergeSetOnInsert adds a $setOnInsert clause without clobbering an existing one. +func mergeSetOnInsert(update bson.M, onInsert bson.M) bson.M { + update["$setOnInsert"] = onInsert + return update +} diff --git a/admin/internal/models/models.go b/admin/internal/models/models.go index e5e23b6..d0f0e77 100644 --- a/admin/internal/models/models.go +++ b/admin/internal/models/models.go @@ -68,6 +68,13 @@ const ( ReasonTierChange = "tier_change" ReasonRelink = "relink" ReasonManual = "manual" + + // ReasonEntitlementChange is a mid-term change to what an instance is + // allowed — servers added, a feature toggled — at the same expiry. + // + // It is deliberately NOT ReasonRenewal: a renewal resets relink_count + // because a new term has begun, and adding a server does not begin one. + ReasonEntitlementChange = "entitlement_change" ) // MaxRelinksPerTerm is the customer-facing relink cap. @@ -158,21 +165,28 @@ type Subscription struct { CurrentPeriodEnd time.Time `bson:"current_period_end" json:"current_period_end"` } -// Plan is the authoritative tier definition, seeded from shared/license. +// Plan is the authoritative definition of one (deployment, tier) pair, seeded +// from shared/license. // // It lives in the database so tier contents change without a deploy. Every // issued licence snapshots it, so editing a plan never rewrites an existing // licence — the same rule as workflow_runs.steps_snapshot. +// +// It holds NO Paddle identifiers. Every price ID lives in `catalogue`, because a +// metered plan is priced by several components and a single map on this row +// cannot express that. type Plan struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"-"` - Tier string `bson:"tier" json:"tier"` - Name string `bson:"name" json:"name"` - Deployment string `bson:"deployment" json:"deployment"` - Limits license.Limits `bson:"limits" json:"limits"` - Features Features `bson:"features" json:"features"` - PaddleProductID string `bson:"paddle_product_id,omitempty" json:"paddle_product_id,omitempty"` - PaddlePriceIDs map[string]string `bson:"paddle_price_ids,omitempty" json:"paddle_price_ids,omitempty"` - Active bool `bson:"active" json:"active"` + ID bson.ObjectID `bson:"_id,omitempty" json:"-"` + Deployment string `bson:"deployment" json:"deployment"` + Tier string `bson:"tier" json:"tier"` + Name string `bson:"name" json:"name"` + // BaseLimits is the allowance before anything is bought. The field is named + // `base_` rather than `limits` because that is a different claim from the one + // the old field made, and a reader must not assume it is the total. + BaseLimits license.Limits `bson:"base_limits" json:"base_limits"` + BaseFeatures Features `bson:"base_features" json:"base_features"` + SupportLevel string `bson:"support_level" json:"support_level"` + Active bool `bson:"active" json:"active"` } type StaffUser struct { diff --git a/admin/internal/models/plans.go b/admin/internal/models/plans.go index c254225..b5da069 100644 --- a/admin/internal/models/plans.go +++ b/admin/internal/models/plans.go @@ -10,38 +10,48 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) -// SeedPlans inserts the tier table from shared/license on first boot. +// SeedPlans inserts the six (deployment, tier) rows from shared/license on first +// boot. // -// It uses $setOnInsert only: once a plan exists, staff edits to limits, features -// and Paddle IDs are authoritative and a redeploy must not stamp over them. +// It uses $setOnInsert only: once a plan exists, staff edits to allowances, +// features and support level are authoritative and a redeploy must not stamp +// over them. func SeedPlans(ctx context.Context) error { - for _, tier := range []string{license.TierFree, license.TierProfessional, license.TierSelfHosted} { - p, ok := license.PlanFor(tier) - if !ok { - continue - } - _, err := db.Admin("plans").UpdateOne(ctx, - bson.M{"tier": tier}, - bson.M{"$setOnInsert": bson.M{ - "tier": p.Tier, - "name": p.Name, - "deployment": p.Deployment, - "limits": p.Limits, - "features": Features(p.Features).OrEmpty(), - "active": true, - }}, - options.UpdateOne().SetUpsert(true)) - if err != nil { - return err + for _, deployment := range license.Deployments() { + for _, tier := range license.Tiers() { + p, ok := license.PlanFor(deployment, tier) + if !ok { + continue + } + _, err := db.Admin("plans").UpdateOne(ctx, + bson.M{"deployment": deployment, "tier": tier}, + bson.M{"$setOnInsert": bson.M{ + "deployment": p.Deployment, + "tier": p.Tier, + "name": p.Name, + "base_limits": p.Limits, + "base_features": Features(p.Features).OrEmpty(), + "support_level": p.SupportLevel, + "active": true, + }}, + options.UpdateOne().SetUpsert(true)) + if err != nil { + return err + } } } return nil } -// GetPlan reads a tier's authoritative definition. -func GetPlan(ctx context.Context, tier string) (*Plan, error) { +// GetPlan reads one pair's authoritative definition. +// +// It normalises the tier first, so a legacy self_hosted licence being reissued +// resolves to the plan that replaced it. +func GetPlan(ctx context.Context, deployment, tier string) (*Plan, error) { + deployment, tier = license.NormaliseTier(deployment, tier) var p Plan - if err := db.Admin("plans").FindOne(ctx, bson.M{"tier": tier}).Decode(&p); err != nil { + if err := db.Admin("plans").FindOne(ctx, + bson.M{"deployment": deployment, "tier": tier}).Decode(&p); err != nil { return nil, err } return &p, nil diff --git a/adminsite/app/(staff)/staff/catalogue/page.tsx b/adminsite/app/(staff)/staff/catalogue/page.tsx new file mode 100644 index 0000000..b233cca --- /dev/null +++ b/adminsite/app/(staff)/staff/catalogue/page.tsx @@ -0,0 +1,184 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { PageHeader } from "@/components/PageHeader"; +import { PageFrame } from "@/components/PageFrame"; +import { api, type CatalogueRow, type Term } from "@/lib/api"; + +const ENVS = ["sandbox", "production"] as const; + +/* Self-hosted sells annual only, so the monthly cell is not rendered for it + * rather than rendered and rejected. The backend refuses one either way; this is + * so nobody types into a field that cannot be saved. */ +function termsFor(deployment: string): Term[] { + return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"]; +} + +function componentLabel(r: CatalogueRow): string { + if (r.kind === "base") return "Base fee"; + if (r.kind === "limit") return `Per ${r.limit_key?.replace("max_", "")}`; + return `Feature: ${r.feature_key}`; +} + +function rowKey(r: CatalogueRow): string { + return [r.deployment, r.tier, r.kind, r.limit_key ?? "", r.feature_key ?? ""].join("/"); +} + +export default function CataloguePage() { + const qc = useQueryClient(); + const { data: rows = [], isLoading } = useQuery({ + queryKey: ["staff", "catalogue"], + queryFn: api.staff.catalogue, + }); + const [drafts, setDrafts] = useState>({}); + + const save = useMutation({ + mutationFn: (r: CatalogueRow) => api.staff.updateCatalogue(r), + onSuccess: () => qc.invalidateQueries({ queryKey: ["staff", "catalogue"] }), + }); + + const groups = Array.from(new Set(rows.map((r) => `${r.deployment}/${r.tier}`))); + + return ( +
+ + +

+ A component with no price ID is free. A feature with no price is a + toggle a customer may take at no charge; giving it a price here is + all it takes to start charging for it. +

+

+ Free is priced by nothing and has no rows. That absence is what + keeps it outside Paddle. +

+

+ Changing a price affects the next checkout only. It cannot touch an + issued licence. +

+ + } + > + {isLoading ? ( +

Loading…

+ ) : ( +
+ {groups.map((g) => { + const [deployment, tier] = g.split("/"); + const terms = termsFor(deployment); + return ( +
+

+ {deployment === "cloud" ? "Cloud" : "Self-Hosted"}{" "} + {tier} +

+
+ + + + + {ENVS.map((env) => + terms.map((t) => ( + + )), + )} + + + + {rows + .filter( + (r) => + r.deployment === deployment && + r.tier === tier, + ) + .map((r) => { + const k = rowKey(r); + const ids = drafts[k] ?? r.price_ids ?? {}; + const dirty = + JSON.stringify(ids) !== + JSON.stringify(r.price_ids ?? {}); + return ( + + + {ENVS.map((env) => + terms.map((t) => ( + + )), + )} + + + ); + })} + +
Component + {env} / {t} + +
+ {componentLabel(r)} + + + setDrafts({ + ...drafts, + [k]: { + ...ids, + [env]: { + ...(ids[ + env + ] ?? {}), + [t]: e + .target + .value, + }, + }, + }) + } + className="w-40 rounded border border-rule bg-panel px-2 py-1 font-mono text-[0.78rem] text-ink" + /> + + +
+
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/adminsite/app/(staff)/staff/instances/[id]/IssuePanel.tsx b/adminsite/app/(staff)/staff/instances/[id]/IssuePanel.tsx index 2aa3d6f..60b7205 100644 --- a/adminsite/app/(staff)/staff/instances/[id]/IssuePanel.tsx +++ b/adminsite/app/(staff)/staff/instances/[id]/IssuePanel.tsx @@ -8,13 +8,11 @@ import { Field } from "@/components/Field"; export function IssuePanel({ instanceId, - deployment, }: { instanceId: string; - deployment: string; }) { const qc = useQueryClient(); - const [tier, setTier] = useState(deployment === "cloud" ? "professional" : "self_hosted"); + const [tier, setTier] = useState("professional"); const [term, setTerm] = useState("annual"); const [newId, setNewId] = useState(""); const [error, setError] = useState(); @@ -47,7 +45,7 @@ export function IssuePanel({ > - + + ))} + + + + + +

+ Changes apply to licences issued from now on. Existing licences + snapshotted their plan and are unaffected. +

+ + + + ); +} + export default function PlansPage() { const qc = useQueryClient(); const plans = useQuery({ queryKey: ["plans"], queryFn: api.staff.plans }); @@ -16,30 +119,27 @@ export default function PlansPage() { queryFn: () => api.staff.licenses(), }); const [draft, setDraft] = useState(null); + const [saving, setSaving] = useState(null); const save = 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, - }), + mutationFn: (p: Plan) => api.staff.updatePlan(p.deployment, p.tier, p), onSuccess: () => { qc.invalidateQueries({ queryKey: ["plans"] }); setDraft(null); + setSaving(null); }, + onError: () => setSaving(null), }); - const original = plans.data?.find((p) => p.tier === draft?.tier); + const original = plans.data?.find( + (p) => p.deployment === draft?.deployment && p.tier === draft?.tier, + ); return (
{draft && original && ( @@ -48,84 +148,46 @@ export default function PlansPage() { next={draft} issuedCount={ (licenses.data ?? []).filter( - (l) => l.tier === draft.tier, + (l) => l.tier === draft.tier && l.deployment === draft.deployment, ).length } - onConfirm={() => save.mutate(draft)} + onConfirm={() => { + setSaving(`${draft.deployment}/${draft.tier}`); + save.mutate(draft); + }} onCancel={() => setDraft(null)} /> )} -
- {(plans.data ?? []).map((p) => ( -
-

{p.name}

-
-
-
servers
-
{limitLabel(p.limits.max_servers)}
-
-
-
secret groups
-
- {limitLabel(p.limits.max_secret_groups)} -
-
-
-
channels
-
{limitLabel(p.limits.max_channels)}
-
-
-
features
-
{p.features?.join(", ") || "none"}
-
-
- - {/* Guard rail two: deployment is shown, never edited. */} -

- - - Deployment is fixed at{" "} - {p.deployment}. - Moving a tier between cloud and self-hosted is a - code change, not a form field. - -

- -
- - -
-
- ))} -
+
+

+ {p.name} +

+ + {p.deployment}/{p.tier} + +
+ setDraft(next)} + /> + + ))} + + ))}
); } diff --git a/adminsite/components/ConfirmPlanChange.tsx b/adminsite/components/ConfirmPlanChange.tsx index a98fb34..553dc5c 100644 --- a/adminsite/components/ConfirmPlanChange.tsx +++ b/adminsite/components/ConfirmPlanChange.tsx @@ -22,29 +22,32 @@ export function ConfirmPlanChange({ onCancel: () => void; }) { const rows: { field: string; was: string; now: string }[] = []; - if (plan.limits.max_servers !== next.limits.max_servers) + const fields = [ + "max_servers", + "max_monitors", + "max_secret_groups", + "max_channels", + "audit_retention_days", + ] as const; + for (const f of fields) { + if (plan.base_limits[f] !== next.base_limits[f]) + rows.push({ + field: f, + was: limitLabel(plan.base_limits[f]), + now: limitLabel(next.base_limits[f]), + }); + } + if (plan.support_level !== next.support_level) rows.push({ - field: "max_servers", - was: limitLabel(plan.limits.max_servers), - now: limitLabel(next.limits.max_servers), + field: "support_level", + was: plan.support_level || "none", + now: next.support_level || "none", }); - if (plan.limits.max_secret_groups !== next.limits.max_secret_groups) - rows.push({ - field: "max_secret_groups", - was: limitLabel(plan.limits.max_secret_groups), - now: limitLabel(next.limits.max_secret_groups), - }); - if (plan.limits.max_channels !== next.limits.max_channels) - rows.push({ - field: "max_channels", - was: limitLabel(plan.limits.max_channels), - now: limitLabel(next.limits.max_channels), - }); - if (plan.features.join(",") !== next.features.join(",")) + if (plan.base_features.join(",") !== next.base_features.join(",")) rows.push({ field: "features", - was: plan.features.join(", ") || "none", - now: next.features.join(", ") || "none", + was: plan.base_features.join(", ") || "none", + now: next.base_features.join(", ") || "none", }); return ( diff --git a/adminsite/components/PlanConfigurator.tsx b/adminsite/components/PlanConfigurator.tsx new file mode 100644 index 0000000..c567747 --- /dev/null +++ b/adminsite/components/PlanConfigurator.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useMemo } from "react"; +import type { CatalogueRow, Deployment, Plan, Term, Tier } from "@/lib/api"; + +export interface PlanChoice { + tier: Tier; + term: Term; + servers: number; + features: string[]; +} + +/* Self-hosted sells annual only. The reason is in shared/license: an offline + * licence cannot be revoked, so the term length IS the revocation window. */ +function termsFor(deployment: Deployment): Term[] { + return deployment === "self_hosted" ? ["annual"] : ["monthly", "annual"]; +} + +/* + * PlanConfigurator is the whole of "what is this instance allowed", driven + * entirely by the plans and catalogue it is handed. + * + * A feature appears because a catalogue row offers it, and shows a price because + * that row has one. Nothing here is hardcoded per tier, which is what lets a new + * paid add-on ship as a staff edit rather than a frontend release. + * + * It saves nothing and knows nothing about who is using it. Staff mount it to + * set an entitlement; the customer purchase flow mounts the same component and + * hands it a checkout. + */ +export default function PlanConfigurator({ + deployment, + value, + plans, + catalogue, + onChange, + disabled, +}: { + deployment: Deployment; + value: PlanChoice; + plans: Plan[]; + catalogue: CatalogueRow[]; + onChange: (next: PlanChoice) => void; + disabled?: boolean; +}) { + const available = useMemo( + () => plans.filter((p) => p.deployment === deployment && p.active), + [plans, deployment], + ); + const plan = available.find((p) => p.tier === value.tier); + const rows = useMemo( + () => catalogue.filter((r) => r.deployment === deployment && r.tier === value.tier), + [catalogue, deployment, value.tier], + ); + const featureRows = rows.filter((r) => r.kind === "feature"); + const base = plan?.base_limits.max_servers ?? 0; + const extra = Math.max(0, value.servers - base); + + const priceOf = (r: CatalogueRow) => + r.price_ids?.sandbox?.[value.term] ?? r.price_ids?.production?.[value.term] ?? ""; + + return ( +
+
+ Tier +
+ {available.map((p) => ( + + ))} +
+
+ +
+ Term +
+ {termsFor(deployment).map((t) => ( + + ))} +
+ {deployment === "self_hosted" && ( +

+ Self-hosted is annual only. +

+ )} +
+ + + + {featureRows.length > 0 && ( +
+ Features + {featureRows.map((r) => { + const key = r.feature_key!; + const on = value.features.includes(key); + const priced = priceOf(r) !== ""; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/adminsite/lib/api.ts b/adminsite/lib/api.ts index 0260fc4..ea0f878 100644 --- a/adminsite/lib/api.ts +++ b/adminsite/lib/api.ts @@ -61,7 +61,8 @@ const del = (path: string) => req(path, { method: "DELETE" }); // --- types --------------------------------------------------------------- export type Deployment = "cloud" | "self_hosted"; -export type Tier = "free" | "professional" | "self_hosted"; +export type Tier = "free" | "professional" | "enterprise"; +export type Term = "monthly" | "annual"; export type InstanceStatus = "awaiting_link" | "active" | "lapsed" | "cancelled" | "deleted"; /* @@ -103,8 +104,10 @@ export interface InstanceMember { export interface Limits { max_servers: number; + max_monitors: number; max_secret_groups: number; max_channels: number; + audit_retention_days: number; } export interface Account { @@ -157,16 +160,48 @@ export interface Subscription { } export interface Plan { + deployment: Deployment; tier: Tier; name: string; - deployment: Deployment; - limits: Limits; - features: string[]; - paddle_product_id?: string; - paddle_price_ids?: Record; + /* The allowance BEFORE anything is bought. Not the total — a metered + * dimension adds to it. */ + base_limits: Limits; + base_features: string[]; + support_level: string; active: boolean; } +export interface CatalogueRow { + kind: "base" | "limit" | "feature"; + deployment: Deployment; + tier: Tier; + limit_key?: string; + feature_key?: string; + /* environment -> term -> Paddle price ID. The running PADDLE_ENV picks the + * inner map; both environments are stored so promotion is a config change + * rather than a data migration. */ + price_ids?: Record>>; +} + +export interface EntitlementConfig { + servers: number; + features: string[]; +} + +export interface Entitlement { + instance_id: string; + account_id: string; + deployment: Deployment; + tier: Tier; + term: Term; + desired: EntitlementConfig; + granted: EntitlementConfig; + resolved_limits: Limits; + scheduled_change_at?: string; + granted_at: string; + updated_at: string; +} + export interface CustomerUser { user_id: string; account_id: string; @@ -274,11 +309,21 @@ export const api = { licenses: (params?: Record) => req(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`), plans: () => req("/api/staff/plans"), - updatePlan: (tier: Tier, plan: Omit) => - req<{ updated: boolean }>(`/api/staff/plans/${tier}`, { - method: "PUT", - body: JSON.stringify(plan), - }), + updatePlan: (deployment: Deployment, tier: Tier, plan: Plan) => + put<{ updated: boolean }>(`/api/staff/plans/${deployment}/${tier}`, plan), + catalogue: () => req("/api/staff/catalogue"), + updateCatalogue: (row: CatalogueRow) => + put<{ updated: boolean }>("/api/staff/catalogue", row), + entitlement: (id: string) => + req<{ entitlement: Entitlement; pending: boolean }>( + `/api/staff/instances/${id}/entitlement`, + ), + setEntitlement: ( + id: string, + body: { tier: Tier; term: Term; servers: number; features: string[]; grant?: boolean }, + ) => + put<{ entitlement: Entitlement; pending: boolean }>( + `/api/staff/instances/${id}/entitlement`, body), audit: (accountId?: string) => req(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`), injectionHealth: () => diff --git a/claude.md b/claude.md index 8ca109a..9031e53 100644 --- a/claude.md +++ b/claude.md @@ -312,6 +312,8 @@ org GET,POST /org/users · PUT /org/users/:id/role · DELETE /org/users `POST /license` is also in `licenceExemptPaths`: pasting a valid licence has to work while the current one is expired, because it is the way out of degraded mode. +Free exists in both deployments, so it is no longer cloud-only by construction. The one-Free-per-account rule is enforced per account **and deployment**, in `licensing.checkFreeLimit` and in `createInstance`'s friendly pre-check — the two must stay scoped identically, because a pre-check stricter than the issuer refuses what would have worked. A self-hosted Free licence is claimed with `POST /api/instances/:id/claim-free` after the install is linked; the metered server count and per-instance feature toggles come from the instance's `entitlement`, which a licence snapshots at issue time (see the `catalogue`/`entitlements` note under MongoDB Collections). + --- ## Admin REST API (`admin`, :8083) @@ -331,9 +333,11 @@ Customer-session (`/api`), every instance resolved through `ownedInstance`: ``` GET /account # account, instances, max_relinks -POST /instances # create a cloud instance (Free tier, capped at one per account) +POST /instances # create a cloud instance (Free tier, one Free per account per deployment) POST /instances/:id/renew # Free renewal; refuses outside the renewal window +POST /instances/:id/claim-free # issue Free on a linked self-hosted instance POST /instances/link · /instances/:id/relink +GET /instances/:id/entitlement GET /instances/:id/license · /instances/:id/license/download GET /subscriptions GET,POST /account/users · PUT /account/users/:id/role · DELETE /account/users/:id @@ -353,7 +357,9 @@ Staff-session (`/api/staff`): GET,POST /accounts · GET /accounts/:id # search by name, email, Paddle ID or instance UUID GET,POST /instances · GET /instances/:id # instance + account + licence history + injection state POST /instances/:id/issue · /instances/:id/relink -GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:tier +GET /licenses · /subscriptions · /audit · /plans · PUT /plans/:deployment/:tier +GET,PUT /catalogue +GET,PUT /instances/:id/entitlement GET /health/injection ``` @@ -375,7 +381,9 @@ Notes that are not obvious from the structs: - `console_sessions.token_consumed_at` is set atomically to enforce one-time use. - `users.auth_source` is `local`, `oidc` or `hq`. An `hq` user was projected from a Vantage HQ account and carries `hq_user_id`; HQ owns its role, password and existence. -Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an *index* of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes. +Admin's own database is separate and holds `accounts` · `admin_instances` · `licenses` · `subscriptions` · `plans` · `catalogue` · `entitlements` · `staff_users` · `customer_users` · `instance_members` · `admin_audit`. `instance_members` is unique on `(instance_id, customer_user_id)` — one person holds at most one user in one instance, which makes a grant idempotent-by-refusal rather than silently doubling a projection. It is an *index* of the control-plane rows, not the authority (see "Grants project, they do not federate"). Admin has no migrations collection; `models.Backfill` runs on every boot and is idempotent by filtering on the absence of what it writes. + +`plans` is keyed on `(deployment, tier)` — six rows, two deployments times three tiers — and holds base allowances only. **Every Paddle price ID lives in `catalogue`**, one row per priceable component (`base`, `limit`, `feature`), because a metered plan is priced by several prices and one map on a plan row cannot express that. `entitlements` holds one row per instance with `desired` beside `granted`: the checkout is built from `desired`, a licence is only ever signed from `granted`, and an abandoned checkout therefore leaves a `desired` that reached nothing. The two Free plans have **no catalogue rows at all**, which is what keeps Free outside Paddle. ### Migrations diff --git a/docs/superpowers/plans/2026-07-26-paddle-billing.md b/docs/superpowers/plans/2026-07-26-paddle-billing.md index 62c8115..efed02c 100644 --- a/docs/superpowers/plans/2026-07-26-paddle-billing.md +++ b/docs/superpowers/plans/2026-07-26-paddle-billing.md @@ -10,15 +10,20 @@ --- -## ⚠ REVISED BY SPEC 7 — DO NOT EXECUTE AS WRITTEN +## ⚠ REVISED BY SPEC 7 — WHICH HAS SHIPPED -[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 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. -**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 +**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. diff --git a/docs/superpowers/specs/README.md b/docs/superpowers/specs/README.md index 68da893..97b0fd4 100644 --- a/docs/superpowers/specs/README.md +++ b/docs/superpowers/specs/README.md @@ -12,7 +12,7 @@ Build in this order. Specs 0a–5 were designed 2026-07-24; spec 6 on 2026-07-26 | 4 | [admin-site](2026-07-24-admin-site-design.md) | — | ready to start | | 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | [plan](../plans/2026-07-26-paddle-billing.md) | ready to start, **but revised by 7** — its "signup migration off sitesvc" section is superseded by 6, and its single-price-per-subscription assumption by 7 | | 6 | [cloud-instance-creation](2026-07-26-cloud-instance-creation-design.md) | — | ready to start | -| 7 | [metered-licensing](2026-07-26-metered-licensing-design.md) | — | designed; **build before 5**, whose plan it revises | +| 7 | [metered-licensing](2026-07-26-metered-licensing-design.md) | [plan](../plans/2026-07-26-metered-licensing.md) | **shipped** — staff can configure and issue any of the six plans; no customer can buy one until 5 lands | Specs 1 and 2 together give working licensing with licences cut by hand with `lkctl` — no admin service needed. 4 and 5 can run in parallel once 3 lands. diff --git a/server/cmd/main.go b/server/cmd/main.go index 1a84023..60cdd09 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -88,6 +88,7 @@ func main() { } services.StartLogSweeper() + services.StartAuditSweeper() redisAddr := getEnv("REDIS_ADDR", "localhost:6379") if err := auth.InitRedis(redisAddr); err != nil { diff --git a/server/internal/api/licence.go b/server/internal/api/licence.go index 261dd6c..d8418d4 100644 --- a/server/internal/api/licence.go +++ b/server/internal/api/licence.go @@ -96,6 +96,7 @@ type licenceResponse struct { State license.State `json:"state"` Reason string `json:"reason,omitempty"` Tier string `json:"tier,omitempty"` + SupportLevel string `json:"support_level,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` DaysRemaining *int `json:"days_remaining,omitempty"` Limits license.Limits `json:"limits"` @@ -110,6 +111,7 @@ type licenceResponse struct { type licenceUsageResponse struct { Servers int `json:"servers"` + Monitors int `json:"monitors"` SecretGroups int `json:"secret_groups"` Channels int `json:"channels"` } @@ -117,19 +119,20 @@ type licenceUsageResponse struct { func getLicence(c *gin.Context) { instanceID := auth.InstanceID(c) st := services.GetLicenseState(instanceID) - servers, groups, channels := services.LicenseUsage(instanceID) + servers, monitors, groups, channels := services.LicenseUsage(instanceID) resp := licenceResponse{ - InstanceID: instanceID, - State: st.Status, - Reason: st.Reason, - Tier: st.Tier, - ExpiresAt: st.ExpiresAt, - Limits: st.Limits, - Features: st.Features, - Usage: licenceUsageResponse{Servers: servers, SecretGroups: groups, Channels: channels}, - Source: st.Source, - Deployment: services.DeploymentMode(), + InstanceID: instanceID, + State: st.Status, + Reason: st.Reason, + Tier: st.Tier, + SupportLevel: st.SupportLevel, + ExpiresAt: st.ExpiresAt, + Limits: st.Limits, + Features: st.Features, + Usage: licenceUsageResponse{Servers: servers, Monitors: monitors, SecretGroups: groups, Channels: channels}, + Source: st.Source, + Deployment: services.DeploymentMode(), } if st.ExpiresAt != nil { d := int(time.Until(*st.ExpiresAt).Hours() / 24) diff --git a/server/internal/api/monitors.go b/server/internal/api/monitors.go index 78010e5..a2d3737 100644 --- a/server/internal/api/monitors.go +++ b/server/internal/api/monitors.go @@ -40,6 +40,13 @@ func createMonitor(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "name and type are required"}) return } + if err := services.CheckMonitorLimit(auth.InstanceID(c)); err != nil { + if limitStatus(c, err) { + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } created, err := services.CreateMonitor(auth.InstanceID(c), &m) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) diff --git a/server/internal/auth/oidc.go b/server/internal/auth/oidc.go index f252957..f6bd01e 100644 --- a/server/internal/auth/oidc.go +++ b/server/internal/auth/oidc.go @@ -98,6 +98,20 @@ func HandleOIDCCallback(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"}) return } + + // The start handler checks this too, but an ungated callback is the half + // that matters: a start that refuses is a dead end, while a callback that + // completes signs somebody in. A licence that lapsed mid-flow stops the + // exchange here rather than after it. + // + // Resolved from the consumed state rather than from the host, because on + // this route the instance is whatever the state said and nobody is signed + // in yet. + if !services.GetLicenseState(instanceID).Feature("oidc") { + c.Redirect(http.StatusFound, "/login?error=oidc_unavailable") + return + } + provider, oauthCfg, err := providerForInstance(ctx, c, instanceID) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/server/internal/services/audit_retention.go b/server/internal/services/audit_retention.go new file mode 100644 index 0000000..c154850 --- /dev/null +++ b/server/internal/services/audit_retention.go @@ -0,0 +1,78 @@ +package services + +import ( + "context" + "log" + "time" + + "github.com/mrhid6/vantage/server/internal/db" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// StartAuditSweeper trims audit logs past their licensed retention. +// +// An immediate pass then daily, following StartLogSweeper's shape. Daily rather +// than hourly because the unit of retention is a day: sweeping twenty-four times +// to delete the same nothing is load without a purpose. +func StartAuditSweeper() { + go func() { + sweepAuditLogs() + t := time.NewTicker(24 * time.Hour) + defer t.Stop() + for range t.C { + sweepAuditLogs() + } + }() +} + +// sweepAuditLogs deletes entries older than each instance's licensed retention. +// +// This is the only part of this subsystem that deletes customer data, so it is +// deliberately conservative in three ways. +// +// It reads the CURRENT licence each run rather than caching a value, so raising +// a customer's retention takes effect on the next sweep instead of whenever a +// process restarts. +// +// It skips an instance whose licence is not valid. A lapsed instance must not +// have its history trimmed on the expired term's allowance — expiry degrades to +// read-only, and deleting more of somebody's audit trail is not read-only. +// +// It skips Unlimited and any non-positive value. A licence that decodes as zero +// has already been filled from the plan base at the decode site, so a zero here +// means something is wrong and doing nothing is the right response to that. +func sweepAuditLogs() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + var ids []string + if err := db.Col("instances").Distinct(ctx, "instance_id", bson.M{}).Decode(&ids); err != nil { + log.Printf("audit sweep: list instances: %v", err) + return + } + + for _, id := range ids { + st := GetLicenseState(id) + if !st.Active() { + continue + } + days := st.Limits.AuditRetentionDays + if days == license.Unlimited || days <= 0 { + continue + } + cutoff := time.Now().UTC().AddDate(0, 0, -days) + res, err := db.Col("audit_logs").DeleteMany(ctx, bson.M{ + "instance_id": id, + "created_at": bson.M{"$lt": cutoff}, + }) + if err != nil { + log.Printf("audit sweep: instance %s: %v", id, err) + continue + } + if res.DeletedCount > 0 { + log.Printf("audit sweep: instance %s: removed %d entries older than %d days", + id, res.DeletedCount, days) + } + } +} diff --git a/server/internal/services/licence.go b/server/internal/services/licence.go index 048b6e8..8f8e19d 100644 --- a/server/internal/services/licence.go +++ b/server/internal/services/licence.go @@ -15,10 +15,11 @@ import ( // LicenseState is the resolved licence for one instance. type LicenseState struct { - Status license.State `json:"state"` - Reason string `json:"reason,omitempty"` - Tier string `json:"tier,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` + Status license.State `json:"state"` + Reason string `json:"reason,omitempty"` + Tier string `json:"tier,omitempty"` + SupportLevel string `json:"support_level,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` Limits license.Limits `json:"limits"` Features map[string]bool `json:"features"` // Source is "stored", "env" or "none" — useful when a self-hosted operator @@ -127,13 +128,27 @@ func stateFromResult(res license.Result, source string) LicenseState { for _, f := range res.License.Features { feats[f] = true } + + // A licence signed before max_monitors and audit_retention_days existed + // decodes them as 0, which would read as "no monitors" and "trim the audit + // log to nothing". Fill from the seed plan for the tier the licence names. + // + // This is the only decode site, which is why the fill belongs here rather + // than at each of the places that reads a limit. + limits := res.License.Limits + deployment, tier := license.NormaliseTier(res.License.Deployment, res.License.Tier) + if base, ok := license.PlanFor(deployment, tier); ok { + limits = limits.FillUnset(base.Limits) + } + s := LicenseState{ - Status: res.State, - Reason: res.Reason, - Tier: res.License.Tier, - Limits: res.License.Limits, - Features: feats, - Source: source, + Status: res.State, + Reason: res.Reason, + Tier: res.License.Tier, + SupportLevel: res.License.SupportLevel, + Limits: limits, + Features: feats, + Source: source, } if !res.License.ExpiresAt.IsZero() { exp := res.License.ExpiresAt diff --git a/server/internal/services/licence_limits.go b/server/internal/services/licence_limits.go index eda74f9..b65d541 100644 --- a/server/internal/services/licence_limits.go +++ b/server/internal/services/licence_limits.go @@ -89,15 +89,39 @@ func CheckChannelLimit(instanceID string) error { return nil } +// CheckMonitorLimit refuses a new monitor when the instance is at its cap. +// +// Counts live rows only, like every other check here. An instance already over +// its cap keeps every monitor it has and they keep executing — the licence +// expiry story is that monitoring never stops, so truncating here would +// contradict it. +func CheckMonitorLimit(instanceID string) error { + st := GetLicenseState(instanceID) + ctx, cancel := limitCtx() + defer cancel() + + n, err := db.Col("monitors").CountDocuments(ctx, bson.M{"instance_id": instanceID}) + if err != nil { + return err + } + if !license.WithinLimit(int(n), st.Limits.MaxMonitors) { + return &LimitError{Limit: "max_monitors", Current: int(n), Max: st.Limits.MaxMonitors} + } + return nil +} + // LicenseUsage reports current counts, so the UI can say "12 of 3 servers" // honestly when an instance is over its cap rather than pretending. -func LicenseUsage(instanceID string) (servers, secretGroups, channels int) { +func LicenseUsage(instanceID string) (servers, monitors, secretGroups, channels int) { ctx, cancel := limitCtx() defer cancel() if n, err := db.Col("servers").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil { servers = int(n) } + if n, err := db.Col("monitors").CountDocuments(ctx, bson.M{"instance_id": instanceID}); err == nil { + monitors = int(n) + } var groups []string if err := db.Col("secrets").Distinct(ctx, "group", bson.M{"instance_id": instanceID}).Decode(&groups); err == nil { diff --git a/web/app/(app)/settings/license/page.tsx b/web/app/(app)/settings/license/page.tsx index 8a91c78..e4bfdb2 100644 --- a/web/app/(app)/settings/license/page.tsx +++ b/web/app/(app)/settings/license/page.tsx @@ -46,6 +46,43 @@ const SOURCE: Record = { none: "None installed", }; +/* + * The three stable identifiers, in prose. An unknown or empty value renders + * NOTHING rather than a fallback: a licence signed before support_level existed + * has no support level, and inventing one would promise something we have not + * sold. + */ +const SUPPORT_LABELS: Record = { + community: "Community support", + email_24_5: "Email support, 24/5", + email_call_24_7: "Email and phone support, 24/7", +}; + +/* + * Retention is a duration, not a cap, so it gets a fact rather than a meter. + * Allowance's bar needs a numerator and there isn't one — how much of a + * retention window have you "used"? + */ +function Retention({ days }: { days: number }) { + const forever = days === UNLIMITED; + return ( + +

+ Audit history +

+

+ {forever ? "Kept" : days} + + {forever ? "indefinitely" : "days"} + +

+

+ {forever ? "Never trimmed" : "Older entries removed daily"} +

+
+ ); +} + /** A keyed field on the record, the way a certificate prints them. */ function Keyed({ label, children }: { label: string; children: React.ReactNode }) { return ( @@ -125,6 +162,9 @@ function RecordPanel({ license }: { license: LicenseInfo }) { already carries the colour. A dot would be the third telling. */}

{s.label}

{license.tier &&

{license.tier} tier

} + {license.support_level && SUPPORT_LABELS[license.support_level] && ( +

{SUPPORT_LABELS[license.support_level]}

+ )} {license.reason &&

{REASON[license.reason] ?? license.reason}

} @@ -225,8 +265,10 @@ export default function LicensePage() {
+ +
diff --git a/web/lib/api.ts b/web/lib/api.ts index 2b70603..54063e7 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -802,11 +802,23 @@ export interface LicenseInfo { state: LicenseState; reason?: string; tier?: string; + support_level?: string; expires_at?: string; days_remaining?: number; - limits: { max_servers: number; max_secret_groups: number; max_channels: number }; + limits: { + max_servers: number; + max_monitors: number; + max_secret_groups: number; + max_channels: number; + audit_retention_days: number; + }; features: Record; - usage: { servers: number; secret_groups: number; channels: number }; + usage: { + servers: number; + monitors: number; + secret_groups: number; + channels: number; + }; source: string; /** "cloud" | "self_hosted". A cloud instance's licence is managed in HQ. */ deployment: string;