expo-iap 2.8.6 → 2.9.0-rc.1

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 (44) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/CLAUDE.md +7 -0
  3. package/CONTRIBUTING.md +3 -4
  4. package/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +120 -7
  5. package/android/src/main/java/expo/modules/iap/Types.kt +1 -1
  6. package/build/helpers/subscription.d.ts.map +1 -1
  7. package/build/helpers/subscription.js +3 -6
  8. package/build/helpers/subscription.js.map +1 -1
  9. package/build/index.d.ts +31 -5
  10. package/build/index.d.ts.map +1 -1
  11. package/build/index.js +53 -25
  12. package/build/index.js.map +1 -1
  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.map +1 -1
  16. package/build/modules/ios.js.map +1 -1
  17. package/build/types/ExpoIapAndroid.types.d.ts +2 -2
  18. package/build/types/ExpoIapAndroid.types.d.ts.map +1 -1
  19. package/build/types/ExpoIapAndroid.types.js.map +1 -1
  20. package/build/types/ExpoIapIOS.types.d.ts +3 -3
  21. package/build/types/ExpoIapIOS.types.d.ts.map +1 -1
  22. package/build/types/ExpoIapIOS.types.js.map +1 -1
  23. package/build/useIAP.d.ts +12 -4
  24. package/build/useIAP.d.ts.map +1 -1
  25. package/build/useIAP.js +10 -5
  26. package/build/useIAP.js.map +1 -1
  27. package/ios/ExpoIap.podspec +1 -0
  28. package/ios/ExpoIapModule.swift +354 -1159
  29. package/jest.config.js +14 -17
  30. package/package.json +5 -3
  31. package/plugin/build/withIAP.d.ts +7 -1
  32. package/plugin/build/withIAP.js +16 -2
  33. package/plugin/build/withLocalOpenIAP.d.ts +9 -0
  34. package/plugin/build/withLocalOpenIAP.js +85 -0
  35. package/plugin/src/withIAP.ts +21 -2
  36. package/plugin/src/withLocalOpenIAP.ts +66 -0
  37. package/plugin/tsconfig.tsbuildinfo +1 -1
  38. package/src/helpers/subscription.ts +21 -28
  39. package/src/index.ts +70 -33
  40. package/src/modules/android.ts +7 -7
  41. package/src/modules/ios.ts +11 -5
  42. package/src/types/ExpoIapAndroid.types.ts +3 -4
  43. package/src/types/ExpoIapIOS.types.ts +4 -3
  44. package/src/useIAP.ts +40 -12
@@ -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,1231 +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("requestProducts") { (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
  }
67
+
68
+ let serializedProducts = products.map { self.serializeProduct($0) }
69
+ logDebug("Serialized products: \(serializedProducts)")
70
+ return serializedProducts
468
71
  }
469
-
470
- AsyncFunction("endConnection") { () -> Bool in
471
- self.cleanupExistingState()
472
- return true
473
- }
474
-
72
+
475
73
  AsyncFunction("getAvailableItems") {
476
- (alsoPublishToEventListenerIOS: Bool, onlyIncludeActiveItemsIOS: Bool) -> [[String: Any?]?] in
477
-
478
- try self.ensureConnection()
479
-
480
- var purchasedItemsSerialized: [[String: Any?]] = []
481
-
482
- func addTransaction(transaction: Transaction, jwsRepresentationIOS: String? = nil) {
483
- let serialized = serializeTransaction(transaction, jwsRepresentationIOS: jwsRepresentationIOS)
484
- purchasedItemsSerialized.append(serialized)
485
-
486
- if alsoPublishToEventListenerIOS {
487
- self.sendEvent(IapEvent.PurchaseUpdated, serialized)
488
- }
489
- }
490
-
491
- for await verification in onlyIncludeActiveItemsIOS
492
- ? Transaction.currentEntitlements : Transaction.all
493
- {
494
- do {
495
- let transaction = try self.checkVerified(verification)
496
- if !onlyIncludeActiveItemsIOS {
497
- addTransaction(transaction: transaction, jwsRepresentationIOS: verification.jwsRepresentation)
498
- continue
499
- }
500
- switch transaction.productType {
501
- case .nonConsumable, .autoRenewable, .consumable:
502
- if await self.productStore?.getProduct(productID: transaction.productID)
503
- != nil
504
- {
505
- addTransaction(transaction: transaction, jwsRepresentationIOS: verification.jwsRepresentation)
506
- }
507
- case .nonRenewable:
508
- if await self.productStore?.getProduct(productID: transaction.productID)
509
- != nil
510
- {
511
- let currentDate = Date()
512
- let expirationDate = Calendar(identifier: .gregorian).date(
513
- byAdding: DateComponents(year: 1), to: transaction.purchaseDate)!
514
- if currentDate < expirationDate {
515
- addTransaction(transaction: transaction, jwsRepresentationIOS: verification.jwsRepresentation)
516
- }
517
- }
518
- default:
519
- break
520
- }
521
- } catch StoreError.failedVerification {
522
- let err = [
523
- "responseCode": IapErrorCode.transactionValidationFailed,
524
- "debugMessage": StoreError.failedVerification.localizedDescription,
525
- "code": IapErrorCode.transactionValidationFailed,
526
- "message": StoreError.failedVerification.localizedDescription,
527
- "productId": "unknown",
528
- ]
529
- if alsoPublishToEventListenerIOS {
530
- self.sendEvent(IapEvent.PurchaseError, err)
531
- }
532
- } catch {
533
- let err = [
534
- "responseCode": IapErrorCode.unknown,
535
- "debugMessage": error.localizedDescription,
536
- "code": IapErrorCode.unknown,
537
- "message": error.localizedDescription,
538
- "productId": "unknown",
539
- ]
540
- if alsoPublishToEventListenerIOS {
541
- self.sendEvent(IapEvent.PurchaseError, err)
542
- }
543
- }
544
- }
545
- 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) }
546
83
  }
547
-
84
+
548
85
  AsyncFunction("requestPurchase") {
549
- (
550
- sku: String, andDangerouslyFinishTransactionAutomatically: Bool,
551
- appAccountToken: String?, quantity: Int, discountOffer: [String: String]?
552
- ) -> [String: Any?]? in
86
+ (sku: String,
87
+ andDangerouslyFinishTransactionAutomaticallyIOS: Bool?,
88
+ appAccountToken: String?,
89
+ quantity: Int?,
90
+ discountOffer: [String: String]?) async throws -> [String: Any?]? in
553
91
 
554
- try self.ensureConnection()
555
- let productStore = self.productStore!
556
-
557
- let product: Product? = await productStore.getProduct(productID: sku)
558
- if let product = product {
559
- do {
560
- var options: Set<Product.PurchaseOption> = []
561
- if quantity > -1 {
562
- options.insert(.quantity(quantity))
563
- }
564
- if let offerID = discountOffer?["identifier"],
565
- let keyID = discountOffer?["keyIdentifier"],
566
- let nonce = discountOffer?["nonce"],
567
- let signature = discountOffer?["signature"],
568
- let timestamp = discountOffer?["timestamp"],
569
- let uuidNonce = UUID(uuidString: nonce),
570
- let signatureData = Data(base64Encoded: signature),
571
- let timestampInt = Int(timestamp)
572
- {
573
- options.insert(
574
- .promotionalOffer(
575
- offerID: offerID, keyID: keyID, nonce: uuidNonce,
576
- signature: signatureData, timestamp: timestampInt))
577
- }
578
- if let appAccountToken = appAccountToken,
579
- let appAccountUUID = UUID(uuidString: appAccountToken)
580
- {
581
- options.insert(.appAccountToken(appAccountUUID))
582
- }
583
- guard let windowScene = await self.currentWindowScene() else {
584
- let errorData = [
585
- "responseCode": IapErrorCode.serviceError,
586
- "debugMessage": "Could not find window scene",
587
- "code": IapErrorCode.serviceError,
588
- "message": "Could not find window scene",
589
- "productId": sku,
590
- ]
591
- self.sendEvent(IapEvent.PurchaseError, errorData)
592
- throw Exception(name: "ExpoIapModule", description: "Could not find window scene", code: IapErrorCode.serviceError)
593
- }
594
- let result: Product.PurchaseResult
595
- #if swift(>=5.9)
596
- if #available(iOS 17.0, tvOS 17.0, *) {
597
- result = try await product.purchase(
598
- confirmIn: windowScene, options: options)
599
- } else {
600
- #if !os(visionOS)
601
- result = try await product.purchase(options: options)
602
- #endif
603
- }
604
- #elseif !os(visionOS)
605
- result = try await product.purchase(options: options)
606
- #endif
607
-
608
- switch result {
609
- case .success(let verification):
610
- let transaction = try self.checkVerified(verification)
611
-
612
- // Debug: Log JWS representation
613
- let jwsRepresentation = verification.jwsRepresentation
614
- if !jwsRepresentation.isEmpty {
615
- logDebug("buyProduct JWS: exists")
616
- logDebug("buyProduct JWS length: \(jwsRepresentation.count)")
617
- } else {
618
- logDebug("buyProduct JWS: empty string")
619
- }
620
-
621
- if andDangerouslyFinishTransactionAutomatically {
622
- await transaction.finish()
623
- return nil
624
- } else {
625
- self.transactions[String(transaction.id)] = transaction
626
- let serialized = serializeTransaction(transaction, jwsRepresentationIOS: verification.jwsRepresentation)
627
-
628
- // Debug: Check if jwsRepresentationIOS is included in serialized result
629
- logDebug("buyProduct serialized includes JWS: \(serialized["jwsRepresentationIOS"] != nil)")
630
-
631
- self.sendEvent(IapEvent.PurchaseUpdated, serialized)
632
- return serialized
633
- }
634
- case .userCancelled:
635
- let errorData = [
636
- "responseCode": IapErrorCode.userCancelled,
637
- "debugMessage": "User cancelled the purchase",
638
- "code": IapErrorCode.userCancelled,
639
- "message": "User cancelled the purchase",
640
- "productId": sku,
641
- ]
642
- self.sendEvent(IapEvent.PurchaseError, errorData)
643
- throw Exception(name: "ExpoIapModule", description: "User cancelled the purchase", code: IapErrorCode.userCancelled)
644
- case .pending:
645
- let errorData = [
646
- "responseCode": IapErrorCode.deferredPayment,
647
- "debugMessage": "The payment was deferred",
648
- "code": IapErrorCode.deferredPayment,
649
- "message": "The payment was deferred",
650
- "productId": sku,
651
- ]
652
- self.sendEvent(IapEvent.PurchaseError, errorData)
653
- throw Exception(name: "ExpoIapModule", description: "The payment was deferred", code: IapErrorCode.deferredPayment)
654
- @unknown default:
655
- let errorData = [
656
- "responseCode": IapErrorCode.unknown,
657
- "debugMessage": "Unknown purchase result",
658
- "code": IapErrorCode.unknown,
659
- "message": "Unknown purchase result",
660
- "productId": sku,
661
- ]
662
- self.sendEvent(IapEvent.PurchaseError, errorData)
663
- throw Exception(name: "ExpoIapModule", description: "Unknown purchase result", code: IapErrorCode.unknown)
664
- }
665
- } catch {
666
- if error is Exception {
667
- throw error
668
- }
669
-
670
- // Map StoreKit errors to proper error codes
671
- var errorCode = IapErrorCode.purchaseError
672
- var errorMessage = error.localizedDescription
673
-
674
- // Check for specific StoreKit error types
675
- if let nsError = error as NSError? {
676
- switch nsError.domain {
677
- case "SKErrorDomain":
678
- // Handle SKError codes
679
- switch nsError.code {
680
- case 0: // SKError.unknown
681
- errorCode = IapErrorCode.unknown
682
- case 1: // SKError.clientInvalid
683
- errorCode = IapErrorCode.serviceError
684
- case 2: // SKError.paymentCancelled
685
- errorCode = IapErrorCode.userCancelled
686
- errorMessage = "User cancelled the purchase"
687
- case 3: // SKError.paymentInvalid
688
- errorCode = IapErrorCode.userError
689
- case 4: // SKError.paymentNotAllowed
690
- errorCode = IapErrorCode.userError
691
- errorMessage = "Payment not allowed"
692
- case 5: // SKError.storeProductNotAvailable
693
- errorCode = IapErrorCode.itemUnavailable
694
- case 6: // SKError.cloudServicePermissionDenied
695
- errorCode = IapErrorCode.serviceError
696
- case 7: // SKError.cloudServiceNetworkConnectionFailed
697
- errorCode = IapErrorCode.networkError
698
- case 8: // SKError.cloudServiceRevoked
699
- errorCode = IapErrorCode.serviceError
700
- default:
701
- errorCode = IapErrorCode.purchaseError
702
- }
703
- case "NSURLErrorDomain":
704
- errorCode = IapErrorCode.networkError
705
- errorMessage = "Network error: \(error.localizedDescription)"
706
- default:
707
- errorCode = IapErrorCode.purchaseError
708
- }
709
- } else if error.localizedDescription.lowercased().contains("network") {
710
- errorCode = IapErrorCode.networkError
711
- } else if error.localizedDescription.lowercased().contains("cancelled") {
712
- errorCode = IapErrorCode.userCancelled
713
- }
714
-
715
- let errorData = [
716
- "responseCode": errorCode,
717
- "debugMessage": "Purchase failed: \(error.localizedDescription)",
718
- "code": errorCode,
719
- "message": errorMessage,
720
- "productId": sku,
721
- ]
722
- self.sendEvent(IapEvent.PurchaseError, errorData)
723
- throw Exception(name: "ExpoIapModule", description: errorMessage, code: errorCode)
724
- }
725
- } else {
726
- let errorData = [
727
- "responseCode": IapErrorCode.itemUnavailable,
728
- "debugMessage": "Invalid product ID",
729
- "code": IapErrorCode.itemUnavailable,
730
- "message": "Invalid product ID",
731
- "productId": sku,
732
- ]
733
- self.sendEvent(IapEvent.PurchaseError, errorData)
734
- throw Exception(name: "ExpoIapModule", description: "Invalid product ID", code: IapErrorCode.itemUnavailable)
735
- }
736
- }
737
-
738
- AsyncFunction("isEligibleForIntroOfferIOS") { (groupID: String) -> Bool in
739
- 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)
740
107
  }
741
-
742
- AsyncFunction("subscriptionStatusIOS") { (sku: String) -> [[String: Any?]?]? in
743
- try self.ensureConnection()
744
- let productStore = self.productStore!
745
-
746
- do {
747
- let product = await productStore.getProduct(productID: sku)
748
- let status: [Product.SubscriptionInfo.Status]? = try await product?.subscription?
749
- .status
750
- guard let status = status else {
751
- return nil
752
- }
753
- return status.map { serializeSubscriptionStatus($0) }
754
- } catch {
755
- if error is Exception {
756
- throw error
757
- }
758
- throw Exception(name: "ExpoIapModule", description: "Error getting subscription status: \(error.localizedDescription)", code: IapErrorCode.serviceError)
759
- }
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)
760
112
  }
761
-
762
- AsyncFunction("currentEntitlementIOS") { (sku: String) -> [String: Any?]? in
763
- try self.ensureConnection()
764
- let productStore = self.productStore!
765
-
766
- if let product = await productStore.getProduct(productID: sku) {
767
- if let result = await product.currentEntitlement {
768
- do {
769
- let transaction = try self.checkVerified(result)
770
- return serializeTransaction(transaction)
771
- } catch StoreError.failedVerification {
772
- throw Exception(name: "ExpoIapModule", description: "Failed to verify transaction for sku \(sku)", code: IapErrorCode.transactionValidationFailed)
773
- } catch {
774
- if error is Exception {
775
- throw error
776
- }
777
- throw Exception(name: "ExpoIapModule", description: "Error fetching entitlement for sku \(sku): \(error.localizedDescription)", code: IapErrorCode.serviceError)
778
- }
779
- } else {
780
- throw Exception(name: "ExpoIapModule", description: "Can't find entitlement for sku \(sku)", code: IapErrorCode.itemUnavailable)
781
- }
782
- } else {
783
- throw Exception(name: "ExpoIapModule", description: "Can't find product for sku \(sku)", code: IapErrorCode.itemUnavailable)
784
- }
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) }
785
118
  }
786
-
787
- AsyncFunction("latestTransactionIOS") { (sku: String) -> [String: Any?]? in
788
- try self.ensureConnection()
789
- let productStore = self.productStore!
790
-
791
- if let product = await productStore.getProduct(productID: sku) {
792
- if let result = await product.latestTransaction {
793
- do {
794
- let transaction = try self.checkVerified(result)
795
- return serializeTransaction(transaction)
796
- } catch StoreError.failedVerification {
797
- throw Exception(name: "ExpoIapModule", description: "Failed to verify transaction for sku \(sku)", code: IapErrorCode.transactionValidationFailed)
798
- } catch {
799
- if error is Exception {
800
- throw error
801
- }
802
- throw Exception(name: "ExpoIapModule", description: "Error fetching latest transaction for sku \(sku): \(error.localizedDescription)", code: IapErrorCode.serviceError)
803
- }
804
- } else {
805
- throw Exception(name: "ExpoIapModule", description: "Can't find latest transaction for sku \(sku)", code: IapErrorCode.itemUnavailable)
806
- }
807
- } else {
808
- throw Exception(name: "ExpoIapModule", description: "Can't find product for sku \(sku)", code: IapErrorCode.itemUnavailable)
809
- }
119
+
120
+ AsyncFunction("clearTransactionIOS") { () async throws in
121
+ logDebug("clearTransactionIOS called")
122
+ try await self.iapModule.clearTransactionIOS()
810
123
  }
811
-
812
- AsyncFunction("finishTransaction") { (transactionIdentifier: String) -> Bool in
813
- if let transaction = self.transactions[transactionIdentifier] {
814
- await transaction.finish()
815
- self.transactions.removeValue(forKey: transactionIdentifier)
816
- return true
817
- } else {
818
- throw Exception(name: "ExpoIapModule", description: "Invalid transaction ID", code: IapErrorCode.developerError)
819
- }
124
+
125
+ AsyncFunction("getReceiptDataIOS") { () async throws -> String? in
126
+ logDebug("getReceiptDataIOS called")
127
+ return try await self.iapModule.getReceiptDataIOS()
820
128
  }
821
-
822
- AsyncFunction("getPendingTransactionsIOS") { () -> [[String: Any?]?] in
823
- 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)
824
133
  }
825
-
826
- AsyncFunction("syncIOS") { () -> Bool in
827
- do {
828
- try await AppStore.sync()
829
- return true
830
- } catch {
831
- if error is Exception {
832
- throw error
833
- }
834
- 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)
835
147
  }
148
+ return result
836
149
  }
837
-
838
- AsyncFunction("presentCodeRedemptionSheetIOS") { () -> Bool in
839
- #if !os(tvOS)
840
- SKPaymentQueue.default().presentCodeRedemptionSheet()
841
- return true
842
- #else
843
- throw Exception(name: "ExpoIapModule", description: "This method is not available on tvOS", code: IapErrorCode.serviceError)
844
- #endif
150
+
151
+ AsyncFunction("getStorefrontIOS") { () async throws -> String in
152
+ logDebug("getStorefrontIOS called")
153
+ return try await self.iapModule.getStorefrontIOS()
845
154
  }
846
-
847
- AsyncFunction("showManageSubscriptionsIOS") { () -> Bool in
848
- #if !os(tvOS)
849
- guard let windowScene = await self.currentWindowScene() else {
850
- throw Exception(name: "ExpoIapModule", description: "Cannot find window scene or not available on macOS", code: IapErrorCode.serviceError)
851
- }
852
- // Get all subscription products before showing the management UI
853
- let subscriptionSkus = await self.getAllSubscriptionProductIds()
854
- self.pollingSkus = Set(subscriptionSkus)
855
- // Show the management UI
856
- try await AppStore.showManageSubscriptions(in: windowScene)
857
- // Start polling for status changes
858
- self.pollForSubscriptionStatusChanges()
859
- return true
860
- #else
861
- throw Exception(name: "ExpoIapModule", description: "This method is not available on tvOS", code: IapErrorCode.serviceError)
862
- #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()
863
160
  }
864
-
865
- AsyncFunction("clearTransactionIOS") { () -> Void in
866
- Task {
867
- for await result in Transaction.unfinished {
868
- do {
869
- let transaction = try self.checkVerified(result)
870
- await transaction.finish()
871
- self.transactions.removeValue(forKey: String(transaction.id))
872
- } catch {
873
- if error is Exception {
874
- throw error
875
- }
876
- print("Failed to finish transaction")
877
- }
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
+ ]
878
175
  }
879
176
  }
177
+ return nil
880
178
  }
881
-
882
- AsyncFunction("beginRefundRequestIOS") { (sku: String) -> String? in
883
- #if !os(tvOS)
884
- try self.ensureConnection()
885
- let productStore = self.productStore!
886
-
887
- guard let product = await productStore.getProduct(productID: sku),
888
- let result = await product.latestTransaction
889
- else {
890
- throw Exception(name: "ExpoIapModule", description: "Can't find product or transaction for sku \(sku)", code: IapErrorCode.itemUnavailable)
891
- }
892
-
893
- do {
894
- let transaction = try self.checkVerified(result)
895
- guard let windowScene = await self.currentWindowScene() else {
896
- throw Exception(name: "ExpoIapModule", description: "Cannot find window scene or not available on macOS", code: IapErrorCode.serviceError)
897
- }
898
- let refundStatus = try await transaction.beginRefundRequest(in: windowScene)
899
- return serialize(refundStatus)
900
- } catch StoreError.failedVerification {
901
- throw Exception(name: "ExpoIapModule", description: "Failed to verify transaction for sku \(sku)", code: IapErrorCode.transactionValidationFailed)
902
- } catch {
903
- if error is Exception {
904
- throw error
905
- }
906
- throw Exception(name: "ExpoIapModule", description: "Failed to refund purchase: \(error.localizedDescription)", code: IapErrorCode.serviceError)
907
- }
908
- #else
909
- throw Exception(name: "ExpoIapModule", description: "This method is not available on tvOS", code: IapErrorCode.serviceError)
910
- #endif
911
- }
912
-
913
- // @deprecated - This function is deprecated and will be removed in v2.9.0
914
- // The transaction observer is now managed automatically
915
- Function("disable") { () -> Bool in
916
- // No longer needed - observer management is automatic
917
- return true
918
- }
919
-
920
- AsyncFunction("getReceiptDataIOS") { () -> String? in
921
- 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)
922
183
  }
923
-
924
- AsyncFunction("isTransactionVerifiedIOS") { (sku: String) -> Bool in
925
- try self.ensureConnection()
926
- let productStore = self.productStore!
927
-
928
- if let product = await productStore.getProduct(productID: sku),
929
- let result = await product.latestTransaction {
930
- do {
931
- // If this doesn't throw, the transaction is verified
932
- _ = try self.checkVerified(result)
933
- return true
934
- } catch {
935
- 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
+ ]
936
197
  }
937
198
  }
938
- return false
199
+ return nil
939
200
  }
940
-
941
- AsyncFunction("getTransactionJwsIOS") { (sku: String) -> String? in
942
- try self.ensureConnection()
943
- let productStore = self.productStore!
944
-
945
- if let product = await productStore.getProduct(productID: sku),
946
- let result = await product.latestTransaction {
947
- return result.jwsRepresentation
948
- } else {
949
- 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)
950
206
  }
207
+ return nil
951
208
  }
952
-
953
- AsyncFunction("validateReceiptIOS") { (sku: String) -> [String: Any] in
954
- try self.ensureConnection()
955
- let productStore = self.productStore!
956
-
957
- // Get receipt data
958
- var receiptData: String = ""
959
- do {
960
- receiptData = try self.getReceiptDataInternal()
961
- } catch {
962
- // Continue with validation even if receipt retrieval fails
963
- // Error will be reflected by empty receipt data
964
- }
965
-
966
- var isValid = false
967
- var jwsRepresentation: String? = nil
968
- var latestTransaction: [String: Any?]? = nil
969
-
970
- // Get JWS representation and verify transaction
971
- if let product = await productStore.getProduct(productID: sku),
972
- let result = await product.latestTransaction {
973
- jwsRepresentation = result.jwsRepresentation
974
-
975
- do {
976
- // If this doesn't throw, the transaction is verified
977
- let transaction = try self.checkVerified(result)
978
- isValid = true
979
- latestTransaction = serializeTransaction(transaction, jwsRepresentationIOS: result.jwsRepresentation)
980
- } catch {
981
- isValid = false
982
- }
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)
983
214
  }
984
-
985
- return [
986
- "isValid": isValid,
987
- "receiptData": receiptData,
988
- "jwsRepresentation": jwsRepresentation ?? "",
989
- "latestTransaction": latestTransaction as Any
990
- ]
991
- }
992
- }
993
-
994
- // Similar to Android's ensureConnection pattern
995
- private func ensureConnection() throws {
996
- guard isInitialized else {
997
- throw Exception(
998
- name: "ExpoIapModule",
999
- description: "Connection not initialized. Call initConnection() first.",
1000
- code: IapErrorCode.notPrepared
1001
- )
215
+ return nil
1002
216
  }
1003
217
 
1004
- guard productStore != nil else {
1005
- throw Exception(
1006
- name: "ExpoIapModule",
1007
- description: "Product store not available",
1008
- code: IapErrorCode.notPrepared
1009
- )
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)
1010
221
  }
1011
- }
1012
-
1013
- private func cleanupExistingState() {
1014
- // Cancel any existing tasks
1015
- updateListenerTask?.cancel()
1016
- updateListenerTask = nil
1017
222
 
1018
- subscriptionPollingTask?.cancel()
1019
- 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
+ }
1020
233
 
1021
- // Clear collections
1022
- transactions.removeAll()
1023
- pollingSkus.removeAll()
234
+ AsyncFunction("requestPurchaseOnPromotedProductIOS") { () async throws in
235
+ logDebug("requestPurchaseOnPromotedProductIOS called")
236
+ try await self.iapModule.requestPurchaseOnPromotedProductIOS()
237
+ }
1024
238
 
1025
- // Reset promoted products
1026
- promotedPayment = nil
1027
- promotedProduct = nil
239
+ AsyncFunction("syncIOS") { () async throws -> Bool in
240
+ logDebug("syncIOS called")
241
+ return try await self.iapModule.syncIOS()
242
+ }
1028
243
 
1029
- // Remove existing payment observer if any
1030
- if let observer = paymentObserver {
1031
- SKPaymentQueue.default().remove(observer)
1032
- paymentObserver = nil
244
+ AsyncFunction("presentCodeRedemptionSheetIOS") { () async throws -> Bool in
245
+ logDebug("presentCodeRedemptionSheetIOS called")
246
+ return try await self.iapModule.presentCodeRedemptionSheetIOS()
1033
247
  }
1034
248
 
1035
- // Clear product store
1036
- if let store = productStore {
1037
- Task {
1038
- await store.removeAll()
1039
- }
249
+ AsyncFunction("showManageSubscriptionsIOS") { () async throws -> Bool in
250
+ logDebug("showManageSubscriptionsIOS called")
251
+ return try await self.iapModule.showManageSubscriptionsIOS()
1040
252
  }
1041
- productStore = nil
1042
253
 
1043
- isInitialized = false
1044
- }
1045
-
1046
- private func addTransactionObserver() {
1047
- if updateListenerTask == nil {
1048
- 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)
1049
257
  }
1050
258
  }
1051
-
1052
- private func removeTransactionObserver() {
1053
- updateListenerTask?.cancel()
1054
- updateListenerTask = nil
1055
- }
1056
-
1057
- private func listenForTransactions() -> Task<Void, Error> {
1058
- return Task.detached { [weak self] in
1059
- guard let self = self else { return }
1060
- for await result in Transaction.updates {
1061
- do {
1062
- let transaction = try self.checkVerified(result)
1063
- self.transactions[String(transaction.id)] = transaction
1064
- if self.hasListeners {
1065
- let serialized = serializeTransaction(transaction, jwsRepresentationIOS: result.jwsRepresentation)
1066
- self.sendEvent(IapEvent.PurchaseUpdated, serialized)
1067
- }
1068
- } catch {
1069
- if self.hasListeners {
1070
- let err = [
1071
- "responseCode": IapErrorCode.transactionValidationFailed,
1072
- "debugMessage": error.localizedDescription,
1073
- "code": IapErrorCode.transactionValidationFailed,
1074
- "message": error.localizedDescription,
1075
- ]
1076
- self.sendEvent(IapEvent.PurchaseError, err)
1077
- }
1078
- }
1079
- }
259
+
260
+ // MARK: - Purchase Listeners
261
+
262
+ private func setupPurchaseListeners() {
263
+ _ = iapModule.purchaseUpdatedListener { [weak self] purchase in
264
+ self?.handlePurchaseUpdated(purchase)
1080
265
  }
1081
- }
1082
-
1083
- public func startObserving() {
1084
- hasListeners = true
1085
- addTransactionObserver()
1086
- }
1087
-
1088
- public func stopObserving() {
1089
- hasListeners = false
1090
- removeTransactionObserver()
1091
- }
1092
-
1093
- private func currentWindowScene() async -> UIWindowScene? {
1094
- await MainActor.run {
1095
- return UIApplication.shared.connectedScenes.first as? UIWindowScene
266
+ _ = iapModule.purchaseErrorListener { [weak self] error in
267
+ self?.handlePurchaseError(error)
1096
268
  }
1097
269
  }
1098
-
1099
- private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
1100
- switch result {
1101
- case .unverified(_, let error):
1102
- throw error
1103
- case .verified(let item):
1104
- return item
1105
- }
270
+
271
+ private func handlePurchaseUpdated(_ purchase: OpenIapPurchase) {
272
+ logDebug("Purchase updated: \(purchase.productId)")
273
+ let serialized = serializePurchase(purchase)
274
+ sendEvent(OpenIapEvent.PurchaseUpdated, serialized)
1106
275
  }
1107
-
1108
- private func getAllSubscriptionProductIds() async -> [String] {
1109
- guard let productStore = self.productStore else { return [] }
1110
- let products = await productStore.getAllProducts()
1111
- return products.compactMap { product in
1112
- if product.subscription != nil {
1113
- return product.id
1114
- }
1115
- return nil
1116
- }
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)
1117
285
  }
1118
-
1119
- private func pollForSubscriptionStatusChanges() {
1120
- subscriptionPollingTask?.cancel()
1121
- subscriptionPollingTask = Task {
1122
- 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,
1123
298
 
1124
- 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,
1125
305
 
1126
- for sku in self.pollingSkus {
1127
- guard let product = await self.productStore?.getProduct(productID: sku),
1128
- let status = try? await product.subscription?.status.first else { continue }
1129
-
1130
- // Track willAutoRenew as a bool value
1131
- var willAutoRenew = false
1132
- if case .verified(let info) = status.renewalInfo {
1133
- willAutoRenew = info.willAutoRenew
1134
- }
1135
- previousStatuses[sku] = willAutoRenew
1136
- }
1137
-
1138
- for _ in 1...5 {
1139
- try? await Task.sleep(nanoseconds: 2_000_000_000) // 2 seconds
1140
- if Task.isCancelled {
1141
- return
1142
- }
1143
-
1144
- for sku in self.pollingSkus {
1145
- guard let product = await self.productStore?.getProduct(productID: sku),
1146
- let status = try? await product.subscription?.status.first,
1147
- let result = await product.latestTransaction else { continue }
1148
- // Try to verify the transaction
1149
- let transaction: Transaction
1150
- do {
1151
- transaction = try self.checkVerified(result)
1152
- } catch {
1153
- continue // Skip if verification fails
1154
- }
1155
-
1156
- // Track current auto-renewal state
1157
- var currentWillAutoRenew = false
1158
- if case .verified(let info) = status.renewalInfo {
1159
- currentWillAutoRenew = info.willAutoRenew
1160
- }
1161
-
1162
- // Compare with previous state
1163
- if let previousWillAutoRenew = previousStatuses[sku],
1164
- previousWillAutoRenew != currentWillAutoRenew {
1165
-
1166
- // Use the jwsRepresentation when serializing the transaction
1167
- var purchaseMap = serializeTransaction(transaction, jwsRepresentationIOS: result.jwsRepresentation)
1168
-
1169
- if case .verified(let renewalInfo) = status.renewalInfo {
1170
- if let renewalInfoDict = serializeRenewalInfo(.verified(renewalInfo)) {
1171
- purchaseMap["renewalInfo"] = renewalInfoDict
1172
- }
1173
- }
1174
-
1175
- self.sendEvent(IapEvent.PurchaseUpdated, purchaseMap)
1176
- previousStatuses[sku] = currentWillAutoRenew
1177
- }
1178
- }
1179
- }
1180
- self.pollingSkus.removeAll()
1181
- }
1182
- }
1183
-
1184
- private func getReceiptDataInternal() throws -> String {
1185
- if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
1186
- FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
1187
- do {
1188
- let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
1189
- return receiptData.base64EncodedString(options: [])
1190
- } catch {
1191
- throw Exception(name: "ExpoIapModule", description: "Error reading receipt data: \(error.localizedDescription)", code: IapErrorCode.receiptFailed)
1192
- }
1193
- } else {
1194
- throw Exception(name: "ExpoIapModule", description: "App Store receipt not found", code: IapErrorCode.receiptFailed)
1195
- }
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
+ ]
1196
340
  }
1197
341
 
1198
- // Called by PaymentObserver when a promoted product is received
1199
- func handlePromotedProduct(payment: SKPayment, product: SKProduct) {
1200
- self.promotedPayment = payment
1201
- 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
+ ]
1202
387
 
1203
- if hasListeners {
1204
- let productData: [String: Any] = [
1205
- "productIdentifier": product.productIdentifier,
1206
- "localizedTitle": product.localizedTitle,
1207
- "localizedDescription": product.localizedDescription,
1208
- "price": product.price.doubleValue,
1209
- "priceLocale": [
1210
- "currencyCode": product.priceLocale.currencyCode ?? "",
1211
- "currencySymbol": product.priceLocale.currencySymbol ?? "",
1212
- "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
1213
395
  ]
1214
396
  ]
1215
- 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
1216
431
  }
432
+
433
+ // Deprecated fields for backward compatibility
434
+ result["isFamilyShareable"] = product.isFamilyShareableIOS
435
+ result["jsonRepresentation"] = product.jsonRepresentationIOS
436
+
437
+ return result
1217
438
  }
1218
439
 
1219
- // Ensure cleanup when module is deallocated
1220
- deinit {
1221
- cleanupExistingState()
1222
- }
1223
- }
1224
-
1225
- // PaymentObserver for handling promoted products
1226
- @available(iOS 15.0, *)
1227
- class PaymentObserver: NSObject, SKPaymentTransactionObserver {
1228
- weak var module: ExpoIapModule?
1229
-
1230
- init(module: ExpoIapModule) {
1231
- self.module = module
1232
- }
1233
-
1234
- // Required by SKPaymentTransactionObserver protocol but not used
1235
- func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) {
1236
- // We don't handle transactions here as StoreKit 2 handles them in ExpoIapModule
1237
- }
1238
-
1239
- // Handle promoted products from App Store
1240
- func paymentQueue(_ queue: SKPaymentQueue, shouldAddStorePayment payment: SKPayment, for product: SKProduct) -> Bool {
1241
- module?.handlePromotedProduct(payment: payment, product: product)
1242
- // Return false to defer the payment
1243
- return false
1244
- }
1245
440
  }