Skip to content
This repository was archived by the owner on Oct 16, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions ISSUE_ANALYSIS.md
Original file line number Diff line number Diff line change
@@ -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!
32 changes: 32 additions & 0 deletions Sources/Helpers/IapState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)] = []
Expand All @@ -18,6 +21,7 @@ actor IapState {
func reset() {
processedTransactionIds.removeAll()
pendingTransactions.removeAll()
latestTransactionByGroup.removeAll()
isInitialized = false
promotedProductId = nil
}
Expand All @@ -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 }
Expand Down
24 changes: 21 additions & 3 deletions Sources/OpenIapModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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) }
}
Expand Down