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
+41 -21
View File
@@ -13,6 +13,7 @@ import (
"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"
)
// MigrateLegacyPlans re-keys the pre-spec-7 plan rows and MUST run before
@@ -212,23 +213,40 @@ func Backfill(ctx context.Context) error {
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 {
// Pass 6: instances whose identity was rewritten before previous_instance_ids
// existed carry no trail, and Paddle's custom_data still names the id they
// were rewritten FROM — so their next webhook resolves to nothing. Both
// rewrites wrote an audit entry naming the old id, which is the only surviving
// record of it, so reconstruct the trail from those.
if err := backfillInstanceIDHistory(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 {
// backfillInstanceIDHistory rebuilds previous_instance_ids from the audit entries
// the two identity rewrites leave behind: a placeholder claim
// ("instance.placeholder_linked", detail "from placeholder <id>") and a relink
// ("instance.relinked", detail "was <id>").
//
// $addToSet is what makes it idempotent, and it also means a chain of relinks
// accumulates rather than the last one winning. Entries are walked NEWEST first,
// matching on the current id or an already-recovered one: an instance relinked
// A→B→C answers to neither A nor B by the time this runs, so the C entry has to
// record B before the B entry has anything to attach A to.
func backfillInstanceIDHistory(ctx context.Context) error {
prefixes := map[string]string{
"instance.placeholder_linked": "from placeholder ",
"instance.relinked": "was ",
}
actions := make(bson.A, 0, len(prefixes))
for action := range prefixes {
actions = append(actions, action)
}
cur, err := db.Admin("admin_audit").Find(ctx,
bson.M{"action": "instance.placeholder_linked"})
bson.M{"action": bson.M{"$in": actions}},
options.Find().SetSort(bson.D{{Key: "created_at", Value: -1}}))
if err != nil {
return err
}
@@ -237,27 +255,29 @@ func backfillPlaceholderAliases(ctx context.Context) error {
return err
}
const prefix = "from placeholder "
linked := 0
recorded := 0
for _, e := range entries {
prefix := prefixes[e.Action]
if e.Target == "" || !strings.HasPrefix(e.Detail, prefix) {
continue
}
placeholderID := strings.TrimSpace(strings.TrimPrefix(e.Detail, prefix))
if placeholderID == "" || placeholderID == e.Target {
oldID := strings.TrimSpace(strings.TrimPrefix(e.Detail, prefix))
if oldID == "" || oldID == 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}})
bson.M{"$or": bson.A{
bson.M{"instance_id": e.Target},
bson.M{"previous_instance_ids": e.Target},
}},
bson.M{"$addToSet": bson.M{"previous_instance_ids": oldID}})
if err != nil {
return err
}
linked += int(res.ModifiedCount)
recorded += int(res.ModifiedCount)
}
if linked > 0 {
log.Printf("backfill: recovered %d placeholder aliases from the audit log", linked)
if recorded > 0 {
log.Printf("backfill: recovered %d instance id rewrites from the audit log", recorded)
}
return nil
}
+8 -7
View File
@@ -147,13 +147,14 @@ 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:"-"`
// 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
// 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.
PreviousInstanceIDs []string `bson:"previous_instance_ids,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.