Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4e6ad5485 | ||
|
|
3fc726da9e | ||
|
|
73b6b548f0 | ||
|
|
ff0caf5a90 | ||
|
|
87dc9fc858 |
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+111
-11
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+29
-1
@@ -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
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Record<string, CatalogueRow["price_ids"]>>({});
|
||||
|
||||
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 (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Catalogue"
|
||||
back={{ href: "/staff", label: "Operations" }}
|
||||
subtitle="Every priceable component. This is the only place a Paddle price ID lives."
|
||||
/>
|
||||
<PageFrame
|
||||
aside={
|
||||
<aside className="space-y-3 text-[0.82rem] text-ink-2">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
Free is priced by nothing and has no rows. That absence is what
|
||||
keeps it outside Paddle.
|
||||
</p>
|
||||
<p>
|
||||
Changing a price affects the next checkout only. It cannot touch an
|
||||
issued licence.
|
||||
</p>
|
||||
</aside>
|
||||
}
|
||||
>
|
||||
{isLoading ? (
|
||||
<p className="text-[0.85rem] text-ink-3">Loading…</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{groups.map((g) => {
|
||||
const [deployment, tier] = g.split("/");
|
||||
const terms = termsFor(deployment);
|
||||
return (
|
||||
<section key={g} className="space-y-2">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">
|
||||
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}{" "}
|
||||
{tier}
|
||||
</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[42rem] border-collapse text-[0.82rem]">
|
||||
<thead>
|
||||
<tr className="border-b border-rule text-left text-ink-3">
|
||||
<th className="py-2 pr-3 font-normal">Component</th>
|
||||
{ENVS.map((env) =>
|
||||
terms.map((t) => (
|
||||
<th
|
||||
key={`${env}-${t}`}
|
||||
className="py-2 pr-3 font-normal"
|
||||
>
|
||||
{env} / {t}
|
||||
</th>
|
||||
)),
|
||||
)}
|
||||
<th className="py-2 font-normal" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr
|
||||
key={k}
|
||||
className="border-b border-rule/60"
|
||||
>
|
||||
<td className="py-2 pr-3 text-ink">
|
||||
{componentLabel(r)}
|
||||
</td>
|
||||
{ENVS.map((env) =>
|
||||
terms.map((t) => (
|
||||
<td
|
||||
key={`${env}-${t}`}
|
||||
className="py-2 pr-3"
|
||||
>
|
||||
<input
|
||||
value={
|
||||
ids[env]?.[t] ?? ""
|
||||
}
|
||||
placeholder="pri_…"
|
||||
onChange={(e) =>
|
||||
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"
|
||||
/>
|
||||
</td>
|
||||
)),
|
||||
)}
|
||||
<td className="py-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!dirty || save.isPending
|
||||
}
|
||||
onClick={() =>
|
||||
save.mutate({
|
||||
...r,
|
||||
price_ids: ids,
|
||||
})
|
||||
}
|
||||
className="rounded border border-accent/50 px-2.5 py-1 text-[0.78rem] text-accent disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Tier>(deployment === "cloud" ? "professional" : "self_hosted");
|
||||
const [tier, setTier] = useState<Tier>("professional");
|
||||
const [term, setTerm] = useState("annual");
|
||||
const [newId, setNewId] = useState("");
|
||||
const [error, setError] = useState<string | undefined>();
|
||||
@@ -47,7 +45,7 @@ export function IssuePanel({
|
||||
>
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="self_hosted">Self Hosted</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
import { api, type InjectionState } from "@/lib/api";
|
||||
import { api, type Deployment, type InjectionState } from "@/lib/api";
|
||||
import { Ledger } from "@/components/Ledger";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
|
||||
import { IssuePanel } from "./IssuePanel";
|
||||
|
||||
const INJECTION: Record<InjectionState, { label: string; tone: string }> = {
|
||||
@@ -71,11 +73,112 @@ export default function StaffInstancePage() {
|
||||
<section className="grid gap-3 rounded border border-rule bg-panel p-5">
|
||||
<h2 className="text-xl">Licence history</h2>
|
||||
<Ledger licenses={data.licenses} />
|
||||
<IssuePanel
|
||||
instanceId={data.instance.instance_id}
|
||||
deployment={data.instance.deployment}
|
||||
/>
|
||||
<IssuePanel instanceId={data.instance.instance_id} />
|
||||
</section>
|
||||
|
||||
<EntitlementSection
|
||||
instanceId={data.instance.instance_id}
|
||||
deployment={data.instance.deployment}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntitlementSection({ instanceId, deployment }: { instanceId: string; deployment: Deployment }) {
|
||||
const qc = useQueryClient();
|
||||
const { data: plans = [] } = useQuery({
|
||||
queryKey: ["staff", "plans"],
|
||||
queryFn: api.staff.plans,
|
||||
});
|
||||
const { data: catalogue = [] } = useQuery({
|
||||
queryKey: ["staff", "catalogue"],
|
||||
queryFn: api.staff.catalogue,
|
||||
});
|
||||
const { data } = useQuery({
|
||||
queryKey: ["staff", "entitlement", instanceId],
|
||||
queryFn: () => api.staff.entitlement(instanceId),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const ent = data?.entitlement;
|
||||
const [draft, setDraft] = useState<PlanChoice | null>(null);
|
||||
const choice: PlanChoice =
|
||||
draft ??
|
||||
(ent
|
||||
? {
|
||||
tier: ent.tier,
|
||||
term: ent.term,
|
||||
servers: ent.desired.servers,
|
||||
features: ent.desired.features ?? [],
|
||||
}
|
||||
: { tier: "professional", term: deployment === "self_hosted" ? "annual" : "monthly", servers: 3, features: [] });
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (grant: boolean) =>
|
||||
api.staff.setEntitlement(instanceId, { ...choice, grant }),
|
||||
onSuccess: () => {
|
||||
setDraft(null);
|
||||
qc.invalidateQueries({ queryKey: ["staff", "entitlement", instanceId] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-rule bg-panel p-4">
|
||||
<header className="mb-3">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">Entitlement</h2>
|
||||
<p className="text-[0.78rem] text-ink-3">
|
||||
What this instance is allowed. A licence is signed from{" "}
|
||||
<em>granted</em>, never from <em>desired</em>.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{ent && data?.pending && (
|
||||
<p className="mb-3 rounded border border-warn/50 bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-2">
|
||||
Pending change — currently granted {ent.granted.servers} servers,
|
||||
configured for {ent.desired.servers}
|
||||
{ent.scheduled_change_at
|
||||
? `, effective ${new Date(ent.scheduled_change_at).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })}`
|
||||
: ""}
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<PlanConfigurator
|
||||
deployment={deployment}
|
||||
value={choice}
|
||||
plans={plans}
|
||||
catalogue={catalogue}
|
||||
onChange={setDraft}
|
||||
disabled={save.isPending}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate(false)}
|
||||
className="rounded border border-rule px-3 py-1.5 text-[0.85rem] text-ink-2 disabled:opacity-40"
|
||||
>
|
||||
Save as configured
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={save.isPending}
|
||||
onClick={() => save.mutate(true)}
|
||||
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
|
||||
>
|
||||
Save and grant
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-2 text-[0.72rem] text-ink-3">
|
||||
Granting takes effect on the next licence issued. It does not issue
|
||||
one.
|
||||
</p>
|
||||
{save.error && (
|
||||
<p className="mt-2 text-[0.82rem] text-expired">
|
||||
{String((save.error as Error).message)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const LINKS: NavLink[] = [
|
||||
{ href: "/staff/accounts", label: "Accounts" },
|
||||
{ href: "/staff/licenses", label: "Licences" },
|
||||
{ href: "/staff/plans", label: "Plans" },
|
||||
{ href: "/staff/catalogue", label: "Catalogue" },
|
||||
{ href: "/staff/audit", label: "Audit" },
|
||||
];
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ export default function LicensesPage() {
|
||||
<option value="">All tiers</option>
|
||||
<option value="free">Free</option>
|
||||
<option value="professional">Professional</option>
|
||||
<option value="self_hosted">Self Hosted</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
<option value="self_hosted">Self-Hosted (legacy)</option>
|
||||
</select>
|
||||
<select
|
||||
value={reason}
|
||||
|
||||
@@ -2,12 +2,115 @@
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { api, type Plan } from "@/lib/api";
|
||||
import { Button } from "@/components/Button";
|
||||
import { api, type Deployment, type Plan, type Tier } from "@/lib/api";
|
||||
import { ConfirmPlanChange } from "@/components/ConfirmPlanChange";
|
||||
import { limitLabel } from "@/lib/format";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
|
||||
const SUPPORT_LEVELS = [
|
||||
{ value: "community", label: "Community" },
|
||||
{ value: "email_24_5", label: "Email, 24/5" },
|
||||
{ value: "email_call_24_7", label: "Email + call, 24/7" },
|
||||
] as const;
|
||||
|
||||
const LIMIT_FIELDS = [
|
||||
{ key: "max_servers", label: "Servers" },
|
||||
{ key: "max_monitors", label: "Monitors" },
|
||||
{ key: "max_secret_groups", label: "Secret groups" },
|
||||
{ key: "max_channels", label: "Channels" },
|
||||
{ key: "audit_retention_days", label: "Audit history (days)" },
|
||||
] as const;
|
||||
|
||||
/*
|
||||
* -1 is Unlimited everywhere in the licence payload, so the form takes it
|
||||
* literally rather than inventing a checkbox. A staff screen that hides the
|
||||
* sentinel is a staff screen where nobody can tell whether a plan says
|
||||
* unlimited or nothing at all.
|
||||
*/
|
||||
function AllowanceForm({
|
||||
plan,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
plan: Plan;
|
||||
onSave: (next: Plan) => void;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Plan>(plan);
|
||||
const dirty = JSON.stringify(draft) !== JSON.stringify(plan);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{LIMIT_FIELDS.map((f) => (
|
||||
<label key={f.key} className="block">
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">
|
||||
{f.label}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={draft.base_limits[f.key]}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
base_limits: {
|
||||
...draft.base_limits,
|
||||
[f.key]: Number(e.target.value),
|
||||
},
|
||||
})
|
||||
}
|
||||
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
/>
|
||||
<span className="mt-0.5 block text-[0.72rem] text-ink-3">
|
||||
−1 is unlimited
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">
|
||||
Support level
|
||||
</span>
|
||||
<select
|
||||
value={draft.support_level}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, support_level: e.target.value })
|
||||
}
|
||||
className="w-full rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
>
|
||||
{SUPPORT_LEVELS.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-[0.85rem] text-ink-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.active}
|
||||
onChange={(e) => setDraft({ ...draft, active: e.target.checked })}
|
||||
/>
|
||||
Offered to customers
|
||||
</label>
|
||||
|
||||
<p className="text-[0.78rem] text-ink-3">
|
||||
Changes apply to licences issued from now on. Existing licences
|
||||
snapshotted their plan and are unaffected.
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => onSave(draft)}
|
||||
className="rounded border border-accent/50 px-3 py-1.5 text-[0.85rem] text-accent disabled:opacity-40"
|
||||
>
|
||||
{saving ? "Saving…" : "Save allowances"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<Plan | null>(null);
|
||||
const [saving, setSaving] = useState<string | null>(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 (
|
||||
<div className="grid gap-6">
|
||||
<PageHeader
|
||||
title="Plans"
|
||||
subtitle="The authoritative tier table. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
|
||||
subtitle="The authoritative tier table — six plans, two deployments by three tiers, base allowances only. Every issued licence snapshots the plan it was cut from, so editing one never rewrites an existing licence."
|
||||
/>
|
||||
|
||||
{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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
{(plans.data ?? []).map((p) => (
|
||||
<section
|
||||
key={p.tier}
|
||||
className="grid gap-3 rounded border border-rule bg-panel p-5"
|
||||
>
|
||||
<h2 className="text-xl">{p.name}</h2>
|
||||
<dl className="grid gap-1 font-mono text-[0.82rem] tabular-nums text-ink-2">
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>servers</dt>
|
||||
<dd>{limitLabel(p.limits.max_servers)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>secret groups</dt>
|
||||
<dd>
|
||||
{limitLabel(p.limits.max_secret_groups)}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>channels</dt>
|
||||
<dd>{limitLabel(p.limits.max_channels)}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<dt>features</dt>
|
||||
<dd>{p.features?.join(", ") || "none"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* Guard rail two: deployment is shown, never edited. */}
|
||||
<p className="flex items-center gap-2 rounded border border-rule bg-panel-2 px-2.5 py-2 text-[0.82rem] text-ink-3">
|
||||
<span aria-hidden="true">🔒</span>
|
||||
<span>
|
||||
Deployment is fixed at{" "}
|
||||
<b className="font-mono">{p.deployment}</b>.
|
||||
Moving a tier between cloud and self-hosted is a
|
||||
code change, not a form field.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
...p,
|
||||
limits: { ...p.limits, max_servers: 7 },
|
||||
})
|
||||
}
|
||||
{(["cloud", "self_hosted"] as const).map((deployment: Deployment) => (
|
||||
<section key={deployment} className="space-y-3">
|
||||
<h2 className="text-[0.95rem] font-medium text-ink">
|
||||
{deployment === "cloud" ? "Cloud" : "Self-Hosted"}
|
||||
</h2>
|
||||
{(plans.data ?? [])
|
||||
.filter((p) => p.deployment === deployment)
|
||||
.map((p) => (
|
||||
<article
|
||||
key={`${p.deployment}/${p.tier}`}
|
||||
className="rounded-lg border border-rule bg-panel p-4"
|
||||
>
|
||||
Cap servers at 7
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="line"
|
||||
onClick={() =>
|
||||
setDraft({
|
||||
...p,
|
||||
features: p.features.filter(
|
||||
(f) => f !== "oidc",
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
Remove OIDC
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
<header className="mb-3 flex items-baseline justify-between gap-3">
|
||||
<h3 className="text-[0.9rem] font-medium text-ink">
|
||||
{p.name}
|
||||
</h3>
|
||||
<span className="font-mono text-[0.75rem] text-ink-3">
|
||||
{p.deployment}/{p.tier}
|
||||
</span>
|
||||
</header>
|
||||
<AllowanceForm
|
||||
plan={p}
|
||||
saving={saving === `${p.deployment}/${p.tier}`}
|
||||
onSave={(next: Plan) => setDraft(next)}
|
||||
/>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="text-[0.78rem] text-ink-3">Tier</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{available.map((p) => (
|
||||
<button
|
||||
key={p.tier}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...value,
|
||||
tier: p.tier,
|
||||
/* Moving tier moves the floor, so clamp up
|
||||
* rather than leaving an invalid count the
|
||||
* backend would refuse. */
|
||||
servers: Math.max(value.servers, p.base_limits.max_servers),
|
||||
})
|
||||
}
|
||||
className={`rounded border px-3 py-1.5 text-[0.85rem] ${
|
||||
p.tier === value.tier
|
||||
? "border-accent text-accent"
|
||||
: "border-rule text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="text-[0.78rem] text-ink-3">Term</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{termsFor(deployment).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange({ ...value, term: t })}
|
||||
className={`rounded border px-3 py-1.5 text-[0.85rem] ${
|
||||
t === value.term
|
||||
? "border-accent text-accent"
|
||||
: "border-rule text-ink-2"
|
||||
}`}
|
||||
>
|
||||
{t === "monthly" ? "Monthly" : "Annual"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{deployment === "self_hosted" && (
|
||||
<p className="text-[0.72rem] text-ink-3">
|
||||
Self-hosted is annual only.
|
||||
</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-[0.78rem] text-ink-3">Servers</span>
|
||||
<input
|
||||
type="number"
|
||||
min={base}
|
||||
value={value.servers}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, servers: Number(e.target.value) })
|
||||
}
|
||||
className="w-28 rounded border border-rule bg-panel px-2 py-1.5 text-[0.85rem] text-ink"
|
||||
/>
|
||||
<span className="ml-2 text-[0.78rem] text-ink-3">
|
||||
{base} included{extra > 0 ? `, ${extra} extra` : ""}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{featureRows.length > 0 && (
|
||||
<fieldset className="space-y-1.5">
|
||||
<legend className="text-[0.78rem] text-ink-3">Features</legend>
|
||||
{featureRows.map((r) => {
|
||||
const key = r.feature_key!;
|
||||
const on = value.features.includes(key);
|
||||
const priced = priceOf(r) !== "";
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className="flex items-center gap-2 text-[0.85rem] text-ink-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={on}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
...value,
|
||||
features: e.target.checked
|
||||
? [...value.features, key]
|
||||
: value.features.filter((f) => f !== key),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{key === "console" ? "Browser console" : "Single sign-on"}</span>
|
||||
<span className="text-[0.72rem] text-ink-3">
|
||||
{priced ? "paid add-on" : "included"}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+56
-11
@@ -61,7 +61,8 @@ const del = <T,>(path: string) => req<T>(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<string, string>;
|
||||
/* 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<string, Partial<Record<Term, string>>>;
|
||||
}
|
||||
|
||||
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<string, string>) =>
|
||||
req<License[]>(`/api/staff/licenses${params ? `?${new URLSearchParams(params)}` : ""}`),
|
||||
plans: () => req<Plan[]>("/api/staff/plans"),
|
||||
updatePlan: (tier: Tier, plan: Omit<Plan, "tier" | "deployment">) =>
|
||||
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<CatalogueRow[]>("/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<AuditEntry[]>(`/api/staff/audit${accountId ? `?account_id=${accountId}` : ""}`),
|
||||
injectionHealth: () =>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,455 @@
|
||||
# Metered Licensing — Design
|
||||
|
||||
**Status:** designed 2026-07-26. Supersedes parts of spec 5 (paddle-billing) and
|
||||
the tier table in [`README.md`](README.md).
|
||||
|
||||
**Goal:** turn the licence from a snapshot of a fixed tier into a snapshot of
|
||||
what one customer configured and paid for. Two deployments times three tiers,
|
||||
servers metered per month, features opted into individually, all of it
|
||||
self-service in Vantage HQ.
|
||||
|
||||
**Why now:** spec 5 is designed but not implemented — `admin/internal/paddle`
|
||||
and `admin/internal/billing` do not exist. Its `Subscription` struct, its
|
||||
`plans.paddle_price_ids` shape, its single-price checkout and its
|
||||
`ApplySubscription` all assume one price per subscription, and a metered plan has
|
||||
several. Folding this in now costs a revision of an unstarted plan; folding it in
|
||||
later would cost a rewrite of shipped billing code.
|
||||
|
||||
---
|
||||
|
||||
## The pricing model
|
||||
|
||||
Two deployments, three tiers, six plans.
|
||||
|
||||
| | servers | monitors | secret groups | channels | audit history | console | SSO | support |
|
||||
|---|---|---|---|---|---|---|---|---|
|
||||
| **Free** | 3 | 3 | 1 | 1 | 30 days | — | — | community |
|
||||
| **Professional** | 3 + N | ∞ | ∞ | ∞ | 365 days | opt-in | opt-in | email, 24/5 |
|
||||
| **Enterprise** | 10 + N | ∞ | ∞ | ∞ | ∞ | opt-in | opt-in | email + call, 24/7 |
|
||||
|
||||
The allowances are identical in both deployments. What differs is the term:
|
||||
|
||||
| | monthly | annual |
|
||||
|---|---|---|
|
||||
| Cloud Free | — | yes, renewed from HQ |
|
||||
| Cloud Professional | yes | yes |
|
||||
| Cloud Enterprise | yes | yes |
|
||||
| Self-Hosted Free | — | yes, renewed from HQ |
|
||||
| Self-Hosted Professional | — | yes |
|
||||
| Self-Hosted Enterprise | — | yes |
|
||||
|
||||
**Self-Hosted stays annual-only, for the reason already written into
|
||||
`shared/license/license.go`:** an offline licence cannot be revoked, so the term
|
||||
length *is* the revocation window. A self-hosted monthly licence would renew that
|
||||
unrevokable window twelve times a year for no commercial gain. A resolved
|
||||
self-hosted monthly price is therefore a configuration error and must fail loudly
|
||||
rather than issue.
|
||||
|
||||
**Servers are the only metered dimension.** Everything above Free is unlimited
|
||||
except audit history. This was a deliberate narrowing: an earlier draft sold
|
||||
secret groups in blocks of five, and dropping it leaves one number for a customer
|
||||
to understand and one line item on an invoice.
|
||||
|
||||
**Enterprise is self-service at a published price**, bought through the same
|
||||
configurator as Professional. The 24/7 phone commitment is an operational promise
|
||||
we make, not a technical gate we build.
|
||||
|
||||
**Support level is not enforced by anything.** It is carried for display, and
|
||||
that is the whole of its job.
|
||||
|
||||
---
|
||||
|
||||
## What breaks, and must be fixed in the same change
|
||||
|
||||
Three invariants stop being true. Each is load-bearing today.
|
||||
|
||||
**`plans` is keyed on `tier` alone.** It becomes `(deployment, tier)` with a
|
||||
unique index on the pair. `license.PlanFor(tier)` becomes
|
||||
`PlanFor(deployment, tier)`.
|
||||
|
||||
**Free is cloud-only by construction.** The single comparison in
|
||||
`licensing.Issue` — `plan.Deployment != inst.Deployment` — is what enforces it
|
||||
today, because Free's only plan row says `cloud`. With a self-hosted Free row
|
||||
that comparison stops meaning "Free is cloud-only" and starts meaning only "the
|
||||
plan row matches the instance". The paragraph in `shared/license/plans.go`
|
||||
claiming construction-level enforcement must go, because it is no longer true.
|
||||
|
||||
**`checkFreeLimit` counts Free instances per account.** It must count per account
|
||||
*and deployment*, or a customer holding a cloud Free instance is refused a
|
||||
self-hosted Free one with a message about a limit they have not reached.
|
||||
|
||||
---
|
||||
|
||||
## Data model
|
||||
|
||||
### `plans` — the tier definition
|
||||
|
||||
Loses `paddle_product_id` and `paddle_price_ids` entirely; those move to
|
||||
`catalogue`. Safe to delete because nothing has ever written to them.
|
||||
|
||||
```
|
||||
{deployment: "cloud", tier: "professional", name: "Professional",
|
||||
base_limits: {max_servers: 3, max_monitors: -1, max_secret_groups: -1,
|
||||
max_channels: -1, audit_retention_days: 365},
|
||||
base_features: [], support_level: "email_24_5", active: true}
|
||||
```
|
||||
|
||||
`base_limits` replaces `limits`: it is the allowance before anything is bought,
|
||||
which is a different claim from the one the old field made. `base_features` is
|
||||
what the tier includes without opting in — empty for all six plans today, because
|
||||
console and SSO are both opt-in, but the field is what lets a future tier bundle
|
||||
one.
|
||||
|
||||
### `catalogue` — every priceable component
|
||||
|
||||
The only place a Paddle price ID appears anywhere in the system.
|
||||
|
||||
```
|
||||
{kind: "base", deployment: "cloud", tier: "professional",
|
||||
price_ids: {sandbox: {monthly: "pri_…", annual: "pri_…"},
|
||||
production: {monthly: "pri_…", annual: "pri_…"}}}
|
||||
|
||||
{kind: "limit", deployment: "cloud", tier: "professional", limit_key: "max_servers",
|
||||
price_ids: {sandbox: {monthly: "pri_…", annual: "pri_…"}, production: {…}}}
|
||||
|
||||
{kind: "feature", deployment: "cloud", tier: "professional", feature_key: "console",
|
||||
price_ids: {}}
|
||||
|
||||
{kind: "feature", deployment: "cloud", tier: "professional", feature_key: "oidc",
|
||||
price_ids: {}}
|
||||
```
|
||||
|
||||
Unique index on `(deployment, tier, kind, limit_key, feature_key)`.
|
||||
|
||||
- **`kind: "base"`** is the plan's own fee, always quantity 1.
|
||||
- **`kind: "limit"`** raises a named limit by one per quantity. `limit_key` is a
|
||||
field name in `license.Limits`, so adding metered channels later is a catalogue
|
||||
row and no code. There is deliberately **no `block_size` field**: with
|
||||
secret-group blocks dropped it would be `1` in every row that will ever exist.
|
||||
- **`kind: "feature"`** is a feature key. **An empty `price_ids` means free to
|
||||
toggle.** A price appearing later is a staff edit in the plans UI, not a
|
||||
migration and not a deploy — which is the whole reason features are catalogue
|
||||
rows rather than a list on the plan.
|
||||
|
||||
A self-hosted row simply has no `monthly` key. Nesting by environment before term
|
||||
keeps promoting sandbox to production a configuration change, as spec 5 already
|
||||
established.
|
||||
|
||||
### `entitlements` — one row per instance
|
||||
|
||||
The customer's configuration. Both the subscription and the licence are derived
|
||||
from it; it is derived from nothing.
|
||||
|
||||
```
|
||||
{instance_id: "uuid", account_id: "uuid",
|
||||
deployment: "cloud", tier: "professional", term: "monthly",
|
||||
|
||||
desired: {servers: 10, features: ["console"]},
|
||||
granted: {servers: 5, features: []},
|
||||
|
||||
resolved_limits: {max_servers: 5, max_monitors: -1, max_secret_groups: -1,
|
||||
max_channels: -1, audit_retention_days: 365},
|
||||
|
||||
granted_at, updated_at, scheduled_change_at}
|
||||
```
|
||||
|
||||
Unique index on `instance_id`.
|
||||
|
||||
**`desired` is what they asked for; `granted` is what a payment confirmed.** The
|
||||
checkout and the subscription update are built from `desired`. A licence is only
|
||||
ever signed from `granted`. An abandoned checkout therefore leaves a `desired`
|
||||
that reached no licence, which is harmless, and HQ can say "pending change"
|
||||
truthfully instead of guessing.
|
||||
|
||||
**`resolved_limits` is stored, not derived on read.** It is `plan.base_limits`
|
||||
with `granted.servers` folded in, and it is what `Issue` snapshots. Storing it
|
||||
keeps the fold in exactly one place; deriving it at every read would put the
|
||||
arithmetic in the issuer, the portal and the staff console.
|
||||
|
||||
**Free gets a row at instance creation** with `desired == granted` and no
|
||||
subscription. Every one of the six cases then reads the same shape, and licence
|
||||
issuance has one path rather than a Free branch.
|
||||
|
||||
### `license.Limits` gains two fields
|
||||
|
||||
```go
|
||||
type Limits struct {
|
||||
MaxServers int `json:"max_servers"`
|
||||
MaxMonitors int `json:"max_monitors"`
|
||||
MaxSecretGroups int `json:"max_secret_groups"`
|
||||
MaxChannels int `json:"max_channels"`
|
||||
AuditRetentionDays int `json:"audit_retention_days"`
|
||||
}
|
||||
```
|
||||
|
||||
`MaxMonitors` behaves exactly like the existing counts. `AuditRetentionDays` is a
|
||||
new kind of limit — a duration rather than a cap — and `Unlimited` means never
|
||||
trim.
|
||||
|
||||
### `license.License` gains `SupportLevel string`
|
||||
|
||||
Display-only, exactly as `InstanceName` already is. It goes in the signed payload
|
||||
rather than being fetched from HQ so that `/settings/license` can state the
|
||||
support level on an air-gapped install, which is the one deployment most likely
|
||||
to need to know who to call.
|
||||
|
||||
### `models` additions
|
||||
|
||||
`ReasonEntitlementChange = "entitlement_change"` joins the issuance reasons.
|
||||
Reasons end up in support conversations, so a mid-term server addition must not
|
||||
be filed as a renewal — a renewal resets `relink_count`, and adding a server is
|
||||
not a new term.
|
||||
|
||||
---
|
||||
|
||||
## Resolution
|
||||
|
||||
Two folds, in one package (`admin/internal/catalogue`), so the arithmetic exists
|
||||
once.
|
||||
|
||||
**To a licence.** `Resolve(plan, granted) → (license.Limits, []string)`:
|
||||
start from `plan.base_limits`, and for each `kind: "limit"` row add the
|
||||
configured quantity to `limit_key`. `granted.servers` is the *total* the customer
|
||||
sees, so the quantity billed is `servers - plan.base_limits.max_servers` and the
|
||||
resolved limit is `servers`. Features are `plan.base_features` plus
|
||||
`granted.features`, deduplicated, filtered to keys the catalogue actually offers
|
||||
for that `(deployment, tier)` — a stale feature key in a stored entitlement must
|
||||
not survive into a signed payload.
|
||||
|
||||
**To Paddle line items.** `LineItems(env, deployment, tier, term, desired) → []Item`:
|
||||
the base row at quantity 1, the server row at quantity
|
||||
`desired.servers - base_limits.max_servers`, and one item per desired feature
|
||||
that has a price ID in this environment and term. A feature with no price ID
|
||||
produces no line item and is granted for free. A quantity of zero produces no
|
||||
line item at all, so a Professional customer at exactly 3 servers has a
|
||||
single-item subscription.
|
||||
|
||||
**Reverse resolution replaces spec 5's `ResolvePriceID`.** A metered subscription
|
||||
has several prices, and only one of them identifies the plan. Given the full item
|
||||
list from a webhook:
|
||||
|
||||
1. Find the item whose price ID matches a `kind: "base"` row. That row gives
|
||||
`deployment`, `tier` and — by which term key matched — `term`.
|
||||
2. Sum the quantities of items matching that plan's `kind: "limit"` rows.
|
||||
3. Collect the feature keys of items matching its `kind: "feature"` rows.
|
||||
4. Any item matching nothing is a configuration error: fail the event loudly so
|
||||
it lands on the staff dashboard. Guessing a tier from a price we cannot map is
|
||||
how a customer ends up with the wrong licence and no record of why.
|
||||
|
||||
Only the running `PADDLE_ENV`'s IDs are consulted, so a production process cannot
|
||||
be talked into resolving a sandbox price. That property is spec 5's and survives
|
||||
unchanged.
|
||||
|
||||
**Out-of-order delivery is still handled by construction.** Paddle sends the
|
||||
complete item list on every subscription event, so a handler that reads the whole
|
||||
list is still a function of current state rather than of a transition. Nothing
|
||||
about metering weakens this.
|
||||
|
||||
---
|
||||
|
||||
## Issuance
|
||||
|
||||
`licensing.Issue` reads the entitlement row for the instance and snapshots
|
||||
`resolved_limits` and `granted.features`. When no row exists it falls back to the
|
||||
plan's base — which covers staff manual issuance and any instance predating the
|
||||
backfill.
|
||||
|
||||
`Issue` stays the only signer, and it stays the thing that does not deliver.
|
||||
|
||||
**Upgrades preserve the expiry.** A mid-term server addition passes
|
||||
`ExpiresAt` = the current licence's expiry, so the licence is reissued with a
|
||||
larger cap and the same end date. It must not extend the term: the customer paid
|
||||
a prorated amount for the rest of this period, not for a new one. Note that the
|
||||
current expiry already includes `GracePeriod`, so nothing adds it again —
|
||||
`ExpiresAt` overriding `Term` is exactly the existing contract.
|
||||
|
||||
**Reductions issue nothing.** They live in `desired` with `scheduled_change_at`
|
||||
set until the renewal webhook promotes `desired` into `granted` and issues the
|
||||
next term at the lower cap. The customer keeps what they paid for to the end of
|
||||
the period, there is no refund to reason about, and no licence ever shortens —
|
||||
which is the rule spec 5 states and this design does not touch.
|
||||
|
||||
---
|
||||
|
||||
## Changing a live subscription
|
||||
|
||||
`PUT /api/instances/:id/entitlement` writes `desired`, then calls Paddle:
|
||||
|
||||
- **An increase** updates the subscription items prorated immediately. The
|
||||
resulting `subscription.updated` webhook promotes `granted` and reissues.
|
||||
- **A decrease** schedules the item change for the next billing period and sets
|
||||
`scheduled_change_at`. No licence action now.
|
||||
|
||||
This is admin's **first outbound Paddle call beyond the portal session**, and
|
||||
spec 5 currently states it has none. That statement changes. The important part
|
||||
does not: **the webhook remains the only thing that promotes `granted` or issues
|
||||
a licence.** The endpoint writes `desired` and asks Paddle for a change; it never
|
||||
grants anything itself. A customer whose card is declined on a prorated upgrade
|
||||
gets no licence, which is correct, and admin needs no compensating logic to
|
||||
achieve it.
|
||||
|
||||
A tier change (Professional to Enterprise) is the same call with a different base
|
||||
price, and issues with `ReasonTierChange` as it already would.
|
||||
|
||||
---
|
||||
|
||||
## Control-plane enforcement
|
||||
|
||||
**Feature gating already exists and is already mounted.** `RequireFeature` in
|
||||
`server/internal/api/licence.go` answers 403 `feature_unavailable`, and
|
||||
`server/internal/api/handlers.go` already wraps `POST /api/console/connect`,
|
||||
`GET /api/console/tunnel` and `GET`/`PUT /api/org/oidc` in it. Free's feature list
|
||||
is empty, so a Free instance already cannot open the console. **No capability is
|
||||
taken away from an existing tenant by this spec, and no customer email is owed.**
|
||||
|
||||
**One gap remains, and it is a single check.** `HandleOIDCStart` already tests
|
||||
`Feature("oidc")` and redirects to `/login?error=oidc_unavailable`.
|
||||
`HandleOIDCCallback` does not test it at all. A start that 403s is a dead end; an
|
||||
ungated callback completes a sign-in, so the unguarded half is the half that
|
||||
matters.
|
||||
|
||||
The callback cannot copy the start's instance resolution: the start reads
|
||||
`InstanceFromHost(c)`, while the callback resolves the instance from the OAuth
|
||||
state it consumes, and by then it holds `instanceID` directly. The check goes
|
||||
after `ConsumeStateInstance` and before `providerForInstance`, so a licence that
|
||||
lapsed mid-flow stops the exchange rather than completing it.
|
||||
|
||||
`web/` hides the Console button and the SSO card when the feature is absent, but
|
||||
as everywhere else in this codebase the API is the boundary and the UI is the
|
||||
courtesy.
|
||||
|
||||
**`CheckMonitorLimit`** joins the three existing checks in
|
||||
`server/internal/services/licence_limits.go`, counting `monitors` for the
|
||||
instance. Same shape: refuse a new one at the cap, never truncate what exists.
|
||||
`LicenseUsage` reports monitors alongside the other counts.
|
||||
|
||||
**Audit retention is new work.** Nothing trims `audit_logs` today. A daily sweep
|
||||
deletes entries older than the licence's `AuditRetentionDays` per instance;
|
||||
`Unlimited` skips the instance entirely. It is modelled on the existing workflow
|
||||
log retention sweep, and it is the one item in this design that deletes customer
|
||||
data — so it must read the *current* licence's value each run rather than caching
|
||||
it, and an instance whose licence has lapsed must not be swept on the expired
|
||||
term's allowance.
|
||||
|
||||
**Degraded mode is unchanged.** Expiry still stops mutations and leaves monitors
|
||||
executing, alerts firing and agents keyed. A feature gate is a mutation gate for
|
||||
console and SSO, so it behaves the same way.
|
||||
|
||||
---
|
||||
|
||||
## HQ, the configurator
|
||||
|
||||
One screen, reached from an instance in `InstanceRecord` and from the
|
||||
self-hosted purchase page.
|
||||
|
||||
```
|
||||
Deployment ( ) Cloud (•) Self-Hosted ← fixed after creation
|
||||
Tier ( ) Free (•) Professional ( ) Enterprise
|
||||
Term (•) Annual ← monthly hidden for self-hosted
|
||||
Servers [ 10 ] base 3 included, 7 extra
|
||||
Features [x] Browser console
|
||||
[ ] Single sign-on
|
||||
─────────────────────────────────────────────
|
||||
£B + 7 × £S per year
|
||||
[ Continue to payment ]
|
||||
```
|
||||
|
||||
It is one component in both places, driven by the catalogue rather than by
|
||||
anything hardcoded — a feature that gains a price shows its price with no
|
||||
frontend change, which is the point of the catalogue being data.
|
||||
|
||||
**Existing subscriptions show `desired` and `granted` when they differ:** "10
|
||||
servers, dropping to 5 on 12 August". A pending reduction is a fact about the
|
||||
account and belongs on the screen, not only in Paddle.
|
||||
|
||||
**Choosing Free skips payment entirely.** With no catalogue rows there is no
|
||||
checkout to open, so the configurator's Continue button links a UUID and issues
|
||||
directly. For cloud that is the shipped `POST /api/instances`, untouched. For
|
||||
self-hosted Free it is the existing link flow with no subscription attached — a
|
||||
new path, and the only place in the system where an instance is licensed without
|
||||
either a payment or a staff action. It is bounded by the same one-Free-per-account
|
||||
rule, now scoped per deployment.
|
||||
|
||||
**The staff plans editor** edits `plans` (allowances, support level, active) and
|
||||
`catalogue` (price IDs per environment and term) as two tables. This replaces
|
||||
spec 5's price-ID editor, which was built for a single map on the plan row.
|
||||
|
||||
Follows `adminsite/`'s existing shell without exception: `PageHeader` with its
|
||||
record line, `PageFrame`'s main-plus-rail split, tokens only and no hex values,
|
||||
light default. Price and server count read as text as well as position, since
|
||||
state never reads by colour alone here.
|
||||
|
||||
---
|
||||
|
||||
## Migration
|
||||
|
||||
Admin has no migrations collection: `models.Backfill` runs every boot and is
|
||||
idempotent by filtering on the absence of what it writes. This all goes there.
|
||||
|
||||
1. **Seed six plan rows** from `shared/license/plans.go`, `$setOnInsert` only, so
|
||||
staff edits to allowances survive a redeploy — the existing `SeedPlans` rule.
|
||||
2. **Re-key existing plan rows.** The three current rows are keyed by tier alone.
|
||||
`free` and `professional` gain `deployment: "cloud"`. The row with tier
|
||||
`self_hosted` becomes `deployment: "self_hosted", tier: "professional"`.
|
||||
3. **Re-tier existing self-hosted instances and their entitlements.** Instances
|
||||
holding `tier: "self_hosted"` become `tier: "professional"`; their deployment
|
||||
already says so.
|
||||
4. **`license.TierSelfHosted` is kept as a legacy constant** that no new licence
|
||||
uses. Licences already issued carry `tier: "self_hosted"` in a signed payload
|
||||
we cannot rewrite, and the server reads limits and features from the payload
|
||||
rather than from the tier name — so they keep working untouched. This is
|
||||
exactly what "the server never branches on tier name" was for.
|
||||
5. **Backfill an entitlement row per instance** from its current licence:
|
||||
`granted.servers` from `limits.max_servers` (`Unlimited` maps to the plan
|
||||
base, since an unlimited licence bought no server units), `granted.features`
|
||||
from the licence's features, `desired` equal to `granted`.
|
||||
6. **Seed the catalogue** with sixteen rows — the four paid plans times a `base`,
|
||||
a `limit: max_servers`, a `feature: console` and a `feature: oidc` — price IDs
|
||||
empty. **The two Free plans get no catalogue rows at all**, which is what keeps
|
||||
Free outside Paddle: there is nothing to price, so no checkout can be built. Empty price IDs mean checkout refuses until staff paste them, which is
|
||||
the correct failure: a checkout that silently picks the wrong price is worse
|
||||
than one that will not open.
|
||||
|
||||
Existing licences are not reissued. `MaxMonitors` and `AuditRetentionDays` are
|
||||
absent from their payloads and decode as `0`, which would read as "no monitors,
|
||||
trim everything". **Zero must therefore be treated as unset on decode** and
|
||||
filled from the plan base — a licence signed before a field existed cannot be
|
||||
allowed to mean the most restrictive possible value of it. This is the one
|
||||
sharp edge in the whole migration and it is worth a comment at the decode site.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Paid feature add-ons.** The model supports one — a `price_ids` entry on a
|
||||
`kind: "feature"` row — but no feature has a price at launch.
|
||||
- **Metered channels, monitors or secret groups.** A catalogue row away, and
|
||||
deliberately not taken.
|
||||
- **Usage-based billing.** Servers are a configured cap, not a measured count. We
|
||||
never bill for what an instance ran; we bill for what it is allowed to run.
|
||||
- **Refunds and credits.** Paddle's, and only Paddle's.
|
||||
- **Enterprise contract terms, POs and invoicing.** Card only at launch.
|
||||
- **Anything that revokes or shortens a licence.** Offline verification means
|
||||
this is not that kind of system, and no part of this design changes it.
|
||||
|
||||
---
|
||||
|
||||
## Done when
|
||||
|
||||
- Six plan rows exist, keyed on `(deployment, tier)`, and a customer can buy any
|
||||
of the four paid combinations from the configurator.
|
||||
- A Professional cloud customer can go from 3 to 10 servers and see the new cap
|
||||
in `web/` without waiting for a renewal.
|
||||
- The same customer can reduce to 5 and see both the current cap and the date it
|
||||
drops, with their licence untouched until then.
|
||||
- Free self-hosted can be created, renewed from HQ, and lapses to read-only
|
||||
without being reaped.
|
||||
- Unticking Browser console removes it from the next issued licence, and
|
||||
`POST /api/console/connect` answers 403 on an instance whose licence lacks it
|
||||
(already true; the new part is that a customer controls the tick).
|
||||
- `/auth/oidc/callback` answers 403 on an instance whose licence lacks `oidc`.
|
||||
- A monitor beyond the cap is refused with a machine-readable 403.
|
||||
- `audit_logs` older than the licence's retention are gone, and an unlimited
|
||||
licence's are not.
|
||||
- Every price ID in the running environment resolves to a plan, and a webhook
|
||||
naming one that does not fails loudly onto the staff dashboard.
|
||||
@@ -10,13 +10,18 @@ Build in this order. Specs 0a–5 were designed 2026-07-24; spec 6 on 2026-07-26
|
||||
| 2 | [instance-licensing](2026-07-24-instance-licensing-design.md) | [plan](../plans/2026-07-24-instance-licensing.md) | **shipped**, no grandfathering — existing cloud instances are read-only until admin backfills |
|
||||
| 3 | [admin-backend](2026-07-24-admin-backend-design.md) | [plan](../plans/2026-07-24-admin-backend.md) | **shipped**, verified end to end against scratch databases |
|
||||
| 4 | [admin-site](2026-07-24-admin-site-design.md) | — | ready to start |
|
||||
| 5 | [paddle-billing](2026-07-24-paddle-billing-design.md) | — | ready to start; its "signup migration off sitesvc" section is superseded by 6 |
|
||||
| 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) | [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.
|
||||
|
||||
4 and 5 can run in parallel once 3 lands.
|
||||
7 lands before 5. It re-keys `plans` on `(deployment, tier)`, moves every Paddle
|
||||
price ID out of `plans` into a new `catalogue` collection, and adds the
|
||||
`entitlements` collection that both a subscription and a licence are derived from
|
||||
— all of which plan 5 builds on top of, so building 5 first would mean writing
|
||||
its billing code twice.
|
||||
|
||||
## The shape
|
||||
|
||||
@@ -53,6 +58,11 @@ branches on tier name. Tier contents live in the admin `plans` table and are
|
||||
snapshotted into each issued licence, so editing a plan never rewrites history —
|
||||
the same rule as `workflow_runs.steps_snapshot`.
|
||||
|
||||
Spec 7 replaces the three-tier table below with two deployments times three
|
||||
tiers, and makes the server count a metered quantity rather than a fixed
|
||||
allowance. See [metered-licensing](2026-07-26-metered-licensing-design.md) for
|
||||
the current grid. As shipped through spec 3, the table is:
|
||||
|
||||
| | Free | Professional | Self Hosted |
|
||||
|---|---|---|---|
|
||||
| deployment | cloud only | cloud | self-hosted |
|
||||
@@ -67,6 +77,10 @@ Free is cloud-only by construction: it is only ever signed with
|
||||
`deployment: "cloud"`, and verification rejects a deployment mismatch. There is
|
||||
no server-side flag to edit. One Free instance per account.
|
||||
|
||||
**Spec 7 ends that construction-level guarantee** — there is a self-hosted Free
|
||||
plan, so `plan.Deployment != inst.Deployment` no longer implies it, and the Free
|
||||
limit becomes one per account *per deployment*.
|
||||
|
||||
**Existing cloud tenants are not grandfathered.** The migration that would have
|
||||
done it was removed before plan 2 shipped, so every existing cloud instance is
|
||||
read-only until it is licensed by hand through the admin service: attach it to an
|
||||
|
||||
@@ -88,6 +88,7 @@ func main() {
|
||||
}
|
||||
|
||||
services.StartLogSweeper()
|
||||
services.StartAuditSweeper()
|
||||
|
||||
redisAddr := getEnv("REDIS_ADDR", "localhost:6379")
|
||||
if err := auth.InitRedis(redisAddr); err != nil {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -68,7 +68,8 @@ func issue(args []string) {
|
||||
instanceID := fs.String("instance-id", "", "instance UUID the licence is bound to (required)")
|
||||
instanceName := fs.String("instance-name", "", "display name")
|
||||
accountID := fs.String("account-id", "", "admin-side account id, optional")
|
||||
tier := fs.String("tier", "", "free | professional | self_hosted (required)")
|
||||
tier := fs.String("tier", "", "free | professional | enterprise (required)")
|
||||
deployment := fs.String("deployment", "cloud", "cloud | self_hosted")
|
||||
term := fs.String("term", "1y", "1m or 1y")
|
||||
expires := fs.String("expires", "", "explicit RFC3339 expiry, overrides --term")
|
||||
out := fs.String("out", "", "write the blob to this file instead of stdout")
|
||||
@@ -78,9 +79,9 @@ func issue(args []string) {
|
||||
fatal("--instance-id and --tier are required")
|
||||
}
|
||||
|
||||
plan, ok := license.PlanFor(*tier)
|
||||
plan, ok := license.PlanFor(*deployment, *tier)
|
||||
if !ok {
|
||||
fatal("unknown tier %q", *tier)
|
||||
fatal("no plan for deployment %q tier %q", *deployment, *tier)
|
||||
}
|
||||
|
||||
key := os.Getenv("LICENSE_SIGNING_KEY")
|
||||
@@ -107,7 +108,7 @@ func issue(args []string) {
|
||||
|
||||
// Self Hosted is sold annually only, so the window in which a cancelled
|
||||
// licence keeps working is bounded at a year.
|
||||
if plan.Tier == license.TierSelfHosted && *term == "1m" && *expires == "" {
|
||||
if plan.Deployment == license.DeploymentSelfHosted && *term == "1m" && *expires == "" {
|
||||
fatal("self_hosted is annual only; use --term=1y or an explicit --expires")
|
||||
}
|
||||
|
||||
@@ -123,6 +124,7 @@ func issue(args []string) {
|
||||
InstanceName: name,
|
||||
Tier: plan.Tier,
|
||||
Deployment: plan.Deployment,
|
||||
SupportLevel: plan.SupportLevel,
|
||||
IssuedAt: now,
|
||||
ExpiresAt: exp,
|
||||
Limits: plan.Limits,
|
||||
|
||||
@@ -16,7 +16,15 @@ import "time"
|
||||
const (
|
||||
TierFree = "free"
|
||||
TierProfessional = "professional"
|
||||
TierSelfHosted = "self_hosted"
|
||||
TierEnterprise = "enterprise"
|
||||
|
||||
// TierSelfHosted is LEGACY and no new licence carries it.
|
||||
//
|
||||
// It was a tier when self-hosting was a tier rather than a deployment. Blobs
|
||||
// already signed with it exist and cannot be rewritten, so it stays a
|
||||
// recognised value that NormaliseTier maps forward. Never put it in a plan
|
||||
// row and never offer it in a UI.
|
||||
TierSelfHosted = "self_hosted"
|
||||
|
||||
DeploymentCloud = "cloud"
|
||||
DeploymentSelfHosted = "self_hosted"
|
||||
@@ -25,14 +33,59 @@ const (
|
||||
FeatureOIDC = "oidc" // per-instance single sign-on
|
||||
)
|
||||
|
||||
// Support levels. Carried for display and enforced by nothing — there is no code
|
||||
// path anywhere that branches on these, and there must not be one. They are here
|
||||
// so an air-gapped install can tell its operator who to call without reaching
|
||||
// Vantage HQ.
|
||||
const (
|
||||
SupportCommunity = "community"
|
||||
SupportEmail24x5 = "email_24_5"
|
||||
SupportEmailCall24x7 = "email_call_24_7"
|
||||
)
|
||||
|
||||
// Unlimited is the sentinel for "no cap" in every Limits field.
|
||||
const Unlimited = -1
|
||||
|
||||
// Limits are the countable caps a licence grants.
|
||||
//
|
||||
// Every field is a plain int with Unlimited as the sentinel. AuditRetentionDays
|
||||
// is the odd one out: it bounds a duration rather than a count, and Unlimited
|
||||
// there means "never trim" rather than "no cap".
|
||||
type Limits struct {
|
||||
MaxServers int `json:"max_servers"`
|
||||
MaxSecretGroups int `json:"max_secret_groups"`
|
||||
MaxChannels int `json:"max_channels"`
|
||||
MaxServers int `json:"max_servers"`
|
||||
MaxMonitors int `json:"max_monitors"`
|
||||
MaxSecretGroups int `json:"max_secret_groups"`
|
||||
MaxChannels int `json:"max_channels"`
|
||||
AuditRetentionDays int `json:"audit_retention_days"`
|
||||
}
|
||||
|
||||
// FillUnset replaces any zero field with the same field from base.
|
||||
//
|
||||
// This exists for one reason: a licence signed before a field existed decodes it
|
||||
// as 0, and 0 would read as the most restrictive possible value — no monitors,
|
||||
// and an audit log trimmed to nothing. A blob we cannot re-sign must not be
|
||||
// allowed to mean that.
|
||||
//
|
||||
// The cost is that 0 stops being expressible as a real allowance. No plan grants
|
||||
// zero of anything, so nothing is lost today; a plan that genuinely means zero
|
||||
// must use a negative-free sentinel of its own rather than reintroducing 0 here.
|
||||
func (l Limits) FillUnset(base Limits) Limits {
|
||||
if l.MaxServers == 0 {
|
||||
l.MaxServers = base.MaxServers
|
||||
}
|
||||
if l.MaxMonitors == 0 {
|
||||
l.MaxMonitors = base.MaxMonitors
|
||||
}
|
||||
if l.MaxSecretGroups == 0 {
|
||||
l.MaxSecretGroups = base.MaxSecretGroups
|
||||
}
|
||||
if l.MaxChannels == 0 {
|
||||
l.MaxChannels = base.MaxChannels
|
||||
}
|
||||
if l.AuditRetentionDays == 0 {
|
||||
l.AuditRetentionDays = base.AuditRetentionDays
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// License is the signed payload.
|
||||
@@ -47,6 +100,7 @@ type License struct {
|
||||
InstanceName string `json:"instance_name"` // display only
|
||||
Tier string `json:"tier"`
|
||||
Deployment string `json:"deployment"`
|
||||
SupportLevel string `json:"support_level,omitempty"` // display only
|
||||
IssuedAt time.Time `json:"issued_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Limits Limits `json:"limits"`
|
||||
|
||||
+112
-32
@@ -1,49 +1,129 @@
|
||||
package license
|
||||
|
||||
// Plan is the contents of a tier at issue time.
|
||||
// Plan is the contents of one (deployment, tier) pair at issue time.
|
||||
//
|
||||
// This table is the seed. Once the admin service exists (spec 3) it owns the
|
||||
// authoritative copy in its `plans` collection, and every issued licence
|
||||
// snapshots the plan it was cut from — so editing a plan never rewrites an
|
||||
// existing licence, the same rule as workflow_runs.steps_snapshot.
|
||||
// This table is the seed. The admin service owns the authoritative copy in its
|
||||
// `plans` collection, and every issued licence snapshots the plan it was cut
|
||||
// from — so editing a plan never rewrites an existing licence, the same rule as
|
||||
// workflow_runs.steps_snapshot.
|
||||
//
|
||||
// lkctl uses this table to issue by hand until then.
|
||||
// Limits here are the BASE allowance: what the tier grants before anything is
|
||||
// bought. A metered dimension adds to it, which is why max_servers is a real
|
||||
// number at Professional and Enterprise rather than Unlimited.
|
||||
type Plan struct {
|
||||
Tier string
|
||||
Name string
|
||||
Deployment string
|
||||
Limits Limits
|
||||
Features []string
|
||||
Tier string
|
||||
Name string
|
||||
Deployment string
|
||||
SupportLevel string
|
||||
Limits Limits
|
||||
Features []string
|
||||
}
|
||||
|
||||
var plans = map[string]Plan{
|
||||
TierFree: {
|
||||
Tier: TierFree,
|
||||
Name: "Free",
|
||||
Deployment: DeploymentCloud, // cloud only, by construction
|
||||
Limits: Limits{MaxServers: 3, MaxSecretGroups: 1, MaxChannels: 1},
|
||||
// planKey is the composite the table is keyed on.
|
||||
//
|
||||
// Keying on tier alone was what made Free cloud-only by construction. There is
|
||||
// now a self-hosted Free plan, so that guarantee is gone and the Free limit is
|
||||
// enforced per account AND deployment instead. See licensing.checkFreeLimit.
|
||||
type planKey struct {
|
||||
Deployment string
|
||||
Tier string
|
||||
}
|
||||
|
||||
// baseFree, baseProfessional and baseEnterprise are shared by both deployments.
|
||||
//
|
||||
// The allowances are deliberately identical across cloud and self-hosted: what
|
||||
// differs between the two is the term on offer, not what you get. Duplicating
|
||||
// them per deployment would be four places to forget.
|
||||
var (
|
||||
baseFree = Limits{
|
||||
MaxServers: 3, MaxMonitors: 3, MaxSecretGroups: 1,
|
||||
MaxChannels: 1, AuditRetentionDays: 30,
|
||||
}
|
||||
baseProfessional = Limits{
|
||||
MaxServers: 3, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited,
|
||||
MaxChannels: Unlimited, AuditRetentionDays: 365,
|
||||
}
|
||||
baseEnterprise = Limits{
|
||||
MaxServers: 10, MaxMonitors: Unlimited, MaxSecretGroups: Unlimited,
|
||||
MaxChannels: Unlimited, AuditRetentionDays: Unlimited,
|
||||
}
|
||||
)
|
||||
|
||||
var plans = map[planKey]Plan{
|
||||
planKey{DeploymentCloud, TierFree}: {
|
||||
Tier: TierFree, Name: "Free", Deployment: DeploymentCloud,
|
||||
SupportLevel: SupportCommunity, Limits: baseFree,
|
||||
// Empty rather than nil: nil marshals as JSON null, and this table is
|
||||
// the seed every plan and licence is cut from.
|
||||
Features: []string{},
|
||||
},
|
||||
TierProfessional: {
|
||||
Tier: TierProfessional,
|
||||
Name: "Professional",
|
||||
Deployment: DeploymentCloud,
|
||||
Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
|
||||
Features: []string{FeatureConsole, FeatureOIDC},
|
||||
planKey{DeploymentCloud, TierProfessional}: {
|
||||
Tier: TierProfessional, Name: "Professional", Deployment: DeploymentCloud,
|
||||
SupportLevel: SupportEmail24x5, Limits: baseProfessional,
|
||||
// Console and SSO are opt-in per customer, so no tier bundles them. The
|
||||
// field stays because a future tier might.
|
||||
Features: []string{},
|
||||
},
|
||||
TierSelfHosted: {
|
||||
Tier: TierSelfHosted,
|
||||
Name: "Self Hosted",
|
||||
Deployment: DeploymentSelfHosted,
|
||||
Limits: Limits{MaxServers: Unlimited, MaxSecretGroups: Unlimited, MaxChannels: Unlimited},
|
||||
Features: []string{FeatureConsole, FeatureOIDC},
|
||||
planKey{DeploymentCloud, TierEnterprise}: {
|
||||
Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentCloud,
|
||||
SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise,
|
||||
Features: []string{},
|
||||
},
|
||||
planKey{DeploymentSelfHosted, TierFree}: {
|
||||
Tier: TierFree, Name: "Free", Deployment: DeploymentSelfHosted,
|
||||
SupportLevel: SupportCommunity, Limits: baseFree,
|
||||
Features: []string{},
|
||||
},
|
||||
planKey{DeploymentSelfHosted, TierProfessional}: {
|
||||
Tier: TierProfessional, Name: "Professional", Deployment: DeploymentSelfHosted,
|
||||
SupportLevel: SupportEmail24x5, Limits: baseProfessional,
|
||||
Features: []string{},
|
||||
},
|
||||
planKey{DeploymentSelfHosted, TierEnterprise}: {
|
||||
Tier: TierEnterprise, Name: "Enterprise", Deployment: DeploymentSelfHosted,
|
||||
SupportLevel: SupportEmailCall24x7, Limits: baseEnterprise,
|
||||
Features: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
// PlanFor returns the seed plan for a tier.
|
||||
func PlanFor(tier string) (Plan, bool) {
|
||||
p, ok := plans[tier]
|
||||
// PlanFor returns the seed plan for one deployment and tier.
|
||||
//
|
||||
// It normalises first, so a legacy self_hosted licence resolves to the plan that
|
||||
// replaced it rather than to nothing.
|
||||
func PlanFor(deployment, tier string) (Plan, bool) {
|
||||
deployment, tier = NormaliseTier(deployment, tier)
|
||||
p, ok := plans[planKey{deployment, tier}]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// NormaliseTier maps a legacy tier forward.
|
||||
//
|
||||
// tier "self_hosted" predates deployments being separate from tiers. Such a
|
||||
// licence granted what Professional now grants, on a self-hosted install, so it
|
||||
// maps to exactly that. Called by PlanFor and by anything reading a tier off an
|
||||
// already-signed payload.
|
||||
func NormaliseTier(deployment, tier string) (string, string) {
|
||||
if tier == TierSelfHosted {
|
||||
return DeploymentSelfHosted, TierProfessional
|
||||
}
|
||||
return deployment, tier
|
||||
}
|
||||
|
||||
// Tiers is the offer order, for any UI that lists them.
|
||||
func Tiers() []string { return []string{TierFree, TierProfessional, TierEnterprise} }
|
||||
|
||||
// Deployments is the offer order.
|
||||
func Deployments() []string { return []string{DeploymentCloud, DeploymentSelfHosted} }
|
||||
|
||||
// TermsFor reports which billing terms a deployment sells.
|
||||
//
|
||||
// Self-hosted is annual only, and the reason is in this package's doc comment: an
|
||||
// offline licence cannot be revoked, so the term length IS the revocation
|
||||
// window. A self-hosted monthly licence would renew that window twelve times a
|
||||
// year for no commercial gain.
|
||||
func TermsFor(deployment string) []string {
|
||||
if deployment == DeploymentSelfHosted {
|
||||
return []string{"annual"}
|
||||
}
|
||||
return []string{"monthly", "annual"}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,43 @@ const SOURCE: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
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 (
|
||||
<Card padding={false} className="p-4">
|
||||
<p className="font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-tertiary">
|
||||
Audit history
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-extrabold tracking-[-0.03em] tabular-nums text-text-primary">
|
||||
{forever ? "Kept" : days}
|
||||
<span className="ml-1.5 text-sm font-medium tracking-normal text-text-secondary">
|
||||
{forever ? "indefinitely" : "days"}
|
||||
</span>
|
||||
</p>
|
||||
<p className="mt-3 font-mono text-[0.62rem] uppercase tracking-[0.14em] text-text-secondary">
|
||||
{forever ? "Never trimmed" : "Older entries removed daily"}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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. */}
|
||||
<h2 className={`text-2xl font-extrabold tracking-[-0.03em] ${s.text}`}>{s.label}</h2>
|
||||
{license.tier && <p className="text-sm text-text-secondary">{license.tier} tier</p>}
|
||||
{license.support_level && SUPPORT_LABELS[license.support_level] && (
|
||||
<p className="text-sm text-text-secondary">{SUPPORT_LABELS[license.support_level]}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{license.reason && <p className="mt-3 max-w-prose text-sm text-text-secondary">{REASON[license.reason] ?? license.reason}</p>}
|
||||
@@ -225,8 +265,10 @@ export default function LicensePage() {
|
||||
<Group label="Allowances">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Allowance label="Servers" used={license.usage.servers} limit={license.limits.max_servers} />
|
||||
<Allowance label="Monitors" used={license.usage.monitors} limit={license.limits.max_monitors} />
|
||||
<Allowance label="Secret groups" used={license.usage.secret_groups} limit={license.limits.max_secret_groups} />
|
||||
<Allowance label="Notification channels" used={license.usage.channels} limit={license.limits.max_channels} />
|
||||
<Retention days={license.limits.audit_retention_days} />
|
||||
</div>
|
||||
|
||||
<Card padding={false} className="px-5 py-2">
|
||||
|
||||
+14
-2
@@ -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<string, boolean>;
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user