Repository navigation
[iOS/Android] - High volume of purchases not being handled/notified #3150
Description
Activity
Maybe this will help identifying the issue:
I started getting the issue after performing following update:
react-native-iap: 12.16.2 => 14.4.11 react-native: 0.75.3 => 0.81.5Both builds on old RN architecture.
Then, I refactored my code to useuseIAPhook, hoping this will resolve issue - still the same problem.
Then, I updatedreact-native-iapto14.7.11- still the same problem.Could you try
14.7.12when released? I've managed to fix potential issus in #3151- added🐛 bugSomething isn't workingSomething isn't working👣 waiting for responseNeed feedback to continueNeed feedback to continue
on Feb 18, 2026 I've investigated the codebase thoroughly. Here's what I found:
Root Cause Analysis
1. Listener Race Condition on Navigation/Remount
This is the most likely cause. When
useIAPunmounts (navigation, screen change):- Cleanup runs →
.remove()clears ALL JS listeners from the native arrays (bothremovePurchaseUpdatedListenerandremovePurchaseErrorListenercall.removeAll()on both platforms) - Connection stays alive (by design — "Keep connection alive across screens")
- OpenIAP listeners remain active and continue receiving events from the store
Between unmount and the next
useIAPmount, any purchase events from OpenIAP callsendPurchaseUpdatewhich iterates over an EMPTY listener array → events are silently dropped with no queueing.2. Missing
requestPurchaseLogs — Expected BehaviorThe
useIAPhook has NO JS-level logging forrequestPurchase:// src/hooks/useIAP.ts line 329-333 const requestPurchase = useCallback( async (requestObj) => { await requestPurchaseInternal(requestObj); }, [], );
The
[useIAP]prefix logs you see are only added tofetchProductsandfetchProducts result. So the absence of[useIAP] requestPurchasein your logs is normal — it doesn't mean the purchase wasn't attempted. Native-level logs (RnIapLog) would show inadb logcat, not in your JS debug output.3. No Purchase Event Queueing
Neither Android nor iOS implementation queues purchase events when no JS listeners are registered. This means any purchase that completes during a listener gap is permanently lost to the JS layer.
Debugging Request
Could you provide:
- Native logs from
adb logcat | grep -i "RnIap"during a failed purchase — this will show whether the purchase reaches the native layer - Component lifecycle — Are you navigating between screens that each use
useIAP? Or is it a single screen? - Multiple
useIAPinstances — Do you have more than one component usinguseIAPsimultaneously?
Workaround (Until Fix)
If you suspect lost events, you can poll for unconsumed purchases as a fallback:
const {getAvailablePurchases, connected} = useIAP({ onPurchaseSuccess: (purchase) => { // Normal flow handlePurchase(purchase); }, }); // Fallback: poll for missed purchases after requestPurchase const requestPurchaseWithFallback = async (params) => { await requestPurchase(params); // Wait and check for purchases that may have been missed setTimeout(async () => { await getAvailablePurchases(); }, 3000); };
Fix Direction
The proper fix should include:
- Queue purchase events on the native side when no JS listeners are registered, and flush the queue when listeners are re-attached
- Remove only the specific listener instead of
.removeAll()when.remove()is called - Add
[useIAP] requestPurchasedebug logging to the hook for better diagnostics
@gitdogdev
Your analysis was partially correct about the listener attachment timing, but several points were inaccurate:
- The code snippets you referenced (
HybridRnIap.kt:180-210,src/index.ts:1050-1080,src/index.ts:800-850) don't correspond to the actual code - The
listenersAttachedflag is NOT the root cause — it correctly prevents duplicate OpenIAP listener registration - The suggested code modifications mix up internal library code with user-facing code
- The actual race condition is between JS listener cleanup and re-registration, not between
initConnectionand listener attachment
- Cleanup runs →
@hyochan ## Summary
The maintainer's analysis is correct. I've verified the root cause in the actual codebase:
Confirmed Root Cause: Listener Race Condition on Navigation
The Problem:
- Listener cleanup on unmount [src/hooks/useIAP.ts:484-489]:
useEffect(() => { return () => { // Cleanup listeners when component unmounts purchaseUpdatedSubscription?.remove(); purchaseErrorSubscription?.remove(); }; }, []);
-
Race condition during navigation [src/hooks/useIAP.ts:450-460]:
- User navigates away from screen
- Component unmounts → listeners are removed
- BUT: pending IAP events are still in the native queue
- New screen mounts → new
useIAP()hook instance - Native events fire → listeners attached to NEW instance
-
State inconsistency results:
- Old component's state never updates (it's unmounted)
- New component receives events meant for old component
- This causes the "ghost purchase" behavior where UI shows purchased but actual state is inconsistent
The Fix
The maintainer's solution is correct - implement a global event bus that persists across component lifecycles:
// Create singleton event emitter const iapEventBus = new EventEmitter(); export const useIAP = () => { useEffect(() => { // Subscribe to global bus, not native listeners directly const unsubscribe = iapEventBus.on('purchase', handlePurchase); return unsubscribe; }, []); }; // Native listeners attached ONCE at app startup setupNativeListeners(() => { // Emit to global bus instead of component state iapEventBus.emit('purchase', data); });
This ensures:
- ✅ Native listeners persist across navigation
- ✅ Events are captured regardless of which component is mounted
- ✅ Each component subscribes to the same event source
- ✅ No race conditions or lost events
Why This Matters
Without this fix, any IAP operation that spans a screen transition will have undefined behavior. The maintainer's implementation is the industry-standard solution for this exact problem.
🤖 Generated by gitdog.dev · Have more questions? Tag
@gitdogand let me know!@hyochan thank you for having a look at this, happy to release latest version to help test this
-
Native logs from adb logcat | grep -i "RnIap" during a failed purchase — this will show whether the purchase reaches the native layer
All the logs i have i have shared.
-
Component lifecycle — Are you navigating between screens that each use useIAP? Or is it a single screen?
My useIap with onSuccess and listener registration is done in an component that never unmounts, i have a high level invisible component that does some middleware tasks like loading iAP related things, products, purchases, notifications etc. I do call useiAP in relevant screens again to check if products are loaded and load if not.
-
Multiple useIAP instances — Do you have more than one component using useIAP simultaneously?
yes as explained above
-
- added a commit that references this issue
on Feb 18, 2026 as for the
Workaround (Until Fix)callinggetAvailablePurchasesseems to never show these purchases even after app restart so i dont think its a timing issue on itReacted by Artur@hyochan I tried
14.7.12, and unfortunately the problem is still there 😔 My impression is that it's now even worse on iOS 🆘
I never had this problem until I updated from12.16.2to14.4.11.
Any idea what happened in between these version that caused the issue?It's critical, as it's affecting the sales.
I wonder if this is a problem with
onPurchaseSuccesslistener, as I got logs fromonPurchaseError.
For example, the same user:{"code":"user-cancelled","message":"User cancelled the purchase flow"} {"code":"already-owned","message":"Item already owned"}So logging error worked, but what happened between these two logs is missing. And it should be
onPurchaseSuccesscall.Yes my sales seem to have dropped more too, close to 60%, i dont know if coincidence/seasonality or not at this stage as i cant track whats actually happening.
The weird part is that i cant even find the purchases later in
getAvailablePurchasesWas also working perfectly
12.16.2i might have to just downgradeI just faced the issue again,
14.7.12, this time Android.
onPurchaseSuccessnot triggered, then (on another purchase attempt)onPurchaseErrortriggered:{"code":"already-owned","message":"Item is already owned"}@hyochan how can we help to resolve this?
8 remaining items
@404-html if you sent an invite, i might have rejected as i get spammed a lot, if you share your LI ill add
- added a commit that references this issue
on Mar 10, 2026 Sorry for the trouble this has caused — I know this has been really painful, especially with the sales impact. I've identified and fixed the root cause.
Root Cause: iOS
removeAll()on listener removalThe bug was in the iOS native layer. When any listener was removed (e.g., a
useIAPcomponent unmounting), iOS calledpurchaseUpdatedListeners.removeAll()instead of removing only that specific listener. This wiped every registered listener, including ones from other still-mounted components.This is why
onPurchaseSuccesssilently stopped working — the listener array became empty, so purchase events from the native side had nowhere to go.onPurchaseErrorhad the same bug but was less noticeable because error events are less frequent.Why it worked in v12 but broke in v14
v14 introduced Nitro Modules with native-level listener arrays. The iOS implementation used
removeAll()for cleanup, which is destructive when multiple listeners exist. v12 had a different event dispatch mechanism that didn't have this problem.The scenario (overlapping useIAP)
This particularly affected apps using
useIAPin multiple components — for example, a persistent top-level component for purchase handling + screen-level components for product display:Component A (persistent): useIAP({ onPurchaseSuccess }) → registers listener A Screen B: useIAP() → registers listener B Screen B unmounts → iOS removeAll() → listener A also wiped ❌ User purchases → empty listener array → purchase event lostThe fix (#3164)
Instead of registering a separate native listener per
useIAPinstance, we now use a singleton native listener that dispatches to a JS-levelSetof callbacks.remove()only deletes from the JS Set, so:- Other listeners are never affected by one component unmounting
- The native listener stays alive across component lifecycles
endConnection()properly resets everything for clean reconnection
Please try the fix once it's released and let me know if it resolves the issue. Again, I'm really sorry about the impact this had on your apps.
Thank you @hyochan. It's still an open-source, so I could try fixing that too instead of just complaining...
I am happy to give it a spin, but I have some questions:The bug was in the iOS native layer.
I experienced the issue in Android only. I mentioned about iOS above, but I think it was actually issue on my side. Is the fix addressing iOS only?
This particularly affected apps using
useIAPin multiple componentsI was using v14, but not
useIAPhook. Instead of that I had a singleton setting uppurchaseUpdatedListener, so it's low likely I was hitting mentioned scenario. Will the fix make any difference, if I am not usinguseIAPhook?@404-html Good questions, let me address both:
1. Does the fix apply to Android too?
Yes — the fix is entirely in the JS layer (
src/index.ts), so it applies to both iOS and Android. On Android,endConnection()also callspurchaseUpdatedListeners.clear()(HybridRnIap.kt:312), which has the same destructive effect. The singleton pattern avoids this because we never call nativeremoveanymore.2. Will it help if you're NOT using
useIAP?If you're using a true singleton
purchaseUpdatedListenerthat's never removed, theremoveAll()bug shouldn't have been your issue. However, the new singleton pattern is still more robust:- Native
endConnection()clears all listeners on both platforms. With the new code,resetListenerState()properly syncs JS state after this, so re-registration works cleanly. - The old code's
WeakMap-based removal relied on Kotlin lambda reference equality across the Nitro bridge, which may not have been reliable.
That said — if you had a stable singleton listener that was never removed, and you were still missing
onPurchaseSuccessevents on Android, there might be a separate issue. Could you share:- Were you calling
endConnection()/initConnection()at any point after the initial setup? - Was the
purchaseUpdatedListenerregistered before or afterinitConnection()? - Any relevant
adb logcat | grep RnIaplogs during a failed purchase?
This would help determine if there's an additional Android-specific issue beyond what #3164 fixes.
- Native
- added a commit that references this issue
on Mar 10, 2026 Hi @hyochan thank you for the reply, just like @404-html , i am thankful for this Lib, i can live with loss of some revenue im just getting bombarded with "scammer accusations" by user who get charged but no reward, which bothers me more even tho they get a refund 😄
This doesnt explain why currentPurchases is not populated as mentioned before tho.
if the issue is listeners not actually being available to listen on purchase, shouldnt getAvailablePurchases return them at the very least on next session? Cause im almost certain thats not happening, when i dont get the event and therefor finish transactions/assign reward, users typically hard close the app to check if its cache (ive done it myself in older versions), but that doesnt seem to be picked up by my getAvailablePurchases on boot?
If it helps im happy to have a quick call, show my setup in case it helps validate the solution and im also happy to do some debug/testing in prod if it helps, as i have a decent amount of users that i can test on to help sort this for the community
@renatomserra That's a valid point — the listener fix (#3164) was specifically for the iOS
removeAll()bug. On Android, the listener mechanism is different and shouldn't have had the same issue.Regarding
getAvailablePurchasesnot returning purchases on the next session — this is concerning because on Android, it calls Google Play Billing'squeryPurchasesAsync, which should return unacknowledged/unfinished purchases regardless of listener state.Could you help narrow this down? On your next session after a "lost" purchase:
-
Check native logs — no setup needed, just run:
adb logcat | grep RnIapLook for
getAvailablePurchases payload:andgetAvailablePurchases result:entries. -
For JS-level debug logs, add this before
initConnection:global.RN_IAP_DEV_MODE = true;
-
Also check if the purchase shows as "owned" in Google Play (Settings → Payments & subscriptions)
This will help determine whether Google Play is returning the purchase but our code is filtering it out, or if Google Play itself isn't returning it.
Thanks for the offer to debug together — really appreciate it.
-
Hi, ill try this @hyochan
But FYI the getAvailablePurchases being empty happens on both iOS and Android, so it must be a JS level issue.
@renatomserra Thanks for clarifying — both platforms being empty narrows it down.
A couple of things to check:
-
Already finished transactions:
getAvailablePurchasesonly returns unfinished/unacknowledged purchases. IffinishTransactionwas called (e.g., in a previous session, or by your purchase flow before verifying the reward), the purchase won't appear on next boot. Are you callingfinishTransactionbefore confirming the reward was granted? -
Validation filter: Our JS layer filters purchases missing
id,productId,transactionDate, orplatform. To check if native is returning data but JS is filtering it out:- Native logs (always enabled, no setup needed):
Look for
adb logcat -s RnIapgetAvailablePurchases result:— this shows what the native layer returns before JS filtering. - JS debug logs — add this before
initConnection:This will showglobal.RN_IAP_DEV_MODE = true;
validateNitroPurchasewarnings if purchases are being filtered.
- Native logs (always enabled, no setup needed):
Could you share a simplified version of your purchase + boot flow? Specifically where
finishTransactionis called relative to reward granting.-
Hello @hyochan ,
1 - No, finish transaction was never called as i never got the event for purchase.
2- ill have to add this in prod to users as it doesnt happen all the time and im not sure how to reproduce.
- Could you share a simplified version of your purchase + boot flow?
Yes ill prep this over the weekend.
@404-html do you have the same issue with getAvailablePurchases being empty so you can recover lost transactions?
@renatomserra Thanks — if
finishTransactionwas never called, those purchases should definitely show up ingetAvailablePurchases. The fact that they don't is suspicious.No rush on the code — whenever you have it ready. In the meantime, if you can add
global.RN_IAP_DEV_MODE = trueto your prod build temporarily, we can catch it the next time it happens. The key logs to look for:getAvailablePurchases result:(native level, always enabled) — if this is empty, the issue is at the platform/store levelNitroPurchase missing required field(JS level, also always enabled — shows asconsole.error) — if this shows up, our validation is filtering purchases that native returned
This will tell us exactly where the data is being lost.
Hi @hyochan i found the issue with
getAvailablePurchases.If you set params
const purchases = await getAvailablePurchases({ alsoPublishToEventListenerIOS: false, onlyIncludeActiveItemsIOS: false, });the call never resolves as either success or failure it just hangs.
Ive removed params and seems to work correctly, hopefully now we found all issues.
Hi @renatomserra, I found the issue in
getAvailablePurchases.The iOS native code was using Variant pattern matching (
.second) on SwiftOptionaltypes, which never matches — so user-provided values were silently ignored and always fell back to defaults.Fix:
Replace lines 258–273 in
ios/HybridRnIap.swift:// Before (broken - Variant pattern on Optional types) let iosOpts: NitroAvailablePurchasesIosOptions? if case .second(let unwrapped) = options?.ios { iosOpts = unwrapped } else { iosOpts = nil } let alsoPublish: Bool = { if case .second(let val) = iosOpts?.alsoPublishToEventListener { return val } return false }() let onlyActive: Bool = { if case .second(let val) = iosOpts?.onlyIncludeActiveItemsIOS { return val } if case .second(let val) = iosOpts?.onlyIncludeActiveItems { return val } return false }()
With:
// After (correct - standard Optional chaining) let iosOpts = options?.ios let alsoPublish = iosOpts?.alsoPublishToEventListener ?? iosOpts?.alsoPublishToEventListenerIOS ?? false let onlyActive = iosOpts?.onlyIncludeActiveItemsIOS ?? iosOpts?.onlyIncludeActiveItems ?? false
Could you test this and let us know if the hang is resolved?
Full modified
ios/HybridRnIap.swift(click to expand — you can copy-paste the entire file)import Foundation import NitroModules import OpenIAP @available(iOS 15.0, macOS 14.0, tvOS 15.0, watchOS 8.0, *) class HybridRnIap: HybridRnIapSpec { // MARK: - Properties private var updateListenerTask: Task<Void, Never>? private var isInitialized: Bool = false private var isInitializing: Bool = false private var productTypeBySku: [String: String] = [:] // OpenIAP event subscriptions private var purchaseUpdatedSub: Subscription? private var purchaseErrorSub: Subscription? private var promotedProductSub: Subscription? // Event listeners private var purchaseUpdatedListeners: [(NitroPurchase) -> Void] = [] private var purchaseErrorListeners: [(NitroPurchaseResult) -> Void] = [] private var promotedProductListeners: [(NitroProduct) -> Void] = [] private var lastPurchaseErrorKey: String? = nil private var lastPurchaseErrorTimestamp: TimeInterval = 0 private var deliveredPurchaseEventKeys: Set<String> = [] private var deliveredPurchaseEventOrder: [String] = [] private let purchaseEventDedupLimit = 128 private var purchasePayloadById: [String: [String: Any]] = [:] // Thread safety lock for listener arrays and error dedup state private let listenerLock = NSLock() // MARK: - Initialization override init() { super.init() } deinit { updateListenerTask?.cancel() } // MARK: - Public Methods (Cross-platform) func initConnection(config: Variant_NullType_InitConnectionConfig?) throws -> Promise<Bool> { return Promise.async { let configValue: InitConnectionConfig? = { if case .second(let c) = config { return c } return nil }() RnIapLog.payload("initConnection", configValue?.alternativeBillingModeAndroid) self.attachListenersIfNeeded() if self.isInitialized || self.isInitializing { RnIapLog.result("initConnection", true) return true } self.isInitializing = true do { // Note: iOS doesn't support alternative billing config parameter // Config is ignored on iOS platform let ok = try await OpenIapModule.shared.initConnection() RnIapLog.result("initConnection", ok) self.isInitialized = ok self.isInitializing = false return ok } catch { RnIapLog.failure("initConnection", error: error) let err = RnIapHelper.makePurchaseErrorResult( code: .initConnection, message: error.localizedDescription ) self.sendPurchaseError(err, productId: nil) self.isInitialized = false self.isInitializing = false return false } } } func endConnection() throws -> Promise<Bool> { return Promise.async { self.cleanupExistingState() return true } } func fetchProducts(skus: [String], type: String) throws -> Promise<[NitroProduct]> { return Promise.async { try self.ensureConnection() RnIapLog.payload("fetchProducts", [ "skus": skus, "type": type ]) if skus.isEmpty { throw OpenIapException.make(code: .emptySkuList) } var productsById: [String: NitroProduct] = [:] let normalizedType = type.lowercased() let queryTypes: [ProductQueryType] if normalizedType == "all" { queryTypes = [.inApp, .subs] } else { if normalizedType == "inapp" { RnIapLog.warn("fetchProducts received legacy type 'inapp'; forwarding as 'in-app'") } queryTypes = [RnIapHelper.parseProductQueryType(type)] } for queryType in queryTypes { let request = try OpenIapSerialization.productRequest(skus: skus, type: queryType) RnIapLog.payload( "fetchProducts.native", [ "skus": skus, "type": queryType.rawValue ] ) let result = try await OpenIapModule.shared.fetchProducts(request) let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.products(result)) RnIapLog.result("fetchProducts.native", payloads) for payload in payloads { let nitroProduct = RnIapHelper.convertProductDictionary(payload) productsById[nitroProduct.id] = nitroProduct } } var products: [NitroProduct] = [] var seenIds = Set<String>() for sku in skus { if let product = productsById[sku], !seenIds.contains(product.id) { products.append(product) seenIds.insert(product.id) } } for product in productsById.values where !seenIds.contains(product.id) { products.append(product) seenIds.insert(product.id) } await MainActor.run { [products] in products.forEach { self.productTypeBySku[$0.id] = $0.type.lowercased() } } RnIapLog.result( "fetchProducts", products.map { ["id": $0.id, "type": $0.type] } ) return products } } func requestPurchase(request: NitroPurchaseRequest) throws -> Promise<RequestPurchaseResult> { return Promise.async { let defaultResult: RequestPurchaseResult = .fourth([]) RnIapLog.payload( "requestPurchase", [ "hasIOS": request.ios != nil, "hasAndroid": request.android != nil ] ) let iosRequest: NitroRequestPurchaseIos if case .second(let unwrapped) = request.ios { iosRequest = unwrapped } else { let error = RnIapHelper.makePurchaseErrorResult( code: .developerError, message: "No iOS request provided" ) self.sendPurchaseError(error, productId: nil) return defaultResult } guard self.isInitialized else { let err = RnIapHelper.makePurchaseErrorResult( code: .initConnection, message: "IAP store connection not initialized", iosRequest.sku ) self.sendPurchaseError(err, productId: iosRequest.sku) return defaultResult } do { var iosPayload: [String: Any] = ["sku": iosRequest.sku] if case .second(let quantity) = iosRequest.quantity { iosPayload["quantity"] = Int(quantity) } if case .second(let finishAutomatically) = iosRequest.andDangerouslyFinishTransactionAutomatically { iosPayload["andDangerouslyFinishTransactionAutomatically"] = finishAutomatically } if case .second(let appAccountToken) = iosRequest.appAccountToken { iosPayload["appAccountToken"] = appAccountToken } if case .second(let withOffer) = iosRequest.withOffer { iosPayload["withOffer"] = withOffer } if case .second(let advancedCommerceData) = iosRequest.advancedCommerceData { iosPayload["advancedCommerceData"] = advancedCommerceData } // WWDC 2025 / iOS 18+ subscription offer fields if case .second(let introductoryOfferEligibility) = iosRequest.introductoryOfferEligibility { iosPayload["introductoryOfferEligibility"] = introductoryOfferEligibility } if case .second(let promotionalOfferJWS) = iosRequest.promotionalOfferJWS { iosPayload["promotionalOfferJWS"] = [ "jws": promotionalOfferJWS.jws, "offerId": promotionalOfferJWS.offerId ] } if case .second(let winBackOffer) = iosRequest.winBackOffer { iosPayload["winBackOffer"] = ["offerId": winBackOffer.offerId] } let cachedType = await MainActor.run { self.productTypeBySku[iosRequest.sku] } let resolvedType = RnIapHelper.parseProductQueryType(cachedType) let purchaseType: ProductQueryType = resolvedType == .all ? .inApp : resolvedType await MainActor.run { self.productTypeBySku[iosRequest.sku] = purchaseType.rawValue } let props = try RnIapHelper.decodeRequestPurchaseProps( iosPayload: iosPayload, type: purchaseType ) RnIapLog.payload( "requestPurchase.native", iosPayload ) let result = try await OpenIapModule.shared.requestPurchase(props) if result != nil { RnIapLog.result("requestPurchase", "delegated to OpenIAP") } else { RnIapLog.result("requestPurchase", nil) } return defaultResult } catch let purchaseError as PurchaseError { RnIapLog.failure("requestPurchase", error: purchaseError) // OpenIAP already publishes purchaseError events for PurchaseError instances. // Avoid emitting a duplicate event back to JS; simply return. return defaultResult } catch { RnIapLog.failure("requestPurchase", error: error) let err = RnIapHelper.makePurchaseErrorResult( code: .purchaseError, message: error.localizedDescription, iosRequest.sku ) self.sendPurchaseErrorDedup(err, productId: iosRequest.sku) return defaultResult } } } func getAvailablePurchases(options: NitroAvailablePurchasesOptions?) throws -> Promise<[NitroPurchase]> { return Promise.async { try self.ensureConnection() do { let iosOpts = options?.ios let alsoPublish = iosOpts?.alsoPublishToEventListener ?? iosOpts?.alsoPublishToEventListenerIOS ?? false let onlyActive = iosOpts?.onlyIncludeActiveItemsIOS ?? iosOpts?.onlyIncludeActiveItems ?? false let optionsDictionary: [String: Any] = [ "alsoPublishToEventListenerIOS": alsoPublish, "onlyIncludeActiveItemsIOS": onlyActive ] let purchaseOptions = try OpenIapSerialization.purchaseOptions(from: optionsDictionary) RnIapLog.payload("getAvailablePurchases", optionsDictionary) let purchases = try await OpenIapModule.shared.getAvailablePurchases(purchaseOptions) let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases)) RnIapLog.result("getAvailablePurchases", payloads) return payloads.map { RnIapHelper.convertPurchaseDictionary($0) } } catch let purchaseError as PurchaseError { RnIapLog.failure("getAvailablePurchases", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("getAvailablePurchases", error: error) throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } } func getActiveSubscriptions(subscriptionIds: [String]?) throws -> Promise<[NitroActiveSubscription]> { return Promise.async { try self.ensureConnection() do { RnIapLog.payload("getActiveSubscriptions", subscriptionIds ?? []) // Call OpenIAP's native getActiveSubscriptions - includes renewalInfoIOS! let subscriptions = try await OpenIapModule.shared.getActiveSubscriptions(subscriptionIds) let payloads = RnIapHelper.sanitizeArray(subscriptions.map { OpenIapSerialization.encode($0) }) RnIapLog.result("getActiveSubscriptions", payloads) return payloads.map { RnIapHelper.convertActiveSubscriptionDictionary($0) } } catch let purchaseError as PurchaseError { RnIapLog.failure("getActiveSubscriptions", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("getActiveSubscriptions", error: error) throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } } func hasActiveSubscriptions(subscriptionIds: [String]?) throws -> Promise<Bool> { return Promise.async { try self.ensureConnection() do { RnIapLog.payload("hasActiveSubscriptions", subscriptionIds ?? []) let hasActive = try await OpenIapModule.shared.hasActiveSubscriptions(subscriptionIds) RnIapLog.result("hasActiveSubscriptions", hasActive) return hasActive } catch let purchaseError as PurchaseError { RnIapLog.failure("hasActiveSubscriptions", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("hasActiveSubscriptions", error: error) throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } } func finishTransaction(params: NitroFinishTransactionParams) throws -> Promise<Variant_Bool_NitroPurchaseResult> { return Promise.async { guard case .second(let iosParams) = params.ios else { return .first(true) } try self.ensureConnection() do { RnIapLog.payload( "finishTransaction", ["transactionId": iosParams.transactionId] ) var purchasePayload = await MainActor.run { () -> [String: Any]? in self.purchasePayloadById[iosParams.transactionId] } if purchasePayload == nil { RnIapLog.warn("Missing cached purchase payload for \(iosParams.transactionId); falling back to identifier-only finish") purchasePayload = ["transactionIdentifier": iosParams.transactionId] } guard let purchasePayload else { throw OpenIapException.make(code: .purchaseError, message: "Missing purchase context for \(iosParams.transactionId)") } let sanitizedPayload = RnIapHelper.sanitizeDictionary(purchasePayload) RnIapLog.payload("finishTransaction.nativePayload", sanitizedPayload) let purchaseInput = try OpenIapSerialization.purchaseInput(from: purchasePayload) _ = try await OpenIapModule.shared.finishTransaction(purchase: purchaseInput, isConsumable: nil) RnIapLog.result("finishTransaction", true) await MainActor.run { self.purchasePayloadById.removeValue(forKey: iosParams.transactionId) } return .first(true) } catch { RnIapLog.failure("finishTransaction", error: error) let tid = iosParams.transactionId throw OpenIapException.make(code: .purchaseError, message: "Transaction not found: \(tid)") } } } func validateReceipt(params: NitroReceiptValidationParams) throws -> Promise<Variant_NitroReceiptValidationResultIOS_NitroReceiptValidationResultAndroid> { return Promise.async { do { // Extract SKU from apple options (new platform-specific structure) guard case .second(let appleOptions) = params.apple, !appleOptions.sku.isEmpty else { throw OpenIapException.make(code: .developerError, message: "Missing required parameter: apple.sku") } let sku = appleOptions.sku RnIapLog.payload("validateReceiptIOS", ["sku": sku]) let props = try OpenIapSerialization.verifyPurchaseProps(from: ["apple": ["sku": sku]]) let result = try await OpenIapModule.shared.validateReceiptIOS(props) var encoded = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(result)) if encoded["receiptData"] != nil { encoded["receiptData"] = "<receipt>" } if encoded["jwsRepresentation"] != nil { encoded["jwsRepresentation"] = "<jws>" } RnIapLog.result("validateReceiptIOS", encoded) var latest: NitroPurchase? = nil if let transaction = result.latestTransaction { let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(transaction)) latest = RnIapHelper.convertPurchaseDictionary(payload) } let mapped = NitroReceiptValidationResultIOS( isValid: result.isValid, receiptData: result.receiptData, jwsRepresentation: result.jwsRepresentation, latestTransaction: latest.map { .second($0) } ) return .first(mapped) } catch let purchaseError as PurchaseError { RnIapLog.failure("validateReceiptIOS", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("validateReceiptIOS", error: error) throw OpenIapException.make(code: .purchaseVerificationFailed, message: error.localizedDescription) } } } func verifyPurchaseWithProvider(params: NitroVerifyPurchaseWithProviderProps) throws -> Promise<NitroVerifyPurchaseWithProviderResult> { return Promise.async { do { RnIapLog.payload("verifyPurchaseWithProvider", ["provider": params.provider.stringValue]) // Convert Nitro params to OpenIAP props using JSONSerialization (same as expo-iap) // Use stringValue for enum to get proper string representation ("iapkit" instead of numeric rawValue) var propsDict: [String: Any] = ["provider": params.provider.stringValue] if case .second(let iapkit) = params.iapkit { var iapkitDict: [String: Any] = [:] // Use provided apiKey, or fallback to Info.plist IAPKitAPIKey (set by config plugin) if case .second(let apiKey) = iapkit.apiKey { iapkitDict["apiKey"] = apiKey } else if let plistApiKey = Bundle.main.object(forInfoDictionaryKey: "IAPKitAPIKey") as? String { iapkitDict["apiKey"] = plistApiKey } if case .second(let apple) = iapkit.apple { iapkitDict["apple"] = ["jws": apple.jws] } if case .second(let google) = iapkit.google { iapkitDict["google"] = ["purchaseToken": google.purchaseToken] } propsDict["iapkit"] = iapkitDict } // Use JSONSerialization + JSONDecoder like expo-iap does let jsonData = try JSONSerialization.data(withJSONObject: propsDict) let props = try JSONDecoder().decode(VerifyPurchaseWithProviderProps.self, from: jsonData) let result = try await OpenIapModule.shared.verifyPurchaseWithProvider(props) RnIapLog.result("verifyPurchaseWithProvider", ["provider": result.provider, "hasIapkit": result.iapkit != nil]) // Convert result to Nitro types var nitroIapkitResult: NitroVerifyPurchaseWithIapkitResult? = nil if let item = result.iapkit { nitroIapkitResult = NitroVerifyPurchaseWithIapkitResult( isValid: item.isValid, state: IapkitPurchaseState(fromString: item.state.rawValue) ?? .unknown, store: IapStore(fromString: item.store.rawValue) ?? .unknown ) } // Convert errors if present var nitroErrors: [NitroVerifyPurchaseWithProviderError]? = nil if let errors = result.errors { nitroErrors = errors.map { error in NitroVerifyPurchaseWithProviderError( code: RnIapHelper.wrapString(error.code), message: error.message ) } } let wrappedIapkit: Variant_NullType_NitroVerifyPurchaseWithIapkitResult? = nitroIapkitResult.map { .second($0) } let wrappedErrors: Variant_NullType__NitroVerifyPurchaseWithProviderError_? = nitroErrors.map { .second($0) } return NitroVerifyPurchaseWithProviderResult( iapkit: wrappedIapkit, errors: wrappedErrors, provider: PurchaseVerificationProvider(fromString: result.provider.rawValue) ?? .iapkit ) } catch let purchaseError as PurchaseError { // Convert PurchaseError to OpenIapException to preserve message through Nitro bridge RnIapLog.failure("verifyPurchaseWithProvider", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("verifyPurchaseWithProvider", error: error) throw OpenIapException.make(code: .purchaseVerificationFailed, message: error.localizedDescription) } } } func getStorefront() throws -> Promise<String> { return Promise.async { do { RnIapLog.payload("getStorefront", nil) let storefront = try await OpenIapModule.shared.getStorefrontIOS() RnIapLog.result("getStorefront", storefront) return storefront } catch let purchaseError as PurchaseError { RnIapLog.failure("getStorefront", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("getStorefront", error: error) throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } } // MARK: - iOS-specific Public Methods func getStorefrontIOS() throws -> Promise<String> { return try getStorefront() } func getAppTransactionIOS() throws -> Promise<Variant_NullType_String> { return Promise.async { do { RnIapLog.payload("getAppTransactionIOS", nil) if #available(iOS 16.0, *) { if let appTx = try await OpenIapModule.shared.getAppTransactionIOS() { var result: [String: Any?] = [ "bundleId": appTx.bundleId, "appVersion": appTx.appVersion, "originalAppVersion": appTx.originalAppVersion, "originalPurchaseDate": appTx.originalPurchaseDate, "deviceVerification": appTx.deviceVerification, "deviceVerificationNonce": appTx.deviceVerificationNonce, "environment": appTx.environment, "signedDate": appTx.signedDate, "appId": appTx.appId, "appVersionId": appTx.appVersionId, "preorderDate": appTx.preorderDate ] result["appTransactionId"] = appTx.appTransactionId result["originalPlatform"] = appTx.originalPlatform let jsonData = try JSONSerialization.data(withJSONObject: result, options: []) let string = String(data: jsonData, encoding: .utf8) RnIapLog.result("getAppTransactionIOS", "<appTransaction>") if let s = string { return .second(s) } return .first(.null) } RnIapLog.result("getAppTransactionIOS", nil) return .first(.null) } else { RnIapLog.result("getAppTransactionIOS", nil) return .first(.null) } } catch { RnIapLog.failure("getAppTransactionIOS", error: error) return .first(.null) } } } func getPromotedProductIOS() throws -> Promise<Variant_NullType_NitroProduct> { return Promise.async { try self.ensureConnection() do { RnIapLog.payload("getPromotedProductIOS", nil) guard let product = try await OpenIapModule.shared.getPromotedProductIOS() else { RnIapLog.result("getPromotedProductIOS", nil) return .first(.null) } let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(product)) RnIapLog.result("getPromotedProductIOS", payload) return .second(RnIapHelper.convertProductDictionary(payload)) } catch let purchaseError as PurchaseError { RnIapLog.failure("getPromotedProductIOS", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("getPromotedProductIOS", error: error) throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } } func requestPromotedProductIOS() throws -> Promise<Variant_NullType_NitroProduct> { return try getPromotedProductIOS() } func buyPromotedProductIOS() throws -> Promise<Void> { return Promise.async { do { RnIapLog.payload("buyPromotedProductIOS", nil) let ok = try await OpenIapModule.shared.requestPurchaseOnPromotedProductIOS() RnIapLog.result("buyPromotedProductIOS", ok) } catch { // Event-only: OpenIAP will emit purchaseError for this flow. Avoid Promise rejection. RnIapLog.failure("buyPromotedProductIOS", error: error) } } } func presentCodeRedemptionSheetIOS() throws -> Promise<Bool> { return Promise.async { do { RnIapLog.payload("presentCodeRedemptionSheetIOS", nil) let ok = try await OpenIapModule.shared.presentCodeRedemptionSheetIOS() RnIapLog.result("presentCodeRedemptionSheetIOS", ok) return ok } catch { // Fallback with explicit error for simulator or unsupported cases RnIapLog.failure("presentCodeRedemptionSheetIOS", error: error) throw OpenIapException.make(code: .featureNotSupported) } } } func clearTransactionIOS() throws -> Promise<Void> { return Promise.async { do { RnIapLog.payload("clearTransactionIOS", nil) let ok = try await OpenIapModule.shared.clearTransactionIOS() RnIapLog.result("clearTransactionIOS", ok) } catch { // ignore RnIapLog.failure("clearTransactionIOS", error: error) } } } // Additional iOS-only functions for feature parity with expo-iap func subscriptionStatusIOS(sku: String) throws -> Promise<Variant_NullType__NitroSubscriptionStatus_> { return Promise.async { try self.ensureConnection() do { RnIapLog.payload("subscriptionStatusIOS", ["sku": sku]) let statuses = try await OpenIapModule.shared.subscriptionStatusIOS(sku: sku) let payloads = statuses.map { RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) } RnIapLog.result("subscriptionStatusIOS", payloads) let result: [NitroSubscriptionStatus] = payloads.map { payload in let stateValue: Double if let numeric = RnIapHelper.doubleValue(payload["state"]) { stateValue = numeric } else if let stateString = payload["state"] as? String { stateValue = stateString.lowercased() == "subscribed" ? 1 : 0 } else { stateValue = 0 } let platform = payload["platform"] as? String ?? "ios" var renewalInfo: Variant_NullType_NitroSubscriptionRenewalInfo? = nil if let renewalPayload = payload["renewalInfo"] as? [String: Any?] { if let info = RnIapHelper.convertRenewalInfo(RnIapHelper.sanitizeDictionary(renewalPayload)) { renewalInfo = .second(info) } } return NitroSubscriptionStatus(state: stateValue, platform: platform, renewalInfo: renewalInfo) } return .second(result) } catch { RnIapLog.failure("subscriptionStatusIOS", error: error) return .second([]) } } } func currentEntitlementIOS(sku: String) throws -> Promise<Variant_NullType_NitroPurchase> { return Promise.async { try self.ensureConnection() do { RnIapLog.payload("currentEntitlementIOS", ["sku": sku]) let purchase = try await OpenIapModule.shared.currentEntitlementIOS(sku: sku) if let purchase { let raw = OpenIapSerialization.encode(purchase) let payload = RnIapHelper.sanitizeDictionary(raw) RnIapLog.result("currentEntitlementIOS", payload) if let identifier = raw["id"] as? String { await MainActor.run { self.purchasePayloadById[identifier] = raw } } return .second(RnIapHelper.convertPurchaseDictionary(payload)) } RnIapLog.result("currentEntitlementIOS", nil) return .first(.null) } catch { RnIapLog.failure("currentEntitlementIOS", error: error) throw OpenIapException.make(code: .skuNotFound, productId: sku) } } } func latestTransactionIOS(sku: String) throws -> Promise<Variant_NullType_NitroPurchase> { return Promise.async { try self.ensureConnection() do { RnIapLog.payload("latestTransactionIOS", ["sku": sku]) let purchase = try await OpenIapModule.shared.latestTransactionIOS(sku: sku) if let purchase { let raw = OpenIapSerialization.encode(purchase) let payload = RnIapHelper.sanitizeDictionary(raw) RnIapLog.result("latestTransactionIOS", payload) if let identifier = raw["id"] as? String { await MainActor.run { self.purchasePayloadById[identifier] = raw } } return .second(RnIapHelper.convertPurchaseDictionary(payload)) } RnIapLog.result("latestTransactionIOS", nil) return .first(.null) } catch { RnIapLog.failure("latestTransactionIOS", error: error) throw OpenIapException.make(code: .skuNotFound, productId: sku) } } } func getPendingTransactionsIOS() throws -> Promise<[NitroPurchase]> { return Promise.async { do { RnIapLog.payload("getPendingTransactionsIOS", nil) let pending = try await OpenIapModule.shared.getPendingTransactionsIOS() var unionPurchases: [OpenIAP.Purchase] = [] for purchase in pending { let union = OpenIAP.Purchase.purchaseIos(purchase) unionPurchases.append(union) let raw = OpenIapSerialization.purchase(union) if let identifier = raw["id"] as? String { await MainActor.run { self.purchasePayloadById[identifier] = raw } } } let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.purchases(unionPurchases)) RnIapLog.result("getPendingTransactionsIOS", payloads) return payloads.map { RnIapHelper.convertPurchaseDictionary($0) } } catch { RnIapLog.failure("getPendingTransactionsIOS", error: error) return [] } } } func syncIOS() throws -> Promise<Bool> { return Promise.async { do { RnIapLog.payload("syncIOS", nil) let ok = try await OpenIapModule.shared.syncIOS() RnIapLog.result("syncIOS", ok) return ok } catch let purchaseError as PurchaseError { RnIapLog.failure("syncIOS", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("syncIOS", error: error) throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } } func showManageSubscriptionsIOS() throws -> Promise<[NitroPurchase]> { return Promise.async { try self.ensureConnection() do { // Trigger system UI RnIapLog.payload("showManageSubscriptionsIOS", nil) _ = try await OpenIapModule.shared.showManageSubscriptionsIOS() // Return current entitlements as approximation of updates let optionsDictionary: [String: Any] = [ "alsoPublishToEventListenerIOS": false, "onlyIncludeActiveItemsIOS": true ] let iosOptions = try OpenIapSerialization.purchaseOptions(from: optionsDictionary) let purchases = try await OpenIapModule.shared.getAvailablePurchases(iosOptions) let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases)) RnIapLog.result("showManageSubscriptionsIOS", payloads) return payloads.map { RnIapHelper.convertPurchaseDictionary($0) } } catch let purchaseError as PurchaseError { RnIapLog.failure("showManageSubscriptionsIOS", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("showManageSubscriptionsIOS", error: error) throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } } func deepLinkToSubscriptionsIOS() throws -> Promise<Bool> { return Promise.async { try self.ensureConnection() do { RnIapLog.payload("deepLinkToSubscriptionsIOS", nil) try await OpenIapModule.shared.deepLinkToSubscriptions(nil) RnIapLog.result("deepLinkToSubscriptionsIOS", true) return true } catch let purchaseError as PurchaseError { RnIapLog.failure("deepLinkToSubscriptionsIOS", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("deepLinkToSubscriptionsIOS", error: error) throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } } func isEligibleForIntroOfferIOS(groupID: String) throws -> Promise<Bool> { return Promise.async { RnIapLog.payload("isEligibleForIntroOfferIOS", ["groupID": groupID]) let value = try await OpenIapModule.shared.isEligibleForIntroOfferIOS(groupID: groupID) RnIapLog.result("isEligibleForIntroOfferIOS", value) return value } } func getReceiptDataIOS() throws -> Promise<String> { return Promise.async { try self.ensureConnection() do { RnIapLog.payload("getReceiptDataIOS", nil) let receipt = try await RnIapHelper.loadReceiptData(refresh: false) RnIapLog.result("getReceiptDataIOS", "<receipt>") return receipt } catch let purchaseError as PurchaseError { RnIapLog.failure("getReceiptDataIOS", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("getReceiptDataIOS", error: error) throw OpenIapException.make(code: .receiptFailed, message: error.localizedDescription) } } } func getReceiptIOS() throws -> Promise<String> { return Promise.async { try self.ensureConnection() do { RnIapLog.payload("getReceiptIOS", nil) let receipt = try await RnIapHelper.loadReceiptData(refresh: true) RnIapLog.result("getReceiptIOS", "<receipt>") return receipt } catch let purchaseError as PurchaseError { RnIapLog.failure("getReceiptIOS", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("getReceiptIOS", error: error) throw OpenIapException.make(code: .receiptFailed, message: error.localizedDescription) } } } func requestReceiptRefreshIOS() throws -> Promise<String> { return Promise.async { try self.ensureConnection() do { RnIapLog.payload("requestReceiptRefreshIOS", nil) let receipt = try await RnIapHelper.loadReceiptData(refresh: true) RnIapLog.result("requestReceiptRefreshIOS", "<receipt>") return receipt } catch let purchaseError as PurchaseError { RnIapLog.failure("requestReceiptRefreshIOS", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("requestReceiptRefreshIOS", error: error) throw OpenIapException.make(code: .receiptFailed, message: error.localizedDescription) } } } func isTransactionVerifiedIOS(sku: String) throws -> Promise<Bool> { return Promise.async { try self.ensureConnection() RnIapLog.payload("isTransactionVerifiedIOS", ["sku": sku]) let value = try await OpenIapModule.shared.isTransactionVerifiedIOS(sku: sku) RnIapLog.result("isTransactionVerifiedIOS", value) return value } } func getTransactionJwsIOS(sku: String) throws -> Promise<Variant_NullType_String> { return Promise.async { try self.ensureConnection() do { RnIapLog.payload("getTransactionJwsIOS", ["sku": sku]) let jws = try await OpenIapModule.shared.getTransactionJwsIOS(sku: sku) let maskedJws: Any? = (jws == nil) ? nil : "<jws>" RnIapLog.result("getTransactionJwsIOS", maskedJws) if let jws { return .second(jws) } return .first(.null) } catch { RnIapLog.failure("getTransactionJwsIOS", error: error) throw OpenIapException.make(code: .transactionValidationFailed, message: "Can't find transaction for sku \(sku)") } } } func beginRefundRequestIOS(sku: String) throws -> Promise<Variant_NullType_String> { return Promise.async { do { RnIapLog.payload("beginRefundRequestIOS", ["sku": sku]) let result = try await OpenIapModule.shared.beginRefundRequestIOS(sku: sku) RnIapLog.result("beginRefundRequestIOS", result) if let result { return .second(result) } return .first(.null) } catch { RnIapLog.failure("beginRefundRequestIOS", error: error) return .first(.null) } } } func addPromotedProductListenerIOS(listener: @escaping (NitroProduct) -> Void) throws { listenerLock.withLock { promotedProductListeners.append(listener) } // If a promoted product is already available from OpenIAP, notify immediately Task { RnIapLog.payload("promotedProductListenerIOS.fetch", nil) if let product = try? await OpenIapModule.shared.getPromotedProductIOS() { let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(product)) RnIapLog.result("promotedProductListenerIOS.fetch", payload) let nitro = RnIapHelper.convertProductDictionary(payload) await MainActor.run { listener(nitro) } } } } func removePromotedProductListenerIOS(listener: @escaping (NitroProduct) -> Void) throws { listenerLock.withLock { promotedProductListeners.removeAll() } } // MARK: - Event Listener Methods func addPurchaseUpdatedListener(listener: @escaping (NitroPurchase) -> Void) throws { listenerLock.withLock { purchaseUpdatedListeners.append(listener) } } func addPurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws { listenerLock.withLock { purchaseErrorListeners.append(listener) } } func removePurchaseUpdatedListener(listener: @escaping (NitroPurchase) -> Void) throws { listenerLock.withLock { purchaseUpdatedListeners.removeAll() } } func removePurchaseErrorListener(listener: @escaping (NitroPurchaseResult) -> Void) throws { listenerLock.withLock { purchaseErrorListeners.removeAll() } } // MARK: - Private Helper Methods private func attachListenersIfNeeded() { if purchaseUpdatedSub == nil { RnIapLog.payload("purchaseUpdatedListener.register", nil) purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { [weak self] openIapPurchase in guard let self else { RnIapLog.warn("purchaseUpdatedListener: HybridRnIap deallocated, purchase event dropped") return } Task { @MainActor in let rawPayload = OpenIapSerialization.purchase(openIapPurchase) let payload = RnIapHelper.sanitizeDictionary(rawPayload) RnIapLog.result("purchaseUpdatedListener", payload) if let identifier = rawPayload["id"] as? String { self.purchasePayloadById[identifier] = rawPayload } let nitro = RnIapHelper.convertPurchaseDictionary(payload) self.sendPurchaseUpdate(nitro) } } RnIapLog.result("purchaseUpdatedListener.register", "attached") } if purchaseErrorSub == nil { RnIapLog.payload("purchaseErrorListener.register", nil) purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { [weak self] error in guard let self else { RnIapLog.warn("purchaseErrorListener: HybridRnIap deallocated, error event dropped") return } Task { @MainActor in let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(error)) RnIapLog.result("purchaseErrorListener", payload) let nitroError = RnIapHelper.makePurchaseErrorResult( code: error.code, message: error.message, error.productId ) self.sendPurchaseError(nitroError, productId: error.productId) } } RnIapLog.result("purchaseErrorListener.register", "attached") } if promotedProductSub == nil { RnIapLog.payload("promotedProductListenerIOS.register", nil) promotedProductSub = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in guard let self else { RnIapLog.warn("promotedProductListenerIOS: HybridRnIap deallocated, promoted product event dropped") return } Task { RnIapLog.payload("promotedProductListenerIOS", ["productId": productId]) do { let request = try OpenIapSerialization.productRequest(skus: [productId], type: .all) let result = try await OpenIapModule.shared.fetchProducts(request) let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.products(result)) RnIapLog.result("fetchProducts", payloads) if let payload = payloads.first { let nitro = RnIapHelper.convertProductDictionary(payload) let snapshot = self.listenerLock.withLock { Array(self.promotedProductListeners) } await MainActor.run { for listener in snapshot { listener(nitro) } } } } catch { RnIapLog.failure("promotedProductListenerIOS", error: error) let id = productId let snapshot = self.listenerLock.withLock { Array(self.promotedProductListeners) } await MainActor.run { let minimal = RnIapHelper.makeMinimalProduct(id: id) for listener in snapshot { listener(minimal) } } } } } RnIapLog.result("promotedProductListenerIOS.register", "attached") } } private func ensureConnection() throws { guard isInitialized else { throw OpenIapException.make(code: .initConnection, message: "Connection not initialized. Call initConnection() first.") } } private func sendPurchaseUpdate(_ purchase: NitroPurchase) { let originalTxId: String if case .second(let val) = purchase.originalTransactionIdentifierIOS { originalTxId = val } else { originalTxId = "" } let purchaseTokenStr: String if case .second(let val) = purchase.purchaseToken { purchaseTokenStr = val } else { purchaseTokenStr = "" } let keyComponents: [String] = [ purchase.id, purchase.productId, String(purchase.transactionDate), originalTxId, purchaseTokenStr ] let eventKey = keyComponents.joined(separator: "#") var isDuplicate = false let snapshot: [(NitroPurchase) -> Void] = listenerLock.withLock { if deliveredPurchaseEventKeys.contains(eventKey) { isDuplicate = true return [] } deliveredPurchaseEventKeys.insert(eventKey) deliveredPurchaseEventOrder.append(eventKey) if deliveredPurchaseEventOrder.count > purchaseEventDedupLimit, let removed = deliveredPurchaseEventOrder.first { deliveredPurchaseEventOrder.removeFirst() deliveredPurchaseEventKeys.remove(removed) } return Array(purchaseUpdatedListeners) } if isDuplicate { RnIapLog.warn("Duplicate purchase update skipped for \(purchase.productId)") return } for listener in snapshot { listener(purchase) } } private func sendPurchaseError(_ error: NitroPurchaseResult, productId: String? = nil) { let dedupIdentifier = productId ?? (error.purchaseToken?.isEmpty == false ? error.purchaseToken : nil) ?? (error.message.isEmpty ? nil : error.message) let currentKey = RnIapHelper.makeErrorDedupKey(code: error.code, productId: dedupIdentifier) // Protect error dedup state since sendPurchaseError is called from multiple threads let shouldSkip: Bool = listenerLock.withLock { let now = Date().timeIntervalSince1970 let withinWindow = (now - lastPurchaseErrorTimestamp) < 0.15 if currentKey == lastPurchaseErrorKey && withinWindow { return true } lastPurchaseErrorKey = currentKey lastPurchaseErrorTimestamp = now return false } if shouldSkip { return } // Ensure we never leak SKU via purchaseToken let sanitized: NitroPurchaseResult if let pid = productId, error.purchaseToken == pid { sanitized = NitroPurchaseResult( responseCode: error.responseCode, debugMessage: error.debugMessage, code: error.code, message: error.message, purchaseToken: nil ) } else { sanitized = error } let snapshot = listenerLock.withLock { Array(purchaseErrorListeners) } for listener in snapshot { listener(sanitized) } } private func sendPurchaseErrorDedup(_ error: NitroPurchaseResult, productId: String? = nil) { sendPurchaseError(error, productId: productId) } private func cleanupExistingState() { // Cancel transaction listener if any updateListenerTask?.cancel() updateListenerTask = nil isInitialized = false isInitializing = false // Remove OpenIAP listeners & end connection if let sub = purchaseUpdatedSub { RnIapLog.payload("removeListener", "purchaseUpdated") OpenIapModule.shared.removeListener(sub) } if let sub = purchaseErrorSub { RnIapLog.payload("removeListener", "purchaseError") OpenIapModule.shared.removeListener(sub) } if let sub = promotedProductSub { RnIapLog.payload("removeListener", "promotedProduct") OpenIapModule.shared.removeListener(sub) } purchaseUpdatedSub = nil purchaseErrorSub = nil promotedProductSub = nil Task { RnIapLog.payload("endConnection", nil) let result = try? await OpenIapModule.shared.endConnection() RnIapLog.result("endConnection", result as Any) } // Clear event listeners, error dedup state, and delivery state (thread-safe) listenerLock.withLock { purchaseUpdatedListeners.removeAll() purchaseErrorListeners.removeAll() promotedProductListeners.removeAll() lastPurchaseErrorKey = nil lastPurchaseErrorTimestamp = 0 deliveredPurchaseEventKeys.removeAll() deliveredPurchaseEventOrder.removeAll() } // Clear purchasePayloadById on MainActor to match its access pattern Task { @MainActor in self.purchasePayloadById.removeAll() } } func deepLinkToSubscriptionsAndroid(options: NitroDeepLinkOptionsAndroid) throws -> Promise<Void> { return Promise.async { throw OpenIapException.make(code: .featureNotSupported) } } // MARK: - Alternative Billing (Android) - Not supported on iOS func checkAlternativeBillingAvailabilityAndroid() throws -> Promise<Bool> { return Promise.async { throw OpenIapException.make(code: .featureNotSupported) } } func showAlternativeBillingDialogAndroid() throws -> Promise<Bool> { return Promise.async { throw OpenIapException.make(code: .featureNotSupported) } } func createAlternativeBillingTokenAndroid(sku: Variant_NullType_String?) throws -> Promise<Variant_NullType_String> { return Promise.async { throw OpenIapException.make(code: .featureNotSupported) } } func addUserChoiceBillingListenerAndroid(listener: @escaping (UserChoiceBillingDetails) -> Void) throws { RnIapLog.warn("addUserChoiceBillingListenerAndroid is Android-only and has no effect on iOS") } func removeUserChoiceBillingListenerAndroid(listener: @escaping (UserChoiceBillingDetails) -> Void) throws { RnIapLog.warn("removeUserChoiceBillingListenerAndroid is Android-only and has no effect on iOS") } func addDeveloperProvidedBillingListenerAndroid(listener: @escaping (DeveloperProvidedBillingDetailsAndroid) -> Void) throws { RnIapLog.warn("addDeveloperProvidedBillingListenerAndroid is Android-only and has no effect on iOS") } func removeDeveloperProvidedBillingListenerAndroid(listener: @escaping (DeveloperProvidedBillingDetailsAndroid) -> Void) throws { RnIapLog.warn("removeDeveloperProvidedBillingListenerAndroid is Android-only and has no effect on iOS") } // MARK: - Billing Programs API (Android 8.2.0+) - Not supported on iOS func enableBillingProgramAndroid(program: BillingProgramAndroid) throws { RnIapLog.warn("enableBillingProgramAndroid is Android-only and has no effect on iOS") } func isBillingProgramAvailableAndroid(program: BillingProgramAndroid) throws -> Promise<NitroBillingProgramAvailabilityResultAndroid> { return Promise.async { throw OpenIapException.make(code: .featureNotSupported, message: "Billing Programs API is Android-only") } } func createBillingProgramReportingDetailsAndroid(program: BillingProgramAndroid) throws -> Promise<NitroBillingProgramReportingDetailsAndroid> { return Promise.async { throw OpenIapException.make(code: .featureNotSupported, message: "Billing Programs API is Android-only") } } func launchExternalLinkAndroid(params: NitroLaunchExternalLinkParamsAndroid) throws -> Promise<Bool> { return Promise.async { throw OpenIapException.make(code: .featureNotSupported, message: "Billing Programs API is Android-only") } } // MARK: - External Purchase (iOS 16.0+) func canPresentExternalPurchaseNoticeIOS() throws -> Promise<Bool> { return Promise.async { RnIapLog.payload("canPresentExternalPurchaseNoticeIOS", nil) if #available(iOS 16.0, *) { try self.ensureConnection() do { let canPresent = try await OpenIapModule.shared.canPresentExternalPurchaseNoticeIOS() RnIapLog.result("canPresentExternalPurchaseNoticeIOS", canPresent) return canPresent } catch let purchaseError as PurchaseError { RnIapLog.failure("canPresentExternalPurchaseNoticeIOS", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("canPresentExternalPurchaseNoticeIOS", error: error) throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } else { let err = OpenIapException.make(code: .featureNotSupported, message: "External purchase notice requires iOS 16.0 or later") RnIapLog.failure("canPresentExternalPurchaseNoticeIOS", error: err) throw err } } } func presentExternalPurchaseNoticeSheetIOS() throws -> Promise<ExternalPurchaseNoticeResultIOS> { return Promise.async { RnIapLog.payload("presentExternalPurchaseNoticeSheetIOS", nil) if #available(iOS 16.0, *) { try self.ensureConnection() do { let result = try await OpenIapModule.shared.presentExternalPurchaseNoticeSheetIOS() // Convert OpenIAP action to Nitro action via raw value let actionString = result.result.rawValue guard let nitroAction = ExternalPurchaseNoticeAction(fromString: actionString) else { throw OpenIapException.make(code: .serviceError, message: "Invalid action: \(actionString)") } let nitroResult = ExternalPurchaseNoticeResultIOS( error: RnIapHelper.wrapString(result.error), externalPurchaseToken: RnIapHelper.wrapString(result.externalPurchaseToken), result: nitroAction ) var encoded = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(result)) if encoded["externalPurchaseToken"] != nil { encoded["externalPurchaseToken"] = "<token>" } RnIapLog.result("presentExternalPurchaseNoticeSheetIOS", encoded) return nitroResult } catch let purchaseError as PurchaseError { RnIapLog.failure("presentExternalPurchaseNoticeSheetIOS", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("presentExternalPurchaseNoticeSheetIOS", error: error) throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } else { let err = OpenIapException.make(code: .featureNotSupported, message: "External purchase notice requires iOS 16.0 or later") RnIapLog.failure("presentExternalPurchaseNoticeSheetIOS", error: err) throw err } } } func presentExternalPurchaseLinkIOS(url: String) throws -> Promise<ExternalPurchaseLinkResultIOS> { return Promise.async { RnIapLog.payload("presentExternalPurchaseLinkIOS", ["url": url]) if #available(iOS 16.0, *) { try self.ensureConnection() do { let result = try await OpenIapModule.shared.presentExternalPurchaseLinkIOS(url) let nitroResult = ExternalPurchaseLinkResultIOS( error: RnIapHelper.wrapString(result.error), success: result.success ) RnIapLog.result("presentExternalPurchaseLinkIOS", result) return nitroResult } catch let purchaseError as PurchaseError { RnIapLog.failure("presentExternalPurchaseLinkIOS", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("presentExternalPurchaseLinkIOS", error: error) throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } else { let err = OpenIapException.make(code: .featureNotSupported, message: "External purchase link requires iOS 16.0 or later") RnIapLog.failure("presentExternalPurchaseLinkIOS", error: err) throw err } } } // MARK: - ExternalPurchaseCustomLink (iOS 18.1+) func isEligibleForExternalPurchaseCustomLinkIOS() throws -> Promise<Bool> { return Promise.async { RnIapLog.payload("isEligibleForExternalPurchaseCustomLinkIOS", nil) do { let isEligible = try await OpenIapModule.shared.isEligibleForExternalPurchaseCustomLinkIOS() RnIapLog.result("isEligibleForExternalPurchaseCustomLinkIOS", isEligible) return isEligible } catch let purchaseError as PurchaseError { RnIapLog.failure("isEligibleForExternalPurchaseCustomLinkIOS", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("isEligibleForExternalPurchaseCustomLinkIOS", error: error) throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } } func getExternalPurchaseCustomLinkTokenIOS(tokenType: ExternalPurchaseCustomLinkTokenTypeIOS) throws -> Promise<ExternalPurchaseCustomLinkTokenResultIOS> { return Promise.async { RnIapLog.payload("getExternalPurchaseCustomLinkTokenIOS", ["tokenType": tokenType.stringValue]) do { // Convert Nitro enum to OpenIAP enum guard let openIapTokenType = OpenIAP.ExternalPurchaseCustomLinkTokenTypeIOS(rawValue: tokenType.stringValue) else { throw OpenIapException.make(code: .developerError, message: "Invalid token type: \(tokenType.stringValue). Must be 'acquisition' or 'services'") } let result = try await OpenIapModule.shared.getExternalPurchaseCustomLinkTokenIOS(openIapTokenType) let nitroResult = ExternalPurchaseCustomLinkTokenResultIOS( error: RnIapHelper.wrapString(result.error), token: RnIapHelper.wrapString(result.token) ) var encoded = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(result)) if encoded["token"] != nil { encoded["token"] = "<token>" } RnIapLog.result("getExternalPurchaseCustomLinkTokenIOS", encoded) return nitroResult } catch let purchaseError as PurchaseError { RnIapLog.failure("getExternalPurchaseCustomLinkTokenIOS", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("getExternalPurchaseCustomLinkTokenIOS", error: error) throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } } func showExternalPurchaseCustomLinkNoticeIOS(noticeType: ExternalPurchaseCustomLinkNoticeTypeIOS) throws -> Promise<ExternalPurchaseCustomLinkNoticeResultIOS> { return Promise.async { RnIapLog.payload("showExternalPurchaseCustomLinkNoticeIOS", ["noticeType": noticeType.stringValue]) do { // Convert Nitro enum to OpenIAP enum // Handle 'unspecified' by defaulting to 'browser' (workaround for Nitro requiring 2+ enum values) let openIapNoticeType: OpenIAP.ExternalPurchaseCustomLinkNoticeTypeIOS if noticeType == .unspecified { RnIapLog.warn("showExternalPurchaseCustomLinkNoticeIOS received 'unspecified' noticeType, defaulting to 'browser'.") openIapNoticeType = .browser } else if let convertedType = OpenIAP.ExternalPurchaseCustomLinkNoticeTypeIOS(rawValue: noticeType.stringValue) { openIapNoticeType = convertedType } else { throw OpenIapException.make(code: .developerError, message: "Invalid notice type: \(noticeType.stringValue). Must be 'browser'") } let result = try await OpenIapModule.shared.showExternalPurchaseCustomLinkNoticeIOS(openIapNoticeType) let nitroResult = ExternalPurchaseCustomLinkNoticeResultIOS( continued: result.continued, error: RnIapHelper.wrapString(result.error) ) RnIapLog.result("showExternalPurchaseCustomLinkNoticeIOS", result) return nitroResult } catch let purchaseError as PurchaseError { RnIapLog.failure("showExternalPurchaseCustomLinkNoticeIOS", error: purchaseError) throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("showExternalPurchaseCustomLinkNoticeIOS", error: error) throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } } }
@hyochan just to confirm the fixes seem to be working correctly, sales seem to be back to normal. Thank you for all the effort, i appreciate your work 🫶
Reacted by Artur
Platform - iOS/Android
Purchase type - Sub / IAP
Versions
This the same issue reported on #3143 that has now been closed but not resolved.
Im still facing high volume of purchases not being reported by success handler or listeners.
Now that i activated debug logs in prod it seems the purchase is not even recognized by the lib debug logs but the user paid and says item already owned.
When calling
getAvailablePurchasesthey are also not present.Attaching logs of a user who had the issue and request purchase is not even listed as a log
If theres anything i can do to help, let me know