fix: Fixed paddle relink sub
Chart Release / chart (push) Successful in 27s
Server Deploy / deploy (push) Successful in 1m17s

This commit is contained in:
2026-08-03 17:34:39 +01:00
parent b5f684c4fe
commit 287bd9657b
5 changed files with 127 additions and 88 deletions
+17 -55
View File
@@ -1,9 +1,7 @@
package api
import (
"context"
"fmt"
"log"
"net/http"
"strings"
"time"
@@ -13,6 +11,7 @@ import (
"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"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/admin/internal/paddle"
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
@@ -247,32 +246,26 @@ func claimPlaceholderLink(c *gin.Context) {
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,
"linked_from_placeholder_id": placeholderID,
}}); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Re-point the subscription from the placeholder id to the real UUID so
// billing.IssueForInstance (and every later webhook) finds it.
if _, err := db.Admin("subscriptions").UpdateMany(ctx,
bson.M{"instance_id": placeholderID},
bson.M{"$set": bson.M{"instance_id": body.InstanceID}}); err != nil {
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
}
// 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)
// 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,
@@ -289,37 +282,6 @@ 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) {