fix: Fixed paddle subs
Chart Release / chart (push) Successful in 28s
Server Deploy / deploy (push) Successful in 1m17s

This commit is contained in:
2026-08-03 15:32:01 +01:00
parent 1f08e90009
commit b5f684c4fe
6 changed files with 157 additions and 14 deletions
+46 -3
View File
@@ -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) {
+40 -11
View File
@@ -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
+49
View File
@@ -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
}
+7
View File
@@ -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.
+4
View File
@@ -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
+11
View File
@@ -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")