feat: Changes to self hosted purchase

This commit is contained in:
2026-08-11 09:30:19 +01:00
parent 3acbf01d46
commit a228ab24a0
20 changed files with 150 additions and 433 deletions
+53 -95
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"fmt"
"net/http"
"strings"
@@ -8,7 +9,6 @@ import (
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing"
@@ -18,6 +18,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
)
// checkoutOptions serves everything the browser configurator needs to price a
@@ -43,36 +44,67 @@ func checkoutOptions(c *gin.Context) {
})
}
// 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) {
// createSelfHostedCheckout prepares a paid self-hosted checkout against the
// customer's REAL install UUID, and hands that id back for the checkout's
// custom_data.
//
// A licence binds to the install's UUID, so the buyer must have a control plane
// standing before they pay — the same precondition self-hosted Free already has.
// That is what removes the placeholder: there is no temporary identity to
// rewrite afterwards, the subscription's custom_data names the real instance
// from the first event, and the webhook issues with no claim step.
//
// An id this account already owns is REUSED rather than refused: upgrading a
// Free self-hosted install to a paid plan is the same purchase form, and
// refusing it would mean the only route to Professional was to unlink first.
// A UUID belonging to anyone else is still 409, from the unique index.
func createSelfHostedCheckout(c *gin.Context) {
s := auth.Current(c)
var body struct {
Name string `json:"name"`
InstanceID string `json:"instance_id"`
Name string `json:"name"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.InstanceID) == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
return
}
instanceID := strings.TrimSpace(body.InstanceID)
name := strings.TrimSpace(body.Name)
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 {
var existing models.Instance
err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&existing)
switch {
case err == nil:
if existing.Deployment != license.DeploymentSelfHosted {
c.JSON(http.StatusBadRequest, gin.H{
"error": "that instance is a cloud instance; change its plan from its own page"})
return
}
c.JSON(http.StatusOK, gin.H{"instance_id": existing.InstanceID})
return
case !errors.Is(err, mongo.ErrNoDocuments):
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"})
return
}
inst, err := licensing.LinkInstance(ctx, s.AccountID, instanceID, name)
if err != nil {
status := http.StatusBadRequest
if errors.Is(err, licensing.ErrAlreadyLinked) {
status = http.StatusConflict
}
c.JSON(status, 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()})
Actor: s.Email, Action: "instance.checkout_started", AccountID: s.AccountID,
Target: inst.InstanceID, Detail: "self-hosted", IP: c.ClientIP()})
c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
}
@@ -208,80 +240,6 @@ func updateEntitlement(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"entitlement": next, "pending": next.Pending()})
}
// claimPlaceholderLink binds a paid self-hosted placeholder to the customer's
// real install UUID, then issues.
//
// :id is the placeholder (generated at checkout, carried in the subscription's
// custom_data); the body carries the UUID the install actually reports. The
// licence must bind to that real UUID (spec 1 has no unbound licence), so the
// placeholder row's identity is rewritten to it and the subscription re-pointed,
// then billing issues from the recorded subscription. Linking and claiming stay
// one call here because, unlike Free, the payment already happened.
func claimPlaceholderLink(c *gin.Context) {
inst, ok := ownedInstance(c, c.Param("id"))
if !ok {
return
}
if !inst.Placeholder {
c.JSON(http.StatusBadRequest, gin.H{"error": "this instance is already linked"})
return
}
var body struct {
InstanceID string `json:"instance_id"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"})
return
}
ctx := c.Request.Context()
// The real UUID must be free across every account — the unique index on
// instance_id is the tenant-isolation property, so refuse rather than collide.
if n, _ := db.Admin("admin_instances").CountDocuments(ctx,
bson.M{"instance_id": body.InstanceID}); n > 0 {
c.JSON(http.StatusConflict, gin.H{"error": "that instance ID is already linked"})
return
}
placeholderID := inst.InstanceID
if _, err := db.Admin("admin_instances").UpdateOne(ctx,
bson.M{"instance_id": placeholderID},
bson.M{
"$set": bson.M{
"instance_id": body.InstanceID,
"status": models.StatusActive,
"placeholder": false,
},
"$addToSet": bson.M{"previous_instance_ids": placeholderID},
}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Re-point the subscription rows from the placeholder id to the real UUID so
// billing.IssueForInstance finds it, and rewrite Paddle's own copy of
// custom_data — written at checkout, it still names the placeholder, and every
// later event on this subscription is decoded from it.
if err := licensing.RepointSubscriptions(ctx, placeholderID, body.InstanceID, inst.AccountID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := billing.IssueForInstance(ctx, body.InstanceID); err != nil {
// The link stuck; issuance did not. The reconciler and a retry recover it,
// and the customer is not blocked from linking. Surface it, do not roll back.
c.JSON(http.StatusAccepted, gin.H{
"instance_id": body.InstanceID,
"warning": "linked, but licence issuance is pending: " + err.Error()})
return
}
audit.Write(ctx, models.AuditEntry{
Actor: auth.Current(c).Email, Action: "instance.placeholder_linked",
AccountID: inst.AccountID, Target: body.InstanceID,
Detail: "from placeholder " + placeholderID, IP: c.ClientIP()})
c.JSON(http.StatusOK, gin.H{"instance_id": body.InstanceID})
}
// 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) {
+3 -4
View File
@@ -77,9 +77,11 @@ func Routes(cfg config.Config) http.Handler {
claimFree)
cust.GET("/instances/:id/entitlement", getEntitlement)
cust.GET("/checkout/options", checkoutOptions)
// Paid self-hosted: links (or reuses) the customer's real install UUID so
// the checkout can name it. There is no placeholder and no claim step.
cust.POST("/instances/self-hosted",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
createSelfHostedPlaceholder)
createSelfHostedCheckout)
// Paid cloud: provisions a real instance the paid webhook then licenses.
cust.POST("/instances/cloud",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
@@ -88,9 +90,6 @@ func Routes(cfg config.Config) http.Handler {
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
updateEntitlement)
cust.POST("/billing/portal", billingPortal)
cust.POST("/instances/:id/claim-link",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
claimPlaceholderLink)
cust.GET("/instances/:id/license", getInstanceLicense)
cust.GET("/instances/:id/license/download", downloadInstanceLicense)
cust.GET("/instances/:id/members", listInstanceMembers)
+6 -3
View File
@@ -380,9 +380,12 @@ func staffListLicenses(c *gin.Context) {
c.JSON(http.StatusOK, lics)
}
// staffBillingHealth surfaces webhook handlers that failed and paid-but-unlinked
// placeholders, so a customer who paid and got nothing is visible rather than
// stuck in a support queue.
// staffBillingHealth surfaces webhook handlers that failed and placeholders
// still awaiting their instance, so a customer who paid and got nothing is
// visible rather than stuck in a support queue.
//
// Placeholders are a cloud-only path now; any self-hosted row still listed here
// predates the checkout change and needs issuing by hand.
func staffBillingHealth(c *gin.Context) {
ctx := c.Request.Context()
failed := []models.PaddleEvent{}