expo-iap 2.9.0-rc.3 → 2.9.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 (46) hide show
  1. package/CHANGELOG.md +29 -6
  2. package/CLAUDE.md +40 -0
  3. package/CONTRIBUTING.md +4 -3
  4. package/build/helpers/subscription.d.ts +3 -0
  5. package/build/helpers/subscription.d.ts.map +1 -1
  6. package/build/helpers/subscription.js +10 -5
  7. package/build/helpers/subscription.js.map +1 -1
  8. package/build/index.d.ts.map +1 -1
  9. package/build/index.js +29 -10
  10. package/build/index.js.map +1 -1
  11. package/build/modules/ios.d.ts +4 -5
  12. package/build/modules/ios.d.ts.map +1 -1
  13. package/build/modules/ios.js +2 -3
  14. package/build/modules/ios.js.map +1 -1
  15. package/build/types/ExpoIapAndroid.types.d.ts +2 -2
  16. package/build/types/ExpoIapAndroid.types.d.ts.map +1 -1
  17. package/build/types/ExpoIapAndroid.types.js.map +1 -1
  18. package/build/types/ExpoIapIOS.types.d.ts +3 -3
  19. package/build/types/ExpoIapIOS.types.d.ts.map +1 -1
  20. package/build/types/ExpoIapIOS.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 +54 -33
  24. package/build/useIAP.js.map +1 -1
  25. package/build/utils/constants.d.ts +4 -0
  26. package/build/utils/constants.d.ts.map +1 -0
  27. package/build/utils/constants.js +12 -0
  28. package/build/utils/constants.js.map +1 -0
  29. package/ios/ExpoIap.podspec +1 -1
  30. package/ios/ExpoIapModule.swift +341 -334
  31. package/jest.config.js +19 -14
  32. package/package.json +2 -3
  33. package/plugin/build/withIAP.js +25 -23
  34. package/plugin/build/withLocalOpenIAP.js +5 -1
  35. package/plugin/src/withIAP.ts +39 -31
  36. package/plugin/src/withLocalOpenIAP.ts +8 -2
  37. package/plugin/tsconfig.tsbuildinfo +1 -1
  38. package/src/helpers/subscription.ts +35 -23
  39. package/src/index.ts +50 -33
  40. package/src/modules/ios.ts +4 -5
  41. package/src/types/ExpoIapAndroid.types.ts +4 -3
  42. package/src/types/ExpoIapIOS.types.ts +3 -4
  43. package/src/useIAP.ts +73 -52
  44. package/src/utils/constants.ts +14 -0
  45. package/ios/ProductStore.swift +0 -27
  46. package/ios/Types.swift +0 -96
@@ -1,12 +1,18 @@
1
1
  import ExpoModulesCore
2
+ import StoreKit
2
3
  import OpenIAP
4
+ import OSLog
3
5
 
4
- func logDebug(_ message: String) {
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
5
10
  #if DEBUG
6
- print("DEBUG - \(message)")
11
+ iapLogger.debug("\(message, privacy: .public)")
7
12
  #endif
8
13
  }
9
14
 
15
+ // Event names
10
16
  struct OpenIapEvent {
11
17
  static let PurchaseUpdated = "purchase-updated"
12
18
  static let PurchaseError = "purchase-error"
@@ -16,183 +22,355 @@ struct OpenIapEvent {
16
22
  @available(iOS 15.0, tvOS 15.0, *)
17
23
  @MainActor
18
24
  public class ExpoIapModule: Module {
19
- private let iapModule = OpenIapModule.shared
20
- private var hasListeners = false
25
+ // Subscriptions for OpenIapModule event listeners
26
+ private var purchaseUpdatedSub: Subscription?
27
+ private var purchaseErrorSub: Subscription?
28
+ private var promotedProductSub: Subscription?
29
+
30
+ // Helper to safely remove a listener and nil out the reference
31
+ private func removeListener(_ sub: inout Subscription?) {
32
+ if let s = sub { OpenIapModule.shared.removeListener(s) }
33
+ sub = nil
34
+ }
21
35
 
22
- public func definition() -> ModuleDefinition {
36
+ nonisolated public func definition() -> ModuleDefinition {
23
37
  Name("ExpoIap")
24
38
 
25
- // Export native constants for error code mapping
26
- Constants([
27
- "ERROR_CODES": IapErrorCode.toDictionary()
28
- ])
39
+ Constants {
40
+ OpenIapSerialization.errorCodes()
41
+ }
42
+
43
+ Events(
44
+ OpenIapEvent.PurchaseUpdated,
45
+ OpenIapEvent.PurchaseError,
46
+ OpenIapEvent.PromotedProductIOS
47
+ )
29
48
 
30
- Events(OpenIapEvent.PurchaseUpdated, OpenIapEvent.PurchaseError, OpenIapEvent.PromotedProductIOS)
49
+ OnCreate {
50
+ logDebug("Module created")
51
+ Task { @MainActor in
52
+ self.setupStore()
53
+ }
54
+ }
55
+
56
+ OnDestroy {
57
+ logDebug("Module destroyed")
58
+ Task { @MainActor in
59
+ await self.cleanupStore()
60
+ }
61
+ }
62
+
63
+ // MARK: - Connection Management
31
64
 
32
65
  AsyncFunction("initConnection") { () async throws -> Bool in
33
66
  logDebug("initConnection called")
34
-
35
- if !self.hasListeners {
36
- self.setupPurchaseListeners()
37
- self.hasListeners = true
38
- }
39
-
40
- return try await self.iapModule.initConnection()
67
+ let isConnected = try await OpenIapModule.shared.initConnection()
68
+ logDebug("Connection initialized: \(isConnected)")
69
+ return isConnected
41
70
  }
42
71
 
43
72
  AsyncFunction("endConnection") { () async throws -> Bool in
44
73
  logDebug("endConnection called")
74
+ let _ = try await OpenIapModule.shared.endConnection()
45
75
 
46
- if self.hasListeners {
47
- // OpenIAP now exposes unified listener management
48
- await self.iapModule.removeAllListeners()
49
- self.hasListeners = false
50
- }
51
-
52
- return try await self.iapModule.endConnection()
76
+ logDebug("Connection ended")
77
+ return true
53
78
  }
54
79
 
55
- AsyncFunction("fetchProducts") { (skus: [String]) async throws -> [[String: Any?]] in
56
- logDebug("fetchProducts called with skus: \(skus)")
57
- // Use ProductRequest for OpenIAP PR #3
58
- let request = ProductRequest(skus: skus, type: "all")
59
- let products = try await self.iapModule.fetchProducts(request)
80
+ // MARK: - Product Management
81
+
82
+ AsyncFunction("fetchProducts") { (params: [String: Any]) async throws -> [[String: Any?]] in
83
+ logDebug("fetchProducts raw params: \(params)")
60
84
 
61
- // Debug logging
62
- for product in products {
63
- logDebug("Product: id=\(product.id), title=\(product.title), description=\(product.description)")
64
- logDebug("Product: price=\(product.price ?? 0), displayPrice=\(product.displayPrice), currency=\(product.currency)")
65
- logDebug("Product: type=\(product.type), platform=\(product.platform)")
85
+ // Handle both object format {skus: [...], type: "..."} and array format
86
+ var skus: [String] = []
87
+ var typeString = "all"
88
+
89
+ if let skusArray = params["skus"] as? [String] {
90
+ // Object format: {skus: [...], type: "..."}
91
+ skus = skusArray
92
+ typeString = params["type"] as? String ?? "all"
93
+ } else {
94
+ // Array format passed directly - reconstruct from indexed keys
95
+ var tempSkus: [String] = []
96
+ var index = 0
97
+ while let sku = params["\(index)"] as? String {
98
+ tempSkus.append(sku)
99
+ index += 1
100
+ }
101
+ skus = tempSkus
66
102
  }
67
103
 
68
- let serializedProducts = products.map { self.serializeProduct($0) }
69
- logDebug("Serialized products: \(serializedProducts)")
70
- return serializedProducts
71
- }
72
-
73
- AsyncFunction("getAvailableItems") {
74
- (alsoPublishToEventListenerIOS: Bool?, onlyIncludeActiveItemsIOS: Bool?) async throws -> [[String: Any?]] in
75
- logDebug("getAvailableItems called")
76
- // Use PurchaseOptions for OpenIAP PR #3
77
- let options = PurchaseOptions(
78
- alsoPublishToEventListenerIOS: alsoPublishToEventListenerIOS,
79
- onlyIncludeActiveItemsIOS: onlyIncludeActiveItemsIOS
80
- )
81
- let transactions = try await self.iapModule.getAvailablePurchases(options)
82
- return transactions.map { self.serializePurchase($0) }
104
+ logDebug("fetchProducts parsed - skus: \(skus), type: \(typeString)")
105
+ logDebug("SKUs count: \(skus.count)")
106
+
107
+ // Validate SKUs
108
+ guard !skus.isEmpty else {
109
+ logDebug("ERROR: Empty SKUs array!")
110
+ throw OpenIapError.purchaseFailed(reason: "Empty SKU list provided")
111
+ }
112
+
113
+ // Convert string to OpenIapRequestProductType enum
114
+ let productType: OpenIapRequestProductType = {
115
+ switch typeString {
116
+ case "inapp":
117
+ return .inapp
118
+ case "subs":
119
+ return .subs
120
+ default:
121
+ return .all
122
+ }
123
+ }()
124
+
125
+ logDebug("Converted type to OpenIapRequestProductType: \(productType)")
126
+
127
+ // Build OpenIapProductRequest and fetch via OpenIapModule
128
+ let request = OpenIapProductRequest(skus: skus, type: productType)
129
+ let products = try await OpenIapModule.shared.fetchProducts(request)
130
+ logDebug("Fetched \(products.count) products from store")
131
+ if products.isEmpty {
132
+ logDebug("No products found. Possible reasons:")
133
+ logDebug("1. Products not configured in App Store Connect")
134
+ logDebug("2. Bundle ID mismatch")
135
+ logDebug("3. Not signed in to sandbox account")
136
+ logDebug("4. Products pending review")
137
+ }
138
+ for product in products {
139
+ logDebug("Product: \(product.id) - \(product.title) - \(product.displayPrice)")
140
+ }
141
+ return OpenIapSerialization.products(products)
83
142
  }
84
143
 
85
- AsyncFunction("requestPurchase") {
86
- (sku: String,
87
- andDangerouslyFinishTransactionAutomaticallyIOS: Bool?,
88
- appAccountToken: String?,
89
- quantity: Int?,
90
- discountOffer: [String: String]?) async throws -> [String: Any?]? in
91
-
92
- logDebug("requestPurchase called for sku: \(sku)")
144
+ // MARK: - Purchase Operations
145
+
146
+ AsyncFunction("requestPurchase") { (params: [String: Any]) async throws in
147
+ // Extract and validate required fields
148
+ guard let sku = params["sku"] as? String, !sku.isEmpty else {
149
+ throw OpenIapError.purchaseFailed(reason: "Missing required 'sku'")
150
+ }
151
+
152
+ // Optional fields
153
+ let andFinish = (params["andDangerouslyFinishTransactionAutomatically"] as? Bool) ?? false
154
+ let appAccountToken = params["appAccountToken"] as? String
155
+ let quantity: Int? = {
156
+ if let q = params["quantity"] as? Int { return q }
157
+ if let qd = params["quantity"] as? Double { return Int(qd) }
158
+ return nil
159
+ }()
160
+
161
+ // Discount offer mapping (strings expected from JS)
162
+ // Use OpenIapDiscountOffer from OpenIAP package (avoid relying on legacy typealiases)
163
+ var discountOffer: OpenIapDiscountOffer? = nil
164
+ if let offer = params["withOffer"] as? [String: Any] {
165
+ let identifier = (offer["identifier"] as? String) ?? (offer["id"] as? String) ?? ""
166
+ let keyIdentifier = (offer["keyIdentifier"] as? String) ?? ""
167
+ let nonce = (offer["nonce"] as? String) ?? ""
168
+ let signature = (offer["signature"] as? String) ?? ""
169
+ let timestamp = (offer["timestamp"] as? String) ?? ""
170
+ if !identifier.isEmpty && !keyIdentifier.isEmpty && !nonce.isEmpty && !signature.isEmpty && !timestamp.isEmpty {
171
+ discountOffer = OpenIapDiscountOffer(
172
+ identifier: identifier,
173
+ keyIdentifier: keyIdentifier,
174
+ nonce: nonce,
175
+ signature: signature,
176
+ timestamp: timestamp
177
+ )
178
+ }
179
+ }
180
+
181
+ let tokenForLog = appAccountToken ?? "nil"
182
+ let qtyForLog = quantity ?? -1
183
+ logDebug("requestPurchase parsed - sku: \(sku), andFinish: \(andFinish), appAccountToken: \(tokenForLog), quantity: \(qtyForLog), hasOffer: \(discountOffer != nil)")
93
184
 
94
- let finishAutomatically = andDangerouslyFinishTransactionAutomaticallyIOS ?? false
95
- let qty = quantity ?? 1
96
185
 
97
- // Use RequestPurchaseProps for OpenIAP PR #3
98
- let props = RequestPurchaseProps(
186
+ // Build purchase request props using OpenIapRequestPurchaseProps
187
+ let requestProps = OpenIapRequestPurchaseProps(
99
188
  sku: sku,
100
- andDangerouslyFinishTransactionAutomatically: finishAutomatically,
189
+ andDangerouslyFinishTransactionAutomatically: andFinish,
101
190
  appAccountToken: appAccountToken,
102
- quantity: qty,
103
- discountOffer: discountOffer
191
+ quantity: quantity,
192
+ withOffer: discountOffer
104
193
  )
105
- let purchase = try await self.iapModule.requestPurchase(props)
106
- return self.serializePurchase(purchase)
194
+
195
+ do {
196
+ _ = try await OpenIapModule.shared.requestPurchase(requestProps)
197
+ logDebug("Purchase request completed successfully")
198
+ } catch {
199
+ logDebug("Purchase request failed with error: \(error)")
200
+ throw error
201
+ }
107
202
  }
108
203
 
109
- AsyncFunction("finishTransaction") { (transactionIdentifier: String) async throws -> Bool in
110
- logDebug("finishTransaction called for id: \(transactionIdentifier)")
111
- return try await self.iapModule.finishTransaction(transactionIdentifier: transactionIdentifier)
204
+ AsyncFunction("finishTransaction") { (transactionId: String) async throws -> Bool in
205
+ logDebug("finishTransaction called with id: \(transactionId)")
206
+ let result = try await OpenIapModule.shared.finishTransaction(transactionIdentifier: transactionId)
207
+ return result
208
+ }
209
+
210
+ // MARK: - Purchase History
211
+
212
+ AsyncFunction("getAvailablePurchases") { (options: [String: Any?]?) async throws -> [[String: Any?]] in
213
+ logDebug("getAvailablePurchases called")
214
+
215
+ // Build options and get purchases directly from OpenIapModule
216
+ let purchaseOptions: OpenIapGetAvailablePurchasesProps? = options.map {
217
+ OpenIapGetAvailablePurchasesProps(
218
+ alsoPublishToEventListenerIOS: $0["alsoPublishToEventListenerIOS"] as? Bool,
219
+ onlyIncludeActiveItemsIOS: $0["onlyIncludeActiveItemsIOS"] as? Bool
220
+ )
221
+ }
222
+ let purchases = try await OpenIapModule.shared.getAvailablePurchases(purchaseOptions)
223
+ return OpenIapSerialization.purchases(purchases)
224
+ }
225
+
226
+ // Legacy function for backward compatibility
227
+ AsyncFunction("getAvailableItems") { (alsoPublishToEventListener: Bool, onlyIncludeActiveItems: Bool) async throws -> [[String: Any?]] in
228
+ logDebug("getAvailableItems called (legacy)")
229
+
230
+ let purchaseOptions = OpenIapGetAvailablePurchasesProps(
231
+ alsoPublishToEventListenerIOS: alsoPublishToEventListener,
232
+ onlyIncludeActiveItemsIOS: onlyIncludeActiveItems
233
+ )
234
+ let purchases = try await OpenIapModule.shared.getAvailablePurchases(purchaseOptions)
235
+ return OpenIapSerialization.purchases(purchases)
112
236
  }
113
237
 
114
238
  AsyncFunction("getPendingTransactionsIOS") { () async throws -> [[String: Any?]] in
115
239
  logDebug("getPendingTransactionsIOS called")
116
- let transactions = try await self.iapModule.getPendingTransactionsIOS()
117
- return transactions.map { self.serializePurchase($0) }
240
+
241
+ let pendingTransactions = try await OpenIapModule.shared.getPendingTransactionsIOS()
242
+ return OpenIapSerialization.purchases(pendingTransactions)
118
243
  }
119
244
 
120
- AsyncFunction("clearTransactionIOS") { () async throws in
245
+ AsyncFunction("clearTransactionIOS") { () async throws -> Bool in
121
246
  logDebug("clearTransactionIOS called")
122
- try await self.iapModule.clearTransactionIOS()
247
+ try await OpenIapModule.shared.clearTransactionIOS()
248
+ return true
123
249
  }
124
250
 
125
- AsyncFunction("getReceiptDataIOS") { () async throws -> String? in
126
- logDebug("getReceiptDataIOS called")
127
- return try await self.iapModule.getReceiptDataIOS()
251
+ // MARK: - Receipt & Validation
252
+
253
+ AsyncFunction("getReceiptIOS") { () async throws -> String in
254
+ logDebug("getReceiptIOS called")
255
+ return try await OpenIapModule.shared.getReceiptDataIOS() ?? ""
128
256
  }
129
257
 
130
- AsyncFunction("getTransactionJwsIOS") { (sku: String) async throws -> String? in
131
- logDebug("getTransactionJwsIOS called for sku: \(sku)")
132
- return try await self.iapModule.getTransactionJwsIOS(sku: sku)
258
+ AsyncFunction("requestReceiptRefreshIOS") { () async throws -> String in
259
+ logDebug("requestReceiptRefreshIOS called")
260
+ // Receipt refresh is handled automatically by StoreKit 2
261
+ return try await OpenIapModule.shared.getReceiptDataIOS() ?? ""
133
262
  }
134
263
 
135
264
  AsyncFunction("validateReceiptIOS") { (sku: String) async throws -> [String: Any?] in
136
265
  logDebug("validateReceiptIOS called for sku: \(sku)")
137
- // Use ReceiptValidationProps for OpenIAP PR #3
138
- let props = ReceiptValidationProps(sku: sku)
139
- let validation = try await self.iapModule.validateReceiptIOS(props)
140
- var result: [String: Any?] = [
141
- "isValid": validation.isValid,
142
- "receiptData": validation.receiptData,
143
- "jwsRepresentation": validation.jwsRepresentation
266
+
267
+ // Use OpenIapReceiptValidationProps to keep naming parity with OpenIAP
268
+ let props = OpenIapReceiptValidationProps(sku: sku)
269
+ let result = try await OpenIapModule.shared.validateReceiptIOS(props)
270
+
271
+ return [
272
+ "isValid": result.isValid,
273
+ "receiptData": result.receiptData,
274
+ "jwsRepresentation": result.jwsRepresentation,
275
+ // Populate unified purchaseToken for iOS as alias of JWS
276
+ "purchaseToken": result.jwsRepresentation,
277
+ "latestTransaction": result.latestTransaction.map { OpenIapSerialization.purchase($0) },
144
278
  ]
145
- if let latest = validation.latestTransaction {
146
- result["latestTransaction"] = self.serializePurchase(latest)
147
- }
148
- return result
149
279
  }
150
280
 
151
- AsyncFunction("getStorefrontIOS") { () async throws -> String in
152
- logDebug("getStorefrontIOS called")
153
- return try await self.iapModule.getStorefrontIOS()
281
+ // MARK: - iOS Specific Features
282
+
283
+ AsyncFunction("presentCodeRedemptionSheetIOS") { () async throws -> Bool in
284
+ logDebug("presentCodeRedemptionSheetIOS called")
285
+ let _ = try await OpenIapModule.shared.presentCodeRedemptionSheetIOS()
286
+ return true
154
287
  }
155
288
 
156
- // Deprecated: Keep for backward compatibility
157
- AsyncFunction("getStorefront") { () async throws -> String in
158
- logDebug("getStorefront called (deprecated)")
159
- return try await self.iapModule.getStorefrontIOS()
289
+ AsyncFunction("showManageSubscriptionsIOS") { () async throws -> Bool in
290
+ logDebug("showManageSubscriptionsIOS called")
291
+ let _ = try await OpenIapModule.shared.showManageSubscriptionsIOS()
292
+ return true
160
293
  }
161
294
 
162
- AsyncFunction("getAppTransactionIOS") { () async throws -> [String: Any?]? in
163
- logDebug("getAppTransactionIOS called")
164
- if #available(iOS 16.0, tvOS 16.0, *) {
165
- if let appTransaction = try await self.iapModule.getAppTransactionIOS() {
166
- return [
167
- "appVersion": appTransaction.appVersion,
168
- "originalAppVersion": appTransaction.originalAppVersion,
169
- "originalPurchaseDate": appTransaction.originalPurchaseDate.timeIntervalSince1970 * 1000,
170
- "deviceVerification": appTransaction.deviceVerification,
171
- "deviceVerificationNonce": appTransaction.deviceVerificationNonce,
172
- "preorderDate": appTransaction.preorderDate.map { $0.timeIntervalSince1970 * 1000 },
173
- "jwsRepresentation": nil // Not available in IapAppTransaction
174
- ]
295
+ AsyncFunction("deepLinkToSubscriptionsIOS") { () async throws in
296
+ logDebug("deepLinkToSubscriptionsIOS called")
297
+ // Open App Store subscriptions page directly
298
+ if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
299
+ #if canImport(UIKit)
300
+ await MainActor.run {
301
+ UIApplication.shared.open(url, options: [:], completionHandler: nil)
175
302
  }
303
+ #endif
304
+ }
305
+ }
306
+
307
+ AsyncFunction("beginRefundRequestIOS") { (sku: String) async throws -> String? in
308
+ logDebug("beginRefundRequestIOS called for sku: \(sku)")
309
+ return try await OpenIapModule.shared.beginRefundRequestIOS(sku: sku)
310
+ }
311
+
312
+ AsyncFunction("getPromotedProductIOS") { () async throws -> [String: Any?]? in
313
+ logDebug("getPromotedProductIOS called")
314
+
315
+ if let promotedProduct = try await OpenIapModule.shared.getPromotedProductIOS() {
316
+ return [
317
+ "productIdentifier": promotedProduct.productIdentifier,
318
+ "localizedTitle": promotedProduct.localizedTitle,
319
+ "localizedDescription": promotedProduct.localizedDescription,
320
+ "price": promotedProduct.price,
321
+ "priceLocale": [
322
+ "currencyCode": promotedProduct.priceLocale.currencyCode,
323
+ "currencySymbol": promotedProduct.priceLocale.currencySymbol
324
+ ]
325
+ ]
176
326
  }
177
327
  return nil
178
328
  }
179
329
 
180
- AsyncFunction("isEligibleForIntroOfferIOS") { (groupID: String) async -> Bool in
330
+ AsyncFunction("buyPromotedProductIOS") { () async throws in
331
+ logDebug("buyPromotedProductIOS called")
332
+ try await OpenIapModule.shared.requestPurchaseOnPromotedProductIOS()
333
+ }
334
+
335
+ AsyncFunction("getStorefrontIOS") { () async throws -> String in
336
+ logDebug("getStorefrontIOS called")
337
+ return try await OpenIapModule.shared.getStorefrontIOS()
338
+ }
339
+
340
+ AsyncFunction("syncIOS") { () async throws -> Bool in
341
+ logDebug("syncIOS called")
342
+ return try await OpenIapModule.shared.syncIOS()
343
+ }
344
+
345
+ // MARK: - Additional iOS Methods
346
+
347
+ AsyncFunction("isTransactionVerifiedIOS") { (sku: String) async throws -> Bool in
348
+ logDebug("isTransactionVerifiedIOS called for sku: \(sku)")
349
+ return await OpenIapModule.shared.isTransactionVerifiedIOS(sku: sku)
350
+ }
351
+
352
+ AsyncFunction("getTransactionJwsIOS") { (sku: String) async throws -> String? in
353
+ logDebug("getTransactionJwsIOS called for sku: \(sku)")
354
+ return try await OpenIapModule.shared.getTransactionJwsIOS(sku: sku)
355
+ }
356
+
357
+ AsyncFunction("isEligibleForIntroOfferIOS") { (groupID: String) async throws -> Bool in
181
358
  logDebug("isEligibleForIntroOfferIOS called for groupID: \(groupID)")
182
- return await self.iapModule.isEligibleForIntroOfferIOS(groupID: groupID)
359
+ return await OpenIapModule.shared.isEligibleForIntroOfferIOS(groupID: groupID)
183
360
  }
184
361
 
185
362
  AsyncFunction("subscriptionStatusIOS") { (sku: String) async throws -> [[String: Any?]]? in
186
363
  logDebug("subscriptionStatusIOS called for sku: \(sku)")
187
- if let statuses = try await self.iapModule.subscriptionStatusIOS(sku: sku) {
364
+
365
+ if let statuses = try await OpenIapModule.shared.subscriptionStatusIOS(sku: sku) {
188
366
  return statuses.map { status in
189
- [
367
+ return [
190
368
  "state": status.state,
191
- "renewalInfo": status.renewalInfo != nil ? [
192
- "autoRenewPreference": status.renewalInfo!.autoRenewPreference as Any,
193
- "expirationReason": status.renewalInfo!.expirationReason as Any,
194
- "gracePeriodExpirationDate": status.renewalInfo!.gracePeriodExpirationDate.map { $0.timeIntervalSince1970 * 1000 } as Any
195
- ] : nil
369
+ "autoRenewStatus": status.renewalInfo?.autoRenewStatus,
370
+ "autoRenewPreference": status.renewalInfo?.autoRenewPreference,
371
+ "expirationReason": status.renewalInfo?.expirationReason,
372
+ "currentProductID": status.renewalInfo?.currentProductID,
373
+ "gracePeriodExpirationDate": status.renewalInfo?.gracePeriodExpirationDate
196
374
  ]
197
375
  }
198
376
  }
@@ -201,240 +379,69 @@ public class ExpoIapModule: Module {
201
379
 
202
380
  AsyncFunction("currentEntitlementIOS") { (sku: String) async throws -> [String: Any?]? in
203
381
  logDebug("currentEntitlementIOS called for sku: \(sku)")
204
- if let transaction = try await self.iapModule.currentEntitlementIOS(sku: sku) {
205
- return self.serializePurchase(transaction)
382
+
383
+ if let entitlement = try await OpenIapModule.shared.currentEntitlementIOS(sku: sku) {
384
+ return OpenIapSerialization.purchase(entitlement)
206
385
  }
207
386
  return nil
208
387
  }
209
388
 
210
389
  AsyncFunction("latestTransactionIOS") { (sku: String) async throws -> [String: Any?]? in
211
390
  logDebug("latestTransactionIOS called for sku: \(sku)")
212
- if let transaction = try await self.iapModule.latestTransactionIOS(sku: sku) {
213
- return self.serializePurchase(transaction)
391
+
392
+ if let transaction = try await OpenIapModule.shared.latestTransactionIOS(sku: sku) {
393
+ return OpenIapSerialization.purchase(transaction)
214
394
  }
215
395
  return nil
216
396
  }
397
+ }
398
+
399
+ // MARK: - Listeners Setup
400
+
401
+ @MainActor
402
+ private func setupStore() {
403
+ logDebug("Setting up OpenIapModule event listeners")
217
404
 
218
- AsyncFunction("beginRefundRequestIOS") { (sku: String) async throws -> String? in
219
- logDebug("beginRefundRequestIOS called for sku: \(sku)")
220
- return try await self.iapModule.beginRefundRequestIOS(sku: sku)
405
+ purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { [weak self] purchase in
406
+ Task { @MainActor in
407
+ guard let self else { return }
408
+ logDebug("✅ Purchase success callback - sending event")
409
+ let purchaseData = OpenIapSerialization.purchase(purchase)
410
+ self.sendEvent(OpenIapEvent.PurchaseUpdated, purchaseData)
411
+ }
221
412
  }
222
413
 
223
- AsyncFunction("getPromotedProductIOS") { () async throws -> [String: Any?]? in
224
- logDebug("getPromotedProductIOS called")
225
- if let promotedProduct = try await self.iapModule.getPromotedProductIOS() {
226
- return [
227
- "productId": promotedProduct.productIdentifier,
228
- "paymentDiscount": nil
414
+ purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { [weak self] error in
415
+ Task { @MainActor in
416
+ guard let self else { return }
417
+ logDebug("❌ Purchase error callback - sending error event")
418
+ // Use OpenIapPurchaseError alias for clarity/parity
419
+ let err: OpenIapPurchaseError = error
420
+ let errorData: [String: Any?] = [
421
+ "code": err.code,
422
+ "message": err.message,
423
+ "productId": err.productId,
229
424
  ]
425
+ self.sendEvent(OpenIapEvent.PurchaseError, errorData)
230
426
  }
231
- return nil
232
- }
233
-
234
- AsyncFunction("requestPurchaseOnPromotedProductIOS") { () async throws in
235
- logDebug("requestPurchaseOnPromotedProductIOS called")
236
- try await self.iapModule.requestPurchaseOnPromotedProductIOS()
237
- }
238
-
239
- AsyncFunction("syncIOS") { () async throws -> Bool in
240
- logDebug("syncIOS called")
241
- return try await self.iapModule.syncIOS()
242
- }
243
-
244
- AsyncFunction("presentCodeRedemptionSheetIOS") { () async throws -> Bool in
245
- logDebug("presentCodeRedemptionSheetIOS called")
246
- return try await self.iapModule.presentCodeRedemptionSheetIOS()
247
427
  }
248
428
 
249
- AsyncFunction("showManageSubscriptionsIOS") { () async throws -> Bool in
250
- logDebug("showManageSubscriptionsIOS called")
251
- return try await self.iapModule.showManageSubscriptionsIOS()
252
- }
253
-
254
- AsyncFunction("isTransactionVerifiedIOS") { (sku: String) async -> Bool in
255
- logDebug("isTransactionVerifiedIOS called for sku: \(sku)")
256
- return await self.iapModule.isTransactionVerifiedIOS(sku: sku)
257
- }
258
- }
259
-
260
- // MARK: - Purchase Listeners
261
-
262
- private func setupPurchaseListeners() {
263
- _ = iapModule.purchaseUpdatedListener { [weak self] purchase in
264
- self?.handlePurchaseUpdated(purchase)
265
- }
266
- _ = iapModule.purchaseErrorListener { [weak self] error in
267
- self?.handlePurchaseError(error)
429
+ promotedProductSub = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in
430
+ Task { @MainActor in
431
+ guard let self else { return }
432
+ logDebug("📱 Promoted product callback - sending event for: \(productId)")
433
+ self.sendEvent(OpenIapEvent.PromotedProductIOS, ["productId": productId])
434
+ }
268
435
  }
269
436
  }
270
437
 
271
- private func handlePurchaseUpdated(_ purchase: OpenIapPurchase) {
272
- logDebug("Purchase updated: \(purchase.productId)")
273
- let serialized = serializePurchase(purchase)
274
- sendEvent(OpenIapEvent.PurchaseUpdated, serialized)
275
- }
276
-
277
- private func handlePurchaseError(_ error: PurchaseError) {
278
- logDebug("Purchase error: \(error)")
279
- let serialized: [String: Any?] = [
280
- "code": error.code,
281
- "message": error.message,
282
- "productId": error.productId as Any
283
- ]
284
- sendEvent(OpenIapEvent.PurchaseError, serialized)
285
- }
286
-
287
- // MARK: - Serialization Helpers
288
-
289
- private func serializePurchase(_ purchase: OpenIapPurchase) -> [String: Any?] {
290
- return [
291
- // PurchaseCommon required fields
292
- "id": purchase.id,
293
- "productId": purchase.productId,
294
- "transactionDate": purchase.transactionDate,
295
- "transactionReceipt": purchase.transactionReceipt,
296
- "purchaseToken": purchase.purchaseToken,
297
- "platform": purchase.platform,
298
-
299
- // PurchaseCommon optional fields
300
- "ids": purchase.ids,
301
- "transactionId": purchase.id, // deprecated but kept for backward compatibility
302
- "quantity": purchase.quantity,
303
- "purchaseState": purchase.purchaseState.rawValue,
304
- "isAutoRenewing": purchase.isAutoRenewing,
305
-
306
- // PurchaseIOS specific fields
307
- "quantityIOS": purchase.quantityIOS,
308
- "originalTransactionDateIOS": purchase.originalTransactionDateIOS,
309
- "originalTransactionIdentifierIOS": purchase.originalTransactionIdentifierIOS,
310
- "appAccountToken": purchase.appAccountToken,
311
-
312
- // Additional iOS fields from StoreKit 2
313
- "expirationDateIOS": purchase.expirationDateIOS,
314
- "webOrderLineItemIdIOS": purchase.webOrderLineItemIdIOS,
315
- "environmentIOS": purchase.environmentIOS,
316
- "storefrontCountryCodeIOS": purchase.storefrontCountryCodeIOS,
317
- "appBundleIdIOS": purchase.appBundleIdIOS,
318
- "productTypeIOS": purchase.productTypeIOS,
319
- "subscriptionGroupIdIOS": purchase.subscriptionGroupIdIOS,
320
- "isUpgradedIOS": purchase.isUpgradedIOS,
321
- "ownershipTypeIOS": purchase.ownershipTypeIOS,
322
- "reasonIOS": purchase.reasonIOS,
323
- "reasonStringRepresentationIOS": purchase.reasonStringRepresentationIOS,
324
- "transactionReasonIOS": purchase.transactionReasonIOS,
325
- "revocationDateIOS": purchase.revocationDateIOS,
326
- "revocationReasonIOS": purchase.revocationReasonIOS,
327
-
328
- // Offer information
329
- "offerIOS": purchase.offerIOS != nil ? [
330
- "id": purchase.offerIOS!.id,
331
- "type": purchase.offerIOS!.type,
332
- "paymentMode": purchase.offerIOS!.paymentMode
333
- ] : nil,
334
-
335
- // Price locale fields
336
- "currencyCodeIOS": purchase.currencyCodeIOS,
337
- "currencySymbolIOS": purchase.currencySymbolIOS,
338
- "countryCodeIOS": purchase.countryCodeIOS
339
- ]
340
- }
341
-
342
- private func serializeProduct(_ product: OpenIapProduct) -> [String: Any?] {
343
- var result: [String: Any?] = [
344
- // Common fields (required by ProductCommon)
345
- "id": product.id,
346
- "title": product.title,
347
- "description": product.description,
348
- "type": product.type,
349
- "displayName": product.displayName,
350
- "displayPrice": product.displayPrice,
351
- "currency": product.currency,
352
- "price": product.price,
353
- "debugDescription": product.debugDescription,
354
- "platform": product.platform,
355
-
356
- // iOS-specific fields (required by ProductIOS)
357
- "displayNameIOS": product.displayNameIOS,
358
- "isFamilyShareableIOS": product.isFamilyShareableIOS,
359
- "jsonRepresentationIOS": product.jsonRepresentationIOS,
360
- "typeIOS": product.typeIOS.rawValue,
361
-
362
- // Additional iOS-specific fields
363
- "descriptionIOS": product.description,
364
- "displayPriceIOS": product.displayPrice,
365
- "priceIOS": product.price,
366
-
367
- // ProductSubscriptionIOS specific fields
368
- "discountsIOS": product.discountsIOS?.map { discount in
369
- [
370
- "identifier": discount.identifier,
371
- "type": discount.type,
372
- "numberOfPeriods": discount.numberOfPeriods,
373
- "price": discount.price,
374
- "priceAmount": discount.priceAmount,
375
- "paymentMode": discount.paymentMode,
376
- "subscriptionPeriod": discount.subscriptionPeriod
377
- ]
378
- },
379
- "introductoryPriceIOS": product.introductoryPriceIOS,
380
- "introductoryPriceAsAmountIOS": product.introductoryPriceAsAmountIOS,
381
- "introductoryPricePaymentModeIOS": product.introductoryPricePaymentModeIOS,
382
- "introductoryPriceNumberOfPeriodsIOS": product.introductoryPriceNumberOfPeriodsIOS,
383
- "introductoryPriceSubscriptionPeriodIOS": product.introductoryPriceSubscriptionPeriodIOS,
384
- "subscriptionPeriodNumberIOS": product.subscriptionPeriodNumberIOS,
385
- "subscriptionPeriodUnitIOS": product.subscriptionPeriodUnitIOS
386
- ]
387
-
388
- // Add subscriptionInfoIOS if available
389
- if let subInfo = product.subscriptionInfoIOS {
390
- var subInfoDict: [String: Any?] = [
391
- "subscriptionGroupId": subInfo.subscriptionGroupId,
392
- "subscriptionPeriod": [
393
- "unit": subInfo.subscriptionPeriod.unit.rawValue,
394
- "value": subInfo.subscriptionPeriod.value
395
- ]
396
- ]
397
-
398
- if let intro = subInfo.introductoryOffer {
399
- subInfoDict["introductoryOffer"] = [
400
- "displayPrice": intro.displayPrice,
401
- "id": intro.id,
402
- "paymentMode": intro.paymentMode.rawValue,
403
- "period": [
404
- "unit": intro.period.unit.rawValue,
405
- "value": intro.period.value
406
- ],
407
- "periodCount": intro.periodCount,
408
- "price": intro.price,
409
- "type": intro.type.rawValue
410
- ]
411
- }
412
-
413
- if let promos = subInfo.promotionalOffers {
414
- subInfoDict["promotionalOffers"] = promos.map { offer in
415
- [
416
- "displayPrice": offer.displayPrice,
417
- "id": offer.id,
418
- "paymentMode": offer.paymentMode.rawValue,
419
- "period": [
420
- "unit": offer.period.unit.rawValue,
421
- "value": offer.period.value
422
- ],
423
- "periodCount": offer.periodCount,
424
- "price": offer.price,
425
- "type": offer.type.rawValue
426
- ]
427
- }
428
- }
429
-
430
- result["subscriptionInfoIOS"] = subInfoDict
431
- }
432
-
433
- // Deprecated fields for backward compatibility
434
- result["isFamilyShareable"] = product.isFamilyShareableIOS
435
- result["jsonRepresentation"] = product.jsonRepresentationIOS
436
-
437
- return result
438
+ @MainActor
439
+ private func cleanupStore() async {
440
+ logDebug("Cleaning up listeners and ending connection")
441
+ removeListener(&purchaseUpdatedSub)
442
+ removeListener(&purchaseErrorSub)
443
+ removeListener(&promotedProductSub)
444
+ _ = try? await OpenIapModule.shared.endConnection()
438
445
  }
439
446
 
440
447
  }