From 4ad68e3ac4264b58c4bc7c6170306dc05cb2a30f Mon Sep 17 00:00:00 2001
From: mrhid6
Date: Mon, 27 Jul 2026 14:59:43 +0100
Subject: [PATCH] feat: Updated purchase page
---
admin/cmd/main.go | 2 +
admin/internal/api/checkout.go | 45 +
admin/internal/api/customer.go | 159 ++-
admin/internal/api/routes.go | 4 +
admin/internal/billing/cloudprovision.go | 196 +++
admin/internal/billing/subscription.go | 23 +-
admin/internal/cloudprov/cloudprov.go | 21 +-
admin/internal/models/models.go | 6 +-
adminsite/app/(customer)/page.tsx | 18 +-
.../app/(customer)/purchase/PurchaseForm.tsx | 1060 +++++++++++++++--
adminsite/app/(customer)/purchase/page.tsx | 6 +-
adminsite/lib/api.ts | 3 +
adminsite/lib/paddle.ts | 59 +
shared/provision/instance.go | 28 +-
14 files changed, 1443 insertions(+), 187 deletions(-)
create mode 100644 admin/internal/billing/cloudprovision.go
diff --git a/admin/cmd/main.go b/admin/cmd/main.go
index d568e26..54c6971 100644
--- a/admin/cmd/main.go
+++ b/admin/cmd/main.go
@@ -13,6 +13,7 @@ import (
"github.com/joho/godotenv"
"github.com/mrhid6/vantage/admin/internal/api"
"github.com/mrhid6/vantage/admin/internal/auth"
+ "github.com/mrhid6/vantage/admin/internal/billing"
"github.com/mrhid6/vantage/admin/internal/config"
"github.com/mrhid6/vantage/admin/internal/db"
"github.com/mrhid6/vantage/admin/internal/hqsync"
@@ -93,6 +94,7 @@ func main() {
reconcileCtx, stopReconcile := context.WithCancel(context.Background())
defer stopReconcile()
inject.StartReconciler(reconcileCtx)
+ billing.StartPlaceholderReconciler(reconcileCtx)
hqsync.Start(reconcileCtx)
lifecycle.SetPortalURL(cfg.PublicURL)
diff --git a/admin/internal/api/checkout.go b/admin/internal/api/checkout.go
index e49f30f..1efc345 100644
--- a/admin/internal/api/checkout.go
+++ b/admin/internal/api/checkout.go
@@ -3,6 +3,7 @@ package api
import (
"fmt"
"net/http"
+ "strings"
"time"
"github.com/gin-gonic/gin"
@@ -74,6 +75,50 @@ func createSelfHostedPlaceholder(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID})
}
+// createCloudCheckout creates a PAID cloud placeholder and hands back its id so
+// the browser can open a Paddle checkout keyed to it. Nothing is provisioned yet:
+// a cloud instance costs real infrastructure, so it is created only once payment
+// is confirmed, by the subscription webhook (billing.handleSubscription).
+//
+// This mirrors the self-hosted placeholder, with one difference that matters:
+// admin owns the cloud UUID, so the id generated here is the id the instance
+// will keep. Provisioning on the webhook reuses it (provision.CreateInstanceWithID),
+// which is why there is no claim-and-rewrite step and the subscription's
+// custom_data never goes stale. PendingOwnerUserID remembers who bought it so the
+// webhook can make them the instance owner.
+//
+// An abandoned checkout therefore leaves only this row — no infrastructure — the
+// same cheap, reap-safe state a self-hosted placeholder leaves.
+func createCloudCheckout(c *gin.Context) {
+ s := auth.Current(c)
+ var body struct {
+ Name string `json:"name"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(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: strings.TrimSpace(body.Name),
+ Deployment: license.DeploymentCloud,
+ Status: models.StatusAwaitingLink,
+ Placeholder: true,
+ PendingOwnerUserID: s.UserID,
+ 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.cloud_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
diff --git a/admin/internal/api/customer.go b/admin/internal/api/customer.go
index ca3f211..843b605 100644
--- a/admin/internal/api/customer.go
+++ b/admin/internal/api/customer.go
@@ -200,6 +200,96 @@ func listSubscriptions(c *gin.Context) {
c.JSON(http.StatusOK, subs)
}
+// provisionCloudInstance provisions a real cloud instance in the control plane
+// and records admin's row for it, WITHOUT issuing a licence. Both the Free
+// create path and the paid-checkout path share it, so the provisioning — and its
+// unwind-in-reverse rollback — has one definition rather than two that drift.
+//
+// It leaves the instance unlicensed on purpose: createInstance then issues Free,
+// and createCloudCheckout leaves it for the paid subscription webhook to license.
+// The returned rec carries no Tier or CurrentLicense; the caller sets those once
+// it has issued.
+//
+// Errors are returned unwrapped for the provisioning step so the caller can still
+// match provision.ErrEmailTaken / ErrNameRejected; later steps are wrapped.
+func provisionCloudInstance(c *gin.Context, name string) (*models.Instance, error) {
+ ctx := c.Request.Context()
+ s := auth.Current(c)
+
+ var cu models.CustomerUser
+ if err := db.Admin("customer_users").FindOne(ctx,
+ bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
+ return nil, fmt.Errorf("read account: %w", err)
+ }
+
+ inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
+ if err != nil {
+ return nil, err
+ }
+
+ rec := models.Instance{
+ InstanceID: inst.InstanceID,
+ AccountID: s.AccountID,
+ Name: inst.Name,
+ Slug: inst.Slug,
+ Deployment: license.DeploymentCloud,
+ Status: models.StatusActive,
+ CreatedAt: time.Now().UTC(),
+ }
+ if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil {
+ // Unwind in reverse: the owner first, because RollbackInstance refuses
+ // an instance that still has users.
+ if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil {
+ _ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid)
+ }
+ if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil {
+ log.Printf("provisionCloudInstance: rollback of %s failed: %v", inst.InstanceID, e)
+ }
+ return nil, fmt.Errorf("record instance: %w", err)
+ }
+
+ // Record the owner's membership. Best-effort: the projected user already
+ // exists and is what actually grants access, so a missing row here costs a
+ // line in the members panel, not access — and the boot backfill rebuilds it.
+ ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
+ if err != nil {
+ log.Printf("provisionCloudInstance: owner lookup for %s: %v", inst.InstanceID, err)
+ } else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
+ MemberID: uuid.NewString(),
+ AccountID: s.AccountID,
+ InstanceID: inst.InstanceID,
+ CustomerUserID: cu.UserID,
+ ControlUserID: ownerID,
+ Role: sharedmodels.RoleOwner,
+ Email: cu.Email,
+ CreatedAt: time.Now().UTC(),
+ }); err != nil {
+ log.Printf("provisionCloudInstance: record owner membership for %s: %v", inst.InstanceID, err)
+ }
+
+ audit.Write(ctx, models.AuditEntry{
+ Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
+ Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
+ return &rec, nil
+}
+
+// cloudProvisionError maps the errors provisionCloudInstance can surface onto the
+// customer-facing responses shared by the Free and paid-checkout paths.
+func cloudProvisionError(c *gin.Context, err error) {
+ switch {
+ case errors.Is(err, provision.ErrEmailTaken):
+ // users.email is unique per instance, so this means the address already
+ // owns a user in an instance we are not creating — a legacy cloud tenant.
+ // Staff have to attach that one by hand.
+ c.JSON(http.StatusConflict, gin.H{
+ "error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"})
+ case errors.Is(err, provision.ErrNameRejected):
+ c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
+ default:
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
+ }
+}
+
// createInstance provisions a Free cloud instance for the calling account.
//
// The ordering matters and each step unwinds the previous one. Licence issuance
@@ -241,75 +331,12 @@ func createInstance(c *gin.Context) {
return
}
- var cu models.CustomerUser
- if err := db.Admin("customer_users").FindOne(ctx,
- bson.M{"user_id": s.UserID}).Decode(&cu); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not read your account"})
- return
- }
-
- inst, err := cloudprov.CreateInstance(ctx, name, cu.Email, cu.PasswordHash, cu.UserID)
+ rec, err := provisionCloudInstance(c, name)
if err != nil {
- if errors.Is(err, provision.ErrEmailTaken) {
- // users.email is unique per instance, so this means the address
- // already owns a user in an instance we are not creating — a legacy
- // cloud tenant. Staff have to attach that one by hand.
- c.JSON(http.StatusConflict, gin.H{
- "error": "that email address already belongs to an existing Vantage instance; contact support@hostxtra.co.uk and we will link it to your account"})
- return
- }
- if errors.Is(err, provision.ErrNameRejected) {
- c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
+ cloudProvisionError(c, err)
return
}
-
- rec := models.Instance{
- InstanceID: inst.InstanceID,
- AccountID: s.AccountID,
- Name: inst.Name,
- Slug: inst.Slug,
- Deployment: license.DeploymentCloud,
- Status: models.StatusActive,
- CreatedAt: time.Now().UTC(),
- }
- if _, err := db.Admin("admin_instances").InsertOne(ctx, rec); err != nil {
- // Unwind in reverse: the owner first, because RollbackInstance refuses
- // an instance that still has users.
- if uid, e := cloudprov.OwnerUserID(ctx, inst.InstanceID); e == nil {
- _ = cloudprov.DeleteUser(ctx, inst.InstanceID, uid)
- }
- if e := cloudprov.RollbackInstance(ctx, inst.InstanceID); e != nil {
- log.Printf("createInstance: rollback of %s failed: %v", inst.InstanceID, e)
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "could not create the instance"})
- return
- }
-
- // Record the owner's membership. Best-effort: the projected user already
- // exists and is what actually grants access, so a missing row here costs a
- // line in the members panel, not access — and the boot backfill rebuilds it.
- ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID)
- if err != nil {
- log.Printf("createInstance: owner lookup for %s: %v", inst.InstanceID, err)
- } else if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
- MemberID: uuid.NewString(),
- AccountID: s.AccountID,
- InstanceID: inst.InstanceID,
- CustomerUserID: cu.UserID,
- ControlUserID: ownerID,
- Role: sharedmodels.RoleOwner,
- Email: cu.Email,
- CreatedAt: time.Now().UTC(),
- }); err != nil {
- log.Printf("createInstance: record owner membership for %s: %v", inst.InstanceID, err)
- }
-
- audit.Write(ctx, models.AuditEntry{
- Actor: s.Email, Action: "instance.created", AccountID: s.AccountID,
- Target: inst.InstanceID, Detail: "slug=" + inst.Slug, IP: c.ClientIP()})
+ inst := rec
// Past this point nothing fails the request.
lic, err := licensing.Issue(ctx, licensing.IssueInput{
diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go
index 7800d11..7e9cf75 100644
--- a/admin/internal/api/routes.go
+++ b/admin/internal/api/routes.go
@@ -80,6 +80,10 @@ func Routes(cfg config.Config) http.Handler {
cust.POST("/instances/self-hosted",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
createSelfHostedPlaceholder)
+ // Paid cloud: provisions a real instance the paid webhook then licenses.
+ cust.POST("/instances/cloud",
+ auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
+ createCloudCheckout)
cust.PUT("/instances/:id/entitlement",
auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin),
updateEntitlement)
diff --git a/admin/internal/billing/cloudprovision.go b/admin/internal/billing/cloudprovision.go
new file mode 100644
index 0000000..0cde8db
--- /dev/null
+++ b/admin/internal/billing/cloudprovision.go
@@ -0,0 +1,196 @@
+package billing
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/mrhid6/vantage/admin/internal/catalogue"
+ "github.com/mrhid6/vantage/admin/internal/cloudprov"
+ "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"
+ sharedmodels "github.com/mrhid6/vantage/shared/models"
+ "go.mongodb.org/mongo-driver/v2/bson"
+)
+
+// placeholderReconcileInterval is how often placeholders are swept: paid cloud
+// ones a failed webhook left unprovisioned are completed, and stale unpaid ones
+// of either deployment are reaped.
+const placeholderReconcileInterval = 5 * time.Minute
+
+// abandonedPlaceholderAfter is how long an unpaid placeholder may sit before it
+// is treated as an abandoned checkout and deleted. Comfortably longer than a
+// webhook's delivery lag, so a just-paid placeholder awaiting its subscription
+// event is never mistaken for an abandoned one.
+const abandonedPlaceholderAfter = 24 * time.Hour
+
+// StartPlaceholderReconciler owns the after-checkout lifecycle of placeholders.
+//
+// It recovers the one failure the webhook cannot on its own — a confirmed payment
+// whose provisioning handler errored, which is not retried once its event is
+// claimed and which the inject reconciler (licences only) does not repair — by
+// completing paid cloud placeholders here. And it reaps abandoned ones: a
+// placeholder with no subscription past abandonedPlaceholderAfter is a checkout
+// nobody finished, and deleting it loses nothing, because a placeholder has no
+// control-plane footprint until it is paid for and provisioned.
+func StartPlaceholderReconciler(ctx context.Context) {
+ go func() {
+ t := time.NewTicker(placeholderReconcileInterval)
+ defer t.Stop()
+ reconcilePlaceholders(ctx)
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case <-t.C:
+ reconcilePlaceholders(ctx)
+ }
+ }
+ }()
+}
+
+func reconcilePlaceholders(ctx context.Context) {
+ cur, err := db.Admin("admin_instances").Find(ctx, bson.M{"placeholder": true})
+ if err != nil {
+ log.Printf("placeholder reconcile: query: %v", err)
+ return
+ }
+ var placeholders []models.Instance
+ if err := cur.All(ctx, &placeholders); err != nil {
+ log.Printf("placeholder reconcile: decode: %v", err)
+ return
+ }
+
+ now := time.Now().UTC()
+ for _, inst := range placeholders {
+ var sub models.Subscription
+ paid := db.Admin("subscriptions").FindOne(ctx,
+ bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}).Decode(&sub) == nil
+
+ if !paid {
+ // Never paid for. Reap once it is old enough to be an abandoned
+ // checkout rather than one still awaiting its subscription webhook.
+ if now.Sub(inst.CreatedAt) > abandonedPlaceholderAfter {
+ if _, err := db.Admin("admin_instances").DeleteOne(ctx,
+ bson.M{"instance_id": inst.InstanceID, "placeholder": true}); err != nil {
+ log.Printf("placeholder reconcile: reap abandoned %s: %v", inst.InstanceID, err)
+ } else {
+ log.Printf("placeholder reconcile: reaped abandoned placeholder %s", inst.InstanceID)
+ }
+ }
+ continue
+ }
+
+ // Paid, self-hosted: nothing to provision — the customer installs and
+ // links, and lifecycle chases them. Only cloud is completed here.
+ if inst.Deployment != license.DeploymentCloud {
+ continue
+ }
+
+ items := make([]catalogue.Item, 0, len(sub.Items))
+ for _, it := range sub.Items {
+ items = append(items, catalogue.Item{PriceID: it.PriceID, Quantity: it.Quantity})
+ }
+ match, err := catalogue.ResolveItems(ctx, paddle.Get().Env(), items)
+ if err != nil {
+ log.Printf("placeholder reconcile: resolve items for %s: %v", inst.InstanceID, err)
+ continue
+ }
+ provisioned, err := completeCloudPlaceholder(ctx, &inst)
+ if err != nil {
+ log.Printf("placeholder reconcile: complete %s: %v", inst.InstanceID, err)
+ continue
+ }
+ if err := promoteAndIssue(ctx, provisioned, match, models.ReasonNew); err != nil {
+ log.Printf("placeholder reconcile: issue %s: %v", inst.InstanceID, err)
+ continue
+ }
+ log.Printf("placeholder reconcile: completed paid cloud instance %s", inst.InstanceID)
+ }
+}
+
+// completeCloudPlaceholder provisions the cloud instance a paid placeholder stands
+// for, once payment is confirmed, and returns the row promoted to a real instance.
+//
+// It is the payment-first half of the paid-cloud flow: createCloudCheckout made
+// the placeholder before payment, this provisions it after. The control-plane
+// instance is created with the placeholder's OWN id (cloudprov.CreateInstanceWithID),
+// so nothing is rewritten and the subscription's custom_data still resolves this
+// row on every later webhook.
+//
+// Every step is idempotent, because a webhook can be retried after this partly
+// ran: provisioning converges rather than duplicates, and the row flip and
+// membership insert are guarded on what they write. The caller then issues.
+func completeCloudPlaceholder(ctx context.Context, inst *models.Instance) (*models.Instance, error) {
+ cu, err := placeholderOwner(ctx, inst)
+ if err != nil {
+ return nil, err
+ }
+
+ prov, err := cloudprov.CreateInstanceWithID(ctx, inst.InstanceID, inst.Name,
+ cu.Email, cu.PasswordHash, cu.UserID)
+ if err != nil {
+ return nil, fmt.Errorf("provision cloud instance %s: %w", inst.InstanceID, err)
+ }
+
+ if _, err := db.Admin("admin_instances").UpdateOne(ctx,
+ bson.M{"instance_id": inst.InstanceID},
+ bson.M{
+ "$set": bson.M{"slug": prov.Slug, "status": models.StatusActive, "placeholder": false},
+ "$unset": bson.M{"pending_owner_user_id": ""},
+ }); err != nil {
+ return nil, fmt.Errorf("promote placeholder %s: %w", inst.InstanceID, err)
+ }
+
+ // Record the owner's membership. Best-effort and guarded on absence: the
+ // projected user is what grants access, so a missing row costs a line in the
+ // members panel, not access — and the boot backfill rebuilds it.
+ if ownerID, err := cloudprov.OwnerUserID(ctx, inst.InstanceID); err == nil {
+ if n, _ := db.Admin("instance_members").CountDocuments(ctx,
+ bson.M{"instance_id": inst.InstanceID, "customer_user_id": cu.UserID}); n == 0 {
+ if _, err := db.Admin("instance_members").InsertOne(ctx, models.InstanceMember{
+ MemberID: uuid.NewString(),
+ AccountID: inst.AccountID,
+ InstanceID: inst.InstanceID,
+ CustomerUserID: cu.UserID,
+ ControlUserID: ownerID,
+ Role: sharedmodels.RoleOwner,
+ Email: cu.Email,
+ CreatedAt: time.Now().UTC(),
+ }); err != nil {
+ log.Printf("completeCloudPlaceholder: record owner membership for %s: %v",
+ inst.InstanceID, err)
+ }
+ }
+ }
+
+ next := *inst
+ next.Slug = prov.Slug
+ next.Status = models.StatusActive
+ next.Placeholder = false
+ next.PendingOwnerUserID = ""
+ return &next, nil
+}
+
+// placeholderOwner resolves the customer_user who should own a provisioned cloud
+// placeholder: the buyer recorded at checkout, or the account owner if that
+// pointer is somehow missing.
+func placeholderOwner(ctx context.Context, inst *models.Instance) (*models.CustomerUser, error) {
+ var cu models.CustomerUser
+ if inst.PendingOwnerUserID != "" {
+ if err := db.Admin("customer_users").FindOne(ctx,
+ bson.M{"user_id": inst.PendingOwnerUserID}).Decode(&cu); err == nil {
+ return &cu, nil
+ }
+ }
+ if err := db.Admin("customer_users").FindOne(ctx,
+ bson.M{"account_id": inst.AccountID, "account_role": models.AccountRoleOwner}).Decode(&cu); err != nil {
+ return nil, fmt.Errorf("no owner for account %s to provision %s: %w",
+ inst.AccountID, inst.InstanceID, err)
+ }
+ return &cu, nil
+}
diff --git a/admin/internal/billing/subscription.go b/admin/internal/billing/subscription.go
index 27c3863..b852093 100644
--- a/admin/internal/billing/subscription.go
+++ b/admin/internal/billing/subscription.go
@@ -12,6 +12,7 @@ import (
"github.com/mrhid6/vantage/admin/internal/mail"
"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"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
@@ -87,20 +88,32 @@ func handleSubscription(ctx context.Context, ev Event) error {
bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}})
}
- // A self-hosted placeholder that has not been linked yet gets its
- // subscription recorded but NO licence — there is no UUID to bind to. The
- // link endpoint issues when the customer pastes it.
var inst models.Instance
if err := db.Admin("admin_instances").FindOne(ctx,
bson.M{"instance_id": d.CustomData.InstanceID}).Decode(&inst); err != nil {
return fmt.Errorf("subscription %s names unknown instance %s: %w",
d.ID, d.CustomData.InstanceID, err)
}
+
+ // Placeholders are the payment-first path: the instance does not exist until
+ // this confirmed-payment event. A cloud placeholder is provisioned here and
+ // then issued (first term). A self-hosted placeholder has no UUID to bind to
+ // until the customer pastes their install's — its subscription is recorded and
+ // the link endpoint issues later.
+ reason := models.ReasonEntitlementChange
if inst.Placeholder {
- return nil
+ if inst.Deployment != license.DeploymentCloud {
+ return nil
+ }
+ provisioned, err := completeCloudPlaceholder(ctx, &inst)
+ if err != nil {
+ return err
+ }
+ inst = *provisioned
+ reason = models.ReasonNew
}
- return promoteAndIssue(ctx, &inst, match, models.ReasonEntitlementChange)
+ return promoteAndIssue(ctx, &inst, match, reason)
}
// promoteAndIssue promotes desired→granted from the resolved match, then signs a
diff --git a/admin/internal/cloudprov/cloudprov.go b/admin/internal/cloudprov/cloudprov.go
index 30cbac2..a679117 100644
--- a/admin/internal/cloudprov/cloudprov.go
+++ b/admin/internal/cloudprov/cloudprov.go
@@ -15,6 +15,7 @@ import (
"context"
"fmt"
+ "github.com/google/uuid"
"github.com/mrhid6/vantage/admin/internal/db"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
@@ -32,11 +33,29 @@ import (
// On owner-insert failure the instance is rolled back, so a failed provision
// never leaves a slug permanently occupied by an instance nobody owns.
func CreateInstance(ctx context.Context, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
- inst, err := provision.CreateInstance(ctx, db.ControlDB(), name)
+ return CreateInstanceWithID(ctx, uuid.NewString(), name, ownerEmail, ownerPasswordHash, hqUserID)
+}
+
+// CreateInstanceWithID provisions a cloud instance under a caller-supplied ID and
+// its owner. It backs the paid-cloud flow, where the ID is a placeholder created
+// before payment and provisioning runs on the confirmed-payment webhook (see
+// provision.CreateInstanceWithID).
+//
+// It is idempotent, because a webhook can be retried after provisioning partly
+// completed: the instance is created only if absent, and the owner only if the
+// instance has none yet. A second call therefore converges to the same state
+// rather than colliding on the per-instance email unique index.
+func CreateInstanceWithID(ctx context.Context, instanceID, name, ownerEmail, ownerPasswordHash, hqUserID string) (*sharedmodels.Instance, error) {
+ inst, err := provision.CreateInstanceWithID(ctx, db.ControlDB(), instanceID, name)
if err != nil {
return nil, err
}
+ // A retry that already created the owner must not create a second one.
+ if _, err := OwnerUserID(ctx, inst.InstanceID); err == nil {
+ return inst, nil
+ }
+
u, err := provision.CreateUserWithHash(ctx, db.ControlDB(), inst.InstanceID,
ownerEmail, ownerPasswordHash, sharedmodels.RoleOwner, sharedmodels.AuthHQ)
if err != nil {
diff --git a/admin/internal/models/models.go b/admin/internal/models/models.go
index 13bf5f7..e4897e3 100644
--- a/admin/internal/models/models.go
+++ b/admin/internal/models/models.go
@@ -147,7 +147,11 @@ type Instance struct {
// checkout has something to attach custom_data to, before the customer has
// pasted their install's real UUID. Cleared when the instance is linked.
Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"`
- CreatedAt time.Time `bson:"created_at" json:"created_at"`
+ // PendingOwnerUserID is the customer_user who bought a paid-cloud placeholder,
+ // remembered so the confirmed-payment webhook can provision the instance with
+ // them as owner. Cleared once provisioned. Only ever set on a cloud placeholder.
+ PendingOwnerUserID string `bson:"pending_owner_user_id,omitempty" json:"-"`
+ CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// License is append-only. A renewal writes a new row and sets SupersededBy on
diff --git a/adminsite/app/(customer)/page.tsx b/adminsite/app/(customer)/page.tsx
index 4f3e2dd..0bbc8a8 100644
--- a/adminsite/app/(customer)/page.tsx
+++ b/adminsite/app/(customer)/page.tsx
@@ -70,10 +70,15 @@ export default function OverviewPage() {
title="Overview"
subtitle={subtitle}
actions={
- !hasFree && live.length > 0 ? (
-
- Create a free instance
-
+ live.length > 0 ? (
+ <>
+ {!hasFree && (
+
+ Create a free instance
+
+ )}
+ Buy a plan
+ >
) : undefined
}
record={[
@@ -96,7 +101,10 @@ export default function OverviewPage() {
server, and link it here to get your licence file.
- Create a free instance
+ Buy a plan
+
+ Create a free instance
+
Link an install
diff --git a/adminsite/app/(customer)/purchase/PurchaseForm.tsx b/adminsite/app/(customer)/purchase/PurchaseForm.tsx
index d731d77..48d0560 100644
--- a/adminsite/app/(customer)/purchase/PurchaseForm.tsx
+++ b/adminsite/app/(customer)/purchase/PurchaseForm.tsx
@@ -1,131 +1,985 @@
"use client";
-import { useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
+import Link from "next/link";
import { useMutation, useQuery } from "@tanstack/react-query";
-import { ApiError, api, lineItemsFor } from "@/lib/api";
-import { Button } from "@/components/Button";
-import { Field } from "@/components/Field";
-import { CheckoutButton } from "@/components/CheckoutButton";
-import PlanConfigurator, { type PlanChoice } from "@/components/PlanConfigurator";
+import {
+ ApiError,
+ api,
+ lineItemsFor,
+ type CatalogueRow,
+ type CheckoutOptions,
+ type Deployment,
+ type Plan,
+ type Term,
+ type Tier,
+} from "@/lib/api";
+import { initPaddle, previewPrices, type PricePreview } from "@/lib/paddle";
+
+/* Tiers in the order a customer reads them, cheapest first. */
+const TIER_ORDER: Tier[] = ["free", "professional", "enterprise"];
+
+/* Human labels for feature keys. The catalogue names them by key; this is the
+ * one place the customer-facing wording lives. */
+const FEATURE_LABEL: Record = {
+ console: "Browser console",
+ sso: "Single sign-on",
+};
+const FEATURE_DESC: Record = {
+ console: "In-browser SSH, RDP and VNC sessions",
+ sso: "OIDC sign-in for your whole team",
+};
+function featureLabel(key: string) {
+ return FEATURE_LABEL[key] ?? key;
+}
+
+interface Choice {
+ tier: Tier;
+ term: Term;
+ servers: number;
+ features: string[];
+}
+
+/* What a plan offers a given feature: included in the base, a paid add-on, or
+ * absent. Drives both the tier cards and the configurator toggles. */
+type FeatureState = "included" | "addon" | "absent";
+
+function featureStateFor(
+ plan: Plan | undefined,
+ rows: CatalogueRow[],
+ env: string,
+ term: Term,
+ key: string,
+): FeatureState {
+ if (plan?.base_features.includes(key)) return "included";
+ const row = rows.find((r) => r.kind === "feature" && r.feature_key === key);
+ const priced = Boolean(row?.price_ids?.[env]?.[term]);
+ return priced ? "addon" : "absent";
+}
-/*
- * Three visible steps: name (creates a placeholder), configure + pay, then link
- * the real install UUID. The placeholder exists before payment so the webhook
- * has something to attach custom_data to; the licence is only issued once the
- * install's real UUID is known.
- */
export function PurchaseForm() {
const router = useRouter();
const account = useQuery({ queryKey: ["account"], queryFn: api.account });
- const options = useQuery({ queryKey: ["checkout-options"], queryFn: api.checkoutOptions });
+ const optionsQ = useQuery({ queryKey: ["checkout-options"], queryFn: api.checkoutOptions });
- const [name, setName] = useState("");
- const [placeholderId, setPlaceholderId] = useState(null);
- const [uuid, setUuid] = useState("");
- const [error, setError] = useState(null);
- const [choice, setChoice] = useState({
+ const [dep, setDep] = useState("cloud");
+ const [choice, setChoice] = useState({
tier: "professional",
- term: "annual", // self-hosted is annual only
+ term: "annual",
servers: 3,
features: [],
});
+ const [name, setName] = useState("");
+ const [error, setError] = useState(null);
- const create = useMutation({
- mutationFn: () => api.createSelfHosted(name.trim()),
- onSuccess: (r) => setPlaceholderId(r.instance_id),
- onError: (e) => setError(e instanceof ApiError ? e.message : "Could not start. Try again."),
+ // Follow-up phase after a checkout has been started.
+ const [pending, setPending] = useState(null);
+ const [uuid, setUuid] = useState("");
+
+ const options = optionsQ.data;
+ const accountId = account.data?.account.account_id ?? "";
+
+ // Distinct feature keys offered on this deployment, in a stable order.
+ const featureKeys = useMemo(() => {
+ if (!options) return [] as string[];
+ const keys = new Set();
+ for (const r of options.catalogue) {
+ if (r.deployment === dep && r.kind === "feature" && r.feature_key) {
+ keys.add(r.feature_key);
+ }
+ }
+ return [...keys];
+ }, [options, dep]);
+
+ const activePlans = useMemo(
+ () =>
+ (options?.plans ?? [])
+ .filter((p) => p.deployment === dep && p.active)
+ .sort((a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier)),
+ [options, dep],
+ );
+ const plan = activePlans.find((p) => p.tier === choice.tier);
+ const baseServers = plan?.base_limits.max_servers ?? 0;
+ const unlimited = baseServers === -1;
+
+ const rows = useMemo(
+ () =>
+ (options?.catalogue ?? []).filter(
+ (r) => r.deployment === dep && r.tier === choice.tier,
+ ),
+ [options, dep, choice.tier],
+ );
+
+ // Real line items for the current configuration — the same builder the
+ // checkout uses, so the summary can never disagree with the overlay.
+ const items = useMemo(
+ () => (options ? lineItemsFor(options, choice, dep) : []),
+ [options, choice, dep],
+ );
+
+ // Real, localised prices from Paddle for those items.
+ const [receiptPrice, setReceiptPrice] = useState(null);
+ useEffect(() => {
+ let live = true;
+ previewPrices(items).then((p) => {
+ if (live) setReceiptPrice(p);
+ });
+ return () => {
+ live = false;
+ };
+ }, [items]);
+
+ // A headline "base" price per tier, all previewed in one call.
+ const [basePrices, setBasePrices] = useState>({});
+ useEffect(() => {
+ if (!options) return;
+ const baseItems: { priceId: string; quantity: number; tier: Tier }[] = [];
+ for (const p of activePlans) {
+ const row = options.catalogue.find(
+ (r) => r.deployment === dep && r.tier === p.tier && r.kind === "base",
+ );
+ const id = row?.price_ids?.[options.env]?.[choice.term];
+ if (id) baseItems.push({ priceId: id, quantity: 1, tier: p.tier });
+ }
+ let live = true;
+ previewPrices(baseItems.map(({ priceId, quantity }) => ({ priceId, quantity }))).then(
+ (p) => {
+ if (!live) return;
+ const next: Record = {};
+ if (p) {
+ for (const bi of baseItems) {
+ const line = p.lines[bi.priceId];
+ if (line) next[bi.tier] = line.total;
+ }
+ }
+ setBasePrices(next);
+ },
+ );
+ return () => {
+ live = false;
+ };
+ }, [options, dep, choice.term, activePlans]);
+
+ // --- actions -----------------------------------------------------------
+
+ const createFree = useMutation({
+ mutationFn: () => api.createInstance(name.trim()),
+ onSuccess: () => router.push("/"),
+ onError: (e) =>
+ setError(e instanceof ApiError ? e.message : "Could not create the instance."),
+ });
+
+ const startCheckout = useMutation({
+ mutationFn: async () => {
+ const trimmed = name.trim();
+ const r =
+ dep === "cloud"
+ ? await api.createCloudCheckout(trimmed)
+ : await api.createSelfHosted(trimmed);
+ return r.instance_id;
+ },
+ onSuccess: async (instanceId) => {
+ setPending({ instanceId, deployment: dep });
+ const paddle = await initPaddle();
+ paddle?.Checkout.open({
+ items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })),
+ customData: { account_id: accountId, instance_id: instanceId },
+ });
+ },
+ onError: (e) =>
+ setError(e instanceof ApiError ? e.message : "Could not start checkout."),
});
const claim = useMutation({
- mutationFn: () => api.claimLink(placeholderId!, uuid.trim()),
+ mutationFn: () => api.claimLink(pending!.instanceId, uuid.trim()),
onSuccess: () => router.push("/"),
- onError: (e) => setError(e instanceof ApiError ? e.message : "Could not link. Try again."),
+ onError: (e) => setError(e instanceof ApiError ? e.message : "Could not link the install."),
});
- const accountId = account.data?.account.account_id ?? "";
- const items =
- options.data && placeholderId
- ? lineItemsFor(options.data, choice, "self_hosted")
- : [];
-
- // Step 1 — name.
- if (!placeholderId) {
- return (
-
- );
+ if (optionsQ.isLoading || account.isLoading) {
+ return
Loading plans…
;
+ }
+ if (!options) {
+ return
Plans are unavailable right now. Try again shortly.