fix: corrected transactionReason when a previously cancelled subscription was purchased again#21
Conversation
…tion was purchased again
WalkthroughIntroduces an internal JSONTransactionReason struct to decode transaction.jsonRepresentation and identify renewal transactions by checking for "RENEWAL". If decoded as renewal, returns a renewal TransactionReason; otherwise, falls back to the previous logic. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor App
participant StoreKitBridge as StoreKitTypesBridge
participant Transaction as StoreKit.Transaction
App->>StoreKitBridge: handle(transaction)
StoreKitBridge->>Transaction: get jsonRepresentation
Note over StoreKitBridge: Decode JSONTransactionReason
alt Decoding succeeds and reason == "RENEWAL"
StoreKitBridge-->>App: return TransactionReason.renewal
else Fallback
Note over StoreKitBridge: Use previous detection logic
StoreKitBridge-->>App: return prior-derived TransactionReason
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
Sources/Helpers/StoreKitTypesBridge.swift (1)
365-368: Logic looks correct for the fix.The new approach of decoding
transaction.jsonRepresentationto check for "RENEWAL" properly addresses the issue described in the PR where previously cancelled subscriptions were incorrectly marked as renewals. The graceful fallback usingtry?and the default to "purchase" align with the expected behavior.However, the comment on line 364 mentions "Try to infer renewal for iOS <17" but doesn't explain what the preferred approach would be for iOS 17+. Consider clarifying whether there's a direct API available in iOS 17+ that should be used instead, or if this approach is still the recommended solution across all versions.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
Sources/Helpers/StoreKitTypesBridge.swift(2 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
Sources/**/*.swift
📄 CodeRabbit inference engine (CLAUDE.md)
Sources/**/*.swift: iOS-specific functions MUST have IOS suffix
Android-specific functions MUST have Android suffix
Cross-platform functions must have NO platform suffix
Acronyms in Swift should be ALL CAPS only when used as a suffix; otherwise use Pascal case (first letter caps, rest lowercase)
Specific casing: iOS -> Ios in beginning/middle, IOS as suffix
Specific casing: IAP -> Iap in beginning/middle, IAP as suffix
Specific casing: API -> Api in beginning/middle, API as suffix
Specific casing: URL -> Url in beginning/middle, URL as suffix
OpenIapError static code constants use PascalCase names; raw string values remain E_ codes; avoid introducing new E_-prefixed identifiers in Swift
Files:
Sources/Helpers/StoreKitTypesBridge.swift
Sources/Helpers/**/*.swift
📄 CodeRabbit inference engine (CLAUDE.md)
Helpers should use descriptive names ending with purpose (e.g., Manager, Cache, Status)
Files:
Sources/Helpers/StoreKitTypesBridge.swift
Sources/Helpers/**/*Store*.swift
📄 CodeRabbit inference engine (CLAUDE.md)
Avoid using 'Store' in names for caching/helper classes; use Cache or Manager instead
Files:
Sources/Helpers/StoreKitTypesBridge.swift
| struct JSONTransactionReason: Codable { | ||
| let transactionReason: String | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Rename to align with coding guidelines.
According to the coding guidelines, acronyms in Swift should use Pascal case (first letter caps, rest lowercase) when at the beginning or middle of an identifier. JSONTransactionReason should be JsonTransactionReason.
As per coding guidelines.
Apply this diff to fix the naming:
- struct JSONTransactionReason: Codable {
+ struct JsonTransactionReason: Codable {
let transactionReason: String
}Also update the usage at line 365:
- if let decodedReason = try? JSONDecoder().decode(JSONTransactionReason.self, from: transaction.jsonRepresentation),
+ if let decodedReason = try? JSONDecoder().decode(JsonTransactionReason.self, from: transaction.jsonRepresentation),
decodedReason.transactionReason == "RENEWAL" {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| struct JSONTransactionReason: Codable { | |
| let transactionReason: String | |
| } | |
| struct JsonTransactionReason: Codable { | |
| let transactionReason: String | |
| } |
| struct JSONTransactionReason: Codable { | |
| let transactionReason: String | |
| } | |
| if let decodedReason = try? JSONDecoder().decode(JsonTransactionReason.self, from: transaction.jsonRepresentation), | |
| decodedReason.transactionReason == "RENEWAL" { |
🤖 Prompt for AI Agents
In Sources/Helpers/StoreKitTypesBridge.swift around lines 337–339, rename the
struct JSONTransactionReason to JsonTransactionReason to follow Pascal-case for
acronyms; update its declaration and all references (including the usage at line
365) to the new name so the type matches everywhere.
| if let decodedReason = try? JSONDecoder().decode(JSONTransactionReason.self, from: transaction.jsonRepresentation), | ||
| decodedReason.transactionReason == "RENEWAL" { |
There was a problem hiding this comment.
The current JSON decoding approach is good, but there's a more reliable way to detect renewals using originalID:
// More reliable - compares originalID with current transaction ID
if transaction.productType == .autoRenewable,
transaction.originalID != transaction.id {
return TransactionReason(lowercased: "renewal", string: "renewal", uppercased: "RENEWAL")
}Why this is better:
- No JSON parsing overhead
- More reliable:
originalID != iddefinitively means renewal - First purchase:
originalID == id - Any renewal:
originalID != id(points to original purchase)
Evidence from issue hyochan/react-native-iap#3054:
Both monthly renewal and yearly upgrade showed originalTransactionIdentifierIOS: "2000001030743975" (different from their own transaction IDs), proving they are renewals/upgrades.
There was a problem hiding this comment.
Hey, I’ve tested your implementation, but the case where a previously cancelled subscription is purchased again is still marked as a renewal, and its originalId remains the originalId of the first-ever purchase.
I believe jsonRepresentation is more reliable here, as it reflects the actual state of transactionReason.
Here is the part of purchase object that was tested.
countryCodeIOS: "UKR"
environmentIOS: "Sandbox"
expirationDateIOS: 1760169564000
id: "2000001031849543"
isAutoRenewing: true
isUpgradedIOS: falseofferIOS: null
originalTransactionDateIOS: 1747052007000
originalTransactionIdentifierIOS: "2000000917067139"
ownershipTypeIOS: "purchased"
platform: "ios"
productId: "uapro_month"
purchaseState: "purchased"
reasonIOS: "renewal"
reasonStringRepresentationIOS: "renewal"
transactionDate: 1760169384000
transactionReasonIOS: "RENEWAL"
There was a problem hiding this comment.
You're right! Thanks for testing with actual data.
When a user re-purchases a cancelled subscription:
- The
originalIDstill points to the very first purchase - So
originalID != idwould wrongly mark it as "RENEWAL" instead of "PURCHASE"
Apple's transactionReason in JSON correctly distinguishes:
- Re-purchase after cancel =
"PURCHASE"✅ - Actual renewal =
"RENEWAL"✅
Your implementation is correct. My suggestion was flawed - thanks for catching this! 🙏
hyochan
left a comment
There was a problem hiding this comment.
Thanks for your contribution and please see the code review 🙏
Hello, I discovered that
transactionReasonwas incorrectly set toRENEWinstead ofPURCHASE, for iOS purchases that had been previously cancelled and then purchased again in thepurchaseUpdatedListenercallback.This fix restores the previous logic for determining
transactionReason, consistent with the behavior in react-native-iap versions 12.x.x and 13.x.x and real App Store data encoded injsonRepresentation.hyochan/react-native-iap#3056
Summary by CodeRabbit