From 28b138b4c32ad50fe264ad5f0441d1526065ca2f Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Mon, 27 Jul 2026 10:43:12 +0100 Subject: [PATCH] feat(admin): checkout options, self-hosted placeholder, entitlement update, and portal endpoints --- admin/internal/api/checkout.go | 185 +++++++++++++++++++++++++++++++++ admin/internal/api/routes.go | 8 ++ 2 files changed, 193 insertions(+) create mode 100644 admin/internal/api/checkout.go diff --git a/admin/internal/api/checkout.go b/admin/internal/api/checkout.go new file mode 100644 index 0000000..9c0cadf --- /dev/null +++ b/admin/internal/api/checkout.go @@ -0,0 +1,185 @@ +package api + +import ( + "fmt" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "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" + "github.com/mrhid6/vantage/admin/internal/paddle" + "github.com/mrhid6/vantage/shared/license" + "go.mongodb.org/mongo-driver/v2/bson" +) + +// checkoutOptions serves everything the browser configurator needs to price a +// plan: the active plans (base allowances), the full catalogue (component prices +// in the running environment), and the environment name so the client can refuse +// a mismatch. The client token itself is baked into the adminsite build, never +// served from here. +func checkoutOptions(c *gin.Context) { + ctx := c.Request.Context() + plans := []models.Plan{} + if cur, err := db.Admin("plans").Find(ctx, bson.M{"active": true}); err == nil { + _ = cur.All(ctx, &plans) + } + rows, err := models.AllCatalogue(ctx) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "plans": plans, + "catalogue": rows, + "env": paddle.Get().Env(), + }) +} + +// createSelfHostedPlaceholder makes an instance row that exists only so a +// checkout has something to put in custom_data. It carries no licence and is +// flagged Placeholder until the customer pastes their install's real UUID. The +// generated id is temporary; linking replaces the identity. +func createSelfHostedPlaceholder(c *gin.Context) { + s := auth.Current(c) + var body struct { + Name string `json:"name"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"}) + return + } + ctx := c.Request.Context() + inst := models.Instance{ + InstanceID: uuid.NewString(), + AccountID: s.AccountID, + Name: body.Name, + Deployment: license.DeploymentSelfHosted, + Status: models.StatusAwaitingLink, + Placeholder: true, + CreatedAt: time.Now().UTC(), + } + if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + audit.Write(ctx, models.AuditEntry{ + Actor: s.Email, Action: "instance.placeholder_created", AccountID: s.AccountID, + Target: inst.InstanceID, IP: c.ClientIP()}) + c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID}) +} + +// updateEntitlement sets an instance's DESIRED configuration and pushes the +// resulting line items to Paddle. It does NOT issue — the resulting +// subscription.updated webhook does, from granted. An increase is prorated +// immediately by Paddle; a reduction is recorded as desired and takes effect at +// renewal, so this never shrinks a live licence. +func updateEntitlement(c *gin.Context) { + inst, ok := ownedInstance(c, c.Param("id")) + if !ok { + return + } + ctx := c.Request.Context() + var body struct { + Tier string `json:"tier"` + Term string `json:"term"` + Servers int `json:"servers"` + Features []string `json:"features"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid configuration"}) + return + } + + plan, err := models.GetPlan(ctx, inst.Deployment, body.Tier) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "no such plan"}) + return + } + if body.Servers < plan.BaseLimits.MaxServers && plan.BaseLimits.MaxServers != license.Unlimited { + c.JSON(http.StatusBadRequest, gin.H{ + "error": fmt.Sprintf("%s includes %d servers", plan.Name, plan.BaseLimits.MaxServers)}) + return + } + + desired := models.Config{Servers: body.Servers, Features: models.Features(body.Features).OrEmpty()} + items, err := catalogue.LineItems(ctx, paddle.Get().Env(), body.Term, plan, desired) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // A live subscription is required to update. None means this instance has + // never been paid for — that is a checkout, not an update. + var sub models.Subscription + if err := db.Admin("subscriptions").FindOne(ctx, + bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}).Decode(&sub); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "no active subscription; start a checkout instead"}) + return + } + + pItems := make([]paddle.LineItem, 0, len(items)) + for _, it := range items { + pItems = append(pItems, paddle.LineItem{PriceID: it.PriceID, Quantity: it.Quantity}) + } + if err := paddle.Get().UpdateSubscriptionItems(ctx, sub.PaddleSubscriptionID, pItems); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "billing update failed; nothing changed"}) + return + } + + // Record desired now; the webhook Paddle sends back promotes to granted and + // reissues. Recording here makes the portal reflect the intent instantly + // rather than waiting on the round-trip. + limits, _, _ := catalogue.Resolve(ctx, plan, desired) + next := models.Entitlement{ + InstanceID: inst.InstanceID, AccountID: inst.AccountID, + Deployment: inst.Deployment, Tier: body.Tier, Term: body.Term, + Desired: desired, ResolvedLimits: limits, + } + ent, _ := models.GetEntitlement(ctx, inst.InstanceID) + if ent != nil { + next.Granted = ent.Granted + next.GrantedAt = ent.GrantedAt + if desired.Servers < ent.Granted.Servers { + now := time.Now().UTC() + next.ScheduledChangeAt = &now + } + } else { + next.Granted = desired + } + if err := models.UpsertEntitlement(ctx, next); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + audit.Write(ctx, models.AuditEntry{ + Actor: auth.Current(c).Email, Action: "entitlement.requested", + AccountID: inst.AccountID, Target: inst.InstanceID}) + c.JSON(http.StatusOK, gin.H{"entitlement": next, "pending": next.Pending()}) +} + +// billingPortal mints a Paddle customer-portal URL. The account must already +// have a paddle_customer_id, which it learns from its first subscription webhook. +func billingPortal(c *gin.Context) { + s := auth.Current(c) + ctx := c.Request.Context() + var acc models.Account + if err := db.Admin("accounts").FindOne(ctx, + bson.M{"account_id": s.AccountID}).Decode(&acc); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "no account"}) + return + } + if acc.PaddleCustomerID == "" { + c.JSON(http.StatusConflict, gin.H{"error": "no billing account yet; buy a paid plan first"}) + return + } + url, err := paddle.Get().PortalSession(ctx, acc.PaddleCustomerID) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": "could not open billing portal"}) + return + } + c.JSON(http.StatusOK, gin.H{"url": url}) +} diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index efb5260..953f25c 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -76,6 +76,14 @@ func Routes(cfg config.Config) http.Handler { auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), claimFree) cust.GET("/instances/:id/entitlement", getEntitlement) + cust.GET("/checkout/options", checkoutOptions) + cust.POST("/instances/self-hosted", + auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), + createSelfHostedPlaceholder) + cust.PUT("/instances/:id/entitlement", + auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), + updateEntitlement) + cust.POST("/billing/portal", billingPortal) cust.GET("/instances/:id/license", getInstanceLicense) cust.GET("/instances/:id/license/download", downloadInstanceLicense) cust.GET("/instances/:id/members", listInstanceMembers)