From 22d5defb574abea8fed8b44f6538f398af92cd0c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 22:57:23 +0000 Subject: [PATCH 1/4] Trigger /redeem after a successful native purchase A successful native (App Store) purchase only refreshed entitlements locally via `loadPurchasedProducts` and never called `/redeem`, so a new subscription for an already-identified user wasn't linked to them server-side until another trigger fired. Call `webEntitlementRedeemer.redeem(.existingCodes)` after `loadPurchasedProducts` in `TransactionManager.loadPurchasedProductsIfNeeded` so the freshly-loaded receipts + appTransactionId are pushed to the backend on every native purchase. This is the single funnel for both SK1 and SK2 purchases and, being gated by `shouldSkipReceiptLoading`, skips custom/web products and test mode. Restores go through `didRestore` and are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01UrsXRkhSGciN62RK7mVpBV --- CHANGELOG.md | 1 + .../StoreKit/Transactions/TransactionManager.swift | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d06cfe70fa..1779c58ddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Enhancements - Adds the user's system-wide text size (Dynamic Type) as three device attributes for use in paywalls and audience filters: `fontScale`, `fontSize`, and `preferredContentSizeCategory`. +- Triggers a redemption after a successful native purchase so new subscriptions are linked to the user server-side. ## 4.16.1 diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index f6f658cd52..ea4ef45e8d 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -835,6 +835,17 @@ final class TransactionManager { } await receiptManager.loadPurchasedProducts(config: nil) + + // A successful native (App Store) purchase doesn't otherwise hit `/redeem`, + // so a new subscription for an already-identified user isn't linked to them + // server-side until some other trigger fires. Redeem existing codes here so + // the freshly-loaded receipts + appTransactionId are always pushed to the + // backend right after a native purchase completes. This runs on the purchase + // path only (restores go through `didRestore` and don't reach here), and is + // gated by `shouldSkipReceiptLoading`, so it skips custom/web products and + // test mode. Open question for the backend team: whether generic restores + // should also push receipts this way, or whether that would be redundant. + await Superwall.shared.dependencyContainer.webEntitlementRedeemer.redeem(.existingCodes) } private func shouldSkipReceiptLoading(for product: StoreProduct) -> Bool { From 92ad5155f7f0d1b07c881a932d237bfcc122a3d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 23:21:28 +0000 Subject: [PATCH 2/4] Trigger /redeem after a successful restore Extend SW-5516 (already redeeming after a native purchase) to also fire webEntitlementRedeemer.redeem(.existingCodes) on the successful restore path. Added in TransactionManager.didRestore, which is only reached after a restore succeeds and after receipts/entitlements have been reloaded, so a freshly-restored subscription is linked to the identified user server-side. Skipped in test mode, mirroring the purchase path's shouldSkipReceiptLoading gate. Updated the purchase-path comment and CHANGELOG accordingly. --- CHANGELOG.md | 2 +- .../Transactions/TransactionManager.swift | 20 ++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1779c58ddd..706ba8bbcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Enhancements - Adds the user's system-wide text size (Dynamic Type) as three device attributes for use in paywalls and audience filters: `fontScale`, `fontSize`, and `preferredContentSizeCategory`. -- Triggers a redemption after a successful native purchase so new subscriptions are linked to the user server-side. +- Triggers a redemption after a successful native purchase or restore so new subscriptions are linked to the user server-side. ## 4.16.1 diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index ea4ef45e8d..49878e52eb 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -476,6 +476,17 @@ final class TransactionManager { ) await Superwall.shared.track(trackedEvent) } + + // `didRestore` is only reached on a successful restore, at which point + // receipts/entitlements have just been reloaded. Redeem existing codes so a + // freshly-restored subscription is linked to the identified user server-side, + // mirroring the purchase path in `loadPurchasedProductsIfNeeded`. Skipped in + // test mode, which sets subscription state without real receipt data + // (SW-5516: always redeem after a successful purchase & restore). + guard !factory.makeTestModeManager().isTestMode else { + return + } + await Superwall.shared.dependencyContainer.webEntitlementRedeemer.redeem(.existingCodes) } private func purchase( @@ -840,11 +851,10 @@ final class TransactionManager { // so a new subscription for an already-identified user isn't linked to them // server-side until some other trigger fires. Redeem existing codes here so // the freshly-loaded receipts + appTransactionId are always pushed to the - // backend right after a native purchase completes. This runs on the purchase - // path only (restores go through `didRestore` and don't reach here), and is - // gated by `shouldSkipReceiptLoading`, so it skips custom/web products and - // test mode. Open question for the backend team: whether generic restores - // should also push receipts this way, or whether that would be redundant. + // backend right after a native purchase completes. This is the purchase path; + // the equivalent redeem for restores lives in `didRestore`. Gated by + // `shouldSkipReceiptLoading`, so it skips custom/web products and test mode + // (SW-5516: always redeem after a successful purchase & restore). await Superwall.shared.dependencyContainer.webEntitlementRedeemer.redeem(.existingCodes) } From 0844b18253aaf4073cdeb2aa196f3694f174dc68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:18:06 +0200 Subject: [PATCH 3/4] Update TransactionManager.swift --- .../StoreKit/Transactions/TransactionManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 49878e52eb..052f086eb7 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -483,7 +483,7 @@ final class TransactionManager { // mirroring the purchase path in `loadPurchasedProductsIfNeeded`. Skipped in // test mode, which sets subscription state without real receipt data // (SW-5516: always redeem after a successful purchase & restore). - guard !factory.makeTestModeManager().isTestMode else { + if factory.makeTestModeManager().isTestMode { return } await Superwall.shared.dependencyContainer.webEntitlementRedeemer.redeem(.existingCodes) @@ -841,7 +841,7 @@ final class TransactionManager { } private func loadPurchasedProductsIfNeeded(for product: StoreProduct) async { - guard !shouldSkipReceiptLoading(for: product) else { + if shouldSkipReceiptLoading(for: product) { return } From 1691490034c2c2986eb06a2ef012387d5c7afe7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20To=CC=88r?= <3296904+yusuftor@users.noreply.github.com> Date: Wed, 22 Jul 2026 16:54:58 +0200 Subject: [PATCH 4/4] Makes redeem async --- CHANGELOG.md | 2 +- .../StoreKit/Transactions/TransactionManager.swift | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 706ba8bbcb..a72ae11c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ The changelog for `SuperwallKit`. Also see the [releases](https://github.com/sup ### Enhancements - Adds the user's system-wide text size (Dynamic Type) as three device attributes for use in paywalls and audience filters: `fontScale`, `fontSize`, and `preferredContentSizeCategory`. -- Triggers a redemption after a successful native purchase or restore so new subscriptions are linked to the user server-side. +- Links subscriptions to the user server-side after successful purchase or restore. ## 4.16.1 diff --git a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift index 052f086eb7..4ee53ed3eb 100644 --- a/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift +++ b/Sources/SuperwallKit/StoreKit/Transactions/TransactionManager.swift @@ -486,7 +486,9 @@ final class TransactionManager { if factory.makeTestModeManager().isTestMode { return } - await Superwall.shared.dependencyContainer.webEntitlementRedeemer.redeem(.existingCodes) + Task { + await Superwall.shared.dependencyContainer.webEntitlementRedeemer.redeem(.existingCodes) + } } private func purchase( @@ -855,7 +857,9 @@ final class TransactionManager { // the equivalent redeem for restores lives in `didRestore`. Gated by // `shouldSkipReceiptLoading`, so it skips custom/web products and test mode // (SW-5516: always redeem after a successful purchase & restore). - await Superwall.shared.dependencyContainer.webEntitlementRedeemer.redeem(.existingCodes) + Task { + await Superwall.shared.dependencyContainer.webEntitlementRedeemer.redeem(.existingCodes) + } } private func shouldSkipReceiptLoading(for product: StoreProduct) -> Bool {