From a228ab24a0127bac56115aed017fc5249cd9829c Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 11 Aug 2026 09:30:19 +0100 Subject: [PATCH] feat: Changes to self hosted purchase --- CLAUDE.md | 5 +- admin/internal/api/checkout.go | 148 +++++++----------- admin/internal/api/routes.go | 7 +- admin/internal/api/staff.go | 9 +- admin/internal/billing/subscription.go | 59 +++---- admin/internal/licensing/link.go | 8 +- admin/internal/lifecycle/lifecycle.go | 61 -------- admin/internal/models/models.go | 13 +- admin/internal/paddle/client.go | 4 +- .../(customer)/instances/link/LinkForm.tsx | 74 --------- .../app/(customer)/instances/link/page.tsx | 41 ----- adminsite/app/(customer)/page.tsx | 8 +- .../app/(customer)/purchase/PurchaseForm.tsx | 48 ++---- adminsite/components/InstanceRecord.tsx | 12 +- adminsite/lib/api.ts | 9 +- docsite/docs/hq/billing.md | 12 +- docsite/docs/hq/self-hosted-instances.md | 42 +++-- shared/mail/licence.go | 8 - shared/mail/templates/linkreminder.html.tmpl | 7 - shared/mail/templates/linkreminder.txt.tmpl | 8 - 20 files changed, 150 insertions(+), 433 deletions(-) delete mode 100644 adminsite/app/(customer)/instances/link/LinkForm.tsx delete mode 100644 adminsite/app/(customer)/instances/link/page.tsx delete mode 100644 shared/mail/templates/linkreminder.html.tmpl delete mode 100644 shared/mail/templates/linkreminder.txt.tmpl diff --git a/CLAUDE.md b/CLAUDE.md index 6924625..59ab674 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -695,8 +695,7 @@ POST /instances/:id/claim-free # issue Free on a linked self-hos POST /instances/link · /instances/:id/relink GET /instances/:id/entitlement GET /checkout/options # active plans + catalogue prices for the running PADDLE_ENV -POST /instances/self-hosted # create a paid-checkout placeholder (awaiting_link, no licence) -POST /instances/:id/claim-link # bind a paid placeholder to the real UUID and issue +POST /instances/self-hosted # link (or reuse) the install's real UUID for a paid checkout PUT /instances/:id/entitlement # set desired config; pushes line items to Paddle (owner|admin) POST /billing/portal # mint a Paddle customer-portal URL GET /instances/:id/license · /instances/:id/license/download @@ -730,7 +729,7 @@ GET /health/injection · /health/billing Paddle is merchant of record; `admin/internal/paddle` is a thin REST client (no vendor SDK) and the only place that talks to it. **Free is entirely outside Paddle** — the shipped self-serve Free flow owns its own renewal, so no £0 subscription exists; an account learns its `paddle_customer_id` from its first paid webhook. Checkout happens in the browser (`@paddle/paddle-js`, token baked into the adminsite build); the server only updates a live subscription (`PUT /instances/:id/entitlement`) and mints a portal session. -`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's _current_ line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired`→`granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. Self-hosted purchase creates a placeholder instance before payment (`POST /instances/self-hosted`); the licence is issued only once the customer pastes the install's real UUID (`POST /instances/:id/claim-link`), because a licence binds to that UUID. +`POST /api/paddle/webhook` is the **only** issuing path for paid plans: signature-verified with `PADDLE_WEBHOOK_SECRET` (boot-required), idempotent via `paddle_events`, and a function of the subscription's _current_ line items — resolved back to a plan and configuration by `catalogue.ResolveItems`, so out-of-order delivery is correct by construction. A confirmed webhook promotes the entitlement `desired`→`granted` and signs from `granted` **only**; a checkout is built from `desired`. `subscription.canceled` and `past_due` take **no licence action** — the licence runs to its (grace-padded) expiry, then the existing lifecycle sweep lapses the instance. A renewal (`transaction.completed`, origin `subscription_recurring`) is the only moment a scheduled reduction collapses `desired` into `granted`. **Self-hosted purchase requires a standing control plane**: the customer pastes their install's real instance ID, `POST /instances/self-hosted` links it (or reuses one this account already owns, which is how Free upgrades to paid in place), and the checkout's `custom_data` names that UUID from the first event — so the webhook issues with no claim step and there is **no self-hosted placeholder**. A licence binds to the install's UUID, so buying before the install exists only ever deferred the same requirement behind a second identity to rewrite. `Placeholder` is now a cloud-only flag; a non-cloud placeholder reaching `handleSubscription` is a pre-change row and fails loudly rather than being guessed at. ## MongoDB Collections diff --git a/admin/internal/api/checkout.go b/admin/internal/api/checkout.go index 32029a6..cb5c9ed 100644 --- a/admin/internal/api/checkout.go +++ b/admin/internal/api/checkout.go @@ -1,6 +1,7 @@ package api import ( + "errors" "fmt" "net/http" "strings" @@ -8,7 +9,6 @@ import ( "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/audit" "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/auth" - "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/billing" "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/catalogue" "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/licensing" @@ -18,6 +18,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" ) // checkoutOptions serves everything the browser configurator needs to price a @@ -43,36 +44,67 @@ func checkoutOptions(c *gin.Context) { }) } -// createSelfHostedPlaceholder makes an instance row that exists only so a -// checkout has something to put in custom_data. It carries no licence and is -// flagged Placeholder until the customer pastes their install's real UUID. The -// generated id is temporary; linking replaces the identity. -func createSelfHostedPlaceholder(c *gin.Context) { +// createSelfHostedCheckout prepares a paid self-hosted checkout against the +// customer's REAL install UUID, and hands that id back for the checkout's +// custom_data. +// +// A licence binds to the install's UUID, so the buyer must have a control plane +// standing before they pay — the same precondition self-hosted Free already has. +// That is what removes the placeholder: there is no temporary identity to +// rewrite afterwards, the subscription's custom_data names the real instance +// from the first event, and the webhook issues with no claim step. +// +// An id this account already owns is REUSED rather than refused: upgrading a +// Free self-hosted install to a paid plan is the same purchase form, and +// refusing it would mean the only route to Professional was to unlink first. +// A UUID belonging to anyone else is still 409, from the unique index. +func createSelfHostedCheckout(c *gin.Context) { s := auth.Current(c) var body struct { - Name string `json:"name"` + InstanceID string `json:"instance_id"` + Name string `json:"name"` } - if err := c.ShouldBindJSON(&body); err != nil || body.Name == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"}) + if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.InstanceID) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"}) return } + instanceID := strings.TrimSpace(body.InstanceID) + name := strings.TrimSpace(body.Name) ctx := c.Request.Context() - inst := models.Instance{ - InstanceID: uuid.NewString(), - AccountID: s.AccountID, - Name: body.Name, - Deployment: license.DeploymentSelfHosted, - Status: models.StatusAwaitingLink, - Placeholder: true, - CreatedAt: time.Now().UTC(), - } - if _, err := db.Admin("admin_instances").InsertOne(ctx, inst); err != nil { + + var existing models.Instance + err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": instanceID, "account_id": s.AccountID}).Decode(&existing) + switch { + case err == nil: + if existing.Deployment != license.DeploymentSelfHosted { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "that instance is a cloud instance; change its plan from its own page"}) + return + } + c.JSON(http.StatusOK, gin.H{"instance_id": existing.InstanceID}) + return + case !errors.Is(err, mongo.ErrNoDocuments): c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "a name is required"}) + return + } + inst, err := licensing.LinkInstance(ctx, s.AccountID, instanceID, name) + if err != nil { + status := http.StatusBadRequest + if errors.Is(err, licensing.ErrAlreadyLinked) { + status = http.StatusConflict + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } audit.Write(ctx, models.AuditEntry{ - Actor: s.Email, Action: "instance.placeholder_created", AccountID: s.AccountID, - Target: inst.InstanceID, IP: c.ClientIP()}) + Actor: s.Email, Action: "instance.checkout_started", AccountID: s.AccountID, + Target: inst.InstanceID, Detail: "self-hosted", IP: c.ClientIP()}) c.JSON(http.StatusCreated, gin.H{"instance_id": inst.InstanceID}) } @@ -208,80 +240,6 @@ func updateEntitlement(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"entitlement": next, "pending": next.Pending()}) } -// claimPlaceholderLink binds a paid self-hosted placeholder to the customer's -// real install UUID, then issues. -// -// :id is the placeholder (generated at checkout, carried in the subscription's -// custom_data); the body carries the UUID the install actually reports. The -// licence must bind to that real UUID (spec 1 has no unbound licence), so the -// placeholder row's identity is rewritten to it and the subscription re-pointed, -// then billing issues from the recorded subscription. Linking and claiming stay -// one call here because, unlike Free, the payment already happened. -func claimPlaceholderLink(c *gin.Context) { - inst, ok := ownedInstance(c, c.Param("id")) - if !ok { - return - } - if !inst.Placeholder { - c.JSON(http.StatusBadRequest, gin.H{"error": "this instance is already linked"}) - return - } - var body struct { - InstanceID string `json:"instance_id"` - } - if err := c.ShouldBindJSON(&body); err != nil || body.InstanceID == "" { - c.JSON(http.StatusBadRequest, gin.H{"error": "instance_id is required"}) - return - } - ctx := c.Request.Context() - - // The real UUID must be free across every account — the unique index on - // instance_id is the tenant-isolation property, so refuse rather than collide. - if n, _ := db.Admin("admin_instances").CountDocuments(ctx, - bson.M{"instance_id": body.InstanceID}); n > 0 { - c.JSON(http.StatusConflict, gin.H{"error": "that instance ID is already linked"}) - return - } - - placeholderID := inst.InstanceID - if _, err := db.Admin("admin_instances").UpdateOne(ctx, - bson.M{"instance_id": placeholderID}, - bson.M{ - "$set": bson.M{ - "instance_id": body.InstanceID, - "status": models.StatusActive, - "placeholder": false, - }, - "$addToSet": bson.M{"previous_instance_ids": placeholderID}, - }); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - // Re-point the subscription rows from the placeholder id to the real UUID so - // billing.IssueForInstance finds it, and rewrite Paddle's own copy of - // custom_data — written at checkout, it still names the placeholder, and every - // later event on this subscription is decoded from it. - if err := licensing.RepointSubscriptions(ctx, placeholderID, body.InstanceID, inst.AccountID); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - - if err := billing.IssueForInstance(ctx, body.InstanceID); err != nil { - // The link stuck; issuance did not. The reconciler and a retry recover it, - // and the customer is not blocked from linking. Surface it, do not roll back. - c.JSON(http.StatusAccepted, gin.H{ - "instance_id": body.InstanceID, - "warning": "linked, but licence issuance is pending: " + err.Error()}) - return - } - audit.Write(ctx, models.AuditEntry{ - Actor: auth.Current(c).Email, Action: "instance.placeholder_linked", - AccountID: inst.AccountID, Target: body.InstanceID, - Detail: "from placeholder " + placeholderID, IP: c.ClientIP()}) - c.JSON(http.StatusOK, gin.H{"instance_id": body.InstanceID}) -} - // billingPortal mints a Paddle customer-portal URL. The account must already // have a paddle_customer_id, which it learns from its first subscription webhook. func billingPortal(c *gin.Context) { diff --git a/admin/internal/api/routes.go b/admin/internal/api/routes.go index 8c741e8..19d66e1 100644 --- a/admin/internal/api/routes.go +++ b/admin/internal/api/routes.go @@ -77,9 +77,11 @@ func Routes(cfg config.Config) http.Handler { claimFree) cust.GET("/instances/:id/entitlement", getEntitlement) cust.GET("/checkout/options", checkoutOptions) + // Paid self-hosted: links (or reuses) the customer's real install UUID so + // the checkout can name it. There is no placeholder and no claim step. cust.POST("/instances/self-hosted", auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), - createSelfHostedPlaceholder) + createSelfHostedCheckout) // Paid cloud: provisions a real instance the paid webhook then licenses. cust.POST("/instances/cloud", auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), @@ -88,9 +90,6 @@ func Routes(cfg config.Config) http.Handler { auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), updateEntitlement) cust.POST("/billing/portal", billingPortal) - cust.POST("/instances/:id/claim-link", - auth.RequireAccountRole(models.AccountRoleOwner, models.AccountRoleAdmin), - claimPlaceholderLink) cust.GET("/instances/:id/license", getInstanceLicense) cust.GET("/instances/:id/license/download", downloadInstanceLicense) cust.GET("/instances/:id/members", listInstanceMembers) diff --git a/admin/internal/api/staff.go b/admin/internal/api/staff.go index 762447b..693f86b 100644 --- a/admin/internal/api/staff.go +++ b/admin/internal/api/staff.go @@ -380,9 +380,12 @@ func staffListLicenses(c *gin.Context) { c.JSON(http.StatusOK, lics) } -// staffBillingHealth surfaces webhook handlers that failed and paid-but-unlinked -// placeholders, so a customer who paid and got nothing is visible rather than -// stuck in a support queue. +// staffBillingHealth surfaces webhook handlers that failed and placeholders +// still awaiting their instance, so a customer who paid and got nothing is +// visible rather than stuck in a support queue. +// +// Placeholders are a cloud-only path now; any self-hosted row still listed here +// predates the checkout change and needs issuing by hand. func staffBillingHealth(c *gin.Context) { ctx := c.Request.Context() failed := []models.PaddleEvent{} diff --git a/admin/internal/billing/subscription.go b/admin/internal/billing/subscription.go index 0e62b09..8d35654 100644 --- a/admin/internal/billing/subscription.go +++ b/admin/internal/billing/subscription.go @@ -69,12 +69,12 @@ func handleSubscription(ctx context.Context, ev Event) error { return fmt.Errorf("resolve items for subscription %s: %w", d.ID, err) } - // Resolve BEFORE recording. A self-hosted subscription's custom_data is - // written at checkout and names the placeholder; the claim rewrote the - // instance's identity to the install's real UUID and patched Paddle, but that - // patch is best-effort and any event already in flight still carries the old - // id. Writing it straight through would revert the linked subscription row and - // then fail to find the instance, wedging every renewal. + // Resolve BEFORE recording. custom_data names whatever id the checkout was + // opened against, and a relink since then has rewritten the instance's + // identity and patched Paddle — but that patch is best-effort and any event + // already in flight still carries the old id. Writing it straight through + // would revert the subscription row and then fail to find the instance, + // wedging every renewal. instanceID, inst, err := resolveInstance(ctx, d.CustomData.InstanceID) if err != nil { return fmt.Errorf("subscription %s names unknown instance %s: %w", @@ -102,15 +102,20 @@ func handleSubscription(ctx context.Context, ev Event) error { bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}}) } - // 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. + // A cloud placeholder is the payment-first path: the instance does not exist + // until this confirmed-payment event, so it is provisioned here and then + // issued (first term). Self-hosted has no placeholder — its checkout named + // the install's real UUID — so it falls straight through to issuance. + // An instance with no licence yet is a first purchase, not a change of plan. + // Self-hosted reaches that state through an ordinary link, so the placeholder + // flag no longer answers this on its own. reason := models.ReasonEntitlementChange + if inst.CurrentLicense == "" { + reason = models.ReasonNew + } if inst.Placeholder { if inst.Deployment != license.DeploymentCloud { - return nil + return fmt.Errorf("instance %s is a non-cloud placeholder, which no longer exists", inst.InstanceID) } provisioned, err := completeCloudPlaceholder(ctx, &inst) if err != nil { @@ -124,8 +129,8 @@ func handleSubscription(ctx context.Context, ev Event) error { } // resolveInstance finds the instance a webhook's custom_data names, following the -// identity trail when the id is one a placeholder claim or a relink has since -// replaced. It returns the instance's CURRENT id, which is the only id anything +// identity trail when the id is one a relink or a cloud placeholder's +// provisioning has since replaced. It returns the instance's CURRENT id, which is the only id anything // else should be written against. func resolveInstance(ctx context.Context, customDataID string) (string, models.Instance, error) { var inst models.Instance @@ -245,30 +250,6 @@ func handleCustomerUpdated(ctx context.Context, ev Event) error { return err } -// IssueForInstance issues from an instance's recorded subscription. Called when -// a self-hosted customer finally links a placeholder they have already paid for. -func IssueForInstance(ctx context.Context, instanceID string) error { - var sub models.Subscription - if err := db.Admin("subscriptions").FindOne(ctx, - bson.M{"instance_id": instanceID, "status": models.SubActive}).Decode(&sub); err != nil { - return fmt.Errorf("no active subscription for %s: %w", instanceID, err) - } - var inst models.Instance - if err := db.Admin("admin_instances").FindOne(ctx, - bson.M{"instance_id": instanceID}).Decode(&inst); err != nil { - return err - } - 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 { - return err - } - return promoteAndIssue(ctx, &inst, match, models.ReasonNew) -} - func toSubItems(items []catalogue.Item) []models.SubItem { out := make([]models.SubItem, 0, len(items)) for _, it := range items { @@ -308,7 +289,7 @@ func billingEmailFor(ctx context.Context, accountID string) string { // instanceNameFor is a best-effort display name for an email subject. func instanceNameFor(ctx context.Context, instanceID string) string { - // Alias-aware: a cancellation can name a placeholder id, and "your instance" + // Alias-aware: a cancellation can name an id a relink has replaced, and "your instance" // in place of the name the customer chose reads like the wrong email. _, inst, err := resolveInstance(ctx, instanceID) if err != nil || inst.Name == "" { diff --git a/admin/internal/licensing/link.go b/admin/internal/licensing/link.go index e275277..9d2c659 100644 --- a/admin/internal/licensing/link.go +++ b/admin/internal/licensing/link.go @@ -65,7 +65,7 @@ func LinkInstance(ctx context.Context, accountID, instanceID, name string) (*mod // // The local rewrite is returned as an error — issuance reads the subscription // back, so a half-moved row is worth failing on. The Paddle patch only logs: the -// customer must not be blocked from linking or relinking by an outbound API +// customer must not be blocked from relinking by an outbound API // failure, and the caller has already recorded the old id in // previous_instance_ids, which is what makes the webhook path correct whether or // not the patch lands. @@ -151,9 +151,9 @@ func Relink(ctx context.Context, accountID, oldID, newID string, staff bool) (*m return nil, fmt.Errorf("relink: %w", err) } - // A relink rewrites the instance's identity exactly as a placeholder claim - // does, so the same two things have to follow it: the subscription rows that - // named the old id, and Paddle's own copy of custom_data. Without this a + // A relink rewrites the instance's identity, so two things have to follow it: + // the subscription rows that named the old id, and Paddle's own copy of + // custom_data. Without this a // renewal after a relink cannot find its instance and the term never extends. if err := RepointSubscriptions(ctx, oldID, newID, accountID); err != nil { return nil, err diff --git a/admin/internal/lifecycle/lifecycle.go b/admin/internal/lifecycle/lifecycle.go index 95d2dc0..b628383 100644 --- a/admin/internal/lifecycle/lifecycle.go +++ b/admin/internal/lifecycle/lifecycle.go @@ -182,67 +182,6 @@ func runOnce(ctx context.Context) { if err := Run(runCtx); err != nil { log.Printf("lifecycle: %v", err) } - sweepAwaitingLink(runCtx) -} - -// Awaiting-link reminder keys. -const ( - noticeLink24 = "link_24" - noticeLink72 = "link_72" -) - -// sweepAwaitingLink chases self-hosted instances that were paid for but never -// linked: the subscription exists, the instance is still a placeholder. It -// emails a reminder at 24h and again at 72h. The staff dashboard already flags -// 48h; this is the active chasing on top of that. It never issues or deletes. -func sweepAwaitingLink(ctx context.Context) { - cur, err := db.Admin("admin_instances").Find(ctx, bson.M{ - "deployment": license.DeploymentSelfHosted, - "placeholder": true, - "status": models.StatusAwaitingLink, - }) - if err != nil { - return - } - var instances []models.Instance - if err := cur.All(ctx, &instances); err != nil { - return - } - now := time.Now().UTC() - for _, inst := range instances { - // Only chase placeholders a customer has actually paid for. - n, err := db.Admin("subscriptions").CountDocuments(ctx, - bson.M{"instance_id": inst.InstanceID, "status": models.SubActive}) - if err != nil || n == 0 { - continue - } - if !mail.Enabled() { - continue - } - to := accountEmail(ctx, inst.AccountID) - if to == "" { - continue - } - age := now.Sub(inst.CreatedAt) - var due string - if age > 72*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink72) { - due = noticeLink72 - } else if age > 24*time.Hour && !slices.Contains(inst.NoticesSent, noticeLink24) { - due = noticeLink24 - } - if due == "" { - continue - } - if err := mail.Default.SendLinkReminder(to, inst.Name); err != nil { - log.Printf("lifecycle: link reminder %s for %s: %v", due, inst.InstanceID, err) - continue - } - if _, err := db.Admin("admin_instances").UpdateOne(ctx, - bson.M{"instance_id": inst.InstanceID}, - bson.M{"$addToSet": bson.M{"notices_sent": due}}); err != nil { - log.Printf("lifecycle: record link notice %s for %s: %v", due, inst.InstanceID, err) - } - } } func accountEmail(ctx context.Context, accountID string) string { diff --git a/admin/internal/models/models.go b/admin/internal/models/models.go index e505b19..af2b5f2 100644 --- a/admin/internal/models/models.go +++ b/admin/internal/models/models.go @@ -143,14 +143,15 @@ type Instance struct { // clears it, so the next term starts the sequence again. It is what stops a // restart re-sending a notice. NoticesSent []string `bson:"notices_sent,omitempty" json:"notices_sent,omitempty"` - // Placeholder is true while a self-hosted instance row exists only so a - // 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 is true while a paid CLOUD instance row exists only so a + // checkout has something to attach custom_data to, before the confirmed + // payment provisions it. Cleared once provisioned. Self-hosted has no + // placeholder: its checkout names the install's real UUID. Placeholder bool `bson:"placeholder,omitempty" json:"placeholder,omitempty"` // PreviousInstanceIDs is every id this row has carried before its current one. - // A self-hosted row's identity is rewritten twice over its life — once when a - // paid placeholder is claimed, and again on each relink to a rebuilt server — - // and Paddle keeps its own copy of custom_data written at checkout. That copy + // A self-hosted row's identity is rewritten on each relink to a rebuilt + // server, and Paddle keeps its own copy of custom_data written at checkout. + // That copy // is patched on each rewrite, but the patch is best-effort and any event // already in flight still names an old id, so this is what lets a webhook // resolve to the right instance instead of erroring as unknown. diff --git a/admin/internal/paddle/client.go b/admin/internal/paddle/client.go index 5ddd1f7..2e5a19a 100644 --- a/admin/internal/paddle/client.go +++ b/admin/internal/paddle/client.go @@ -26,8 +26,8 @@ type Client interface { // customer changes their server count or features on an existing plan. UpdateSubscriptionItems(ctx context.Context, paddleSubscriptionID string, items []LineItem) error // UpdateSubscriptionCustomData replaces a subscription's custom_data. Used - // when a self-hosted placeholder is claimed: the checkout attached the - // placeholder id, and every later webhook must name the real install UUID. + // when a self-hosted instance is relinked to a rebuilt server: the checkout + // attached the old id, and every later webhook must name the new one. UpdateSubscriptionCustomData(ctx context.Context, paddleSubscriptionID string, data map[string]string) error // PortalSession returns a customer-portal URL for managing billing. PortalSession(ctx context.Context, paddleCustomerID string) (string, error) diff --git a/adminsite/app/(customer)/instances/link/LinkForm.tsx b/adminsite/app/(customer)/instances/link/LinkForm.tsx deleted file mode 100644 index c036586..0000000 --- a/adminsite/app/(customer)/instances/link/LinkForm.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { ApiError, NotConnected, api } from "@/lib/api"; -import { Button } from "@/components/Button"; -import { Field } from "@/components/Field"; - -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - -export function LinkForm({ - onLinked, - claimId, -}: { - onLinked: (instanceId: string) => void; - // The PAID placeholder awaiting its real install UUID: claim it in place. The - // name was chosen at checkout, so it is not asked for again. Self-hosted Free - // is created on the purchase page instead, not here. - claimId: string; -}) { - const [id, setId] = useState(""); - const [error, setError] = useState(); - const [busy, setBusy] = useState(false); - - async function submit(e: React.FormEvent) { - e.preventDefault(); - const value = id.trim(); - - // Checked here so a typo costs nothing and the message is instant. - if (!UUID_RE.test(value)) { - setError( - "That does not look like an instance ID. It should look like the example below.", - ); - return; - } - - setBusy(true); - setError(undefined); - try { - const inst = await api.claimLink(claimId, value); - onLinked(inst.instance_id); - } catch (err) { - setError( - err instanceof NotConnected - ? "The licensing service is not reachable from this page." - : err instanceof ApiError - ? err.message - : "Could not link that instance. Try again.", - ); - } finally { - setBusy(false); - } - } - - return ( -
- setId(e.target.value)} - error={error} - hint={ - <> - Find this on your install’s Settings → Licence page, or on - the setup screen just after you first sign in. It looks like{" "} - 6a0fe3f0-49d2-4aa1-967c-a3094b200b5d. - - } - /> - - - ); -} diff --git a/adminsite/app/(customer)/instances/link/page.tsx b/adminsite/app/(customer)/instances/link/page.tsx deleted file mode 100644 index 1945e13..0000000 --- a/adminsite/app/(customer)/instances/link/page.tsx +++ /dev/null @@ -1,41 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; -import { LinkForm } from "./LinkForm"; -import { PageHeader } from "@/components/PageHeader"; - -export default function LinkPage() { - const router = useRouter(); - const qc = useQueryClient(); - // This page only claims a PAID placeholder's real install UUID. Self-hosted - // Free is created on the purchase page, so with no placeholder to claim there - // is nothing to do here send them there. - const claimId = useSearchParams().get("claim") ?? undefined; - - useEffect(() => { - if (!claimId) router.replace("/purchase"); - }, [claimId, router]); - - if (!claimId) return null; - - return ( -
- - { - qc.invalidateQueries({ queryKey: ["account"] }); - // Straight to the download, not back to a list: the licence is - // the thing they came for. - router.push(`/instances/${instanceId}`); - }} - /> -
- ); -} diff --git a/adminsite/app/(customer)/page.tsx b/adminsite/app/(customer)/page.tsx index b329d4c..ef3c349 100644 --- a/adminsite/app/(customer)/page.tsx +++ b/adminsite/app/(customer)/page.tsx @@ -53,10 +53,10 @@ export default function OverviewPage() { return [ { id: i.instance_id, - text: `${name} is waiting for an install ID`, - note: "You have paid for this. Paste the UUID from the install to get your licence.", - href: i.status === "awaiting_link" ? `/instances/link?claim=${i.instance_id}` : "/purchase", - action: i.status === "awaiting_link" ? "Link install" : "Get a licence", + text: `${name} has no licence yet`, + note: "Pick a plan and we will issue a licence for this install.", + href: "/purchase", + action: "Get a licence", tag: "", }, ]; diff --git a/adminsite/app/(customer)/purchase/PurchaseForm.tsx b/adminsite/app/(customer)/purchase/PurchaseForm.tsx index 3925b22..df9da0c 100644 --- a/adminsite/app/(customer)/purchase/PurchaseForm.tsx +++ b/adminsite/app/(customer)/purchase/PurchaseForm.tsx @@ -142,10 +142,13 @@ export function PurchaseForm() { onError: (e) => setError(e instanceof ApiError ? e.message : "Could not create the licence."), }); + // Self-hosted checkout names the install's REAL UUID, so the instance is + // linked (or an already-owned one reused) before Paddle opens. The webhook + // then issues straight onto it — there is no placeholder to claim afterwards. const startCheckout = useMutation({ mutationFn: async () => { const trimmed = name.trim(); - const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHosted(trimmed); + const r = dep === "cloud" ? await api.createCloudCheckout(trimmed) : await api.createSelfHostedCheckout(uuid.trim(), trimmed); return r.instance_id; }, onSuccess: async (instanceId) => { @@ -159,12 +162,6 @@ export function PurchaseForm() { onError: (e) => setError(e instanceof ApiError ? e.message : "Could not start checkout."), }); - const claim = useMutation({ - mutationFn: () => api.claimLink(pending!.instanceId, uuid.trim()), - onSuccess: () => router.push("/"), - onError: (e) => setError(e instanceof ApiError ? e.message : "Could not link the install."), - }); - if (optionsQ.isLoading || account.isLoading) { return

Loading plans…

; } @@ -308,11 +305,13 @@ export function PurchaseForm() { )} - {selfHostedFree && ( - + {dep === "self_hosted" && ( +

- Install Vantage on your own server first, then paste the instance ID it reports. We register it and issue your Free licence nothing to pay. + {paid + ? "Every licence binds to one install, so stand your control plane up first and paste the instance ID it reports. We attach it to your account now and the licence lands the moment payment clears. Already have an instance here? Paste its ID to upgrade it." + : "Install Vantage on your own server first, then paste the instance ID it reports. We register it and issue your Free licence — nothing to pay."}