expo-iap 2.8.7 → 2.9.0-rc.2

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 (47) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/CLAUDE.md +7 -0
  3. package/CONTRIBUTING.md +3 -4
  4. package/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +7 -7
  5. package/android/src/main/java/expo/modules/iap/Types.kt +1 -1
  6. package/build/ExpoIap.types.d.ts +4 -4
  7. package/build/ExpoIap.types.d.ts.map +1 -1
  8. package/build/ExpoIap.types.js +3 -0
  9. package/build/ExpoIap.types.js.map +1 -1
  10. package/build/helpers/subscription.d.ts.map +1 -1
  11. package/build/helpers/subscription.js +3 -6
  12. package/build/helpers/subscription.js.map +1 -1
  13. package/build/index.d.ts +1 -1
  14. package/build/index.d.ts.map +1 -1
  15. package/build/index.js +14 -12
  16. package/build/index.js.map +1 -1
  17. package/build/modules/android.d.ts.map +1 -1
  18. package/build/modules/android.js.map +1 -1
  19. package/build/modules/ios.d.ts.map +1 -1
  20. package/build/modules/ios.js.map +1 -1
  21. package/build/types/ExpoIapAndroid.types.d.ts +2 -2
  22. package/build/types/ExpoIapAndroid.types.d.ts.map +1 -1
  23. package/build/types/ExpoIapAndroid.types.js.map +1 -1
  24. package/build/types/ExpoIapIOS.types.d.ts +3 -3
  25. package/build/types/ExpoIapIOS.types.d.ts.map +1 -1
  26. package/build/types/ExpoIapIOS.types.js.map +1 -1
  27. package/build/useIAP.d.ts.map +1 -1
  28. package/build/useIAP.js.map +1 -1
  29. package/ios/ExpoIap.podspec +1 -0
  30. package/ios/ExpoIapModule.swift +353 -1180
  31. package/jest.config.js +14 -17
  32. package/package.json +5 -3
  33. package/plugin/build/withIAP.d.ts +7 -1
  34. package/plugin/build/withIAP.js +16 -2
  35. package/plugin/build/withLocalOpenIAP.d.ts +9 -0
  36. package/plugin/build/withLocalOpenIAP.js +85 -0
  37. package/plugin/src/withIAP.ts +21 -2
  38. package/plugin/src/withLocalOpenIAP.ts +66 -0
  39. package/plugin/tsconfig.tsbuildinfo +1 -1
  40. package/src/ExpoIap.types.ts +5 -11
  41. package/src/helpers/subscription.ts +21 -28
  42. package/src/index.ts +27 -25
  43. package/src/modules/android.ts +7 -7
  44. package/src/modules/ios.ts +11 -5
  45. package/src/types/ExpoIapAndroid.types.ts +3 -4
  46. package/src/types/ExpoIapIOS.types.ts +4 -3
  47. package/src/useIAP.ts +10 -4
@@ -1,13 +1,5 @@
1
1
  import ExpoModulesCore
2
- import StoreKit
3
-
4
- func serializeDebug(_ s: String) -> String? {
5
- #if DEBUG
6
- return s
7
- #else
8
- return nil
9
- #endif
10
- }
2
+ import OpenIAP
11
3
 
12
4
  func logDebug(_ message: String) {
13
5
  #if DEBUG
@@ -15,1253 +7,434 @@ func logDebug(_ message: String) {
15
7
  #endif
16
8
  }
17
9
 
18
- struct IapEvent {
10
+ struct OpenIapEvent {
19
11
  static let PurchaseUpdated = "purchase-updated"
20
12
  static let PurchaseError = "purchase-error"
21
13
  static let PromotedProductIOS = "promoted-product-ios"
22
14
  }
23
15
 
24
- @available(iOS 15.0, *)
25
- func serializeTransaction(_ transaction: Transaction, jwsRepresentationIOS: String? = nil) -> [String: Any?] {
26
- let _ =
27
- transaction.productType.rawValue.lowercased().contains("renewable")
28
- || transaction.expirationDate != nil
29
-
30
- var transactionReasonIOS: String? = nil
31
- var webOrderLineItemId: Int? = nil
32
- var jsonData: [String: Any]? = nil
33
- var jwsReceipt: String = ""
34
-
35
- let jsonRep = transaction.jsonRepresentation
36
- jwsReceipt = String(data: jsonRep, encoding: .utf8) ?? ""
37
-
38
- do {
39
- if let jsonObj = try JSONSerialization.jsonObject(with: jsonRep) as? [String: Any] {
40
- jsonData = jsonObj
41
- transactionReasonIOS = jsonObj["transactionReason"] as? String
42
- if let webOrderId = jsonObj["webOrderLineItemID"] as? NSNumber {
43
- webOrderLineItemId = webOrderId.intValue
44
- }
45
- }
46
- } catch {
47
- print("Error parsing JSON representation: \(error)")
48
- }
49
-
50
- var purchaseMap: [String: Any?] = [
51
- "id": String(transaction.id),
52
- "productId": transaction.productID,
53
- "ids": [transaction.productID],
54
- "transactionId": String(transaction.id), // @deprecated - use id instead
55
- "transactionDate": transaction.purchaseDate.timeIntervalSince1970 * 1000,
56
- "transactionReceipt": jwsReceipt,
57
- "platform": "ios",
58
-
59
- "quantityIOS": transaction.purchasedQuantity,
60
- "originalTransactionDateIOS": transaction.originalPurchaseDate.timeIntervalSince1970 * 1000,
61
- "originalTransactionIdentifierIOS": String(transaction.originalID),
62
- "appAccountToken": transaction.appAccountToken?.uuidString,
63
-
64
- "appBundleIdIOS": transaction.appBundleID,
65
- "productTypeIOS": transaction.productType.rawValue,
66
- "subscriptionGroupIdIOS": transaction.subscriptionGroupID,
67
-
68
- "webOrderLineItemIdIOS": webOrderLineItemId,
69
-
70
- "expirationDateIOS": transaction.expirationDate.map { $0.timeIntervalSince1970 * 1000 },
71
-
72
- "isUpgradedIOS": transaction.isUpgraded,
73
- "ownershipTypeIOS": transaction.ownershipType.rawValue,
74
-
75
- "revocationDateIOS": transaction.revocationDate.map { $0.timeIntervalSince1970 * 1000 },
76
- "revocationReasonIOS": transaction.revocationReason?.rawValue,
77
- "transactionReasonIOS": transactionReasonIOS,
78
- ]
79
-
80
- if (jwsRepresentationIOS != nil) {
81
- logDebug("serializeTransaction adding jwsRepresentationIOS with length: \(jwsRepresentationIOS!.count)")
82
- purchaseMap["jwsRepresentationIOS"] = jwsRepresentationIOS
83
- purchaseMap["purchaseToken"] = jwsRepresentationIOS
84
- } else {
85
- logDebug("serializeTransaction jwsRepresentationIOS is nil")
86
- }
87
-
88
- if #available(iOS 16.0, *) {
89
- purchaseMap["environmentIOS"] = transaction.environment.rawValue
90
- }
91
-
92
- if #available(iOS 17.0, *) {
93
- purchaseMap["storefrontCountryCodeIOS"] = transaction.storefront.countryCode
94
- purchaseMap["reasonIOS"] = transaction.reason.rawValue
95
- }
96
-
97
- if #available(iOS 17.2, *) {
98
- if let offer = transaction.offer {
99
- purchaseMap["offerIOS"] = [
100
- "id": offer.id ?? "",
101
- "type": offer.type.rawValue,
102
- "paymentMode": offer.paymentMode?.rawValue ?? "",
103
- ]
104
- }
105
- }
106
-
107
- if #available(iOS 15.4, *), let jsonData = jsonData {
108
- if let price = jsonData["price"] as? NSNumber {
109
- // START: Deprecated - will be removed in v2.9.0
110
- // Use currencyCodeIOS, currencySymbolIOS, countryCodeIOS instead
111
- purchaseMap["priceIOS"] = price.doubleValue
112
- // END: Deprecated - will be removed in v2.9.0
113
- }
114
- if let currency = jsonData["currency"] as? String {
115
- purchaseMap["currencyCodeIOS"] = currency
116
-
117
- // Try to get currency symbol from locale
118
- let locale = Locale(identifier: Locale.identifier(fromComponents: [NSLocale.Key.currencyCode.rawValue: currency]))
119
- purchaseMap["currencySymbolIOS"] = locale.currencySymbol
120
-
121
- // START: Deprecated - will be removed in v2.9.0
122
- // Use currencyCodeIOS instead
123
- purchaseMap["currencyIOS"] = currency
124
- // END: Deprecated - will be removed in v2.9.0
125
- }
126
- // Extract country code from storefront if available
127
- if let storefront = jsonData["storefront"] as? String {
128
- purchaseMap["countryCodeIOS"] = storefront
129
- }
130
- }
131
-
132
- return purchaseMap
133
- }
134
-
135
- private let DEFAULT_SUBSCRIPTION_PERIOD_UNIT = "DAY" // Default fallback unit for subscription periods.
136
-
137
- func getPeriodIOS(_ unit: Product.SubscriptionPeriod.Unit) -> String {
138
- return switch (unit) {
139
- case .day: "DAY"
140
- case .week: "WEEK"
141
- case .month: "MONTH"
142
- case .year: "YEAR"
143
- @unknown default:
144
- fatalError("Unknown subscription period unit: \(unit)")
145
- }
146
- }
147
-
148
- func serializeOffer(_ offer: Product.SubscriptionOffer?) -> [String: Any?]? {
149
- guard let offer = offer else { return nil }
150
-
151
- return [
152
- "id": offer.id,
153
- "period": [
154
- "unit": getPeriodIOS(offer.period.unit),
155
- "value": offer.period.value
156
- ],
157
- "periodCount": offer.periodCount,
158
- "paymentMode": offer.paymentMode.rawValue,
159
- "type": offer.type.rawValue,
160
- "price": offer.price,
161
- "displayPrice": offer.displayPrice,
162
- ]
163
- }
164
-
165
- func serializeSubscription(_ s: Product.SubscriptionInfo?) -> [String: Any?]? {
166
- guard let s = s else { return nil }
167
- return [
168
- "introductoryOffer": serializeOffer(s.introductoryOffer),
169
- "promotionalOffers": s.promotionalOffers.map(serializeOffer),
170
- "subscriptionGroupId": s.subscriptionGroupID,
171
- "subscriptionPeriod": [
172
- "unit": getPeriodIOS(s.subscriptionPeriod.unit),
173
- "value": s.subscriptionPeriod.value
174
- ],
175
- ]
176
-
177
- }
178
-
179
- @available(iOS 15.0, *)
180
- func serializeProduct(_ p: Product) -> [String: Any?] {
181
- // Convert Product.ProductType to our expected 'inapp' or 'subs' string
182
- let productType: String = p.subscription != nil ? "subs" : "inapp"
183
-
184
- // For subscription products, add discounts and introductory price
185
- var discounts: [[String: Any?]]? = nil
186
- var introductoryPrice: String? = nil
187
- var introductoryPriceAsAmountIOS: String? = nil
188
- var introductoryPricePaymentModeIOS: String? = nil
189
- var introductoryPriceNumberOfPeriodsIOS: String? = nil
190
- var introductoryPriceSubscriptionPeriodIOS: String? = nil
191
- var subscriptionPeriodNumberIOS: String? = nil
192
- var subscriptionPeriodUnitIOS: String? = nil
193
-
194
- if let subscription = p.subscription {
195
- // Extract discount information from promotional offers
196
- if !subscription.promotionalOffers.isEmpty {
197
- discounts = subscription.promotionalOffers.compactMap { offer in
198
- return [
199
- "identifier": offer.id ?? "",
200
- "type": offer.type.rawValue,
201
- "numberOfPeriods": "\(offer.periodCount)",
202
- "price": "\(offer.price)",
203
- "localizedPrice": offer.displayPrice,
204
- "paymentMode": offer.paymentMode.rawValue,
205
- "subscriptionPeriod": getPeriodIOS(offer.period.unit)
206
- ]
207
- }
208
- }
209
-
210
- // Extract introductory price from introductory offer
211
- if let introOffer = subscription.introductoryOffer {
212
- introductoryPrice = introOffer.displayPrice
213
- introductoryPriceAsAmountIOS = "\(introOffer.price)"
214
- introductoryPricePaymentModeIOS = introOffer.paymentMode.rawValue
215
- introductoryPriceNumberOfPeriodsIOS = "\(introOffer.periodCount)"
216
- introductoryPriceSubscriptionPeriodIOS = getPeriodIOS(introOffer.period.unit)
217
- }
218
-
219
- // Extract subscription period information
220
- subscriptionPeriodNumberIOS = "\(subscription.subscriptionPeriod.value)"
221
- subscriptionPeriodUnitIOS = getPeriodIOS(subscription.subscriptionPeriod.unit)
222
- }
223
-
224
- return [
225
- "debugDescription": serializeDebug(p.debugDescription),
226
- "description": p.description,
227
- // New iOS-suffixed fields
228
- "displayNameIOS": p.displayName,
229
- "discountsIOS": discounts,
230
- "introductoryPriceIOS": introductoryPrice,
231
- "introductoryPriceAsAmountIOS": introductoryPriceAsAmountIOS,
232
- "introductoryPricePaymentModeIOS": introductoryPricePaymentModeIOS,
233
- "introductoryPriceNumberOfPeriodsIOS": introductoryPriceNumberOfPeriodsIOS,
234
- "introductoryPriceSubscriptionPeriodIOS": introductoryPriceSubscriptionPeriodIOS,
235
- "subscriptionPeriodNumberIOS": subscriptionPeriodNumberIOS,
236
- "subscriptionPeriodUnitIOS": subscriptionPeriodUnitIOS,
237
- "displayPrice": p.displayPrice,
238
- "id": p.id,
239
- "title": p.displayName,
240
- "isFamilyShareableIOS": p.isFamilyShareable,
241
- "jsonRepresentationIOS": String(data: p.jsonRepresentation, encoding: .utf8),
242
- "price": p.price,
243
- "subscriptionInfoIOS": serializeSubscription(p.subscription),
244
- "type": productType,
245
- "currency": p.priceFormatStyle.currencyCode,
246
- "platform": "ios",
247
- // START: Deprecated - will be removed in v2.9.0
248
- // Use displayNameIOS instead of displayName
249
- "displayName": p.displayName,
250
- // Use discountsIOS instead of discounts
251
- "discounts": discounts,
252
- // Use introductoryPriceIOS instead of introductoryPrice
253
- "introductoryPrice": introductoryPrice,
254
- // Use isFamilyShareableIOS instead of isFamilyShareable
255
- "isFamilyShareable": p.isFamilyShareable,
256
- // Use jsonRepresentationIOS instead of jsonRepresentation
257
- "jsonRepresentation": String(data: p.jsonRepresentation, encoding: .utf8),
258
- // Use subscriptionInfoIOS instead of subscription
259
- "subscription": serializeSubscription(p.subscription),
260
- // END: Deprecated - will be removed in v2.9.0
261
- ]
262
- }
263
-
264
- @available(iOS 15.0, *)
265
- @Sendable func serialize(_ rs: Transaction.RefundRequestStatus?) -> String? {
266
- guard let rs = rs else { return nil }
267
- switch rs {
268
- case .success: return "success"
269
- case .userCancelled: return "userCancelled"
270
- default:
271
- return nil
272
- }
273
- }
274
-
275
- @available(iOS 15.0, *)
276
- func serializeSubscriptionStatus(_ status: Product.SubscriptionInfo.Status) -> [String: Any?] {
277
- return [
278
- "state": status.state.rawValue,
279
- "renewalInfo": serializeRenewalInfo(status.renewalInfo),
280
- "platform": "ios",
281
- ]
282
- }
283
-
284
- @available(iOS 15.0, *)
285
- func serializeRenewalInfo(_ renewalInfo: VerificationResult<Product.SubscriptionInfo.RenewalInfo>)
286
- -> [String: Any?]?
287
- {
288
- switch renewalInfo {
289
- case .unverified:
290
- return nil
291
- case .verified(let info):
292
- return [
293
- "autoRenewStatus": info.willAutoRenew,
294
- "autoRenewPreference": info.autoRenewPreference,
295
- "expirationReason": info.expirationReason,
296
- "deviceVerification": info.deviceVerification,
297
- "currentProductID": info.currentProductID,
298
- "debugDescription": info.debugDescription,
299
- "gracePeriodExpirationDate": info.gracePeriodExpirationDate,
300
- "platform": "ios",
301
- ]
302
- }
303
- }
304
-
305
- @available(iOS 15.0, *)
16
+ @available(iOS 15.0, tvOS 15.0, *)
17
+ @MainActor
306
18
  public class ExpoIapModule: Module {
307
- private var transactions: [String: Transaction] = [:]
308
- private var productStore: ProductStore?
19
+ private let iapModule = OpenIapModule.shared
309
20
  private var hasListeners = false
310
- private var updateListenerTask: Task<Void, Error>?
311
- private var subscriptionPollingTask: Task<Void, Error>?
312
- private var pollingSkus: Set<String> = []
313
- private var paymentObserver: PaymentObserver?
314
- private var promotedPayment: SKPayment?
315
- private var promotedProduct: SKProduct?
316
21
 
317
- // Add a flag to track initialization state
318
- private var isInitialized = false
319
-
320
22
  public func definition() -> ModuleDefinition {
321
23
  Name("ExpoIap")
322
-
24
+
25
+ // Export native constants for error code mapping
323
26
  Constants([
324
27
  "ERROR_CODES": IapErrorCode.toDictionary()
325
28
  ])
326
-
327
- Events(IapEvent.PurchaseUpdated, IapEvent.PurchaseError, IapEvent.PromotedProductIOS)
328
-
329
- OnStartObserving {
330
- self.hasListeners = true
331
- self.addTransactionObserver()
332
- }
333
-
334
- OnStopObserving {
335
- self.hasListeners = false
336
- self.removeTransactionObserver()
337
- }
338
-
339
- Function("initConnection") { () -> Bool in
340
- // Clean up any existing state first (important for hot reload)
341
- self.cleanupExistingState()
342
-
343
- // Initialize fresh state
344
- self.productStore = ProductStore()
29
+
30
+ Events(OpenIapEvent.PurchaseUpdated, OpenIapEvent.PurchaseError, OpenIapEvent.PromotedProductIOS)
31
+
32
+ AsyncFunction("initConnection") { () async throws -> Bool in
33
+ logDebug("initConnection called")
345
34
 
346
- // Set up PaymentObserver for promoted products
347
- if self.paymentObserver == nil {
348
- self.paymentObserver = PaymentObserver(module: self)
349
- SKPaymentQueue.default().add(self.paymentObserver!)
35
+ if !self.hasListeners {
36
+ self.setupPurchaseListeners()
37
+ self.hasListeners = true
350
38
  }
351
39
 
352
- self.isInitialized = true
353
- return AppStore.canMakePayments
354
- }
355
-
356
- AsyncFunction("getStorefront") {
357
- let storefront = await Storefront.current
358
- return storefront?.countryCode
359
- }
360
-
361
- AsyncFunction("getAppTransactionIOS") { () async throws -> [String: Any?]? in
362
- if #available(iOS 16.0, *) {
363
- #if compiler(>=5.7)
364
- let verificationResult = try await AppTransaction.shared
365
-
366
- let appTransaction: AppTransaction
367
- switch verificationResult {
368
- case .verified(let verified):
369
- appTransaction = verified
370
- case .unverified(_, _):
371
- return nil
372
- }
373
-
374
- var result: [String: Any?] = [
375
- "bundleId": appTransaction.bundleID,
376
- "appVersion": appTransaction.appVersion,
377
- "originalAppVersion": appTransaction.originalAppVersion,
378
- "originalPurchaseDate": appTransaction.originalPurchaseDate.timeIntervalSince1970 * 1000,
379
- "deviceVerification": appTransaction.deviceVerification.base64EncodedString(),
380
- "deviceVerificationNonce": appTransaction.deviceVerificationNonce.uuidString,
381
- "environment": appTransaction.environment.rawValue,
382
- "signedDate": appTransaction.signedDate.timeIntervalSince1970 * 1000,
383
- "appId": appTransaction.appID,
384
- "appVersionId": appTransaction.appVersionID,
385
- "preorderDate": appTransaction.preorderDate.map { $0.timeIntervalSince1970 * 1000 }
386
- ]
387
-
388
- // iOS 18.4+ properties - only compile with Xcode 16.4+ (Swift 6.1+)
389
- // This prevents build failures on Xcode 16.3 and below
390
- #if swift(>=6.1)
391
- if #available(iOS 18.4, *) {
392
- result["appTransactionId"] = appTransaction.appTransactionID
393
- result["originalPlatform"] = appTransaction.originalPlatform.rawValue
394
- }
395
- #endif
396
-
397
- return result
398
- #else
399
- throw Exception(
400
- name: "ExpoIapModule",
401
- description: "getAppTransaction requires Xcode 15.0+ with iOS 16.0 SDK for compilation",
402
- code: IapErrorCode.unknown
403
- )
404
- #endif
405
- } else {
406
- throw Exception(
407
- name: "ExpoIapModule",
408
- description: "getAppTransaction requires iOS 16.0 or later",
409
- code: IapErrorCode.unknown
410
- )
411
- }
40
+ return try await self.iapModule.initConnection()
412
41
  }
413
42
 
414
- AsyncFunction("getPromotedProductIOS") { () -> [String: Any?]? in
415
- guard let product = self.promotedProduct else {
416
- return nil
417
- }
43
+ AsyncFunction("endConnection") { () async throws -> Bool in
44
+ logDebug("endConnection called")
418
45
 
419
- // Convert SKProduct to dictionary
420
- return [
421
- "productIdentifier": product.productIdentifier,
422
- "localizedTitle": product.localizedTitle,
423
- "localizedDescription": product.localizedDescription,
424
- "price": product.price.doubleValue,
425
- "priceLocale": [
426
- "currencyCode": product.priceLocale.currencyCode ?? "",
427
- "currencySymbol": product.priceLocale.currencySymbol ?? "",
428
- "countryCode": product.priceLocale.regionCode ?? ""
429
- ]
430
- ]
431
- }
432
-
433
- AsyncFunction("requestPurchaseOnPromotedProductIOS") { () -> Void in
434
- guard let payment = self.promotedPayment else {
435
- throw Exception(
436
- name: "ExpoIapModule",
437
- description: "No promoted product available",
438
- code: IapErrorCode.itemUnavailable
439
- )
46
+ if self.hasListeners {
47
+ // OpenIAP now exposes unified listener management
48
+ await self.iapModule.removeAllListeners()
49
+ self.hasListeners = false
440
50
  }
441
51
 
442
- // Add the deferred payment to the queue
443
- SKPaymentQueue.default().add(payment)
444
-
445
- // Clear the promoted product data
446
- self.promotedPayment = nil
447
- self.promotedProduct = nil
52
+ return try await self.iapModule.endConnection()
448
53
  }
449
-
450
- AsyncFunction("fetchProducts") { (skus: [String]) -> [[String: Any?]?] in
451
- try self.ensureConnection()
452
-
453
- let productStore = self.productStore!
54
+
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)
454
60
 
455
- do {
456
- let fetchedProducts = try await Product.products(for: skus)
457
- await productStore.performOnActor { isolatedStore in
458
- fetchedProducts.forEach { product in
459
- isolatedStore.addProduct(product)
460
- }
461
- }
462
- let products = await productStore.getAllProducts()
463
- return products.map { serializeProduct($0) }.compactMap { $0 }
464
- } catch {
465
- print("Error fetching items: \(error)")
466
- throw error
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)")
467
66
  }
468
- }
469
-
470
- AsyncFunction("requestProducts") { (skus: [String]) -> [[String: Any?]?] in
471
- print("WARNING: requestProducts is deprecated. Use fetchProducts instead. The 'request' prefix should only be used for event-based operations. This method will be removed in version 3.0.0.")
472
-
473
- try self.ensureConnection()
474
-
475
- let productStore = self.productStore!
476
67
 
477
- do {
478
- let fetchedProducts = try await Product.products(for: skus)
479
- await productStore.performOnActor { isolatedStore in
480
- fetchedProducts.forEach { product in
481
- isolatedStore.addProduct(product)
482
- }
483
- }
484
- let products = await productStore.getAllProducts()
485
- return products.map { serializeProduct($0) }.compactMap { $0 }
486
- } catch {
487
- print("Error fetching items: \(error)")
488
- throw error
489
- }
68
+ let serializedProducts = products.map { self.serializeProduct($0) }
69
+ logDebug("Serialized products: \(serializedProducts)")
70
+ return serializedProducts
490
71
  }
491
-
492
- AsyncFunction("endConnection") { () -> Bool in
493
- self.cleanupExistingState()
494
- return true
495
- }
496
-
72
+
497
73
  AsyncFunction("getAvailableItems") {
498
- (alsoPublishToEventListenerIOS: Bool, onlyIncludeActiveItemsIOS: Bool) -> [[String: Any?]?] in
499
-
500
- try self.ensureConnection()
501
-
502
- var purchasedItemsSerialized: [[String: Any?]] = []
503
-
504
- func addTransaction(transaction: Transaction, jwsRepresentationIOS: String? = nil) {
505
- let serialized = serializeTransaction(transaction, jwsRepresentationIOS: jwsRepresentationIOS)
506
- purchasedItemsSerialized.append(serialized)
507
-
508
- if alsoPublishToEventListenerIOS {
509
- self.sendEvent(IapEvent.PurchaseUpdated, serialized)
510
- }
511
- }
512
-
513
- for await verification in onlyIncludeActiveItemsIOS
514
- ? Transaction.currentEntitlements : Transaction.all
515
- {
516
- do {
517
- let transaction = try self.checkVerified(verification)
518
- if !onlyIncludeActiveItemsIOS {
519
- addTransaction(transaction: transaction, jwsRepresentationIOS: verification.jwsRepresentation)
520
- continue
521
- }
522
- switch transaction.productType {
523
- case .nonConsumable, .autoRenewable, .consumable:
524
- if await self.productStore?.getProduct(productID: transaction.productID)
525
- != nil
526
- {
527
- addTransaction(transaction: transaction, jwsRepresentationIOS: verification.jwsRepresentation)
528
- }
529
- case .nonRenewable:
530
- if await self.productStore?.getProduct(productID: transaction.productID)
531
- != nil
532
- {
533
- let currentDate = Date()
534
- let expirationDate = Calendar(identifier: .gregorian).date(
535
- byAdding: DateComponents(year: 1), to: transaction.purchaseDate)!
536
- if currentDate < expirationDate {
537
- addTransaction(transaction: transaction, jwsRepresentationIOS: verification.jwsRepresentation)
538
- }
539
- }
540
- default:
541
- break
542
- }
543
- } catch StoreError.failedVerification {
544
- let err = [
545
- "responseCode": IapErrorCode.transactionValidationFailed,
546
- "debugMessage": StoreError.failedVerification.localizedDescription,
547
- "code": IapErrorCode.transactionValidationFailed,
548
- "message": StoreError.failedVerification.localizedDescription,
549
- "productId": "unknown",
550
- ]
551
- if alsoPublishToEventListenerIOS {
552
- self.sendEvent(IapEvent.PurchaseError, err)
553
- }
554
- } catch {
555
- let err = [
556
- "responseCode": IapErrorCode.unknown,
557
- "debugMessage": error.localizedDescription,
558
- "code": IapErrorCode.unknown,
559
- "message": error.localizedDescription,
560
- "productId": "unknown",
561
- ]
562
- if alsoPublishToEventListenerIOS {
563
- self.sendEvent(IapEvent.PurchaseError, err)
564
- }
565
- }
566
- }
567
- return purchasedItemsSerialized
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) }
568
83
  }
569
-
84
+
570
85
  AsyncFunction("requestPurchase") {
571
- (
572
- sku: String, andDangerouslyFinishTransactionAutomatically: Bool,
573
- appAccountToken: String?, quantity: Int, discountOffer: [String: String]?
574
- ) -> [String: Any?]? in
86
+ (sku: String,
87
+ andDangerouslyFinishTransactionAutomaticallyIOS: Bool?,
88
+ appAccountToken: String?,
89
+ quantity: Int?,
90
+ discountOffer: [String: String]?) async throws -> [String: Any?]? in
575
91
 
576
- try self.ensureConnection()
577
- let productStore = self.productStore!
578
-
579
- let product: Product? = await productStore.getProduct(productID: sku)
580
- if let product = product {
581
- do {
582
- var options: Set<Product.PurchaseOption> = []
583
- if quantity > -1 {
584
- options.insert(.quantity(quantity))
585
- }
586
- if let offerID = discountOffer?["identifier"],
587
- let keyID = discountOffer?["keyIdentifier"],
588
- let nonce = discountOffer?["nonce"],
589
- let signature = discountOffer?["signature"],
590
- let timestamp = discountOffer?["timestamp"],
591
- let uuidNonce = UUID(uuidString: nonce),
592
- let signatureData = Data(base64Encoded: signature),
593
- let timestampInt = Int(timestamp)
594
- {
595
- options.insert(
596
- .promotionalOffer(
597
- offerID: offerID, keyID: keyID, nonce: uuidNonce,
598
- signature: signatureData, timestamp: timestampInt))
599
- }
600
- if let appAccountToken = appAccountToken,
601
- let appAccountUUID = UUID(uuidString: appAccountToken)
602
- {
603
- options.insert(.appAccountToken(appAccountUUID))
604
- }
605
- guard let windowScene = await self.currentWindowScene() else {
606
- let errorData = [
607
- "responseCode": IapErrorCode.serviceError,
608
- "debugMessage": "Could not find window scene",
609
- "code": IapErrorCode.serviceError,
610
- "message": "Could not find window scene",
611
- "productId": sku,
612
- ]
613
- self.sendEvent(IapEvent.PurchaseError, errorData)
614
- throw Exception(name: "ExpoIapModule", description: "Could not find window scene", code: IapErrorCode.serviceError)
615
- }
616
- let result: Product.PurchaseResult
617
- #if swift(>=5.9)
618
- if #available(iOS 17.0, tvOS 17.0, *) {
619
- result = try await product.purchase(
620
- confirmIn: windowScene, options: options)
621
- } else {
622
- #if !os(visionOS)
623
- result = try await product.purchase(options: options)
624
- #endif
625
- }
626
- #elseif !os(visionOS)
627
- result = try await product.purchase(options: options)
628
- #endif
629
-
630
- switch result {
631
- case .success(let verification):
632
- let transaction = try self.checkVerified(verification)
633
-
634
- // Debug: Log JWS representation
635
- let jwsRepresentation = verification.jwsRepresentation
636
- if !jwsRepresentation.isEmpty {
637
- logDebug("buyProduct JWS: exists")
638
- logDebug("buyProduct JWS length: \(jwsRepresentation.count)")
639
- } else {
640
- logDebug("buyProduct JWS: empty string")
641
- }
642
-
643
- if andDangerouslyFinishTransactionAutomatically {
644
- await transaction.finish()
645
- return nil
646
- } else {
647
- self.transactions[String(transaction.id)] = transaction
648
- let serialized = serializeTransaction(transaction, jwsRepresentationIOS: verification.jwsRepresentation)
649
-
650
- // Debug: Check if jwsRepresentationIOS is included in serialized result
651
- logDebug("buyProduct serialized includes JWS: \(serialized["jwsRepresentationIOS"] != nil)")
652
-
653
- self.sendEvent(IapEvent.PurchaseUpdated, serialized)
654
- return serialized
655
- }
656
- case .userCancelled:
657
- let errorData = [
658
- "responseCode": IapErrorCode.userCancelled,
659
- "debugMessage": "User cancelled the purchase",
660
- "code": IapErrorCode.userCancelled,
661
- "message": "User cancelled the purchase",
662
- "productId": sku,
663
- ]
664
- self.sendEvent(IapEvent.PurchaseError, errorData)
665
- throw Exception(name: "ExpoIapModule", description: "User cancelled the purchase", code: IapErrorCode.userCancelled)
666
- case .pending:
667
- let errorData = [
668
- "responseCode": IapErrorCode.deferredPayment,
669
- "debugMessage": "The payment was deferred",
670
- "code": IapErrorCode.deferredPayment,
671
- "message": "The payment was deferred",
672
- "productId": sku,
673
- ]
674
- self.sendEvent(IapEvent.PurchaseError, errorData)
675
- throw Exception(name: "ExpoIapModule", description: "The payment was deferred", code: IapErrorCode.deferredPayment)
676
- @unknown default:
677
- let errorData = [
678
- "responseCode": IapErrorCode.unknown,
679
- "debugMessage": "Unknown purchase result",
680
- "code": IapErrorCode.unknown,
681
- "message": "Unknown purchase result",
682
- "productId": sku,
683
- ]
684
- self.sendEvent(IapEvent.PurchaseError, errorData)
685
- throw Exception(name: "ExpoIapModule", description: "Unknown purchase result", code: IapErrorCode.unknown)
686
- }
687
- } catch {
688
- if error is Exception {
689
- throw error
690
- }
691
-
692
- // Map StoreKit errors to proper error codes
693
- var errorCode = IapErrorCode.purchaseError
694
- var errorMessage = error.localizedDescription
695
-
696
- // Check for specific StoreKit error types
697
- if let nsError = error as NSError? {
698
- switch nsError.domain {
699
- case "SKErrorDomain":
700
- // Handle SKError codes
701
- switch nsError.code {
702
- case 0: // SKError.unknown
703
- errorCode = IapErrorCode.unknown
704
- case 1: // SKError.clientInvalid
705
- errorCode = IapErrorCode.serviceError
706
- case 2: // SKError.paymentCancelled
707
- errorCode = IapErrorCode.userCancelled
708
- errorMessage = "User cancelled the purchase"
709
- case 3: // SKError.paymentInvalid
710
- errorCode = IapErrorCode.userError
711
- case 4: // SKError.paymentNotAllowed
712
- errorCode = IapErrorCode.userError
713
- errorMessage = "Payment not allowed"
714
- case 5: // SKError.storeProductNotAvailable
715
- errorCode = IapErrorCode.itemUnavailable
716
- case 6: // SKError.cloudServicePermissionDenied
717
- errorCode = IapErrorCode.serviceError
718
- case 7: // SKError.cloudServiceNetworkConnectionFailed
719
- errorCode = IapErrorCode.networkError
720
- case 8: // SKError.cloudServiceRevoked
721
- errorCode = IapErrorCode.serviceError
722
- default:
723
- errorCode = IapErrorCode.purchaseError
724
- }
725
- case "NSURLErrorDomain":
726
- errorCode = IapErrorCode.networkError
727
- errorMessage = "Network error: \(error.localizedDescription)"
728
- default:
729
- errorCode = IapErrorCode.purchaseError
730
- }
731
- } else if error.localizedDescription.lowercased().contains("network") {
732
- errorCode = IapErrorCode.networkError
733
- } else if error.localizedDescription.lowercased().contains("cancelled") {
734
- errorCode = IapErrorCode.userCancelled
735
- }
736
-
737
- let errorData = [
738
- "responseCode": errorCode,
739
- "debugMessage": "Purchase failed: \(error.localizedDescription)",
740
- "code": errorCode,
741
- "message": errorMessage,
742
- "productId": sku,
743
- ]
744
- self.sendEvent(IapEvent.PurchaseError, errorData)
745
- throw Exception(name: "ExpoIapModule", description: errorMessage, code: errorCode)
746
- }
747
- } else {
748
- let errorData = [
749
- "responseCode": IapErrorCode.itemUnavailable,
750
- "debugMessage": "Invalid product ID",
751
- "code": IapErrorCode.itemUnavailable,
752
- "message": "Invalid product ID",
753
- "productId": sku,
754
- ]
755
- self.sendEvent(IapEvent.PurchaseError, errorData)
756
- throw Exception(name: "ExpoIapModule", description: "Invalid product ID", code: IapErrorCode.itemUnavailable)
757
- }
758
- }
759
-
760
- AsyncFunction("isEligibleForIntroOfferIOS") { (groupID: String) -> Bool in
761
- return await Product.SubscriptionInfo.isEligibleForIntroOffer(for: groupID)
92
+ logDebug("requestPurchase called for sku: \(sku)")
93
+
94
+ let finishAutomatically = andDangerouslyFinishTransactionAutomaticallyIOS ?? false
95
+ let qty = quantity ?? 1
96
+
97
+ // Use RequestPurchaseProps for OpenIAP PR #3
98
+ let props = RequestPurchaseProps(
99
+ sku: sku,
100
+ andDangerouslyFinishTransactionAutomatically: finishAutomatically,
101
+ appAccountToken: appAccountToken,
102
+ quantity: qty,
103
+ discountOffer: discountOffer
104
+ )
105
+ let purchase = try await self.iapModule.requestPurchase(props)
106
+ return self.serializePurchase(purchase)
762
107
  }
763
-
764
- AsyncFunction("subscriptionStatusIOS") { (sku: String) -> [[String: Any?]?]? in
765
- try self.ensureConnection()
766
- let productStore = self.productStore!
767
-
768
- do {
769
- let product = await productStore.getProduct(productID: sku)
770
- let status: [Product.SubscriptionInfo.Status]? = try await product?.subscription?
771
- .status
772
- guard let status = status else {
773
- return nil
774
- }
775
- return status.map { serializeSubscriptionStatus($0) }
776
- } catch {
777
- if error is Exception {
778
- throw error
779
- }
780
- throw Exception(name: "ExpoIapModule", description: "Error getting subscription status: \(error.localizedDescription)", code: IapErrorCode.serviceError)
781
- }
108
+
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)
782
112
  }
783
-
784
- AsyncFunction("currentEntitlementIOS") { (sku: String) -> [String: Any?]? in
785
- try self.ensureConnection()
786
- let productStore = self.productStore!
787
-
788
- if let product = await productStore.getProduct(productID: sku) {
789
- if let result = await product.currentEntitlement {
790
- do {
791
- let transaction = try self.checkVerified(result)
792
- return serializeTransaction(transaction)
793
- } catch StoreError.failedVerification {
794
- throw Exception(name: "ExpoIapModule", description: "Failed to verify transaction for sku \(sku)", code: IapErrorCode.transactionValidationFailed)
795
- } catch {
796
- if error is Exception {
797
- throw error
798
- }
799
- throw Exception(name: "ExpoIapModule", description: "Error fetching entitlement for sku \(sku): \(error.localizedDescription)", code: IapErrorCode.serviceError)
800
- }
801
- } else {
802
- throw Exception(name: "ExpoIapModule", description: "Can't find entitlement for sku \(sku)", code: IapErrorCode.itemUnavailable)
803
- }
804
- } else {
805
- throw Exception(name: "ExpoIapModule", description: "Can't find product for sku \(sku)", code: IapErrorCode.itemUnavailable)
806
- }
113
+
114
+ AsyncFunction("getPendingTransactionsIOS") { () async throws -> [[String: Any?]] in
115
+ logDebug("getPendingTransactionsIOS called")
116
+ let transactions = try await self.iapModule.getPendingTransactionsIOS()
117
+ return transactions.map { self.serializePurchase($0) }
807
118
  }
808
-
809
- AsyncFunction("latestTransactionIOS") { (sku: String) -> [String: Any?]? in
810
- try self.ensureConnection()
811
- let productStore = self.productStore!
812
-
813
- if let product = await productStore.getProduct(productID: sku) {
814
- if let result = await product.latestTransaction {
815
- do {
816
- let transaction = try self.checkVerified(result)
817
- return serializeTransaction(transaction)
818
- } catch StoreError.failedVerification {
819
- throw Exception(name: "ExpoIapModule", description: "Failed to verify transaction for sku \(sku)", code: IapErrorCode.transactionValidationFailed)
820
- } catch {
821
- if error is Exception {
822
- throw error
823
- }
824
- throw Exception(name: "ExpoIapModule", description: "Error fetching latest transaction for sku \(sku): \(error.localizedDescription)", code: IapErrorCode.serviceError)
825
- }
826
- } else {
827
- throw Exception(name: "ExpoIapModule", description: "Can't find latest transaction for sku \(sku)", code: IapErrorCode.itemUnavailable)
828
- }
829
- } else {
830
- throw Exception(name: "ExpoIapModule", description: "Can't find product for sku \(sku)", code: IapErrorCode.itemUnavailable)
831
- }
119
+
120
+ AsyncFunction("clearTransactionIOS") { () async throws in
121
+ logDebug("clearTransactionIOS called")
122
+ try await self.iapModule.clearTransactionIOS()
832
123
  }
833
-
834
- AsyncFunction("finishTransaction") { (transactionIdentifier: String) -> Bool in
835
- if let transaction = self.transactions[transactionIdentifier] {
836
- await transaction.finish()
837
- self.transactions.removeValue(forKey: transactionIdentifier)
838
- return true
839
- } else {
840
- throw Exception(name: "ExpoIapModule", description: "Invalid transaction ID", code: IapErrorCode.developerError)
841
- }
124
+
125
+ AsyncFunction("getReceiptDataIOS") { () async throws -> String? in
126
+ logDebug("getReceiptDataIOS called")
127
+ return try await self.iapModule.getReceiptDataIOS()
842
128
  }
843
-
844
- AsyncFunction("getPendingTransactionsIOS") { () -> [[String: Any?]?] in
845
- return self.transactions.values.map { serializeTransaction($0) }
129
+
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)
846
133
  }
847
-
848
- AsyncFunction("syncIOS") { () -> Bool in
849
- do {
850
- try await AppStore.sync()
851
- return true
852
- } catch {
853
- if error is Exception {
854
- throw error
855
- }
856
- throw Exception(name: "ExpoIapModule", description: "Error synchronizing with the AppStore: \(error.localizedDescription)", code: IapErrorCode.syncError)
134
+
135
+ AsyncFunction("validateReceiptIOS") { (sku: String) async throws -> [String: Any?] in
136
+ 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
144
+ ]
145
+ if let latest = validation.latestTransaction {
146
+ result["latestTransaction"] = self.serializePurchase(latest)
857
147
  }
148
+ return result
858
149
  }
859
-
860
- AsyncFunction("presentCodeRedemptionSheetIOS") { () -> Bool in
861
- #if !os(tvOS)
862
- SKPaymentQueue.default().presentCodeRedemptionSheet()
863
- return true
864
- #else
865
- throw Exception(name: "ExpoIapModule", description: "This method is not available on tvOS", code: IapErrorCode.serviceError)
866
- #endif
150
+
151
+ AsyncFunction("getStorefrontIOS") { () async throws -> String in
152
+ logDebug("getStorefrontIOS called")
153
+ return try await self.iapModule.getStorefrontIOS()
867
154
  }
868
-
869
- AsyncFunction("showManageSubscriptionsIOS") { () -> Bool in
870
- #if !os(tvOS)
871
- guard let windowScene = await self.currentWindowScene() else {
872
- throw Exception(name: "ExpoIapModule", description: "Cannot find window scene or not available on macOS", code: IapErrorCode.serviceError)
873
- }
874
- // Get all subscription products before showing the management UI
875
- let subscriptionSkus = await self.getAllSubscriptionProductIds()
876
- self.pollingSkus = Set(subscriptionSkus)
877
- // Show the management UI
878
- try await AppStore.showManageSubscriptions(in: windowScene)
879
- // Start polling for status changes
880
- self.pollForSubscriptionStatusChanges()
881
- return true
882
- #else
883
- throw Exception(name: "ExpoIapModule", description: "This method is not available on tvOS", code: IapErrorCode.serviceError)
884
- #endif
155
+
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()
885
160
  }
886
-
887
- AsyncFunction("clearTransactionIOS") { () -> Void in
888
- Task {
889
- for await result in Transaction.unfinished {
890
- do {
891
- let transaction = try self.checkVerified(result)
892
- await transaction.finish()
893
- self.transactions.removeValue(forKey: String(transaction.id))
894
- } catch {
895
- if error is Exception {
896
- throw error
897
- }
898
- print("Failed to finish transaction")
899
- }
161
+
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
+ ]
900
175
  }
901
176
  }
177
+ return nil
902
178
  }
903
-
904
- AsyncFunction("beginRefundRequestIOS") { (sku: String) -> String? in
905
- #if !os(tvOS)
906
- try self.ensureConnection()
907
- let productStore = self.productStore!
908
-
909
- guard let product = await productStore.getProduct(productID: sku),
910
- let result = await product.latestTransaction
911
- else {
912
- throw Exception(name: "ExpoIapModule", description: "Can't find product or transaction for sku \(sku)", code: IapErrorCode.itemUnavailable)
913
- }
914
-
915
- do {
916
- let transaction = try self.checkVerified(result)
917
- guard let windowScene = await self.currentWindowScene() else {
918
- throw Exception(name: "ExpoIapModule", description: "Cannot find window scene or not available on macOS", code: IapErrorCode.serviceError)
919
- }
920
- let refundStatus = try await transaction.beginRefundRequest(in: windowScene)
921
- return serialize(refundStatus)
922
- } catch StoreError.failedVerification {
923
- throw Exception(name: "ExpoIapModule", description: "Failed to verify transaction for sku \(sku)", code: IapErrorCode.transactionValidationFailed)
924
- } catch {
925
- if error is Exception {
926
- throw error
927
- }
928
- throw Exception(name: "ExpoIapModule", description: "Failed to refund purchase: \(error.localizedDescription)", code: IapErrorCode.serviceError)
929
- }
930
- #else
931
- throw Exception(name: "ExpoIapModule", description: "This method is not available on tvOS", code: IapErrorCode.serviceError)
932
- #endif
933
- }
934
-
935
- // @deprecated - This function is deprecated and will be removed in v2.9.0
936
- // The transaction observer is now managed automatically
937
- Function("disable") { () -> Bool in
938
- // No longer needed - observer management is automatic
939
- return true
940
- }
941
-
942
- AsyncFunction("getReceiptDataIOS") { () -> String? in
943
- return try self.getReceiptDataInternal()
179
+
180
+ AsyncFunction("isEligibleForIntroOfferIOS") { (groupID: String) async -> Bool in
181
+ logDebug("isEligibleForIntroOfferIOS called for groupID: \(groupID)")
182
+ return await self.iapModule.isEligibleForIntroOfferIOS(groupID: groupID)
944
183
  }
945
-
946
- AsyncFunction("isTransactionVerifiedIOS") { (sku: String) -> Bool in
947
- try self.ensureConnection()
948
- let productStore = self.productStore!
949
-
950
- if let product = await productStore.getProduct(productID: sku),
951
- let result = await product.latestTransaction {
952
- do {
953
- // If this doesn't throw, the transaction is verified
954
- _ = try self.checkVerified(result)
955
- return true
956
- } catch {
957
- return false
184
+
185
+ AsyncFunction("subscriptionStatusIOS") { (sku: String) async throws -> [[String: Any?]]? in
186
+ logDebug("subscriptionStatusIOS called for sku: \(sku)")
187
+ if let statuses = try await self.iapModule.subscriptionStatusIOS(sku: sku) {
188
+ return statuses.map { status in
189
+ [
190
+ "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
196
+ ]
958
197
  }
959
198
  }
960
- return false
199
+ return nil
961
200
  }
962
-
963
- AsyncFunction("getTransactionJwsIOS") { (sku: String) -> String? in
964
- try self.ensureConnection()
965
- let productStore = self.productStore!
966
-
967
- if let product = await productStore.getProduct(productID: sku),
968
- let result = await product.latestTransaction {
969
- return result.jwsRepresentation
970
- } else {
971
- throw Exception(name: "ExpoIapModule", description: "Can't find transaction for sku \(sku)", code: IapErrorCode.itemUnavailable)
201
+
202
+ AsyncFunction("currentEntitlementIOS") { (sku: String) async throws -> [String: Any?]? in
203
+ logDebug("currentEntitlementIOS called for sku: \(sku)")
204
+ if let transaction = try await self.iapModule.currentEntitlementIOS(sku: sku) {
205
+ return self.serializePurchase(transaction)
972
206
  }
207
+ return nil
973
208
  }
974
-
975
- AsyncFunction("validateReceiptIOS") { (sku: String) -> [String: Any] in
976
- try self.ensureConnection()
977
- let productStore = self.productStore!
978
-
979
- // Get receipt data
980
- var receiptData: String = ""
981
- do {
982
- receiptData = try self.getReceiptDataInternal()
983
- } catch {
984
- // Continue with validation even if receipt retrieval fails
985
- // Error will be reflected by empty receipt data
986
- }
987
-
988
- var isValid = false
989
- var jwsRepresentation: String? = nil
990
- var latestTransaction: [String: Any?]? = nil
991
-
992
- // Get JWS representation and verify transaction
993
- if let product = await productStore.getProduct(productID: sku),
994
- let result = await product.latestTransaction {
995
- jwsRepresentation = result.jwsRepresentation
996
-
997
- do {
998
- // If this doesn't throw, the transaction is verified
999
- let transaction = try self.checkVerified(result)
1000
- isValid = true
1001
- latestTransaction = serializeTransaction(transaction, jwsRepresentationIOS: result.jwsRepresentation)
1002
- } catch {
1003
- isValid = false
1004
- }
209
+
210
+ AsyncFunction("latestTransactionIOS") { (sku: String) async throws -> [String: Any?]? in
211
+ logDebug("latestTransactionIOS called for sku: \(sku)")
212
+ if let transaction = try await self.iapModule.latestTransactionIOS(sku: sku) {
213
+ return self.serializePurchase(transaction)
1005
214
  }
1006
-
1007
- return [
1008
- "isValid": isValid,
1009
- "receiptData": receiptData,
1010
- "jwsRepresentation": jwsRepresentation ?? "",
1011
- "latestTransaction": latestTransaction as Any
1012
- ]
1013
- }
1014
- }
1015
-
1016
- // Similar to Android's ensureConnection pattern
1017
- private func ensureConnection() throws {
1018
- guard isInitialized else {
1019
- throw Exception(
1020
- name: "ExpoIapModule",
1021
- description: "Connection not initialized. Call initConnection() first.",
1022
- code: IapErrorCode.notPrepared
1023
- )
215
+ return nil
1024
216
  }
1025
217
 
1026
- guard productStore != nil else {
1027
- throw Exception(
1028
- name: "ExpoIapModule",
1029
- description: "Product store not available",
1030
- code: IapErrorCode.notPrepared
1031
- )
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)
1032
221
  }
1033
- }
1034
-
1035
- private func cleanupExistingState() {
1036
- // Cancel any existing tasks
1037
- updateListenerTask?.cancel()
1038
- updateListenerTask = nil
1039
222
 
1040
- subscriptionPollingTask?.cancel()
1041
- subscriptionPollingTask = nil
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
229
+ ]
230
+ }
231
+ return nil
232
+ }
1042
233
 
1043
- // Clear collections
1044
- transactions.removeAll()
1045
- pollingSkus.removeAll()
234
+ AsyncFunction("requestPurchaseOnPromotedProductIOS") { () async throws in
235
+ logDebug("requestPurchaseOnPromotedProductIOS called")
236
+ try await self.iapModule.requestPurchaseOnPromotedProductIOS()
237
+ }
1046
238
 
1047
- // Reset promoted products
1048
- promotedPayment = nil
1049
- promotedProduct = nil
239
+ AsyncFunction("syncIOS") { () async throws -> Bool in
240
+ logDebug("syncIOS called")
241
+ return try await self.iapModule.syncIOS()
242
+ }
1050
243
 
1051
- // Remove existing payment observer if any
1052
- if let observer = paymentObserver {
1053
- SKPaymentQueue.default().remove(observer)
1054
- paymentObserver = nil
244
+ AsyncFunction("presentCodeRedemptionSheetIOS") { () async throws -> Bool in
245
+ logDebug("presentCodeRedemptionSheetIOS called")
246
+ return try await self.iapModule.presentCodeRedemptionSheetIOS()
1055
247
  }
1056
248
 
1057
- // Clear product store
1058
- if let store = productStore {
1059
- Task {
1060
- await store.removeAll()
1061
- }
249
+ AsyncFunction("showManageSubscriptionsIOS") { () async throws -> Bool in
250
+ logDebug("showManageSubscriptionsIOS called")
251
+ return try await self.iapModule.showManageSubscriptionsIOS()
1062
252
  }
1063
- productStore = nil
1064
253
 
1065
- isInitialized = false
1066
- }
1067
-
1068
- private func addTransactionObserver() {
1069
- if updateListenerTask == nil {
1070
- updateListenerTask = listenForTransactions()
254
+ AsyncFunction("isTransactionVerifiedIOS") { (sku: String) async -> Bool in
255
+ logDebug("isTransactionVerifiedIOS called for sku: \(sku)")
256
+ return await self.iapModule.isTransactionVerifiedIOS(sku: sku)
1071
257
  }
1072
258
  }
1073
-
1074
- private func removeTransactionObserver() {
1075
- updateListenerTask?.cancel()
1076
- updateListenerTask = nil
1077
- }
1078
-
1079
- private func listenForTransactions() -> Task<Void, Error> {
1080
- return Task.detached { [weak self] in
1081
- guard let self = self else { return }
1082
- for await result in Transaction.updates {
1083
- do {
1084
- let transaction = try self.checkVerified(result)
1085
- self.transactions[String(transaction.id)] = transaction
1086
- if self.hasListeners {
1087
- let serialized = serializeTransaction(transaction, jwsRepresentationIOS: result.jwsRepresentation)
1088
- self.sendEvent(IapEvent.PurchaseUpdated, serialized)
1089
- }
1090
- } catch {
1091
- if self.hasListeners {
1092
- let err = [
1093
- "responseCode": IapErrorCode.transactionValidationFailed,
1094
- "debugMessage": error.localizedDescription,
1095
- "code": IapErrorCode.transactionValidationFailed,
1096
- "message": error.localizedDescription,
1097
- ]
1098
- self.sendEvent(IapEvent.PurchaseError, err)
1099
- }
1100
- }
1101
- }
259
+
260
+ // MARK: - Purchase Listeners
261
+
262
+ private func setupPurchaseListeners() {
263
+ _ = iapModule.purchaseUpdatedListener { [weak self] purchase in
264
+ self?.handlePurchaseUpdated(purchase)
1102
265
  }
1103
- }
1104
-
1105
- public func startObserving() {
1106
- hasListeners = true
1107
- addTransactionObserver()
1108
- }
1109
-
1110
- public func stopObserving() {
1111
- hasListeners = false
1112
- removeTransactionObserver()
1113
- }
1114
-
1115
- private func currentWindowScene() async -> UIWindowScene? {
1116
- await MainActor.run {
1117
- return UIApplication.shared.connectedScenes.first as? UIWindowScene
266
+ _ = iapModule.purchaseErrorListener { [weak self] error in
267
+ self?.handlePurchaseError(error)
1118
268
  }
1119
269
  }
1120
-
1121
- private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
1122
- switch result {
1123
- case .unverified(_, let error):
1124
- throw error
1125
- case .verified(let item):
1126
- return item
1127
- }
270
+
271
+ private func handlePurchaseUpdated(_ purchase: OpenIapPurchase) {
272
+ logDebug("Purchase updated: \(purchase.productId)")
273
+ let serialized = serializePurchase(purchase)
274
+ sendEvent(OpenIapEvent.PurchaseUpdated, serialized)
1128
275
  }
1129
-
1130
- private func getAllSubscriptionProductIds() async -> [String] {
1131
- guard let productStore = self.productStore else { return [] }
1132
- let products = await productStore.getAllProducts()
1133
- return products.compactMap { product in
1134
- if product.subscription != nil {
1135
- return product.id
1136
- }
1137
- return nil
1138
- }
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)
1139
285
  }
1140
-
1141
- private func pollForSubscriptionStatusChanges() {
1142
- subscriptionPollingTask?.cancel()
1143
- subscriptionPollingTask = Task {
1144
- try? await Task.sleep(nanoseconds: 1_500_000_000) // 1.5 seconds
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,
1145
298
 
1146
- var previousStatuses: [String: Bool] = [:] // Track auto-renewal state with Bool
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,
1147
305
 
1148
- for sku in self.pollingSkus {
1149
- guard let product = await self.productStore?.getProduct(productID: sku),
1150
- let status = try? await product.subscription?.status.first else { continue }
1151
-
1152
- // Track willAutoRenew as a bool value
1153
- var willAutoRenew = false
1154
- if case .verified(let info) = status.renewalInfo {
1155
- willAutoRenew = info.willAutoRenew
1156
- }
1157
- previousStatuses[sku] = willAutoRenew
1158
- }
1159
-
1160
- for _ in 1...5 {
1161
- try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
1162
- if Task.isCancelled {
1163
- return
1164
- }
1165
-
1166
- for sku in self.pollingSkus {
1167
- guard let product = await self.productStore?.getProduct(productID: sku),
1168
- let status = try? await product.subscription?.status.first,
1169
- let result = await product.latestTransaction else { continue }
1170
- // Try to verify the transaction
1171
- let transaction: Transaction
1172
- do {
1173
- transaction = try self.checkVerified(result)
1174
- } catch {
1175
- continue // Skip if verification fails
1176
- }
1177
-
1178
- // Track current auto-renewal state
1179
- var currentWillAutoRenew = false
1180
- if case .verified(let info) = status.renewalInfo {
1181
- currentWillAutoRenew = info.willAutoRenew
1182
- }
1183
-
1184
- // Compare with previous state
1185
- if let previousWillAutoRenew = previousStatuses[sku],
1186
- previousWillAutoRenew != currentWillAutoRenew {
1187
-
1188
- // Use the jwsRepresentation when serializing the transaction
1189
- var purchaseMap = serializeTransaction(transaction, jwsRepresentationIOS: result.jwsRepresentation)
1190
-
1191
- if case .verified(let renewalInfo) = status.renewalInfo {
1192
- if let renewalInfoDict = serializeRenewalInfo(.verified(renewalInfo)) {
1193
- purchaseMap["renewalInfo"] = renewalInfoDict
1194
- }
1195
- }
1196
-
1197
- self.sendEvent(IapEvent.PurchaseUpdated, purchaseMap)
1198
- previousStatuses[sku] = currentWillAutoRenew
1199
- }
1200
- }
1201
- }
1202
- self.pollingSkus.removeAll()
1203
- }
1204
- }
1205
-
1206
- private func getReceiptDataInternal() throws -> String {
1207
- if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
1208
- FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
1209
- do {
1210
- let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
1211
- return receiptData.base64EncodedString(options: [])
1212
- } catch {
1213
- throw Exception(name: "ExpoIapModule", description: "Error reading receipt data: \(error.localizedDescription)", code: IapErrorCode.receiptFailed)
1214
- }
1215
- } else {
1216
- throw Exception(name: "ExpoIapModule", description: "App Store receipt not found", code: IapErrorCode.receiptFailed)
1217
- }
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
+ ]
1218
340
  }
1219
341
 
1220
- // Called by PaymentObserver when a promoted product is received
1221
- func handlePromotedProduct(payment: SKPayment, product: SKProduct) {
1222
- self.promotedPayment = payment
1223
- self.promotedProduct = product
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
+ ]
1224
387
 
1225
- if hasListeners {
1226
- let productData: [String: Any] = [
1227
- "productIdentifier": product.productIdentifier,
1228
- "localizedTitle": product.localizedTitle,
1229
- "localizedDescription": product.localizedDescription,
1230
- "price": product.price.doubleValue,
1231
- "priceLocale": [
1232
- "currencyCode": product.priceLocale.currencyCode ?? "",
1233
- "currencySymbol": product.priceLocale.currencySymbol ?? "",
1234
- "countryCode": product.priceLocale.regionCode ?? ""
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
1235
395
  ]
1236
396
  ]
1237
- sendEvent(IapEvent.PromotedProductIOS, productData)
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
1238
431
  }
432
+
433
+ // Deprecated fields for backward compatibility
434
+ result["isFamilyShareable"] = product.isFamilyShareableIOS
435
+ result["jsonRepresentation"] = product.jsonRepresentationIOS
436
+
437
+ return result
1239
438
  }
1240
439
 
1241
- // Ensure cleanup when module is deallocated
1242
- deinit {
1243
- cleanupExistingState()
1244
- }
1245
- }
1246
-
1247
- // PaymentObserver for handling promoted products
1248
- @available(iOS 15.0, *)
1249
- class PaymentObserver: NSObject, SKPaymentTransactionObserver {
1250
- weak var module: ExpoIapModule?
1251
-
1252
- init(module: ExpoIapModule) {
1253
- self.module = module
1254
- }
1255
-
1256
- // Required by SKPaymentTransactionObserver protocol but not used
1257
- func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
1258
- // We don't handle transactions here as StoreKit 2 handles them in ExpoIapModule
1259
- }
1260
-
1261
- // Handle promoted products from App Store
1262
- func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool {
1263
- module?.handlePromotedProduct(payment: payment, product: product)
1264
- // Return false to defer the payment
1265
- return false
1266
- }
1267
440
  }