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) {