diff --git a/admin/internal/api/checkout.go b/admin/internal/api/checkout.go index ce6f4f6..9e8999d 100644 --- a/admin/internal/api/checkout.go +++ b/admin/internal/api/checkout.go @@ -1,7 +1,9 @@ package api import ( + "context" "fmt" + "log" "net/http" "strings" "time" @@ -246,9 +248,10 @@ func claimPlaceholderLink(c *gin.Context) { 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, + "instance_id": body.InstanceID, + "status": models.StatusActive, + "placeholder": false, + "linked_from_placeholder_id": placeholderID, }}); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -262,6 +265,15 @@ func claimPlaceholderLink(c *gin.Context) { return } + // Paddle holds its own copy of custom_data, written at checkout, and it still + // names the placeholder. Every later event on this subscription — renewal, + // cancellation, an entitlement change — is decoded from it, so leaving it + // stale means the next webhook resolves to an id no row carries any more. + // Best-effort: the linked_from_placeholder_id alias above is what makes the + // webhook path correct whether or not this call lands, and the customer must + // not be blocked from linking by an outbound API failure. + syncSubscriptionCustomData(ctx, placeholderID, body.InstanceID, inst.AccountID) + 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. @@ -277,6 +289,37 @@ func claimPlaceholderLink(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"instance_id": body.InstanceID}) } +// syncSubscriptionCustomData rewrites custom_data.instance_id at Paddle for +// every subscription that named the placeholder, so future events decode to the +// real UUID. Paddle replaces the whole object on a PATCH, so account_id is sent +// alongside rather than dropped. +// +// Deliberately silent on failure: it is a convergence step, not the correctness +// boundary — handleSubscription resolves a stale id through the instance row's +// linked_from_placeholder_id either way. +func syncSubscriptionCustomData(ctx context.Context, placeholderID, realID, accountID string) { + cur, err := db.Admin("subscriptions").Find(ctx, bson.M{"instance_id": realID}) + if err != nil { + log.Printf("claim link %s: read subscriptions: %v", realID, err) + return + } + var subs []models.Subscription + if err := cur.All(ctx, &subs); err != nil { + log.Printf("claim link %s: decode subscriptions: %v", realID, err) + return + } + for _, s := range subs { + if s.PaddleSubscriptionID == "" { + continue + } + if err := paddle.Get().UpdateSubscriptionCustomData(ctx, s.PaddleSubscriptionID, + map[string]string{"account_id": accountID, "instance_id": realID}); err != nil { + log.Printf("claim link %s: patch custom_data on %s (was %s): %v", + realID, s.PaddleSubscriptionID, placeholderID, err) + } + } +} + // 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/billing/subscription.go b/admin/internal/billing/subscription.go index bea2abc..9e2567c 100644 --- a/admin/internal/billing/subscription.go +++ b/admin/internal/billing/subscription.go @@ -2,6 +2,7 @@ package billing import ( "context" + "errors" "fmt" "time" @@ -14,6 +15,7 @@ import ( "gitea.hostxtra.co.uk/mrhid6/vantage/shared/license" "github.com/google/uuid" "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" ) @@ -67,9 +69,21 @@ 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. + instanceID, inst, err := resolveInstance(ctx, d.CustomData.InstanceID) + if err != nil { + return fmt.Errorf("subscription %s names unknown instance %s: %w", + d.ID, d.CustomData.InstanceID, err) + } + sub := models.Subscription{ AccountID: d.CustomData.AccountID, - InstanceID: d.CustomData.InstanceID, + InstanceID: instanceID, PaddleSubscriptionID: d.ID, Tier: match.Tier, Term: match.Term, @@ -88,13 +102,6 @@ func handleSubscription(ctx context.Context, ev Event) error { bson.M{"$set": bson.M{"paddle_customer_id": d.CustomerID}}) } - 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 @@ -116,6 +123,27 @@ func handleSubscription(ctx context.Context, ev Event) error { return promoteAndIssue(ctx, &inst, match, reason) } +// resolveInstance finds the instance a webhook's custom_data names, following the +// placeholder alias when the id is one a self-hosted claim 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 + err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"instance_id": customDataID}).Decode(&inst) + if err == nil { + return inst.InstanceID, inst, nil + } + if !errors.Is(err, mongo.ErrNoDocuments) { + return "", inst, err + } + if err := db.Admin("admin_instances").FindOne(ctx, + bson.M{"linked_from_placeholder_id": customDataID}).Decode(&inst); err != nil { + return "", inst, err + } + return inst.InstanceID, inst, nil +} + // promoteAndIssue promotes desired→granted from the resolved match, then signs a // licence from granted. This is the only promotion path other than the staff // grant, and it exists because a webhook is a confirmed payment. @@ -280,9 +308,10 @@ 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 { - var inst models.Instance - if err := db.Admin("admin_instances").FindOne(ctx, - bson.M{"instance_id": instanceID}).Decode(&inst); err != nil || inst.Name == "" { + // Alias-aware: a cancellation can name a placeholder id, 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 == "" { return "your instance" } return inst.Name diff --git a/admin/internal/models/backfill.go b/admin/internal/models/backfill.go index b150766..50926d4 100644 --- a/admin/internal/models/backfill.go +++ b/admin/internal/models/backfill.go @@ -4,6 +4,7 @@ import ( "context" "errors" "log" + "strings" "time" "gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/db" @@ -210,6 +211,54 @@ func Backfill(ctx context.Context) error { if err := backfillEntitlements(ctx); err != nil { return err } + + // Pass 6: instances claimed before linked_from_placeholder_id existed have no + // alias, and Paddle's custom_data still names the placeholder they were + // claimed from — so their next webhook resolves to nothing. The claim wrote an + // audit entry naming both ids, which is the only surviving record of the + // placeholder, so reconstruct the alias from it. + if err := backfillPlaceholderAliases(ctx); err != nil { + return err + } + return nil +} + +// backfillPlaceholderAliases rebuilds linked_from_placeholder_id from the +// instance.placeholder_linked audit entries. Idempotent by filtering on the +// absence of the field. +func backfillPlaceholderAliases(ctx context.Context) error { + cur, err := db.Admin("admin_audit").Find(ctx, + bson.M{"action": "instance.placeholder_linked"}) + if err != nil { + return err + } + var entries []AuditEntry + if err := cur.All(ctx, &entries); err != nil { + return err + } + + const prefix = "from placeholder " + linked := 0 + for _, e := range entries { + if e.Target == "" || !strings.HasPrefix(e.Detail, prefix) { + continue + } + placeholderID := strings.TrimSpace(strings.TrimPrefix(e.Detail, prefix)) + if placeholderID == "" || placeholderID == e.Target { + continue + } + res, err := db.Admin("admin_instances").UpdateOne(ctx, + bson.M{"instance_id": e.Target, + "linked_from_placeholder_id": bson.M{"$exists": false}}, + bson.M{"$set": bson.M{"linked_from_placeholder_id": placeholderID}}) + if err != nil { + return err + } + linked += int(res.ModifiedCount) + } + if linked > 0 { + log.Printf("backfill: recovered %d placeholder aliases from the audit log", linked) + } return nil } diff --git a/admin/internal/models/models.go b/admin/internal/models/models.go index feec4af..7c4abc1 100644 --- a/admin/internal/models/models.go +++ b/admin/internal/models/models.go @@ -147,6 +147,13 @@ 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"` + // LinkedFromPlaceholderID is the id this row carried while it was a + // self-hosted placeholder, kept forever after the claim rewrote InstanceID to + // the install's real UUID. Paddle's copy of custom_data still names the + // placeholder on any subscription created before the claim (and on any webhook + // delivered while the outbound patch was failing), so this is what lets a + // later webhook resolve to the right instance instead of erroring as unknown. + LinkedFromPlaceholderID string `bson:"linked_from_placeholder_id,omitempty" json:"-"` // 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. diff --git a/admin/internal/paddle/client.go b/admin/internal/paddle/client.go index e133e6c..5ddd1f7 100644 --- a/admin/internal/paddle/client.go +++ b/admin/internal/paddle/client.go @@ -25,6 +25,10 @@ type Client interface { // immediately by Paddle. This is the one outbound mutation, used when a // 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. + 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) // Env is "sandbox" or "production", the same value catalogue price lookups diff --git a/admin/internal/paddle/http.go b/admin/internal/paddle/http.go index 2b94371..0bf8985 100644 --- a/admin/internal/paddle/http.go +++ b/admin/internal/paddle/http.go @@ -99,6 +99,17 @@ func (c *httpClient) UpdateSubscriptionItems(ctx context.Context, subID string, }, nil) } +// UpdateSubscriptionCustomData patches custom_data only. Paddle replaces the +// whole object, so callers pass every key they want to keep. +func (c *httpClient) UpdateSubscriptionCustomData(ctx context.Context, subID string, data map[string]string) error { + if subID == "" { + return fmt.Errorf("paddle: empty subscription id") + } + return c.do(ctx, http.MethodPatch, "/subscriptions/"+subID, struct { + CustomData map[string]string `json:"custom_data"` + }{CustomData: data}, nil) +} + func (c *httpClient) PortalSession(ctx context.Context, customerID string) (string, error) { if customerID == "" { return "", fmt.Errorf("paddle: empty customer id")