expo-iap 3.0.8 → 3.1.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 (53) hide show
  1. package/CLAUDE.md +2 -2
  2. package/CONTRIBUTING.md +19 -0
  3. package/README.md +18 -6
  4. package/android/build.gradle +24 -1
  5. package/android/src/main/java/expo/modules/iap/ExpoIapLog.kt +69 -0
  6. package/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +190 -59
  7. package/build/index.d.ts +20 -47
  8. package/build/index.d.ts.map +1 -1
  9. package/build/index.js +94 -137
  10. package/build/index.js.map +1 -1
  11. package/build/modules/android.d.ts.map +1 -1
  12. package/build/modules/android.js +2 -1
  13. package/build/modules/android.js.map +1 -1
  14. package/build/modules/ios.d.ts +16 -1
  15. package/build/modules/ios.d.ts.map +1 -1
  16. package/build/modules/ios.js +29 -16
  17. package/build/modules/ios.js.map +1 -1
  18. package/build/types.d.ts +8 -6
  19. package/build/types.d.ts.map +1 -1
  20. package/build/types.js.map +1 -1
  21. package/build/useIAP.d.ts +1 -1
  22. package/build/useIAP.d.ts.map +1 -1
  23. package/build/useIAP.js +12 -15
  24. package/build/useIAP.js.map +1 -1
  25. package/build/utils/errorMapping.d.ts +32 -23
  26. package/build/utils/errorMapping.d.ts.map +1 -1
  27. package/build/utils/errorMapping.js +117 -22
  28. package/build/utils/errorMapping.js.map +1 -1
  29. package/ios/ExpoIap.podspec +3 -2
  30. package/ios/ExpoIapHelper.swift +96 -0
  31. package/ios/ExpoIapLog.swift +127 -0
  32. package/ios/ExpoIapModule.swift +218 -340
  33. package/openiap-versions.json +5 -0
  34. package/package.json +2 -2
  35. package/plugin/build/withIAP.js +6 -4
  36. package/plugin/src/withIAP.ts +14 -4
  37. package/scripts/update-types.mjs +20 -1
  38. package/src/index.ts +122 -165
  39. package/src/modules/android.ts +2 -1
  40. package/src/modules/ios.ts +31 -19
  41. package/src/types.ts +8 -6
  42. package/src/useIAP.ts +17 -25
  43. package/src/utils/errorMapping.ts +203 -23
  44. package/build/purchase-error.d.ts +0 -67
  45. package/build/purchase-error.d.ts.map +0 -1
  46. package/build/purchase-error.js +0 -166
  47. package/build/purchase-error.js.map +0 -1
  48. package/build/utils/purchase.d.ts +0 -9
  49. package/build/utils/purchase.d.ts.map +0 -1
  50. package/build/utils/purchase.js +0 -34
  51. package/build/utils/purchase.js.map +0 -1
  52. package/src/purchase-error.ts +0 -265
  53. package/src/utils/purchase.ts +0 -52
@@ -1,53 +1,18 @@
1
1
  import ExpoModulesCore
2
- import OSLog
2
+ import Foundation
3
3
  import OpenIAP
4
- import StoreKit
5
-
6
- private let iapLogger = Logger(subsystem: "dev.hyo.expo-iap", category: "ExpoIapModule")
7
- private func logDebug(_ message: String) {
8
- // Use OSLog/Logger so logs are structured and filterable
9
- // Suppress debug logs in Release builds
10
- #if DEBUG
11
- iapLogger.debug("\(message, privacy: .public)")
12
- #endif
13
- }
14
-
15
- // MARK: - Swift helpers for optional dictionary compaction
16
- extension Sequence where Element == [String: Any?] {
17
- fileprivate func compactingValues() -> [[String: Any]] {
18
- return self.map { $0.compactMapValues { $0 } }
19
- }
20
- }
21
-
22
- extension Dictionary where Key == String, Value == Any? {
23
- fileprivate func compactingValues() -> [String: Any] {
24
- return self.compactMapValues { $0 }
25
- }
26
- }
27
-
28
- // Event names
29
- struct OpenIapEvent {
30
- static let PurchaseUpdated = "purchase-updated"
31
- static let PurchaseError = "purchase-error"
32
- static let PromotedProductIOS = "promoted-product-ios"
33
- }
4
+ #if canImport(UIKit)
5
+ import UIKit
6
+ #endif
34
7
 
35
8
  @available(iOS 15.0, tvOS 15.0, *)
36
9
  @MainActor
37
- public class ExpoIapModule: Module {
38
- // Connection state for local validation parity with RN module
39
- private var isInitialized: Bool = false
40
- // Subscriptions for OpenIapModule event listeners
10
+ public final class ExpoIapModule: Module {
11
+ private var isInitialized = false
41
12
  private var purchaseUpdatedSub: Subscription?
42
13
  private var purchaseErrorSub: Subscription?
43
14
  private var promotedProductSub: Subscription?
44
15
 
45
- // Helper to safely remove a listener and nil out the reference
46
- private func removeListener(_ sub: inout Subscription?) {
47
- if let s = sub { OpenIapModule.shared.removeListener(s) }
48
- sub = nil
49
- }
50
-
51
16
  nonisolated public func definition() -> ModuleDefinition {
52
17
  Name("ExpoIap")
53
18
 
@@ -56,473 +21,386 @@ public class ExpoIapModule: Module {
56
21
  }
57
22
 
58
23
  Events(
59
- OpenIapEvent.PurchaseUpdated,
60
- OpenIapEvent.PurchaseError,
61
- OpenIapEvent.PromotedProductIOS
24
+ IapEvent.purchaseUpdated.rawValue,
25
+ IapEvent.purchaseError.rawValue,
26
+ IapEvent.promotedProductIos.rawValue
62
27
  )
63
28
 
64
29
  OnCreate {
65
- logDebug("Module created")
66
30
  Task { @MainActor in
67
31
  self.setupStore()
68
32
  }
69
33
  }
70
34
 
71
35
  OnDestroy {
72
- logDebug("Module destroyed")
73
36
  Task { @MainActor in
74
37
  await self.cleanupStore()
75
38
  }
76
39
  }
77
40
 
78
- // MARK: - Connection Management
79
-
80
41
  AsyncFunction("initConnection") { () async throws -> Bool in
81
- logDebug("initConnection called")
82
42
  let isConnected = try await OpenIapModule.shared.initConnection()
83
- // Track initialization locally for ensureConnection()
84
43
  await MainActor.run { self.isInitialized = isConnected }
85
- logDebug("Connection initialized: \(isConnected)")
86
44
  return isConnected
87
45
  }
88
46
 
89
47
  AsyncFunction("endConnection") { () async throws -> Bool in
90
- logDebug("endConnection called")
91
- let _ = try await OpenIapModule.shared.endConnection()
92
-
93
- logDebug("Connection ended")
48
+ let succeeded = try await OpenIapModule.shared.endConnection()
94
49
  await MainActor.run { self.isInitialized = false }
95
- return true
50
+ return succeeded
96
51
  }
97
52
 
98
- // MARK: - Product Management
99
-
100
53
  AsyncFunction("fetchProducts") { (params: [String: Any]) async throws -> [[String: Any]] in
54
+ ExpoIapLog.payload("fetchProducts", payload: params)
101
55
  try await ensureConnection()
102
- logDebug("fetchProducts raw params: \(params)")
103
-
104
- // Handle both object format {skus: [...], type: "..."} and array format
105
- var skus: [String] = []
106
- var typeString = "all"
107
-
108
- if let skusArray = params["skus"] as? [String] {
109
- // Object format: {skus: [...], type: "..."}
110
- skus = skusArray
111
- typeString = params["type"] as? String ?? "all"
112
- } else {
113
- // Array format passed directly - reconstruct from indexed keys
114
- var tempSkus: [String] = []
115
- var index = 0
116
- while let sku = params["\(index)"] as? String {
117
- tempSkus.append(sku)
118
- index += 1
119
- }
120
- skus = tempSkus
121
- }
122
-
123
- logDebug("fetchProducts parsed - skus: \(skus), type: \(typeString)")
124
- logDebug("SKUs count: \(skus.count)")
125
-
126
- // Validate SKUs
127
- guard !skus.isEmpty else {
128
- logDebug("ERROR: Empty SKUs array!")
129
- throw OpenIapError.emptySkuList()
130
- }
131
-
132
- // Convert string to OpenIapRequestProductType enum
133
- let productType: OpenIapRequestProductType = {
134
- switch typeString {
135
- case "inapp":
136
- return .inapp
137
- case "subs":
138
- return .subs
139
- default:
140
- return .all
141
- }
142
- }()
143
-
144
- logDebug("Converted type to OpenIapRequestProductType: \(productType)")
145
-
146
- // Build OpenIapProductRequest and fetch via OpenIapModule
147
- let request = OpenIapProductRequest(skus: skus, type: productType)
148
- let products = try await OpenIapModule.shared.fetchProducts(request)
149
- logDebug("Fetched \(products.count) products from store")
150
- if products.isEmpty {
151
- logDebug("No products found. Possible reasons:")
152
- logDebug("1. Products not configured in App Store Connect")
153
- logDebug("2. Bundle ID mismatch")
154
- logDebug("3. Not signed in to sandbox account")
155
- logDebug("4. Products pending review")
156
- }
157
- for product in products {
158
- logDebug("Product: \(product.id) - \(product.title) - \(product.displayPrice)")
159
- }
160
- // Ensure non-optional values for Expo bridge
161
- return OpenIapSerialization.products(products).compactingValues()
56
+ let request = try ExpoIapHelper.decodeProductRequest(from: params)
57
+ let result = try await OpenIapModule.shared.fetchProducts(request)
58
+ let products = ExpoIapHelper.sanitizeArray(OpenIapSerialization.products(result))
59
+ ExpoIapLog.result("fetchProducts", value: products)
60
+ return products
162
61
  }
163
62
 
164
- // MARK: - Purchase Operations
165
-
166
- AsyncFunction("requestPurchase") { (params: [String: Any]) async throws in
167
- // Extract and validate required fields
168
- guard let sku = params["sku"] as? String, !sku.isEmpty else {
169
- throw OpenIapError.make(
170
- code: OpenIapError.PurchaseError, message: "Missing required 'sku'")
171
- }
63
+ AsyncFunction("requestPurchase") { (payload: [String: Any]) async throws -> Any? in
64
+ ExpoIapLog.payload("requestPurchase", payload: payload)
172
65
  try await ensureConnection()
66
+ let props = try ExpoIapHelper.decodeRequestPurchaseProps(from: payload)
173
67
 
174
- // Optional fields
175
- let andFinish =
176
- (params["andDangerouslyFinishTransactionAutomatically"] as? Bool) ?? false
177
- let appAccountToken = params["appAccountToken"] as? String
178
- let quantity: Int? = {
179
- if let q = params["quantity"] as? Int { return q }
180
- if let qd = params["quantity"] as? Double { return Int(qd) }
181
- return nil
182
- }()
183
-
184
- // Discount offer mapping (strings expected from JS)
185
- // Use OpenIapDiscountOffer from OpenIAP package (avoid relying on legacy typealiases)
186
- var discountOffer: OpenIapDiscountOffer? = nil
187
- if let offer = params["withOffer"] as? [String: Any] {
188
- let identifier = (offer["identifier"] as? String) ?? (offer["id"] as? String) ?? ""
189
- let keyIdentifier = (offer["keyIdentifier"] as? String) ?? ""
190
- let nonce = (offer["nonce"] as? String) ?? ""
191
- let signature = (offer["signature"] as? String) ?? ""
192
- let timestamp = (offer["timestamp"] as? String) ?? ""
193
- if !identifier.isEmpty && !keyIdentifier.isEmpty && !nonce.isEmpty
194
- && !signature.isEmpty && !timestamp.isEmpty
195
- {
196
- discountOffer = OpenIapDiscountOffer(
197
- identifier: identifier,
198
- keyIdentifier: keyIdentifier,
199
- nonce: nonce,
200
- signature: signature,
201
- timestamp: timestamp
202
- )
68
+ do {
69
+ guard let result = try await OpenIapModule.shared.requestPurchase(props) else {
70
+ ExpoIapLog.result("requestPurchase", value: nil)
71
+ return nil
203
72
  }
204
- }
205
-
206
- let tokenForLog = appAccountToken ?? "nil"
207
- let qtyForLog = quantity ?? -1
208
- logDebug(
209
- "requestPurchase parsed - sku: \(sku), andFinish: \(andFinish), appAccountToken: \(tokenForLog), quantity: \(qtyForLog), hasOffer: \(discountOffer != nil)"
210
- )
211
73
 
212
- // Build purchase request props using OpenIapRequestPurchaseProps
213
- let requestProps = OpenIapRequestPurchaseProps(
214
- sku: sku,
215
- andDangerouslyFinishTransactionAutomatically: andFinish,
216
- appAccountToken: appAccountToken,
217
- quantity: quantity,
218
- withOffer: discountOffer
219
- )
220
-
221
- do {
222
- _ = try await OpenIapModule.shared.requestPurchase(requestProps)
223
- logDebug("Purchase request completed successfully")
224
- } catch {
225
- logDebug("Purchase request failed with error: \(error)")
226
- if let openIapError = error as? OpenIapError {
227
- throw openIapError
74
+ switch result {
75
+ case .purchase(let maybePurchase):
76
+ guard let purchase = maybePurchase else { return nil }
77
+ let sanitized = ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(purchase))
78
+ ExpoIapLog.result("requestPurchase", value: sanitized)
79
+ return sanitized
80
+ case .purchases(let maybePurchases):
81
+ guard let purchases = maybePurchases else { return nil }
82
+ let sanitized = ExpoIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases))
83
+ ExpoIapLog.result("requestPurchase", value: sanitized)
84
+ return sanitized
228
85
  }
229
- throw OpenIapError.make(
230
- code: OpenIapError.PurchaseError, message: error.localizedDescription)
86
+ } catch let error as PurchaseError {
87
+ ExpoIapLog.failure("requestPurchase", error: error)
88
+ throw error
89
+ } catch {
90
+ ExpoIapLog.failure("requestPurchase", error: error)
91
+ throw PurchaseError.make(code: .purchaseError, message: error.localizedDescription)
231
92
  }
232
93
  }
233
94
 
234
- AsyncFunction("finishTransaction") { (transactionId: String) async throws -> Bool in
95
+ AsyncFunction("finishTransaction") {
96
+ (purchasePayload: [String: Any], isConsumable: Bool?) async throws -> Bool in
97
+ ExpoIapLog.payload(
98
+ "finishTransaction",
99
+ payload: [
100
+ "purchase": purchasePayload,
101
+ "isConsumable": isConsumable as Any,
102
+ ]
103
+ )
235
104
  try await ensureConnection()
236
- logDebug("finishTransaction called with id: \(transactionId)")
237
- let result = try await OpenIapModule.shared.finishTransaction(
238
- transactionIdentifier: transactionId)
239
- return result
105
+ let purchaseInput = try OpenIapSerialization.purchaseInput(from: purchasePayload)
106
+ try await OpenIapModule.shared.finishTransaction(
107
+ purchase: purchaseInput,
108
+ isConsumable: isConsumable
109
+ )
110
+ ExpoIapLog.result("finishTransaction", value: true)
111
+ return true
240
112
  }
241
113
 
242
- // MARK: - Purchase History
243
-
244
114
  AsyncFunction("getAvailablePurchases") {
245
- (options: [String: Any?]?) async throws -> [[String: Any]] in
115
+ (options: [String: Any]?) async throws -> [[String: Any]] in
116
+ ExpoIapLog.payload("getAvailablePurchases", payload: options ?? [:])
246
117
  try await ensureConnection()
247
- logDebug("getAvailablePurchases called")
248
-
249
- // Build options and get purchases directly from OpenIapModule
250
- let purchaseOptions: OpenIapGetAvailablePurchasesProps? = options.map {
251
- OpenIapGetAvailablePurchasesProps(
252
- alsoPublishToEventListenerIOS: $0["alsoPublishToEventListenerIOS"] as? Bool,
253
- onlyIncludeActiveItemsIOS: $0["onlyIncludeActiveItemsIOS"] as? Bool
254
- )
255
- }
118
+ let purchaseOptions = try options.map { try OpenIapSerialization.purchaseOptions(from: $0) }
256
119
  let purchases = try await OpenIapModule.shared.getAvailablePurchases(purchaseOptions)
257
- return OpenIapSerialization.purchases(purchases).compactingValues()
120
+ let sanitized = ExpoIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases))
121
+ ExpoIapLog.result("getAvailablePurchases", value: sanitized)
122
+ return sanitized
258
123
  }
259
124
 
260
- // Legacy function for backward compatibility
261
125
  AsyncFunction("getAvailableItems") {
262
- (alsoPublishToEventListener: Bool, onlyIncludeActiveItems: Bool) async throws
263
- -> [[String: Any]] in
264
- try await ensureConnection()
265
- logDebug("getAvailableItems called (legacy)")
266
-
267
- let purchaseOptions = OpenIapGetAvailablePurchasesProps(
268
- alsoPublishToEventListenerIOS: alsoPublishToEventListener,
269
- onlyIncludeActiveItemsIOS: onlyIncludeActiveItems
126
+ (alsoPublish: Bool, onlyIncludeActive: Bool) async throws -> [[String: Any]] in
127
+ ExpoIapLog.payload(
128
+ "getAvailableItems",
129
+ payload: [
130
+ "alsoPublishToEventListenerIOS": alsoPublish,
131
+ "onlyIncludeActiveItemsIOS": onlyIncludeActive,
132
+ ]
270
133
  )
271
- let purchases = try await OpenIapModule.shared.getAvailablePurchases(purchaseOptions)
272
- return OpenIapSerialization.purchases(purchases).compactingValues()
134
+ try await ensureConnection()
135
+ let optionsDictionary: [String: Any] = [
136
+ "alsoPublishToEventListenerIOS": alsoPublish,
137
+ "onlyIncludeActiveItemsIOS": onlyIncludeActive
138
+ ]
139
+ let options = try OpenIapSerialization.purchaseOptions(from: optionsDictionary)
140
+ let purchases = try await OpenIapModule.shared.getAvailablePurchases(options)
141
+ let sanitized = ExpoIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases))
142
+ ExpoIapLog.result("getAvailableItems", value: sanitized)
143
+ return sanitized
273
144
  }
274
145
 
275
146
  AsyncFunction("getPendingTransactionsIOS") { () async throws -> [[String: Any]] in
147
+ ExpoIapLog.payload("getPendingTransactionsIOS", payload: nil)
276
148
  try await ensureConnection()
277
- logDebug("getPendingTransactionsIOS called")
278
-
279
- let pendingTransactions = try await OpenIapModule.shared.getPendingTransactionsIOS()
280
- return OpenIapSerialization.purchases(pendingTransactions).compactingValues()
149
+ let pending = try await OpenIapModule.shared.getPendingTransactionsIOS()
150
+ let sanitized = pending.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) }
151
+ ExpoIapLog.result("getPendingTransactionsIOS", value: sanitized)
152
+ return sanitized
281
153
  }
282
154
 
283
155
  AsyncFunction("clearTransactionIOS") { () async throws -> Bool in
156
+ ExpoIapLog.payload("clearTransactionIOS", payload: nil)
284
157
  try await ensureConnection()
285
- logDebug("clearTransactionIOS called")
286
- try await OpenIapModule.shared.clearTransactionIOS()
287
- return true
158
+ let success = try await OpenIapModule.shared.clearTransactionIOS()
159
+ ExpoIapLog.result("clearTransactionIOS", value: success)
160
+ return success
288
161
  }
289
162
 
290
- // MARK: - Receipt & Validation
291
-
292
163
  AsyncFunction("getReceiptIOS") { () async throws -> String in
164
+ ExpoIapLog.payload("getReceiptIOS", payload: nil)
293
165
  try await ensureConnection()
294
- logDebug("getReceiptIOS called")
295
- return try await OpenIapModule.shared.getReceiptDataIOS() ?? ""
166
+ let receipt = try await OpenIapModule.shared.getReceiptDataIOS() ?? ""
167
+ ExpoIapLog.result("getReceiptIOS", value: receipt)
168
+ return receipt
296
169
  }
297
170
 
298
- // Backward-compatible alias expected by JS layer/tests
299
171
  AsyncFunction("getReceiptDataIOS") { () async throws -> String in
172
+ ExpoIapLog.payload("getReceiptDataIOS", payload: nil)
300
173
  try await ensureConnection()
301
- logDebug("getReceiptDataIOS called (alias of getReceiptIOS)")
302
- return try await OpenIapModule.shared.getReceiptDataIOS() ?? ""
174
+ let receipt = try await OpenIapModule.shared.getReceiptDataIOS() ?? ""
175
+ ExpoIapLog.result("getReceiptDataIOS", value: receipt)
176
+ return receipt
303
177
  }
304
178
 
305
179
  AsyncFunction("requestReceiptRefreshIOS") { () async throws -> String in
180
+ ExpoIapLog.payload("requestReceiptRefreshIOS", payload: nil)
306
181
  try await ensureConnection()
307
- logDebug("requestReceiptRefreshIOS called")
308
- // Receipt refresh is handled automatically by StoreKit 2
309
- return try await OpenIapModule.shared.getReceiptDataIOS() ?? ""
182
+ let receipt = try await OpenIapModule.shared.getReceiptDataIOS() ?? ""
183
+ ExpoIapLog.result("requestReceiptRefreshIOS", value: receipt)
184
+ return receipt
310
185
  }
311
186
 
312
187
  AsyncFunction("validateReceiptIOS") { (sku: String) async throws -> [String: Any] in
188
+ ExpoIapLog.payload("validateReceiptIOS", payload: ["sku": sku])
313
189
  try await ensureConnection()
314
- logDebug("validateReceiptIOS called for sku: \(sku)")
315
190
  do {
316
- // Use OpenIapReceiptValidationProps to keep naming parity with OpenIAP
317
- let props = OpenIapReceiptValidationProps(sku: sku)
191
+ let props = try OpenIapSerialization.receiptValidationProps(from: ["sku": sku])
318
192
  let result = try await OpenIapModule.shared.validateReceiptIOS(props)
319
- let dict: [String: Any?] = [
320
- "isValid": result.isValid,
321
- "receiptData": result.receiptData,
322
- "jwsRepresentation": result.jwsRepresentation,
323
- // Populate unified purchaseToken for iOS as alias of JWS
324
- "purchaseToken": result.jwsRepresentation,
325
- "latestTransaction": result.latestTransaction.map {
326
- OpenIapSerialization.purchase($0).compactingValues()
327
- },
328
- ]
329
- return dict.compactingValues()
193
+ var payload = OpenIapSerialization.encode(result)
194
+ payload["purchaseToken"] = result.jwsRepresentation
195
+ let sanitized = ExpoIapHelper.sanitizeDictionary(payload)
196
+ ExpoIapLog.result("validateReceiptIOS", value: sanitized)
197
+ return sanitized
198
+ } catch let error as PurchaseError {
199
+ ExpoIapLog.failure("validateReceiptIOS", error: error)
200
+ throw error
330
201
  } catch {
331
- throw OpenIapError.make(code: OpenIapError.ReceiptFailed)
202
+ ExpoIapLog.failure("validateReceiptIOS", error: error)
203
+ throw PurchaseError.make(code: .receiptFailed)
332
204
  }
333
205
  }
334
206
 
335
- // MARK: - iOS Specific Features
336
-
337
207
  AsyncFunction("presentCodeRedemptionSheetIOS") { () async throws -> Bool in
208
+ ExpoIapLog.payload("presentCodeRedemptionSheetIOS", payload: nil)
338
209
  try await ensureConnection()
339
- logDebug("presentCodeRedemptionSheetIOS called")
340
- let _ = try await OpenIapModule.shared.presentCodeRedemptionSheetIOS()
341
- return true
210
+ let success = try await OpenIapModule.shared.presentCodeRedemptionSheetIOS()
211
+ ExpoIapLog.result("presentCodeRedemptionSheetIOS", value: success)
212
+ return success
342
213
  }
343
214
 
344
215
  AsyncFunction("showManageSubscriptionsIOS") { () async throws -> [[String: Any]] in
216
+ ExpoIapLog.payload("showManageSubscriptionsIOS", payload: nil)
345
217
  try await ensureConnection()
346
- logDebug("showManageSubscriptionsIOS called")
347
- // OpenIAP returns already-serialized dictionaries here.
348
218
  let purchases = try await OpenIapModule.shared.showManageSubscriptionsIOS()
349
- return purchases.compactingValues()
219
+ let sanitized = purchases.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) }
220
+ ExpoIapLog.result("showManageSubscriptionsIOS", value: sanitized)
221
+ return sanitized
350
222
  }
351
223
 
352
- AsyncFunction("deepLinkToSubscriptionsIOS") { () async throws in
353
- logDebug("deepLinkToSubscriptionsIOS called")
354
- // Open App Store subscriptions page directly
355
- if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
356
- #if canImport(UIKit)
357
- await MainActor.run {
358
- UIApplication.shared.open(url, options: [:], completionHandler: nil)
359
- }
360
- #endif
361
- }
224
+ AsyncFunction("deepLinkToSubscriptionsIOS") { () async throws -> Bool in
225
+ ExpoIapLog.payload("deepLinkToSubscriptionsIOS", payload: nil)
226
+ try await ensureConnection()
227
+ try await OpenIapModule.shared.deepLinkToSubscriptions(nil)
228
+ ExpoIapLog.result("deepLinkToSubscriptionsIOS", value: true)
229
+ return true
362
230
  }
363
231
 
364
232
  AsyncFunction("beginRefundRequestIOS") { (sku: String) async throws -> String? in
233
+ ExpoIapLog.payload("beginRefundRequestIOS", payload: ["sku": sku])
365
234
  try await ensureConnection()
366
- logDebug("beginRefundRequestIOS called for sku: \(sku)")
367
- return try await OpenIapModule.shared.beginRefundRequestIOS(sku: sku)
235
+ let result = try await OpenIapModule.shared.beginRefundRequestIOS(sku: sku)
236
+ ExpoIapLog.result("beginRefundRequestIOS", value: result)
237
+ return result
368
238
  }
369
239
 
370
240
  AsyncFunction("getPromotedProductIOS") { () async throws -> [String: Any]? in
241
+ ExpoIapLog.payload("getPromotedProductIOS", payload: nil)
371
242
  try await ensureConnection()
372
- logDebug("getPromotedProductIOS called")
373
-
374
- if let promoted = try await OpenIapModule.shared.getPromotedProductIOS() {
375
- // Fetch full product info by SKU to conform to OpenIapProduct
376
- let request = OpenIapProductRequest(skus: [promoted.productIdentifier], type: .all)
377
- let products = try await OpenIapModule.shared.fetchProducts(request)
378
- let serialized = OpenIapSerialization.products(products).compactingValues()
379
- return serialized.first
243
+ if let product = try await OpenIapModule.shared.getPromotedProductIOS() {
244
+ let sanitized = ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode(product))
245
+ ExpoIapLog.result("getPromotedProductIOS", value: sanitized)
246
+ return sanitized
380
247
  }
248
+ ExpoIapLog.result("getPromotedProductIOS", value: nil)
381
249
  return nil
382
250
  }
251
+
383
252
  AsyncFunction("getStorefrontIOS") { () async throws -> String in
253
+ ExpoIapLog.payload("getStorefrontIOS", payload: nil)
384
254
  try await ensureConnection()
385
- logDebug("getStorefrontIOS called")
386
- return try await OpenIapModule.shared.getStorefrontIOS()
255
+ let storefront = try await OpenIapModule.shared.getStorefrontIOS()
256
+ ExpoIapLog.result("getStorefrontIOS", value: storefront)
257
+ return storefront
387
258
  }
388
259
 
389
260
  AsyncFunction("syncIOS") { () async throws -> Bool in
261
+ ExpoIapLog.payload("syncIOS", payload: nil)
390
262
  try await ensureConnection()
391
- logDebug("syncIOS called")
392
- return try await OpenIapModule.shared.syncIOS()
263
+ let success = try await OpenIapModule.shared.syncIOS()
264
+ ExpoIapLog.result("syncIOS", value: success)
265
+ return success
393
266
  }
394
267
 
395
- // MARK: - Additional iOS Methods
396
-
397
268
  AsyncFunction("isTransactionVerifiedIOS") { (sku: String) async throws -> Bool in
269
+ ExpoIapLog.payload("isTransactionVerifiedIOS", payload: ["sku": sku])
398
270
  try await ensureConnection()
399
- logDebug("isTransactionVerifiedIOS called for sku: \(sku)")
400
- return await OpenIapModule.shared.isTransactionVerifiedIOS(sku: sku)
271
+ let verified = try await OpenIapModule.shared.isTransactionVerifiedIOS(sku: sku)
272
+ ExpoIapLog.result("isTransactionVerifiedIOS", value: verified)
273
+ return verified
401
274
  }
402
275
 
403
276
  AsyncFunction("getTransactionJwsIOS") { (sku: String) async throws -> String? in
277
+ ExpoIapLog.payload("getTransactionJwsIOS", payload: ["sku": sku])
404
278
  try await ensureConnection()
405
- logDebug("getTransactionJwsIOS called for sku: \(sku)")
406
- return try await OpenIapModule.shared.getTransactionJwsIOS(sku: sku)
279
+ let jws = try await OpenIapModule.shared.getTransactionJwsIOS(sku: sku)
280
+ ExpoIapLog.result("getTransactionJwsIOS", value: jws)
281
+ return jws
407
282
  }
408
283
 
409
284
  AsyncFunction("isEligibleForIntroOfferIOS") { (groupID: String) async throws -> Bool in
285
+ ExpoIapLog.payload("isEligibleForIntroOfferIOS", payload: ["groupID": groupID])
410
286
  try await ensureConnection()
411
- logDebug("isEligibleForIntroOfferIOS called for groupID: \(groupID)")
412
- return await OpenIapModule.shared.isEligibleForIntroOfferIOS(groupID: groupID)
287
+ let eligible = try await OpenIapModule.shared.isEligibleForIntroOfferIOS(groupID: groupID)
288
+ ExpoIapLog.result("isEligibleForIntroOfferIOS", value: eligible)
289
+ return eligible
413
290
  }
414
291
 
415
292
  AsyncFunction("subscriptionStatusIOS") { (sku: String) async throws -> [[String: Any]]? in
293
+ ExpoIapLog.payload("subscriptionStatusIOS", payload: ["sku": sku])
416
294
  try await ensureConnection()
417
- logDebug("subscriptionStatusIOS called for sku: \(sku)")
418
-
419
- if let statuses = try await OpenIapModule.shared.subscriptionStatusIOS(sku: sku) {
420
- // Align output with SubscriptionStatusIOS in TS:
421
- // { state: SubscriptionState; renewalInfo?: { jsonRepresentation?: string; willAutoRenew: boolean; autoRenewPreference?: string } }
422
- return statuses.map { status in
423
- var dict: [String: Any?] = [
424
- "state": status.state
425
- ]
426
-
427
- if let info = status.renewalInfo {
428
- // autoRenewStatus is a Bool from OpenIAP types
429
- let renewalInfo: [String: Any?] = [
430
- "willAutoRenew": info.autoRenewStatus,
431
- "autoRenewPreference": info.autoRenewPreference,
432
- ]
433
- dict["renewalInfo"] = renewalInfo
434
- }
435
-
436
- return dict.compactingValues()
437
- }
438
- }
439
- return nil
295
+ let statuses = try await OpenIapModule.shared.subscriptionStatusIOS(sku: sku)
296
+ let sanitized = statuses.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) }
297
+ ExpoIapLog.result("subscriptionStatusIOS", value: sanitized)
298
+ return sanitized
440
299
  }
441
300
 
442
301
  AsyncFunction("currentEntitlementIOS") { (sku: String) async throws -> [String: Any]? in
302
+ ExpoIapLog.payload("currentEntitlementIOS", payload: ["sku": sku])
443
303
  try await ensureConnection()
444
- logDebug("currentEntitlementIOS called for sku: \(sku)")
445
304
  do {
446
- if let entitlement = try await OpenIapModule.shared.currentEntitlementIOS(sku: sku)
447
- {
448
- return OpenIapSerialization.purchase(entitlement).compactingValues()
305
+ if let entitlement = try await OpenIapModule.shared.currentEntitlementIOS(sku: sku) {
306
+ let sanitized = ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode(entitlement))
307
+ ExpoIapLog.result("currentEntitlementIOS", value: sanitized)
308
+ return sanitized
449
309
  }
310
+ ExpoIapLog.result("currentEntitlementIOS", value: nil)
450
311
  return nil
312
+ } catch let error as PurchaseError {
313
+ ExpoIapLog.failure("currentEntitlementIOS", error: error)
314
+ throw error
451
315
  } catch {
452
- throw OpenIapError.make(code: OpenIapError.SkuNotFound, productId: sku)
316
+ ExpoIapLog.failure("currentEntitlementIOS", error: error)
317
+ throw PurchaseError.make(code: .skuNotFound, productId: sku)
453
318
  }
454
319
  }
455
320
 
456
321
  AsyncFunction("latestTransactionIOS") { (sku: String) async throws -> [String: Any]? in
322
+ ExpoIapLog.payload("latestTransactionIOS", payload: ["sku": sku])
457
323
  try await ensureConnection()
458
- logDebug("latestTransactionIOS called for sku: \(sku)")
459
324
  do {
460
325
  if let transaction = try await OpenIapModule.shared.latestTransactionIOS(sku: sku) {
461
- return OpenIapSerialization.purchase(transaction).compactingValues()
326
+ let sanitized = ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode(transaction))
327
+ ExpoIapLog.result("latestTransactionIOS", value: sanitized)
328
+ return sanitized
462
329
  }
330
+ ExpoIapLog.result("latestTransactionIOS", value: nil)
463
331
  return nil
332
+ } catch let error as PurchaseError {
333
+ ExpoIapLog.failure("latestTransactionIOS", error: error)
334
+ throw error
464
335
  } catch {
465
- throw OpenIapError.make(code: OpenIapError.SkuNotFound, productId: sku)
336
+ ExpoIapLog.failure("latestTransactionIOS", error: error)
337
+ throw PurchaseError.make(code: .skuNotFound, productId: sku)
466
338
  }
467
339
  }
468
340
  }
469
341
 
470
- // MARK: - Listeners Setup
471
-
472
342
  @MainActor
473
343
  private func setupStore() {
474
- logDebug("Setting up OpenIapModule event listeners")
475
-
476
344
  purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { [weak self] purchase in
477
345
  Task { @MainActor in
478
346
  guard let self else { return }
479
- logDebug("✅ Purchase success callback - sending event")
480
- let purchaseData = OpenIapSerialization.purchase(purchase)
481
- self.sendEvent(OpenIapEvent.PurchaseUpdated, purchaseData)
347
+ let payload = ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.purchase(purchase))
348
+ self.sendEvent(IapEvent.purchaseUpdated.rawValue, payload)
482
349
  }
483
350
  }
484
351
 
485
- purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { [weak self] event in
352
+ purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { [weak self] error in
486
353
  Task { @MainActor in
487
354
  guard let self else { return }
488
- logDebug("❌ Purchase error callback - sending error event")
489
- let errorData: [String: Any?] = [
490
- "code": event.code,
491
- "message": event.message,
492
- "productId": event.productId,
493
- ]
494
- self.sendEvent(OpenIapEvent.PurchaseError, errorData)
355
+ let payload = ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode(error))
356
+ self.sendEvent(IapEvent.purchaseError.rawValue, payload)
495
357
  }
496
358
  }
497
359
 
498
- promotedProductSub = OpenIapModule.shared.promotedProductListenerIOS {
499
- [weak self] productId in
360
+ promotedProductSub = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in
500
361
  Task { @MainActor in
501
362
  guard let self else { return }
502
- logDebug("📱 Promoted product callback - sending event for: \(productId)")
503
- self.sendEvent(OpenIapEvent.PromotedProductIOS, ["productId": productId])
363
+ do {
364
+ if let product = try await OpenIapModule.shared.getPromotedProductIOS() {
365
+ let sanitized = ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode(product))
366
+ self.sendEvent(IapEvent.promotedProductIos.rawValue, sanitized)
367
+ return
368
+ }
369
+ } catch {
370
+ ExpoIapLog.failure("promotedProductListenerIOS", error: error)
371
+ }
372
+
373
+ self.sendEvent(
374
+ IapEvent.promotedProductIos.rawValue,
375
+ ["productId": productId]
376
+ )
504
377
  }
505
378
  }
506
379
  }
507
380
 
508
381
  @MainActor
509
382
  private func cleanupStore() async {
510
- logDebug("Cleaning up listeners and ending connection")
511
383
  removeListener(&purchaseUpdatedSub)
512
384
  removeListener(&purchaseErrorSub)
513
385
  removeListener(&promotedProductSub)
514
386
  _ = try? await OpenIapModule.shared.endConnection()
515
387
  }
516
388
 
517
- // MARK: - Private Helper Methods
518
-
519
- private func ensureConnection() throws {
520
- guard isInitialized else {
521
- throw OpenIapError.make(
522
- code: OpenIapError.InitConnection,
523
- message: "Connection not initialized. Call initConnection() first."
524
- )
389
+ private func removeListener(_ subscription: inout Subscription?) {
390
+ if let current = subscription {
391
+ OpenIapModule.shared.removeListener(current)
525
392
  }
393
+ subscription = nil
526
394
  }
527
395
 
396
+ private func ensureConnection() async throws {
397
+ try await MainActor.run {
398
+ guard self.isInitialized else {
399
+ throw PurchaseError.make(
400
+ code: .initConnection,
401
+ message: "Connection not initialized. Call initConnection() first."
402
+ )
403
+ }
404
+ }
405
+ }
528
406
  }