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;