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 ( -
{ - e.preventDefault(); - setError(null); - if (name.trim()) create.mutate(); - }} - > - setName(e.target.value)} - placeholder="Northgate Systems" - required - error={error ?? undefined} - /> - - - ); + if (optionsQ.isLoading || account.isLoading) { + return

Loading plans…

; + } + if (!options) { + return

Plans are unavailable right now. Try again shortly.

; } - // Step 2 + 3 — configure & pay, then link. - return ( -
-
-

Configure

- {options.data ? ( - - ) : ( -

Loading plans…

- )} -
- -
-
+ const selfHostedFree = dep === "self_hosted" && choice.tier === "free"; + const cloudFree = dep === "cloud" && choice.tier === "free"; + const paid = choice.tier !== "free"; -
-

Link your install

-

- After payment, paste the instance ID your Vantage install reports (Settings → - Licence). Your licence is issued the moment it is linked. -

-
- setUuid(e.target.value)} - placeholder="00000000-0000-0000-0000-000000000000" - error={error ?? undefined} - /> - + options={[ + { + value: "cloud", + icon: cloudIcon, + title: "Cloud", + sub: "We host and manage it · monthly or annual", + }, + { + value: "self_hosted", + icon: serverIcon, + title: "Self-hosted", + sub: "Runs on your own servers · annual only", + }, + ]} + /> + + + {dep === "cloud" && ( + + setChoice((c) => ({ ...c, term: v as Term }))} + options={[ + { + value: "monthly", + icon: calendarIcon, + title: "Monthly", + sub: "Pay as you go · cancel anytime", + }, + { + value: "annual", + icon: annualIcon, + title: "Annual", + sub: "2 months free vs monthly", + }, + ]} + /> + + )} + + +
+ {activePlans.map((p) => ( + r.deployment === dep && r.tier === p.tier, + )} + env={options.env} + term={choice.term} + onSelect={() => + setChoice((c) => ({ + ...c, + tier: p.tier, + // Moving tier moves the floor; clamp up. + servers: Math.max( + c.servers, + p.base_limits.max_servers === -1 + ? c.servers + : p.base_limits.max_servers, + ), + // Drop add-ons the new tier does not sell. + features: c.features.filter((k) => { + const st = featureStateFor( + p, + options.catalogue.filter( + (r) => + r.deployment === dep && r.tier === p.tier, + ), + options.env, + c.term, + k, + ); + return st === "addon"; + }), + })) + } + /> + ))} +
+
+ + {paid && ( + +
+ {/* servers */} + + {unlimited ? ( + + Unlimited + + ) : ( + setChoice((c) => ({ ...c, servers }))} + /> + )} + + + {/* features */} + {featureKeys.map((key) => { + const st = featureStateFor( + plan, + rows, + options.env, + choice.term, + key, + ); + return ( + + {st === "included" ? ( + + Included + + ) : st === "absent" ? ( + + Not in this plan + + ) : ( + + setChoice((c) => ({ + ...c, + features: on + ? [...c.features, key] + : c.features.filter((f) => f !== key), + })) + } + /> + )} + + ); + })} +
+
+ )} + + {selfHostedFree && ( + +
+ Free self-hosted starts with your install.{" "} + Install Vantage on your own server, link the instance ID it reports, then + claim your free licence — there is nothing to pay for here. +
+ + Link an install + +
+
+
+ )} +
+ + {/* ---- receipt rail ---- */} +
+
); } + +// --------------------------------------------------------------------------- +// Presentational pieces +// --------------------------------------------------------------------------- + +function cycleShort(dep: Deployment, term: Term) { + return dep === "cloud" ? (term === "annual" ? "/yr" : "/mo") : "/yr"; +} + +function Block({ n, label, children }: { n: number; label: string; children: React.ReactNode }) { + return ( +
+

+ {n} + {label} +

+ {children} +
+ ); +} + +interface SegOption { + value: string; + icon: React.ReactNode; + title: string; + sub: string; +} + +function Seg({ + value, + onChange, + options, +}: { + value: string; + onChange: (v: string) => void; + options: SegOption[]; +}) { + return ( +
+ {options.map((o) => { + const on = o.value === value; + return ( + + ); + })} +
+ ); +} + +function TierCard({ + plan, + selected, + headline, + cycleLabel, + featureKeys, + catalogue, + env, + term, + onSelect, +}: { + plan: Plan; + selected: boolean; + headline?: string; + cycleLabel: string; + featureKeys: string[]; + catalogue: CatalogueRow[]; + env: string; + term: Term; + onSelect: () => void; +}) { + const base = plan.base_limits.max_servers; + const servers = + base === -1 ? "Unlimited servers" : `${base} server${base === 1 ? "" : "s"} included`; + return ( + + ); +} + +function supportLabel(level: string) { + switch (level) { + case "community": + return "Community"; + case "email_24_5": + return "Email, 24/5"; + case "email_call_24_7": + return "Email + call, 24/7"; + default: + return level; + } +} + +function FeatureLine({ on, children }: { on: boolean; children: React.ReactNode }) { + return ( +
  • + + {on ? ( + + + + ) : ( + + + + )} + + {children} +
  • + ); +} + +function Row({ + title, + desc, + dim, + children, +}: { + title: string; + desc: string; + dim?: boolean; + children: React.ReactNode; +}) { + return ( +
    +
    +

    {title}

    + {desc &&

    {desc}

    } +
    +
    {children}
    +
    + ); +} + +function Stepper({ + value, + min, + max, + onChange, +}: { + value: number; + min: number; + max: number; + onChange: (v: number) => void; +}) { + const clamp = (v: number) => Math.min(max, Math.max(min, v)); + return ( +
    + + onChange(clamp(parseInt(e.target.value) || min))} + className="h-9 w-14 border-x border-rule bg-panel text-center text-[0.9rem] font-bold tabular-nums text-ink" + /> + +
    + ); +} + +function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ); +} + +function Receipt({ + options, + dep, + choice, + plan, + items, + price, +}: { + options: CheckoutOptions; + dep: Deployment; + choice: Choice; + plan: Plan | undefined; + items: { priceId: string; quantity: number }[]; + price: PricePreview | null; +}) { + if (choice.tier === "free") { + return ( +
    +
    + + {plan?.name ?? "Free"} plan + + {plan?.base_limits.max_servers ?? 1} server · community support + + + £0 +
    +
    + ); + } + + // Label each real line item from the catalogue, and price it from Paddle. + const base = plan?.base_limits.max_servers ?? 0; + const extra = base === -1 ? 0 : Math.max(0, choice.servers - base); + const rows = options.catalogue.filter( + (r) => r.deployment === dep && r.tier === choice.tier, + ); + const idFor = (predicate: (r: CatalogueRow) => boolean) => { + const row = rows.find(predicate); + return row?.price_ids?.[options.env]?.[choice.term] ?? ""; + }; + const amount = (priceId: string) => price?.lines[priceId]?.total ?? null; + + const lines: { label: string; sub?: string; value: string | null }[] = []; + const baseId = idFor((r) => r.kind === "base"); + lines.push({ + label: `${plan?.name ?? ""} base`, + sub: base === -1 ? "unlimited servers" : `${base} servers included`, + value: amount(baseId), + }); + if (extra > 0) { + lines.push({ + label: "Extra servers", + sub: `${extra} × per server`, + value: amount(idFor((r) => r.kind === "limit" && r.limit_key === "max_servers")), + }); + } + for (const key of choice.features) { + const id = idFor((r) => r.kind === "feature" && r.feature_key === key); + if (id) lines.push({ label: featureLabel(key), sub: "add-on", value: amount(id) }); + } + + const priced = price !== null; + return ( +
    +
    + {lines.map((l, i) => ( +
    + + {l.label} + {l.sub && ( + {l.sub} + )} + + + {l.value ?? "—"} + +
    + ))} +
    +
    + Total + + {priced && price?.total ? price.total : "—"} + +
    +

    + {priced + ? dep === "cloud" + ? choice.term === "annual" + ? "per year, billed annually" + : "per month, billed monthly" + : "per year, billed annually" + : items.length > 0 + ? "Final price shown at checkout." + : ""} +

    +
    + ); +} + +function Cta({ + label, + onClick, + disabled, + variant = "solid", +}: { + label: string; + onClick: () => void; + disabled?: boolean; + variant?: "solid" | "line"; +}) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Icons +// --------------------------------------------------------------------------- + +const cloudIcon = ( + + + +); +const serverIcon = ( + + + + + +); +const calendarIcon = ( + + + + +); +const annualIcon = ( + + + +); +function LockIcon() { + return ( + + + + + ); +} diff --git a/adminsite/app/(customer)/purchase/page.tsx b/adminsite/app/(customer)/purchase/page.tsx index bba6635..123a2ef 100644 --- a/adminsite/app/(customer)/purchase/page.tsx +++ b/adminsite/app/(customer)/purchase/page.tsx @@ -2,15 +2,15 @@ import type { Metadata } from "next"; import { PurchaseForm } from "./PurchaseForm"; import { PageHeader } from "@/components/PageHeader"; -export const metadata: Metadata = { title: "Buy a self-hosted plan" }; +export const metadata: Metadata = { title: "Buy a plan" }; export default function PurchasePage() { return (
    diff --git a/adminsite/lib/api.ts b/adminsite/lib/api.ts index d649314..dd60afc 100644 --- a/adminsite/lib/api.ts +++ b/adminsite/lib/api.ts @@ -319,6 +319,9 @@ export const api = { checkoutOptions: () => req("/api/checkout/options"), createSelfHosted: (name: string) => post<{ instance_id: string }>("/api/instances/self-hosted", { name }), + // Paid cloud: provisions the real instance the paid webhook then licenses. + createCloudCheckout: (name: string) => + post<{ instance_id: string }>("/api/instances/cloud", { name }), updateEntitlement: ( id: string, body: { tier: Tier; term: Term; servers: number; features: string[] }, diff --git a/adminsite/lib/paddle.ts b/adminsite/lib/paddle.ts index 246f5d8..4b224ac 100644 --- a/adminsite/lib/paddle.ts +++ b/adminsite/lib/paddle.ts @@ -15,3 +15,62 @@ export function initPaddle(): Promise { } return cached; } + +export interface PricedLine { + priceId: string; + /* Already localised and currency-formatted by Paddle, e.g. "£39.00". The line + * total for the quantity, not the unit price. */ + total: string; + unit: string; +} + +export interface PricePreview { + currency: string; + /* Grand total, formatted. */ + total: string; + lines: Record; +} + +/* + * previewPrices asks Paddle for the real localised prices of a set of line items, + * so the order summary shows what the customer will actually pay rather than a + * hardcoded number that would drift from the dashboard. + * + * It returns null when Paddle is unavailable or a price cannot be previewed (an + * unconfigured sandbox price, an ad blocker). The caller falls back to showing + * the line items without amounts rather than a wrong total — the real figure + * still appears in the checkout overlay, which is the authority. + */ +export async function previewPrices( + items: { priceId: string; quantity: number }[], +): Promise { + if (items.length === 0) return { currency: "", total: "", lines: {} }; + const paddle = await initPaddle(); + if (!paddle) return null; + try { + const res = await paddle.PricePreview({ + items: items.map((i) => ({ priceId: i.priceId, quantity: i.quantity })), + }); + const currency = res.data.currencyCode; + const lines: Record = {}; + // Paddle gives per-line totals but no grand total, so sum the raw minor + // units and format once. The checkout overlay is the authority; this is + // the honest preview beside it. + let subtotalMinor = 0; + for (const li of res.data.details.lineItems) { + lines[li.price.id] = { + priceId: li.price.id, + total: li.formattedTotals.subtotal, + unit: li.formattedUnitTotals.subtotal, + }; + subtotalMinor += Number.parseInt(li.totals.subtotal, 10) || 0; + } + const total = new Intl.NumberFormat(undefined, { + style: "currency", + currency, + }).format(subtotalMinor / 100); + return { currency, total, lines }; + } catch { + return null; + } +} diff --git a/shared/provision/instance.go b/shared/provision/instance.go index 3dc6bde..19fa8f0 100644 --- a/shared/provision/instance.go +++ b/shared/provision/instance.go @@ -17,13 +17,35 @@ var ErrNameRejected = errors.New("organisation name rejected") const maxSlugAttempts = 50 -// CreateInstance inserts an instance under the first free slug derived from name. +// CreateInstance inserts an instance under the first free slug derived from name, +// with a freshly generated ID. +func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*models.Instance, error) { + return CreateInstanceWithID(ctx, db, uuid.NewString(), name) +} + +// CreateInstanceWithID inserts an instance under the first free slug derived from +// name, using a caller-supplied instance ID. +// +// A caller-supplied ID exists for the paid-cloud flow: a placeholder row is +// created before payment and provisioning happens on the confirmed-payment +// webhook. Provisioning with the placeholder's own ID keeps the id stable, so +// the subscription's custom_data never points at a rewritten row and later +// webhooks still resolve it. If an instance with this ID already exists — a +// webhook retried after a partial provision — it is returned as-is rather than +// duplicated. // // The count-then-insert loop is racy on its own. It is safe only because // instances.slug carries a unique index: a lost race surfaces as a duplicate-key // error, which we treat as "that slug is taken" and retry. Do not remove the // duplicate-key branch, and do not remove the index. -func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*models.Instance, error) { +func CreateInstanceWithID(ctx context.Context, db *mongo.Database, instanceID, name string) (*models.Instance, error) { + // Idempotency: a retried provision finds its own instance already present. + var existing models.Instance + if err := db.Collection("instances").FindOne(ctx, + bson.M{"instance_id": instanceID}).Decode(&existing); err == nil { + return &existing, nil + } + base, err := BaseSlug(name) if err != nil { return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error()) @@ -41,7 +63,7 @@ func CreateInstance(ctx context.Context, db *mongo.Database, name string) (*mode } inst := models.Instance{ - InstanceID: uuid.NewString(), + InstanceID: instanceID, Name: name, Slug: slug, CreatedAt: time.Now().UTC(),