expo-iap 2.7.14 → 2.8.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 (46) hide show
  1. package/.copilot-instructions.md +5 -5
  2. package/.cursorrules +31 -13
  3. package/CHANGELOG.md +53 -1
  4. package/CLAUDE.md +45 -4
  5. package/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +52 -35
  6. package/build/ExpoIap.types.d.ts +21 -14
  7. package/build/ExpoIap.types.d.ts.map +1 -1
  8. package/build/ExpoIap.types.js +0 -1
  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 +24 -17
  12. package/build/helpers/subscription.js.map +1 -1
  13. package/build/index.d.ts +2 -2
  14. package/build/index.js +10 -10
  15. package/build/index.js.map +1 -1
  16. package/build/modules/ios.d.ts +10 -23
  17. package/build/modules/ios.d.ts.map +1 -1
  18. package/build/modules/ios.js +9 -23
  19. package/build/modules/ios.js.map +1 -1
  20. package/build/types/ExpoIapAndroid.types.d.ts +50 -15
  21. package/build/types/ExpoIapAndroid.types.d.ts.map +1 -1
  22. package/build/types/ExpoIapAndroid.types.js +11 -6
  23. package/build/types/ExpoIapAndroid.types.js.map +1 -1
  24. package/build/types/{ExpoIapIos.types.d.ts → ExpoIapIOS.types.d.ts} +68 -39
  25. package/build/types/ExpoIapIOS.types.d.ts.map +1 -0
  26. package/build/types/ExpoIapIOS.types.js +2 -0
  27. package/build/types/ExpoIapIOS.types.js.map +1 -0
  28. package/build/{useIap.d.ts → useIAP.d.ts} +2 -2
  29. package/build/{useIap.d.ts.map → useIAP.d.ts.map} +1 -1
  30. package/build/{useIap.js → useIAP.js} +7 -6
  31. package/build/useIAP.js.map +1 -0
  32. package/bun.lock +677 -61
  33. package/ios/ExpoIapModule.swift +133 -51
  34. package/jest.config.js +43 -0
  35. package/package.json +8 -3
  36. package/src/ExpoIap.types.ts +44 -29
  37. package/src/helpers/subscription.ts +27 -20
  38. package/src/index.ts +13 -13
  39. package/src/modules/ios.ts +13 -40
  40. package/src/types/ExpoIapAndroid.types.ts +62 -15
  41. package/src/types/{ExpoIapIos.types.ts → ExpoIapIOS.types.ts} +73 -39
  42. package/src/{useIap.ts → useIAP.ts} +7 -6
  43. package/build/types/ExpoIapIos.types.d.ts.map +0 -1
  44. package/build/types/ExpoIapIos.types.js +0 -2
  45. package/build/types/ExpoIapIos.types.js.map +0 -1
  46. package/build/useIap.js.map +0 -1
@@ -22,12 +22,12 @@ struct IapEvent {
22
22
  }
23
23
 
24
24
  @available(iOS 15.0, *)
25
- func serializeTransaction(_ transaction: Transaction, jwsRepresentationIos: String? = nil) -> [String: Any?] {
25
+ func serializeTransaction(_ transaction: Transaction, jwsRepresentationIOS: String? = nil) -> [String: Any?] {
26
26
  let _ =
27
27
  transaction.productType.rawValue.lowercased().contains("renewable")
28
28
  || transaction.expirationDate != nil
29
29
 
30
- var transactionReasonIos: String? = nil
30
+ var transactionReasonIOS: String? = nil
31
31
  var webOrderLineItemId: Int? = nil
32
32
  var jsonData: [String: Any]? = nil
33
33
  var jwsReceipt: String = ""
@@ -38,7 +38,7 @@ func serializeTransaction(_ transaction: Transaction, jwsRepresentationIos: Stri
38
38
  do {
39
39
  if let jsonObj = try JSONSerialization.jsonObject(with: jsonRep) as? [String: Any] {
40
40
  jsonData = jsonObj
41
- transactionReasonIos = jsonObj["transactionReason"] as? String
41
+ transactionReasonIOS = jsonObj["transactionReason"] as? String
42
42
  if let webOrderId = jsonObj["webOrderLineItemID"] as? NSNumber {
43
43
  webOrderLineItemId = webOrderId.intValue
44
44
  }
@@ -56,47 +56,47 @@ func serializeTransaction(_ transaction: Transaction, jwsRepresentationIos: Stri
56
56
  "transactionReceipt": jwsReceipt,
57
57
  "platform": "ios",
58
58
 
59
- "quantityIos": transaction.purchasedQuantity,
60
- "originalTransactionDateIos": transaction.originalPurchaseDate.timeIntervalSince1970 * 1000,
61
- "originalTransactionIdentifierIos": String(transaction.originalID),
59
+ "quantityIOS": transaction.purchasedQuantity,
60
+ "originalTransactionDateIOS": transaction.originalPurchaseDate.timeIntervalSince1970 * 1000,
61
+ "originalTransactionIdentifierIOS": String(transaction.originalID),
62
62
  "appAccountToken": transaction.appAccountToken?.uuidString,
63
63
 
64
- "appBundleIdIos": transaction.appBundleID,
65
- "productTypeIos": transaction.productType.rawValue,
66
- "subscriptionGroupIdIos": transaction.subscriptionGroupID,
64
+ "appBundleIdIOS": transaction.appBundleID,
65
+ "productTypeIOS": transaction.productType.rawValue,
66
+ "subscriptionGroupIdIOS": transaction.subscriptionGroupID,
67
67
 
68
- "webOrderLineItemIdIos": webOrderLineItemId,
68
+ "webOrderLineItemIdIOS": webOrderLineItemId,
69
69
 
70
- "expirationDateIos": transaction.expirationDate.map { $0.timeIntervalSince1970 * 1000 },
70
+ "expirationDateIOS": transaction.expirationDate.map { $0.timeIntervalSince1970 * 1000 },
71
71
 
72
- "isUpgradedIos": transaction.isUpgraded,
73
- "ownershipTypeIos": transaction.ownershipType.rawValue,
72
+ "isUpgradedIOS": transaction.isUpgraded,
73
+ "ownershipTypeIOS": transaction.ownershipType.rawValue,
74
74
 
75
- "revocationDateIos": transaction.revocationDate.map { $0.timeIntervalSince1970 * 1000 },
76
- "revocationReasonIos": transaction.revocationReason?.rawValue,
77
- "transactionReasonIos": transactionReasonIos,
75
+ "revocationDateIOS": transaction.revocationDate.map { $0.timeIntervalSince1970 * 1000 },
76
+ "revocationReasonIOS": transaction.revocationReason?.rawValue,
77
+ "transactionReasonIOS": transactionReasonIOS,
78
78
  ]
79
79
 
80
- if (jwsRepresentationIos != nil) {
81
- logDebug("serializeTransaction adding jwsRepresentationIos with length: \(jwsRepresentationIos!.count)")
82
- purchaseMap["jwsRepresentationIos"] = jwsRepresentationIos
83
- purchaseMap["purchaseToken"] = jwsRepresentationIos
80
+ if (jwsRepresentationIOS != nil) {
81
+ logDebug("serializeTransaction adding jwsRepresentationIOS with length: \(jwsRepresentationIOS!.count)")
82
+ purchaseMap["jwsRepresentationIOS"] = jwsRepresentationIOS
83
+ purchaseMap["purchaseToken"] = jwsRepresentationIOS
84
84
  } else {
85
- logDebug("serializeTransaction jwsRepresentationIos is nil")
85
+ logDebug("serializeTransaction jwsRepresentationIOS is nil")
86
86
  }
87
87
 
88
88
  if #available(iOS 16.0, *) {
89
- purchaseMap["environmentIos"] = transaction.environment.rawValue
89
+ purchaseMap["environmentIOS"] = transaction.environment.rawValue
90
90
  }
91
91
 
92
92
  if #available(iOS 17.0, *) {
93
- purchaseMap["storefrontCountryCodeIos"] = transaction.storefront.countryCode
94
- purchaseMap["reasonIos"] = transaction.reason.rawValue
93
+ purchaseMap["storefrontCountryCodeIOS"] = transaction.storefront.countryCode
94
+ purchaseMap["reasonIOS"] = transaction.reason.rawValue
95
95
  }
96
96
 
97
97
  if #available(iOS 17.2, *) {
98
98
  if let offer = transaction.offer {
99
- purchaseMap["offerIos"] = [
99
+ purchaseMap["offerIOS"] = [
100
100
  "id": offer.id ?? "",
101
101
  "type": offer.type.rawValue,
102
102
  "paymentMode": offer.paymentMode?.rawValue ?? "",
@@ -106,10 +106,26 @@ func serializeTransaction(_ transaction: Transaction, jwsRepresentationIos: Stri
106
106
 
107
107
  if #available(iOS 15.4, *), let jsonData = jsonData {
108
108
  if let price = jsonData["price"] as? NSNumber {
109
- purchaseMap["priceIos"] = price.doubleValue
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
110
113
  }
111
114
  if let currency = jsonData["currency"] as? String {
112
- purchaseMap["currencyIos"] = currency
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
113
129
  }
114
130
  }
115
131
 
@@ -118,7 +134,7 @@ func serializeTransaction(_ transaction: Transaction, jwsRepresentationIos: Stri
118
134
 
119
135
  private let DEFAULT_SUBSCRIPTION_PERIOD_UNIT = "DAY" // Default fallback unit for subscription periods.
120
136
 
121
- func getPeriodIos(_ unit: Product.SubscriptionPeriod.Unit) -> String {
137
+ func getPeriodIOS(_ unit: Product.SubscriptionPeriod.Unit) -> String {
122
138
  return switch (unit) {
123
139
  case .day: "DAY"
124
140
  case .week: "WEEK"
@@ -135,7 +151,7 @@ func serializeOffer(_ offer: Product.SubscriptionOffer?) -> [String: Any?]? {
135
151
  return [
136
152
  "id": offer.id,
137
153
  "period": [
138
- "unit": getPeriodIos(offer.period.unit),
154
+ "unit": getPeriodIOS(offer.period.unit),
139
155
  "value": offer.period.value
140
156
  ],
141
157
  "periodCount": offer.periodCount,
@@ -151,9 +167,9 @@ func serializeSubscription(_ s: Product.SubscriptionInfo?) -> [String: Any?]? {
151
167
  return [
152
168
  "introductoryOffer": serializeOffer(s.introductoryOffer),
153
169
  "promotionalOffers": s.promotionalOffers.map(serializeOffer),
154
- "subscriptionGroupID": s.subscriptionGroupID,
170
+ "subscriptionGroupId": s.subscriptionGroupID,
155
171
  "subscriptionPeriod": [
156
- "unit": getPeriodIos(s.subscriptionPeriod.unit),
172
+ "unit": getPeriodIOS(s.subscriptionPeriod.unit),
157
173
  "value": s.subscriptionPeriod.value
158
174
  ],
159
175
  ]
@@ -162,20 +178,86 @@ func serializeSubscription(_ s: Product.SubscriptionInfo?) -> [String: Any?]? {
162
178
 
163
179
  @available(iOS 15.0, *)
164
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
+
165
224
  return [
166
225
  "debugDescription": serializeDebug(p.debugDescription),
167
226
  "description": p.description,
168
- "displayName": p.displayName,
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,
169
237
  "displayPrice": p.displayPrice,
170
238
  "id": p.id,
171
239
  "title": p.displayName,
172
- "isFamilyShareable": p.isFamilyShareable,
173
- "jsonRepresentation": String(data: p.jsonRepresentation, encoding: .utf8),
240
+ "isFamilyShareableIOS": p.isFamilyShareable,
241
+ "jsonRepresentationIOS": String(data: p.jsonRepresentation, encoding: .utf8),
174
242
  "price": p.price,
175
- "subscription": serializeSubscription(p.subscription),
176
- "type": p.type,
243
+ "subscriptionInfoIOS": serializeSubscription(p.subscription),
244
+ "type": productType,
177
245
  "currency": p.priceFormatStyle.currencyCode,
178
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
179
261
  ]
180
262
  }
181
263
 
@@ -290,7 +372,7 @@ public class ExpoIapModule: Module {
290
372
  }
291
373
 
292
374
  var result: [String: Any?] = [
293
- "bundleID": appTransaction.bundleID,
375
+ "bundleId": appTransaction.bundleID,
294
376
  "appVersion": appTransaction.appVersion,
295
377
  "originalAppVersion": appTransaction.originalAppVersion,
296
378
  "originalPurchaseDate": appTransaction.originalPurchaseDate.timeIntervalSince1970 * 1000,
@@ -298,13 +380,13 @@ public class ExpoIapModule: Module {
298
380
  "deviceVerificationNonce": appTransaction.deviceVerificationNonce.uuidString,
299
381
  "environment": appTransaction.environment.rawValue,
300
382
  "signedDate": appTransaction.signedDate.timeIntervalSince1970 * 1000,
301
- "appID": appTransaction.appID,
302
- "appVersionID": appTransaction.appVersionID,
383
+ "appId": appTransaction.appID,
384
+ "appVersionId": appTransaction.appVersionID,
303
385
  "preorderDate": appTransaction.preorderDate.map { $0.timeIntervalSince1970 * 1000 }
304
386
  ]
305
387
 
306
388
  if #available(iOS 18.4, *) {
307
- result["appTransactionID"] = appTransaction.appTransactionID
389
+ result["appTransactionId"] = appTransaction.appTransactionID
308
390
  result["originalPlatform"] = appTransaction.originalPlatform.rawValue
309
391
  }
310
392
 
@@ -393,8 +475,8 @@ public class ExpoIapModule: Module {
393
475
 
394
476
  var purchasedItemsSerialized: [[String: Any?]] = []
395
477
 
396
- func addTransaction(transaction: Transaction, jwsRepresentationIos: String? = nil) {
397
- let serialized = serializeTransaction(transaction, jwsRepresentationIos: jwsRepresentationIos)
478
+ func addTransaction(transaction: Transaction, jwsRepresentationIOS: String? = nil) {
479
+ let serialized = serializeTransaction(transaction, jwsRepresentationIOS: jwsRepresentationIOS)
398
480
  purchasedItemsSerialized.append(serialized)
399
481
 
400
482
  if alsoPublishToEventListener {
@@ -408,7 +490,7 @@ public class ExpoIapModule: Module {
408
490
  do {
409
491
  let transaction = try self.checkVerified(verification)
410
492
  if !onlyIncludeActiveItems {
411
- addTransaction(transaction: transaction, jwsRepresentationIos: verification.jwsRepresentation)
493
+ addTransaction(transaction: transaction, jwsRepresentationIOS: verification.jwsRepresentation)
412
494
  continue
413
495
  }
414
496
  switch transaction.productType {
@@ -416,7 +498,7 @@ public class ExpoIapModule: Module {
416
498
  if await self.productStore?.getProduct(productID: transaction.productID)
417
499
  != nil
418
500
  {
419
- addTransaction(transaction: transaction, jwsRepresentationIos: verification.jwsRepresentation)
501
+ addTransaction(transaction: transaction, jwsRepresentationIOS: verification.jwsRepresentation)
420
502
  }
421
503
  case .nonRenewable:
422
504
  if await self.productStore?.getProduct(productID: transaction.productID)
@@ -426,7 +508,7 @@ public class ExpoIapModule: Module {
426
508
  let expirationDate = Calendar(identifier: .gregorian).date(
427
509
  byAdding: DateComponents(year: 1), to: transaction.purchaseDate)!
428
510
  if currentDate < expirationDate {
429
- addTransaction(transaction: transaction, jwsRepresentationIos: verification.jwsRepresentation)
511
+ addTransaction(transaction: transaction, jwsRepresentationIOS: verification.jwsRepresentation)
430
512
  }
431
513
  }
432
514
  default:
@@ -537,10 +619,10 @@ public class ExpoIapModule: Module {
537
619
  return nil
538
620
  } else {
539
621
  self.transactions[String(transaction.id)] = transaction
540
- let serialized = serializeTransaction(transaction, jwsRepresentationIos: verification.jwsRepresentation)
622
+ let serialized = serializeTransaction(transaction, jwsRepresentationIOS: verification.jwsRepresentation)
541
623
 
542
- // Debug: Check if jwsRepresentationIos is included in serialized result
543
- logDebug("buyProduct serialized includes JWS: \(serialized["jwsRepresentationIos"] != nil)")
624
+ // Debug: Check if jwsRepresentationIOS is included in serialized result
625
+ logDebug("buyProduct serialized includes JWS: \(serialized["jwsRepresentationIOS"] != nil)")
544
626
 
545
627
  self.sendEvent(IapEvent.PurchaseUpdated, serialized)
546
628
  return serialized
@@ -888,7 +970,7 @@ public class ExpoIapModule: Module {
888
970
  // If this doesn't throw, the transaction is verified
889
971
  let transaction = try self.checkVerified(result)
890
972
  isValid = true
891
- latestTransaction = serializeTransaction(transaction, jwsRepresentationIos: result.jwsRepresentation)
973
+ latestTransaction = serializeTransaction(transaction, jwsRepresentationIOS: result.jwsRepresentation)
892
974
  } catch {
893
975
  isValid = false
894
976
  }
@@ -974,7 +1056,7 @@ public class ExpoIapModule: Module {
974
1056
  let transaction = try self.checkVerified(result)
975
1057
  self.transactions[String(transaction.id)] = transaction
976
1058
  if self.hasListeners {
977
- let serialized = serializeTransaction(transaction, jwsRepresentationIos: result.jwsRepresentation)
1059
+ let serialized = serializeTransaction(transaction, jwsRepresentationIOS: result.jwsRepresentation)
978
1060
  self.sendEvent(IapEvent.PurchaseUpdated, serialized)
979
1061
  }
980
1062
  } catch {
@@ -1076,7 +1158,7 @@ public class ExpoIapModule: Module {
1076
1158
  previousWillAutoRenew != currentWillAutoRenew {
1077
1159
 
1078
1160
  // Use the jwsRepresentation when serializing the transaction
1079
- var purchaseMap = serializeTransaction(transaction, jwsRepresentationIos: result.jwsRepresentation)
1161
+ var purchaseMap = serializeTransaction(transaction, jwsRepresentationIOS: result.jwsRepresentation)
1080
1162
 
1081
1163
  if case .verified(let renewalInfo) = status.renewalInfo {
1082
1164
  if let renewalInfoDict = serializeRenewalInfo(.verified(renewalInfo)) {
package/jest.config.js ADDED
@@ -0,0 +1,43 @@
1
+ module.exports = {
2
+ preset: 'ts-jest',
3
+ testEnvironment: 'node',
4
+ roots: ['<rootDir>/src'],
5
+ testMatch: [
6
+ '**/__tests__/**/*.+(ts|tsx|js)',
7
+ '**/?(*.)+(spec|test).+(ts|tsx|js)',
8
+ ],
9
+ transform: {
10
+ '^.+\\.(ts|tsx)$': [
11
+ 'ts-jest',
12
+ {
13
+ tsconfig: {
14
+ jsx: 'react',
15
+ esModuleInterop: true,
16
+ allowSyntheticDefaultImports: true,
17
+ moduleResolution: 'node',
18
+ skipLibCheck: true,
19
+ },
20
+ },
21
+ ],
22
+ },
23
+ moduleNameMapper: {
24
+ '^react-native$': '<rootDir>/src/__mocks__/react-native.js',
25
+ '^expo-modules-core$': '<rootDir>/src/__mocks__/expo-modules-core.js',
26
+ },
27
+ collectCoverageFrom: [
28
+ 'src/**/*.{ts,tsx}',
29
+ '!src/**/*.d.ts',
30
+ '!src/**/__tests__/**',
31
+ '!src/**/__mocks__/**',
32
+ '!src/ExpoIapModule.ts',
33
+ '!src/ExpoIapModule.web.ts',
34
+ ],
35
+ coverageThreshold: {
36
+ global: {
37
+ branches: 15,
38
+ functions: 15,
39
+ lines: 15,
40
+ statements: 15,
41
+ },
42
+ },
43
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-iap",
3
- "version": "2.7.14",
3
+ "version": "2.8.1",
4
4
  "description": "In App Purchase module in Expo",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -12,6 +12,8 @@
12
12
  "lint:prettier": "prettier --write \"**/*.{md,js,jsx,ts,tsx}\"",
13
13
  "lint:tsc": "tsc -p tsconfig.json --noEmit --skipLibCheck",
14
14
  "lint:ci": "bun run lint:tsc && bun run lint:eslint && bun run lint:prettier",
15
+ "test": "jest",
16
+ "test:coverage": "jest --coverage",
15
17
  "prepare": "expo-module prepare",
16
18
  "expo-module": "expo-module",
17
19
  "open:ios": "xed example/ios",
@@ -38,15 +40,18 @@
38
40
  "author": "hyochan <hyochan.dev@gmail.com> (https://github.com/hyochan)",
39
41
  "license": "MIT",
40
42
  "homepage": "https://github.com/hyochan/expo-iap#readme",
41
- "dependencies": {},
42
43
  "devDependencies": {
44
+ "@jest/globals": "^30.0.5",
45
+ "@types/jest": "^30.0.0",
43
46
  "@types/react": "~19.1.7",
44
47
  "eslint": "8.57.0",
45
48
  "eslint-config-expo": "^9.2.0",
46
49
  "eslint-config-prettier": "^10.1.5",
47
50
  "eslint-plugin-prettier": "^5.4.1",
48
51
  "expo-module-scripts": "^4.1.7",
49
- "expo-modules-core": "^2.4.0"
52
+ "expo-modules-core": "^2.4.0",
53
+ "jest": "^29.7.0",
54
+ "ts-jest": "^29.4.1"
50
55
  },
51
56
  "peerDependencies": {
52
57
  "expo": "*",
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  ProductAndroid,
3
3
  ProductPurchaseAndroid,
4
- SubscriptionProductAndroid,
4
+ ProductSubscriptionAndroid,
5
5
  } from './types/ExpoIapAndroid.types';
6
6
  import {
7
- ProductIos,
8
- ProductPurchaseIos,
9
- SubscriptionProductIos,
10
- } from './types/ExpoIapIos.types';
7
+ ProductIOS,
8
+ ProductPurchaseIOS,
9
+ ProductSubscriptionIOS,
10
+ } from './types/ExpoIapIOS.types';
11
11
  import {NATIVE_ERROR_CODES} from './ExpoIapModule';
12
12
 
13
13
  export type ChangeEventPayload = {
@@ -16,7 +16,11 @@ export type ChangeEventPayload = {
16
16
 
17
17
  export type ProductType = 'inapp' | 'subs';
18
18
 
19
- export type ProductBase = {
19
+ // =============================================================================
20
+ // COMMON TYPES (Base types shared across all platforms)
21
+ // =============================================================================
22
+
23
+ export type ProductCommon = {
20
24
  id: string;
21
25
  title: string;
22
26
  description: string;
@@ -25,15 +29,23 @@ export type ProductBase = {
25
29
  displayPrice: string;
26
30
  currency: string;
27
31
  price?: number;
32
+ debugDescription?: string;
33
+ platform?: string;
28
34
  };
29
35
 
30
- export type PurchaseBase = {
36
+ export type PurchaseCommon = {
31
37
  id: string; // Transaction identifier - used by finishTransaction
32
38
  productId: string; // Product identifier - which product was purchased
39
+ ids?: string[]; // Product identifiers for purchases that include multiple products
33
40
  transactionId?: string; // @deprecated - use id instead
34
41
  transactionDate: number;
35
42
  transactionReceipt: string;
36
43
  purchaseToken?: string; // Unified purchase token (jwsRepresentation for iOS, purchaseToken for Android)
44
+ platform?: string;
45
+ };
46
+
47
+ export type ProductSubscriptionCommon = ProductCommon & {
48
+ type: 'subs';
37
49
  };
38
50
 
39
51
  // Define literal platform types for better type discrimination
@@ -43,33 +55,38 @@ export type AndroidPlatform = {platform: 'android'};
43
55
  // Platform-agnostic unified product types (public API)
44
56
  export type Product =
45
57
  | (ProductAndroid & AndroidPlatform)
46
- | (ProductIos & IosPlatform);
58
+ | (ProductIOS & IosPlatform);
47
59
 
48
60
  export type SubscriptionProduct =
49
- | (SubscriptionProductAndroid & AndroidPlatform)
50
- | (SubscriptionProductIos & IosPlatform);
51
-
52
- // ============================================================================
53
- // Legacy Types (For backward compatibility with useIap hook)
54
- // ============================================================================
55
-
56
- // Re-export platform-specific purchase types for legacy compatibility
57
- export type {ProductPurchaseAndroid} from './types/ExpoIapAndroid.types';
58
- export type {ProductPurchaseIos} from './types/ExpoIapIos.types';
61
+ | (ProductSubscriptionAndroid & AndroidPlatform)
62
+ | (ProductSubscriptionIOS & IosPlatform);
59
63
 
60
- // Union type for platform-specific purchase types (legacy support)
64
+ // Re-export platform-specific types
65
+ export type {
66
+ ProductPurchaseAndroid,
67
+ PurchaseAndroid,
68
+ ProductSubscriptionAndroid,
69
+ SubscriptionProductAndroid, // Legacy
70
+ } from './types/ExpoIapAndroid.types';
71
+ export type {
72
+ ProductPurchaseIOS,
73
+ PurchaseIOS,
74
+ ProductSubscriptionIOS,
75
+ SubscriptionProductIOS, // Legacy
76
+ } from './types/ExpoIapIOS.types';
77
+
78
+ // Union type for platform-specific purchase types
61
79
  export type ProductPurchase =
62
80
  | (ProductPurchaseAndroid & AndroidPlatform)
63
- | (ProductPurchaseIos & IosPlatform);
81
+ | (ProductPurchaseIOS & IosPlatform);
64
82
 
65
- // Union type for platform-specific subscription purchase types (legacy support)
83
+ // Union type for platform-specific subscription purchase types
66
84
  export type SubscriptionPurchase =
67
85
  | (ProductPurchaseAndroid & AndroidPlatform & {autoRenewingAndroid: boolean})
68
- | (ProductPurchaseIos & IosPlatform);
86
+ | (ProductPurchaseIOS & IosPlatform);
69
87
 
70
88
  export type Purchase = ProductPurchase | SubscriptionPurchase;
71
89
 
72
- // Legacy result type
73
90
  export type PurchaseResult = {
74
91
  responseCode?: number;
75
92
  debugMessage?: string;
@@ -305,10 +322,10 @@ export interface UnifiedRequestPurchaseProps {
305
322
  readonly skus?: string[]; // Multiple SKUs (Android native, iOS uses first item)
306
323
 
307
324
  // iOS-specific properties (ignored on Android)
308
- readonly andDangerouslyFinishTransactionAutomaticallyIOS?: boolean;
325
+ readonly andDangerouslyFinishTransactionAutomatically?: boolean;
309
326
  readonly appAccountToken?: string;
310
327
  readonly quantity?: number;
311
- readonly withOffer?: import('./types/ExpoIapIos.types').PaymentDiscount;
328
+ readonly withOffer?: import('./types/ExpoIapIOS.types').PaymentDiscount;
312
329
 
313
330
  // Android-specific properties (ignored on iOS)
314
331
  readonly obfuscatedAccountIdAndroid?: string;
@@ -325,10 +342,10 @@ export interface UnifiedRequestPurchaseProps {
325
342
  */
326
343
  export interface RequestPurchaseIosProps {
327
344
  readonly sku: string;
328
- readonly andDangerouslyFinishTransactionAutomaticallyIOS?: boolean;
345
+ readonly andDangerouslyFinishTransactionAutomatically?: boolean;
329
346
  readonly appAccountToken?: string;
330
347
  readonly quantity?: number;
331
- readonly withOffer?: import('./types/ExpoIapIos.types').PaymentDiscount;
348
+ readonly withOffer?: import('./types/ExpoIapIOS.types').PaymentDiscount;
332
349
  }
333
350
 
334
351
  /**
@@ -382,5 +399,3 @@ export type RequestPurchaseProps = RequestPurchasePropsByPlatforms;
382
399
  * This is the recommended API moving forward
383
400
  */
384
401
  export type RequestSubscriptionProps = RequestSubscriptionPropsByPlatforms;
385
-
386
- // Note: Type guard functions are exported from index.ts to avoid conflicts
@@ -28,15 +28,16 @@ export const getActiveSubscriptions = async (
28
28
  const filteredPurchases = purchases.filter((purchase) => {
29
29
  // If specific IDs provided, filter by them
30
30
  if (subscriptionIds && subscriptionIds.length > 0) {
31
- if (!subscriptionIds.includes(purchase.id)) {
31
+ if (!subscriptionIds.includes(purchase.productId)) {
32
32
  return false;
33
33
  }
34
34
  }
35
35
 
36
36
  // Check if this purchase has subscription-specific fields
37
37
  const hasSubscriptionFields =
38
- ('expirationDateIos' in purchase && purchase.expirationDateIos) ||
39
- 'autoRenewingAndroid' in purchase;
38
+ ('expirationDateIOS' in purchase && purchase.expirationDateIOS) ||
39
+ 'autoRenewingAndroid' in purchase ||
40
+ ('environmentIOS' in purchase && purchase.environmentIOS === 'Sandbox');
40
41
 
41
42
  if (!hasSubscriptionFields) {
42
43
  return false;
@@ -44,19 +45,25 @@ export const getActiveSubscriptions = async (
44
45
 
45
46
  // Check if it's actually active
46
47
  if (Platform.OS === 'ios') {
47
- if ('expirationDateIos' in purchase && purchase.expirationDateIos) {
48
- return purchase.expirationDateIos > currentTime;
48
+ if ('expirationDateIOS' in purchase && purchase.expirationDateIOS) {
49
+ return purchase.expirationDateIOS > currentTime;
49
50
  }
50
- if (
51
- 'environmentIos' in purchase &&
52
- purchase.environmentIos === 'Sandbox'
53
- ) {
51
+ // For iOS purchases without expiration date (like Sandbox), we consider them active
52
+ // if they have the environmentIOS field and were created recently
53
+ if ('environmentIOS' in purchase && purchase.environmentIOS) {
54
54
  const dayInMs = 24 * 60 * 60 * 1000;
55
+ // If no expiration date, consider active if transaction is recent (within 24 hours for Sandbox)
55
56
  if (
56
- purchase.transactionDate &&
57
- currentTime - purchase.transactionDate < dayInMs
57
+ !('expirationDateIOS' in purchase) ||
58
+ !purchase.expirationDateIOS
58
59
  ) {
59
- return true;
60
+ if (
61
+ purchase.environmentIOS === 'Sandbox' &&
62
+ purchase.transactionDate &&
63
+ currentTime - purchase.transactionDate < dayInMs
64
+ ) {
65
+ return true;
66
+ }
60
67
  }
61
68
  }
62
69
  } else if (Platform.OS === 'android') {
@@ -70,26 +77,26 @@ export const getActiveSubscriptions = async (
70
77
  // Convert to ActiveSubscription format
71
78
  for (const purchase of filteredPurchases) {
72
79
  const subscription: ActiveSubscription = {
73
- productId: purchase.id,
80
+ productId: purchase.productId,
74
81
  isActive: true,
75
82
  };
76
83
 
77
84
  // Add platform-specific details
78
85
  if (Platform.OS === 'ios') {
79
- if ('expirationDateIos' in purchase && purchase.expirationDateIos) {
80
- const expirationDate = new Date(purchase.expirationDateIos);
86
+ if ('expirationDateIOS' in purchase && purchase.expirationDateIOS) {
87
+ const expirationDate = new Date(purchase.expirationDateIOS);
81
88
  subscription.expirationDateIOS = expirationDate;
82
89
 
83
- // Calculate days until expiration
84
- const daysUntilExpiration = Math.floor(
85
- (purchase.expirationDateIos - currentTime) / (1000 * 60 * 60 * 24),
90
+ // Calculate days until expiration (round to nearest day)
91
+ const daysUntilExpiration = Math.round(
92
+ (purchase.expirationDateIOS - currentTime) / (1000 * 60 * 60 * 24),
86
93
  );
87
94
  subscription.daysUntilExpirationIOS = daysUntilExpiration;
88
95
  subscription.willExpireSoon = daysUntilExpiration <= 7;
89
96
  }
90
97
 
91
- if ('environmentIos' in purchase) {
92
- subscription.environmentIOS = purchase.environmentIos;
98
+ if ('environmentIOS' in purchase) {
99
+ subscription.environmentIOS = purchase.environmentIOS;
93
100
  }
94
101
  } else if (Platform.OS === 'android') {
95
102
  if ('autoRenewingAndroid' in purchase) {