feat(admin): checkout options, self-hosted placeholder, entitlement update, and portal endpoints
This commit is contained in:
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user