diff --git a/ISSUE_ANALYSIS.md b/ISSUE_ANALYSIS.md new file mode 100644 index 0000000..2149f8f --- /dev/null +++ b/ISSUE_ANALYSIS.md @@ -0,0 +1,96 @@ +# Issue #3054 Analysis and Solution + +## Root Cause Discovered + +Thank you @PavanSomarathne for providing the purchase data! This revealed the **actual root cause** of the issue. + +### Key Finding + +Both transactions show `"isUpgradedIOS": false`: + +**Monthly (arrives first):** +```json +{ + "id": "2000001031579336", + "productId": "oxiwearmedicalpromonthly", + "transactionDate": 1760118720000, + "isUpgradedIOS": false, // ❌ Expected true, but is false! + "reasonIOS": "renewal" +} +``` + +**Yearly (arrives 4 minutes later):** +```json +{ + "id": "2000001031579845", + "productId": "oxiwearmedicalproyearly", + "transactionDate": 1760118900000, // 180 seconds later + "isUpgradedIOS": false, + "reasonIOS": "renewal" +} +``` + +### Why Our Previous Fix Didn't Work + +The `isUpgraded` check didn't work because **StoreKit reports subscription upgrades as "renewal" events**, not as upgraded transactions. Both transactions have: +- `isUpgradedIOS: false` +- `reasonIOS: "renewal"` +- Same `subscriptionGroupIdIOS: "21609681"` +- Same `originalTransactionIdentifierIOS` + +This happens because the upgrade occurred at the renewal boundary in Sandbox. + +## New Solution + +Instead of relying on `isUpgraded`, we now track the **latest transaction date per subscription group**: + +### Changes Made + +1. **Added subscription group tracking** in `IapState.swift`: + - Tracks the latest `purchaseDate` for each `subscriptionGroupID` + - Skips older transactions when a newer one exists in the same group + +2. **Enhanced filtering** in `startTransactionListener()`: + ```swift + // For subscriptions, skip if we've already seen a newer transaction in the same group + if await self.state.shouldProcessSubscriptionTransaction(transaction) == false { + OpenIapLog.debug("⏭️ Skipping older subscription transaction: \(transactionId) (superseded by newer transaction in same group)") + continue + } + ``` + +3. **Added comprehensive logging**: + - Transaction details (ID, product, dates, subscription group) + - Skip reasons + - Emit confirmations + +### How It Works + +1. Monthly transaction arrives (transactionDate: 1760118720000) + - ✅ First in group "21609681" → Process and emit + +2. Yearly transaction arrives (transactionDate: 1760118900000) + - ✅ Newer than monthly (180 seconds later) → Process and emit + - If monthly arrives again → ⏭️ Skip (older than yearly) + +### Benefits + +- ✅ Works regardless of `isUpgraded` value +- ✅ Handles both immediate and delayed upgrades +- ✅ Prevents old transactions from being emitted multiple times +- ✅ Maintains support for normal renewals +- ✅ Added detailed logging for debugging + +## Testing + +All unit tests pass. The next release will include these changes. + +## What to Expect + +With this fix: +1. Monthly subscription will emit **once** +2. Yearly subscription will emit **once** when it arrives +3. Monthly won't re-emit after yearly is processed +4. Detailed logs will show exactly what's happening + +Would appreciate if you can test the next version and share the console logs! diff --git a/Sources/Helpers/IapState.swift b/Sources/Helpers/IapState.swift index b0647f6..01913b2 100644 --- a/Sources/Helpers/IapState.swift +++ b/Sources/Helpers/IapState.swift @@ -8,6 +8,9 @@ actor IapState { private var pendingTransactions: [String: Transaction] = [:] private var promotedProductId: String? + // Track latest transaction date and ID per subscription group to filter out superseded transactions + private var latestTransactionByGroup: [String: (date: Date, id: UInt64)] = [:] + // Event listeners private var purchaseUpdatedListeners: [(id: UUID, listener: PurchaseUpdatedListener)] = [] private var purchaseErrorListeners: [(id: UUID, listener: PurchaseErrorListener)] = [] @@ -18,6 +21,7 @@ actor IapState { func reset() { processedTransactionIds.removeAll() pendingTransactions.removeAll() + latestTransactionByGroup.removeAll() isInitialized = false promotedProductId = nil } @@ -32,6 +36,34 @@ actor IapState { func removePending(id: String) { pendingTransactions.removeValue(forKey: id) } func pendingSnapshot() -> [Transaction] { Array(pendingTransactions.values) } + // MARK: - Subscription Group Tracking + func shouldProcessSubscriptionTransaction(_ transaction: Transaction) -> Bool { + guard let groupId = transaction.subscriptionGroupID else { + // Not a subscription, always process + return true + } + + let transactionDate = transaction.purchaseDate + let transactionId = transaction.id + + if let latest = latestTransactionByGroup[groupId] { + // Skip if this transaction is older than the latest + if transactionDate < latest.date { + return false + } + + // If dates are equal, use transaction ID as tie-breaker + // Skip if this transaction ID is less than or equal to the latest + if transactionDate == latest.date && transactionId <= latest.id { + return false + } + } + + // Update latest transaction for this group + latestTransactionByGroup[groupId] = (date: transactionDate, id: transactionId) + return true + } + // MARK: - Promoted Products func setPromotedProductId(_ id: String?) { promotedProductId = id } func promotedProductIdentifier() -> String? { promotedProductId } diff --git a/Sources/OpenIapModule.swift b/Sources/OpenIapModule.swift index cd03c48..10170be 100644 --- a/Sources/OpenIapModule.swift +++ b/Sources/OpenIapModule.swift @@ -838,9 +838,26 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { let transaction = try self.checkVerified(verification) let transactionId = String(transaction.id) - // Skip revoked or upgraded transactions (happens during subscription upgrades) - if transaction.revocationDate != nil || transaction.isUpgraded { - OpenIapLog.debug("⏭️ Skipping revoked/upgraded transaction: \(transactionId)") + // Log all transaction details for debugging + OpenIapLog.debug(""" + 📦 Transaction received: + - ID: \(transactionId) + - Product: \(transaction.productID) + - purchaseDate: \(transaction.purchaseDate) + - subscriptionGroupID: \(transaction.subscriptionGroupID ?? "nil") + - revocationDate: \(transaction.revocationDate?.description ?? "nil") + """) + + // Skip revoked transactions + if transaction.revocationDate != nil { + OpenIapLog.debug("⏭️ Skipping revoked transaction: \(transactionId)") + continue + } + + // For subscriptions, skip if we've already seen a newer transaction in the same group + // This handles subscription upgrades where isUpgraded is not reliably set + if await self.state.shouldProcessSubscriptionTransaction(transaction) == false { + OpenIapLog.debug("⏭️ Skipping older subscription transaction: \(transactionId) (superseded by newer transaction in same group)") continue } @@ -896,6 +913,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { private func emitPurchaseUpdate(_ purchase: Purchase) { Task { [state] in let listeners = await state.snapshotPurchaseUpdated() + OpenIapLog.debug("✅ Emitting purchase update: Product=\(purchase.productId), Listeners=\(listeners.count)") await MainActor.run { listeners.forEach { $0(purchase) } }