react-native-iap 14.3.9 → 14.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/NitroIap.podspec +11 -1
  2. package/README.md +2 -3
  3. package/android/build.gradle +24 -1
  4. package/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +369 -124
  5. package/android/src/main/java/com/margelo/nitro/iap/RnIapLog.kt +64 -0
  6. package/ios/HybridRnIap.swift +525 -362
  7. package/ios/RnIapHelper.swift +224 -0
  8. package/ios/RnIapLog.swift +127 -0
  9. package/lib/module/hooks/useIAP.js +2 -34
  10. package/lib/module/hooks/useIAP.js.map +1 -1
  11. package/lib/module/index.js +52 -2
  12. package/lib/module/index.js.map +1 -1
  13. package/lib/module/types.js.map +1 -1
  14. package/lib/typescript/plugin/src/withIAP.d.ts.map +1 -1
  15. package/lib/typescript/src/hooks/useIAP.d.ts +0 -12
  16. package/lib/typescript/src/hooks/useIAP.d.ts.map +1 -1
  17. package/lib/typescript/src/index.d.ts +3 -0
  18. package/lib/typescript/src/index.d.ts.map +1 -1
  19. package/lib/typescript/src/specs/RnIap.nitro.d.ts +24 -0
  20. package/lib/typescript/src/specs/RnIap.nitro.d.ts.map +1 -1
  21. package/lib/typescript/src/types.d.ts +8 -6
  22. package/lib/typescript/src/types.d.ts.map +1 -1
  23. package/nitrogen/generated/android/c++/JHybridRnIapSpec.cpp +64 -0
  24. package/nitrogen/generated/android/c++/JHybridRnIapSpec.hpp +4 -0
  25. package/nitrogen/generated/android/c++/JIapPlatform.hpp +3 -3
  26. package/nitrogen/generated/android/c++/JPurchaseAndroid.hpp +6 -2
  27. package/nitrogen/generated/android/c++/JPurchaseIOS.hpp +4 -0
  28. package/nitrogen/generated/android/c++/JPurchaseState.hpp +6 -6
  29. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/HybridRnIapSpec.kt +16 -0
  30. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/IapPlatform.kt +2 -2
  31. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/PurchaseAndroid.kt +4 -1
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/PurchaseIOS.kt +3 -0
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/PurchaseState.kt +5 -5
  34. package/nitrogen/generated/ios/c++/HybridRnIapSpecSwift.hpp +32 -0
  35. package/nitrogen/generated/ios/swift/HybridRnIapSpec.swift +4 -0
  36. package/nitrogen/generated/ios/swift/HybridRnIapSpec_cxx.swift +82 -0
  37. package/nitrogen/generated/ios/swift/IapPlatform.swift +4 -4
  38. package/nitrogen/generated/ios/swift/PurchaseAndroid.swift +32 -2
  39. package/nitrogen/generated/ios/swift/PurchaseIOS.swift +13 -2
  40. package/nitrogen/generated/ios/swift/PurchaseState.swift +8 -8
  41. package/nitrogen/generated/shared/c++/HybridRnIapSpec.cpp +4 -0
  42. package/nitrogen/generated/shared/c++/HybridRnIapSpec.hpp +4 -0
  43. package/nitrogen/generated/shared/c++/IapPlatform.hpp +5 -5
  44. package/nitrogen/generated/shared/c++/PurchaseAndroid.hpp +6 -2
  45. package/nitrogen/generated/shared/c++/PurchaseIOS.hpp +5 -1
  46. package/nitrogen/generated/shared/c++/PurchaseState.hpp +11 -11
  47. package/openiap-versions.json +5 -0
  48. package/package.json +3 -2
  49. package/plugin/build/withIAP.js +35 -3
  50. package/plugin/src/withIAP.ts +44 -3
  51. package/src/hooks/useIAP.ts +3 -71
  52. package/src/index.ts +61 -2
  53. package/src/specs/RnIap.nitro.ts +28 -0
  54. package/src/types.ts +8 -6
@@ -5,26 +5,24 @@ import OpenIAP
5
5
  @available(iOS 15.0, *)
6
6
  class HybridRnIap: HybridRnIapSpec {
7
7
  // MARK: - Properties
8
-
9
8
  private var updateListenerTask: Task<Void, Never>?
10
9
  private var isInitialized: Bool = false
11
10
  private var isInitializing: Bool = false
12
- // No local StoreKit cache; rely on OpenIAP
11
+ private var productTypeBySku: [String: String] = [:]
13
12
  // OpenIAP event subscriptions
14
13
  private var purchaseUpdatedSub: Subscription?
15
14
  private var purchaseErrorSub: Subscription?
16
15
  private var promotedProductSub: Subscription?
17
-
18
- // Promoted products are handled via OpenIAP listener
19
-
20
16
  // Event listeners
21
17
  private var purchaseUpdatedListeners: [(NitroPurchase) -> Void] = []
22
18
  private var purchaseErrorListeners: [(NitroPurchaseResult) -> Void] = []
23
19
  private var promotedProductListeners: [(NitroProduct) -> Void] = []
24
- // Deduplication for purchase error events
25
- private var lastPurchaseErrorCode: String? = nil
26
- private var lastPurchaseErrorProductId: String? = nil
20
+ private var lastPurchaseErrorKey: String? = nil
27
21
  private var lastPurchaseErrorTimestamp: TimeInterval = 0
22
+ private var deliveredPurchaseEventKeys: Set<String> = []
23
+ private var deliveredPurchaseEventOrder: [String] = []
24
+ private let purchaseEventDedupLimit = 128
25
+ private var purchasePayloadById: [String: [String: Any]] = [:]
28
26
 
29
27
  // MARK: - Initialization
30
28
 
@@ -42,131 +40,27 @@ class HybridRnIap: HybridRnIapSpec {
42
40
 
43
41
  func initConnection() throws -> Promise<Bool> {
44
42
  return Promise.async {
45
- // If already initialized or initializing, ensure listeners are attached and return immediately
43
+ RnIapLog.payload("initConnection", nil)
44
+ self.attachListenersIfNeeded()
45
+
46
46
  if self.isInitialized || self.isInitializing {
47
- if self.purchaseUpdatedSub == nil {
48
- self.purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { [weak self] openIapPurchase in
49
- guard let self else { return }
50
- Task { @MainActor in
51
- let nitro = self.convertOpenIapPurchaseToNitroPurchase(openIapPurchase)
52
- self.sendPurchaseUpdate(nitro)
53
- }
54
- }
55
- }
56
- if self.purchaseErrorSub == nil {
57
- self.purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { [weak self] error in
58
- guard let self else { return }
59
- Task { @MainActor in
60
- #if DEBUG
61
- print("[HybridRnIap] purchaseError event: code=\(error.code), productId=\(error.productId ?? "-")")
62
- #endif
63
- let nitroError = self.createPurchaseErrorResult(
64
- code: error.code,
65
- message: error.message,
66
- productId: error.productId
67
- )
68
- self.sendPurchaseError(nitroError, productId: error.productId)
69
- }
70
- }
71
- }
72
- if self.promotedProductSub == nil {
73
- self.promotedProductSub = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in
74
- guard let self else { return }
75
- Task {
76
- do {
77
- let req = OpenIapProductRequest(skus: [productId], type: "all")
78
- let products = try await OpenIapModule.shared.fetchProducts(req)
79
- if let p = products.first {
80
- let nitro = self.convertOpenIapProductToNitroProduct(p)
81
- await MainActor.run {
82
- for listener in self.promotedProductListeners { listener(nitro) }
83
- }
84
- }
85
- } catch {
86
- let id = productId
87
- await MainActor.run {
88
- var minimal = NitroProduct()
89
- minimal.id = id
90
- minimal.title = id
91
- minimal.type = "inapp"
92
- minimal.platform = .ios
93
- for listener in self.promotedProductListeners { listener(minimal) }
94
- }
95
- }
96
- }
97
- }
98
- }
47
+ RnIapLog.result("initConnection", true)
99
48
  return true
100
49
  }
101
50
 
102
- // Begin non-blocking initialization
103
51
  self.isInitializing = true
104
- // Pre-attach listeners so events are not missed
105
- if self.purchaseUpdatedSub == nil {
106
- self.purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { [weak self] openIapPurchase in
107
- guard let self else { return }
108
- Task { @MainActor in
109
- let nitro = self.convertOpenIapPurchaseToNitroPurchase(openIapPurchase)
110
- self.sendPurchaseUpdate(nitro)
111
- }
112
- }
113
- }
114
- if self.purchaseErrorSub == nil {
115
- self.purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { [weak self] error in
116
- guard let self else { return }
117
- Task { @MainActor in
118
- #if DEBUG
119
- print("[HybridRnIap] purchaseError event: code=\(error.code), productId=\(error.productId ?? "-")")
120
- #endif
121
- let nitroError = self.createPurchaseErrorResult(
122
- code: error.code,
123
- message: error.message,
124
- productId: error.productId
125
- )
126
- self.sendPurchaseError(nitroError, productId: error.productId)
127
- }
128
- }
129
- }
130
- if self.promotedProductSub == nil {
131
- self.promotedProductSub = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in
132
- guard let self else { return }
133
- Task {
134
- do {
135
- let req = OpenIapProductRequest(skus: [productId], type: "all")
136
- let products = try await OpenIapModule.shared.fetchProducts(req)
137
- if let p = products.first {
138
- let nitro = self.convertOpenIapProductToNitroProduct(p)
139
- await MainActor.run {
140
- for listener in self.promotedProductListeners { listener(nitro) }
141
- }
142
- }
143
- } catch {
144
- let id = productId
145
- await MainActor.run {
146
- var minimal = NitroProduct()
147
- minimal.id = id
148
- minimal.title = id
149
- minimal.type = "inapp"
150
- minimal.platform = .ios
151
- for listener in self.promotedProductListeners { listener(minimal) }
152
- }
153
- }
154
- }
155
- }
156
- }
157
52
 
158
- // Perform initialization and only report success when native init succeeds
159
53
  do {
160
54
  let ok = try await OpenIapModule.shared.initConnection()
55
+ RnIapLog.result("initConnection", ok)
161
56
  self.isInitialized = ok
162
57
  self.isInitializing = false
163
58
  return ok
164
59
  } catch {
165
- // Surface as event and keep flags consistent
166
- let err = self.createPurchaseErrorResult(
167
- code: OpenIapError.InitConnection,
168
- message: error.localizedDescription,
169
- productId: nil
60
+ RnIapLog.failure("initConnection", error: error)
61
+ let err = RnIapHelper.makePurchaseErrorResult(
62
+ code: .initConnection,
63
+ message: error.localizedDescription
170
64
  )
171
65
  self.sendPurchaseError(err, productId: nil)
172
66
  self.isInitialized = false
@@ -185,97 +79,144 @@ class HybridRnIap: HybridRnIapSpec {
185
79
 
186
80
  func fetchProducts(skus: [String], type: String) throws -> Promise<[NitroProduct]> {
187
81
  return Promise.async {
188
- do {
189
- try self.ensureConnection()
82
+ try self.ensureConnection()
83
+ RnIapLog.payload("fetchProducts", [
84
+ "skus": skus,
85
+ "type": type
86
+ ])
190
87
 
191
- let normalizedType = type.lowercased()
192
- let products: [OpenIapProduct]
88
+ if skus.isEmpty {
89
+ throw PurchaseError.make(code: .emptySkuList)
90
+ }
193
91
 
194
- if normalizedType == "all" {
195
- var deduped: [String: OpenIapProduct] = [:]
196
- for kind in ["in-app", "subs"] {
197
- let request = OpenIapProductRequest(skus: skus, type: kind)
198
- let fetched = try await OpenIapModule.shared.fetchProducts(request)
199
- for item in fetched { deduped[item.id] = item }
200
- }
201
- products = Array(deduped.values)
202
- } else {
203
- var resolvedType = type
204
- switch normalizedType {
205
- case "inapp":
206
- #if DEBUG
207
- print("[HybridRnIap] fetchProducts received legacy type 'inapp'; forwarding as 'in-app'")
208
- #endif
209
- fallthrough
210
- case "in-app":
211
- resolvedType = "in-app"
212
- case "subs":
213
- resolvedType = "subs"
214
- default:
215
- break
216
- }
92
+ var productsById: [String: NitroProduct] = [:]
93
+ let normalizedType = type.lowercased()
94
+ let queryTypes: [ProductQueryType]
95
+ if normalizedType == "all" {
96
+ queryTypes = [.inApp, .subs]
97
+ } else {
98
+ if normalizedType == "inapp" {
99
+ RnIapLog.warn("fetchProducts received legacy type 'inapp'; forwarding as 'in-app'")
100
+ }
101
+ queryTypes = [RnIapHelper.parseProductQueryType(type)]
102
+ }
217
103
 
218
- let request = OpenIapProductRequest(skus: skus, type: resolvedType)
219
- products = try await OpenIapModule.shared.fetchProducts(request)
104
+ for queryType in queryTypes {
105
+ let request = try OpenIapSerialization.productRequest(skus: skus, type: queryType)
106
+ RnIapLog.payload(
107
+ "fetchProducts.native", [
108
+ "skus": skus,
109
+ "type": queryType.rawValue
110
+ ]
111
+ )
112
+ let result = try await OpenIapModule.shared.fetchProducts(request)
113
+ let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.products(result))
114
+ RnIapLog.result("fetchProducts.native", payloads)
115
+ for payload in payloads {
116
+ let nitroProduct = RnIapHelper.convertProductDictionary(payload)
117
+ productsById[nitroProduct.id] = nitroProduct
220
118
  }
119
+ }
221
120
 
222
- return products.map { self.convertOpenIapProductToNitroProduct($0) }
223
- } catch {
224
- // Propagate OpenIAP error
225
- throw error
121
+ var products: [NitroProduct] = []
122
+ var seenIds = Set<String>()
123
+ for sku in skus {
124
+ if let product = productsById[sku], !seenIds.contains(product.id) {
125
+ products.append(product)
126
+ seenIds.insert(product.id)
127
+ }
128
+ }
129
+ for product in productsById.values where !seenIds.contains(product.id) {
130
+ products.append(product)
131
+ seenIds.insert(product.id)
226
132
  }
133
+ await MainActor.run {
134
+ products.forEach { self.productTypeBySku[$0.id] = $0.type.lowercased() }
135
+ }
136
+ RnIapLog.result(
137
+ "fetchProducts", products.map { ["id": $0.id, "type": $0.type] }
138
+ )
139
+ return products
227
140
  }
228
141
  }
229
142
 
230
143
  func requestPurchase(request: NitroPurchaseRequest) throws -> Promise<RequestPurchaseResult?> {
231
144
  return Promise.async {
232
145
  let defaultResult: RequestPurchaseResult? = .third([])
146
+ RnIapLog.payload(
147
+ "requestPurchase", [
148
+ "hasIOS": request.ios != nil,
149
+ "hasAndroid": request.android != nil
150
+ ]
151
+ )
152
+
233
153
  guard let iosRequest = request.ios else {
234
- let error = self.createPurchaseErrorResult(
235
- code: OpenIapError.UserError,
154
+ let error = RnIapHelper.makePurchaseErrorResult(
155
+ code: .developerError,
236
156
  message: "No iOS request provided"
237
157
  )
238
158
  self.sendPurchaseError(error, productId: nil)
239
159
  return defaultResult
240
160
  }
161
+
162
+ guard self.isInitialized else {
163
+ let err = RnIapHelper.makePurchaseErrorResult(
164
+ code: .initConnection,
165
+ message: "IAP store connection not initialized",
166
+ iosRequest.sku
167
+ )
168
+ self.sendPurchaseError(err, productId: iosRequest.sku)
169
+ return defaultResult
170
+ }
171
+
241
172
  do {
242
- // Event-first behavior: don't reject Promise on connection issues
243
- guard self.isInitialized else {
244
- #if DEBUG
245
- print("[HybridRnIap] requestPurchase while not initialized; sending InitConnection")
246
- #endif
247
- let err = self.createPurchaseErrorResult(
248
- code: OpenIapError.InitConnection,
249
- message: "IAP store connection not initialized",
250
- productId: iosRequest.sku
251
- )
252
- self.sendPurchaseError(err, productId: iosRequest.sku)
253
- return defaultResult
173
+ var iosPayload: [String: Any] = ["sku": iosRequest.sku]
174
+ if let quantity = iosRequest.quantity { iosPayload["quantity"] = Int(quantity) }
175
+ if let finishAutomatically = iosRequest.andDangerouslyFinishTransactionAutomatically {
176
+ iosPayload["andDangerouslyFinishTransactionAutomatically"] = finishAutomatically
254
177
  }
255
- // Delegate purchase to OpenIAP. It emits success/error events which we bridge above.
256
- let props = OpenIapRequestPurchaseProps(
257
- sku: iosRequest.sku,
258
- andDangerouslyFinishTransactionAutomatically: iosRequest.andDangerouslyFinishTransactionAutomatically,
259
- appAccountToken: iosRequest.appAccountToken,
260
- quantity: iosRequest.quantity != nil ? Int(iosRequest.quantity!) : nil,
261
- withOffer: iosRequest.withOffer.flatMap { dict in
262
- guard let id = dict["identifier"],
263
- let key = dict["keyIdentifier"],
264
- let nonce = dict["nonce"],
265
- let sig = dict["signature"],
266
- let ts = dict["timestamp"] else { return nil }
267
- return OpenIapDiscountOffer(identifier: id, keyIdentifier: key, nonce: nonce, signature: sig, timestamp: ts)
268
- }
178
+ if let appAccountToken = iosRequest.appAccountToken {
179
+ iosPayload["appAccountToken"] = appAccountToken
180
+ }
181
+ if let withOffer = iosRequest.withOffer {
182
+ iosPayload["withOffer"] = withOffer
183
+ }
184
+
185
+ let cachedType = await MainActor.run { self.productTypeBySku[iosRequest.sku] }
186
+ let resolvedType = RnIapHelper.parseProductQueryType(cachedType)
187
+ let purchaseType: ProductQueryType = resolvedType == .all ? .inApp : resolvedType
188
+ await MainActor.run {
189
+ self.productTypeBySku[iosRequest.sku] = purchaseType.rawValue
190
+ }
191
+
192
+ let props = try RnIapHelper.decodeRequestPurchaseProps(
193
+ iosPayload: iosPayload,
194
+ type: purchaseType
195
+ )
196
+
197
+ RnIapLog.payload(
198
+ "requestPurchase.native", iosPayload
269
199
  )
270
- _ = try await OpenIapModule.shared.requestPurchase(props)
200
+
201
+ let result = try await OpenIapModule.shared.requestPurchase(props)
202
+ if result != nil {
203
+ RnIapLog.result("requestPurchase", "delegated to OpenIAP")
204
+ } else {
205
+ RnIapLog.result("requestPurchase", nil)
206
+ }
207
+
208
+ return defaultResult
209
+ } catch let purchaseError as PurchaseError {
210
+ RnIapLog.failure("requestPurchase", error: purchaseError)
211
+ // OpenIAP already publishes purchaseError events for PurchaseError instances.
212
+ // Avoid emitting a duplicate event back to JS; simply return.
271
213
  return defaultResult
272
214
  } catch {
273
- // Ensure an error reaches JS even if OpenIAP threw before emitting.
274
- // Use simple de-duplication window to avoid double-emitting.
275
- let err = self.createPurchaseErrorResult(
276
- code: OpenIapError.ServiceError,
215
+ RnIapLog.failure("requestPurchase", error: error)
216
+ let err = RnIapHelper.makePurchaseErrorResult(
217
+ code: .purchaseError,
277
218
  message: error.localizedDescription,
278
- productId: iosRequest.sku
219
+ iosRequest.sku
279
220
  )
280
221
  self.sendPurchaseErrorDedup(err, productId: iosRequest.sku)
281
222
  return defaultResult
@@ -287,13 +228,21 @@ class HybridRnIap: HybridRnIapSpec {
287
228
  return Promise.async {
288
229
  try self.ensureConnection()
289
230
  do {
231
+ let alsoPublish = options?.ios?.alsoPublishToEventListener ?? false
290
232
  let onlyActive = options?.ios?.onlyIncludeActiveItemsIOS ?? options?.ios?.onlyIncludeActiveItems ?? false
291
- let props = OpenIapPurchaseOptions(alsoPublishToEventListenerIOS: false, onlyIncludeActiveItemsIOS: onlyActive)
292
- let purchases = try await OpenIapModule.shared.getAvailablePurchases(props)
293
- return purchases.map { self.convertOpenIapPurchaseToNitroPurchase($0) }
233
+ let optionsDictionary: [String: Any] = [
234
+ "alsoPublishToEventListenerIOS": alsoPublish,
235
+ "onlyIncludeActiveItemsIOS": onlyActive
236
+ ]
237
+ let purchaseOptions = try OpenIapSerialization.purchaseOptions(from: optionsDictionary)
238
+ RnIapLog.payload("getAvailablePurchases", optionsDictionary)
239
+ let purchases = try await OpenIapModule.shared.getAvailablePurchases(purchaseOptions)
240
+ let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases))
241
+ RnIapLog.result("getAvailablePurchases", payloads)
242
+ return payloads.map { RnIapHelper.convertPurchaseDictionary($0) }
294
243
  } catch {
295
- // Propagate OpenIAP error or map to network error
296
- throw OpenIapError.make(code: OpenIapError.NetworkError)
244
+ RnIapLog.failure("getAvailablePurchases", error: error)
245
+ throw error
297
246
  }
298
247
  }
299
248
  }
@@ -303,11 +252,32 @@ class HybridRnIap: HybridRnIapSpec {
303
252
  guard let iosParams = params.ios else { return .first(true) }
304
253
  try self.ensureConnection()
305
254
  do {
306
- let ok = try await OpenIapModule.shared.finishTransaction(transactionIdentifier: iosParams.transactionId)
307
- return .first(ok)
255
+ RnIapLog.payload(
256
+ "finishTransaction", ["transactionId": iosParams.transactionId]
257
+ )
258
+ var purchasePayload = await MainActor.run { () -> [String: Any]? in
259
+ self.purchasePayloadById[iosParams.transactionId]
260
+ }
261
+ if purchasePayload == nil {
262
+ RnIapLog.warn("Missing cached purchase payload for \(iosParams.transactionId); falling back to identifier-only finish")
263
+ purchasePayload = ["transactionIdentifier": iosParams.transactionId]
264
+ }
265
+ guard let purchasePayload else {
266
+ throw PurchaseError.make(code: .purchaseError, message: "Missing purchase context for \(iosParams.transactionId)")
267
+ }
268
+ let sanitizedPayload = RnIapHelper.sanitizeDictionary(purchasePayload)
269
+ RnIapLog.payload("finishTransaction.nativePayload", sanitizedPayload)
270
+ let purchaseInput = try OpenIapSerialization.purchaseInput(from: purchasePayload)
271
+ try await OpenIapModule.shared.finishTransaction(purchase: purchaseInput, isConsumable: nil)
272
+ RnIapLog.result("finishTransaction", true)
273
+ await MainActor.run {
274
+ self.purchasePayloadById.removeValue(forKey: iosParams.transactionId)
275
+ }
276
+ return .first(true)
308
277
  } catch {
278
+ RnIapLog.failure("finishTransaction", error: error)
309
279
  let tid = iosParams.transactionId
310
- throw OpenIapError.make(code: OpenIapError.PurchaseError, message: "Transaction not found: \(tid)")
280
+ throw PurchaseError.make(code: .purchaseError, message: "Transaction not found: \(tid)")
311
281
  }
312
282
  }
313
283
  }
@@ -315,10 +285,21 @@ class HybridRnIap: HybridRnIapSpec {
315
285
  func validateReceipt(params: NitroReceiptValidationParams) throws -> Promise<Variant_NitroReceiptValidationResultIOS_NitroReceiptValidationResultAndroid> {
316
286
  return Promise.async {
317
287
  do {
318
- let result = try await OpenIapModule.shared.validateReceiptIOS(OpenIapReceiptValidationProps(sku: params.sku))
288
+ RnIapLog.payload("validateReceiptIOS", ["sku": params.sku])
289
+ let props = try OpenIapSerialization.receiptValidationProps(from: ["sku": params.sku])
290
+ let result = try await OpenIapModule.shared.validateReceiptIOS(props)
291
+ var encoded = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(result))
292
+ if encoded["receiptData"] != nil {
293
+ encoded["receiptData"] = "<receipt>"
294
+ }
295
+ if encoded["jwsRepresentation"] != nil {
296
+ encoded["jwsRepresentation"] = "<jws>"
297
+ }
298
+ RnIapLog.result("validateReceiptIOS", encoded)
319
299
  var latest: NitroPurchase? = nil
320
- if let tx = result.latestTransaction {
321
- latest = self.convertOpenIapPurchaseToNitroPurchase(tx)
300
+ if let transaction = result.latestTransaction {
301
+ let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(transaction))
302
+ latest = RnIapHelper.convertPurchaseDictionary(payload)
322
303
  }
323
304
  let mapped = NitroReceiptValidationResultIOS(
324
305
  isValid: result.isValid,
@@ -328,7 +309,8 @@ class HybridRnIap: HybridRnIapSpec {
328
309
  )
329
310
  return .first(mapped)
330
311
  } catch {
331
- throw OpenIapError.make(code: OpenIapError.ReceiptFailed, message: error.localizedDescription)
312
+ RnIapLog.failure("validateReceiptIOS", error: error)
313
+ throw PurchaseError.make(code: .receiptFailed, message: error.localizedDescription)
332
314
  }
333
315
  }
334
316
  }
@@ -338,9 +320,13 @@ class HybridRnIap: HybridRnIapSpec {
338
320
  func getStorefrontIOS() throws -> Promise<String> {
339
321
  return Promise.async {
340
322
  do {
341
- return try await OpenIapModule.shared.getStorefrontIOS()
323
+ RnIapLog.payload("getStorefrontIOS", nil)
324
+ let storefront = try await OpenIapModule.shared.getStorefrontIOS()
325
+ RnIapLog.result("getStorefrontIOS", storefront)
326
+ return storefront
342
327
  } catch {
343
- throw OpenIapError.make(code: OpenIapError.ServiceError, message: error.localizedDescription)
328
+ RnIapLog.failure("getStorefrontIOS", error: error)
329
+ throw PurchaseError.make(code: .serviceError, message: error.localizedDescription)
344
330
  }
345
331
  }
346
332
  }
@@ -348,63 +334,74 @@ class HybridRnIap: HybridRnIapSpec {
348
334
  func getAppTransactionIOS() throws -> Promise<String?> {
349
335
  return Promise.async {
350
336
  do {
337
+ RnIapLog.payload("getAppTransactionIOS", nil)
351
338
  if #available(iOS 16.0, *) {
352
339
  if let appTx = try await OpenIapModule.shared.getAppTransactionIOS() {
353
340
  var result: [String: Any?] = [
354
341
  "bundleId": appTx.bundleId,
355
342
  "appVersion": appTx.appVersion,
356
343
  "originalAppVersion": appTx.originalAppVersion,
357
- "originalPurchaseDate": appTx.originalPurchaseDate.timeIntervalSince1970 * 1000,
344
+ "originalPurchaseDate": appTx.originalPurchaseDate,
358
345
  "deviceVerification": appTx.deviceVerification,
359
346
  "deviceVerificationNonce": appTx.deviceVerificationNonce,
360
347
  "environment": appTx.environment,
361
- "signedDate": appTx.signedDate.timeIntervalSince1970 * 1000,
348
+ "signedDate": appTx.signedDate,
362
349
  "appId": appTx.appId,
363
350
  "appVersionId": appTx.appVersionId,
364
- "preorderDate": appTx.preorderDate != nil ? (appTx.preorderDate!.timeIntervalSince1970 * 1000) : nil
351
+ "preorderDate": appTx.preorderDate
365
352
  ]
366
353
  result["appTransactionId"] = appTx.appTransactionId
367
354
  result["originalPlatform"] = appTx.originalPlatform
368
355
  let jsonData = try JSONSerialization.data(withJSONObject: result, options: [])
369
- return String(data: jsonData, encoding: .utf8)
356
+ let string = String(data: jsonData, encoding: .utf8)
357
+ RnIapLog.result("getAppTransactionIOS", "<appTransaction>")
358
+ return string
370
359
  }
360
+ RnIapLog.result("getAppTransactionIOS", nil)
371
361
  return nil
372
362
  } else {
363
+ RnIapLog.result("getAppTransactionIOS", nil)
373
364
  return nil
374
365
  }
375
366
  } catch {
367
+ RnIapLog.failure("getAppTransactionIOS", error: error)
376
368
  return nil
377
369
  }
378
370
  }
379
371
  }
380
372
 
381
- func requestPromotedProductIOS() throws -> Promise<NitroProduct?> {
373
+ func getPromotedProductIOS() throws -> Promise<NitroProduct?> {
382
374
  return Promise.async {
375
+ try self.ensureConnection()
383
376
  do {
384
- if let p = try await OpenIapModule.shared.getPromotedProductIOS() {
385
- var n = NitroProduct()
386
- n.id = p.productIdentifier
387
- n.title = p.localizedTitle
388
- n.description = p.localizedDescription
389
- n.type = "inapp"
390
- n.platform = .ios
391
- n.price = p.price
392
- n.currency = p.priceLocale.currencyCode
393
- return n
377
+ RnIapLog.payload("getPromotedProductIOS", nil)
378
+ guard let product = try await OpenIapModule.shared.getPromotedProductIOS() else {
379
+ RnIapLog.result("getPromotedProductIOS", nil)
380
+ return nil
394
381
  }
395
- return nil
382
+ let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(product))
383
+ RnIapLog.result("getPromotedProductIOS", payload)
384
+ return RnIapHelper.convertProductDictionary(payload)
396
385
  } catch {
397
- return nil
386
+ RnIapLog.failure("getPromotedProductIOS", error: error)
387
+ throw PurchaseError.make(code: .serviceError, message: error.localizedDescription)
398
388
  }
399
389
  }
400
390
  }
391
+
392
+ func requestPromotedProductIOS() throws -> Promise<NitroProduct?> {
393
+ return try getPromotedProductIOS()
394
+ }
401
395
 
402
396
  func buyPromotedProductIOS() throws -> Promise<Void> {
403
397
  return Promise.async {
404
398
  do {
405
- try await OpenIapModule.shared.requestPurchaseOnPromotedProductIOS()
399
+ RnIapLog.payload("buyPromotedProductIOS", nil)
400
+ let ok = try await OpenIapModule.shared.requestPurchaseOnPromotedProductIOS()
401
+ RnIapLog.result("buyPromotedProductIOS", ok)
406
402
  } catch {
407
403
  // Event-only: OpenIAP will emit purchaseError for this flow. Avoid Promise rejection.
404
+ RnIapLog.failure("buyPromotedProductIOS", error: error)
408
405
  }
409
406
  }
410
407
  }
@@ -412,11 +409,14 @@ class HybridRnIap: HybridRnIapSpec {
412
409
  func presentCodeRedemptionSheetIOS() throws -> Promise<Bool> {
413
410
  return Promise.async {
414
411
  do {
412
+ RnIapLog.payload("presentCodeRedemptionSheetIOS", nil)
415
413
  let ok = try await OpenIapModule.shared.presentCodeRedemptionSheetIOS()
414
+ RnIapLog.result("presentCodeRedemptionSheetIOS", ok)
416
415
  return ok
417
416
  } catch {
418
417
  // Fallback with explicit error for simulator or unsupported cases
419
- throw OpenIapError.make(code: OpenIapError.FeatureNotSupported)
418
+ RnIapLog.failure("presentCodeRedemptionSheetIOS", error: error)
419
+ throw PurchaseError.make(code: .featureNotSupported)
420
420
  }
421
421
  }
422
422
  }
@@ -424,9 +424,12 @@ class HybridRnIap: HybridRnIapSpec {
424
424
  func clearTransactionIOS() throws -> Promise<Void> {
425
425
  return Promise.async {
426
426
  do {
427
- try await OpenIapModule.shared.clearTransactionIOS()
427
+ RnIapLog.payload("clearTransactionIOS", nil)
428
+ let ok = try await OpenIapModule.shared.clearTransactionIOS()
429
+ RnIapLog.result("clearTransactionIOS", ok)
428
430
  } catch {
429
431
  // ignore
432
+ RnIapLog.failure("clearTransactionIOS", error: error)
430
433
  }
431
434
  }
432
435
  }
@@ -437,35 +440,28 @@ class HybridRnIap: HybridRnIapSpec {
437
440
  return Promise.async {
438
441
  try self.ensureConnection()
439
442
  do {
440
- if let statuses = try await OpenIapModule.shared.subscriptionStatusIOS(sku: sku) {
441
- return statuses.map { s in
442
- var renewal: NitroSubscriptionRenewalInfo? = nil
443
- if let r = s.renewalInfo {
444
- renewal = NitroSubscriptionRenewalInfo(
445
- autoRenewStatus: r.autoRenewStatus,
446
- autoRenewPreference: r.autoRenewPreference,
447
- expirationReason: r.expirationReason.map { Double($0) },
448
- gracePeriodExpirationDate: r.gracePeriodExpirationDate?.timeIntervalSince1970,
449
- currentProductID: r.currentProductID,
450
- platform: "ios"
451
- )
452
- }
453
- let isActive: Bool
454
- switch s.state {
455
- case .subscribed:
456
- isActive = true
457
- default:
458
- isActive = false
459
- }
460
- return NitroSubscriptionStatus(
461
- state: isActive ? 1 : 0,
462
- platform: "ios",
463
- renewalInfo: renewal
464
- )
443
+ RnIapLog.payload("subscriptionStatusIOS", ["sku": sku])
444
+ let statuses = try await OpenIapModule.shared.subscriptionStatusIOS(sku: sku)
445
+ let payloads = statuses.map { RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) }
446
+ RnIapLog.result("subscriptionStatusIOS", payloads)
447
+ return payloads.map { payload in
448
+ let stateValue: Double
449
+ if let numeric = RnIapHelper.doubleValue(payload["state"]) {
450
+ stateValue = numeric
451
+ } else if let stateString = payload["state"] as? String {
452
+ stateValue = stateString.lowercased() == "subscribed" ? 1 : 0
453
+ } else {
454
+ stateValue = 0
455
+ }
456
+ let platform = payload["platform"] as? String ?? "ios"
457
+ var renewalInfo: NitroSubscriptionRenewalInfo? = nil
458
+ if let renewalPayload = payload["renewalInfo"] as? [String: Any?] {
459
+ renewalInfo = RnIapHelper.convertRenewalInfo(RnIapHelper.sanitizeDictionary(renewalPayload))
465
460
  }
461
+ return NitroSubscriptionStatus(state: stateValue, platform: platform, renewalInfo: renewalInfo)
466
462
  }
467
- return []
468
463
  } catch {
464
+ RnIapLog.failure("subscriptionStatusIOS", error: error)
469
465
  return []
470
466
  }
471
467
  }
@@ -475,13 +471,24 @@ class HybridRnIap: HybridRnIapSpec {
475
471
  return Promise.async {
476
472
  try self.ensureConnection()
477
473
  do {
474
+ RnIapLog.payload("currentEntitlementIOS", ["sku": sku])
478
475
  let purchase = try await OpenIapModule.shared.currentEntitlementIOS(sku: sku)
479
476
  if let purchase {
480
- return self.convertOpenIapPurchaseToNitroPurchase(purchase)
477
+ let raw = OpenIapSerialization.encode(purchase)
478
+ let payload = RnIapHelper.sanitizeDictionary(raw)
479
+ RnIapLog.result("currentEntitlementIOS", payload)
480
+ if let identifier = raw["id"] as? String {
481
+ await MainActor.run {
482
+ self.purchasePayloadById[identifier] = raw
483
+ }
484
+ }
485
+ return RnIapHelper.convertPurchaseDictionary(payload)
481
486
  }
487
+ RnIapLog.result("currentEntitlementIOS", nil)
482
488
  return Optional<NitroPurchase>.none
483
489
  } catch {
484
- throw OpenIapError.make(code: OpenIapError.SkuNotFound, productId: sku)
490
+ RnIapLog.failure("currentEntitlementIOS", error: error)
491
+ throw PurchaseError.make(code: .skuNotFound, productId: sku)
485
492
  }
486
493
  }
487
494
  }
@@ -490,13 +497,24 @@ class HybridRnIap: HybridRnIapSpec {
490
497
  return Promise.async {
491
498
  try self.ensureConnection()
492
499
  do {
500
+ RnIapLog.payload("latestTransactionIOS", ["sku": sku])
493
501
  let purchase = try await OpenIapModule.shared.latestTransactionIOS(sku: sku)
494
502
  if let purchase {
495
- return self.convertOpenIapPurchaseToNitroPurchase(purchase)
503
+ let raw = OpenIapSerialization.encode(purchase)
504
+ let payload = RnIapHelper.sanitizeDictionary(raw)
505
+ RnIapLog.result("latestTransactionIOS", payload)
506
+ if let identifier = raw["id"] as? String {
507
+ await MainActor.run {
508
+ self.purchasePayloadById[identifier] = raw
509
+ }
510
+ }
511
+ return RnIapHelper.convertPurchaseDictionary(payload)
496
512
  }
513
+ RnIapLog.result("latestTransactionIOS", nil)
497
514
  return Optional<NitroPurchase>.none
498
515
  } catch {
499
- throw OpenIapError.make(code: OpenIapError.SkuNotFound, productId: sku)
516
+ RnIapLog.failure("latestTransactionIOS", error: error)
517
+ throw PurchaseError.make(code: .skuNotFound, productId: sku)
500
518
  }
501
519
  }
502
520
  }
@@ -504,9 +522,24 @@ class HybridRnIap: HybridRnIapSpec {
504
522
  func getPendingTransactionsIOS() throws -> Promise<[NitroPurchase]> {
505
523
  return Promise.async {
506
524
  do {
525
+ RnIapLog.payload("getPendingTransactionsIOS", nil)
507
526
  let pending = try await OpenIapModule.shared.getPendingTransactionsIOS()
508
- return pending.map { self.convertOpenIapPurchaseToNitroPurchase($0) }
527
+ var unionPurchases: [OpenIAP.Purchase] = []
528
+ for purchase in pending {
529
+ let union = OpenIAP.Purchase.purchaseIos(purchase)
530
+ unionPurchases.append(union)
531
+ let raw = OpenIapSerialization.purchase(union)
532
+ if let identifier = raw["id"] as? String {
533
+ await MainActor.run {
534
+ self.purchasePayloadById[identifier] = raw
535
+ }
536
+ }
537
+ }
538
+ let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.purchases(unionPurchases))
539
+ RnIapLog.result("getPendingTransactionsIOS", payloads)
540
+ return payloads.map { RnIapHelper.convertPurchaseDictionary($0) }
509
541
  } catch {
542
+ RnIapLog.failure("getPendingTransactionsIOS", error: error)
510
543
  return []
511
544
  }
512
545
  }
@@ -515,43 +548,115 @@ class HybridRnIap: HybridRnIapSpec {
515
548
  func syncIOS() throws -> Promise<Bool> {
516
549
  return Promise.async {
517
550
  do {
551
+ RnIapLog.payload("syncIOS", nil)
518
552
  let ok = try await OpenIapModule.shared.syncIOS()
553
+ RnIapLog.result("syncIOS", ok)
519
554
  return ok
520
555
  } catch {
521
- throw OpenIapError.make(code: OpenIapError.ServiceError, message: error.localizedDescription)
556
+ RnIapLog.failure("syncIOS", error: error)
557
+ throw PurchaseError.make(code: .serviceError, message: error.localizedDescription)
522
558
  }
523
559
  }
524
560
  }
525
561
 
526
562
  func showManageSubscriptionsIOS() throws -> Promise<[NitroPurchase]> {
527
563
  return Promise.async {
564
+ try self.ensureConnection()
528
565
  do {
529
566
  // Trigger system UI
567
+ RnIapLog.payload("showManageSubscriptionsIOS", nil)
530
568
  _ = try await OpenIapModule.shared.showManageSubscriptionsIOS()
531
569
  // Return current entitlements as approximation of updates
532
- let purchases = try await OpenIapModule.shared.getAvailablePurchases(OpenIapPurchaseOptions(alsoPublishToEventListenerIOS: false, onlyIncludeActiveItemsIOS: true))
533
- return purchases.map { self.convertOpenIapPurchaseToNitroPurchase($0) }
570
+ let optionsDictionary: [String: Any] = [
571
+ "alsoPublishToEventListenerIOS": false,
572
+ "onlyIncludeActiveItemsIOS": true
573
+ ]
574
+ let iosOptions = try OpenIapSerialization.purchaseOptions(from: optionsDictionary)
575
+ let purchases = try await OpenIapModule.shared.getAvailablePurchases(iosOptions)
576
+ let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases))
577
+ RnIapLog.result("showManageSubscriptionsIOS", payloads)
578
+ return payloads.map { RnIapHelper.convertPurchaseDictionary($0) }
579
+ } catch {
580
+ RnIapLog.failure("showManageSubscriptionsIOS", error: error)
581
+ throw PurchaseError.make(code: .serviceError, message: error.localizedDescription)
582
+ }
583
+ }
584
+ }
585
+
586
+ func deepLinkToSubscriptionsIOS() throws -> Promise<Bool> {
587
+ return Promise.async {
588
+ try self.ensureConnection()
589
+ do {
590
+ RnIapLog.payload("deepLinkToSubscriptionsIOS", nil)
591
+ try await OpenIapModule.shared.deepLinkToSubscriptions(nil)
592
+ RnIapLog.result("deepLinkToSubscriptionsIOS", true)
593
+ return true
534
594
  } catch {
535
- throw OpenIapError.make(code: OpenIapError.ServiceError, message: error.localizedDescription)
595
+ RnIapLog.failure("deepLinkToSubscriptionsIOS", error: error)
596
+ throw PurchaseError.make(code: .serviceError, message: error.localizedDescription)
536
597
  }
537
598
  }
538
599
  }
539
600
 
540
601
  func isEligibleForIntroOfferIOS(groupID: String) throws -> Promise<Bool> {
541
602
  return Promise.async {
542
- return await OpenIapModule.shared.isEligibleForIntroOfferIOS(groupID: groupID)
603
+ RnIapLog.payload("isEligibleForIntroOfferIOS", ["groupID": groupID])
604
+ let value = try await OpenIapModule.shared.isEligibleForIntroOfferIOS(groupID: groupID)
605
+ RnIapLog.result("isEligibleForIntroOfferIOS", value)
606
+ return value
543
607
  }
544
608
  }
545
609
 
546
610
  func getReceiptDataIOS() throws -> Promise<String> {
547
611
  return Promise.async {
612
+ try self.ensureConnection()
548
613
  do {
549
- if let receipt = try await OpenIapModule.shared.getReceiptDataIOS() {
550
- return receipt
551
- }
552
- throw OpenIapError.make(code: OpenIapError.ReceiptFailed)
614
+ RnIapLog.payload("getReceiptDataIOS", nil)
615
+ let receipt = try await RnIapHelper.loadReceiptData(refresh: false)
616
+ RnIapLog.result("getReceiptDataIOS", "<receipt>")
617
+ return receipt
618
+ } catch let purchaseError as PurchaseError {
619
+ RnIapLog.failure("getReceiptDataIOS", error: purchaseError)
620
+ throw purchaseError
621
+ } catch {
622
+ RnIapLog.failure("getReceiptDataIOS", error: error)
623
+ throw PurchaseError.make(code: .receiptFailed, message: error.localizedDescription)
624
+ }
625
+ }
626
+ }
627
+
628
+ func getReceiptIOS() throws -> Promise<String> {
629
+ return Promise.async {
630
+ try self.ensureConnection()
631
+ do {
632
+ RnIapLog.payload("getReceiptIOS", nil)
633
+ let receipt = try await RnIapHelper.loadReceiptData(refresh: true)
634
+ RnIapLog.result("getReceiptIOS", "<receipt>")
635
+ return receipt
636
+ } catch let purchaseError as PurchaseError {
637
+ RnIapLog.failure("getReceiptIOS", error: purchaseError)
638
+ throw purchaseError
639
+ } catch {
640
+ RnIapLog.failure("getReceiptIOS", error: error)
641
+ throw PurchaseError.make(code: .receiptFailed, message: error.localizedDescription)
642
+ }
643
+ }
644
+ }
645
+
646
+ func requestReceiptRefreshIOS() throws -> Promise<String> {
647
+ return Promise.async {
648
+ try self.ensureConnection()
649
+ do {
650
+ RnIapLog.payload("requestReceiptRefreshIOS", nil)
651
+ let receipt = try await RnIapHelper.loadReceiptData(refresh: true)
652
+ RnIapLog.result("requestReceiptRefreshIOS", "<receipt>")
653
+ return receipt
654
+ } catch let purchaseError as PurchaseError {
655
+ RnIapLog.failure("requestReceiptRefreshIOS", error: purchaseError)
656
+ throw purchaseError
553
657
  } catch {
554
- throw OpenIapError.make(code: OpenIapError.ReceiptFailed, message: error.localizedDescription)
658
+ RnIapLog.failure("requestReceiptRefreshIOS", error: error)
659
+ throw PurchaseError.make(code: .receiptFailed, message: error.localizedDescription)
555
660
  }
556
661
  }
557
662
  }
@@ -559,7 +664,10 @@ class HybridRnIap: HybridRnIapSpec {
559
664
  func isTransactionVerifiedIOS(sku: String) throws -> Promise<Bool> {
560
665
  return Promise.async {
561
666
  try self.ensureConnection()
562
- return await OpenIapModule.shared.isTransactionVerifiedIOS(sku: sku)
667
+ RnIapLog.payload("isTransactionVerifiedIOS", ["sku": sku])
668
+ let value = try await OpenIapModule.shared.isTransactionVerifiedIOS(sku: sku)
669
+ RnIapLog.result("isTransactionVerifiedIOS", value)
670
+ return value
563
671
  }
564
672
  }
565
673
 
@@ -567,17 +675,29 @@ class HybridRnIap: HybridRnIapSpec {
567
675
  return Promise.async {
568
676
  try self.ensureConnection()
569
677
  do {
678
+ RnIapLog.payload("getTransactionJwsIOS", ["sku": sku])
570
679
  let jws = try await OpenIapModule.shared.getTransactionJwsIOS(sku: sku)
680
+ let maskedJws: Any? = (jws == nil) ? nil : "<jws>"
681
+ RnIapLog.result("getTransactionJwsIOS", maskedJws)
571
682
  return jws
572
683
  } catch {
573
- throw OpenIapError.make(code: OpenIapError.TransactionValidationFailed, message: "Can't find transaction for sku \(sku)")
684
+ RnIapLog.failure("getTransactionJwsIOS", error: error)
685
+ throw PurchaseError.make(code: .transactionValidationFailed, message: "Can't find transaction for sku \(sku)")
574
686
  }
575
687
  }
576
688
  }
577
689
 
578
690
  func beginRefundRequestIOS(sku: String) throws -> Promise<String?> {
579
691
  return Promise.async {
580
- do { return try await OpenIapModule.shared.beginRefundRequestIOS(sku: sku) } catch { return nil }
692
+ do {
693
+ RnIapLog.payload("beginRefundRequestIOS", ["sku": sku])
694
+ let result = try await OpenIapModule.shared.beginRefundRequestIOS(sku: sku)
695
+ RnIapLog.result("beginRefundRequestIOS", result)
696
+ return result
697
+ } catch {
698
+ RnIapLog.failure("beginRefundRequestIOS", error: error)
699
+ return nil
700
+ }
581
701
  }
582
702
  }
583
703
 
@@ -586,23 +706,12 @@ class HybridRnIap: HybridRnIapSpec {
586
706
 
587
707
  // If a promoted product is already available from OpenIAP, notify immediately
588
708
  Task {
589
- if let p = try? await OpenIapModule.shared.getPromotedProductIOS() {
590
- let id = p.productIdentifier
591
- let title = p.localizedTitle
592
- let desc = p.localizedDescription
593
- let price = p.price
594
- let currency = p.priceLocale.currencyCode
595
- await MainActor.run {
596
- var n = NitroProduct()
597
- n.id = id
598
- n.title = title
599
- n.description = desc
600
- n.type = "inapp"
601
- n.platform = .ios
602
- n.price = price
603
- n.currency = currency
604
- listener(n)
605
- }
709
+ RnIapLog.payload("promotedProductListenerIOS.fetch", nil)
710
+ if let product = try? await OpenIapModule.shared.getPromotedProductIOS() {
711
+ let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(product))
712
+ RnIapLog.result("promotedProductListenerIOS.fetch", payload)
713
+ let nitro = RnIapHelper.convertProductDictionary(payload)
714
+ await MainActor.run { listener(nitro) }
606
715
  }
607
716
  }
608
717
  }
@@ -636,24 +745,127 @@ class HybridRnIap: HybridRnIapSpec {
636
745
  }
637
746
 
638
747
  // MARK: - Private Helper Methods
639
-
748
+
749
+ private func attachListenersIfNeeded() {
750
+ if purchaseUpdatedSub == nil {
751
+ RnIapLog.payload("purchaseUpdatedListener.register", nil)
752
+ purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { [weak self] openIapPurchase in
753
+ guard let self else { return }
754
+ Task { @MainActor in
755
+ let rawPayload = OpenIapSerialization.purchase(openIapPurchase)
756
+ let payload = RnIapHelper.sanitizeDictionary(rawPayload)
757
+ RnIapLog.result("purchaseUpdatedListener", payload)
758
+ if let identifier = rawPayload["id"] as? String {
759
+ self.purchasePayloadById[identifier] = rawPayload
760
+ }
761
+ let nitro = RnIapHelper.convertPurchaseDictionary(payload)
762
+ self.sendPurchaseUpdate(nitro)
763
+ }
764
+ }
765
+ RnIapLog.result("purchaseUpdatedListener.register", "attached")
766
+ }
767
+
768
+ if purchaseErrorSub == nil {
769
+ RnIapLog.payload("purchaseErrorListener.register", nil)
770
+ purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { [weak self] error in
771
+ guard let self else { return }
772
+ Task { @MainActor in
773
+ let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(error))
774
+ RnIapLog.result("purchaseErrorListener", payload)
775
+ let nitroError = RnIapHelper.makePurchaseErrorResult(
776
+ code: error.code,
777
+ message: error.message,
778
+ error.productId
779
+ )
780
+ self.sendPurchaseError(nitroError, productId: error.productId)
781
+ }
782
+ }
783
+ RnIapLog.result("purchaseErrorListener.register", "attached")
784
+ }
785
+
786
+ if promotedProductSub == nil {
787
+ RnIapLog.payload("promotedProductListenerIOS.register", nil)
788
+ promotedProductSub = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in
789
+ guard let self else { return }
790
+ Task {
791
+ RnIapLog.payload("promotedProductListenerIOS", ["productId": productId])
792
+ do {
793
+ let request = try OpenIapSerialization.productRequest(skus: [productId], type: .all)
794
+ let result = try await OpenIapModule.shared.fetchProducts(request)
795
+ let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.products(result))
796
+ RnIapLog.result("fetchProducts", payloads)
797
+ if let payload = payloads.first {
798
+ let nitro = RnIapHelper.convertProductDictionary(payload)
799
+ await MainActor.run {
800
+ for listener in self.promotedProductListeners { listener(nitro) }
801
+ }
802
+ }
803
+ } catch {
804
+ RnIapLog.failure("promotedProductListenerIOS", error: error)
805
+ let id = productId
806
+ await MainActor.run {
807
+ var minimal = NitroProduct()
808
+ minimal.id = id
809
+ minimal.title = id
810
+ minimal.type = "inapp"
811
+ minimal.platform = .ios
812
+ for listener in self.promotedProductListeners { listener(minimal) }
813
+ }
814
+ }
815
+ }
816
+ }
817
+ RnIapLog.result("promotedProductListenerIOS.register", "attached")
818
+ }
819
+ }
820
+
640
821
  private func ensureConnection() throws {
641
822
  guard isInitialized else {
642
- throw OpenIapError.make(code: OpenIapError.InitConnection, message: "Connection not initialized. Call initConnection() first.")
823
+ throw PurchaseError.make(code: .initConnection, message: "Connection not initialized. Call initConnection() first.")
643
824
  }
644
825
  }
645
826
 
646
827
  private func sendPurchaseUpdate(_ purchase: NitroPurchase) {
828
+ let keyComponents = [
829
+ purchase.id,
830
+ purchase.productId,
831
+ String(purchase.transactionDate),
832
+ purchase.originalTransactionIdentifierIOS ?? "",
833
+ purchase.purchaseToken ?? ""
834
+ ]
835
+ let eventKey = keyComponents.joined(separator: "#")
836
+
837
+ if deliveredPurchaseEventKeys.contains(eventKey) {
838
+ RnIapLog.warn("Duplicate purchase update skipped for \(purchase.productId)")
839
+ return
840
+ }
841
+
842
+ deliveredPurchaseEventKeys.insert(eventKey)
843
+ deliveredPurchaseEventOrder.append(eventKey)
844
+ if deliveredPurchaseEventOrder.count > purchaseEventDedupLimit, let removed = deliveredPurchaseEventOrder.first {
845
+ deliveredPurchaseEventOrder.removeFirst()
846
+ deliveredPurchaseEventKeys.remove(removed)
847
+ }
848
+
647
849
  for listener in purchaseUpdatedListeners {
648
850
  listener(purchase)
649
851
  }
650
852
  }
651
853
 
652
854
  private func sendPurchaseError(_ error: NitroPurchaseResult, productId: String? = nil) {
653
- // Update last error for deduplication using the associated product SKU (not token)
654
- lastPurchaseErrorCode = error.code
655
- lastPurchaseErrorProductId = productId
656
- lastPurchaseErrorTimestamp = Date().timeIntervalSince1970
855
+ let now = Date().timeIntervalSince1970
856
+ let dedupIdentifier = productId
857
+ ?? (error.purchaseToken?.isEmpty == false ? error.purchaseToken : nil)
858
+ ?? (error.message.isEmpty ? nil : error.message)
859
+ let currentKey = RnIapHelper.makeErrorDedupKey(code: error.code, productId: dedupIdentifier)
860
+ // Dedup only when the exact same error is emitted almost simultaneously.
861
+ let withinWindow = (now - lastPurchaseErrorTimestamp) < 0.15
862
+ if currentKey == lastPurchaseErrorKey && withinWindow {
863
+ return
864
+ }
865
+
866
+ lastPurchaseErrorKey = currentKey
867
+ lastPurchaseErrorTimestamp = now
868
+
657
869
  // Ensure we never leak SKU via purchaseToken
658
870
  var sanitized = error
659
871
  if let pid = productId, sanitized.purchaseToken == pid {
@@ -665,26 +877,9 @@ class HybridRnIap: HybridRnIapSpec {
665
877
  }
666
878
 
667
879
  private func sendPurchaseErrorDedup(_ error: NitroPurchaseResult, productId: String? = nil) {
668
- let now = Date().timeIntervalSince1970
669
- let sameCode = (error.code == lastPurchaseErrorCode)
670
- let sameProduct = (productId == lastPurchaseErrorProductId)
671
- let withinWindow = (now - lastPurchaseErrorTimestamp) < 0.3
672
- if sameCode && sameProduct && withinWindow {
673
- return
674
- }
675
880
  sendPurchaseError(error, productId: productId)
676
881
  }
677
882
 
678
- private func createPurchaseErrorResult(code: String, message: String, productId: String? = nil) -> NitroPurchaseResult {
679
- var result = NitroPurchaseResult()
680
- result.responseCode = 0
681
- result.code = code
682
- result.message = message
683
- // Do not overload the token field with productId
684
- result.purchaseToken = nil
685
- return result
686
- }
687
-
688
883
  private func cleanupExistingState() {
689
884
  // Cancel transaction listener if any
690
885
  updateListenerTask?.cancel()
@@ -693,68 +888,36 @@ class HybridRnIap: HybridRnIapSpec {
693
888
 
694
889
 
695
890
  // Remove OpenIAP listeners & end connection
696
- if let sub = purchaseUpdatedSub { OpenIapModule.shared.removeListener(sub) }
697
- if let sub = purchaseErrorSub { OpenIapModule.shared.removeListener(sub) }
698
- if let sub = promotedProductSub { OpenIapModule.shared.removeListener(sub) }
891
+ if let sub = purchaseUpdatedSub {
892
+ RnIapLog.payload("removeListener", "purchaseUpdated")
893
+ OpenIapModule.shared.removeListener(sub)
894
+ }
895
+ if let sub = purchaseErrorSub {
896
+ RnIapLog.payload("removeListener", "purchaseError")
897
+ OpenIapModule.shared.removeListener(sub)
898
+ }
899
+ if let sub = promotedProductSub {
900
+ RnIapLog.payload("removeListener", "promotedProduct")
901
+ OpenIapModule.shared.removeListener(sub)
902
+ }
699
903
  purchaseUpdatedSub = nil
700
904
  purchaseErrorSub = nil
701
905
  promotedProductSub = nil
702
- Task { _ = try? await OpenIapModule.shared.endConnection() }
906
+ Task {
907
+ RnIapLog.payload("endConnection", nil)
908
+ let result = try? await OpenIapModule.shared.endConnection()
909
+ RnIapLog.result("endConnection", result as Any)
910
+ }
703
911
 
704
912
  // Clear event listeners
705
913
  purchaseUpdatedListeners.removeAll()
706
914
  purchaseErrorListeners.removeAll()
707
915
  promotedProductListeners.removeAll()
708
- }
709
-
710
- // MARK: - OpenIAP -> Nitro mappers
711
- private func convertOpenIapProductToNitroProduct(_ p: OpenIapProduct) -> NitroProduct {
712
- var n = NitroProduct()
713
- n.id = p.id
714
- n.title = p.title
715
- n.description = p.description
716
- n.type = p.type
717
- n.displayName = p.displayName ?? p.displayNameIOS
718
- n.displayPrice = p.displayPrice
719
- n.currency = p.currency
720
- n.price = p.price
721
- if let platform = IapPlatform(fromString: p.platform) {
722
- n.platform = platform
723
- }
724
- // iOS specifics
725
- n.typeIOS = p.typeIOS.rawValue
726
- n.isFamilyShareableIOS = p.isFamilyShareableIOS
727
- n.jsonRepresentationIOS = p.jsonRepresentationIOS
728
- n.subscriptionPeriodUnitIOS = p.subscriptionPeriodUnitIOS
729
- if let num = p.subscriptionPeriodNumberIOS, let d = Double(num) { n.subscriptionPeriodNumberIOS = d }
730
- n.introductoryPriceIOS = p.introductoryPriceIOS
731
- if let amt = p.introductoryPriceAsAmountIOS, let d = Double(amt) { n.introductoryPriceAsAmountIOS = d }
732
- n.introductoryPricePaymentModeIOS = p.introductoryPricePaymentModeIOS
733
- if let cnt = p.introductoryPriceNumberOfPeriodsIOS, let d = Double(cnt) { n.introductoryPriceNumberOfPeriodsIOS = d }
734
- n.introductoryPriceSubscriptionPeriodIOS = p.introductoryPriceSubscriptionPeriodIOS
735
- return n
736
- }
737
-
738
- private func convertOpenIapPurchaseToNitroPurchase(_ p: OpenIapPurchase) -> NitroPurchase {
739
- var n = NitroPurchase()
740
- n.id = p.id
741
- n.productId = p.productId
742
- n.transactionDate = p.transactionDate
743
- n.purchaseToken = p.purchaseToken
744
- if let platform = IapPlatform(fromString: p.platform) {
745
- n.platform = platform
746
- }
747
- n.quantity = Double(p.quantity)
748
- if let state = PurchaseState(fromString: p.purchaseState.rawValue) {
749
- n.purchaseState = state
750
- }
751
- n.isAutoRenewing = p.isAutoRenewing
752
- // iOS specifics
753
- if let q = p.quantityIOS { n.quantityIOS = Double(q) }
754
- n.originalTransactionDateIOS = p.originalTransactionDateIOS
755
- n.originalTransactionIdentifierIOS = p.originalTransactionIdentifierIOS
756
- n.appAccountToken = p.appAccountToken
757
- return n
916
+ deliveredPurchaseEventKeys.removeAll()
917
+ deliveredPurchaseEventOrder.removeAll()
918
+ purchasePayloadById.removeAll()
919
+ lastPurchaseErrorKey = nil
920
+ lastPurchaseErrorTimestamp = 0
758
921
  }
759
922
 
760
923
  // MARK: - Android-only stubs (required for protocol conformance)
@@ -763,13 +926,13 @@ class HybridRnIap: HybridRnIapSpec {
763
926
  // because the TS spec marks them as Android-only.
764
927
  func getStorefrontAndroid() throws -> Promise<String> {
765
928
  return Promise.async {
766
- throw OpenIapError.make(code: OpenIapError.FeatureNotSupported)
929
+ throw PurchaseError.make(code: .featureNotSupported)
767
930
  }
768
931
  }
769
932
 
770
933
  func deepLinkToSubscriptionsAndroid(options: NitroDeepLinkOptionsAndroid) throws -> Promise<Void> {
771
934
  return Promise.async {
772
- throw OpenIapError.make(code: OpenIapError.FeatureNotSupported)
935
+ throw PurchaseError.make(code: .featureNotSupported)
773
936
  }
774
937
  }
775
938
  }