expo-iap 2.9.0-rc.2 → 2.9.0-rc.4

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