fix(ios): emit already-owned error for dup subscription purchases#34
Conversation
WalkthroughA pre-purchase entitlement check is added for auto-renewable subscriptions in Changes
Sequence DiagramsequenceDiagram
actor User
participant App
participant Module as OpenIapModule
participant EntitlementSvc as Entitlement<br/>Service
participant StoreKit
User->>App: Request subscription purchase
App->>Module: requestPurchase(subscription)
rect rgb(200, 220, 255)
Note over Module: Pre-purchase check (NEW)
Module->>EntitlementSvc: Verify entitlement
end
alt Entitlement is active
EntitlementSvc-->>Module: Active entitlement found
rect rgb(255, 200, 200)
Module->>Module: Log warning
Module->>App: Emit alreadyOwned error
end
Module->>User: Halt purchase
else Entitlement not active or not found
EntitlementSvc-->>Module: No active entitlement
Module->>StoreKit: Proceed with purchase
StoreKit-->>Module: Purchase result
Module->>App: Emit success/error
else Verification error
EntitlementSvc-->>Module: Verification failed
rect rgb(255, 230, 200)
Module->>Module: Log debug message
Module->>StoreKit: Continue purchase
end
StoreKit-->>Module: Purchase result
Module->>App: Emit success/error
end
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes
Possibly related issues
Poem
Pre-merge checks and finishing touches✅ Passed checks (4 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 0
🧹 Nitpick comments (1)
packages/apple/Sources/OpenIapModule.swift (1)
186-235: Pre-purchase entitlement check correctly prevents duplicate subscription purchases.The implementation successfully addresses issue #267 by checking for active entitlements before calling
product.purchase(), preventing the iOS "You're already subscribed" modal and ensuring analreadyOwnederror is emitted instead of a success callback.The error handling strategy is sound:
- Active subscription detected → emit
alreadyOwnedand halt purchase ✓- Verification fails → emit error but allow purchase attempt (reasonable fallback) ✓
- No entitlement or expired → proceed normally ✓
Optional: Consider extracting entitlement check into a helper method.
The nested conditionals and error handling add cognitive complexity to
requestPurchase. Consider extracting lines 188-235 into a private helper method:private func validateSubscriptionNotOwnedIOS( product: StoreKit.Product, sku: String ) async throws { guard product.type == .autoRenewable else { return } guard let currentEntitlement = await product.currentEntitlement else { return } do { let transaction = try checkVerified(currentEntitlement) let isActive = transaction.expirationDate.map { $0 > Date() } ?? true if isActive { OpenIapLog.debug(""" ⚠️ [requestPurchase] Subscription already owned: - SKU: \(sku) - Transaction ID: \(transaction.id) - Expiration: \(transaction.expirationDate?.description ?? "none") """) let error = makePurchaseError(code: .alreadyOwned, productId: sku) emitPurchaseError(error) throw error } } catch let purchaseError as PurchaseError { emitPurchaseError(purchaseError) if purchaseError.code == .alreadyOwned { throw purchaseError } OpenIapLog.debug("⚠️ Current entitlement verification failed: \(purchaseError.message)") } catch { let verificationError = makePurchaseError( code: .transactionValidationFailed, productId: sku, message: "Current entitlement check failed: \(error.localizedDescription)" ) OpenIapLog.debug("⚠️ Current entitlement check failed: \(error.localizedDescription)") emitPurchaseError(verificationError) } }Then call it in
requestPurchase:let product = try await storeProduct(for: sku) let options = try StoreKitTypesBridge.purchaseOptions(from: iosProps) try await validateSubscriptionNotOwnedIOS(product: product, sku: sku) let result: StoreKit.Product.PurchaseResult // ... proceed with purchaseThis improves readability while maintaining identical behavior. However, this is purely optional and not required—the current inline implementation is also acceptable.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
packages/apple/Sources/OpenIapModule.swift(1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
packages/apple/Sources/**/*.swift
📄 CodeRabbit inference engine (CLAUDE.md)
packages/apple/Sources/**/*.swift: iOS-specific functions must have IOS suffix; cross-platform functions have no suffix
Swift acronym naming: acronyms are ALL CAPS only as a suffix; when at beginning/middle, use Pascal case (e.g., IapManager, ProductIAP)
Files:
packages/apple/Sources/OpenIapModule.swift
🧠 Learnings (2)
📚 Learning: 2025-10-18T05:54:54.802Z
Learnt from: CR
Repo: hyodotdev/openiap PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-10-18T05:54:54.802Z
Learning: Applies to packages/apple/Sources/Models/**/*.swift : Keep official OpenIAP types in Sources/Models/ matching openiap.dev/docs/types naming (e.g., Product.swift, Purchase.swift, ActiveSubscription.swift)
Applied to files:
packages/apple/Sources/OpenIapModule.swift
📚 Learning: 2025-10-18T05:54:54.802Z
Learnt from: CR
Repo: hyodotdev/openiap PR: 0
File: CLAUDE.md:0-0
Timestamp: 2025-10-18T05:54:54.802Z
Learning: Applies to packages/apple/Sources/Helpers/**/*.swift : Place internal helper classes (not official OpenIAP types) under Sources/Helpers/ (e.g., ProductManager.swift, IapStatus.swift)
Applied to files:
packages/apple/Sources/OpenIapModule.swift
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Test Android
Adds pre-purchase check for active subscriptions on iOS to prevent
"You're already subscribed" alert. Emits alreadyOwned error when user
attempts to purchase an active subscription, matching Android behavior.
Resolve hyochan/expo-iap#267
Summary by CodeRabbit