feat: Changes to self hosted purchase
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string | undefined>();
|
||||
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 (
|
||||
<form onSubmit={submit} className="grid gap-4" noValidate>
|
||||
<Field
|
||||
label="Instance ID"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
error={error}
|
||||
hint={
|
||||
<>
|
||||
Find this on your install’s <code>Settings → Licence</code> page, or on
|
||||
the setup screen just after you first sign in. It looks like{" "}
|
||||
<code>6a0fe3f0-49d2-4aa1-967c-a3094b200b5d</code>.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Button type="submit" disabled={busy} className="justify-self-start">
|
||||
{busy ? "Linking…" : "Link and issue licence"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="grid max-w-2xl gap-6">
|
||||
<PageHeader
|
||||
back={{ href: "/", label: "Overview" }}
|
||||
title="Link an install"
|
||||
subtitle="Every licence is tied to one install, so we need its ID before we can issue yours. Paste it below and your licence is ready on the next screen."
|
||||
/>
|
||||
<LinkForm
|
||||
claimId={claimId}
|
||||
onLinked={(instanceId) => {
|
||||
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}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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: "",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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 <p className="text-ink-3">Loading plans…</p>;
|
||||
}
|
||||
@@ -308,11 +305,13 @@ export function PurchaseForm() {
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{selfHostedFree && (
|
||||
<Block n={3} label="Your install">
|
||||
{dep === "self_hosted" && (
|
||||
<Block n={paid ? 4 : 3} label="Your install">
|
||||
<div className="grid gap-3 rounded border border-rule bg-panel p-4">
|
||||
<p className="text-[0.86rem] text-ink-2">
|
||||
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."}
|
||||
</p>
|
||||
<label className="grid gap-1">
|
||||
<span className="text-[0.72rem] font-semibold uppercase tracking-[0.08em] text-ink-3">Instance ID</span>
|
||||
@@ -380,7 +379,7 @@ export function PurchaseForm() {
|
||||
) : (
|
||||
<Cta
|
||||
label={startCheckout.isPending ? "Starting…" : "Continue to payment"}
|
||||
disabled={!name.trim() || items.length === 0 || !accountId || startCheckout.isPending}
|
||||
disabled={!name.trim() || items.length === 0 || !accountId || startCheckout.isPending || (dep === "self_hosted" && !UUID_RE.test(uuid.trim()))}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
startCheckout.mutate();
|
||||
@@ -389,28 +388,13 @@ export function PurchaseForm() {
|
||||
))}
|
||||
|
||||
{/* Phase B: after the checkout has been opened. */}
|
||||
{pending?.deployment === "self_hosted" && (
|
||||
{pending && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="text-[0.8rem] text-ink-2">Once payment clears, paste the instance ID your install reports (Settings → Licence) to receive your licence.</p>
|
||||
<input
|
||||
value={uuid}
|
||||
onChange={(e) => setUuid(e.target.value)}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
className="rounded border border-rule bg-panel px-2.5 py-2 font-mono text-[0.82rem] text-ink placeholder:text-ink-3"
|
||||
/>
|
||||
<Cta
|
||||
label={claim.isPending ? "Linking…" : "Link and issue licence"}
|
||||
disabled={!uuid.trim() || claim.isPending}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
claim.mutate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{pending?.deployment === "cloud" && (
|
||||
<div className="grid gap-2 border-t border-rule-soft pt-3">
|
||||
<p className="text-[0.8rem] text-ink-2">Your instance is being set up. Its licence appears the moment payment clears no further steps.</p>
|
||||
<p className="text-[0.8rem] text-ink-2">
|
||||
{pending.deployment === "cloud"
|
||||
? "Your instance is being set up. Its licence appears the moment payment clears — no further steps."
|
||||
: "Your install is attached to this account. Its licence appears the moment payment clears — no further steps."}
|
||||
</p>
|
||||
<Link href={`/instances/${pending.instanceId}`} className="font-semibold text-accent underline">
|
||||
Go to your instance
|
||||
</Link>
|
||||
|
||||
@@ -156,14 +156,10 @@ export function InstanceRecord({ instance, license, reapAfterDays, defaultOpen =
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
{state === "none" ? (
|
||||
// A paid placeholder (awaiting_link) claims its real install
|
||||
// UUID in place. Anything else without a licence gets one from
|
||||
// the purchase page (self-hosted Free is created there).
|
||||
instance.status === "awaiting_link" ? (
|
||||
<LinkButton href={`/instances/link?claim=${instance.instance_id}`}>Link an install</LinkButton>
|
||||
) : (
|
||||
<LinkButton href="/purchase">Get a licence</LinkButton>
|
||||
)
|
||||
// Every unlicensed instance is answered from the purchase
|
||||
// page — self-hosted Free and paid both start there, and
|
||||
// both name the install's own UUID.
|
||||
<LinkButton href="/purchase">Get a licence</LinkButton>
|
||||
) : cloud && instance.slug ? (
|
||||
<>
|
||||
<LinkButton external href={`https://${instance.slug}.vantage.hostxtra.co.uk`}>
|
||||
|
||||
@@ -317,8 +317,10 @@ export const api = {
|
||||
entitlement: (id: string) =>
|
||||
req<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`),
|
||||
checkoutOptions: () => req<CheckoutOptions>("/api/checkout/options"),
|
||||
createSelfHosted: (name: string) =>
|
||||
post<{ instance_id: string }>("/api/instances/self-hosted", { name }),
|
||||
// Paid self-hosted: links (or reuses) the install's real UUID, which the
|
||||
// checkout then names. There is no placeholder to claim afterwards.
|
||||
createSelfHostedCheckout: (instance_id: string, name: string) =>
|
||||
post<{ instance_id: string }>("/api/instances/self-hosted", { instance_id, name }),
|
||||
// Paid cloud: provisions the real instance the paid webhook then licenses.
|
||||
createCloudCheckout: (name: string) =>
|
||||
post<{ instance_id: string }>("/api/instances/cloud", { name }),
|
||||
@@ -326,9 +328,6 @@ export const api = {
|
||||
id: string,
|
||||
body: { tier: Tier; term: Term; servers: number; features: string[] },
|
||||
) => put<{ entitlement: Entitlement; pending: boolean }>(`/api/instances/${id}/entitlement`, body),
|
||||
claimLink: (placeholderId: string, instance_id: string) =>
|
||||
post<{ instance_id: string; warning?: string }>(
|
||||
`/api/instances/${placeholderId}/claim-link`, { instance_id }),
|
||||
billingPortal: () => post<{ url: string }>("/api/billing/portal"),
|
||||
|
||||
accountUsers: () => req<AccountUser[]>("/api/account/users"),
|
||||
|
||||
@@ -27,14 +27,16 @@ The instance is created and licensed as soon as payment confirms.
|
||||
|
||||
### Self-hosted
|
||||
|
||||
Install your control plane first — a licence binds to its instance ID.
|
||||
|
||||
1. Set **Deployment** to **Self-hosted**.
|
||||
2. Choose a **Billing** cycle, monthly or annual.
|
||||
3. Choose a **Plan** and configure its features and server allowance.
|
||||
2. Choose a **Plan** and configure its features and server allowance.
|
||||
3. Under **Your install**, paste the instance ID from your install's **Licence**
|
||||
page. An ID you already hold is upgraded in place.
|
||||
4. Enter an **Instance name** and click **Continue to payment**.
|
||||
|
||||
Payment creates a placeholder instance with no licence yet. You then paste your
|
||||
install's instance ID to have the licence issued. See
|
||||
[Self-hosted instances](./self-hosted-instances.md).
|
||||
The licence is issued as soon as payment confirms; download it and paste it into
|
||||
your install. See [Self-hosted instances](./self-hosted-instances.md).
|
||||
|
||||
## Cancelling and failed payments
|
||||
|
||||
|
||||
@@ -14,39 +14,33 @@ Install first, then link and claim. Step by step in
|
||||
|
||||
## Paid
|
||||
|
||||
Buying happens **before** the install has to exist, because you may well be
|
||||
buying in order to build it.
|
||||
**Install first.** A licence is issued to one instance, so your control plane
|
||||
has to exist and report an instance ID before you can buy for it — the same
|
||||
precondition Free has.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Buy in Vantage HQ"] --> B["Placeholder instance<br/>no licence yet"]
|
||||
B --> C["Install Vantage<br/>find its instance ID"]
|
||||
C --> D["Paste your install's ID"]
|
||||
D --> E["Licence issued<br/>for that instance"]
|
||||
A["Install Vantage<br/>find its instance ID"] --> B["Buy in Vantage HQ<br/>paste that ID"]
|
||||
B --> C["Payment confirms"]
|
||||
C --> D["Licence issued<br/>for that instance"]
|
||||
```
|
||||
|
||||
1. On **Overview**, choose **License my own install**, or **Buy a plan** if you
|
||||
already have an instance. Pick **Self-hosted**, then your tier, billing cycle
|
||||
1. [Install Vantage](../getting-started/self-hosted-install.md) and find its
|
||||
instance ID on the **Licence** page.
|
||||
2. On **Overview**, choose **Buy a plan**. Pick **Self-hosted**, then your tier
|
||||
and configuration.
|
||||
2. Complete checkout. Vantage HQ creates a **placeholder** instance, waiting to
|
||||
be linked, with no licence yet.
|
||||
3. [Install Vantage](../getting-started/self-hosted-install.md) if you have not
|
||||
already, and find its instance ID on the **Licence** page.
|
||||
4. Back in Vantage HQ, open the placeholder and paste that ID.
|
||||
5. The licence is issued. Download it and paste it into your install.
|
||||
3. Under **Your install**, paste the instance ID. Enter an **Instance name** and
|
||||
click **Continue to payment**.
|
||||
4. The licence is issued as soon as payment confirms. Download it and paste it
|
||||
into your install.
|
||||
|
||||
:::info Why there is a placeholder
|
||||
A licence is issued to one instance, and at the moment you pay, that install may
|
||||
not exist yet. The placeholder holds your purchase until it does.
|
||||
:::
|
||||
## Upgrading an instance you already have
|
||||
|
||||
## Linking an install you already have
|
||||
Paste the same instance ID you already hold — an install on Free moves to the
|
||||
paid plan in place, keeping its ID and its history. An ID belonging to another
|
||||
account is refused.
|
||||
|
||||
If the install exists before the purchase, the flow is the same: buy, then open
|
||||
the placeholder from **Overview**, choose **Link install** and paste its instance
|
||||
ID. An ID already claimed by another account is refused.
|
||||
|
||||
For a Free licence there is no placeholder step. See
|
||||
For a Free licence, see
|
||||
[Claim a Free licence](../getting-started/claim-free-licence.md).
|
||||
|
||||
## Relinking
|
||||
|
||||
@@ -71,11 +71,3 @@ func (s Sender) SendDeletionWarning(to, instanceName, portalURL string, deleteOn
|
||||
DeleteOn time.Time
|
||||
}{instanceName, portalURL, when, deleteOn})
|
||||
}
|
||||
|
||||
// SendLinkReminder chases a self-hosted customer who paid but never linked.
|
||||
func (s Sender) SendLinkReminder(to, instanceName string) error {
|
||||
return s.sendTemplate(to, "", "linkreminder", struct {
|
||||
InstanceName string
|
||||
PortalURL string
|
||||
}{instanceName, s.PublicURL})
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Action needed" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Finish setting up {{.InstanceName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "Your subscription for %s is active, but the instance is not linked yet." .InstanceName)}}
|
||||
{{template "p" "Paste your install's ID in the portal to receive your licence."}}
|
||||
{{if .PortalURL}}{{template "button" (dict "label" "Link my install" "url" .PortalURL)}}{{end}}
|
||||
{{end}}
|
||||
@@ -1,8 +0,0 @@
|
||||
{{define "subject"}}Finish setting up {{.InstanceName}}{{end}}
|
||||
{{define "pill"}}{{template "chip" (dict "label" "Action needed" "tone" "pend")}}{{end}}
|
||||
{{define "title"}}Finish setting up {{.InstanceName}}{{end}}
|
||||
{{define "body"}}
|
||||
{{template "lead" (printf "Your subscription for %s is active, but the instance is not linked yet." .InstanceName)}}
|
||||
{{template "p" "Paste your install's ID in the portal to receive your licence."}}
|
||||
{{if .PortalURL}}{{template "button" (dict "label" "Link my install" "url" .PortalURL)}}{{end}}
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user