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 ? (
-
Loading plans…
; + } + if (!options) { + returnPlans are unavailable right now. Try again shortly.
; } - // Step 2 + 3 — configure & pay, then link. - return ( -Loading plans…
- )} -- After payment, paste the instance ID your Vantage install reports (Settings → - Licence). Your licence is issued the moment it is linked. -
-