react-native-iap 16.3.0 → 16.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +3 -3
  2. package/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +111 -15
  3. package/android/src/main/java/com/margelo/nitro/iap/RnIapLog.kt +5 -1
  4. package/ios/HybridRnIap.swift +1 -1
  5. package/ios/RnIapLog.swift +6 -6
  6. package/lib/module/index.js +51 -6
  7. package/lib/module/index.js.map +1 -1
  8. package/lib/module/index.kepler.js +9 -0
  9. package/lib/module/index.kepler.js.map +1 -1
  10. package/lib/module/kit-api.js +3 -1
  11. package/lib/module/kit-api.js.map +1 -1
  12. package/lib/module/types.js +2 -0
  13. package/lib/module/types.js.map +1 -1
  14. package/lib/module/vega-adapter.js +3 -4
  15. package/lib/module/vega-adapter.js.map +1 -1
  16. package/lib/typescript/src/index.d.ts.map +1 -1
  17. package/lib/typescript/src/index.kepler.d.ts.map +1 -1
  18. package/lib/typescript/src/kit-api.d.ts +119 -2
  19. package/lib/typescript/src/kit-api.d.ts.map +1 -1
  20. package/lib/typescript/src/specs/RnIap.nitro.d.ts +9 -3
  21. package/lib/typescript/src/specs/RnIap.nitro.d.ts.map +1 -1
  22. package/lib/typescript/src/types.d.ts +33 -9
  23. package/lib/typescript/src/types.d.ts.map +1 -1
  24. package/lib/typescript/src/vega-adapter.d.ts.map +1 -1
  25. package/nitrogen/generated/android/NitroIap+autolinking.cmake +1 -1
  26. package/nitrogen/generated/android/c++/JHybridRnIapSpec.cpp +8 -4
  27. package/nitrogen/generated/android/c++/JHybridRnIapSpec.hpp +1 -1
  28. package/nitrogen/generated/android/c++/JNitroPurchaseVerificationResultAndroid.hpp +5 -1
  29. package/nitrogen/generated/android/c++/JNitroPurchaseVerificationResultHorizon.hpp +69 -0
  30. package/nitrogen/generated/android/c++/JVariant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid_NitroPurchaseVerificationResultHorizon.cpp +30 -0
  31. package/nitrogen/generated/android/c++/{JVariant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid.hpp → JVariant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid_NitroPurchaseVerificationResultHorizon.hpp} +33 -16
  32. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/HybridRnIapSpec.kt +1 -1
  33. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/NitroPurchaseVerificationResultAndroid.kt +8 -3
  34. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/NitroPurchaseVerificationResultHorizon.kt +61 -0
  35. package/nitrogen/generated/android/kotlin/com/margelo/nitro/iap/{Variant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid.kt → Variant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid_NitroPurchaseVerificationResultHorizon.kt} +21 -8
  36. package/nitrogen/generated/ios/NitroIap-Swift-Cxx-Bridge.cpp +4 -4
  37. package/nitrogen/generated/ios/NitroIap-Swift-Cxx-Bridge.hpp +43 -34
  38. package/nitrogen/generated/ios/NitroIap-Swift-Cxx-Umbrella.hpp +3 -0
  39. package/nitrogen/generated/ios/c++/HybridRnIapSpecSwift.hpp +4 -1
  40. package/nitrogen/generated/ios/swift/{Func_void_std__variant_NitroPurchaseVerificationResultIOS__NitroPurchaseVerificationResultAndroid_.swift → Func_void_std__variant_NitroPurchaseVerificationResultIOS__NitroPurchaseVerificationResultAndroid__NitroPurchaseVerificationResultHorizon_.swift} +14 -11
  41. package/nitrogen/generated/ios/swift/HybridRnIapSpec.swift +1 -1
  42. package/nitrogen/generated/ios/swift/HybridRnIapSpec_cxx.swift +11 -9
  43. package/nitrogen/generated/ios/swift/NitroPurchaseVerificationResultAndroid.swift +7 -2
  44. package/nitrogen/generated/ios/swift/NitroPurchaseVerificationResultHorizon.swift +71 -0
  45. package/nitrogen/generated/ios/swift/{Variant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid.swift → Variant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid_NitroPurchaseVerificationResultHorizon.swift} +6 -4
  46. package/nitrogen/generated/shared/c++/HybridRnIapSpec.hpp +4 -1
  47. package/nitrogen/generated/shared/c++/NitroPurchaseVerificationResultAndroid.hpp +5 -1
  48. package/nitrogen/generated/shared/c++/NitroPurchaseVerificationResultHorizon.hpp +93 -0
  49. package/openiap-versions.json +3 -3
  50. package/package.json +3 -2
  51. package/src/index.kepler.ts +10 -2
  52. package/src/index.ts +88 -18
  53. package/src/kit-api.ts +125 -4
  54. package/src/specs/RnIap.nitro.ts +12 -2
  55. package/src/types.ts +34 -9
  56. package/src/vega-adapter.ts +6 -11
  57. package/nitrogen/generated/android/c++/JVariant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid.cpp +0 -26
package/README.md CHANGED
@@ -5,9 +5,9 @@
5
5
 
6
6
  [![Version](http://img.shields.io/npm/v/react-native-iap.svg?style=flat-square)](https://npmjs.org/package/react-native-iap) [![Download](http://img.shields.io/npm/dm/react-native-iap.svg?style=flat-square)](https://npmjs.org/package/react-native-iap) [![OpenIAP](https://img.shields.io/badge/OpenIAP-Compliant-green?style=flat-square)](https://openiap.dev) [![Backers and Sponsors](https://img.shields.io/opencollective/all/openiap.svg)](https://opencollective.com/openiap) [![CI - Test](https://github.com/hyodotdev/openiap/actions/workflows/ci-react-native-iap.yml/badge.svg?branch=main)](https://github.com/hyodotdev/openiap/actions/workflows/ci-react-native-iap.yml?query=branch%3Amain) [![codecov](https://codecov.io/gh/hyodotdev/openiap/branch/main/graph/badge.svg?component=react-native-iap)](https://app.codecov.io/gh/hyodotdev/openiap/tree/main/libraries/react-native-iap) [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fhyochan%2Freact-native-iap.svg?type=shield&issueType=license)](https://app.fossa.com/projects/git%2Bgithub.com%2Fhyochan%2Freact-native-iap?ref=badge_shield&issueType=license)
7
7
 
8
- **React Native IAP** is a high-performance in-app purchase library using Nitro Modules that **conforms to the [Open IAP specification](https://openiap.dev)**. It provides a unified API for handling in-app purchases across iOS and Android platforms with comprehensive error handling and modern TypeScript support.
8
+ **React Native IAP** is a high-performance in-app purchase library using Nitro Modules that **conforms to the [OpenIAP specification](https://openiap.dev)**. It provides a unified API for handling in-app purchases across iOS and Android platforms with comprehensive error handling and modern TypeScript support.
9
9
 
10
- <a href="https://openiap.dev"><img src="https://raw.githubusercontent.com/hyodotdev/openiap/main/logo.png" alt="Open IAP" height="40" /></a>
10
+ <a href="https://openiap.dev"><img src="https://raw.githubusercontent.com/hyodotdev/openiap/main/logo.webp" alt="OpenIAP" height="40" /></a>
11
11
  </div>
12
12
 
13
13
  ## 📚 Documentation
@@ -137,7 +137,7 @@ Quick links:
137
137
 
138
138
  ## Powered by OpenIAP
139
139
 
140
- <a href="https://openiap.dev"><img src="https://raw.githubusercontent.com/hyodotdev/openiap/main/logo.png" alt="OpenIAP" height="50" /></a>
140
+ <a href="https://openiap.dev"><img src="https://raw.githubusercontent.com/hyodotdev/openiap/main/logo.webp" alt="OpenIAP" height="50" /></a>
141
141
 
142
142
  React Native IAP conforms to the **[OpenIAP specification](https://openiap.dev)** — an open, vendor-neutral interoperability standard for in-app purchases. OpenIAP provides:
143
143
 
@@ -4,7 +4,7 @@ import com.facebook.react.bridge.ReactApplicationContext
4
4
  import com.margelo.nitro.NitroModules
5
5
  import com.margelo.nitro.core.NullType
6
6
  import com.margelo.nitro.core.Promise
7
- import dev.hyo.openiap.AndroidSubscriptionOfferInput
7
+ import dev.hyo.openiap.AndroidSubscriptionOfferInput as OpenIapAndroidSubscriptionOfferInput
8
8
  import dev.hyo.openiap.DeepLinkOptions as OpenIapDeepLinkOptions
9
9
  import dev.hyo.openiap.FetchProductsResult
10
10
  import dev.hyo.openiap.FetchProductsResultAll
@@ -31,8 +31,10 @@ import dev.hyo.openiap.SubResponseCodeAndroid as OpenIapSubResponseCodeAndroid
31
31
  import dev.hyo.openiap.SubscriptionProductReplacementParamsAndroid as OpenIapSubscriptionProductReplacementParams
32
32
  import dev.hyo.openiap.SubscriptionReplacementModeAndroid as OpenIapSubscriptionReplacementMode
33
33
  import dev.hyo.openiap.VerifyPurchaseGoogleOptions
34
+ import dev.hyo.openiap.VerifyPurchaseHorizonOptions
34
35
  import dev.hyo.openiap.VerifyPurchaseProps
35
36
  import dev.hyo.openiap.VerifyPurchaseResultAndroid
37
+ import dev.hyo.openiap.VerifyPurchaseResultHorizon
36
38
  import dev.hyo.openiap.InitConnectionConfig as OpenIapInitConnectionConfig
37
39
  import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
38
40
  import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
@@ -99,6 +101,44 @@ internal fun <T> removeSingletonBridgeListener(
99
101
  }
100
102
  }
101
103
 
104
+ internal fun requireValidSubscriptionOffers(
105
+ offers: Array<AndroidSubscriptionOfferInput>?,
106
+ ): List<OpenIapAndroidSubscriptionOfferInput> {
107
+ require(offers.orEmpty().all { it.sku.isNotBlank() && it.offerToken.isNotBlank() }) {
108
+ "Every subscription offer must include non-empty sku and offerToken strings"
109
+ }
110
+ return offers.orEmpty().map { offer ->
111
+ OpenIapAndroidSubscriptionOfferInput(
112
+ sku = offer.sku,
113
+ offerToken = offer.offerToken,
114
+ )
115
+ }
116
+ }
117
+
118
+ internal fun requireMatchingPurchaseOptions(
119
+ type: ProductQueryType,
120
+ hasSubscriptionOffers: Boolean,
121
+ hasSubscriptionReplacementParams: Boolean,
122
+ purchaseToken: String?,
123
+ originalExternalTransactionId: String?,
124
+ offerToken: String?,
125
+ ) {
126
+ when (type) {
127
+ ProductQueryType.InApp -> require(
128
+ !hasSubscriptionOffers &&
129
+ !hasSubscriptionReplacementParams &&
130
+ purchaseToken == null &&
131
+ originalExternalTransactionId == null,
132
+ ) { "Subscription options require product type subs" }
133
+ ProductQueryType.Subs -> require(offerToken == null) {
134
+ "offerToken requires product type in-app"
135
+ }
136
+ ProductQueryType.All -> throw IllegalArgumentException(
137
+ "Product type all is only supported for product queries"
138
+ )
139
+ }
140
+ }
141
+
102
142
  /**
103
143
  * Bound for the pending purchase-event queues, matching expo-iap's
104
144
  * ExpoIapHelper.MAX_BUFFERED_EVENTS. Oldest entries are dropped on overflow.
@@ -555,17 +595,37 @@ class HybridRnIap : HybridRnIapSpec() {
555
595
  val typeHint = androidRequest.skus.firstOrNull()?.let { productTypeBySku[it] } ?: "in-app"
556
596
  val queryType = parseProductQueryType(typeHint)
557
597
 
558
- val subscriptionOffers = (androidRequest.subscriptionOffers as? Variant_NullType_Array_AndroidSubscriptionOfferInput_.Second)?.value
559
- ?.mapNotNull { offer ->
560
- val sku = offer.sku
561
- val token = offer.offerToken
562
- if (sku.isBlank() || token.isBlank()) {
563
- null
564
- } else {
565
- AndroidSubscriptionOfferInput(sku = sku, offerToken = token)
566
- }
567
- }
568
- ?: emptyList()
598
+ try {
599
+ requireMatchingPurchaseOptions(
600
+ type = queryType,
601
+ hasSubscriptionOffers = androidRequest.subscriptionOffers is
602
+ Variant_NullType_Array_AndroidSubscriptionOfferInput_.Second,
603
+ hasSubscriptionReplacementParams =
604
+ androidRequest.subscriptionProductReplacementParams is
605
+ Variant_NullType_SubscriptionProductReplacementParamsAndroid.Second,
606
+ purchaseToken = androidRequest.purchaseToken.unwrapString(),
607
+ originalExternalTransactionId =
608
+ androidRequest.originalExternalTransactionId.unwrapString(),
609
+ offerToken = androidRequest.offerToken.unwrapString(),
610
+ )
611
+ } catch (error: IllegalArgumentException) {
612
+ sendPurchaseError(
613
+ toErrorResult(OpenIapError.DeveloperError(error.message))
614
+ )
615
+ return@async defaultResult
616
+ }
617
+
618
+ val subscriptionOffers = try {
619
+ requireValidSubscriptionOffers(
620
+ (androidRequest.subscriptionOffers as?
621
+ Variant_NullType_Array_AndroidSubscriptionOfferInput_.Second)?.value,
622
+ )
623
+ } catch (error: IllegalArgumentException) {
624
+ sendPurchaseError(
625
+ toErrorResult(OpenIapError.DeveloperError(error.message))
626
+ )
627
+ return@async defaultResult
628
+ }
569
629
  val normalizedOffers = subscriptionOffers.takeIf { it.isNotEmpty() }
570
630
  val developerBillingOption =
571
631
  (androidRequest.developerBillingOption as?
@@ -1405,10 +1465,45 @@ class HybridRnIap : HybridRnIapSpec() {
1405
1465
  }
1406
1466
 
1407
1467
  // Receipt validation - calls OpenIAP's verifyPurchase
1408
- override fun verifyPurchase(params: NitroPurchaseVerificationParams): Promise<Variant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid> {
1468
+ override fun verifyPurchase(params: NitroPurchaseVerificationParams): Promise<Variant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid_NitroPurchaseVerificationResultHorizon> {
1409
1469
  return Promise.async {
1410
1470
  try {
1411
- // For Android, we need the google options to be provided (new platform-specific structure)
1471
+ val nitroHorizonOptions =
1472
+ (params.horizon as? Variant_NullType_NitroPurchaseVerificationHorizonOptions.Second)?.value
1473
+ if (nitroHorizonOptions != null) {
1474
+ val validations = mapOf(
1475
+ "horizon.sku" to nitroHorizonOptions.sku,
1476
+ "horizon.userId" to nitroHorizonOptions.userId,
1477
+ "horizon.accessToken" to nitroHorizonOptions.accessToken
1478
+ )
1479
+ for ((name, value) in validations) {
1480
+ if (value.isEmpty()) {
1481
+ throw OpenIapException(toErrorJson(OpenIapError.DeveloperError(), debugMessage = "Missing or empty required parameter: $name"))
1482
+ }
1483
+ }
1484
+
1485
+ RnIapLog.payload("verifyPurchase", mapOf(
1486
+ "store" to "horizon",
1487
+ "sku" to nitroHorizonOptions.sku
1488
+ ))
1489
+ val props = VerifyPurchaseProps(
1490
+ horizon = VerifyPurchaseHorizonOptions(
1491
+ sku = nitroHorizonOptions.sku,
1492
+ userId = nitroHorizonOptions.userId,
1493
+ accessToken = nitroHorizonOptions.accessToken
1494
+ )
1495
+ )
1496
+ val horizonResult = openIap.verifyPurchase(props) as? VerifyPurchaseResultHorizon
1497
+ ?: throw OpenIapException(toErrorJson(OpenIapError.InvalidPurchaseVerification, debugMessage = "Unexpected Horizon result type from verifyPurchase"))
1498
+ @Suppress("DEPRECATION")
1499
+ val result = NitroPurchaseVerificationResultHorizon(
1500
+ isValid = horizonResult.isValid,
1501
+ grantTime = horizonResult.grantTime.wrapVariant(),
1502
+ success = horizonResult.success
1503
+ )
1504
+ return@async Variant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid_NitroPurchaseVerificationResultHorizon.Third(result)
1505
+ }
1506
+
1412
1507
  val nitroGoogleOptions = (params.google as? Variant_NullType_NitroPurchaseVerificationGoogleOptions.Second)?.value
1413
1508
  ?: throw OpenIapException(toErrorJson(OpenIapError.DeveloperError(), debugMessage = "Missing required parameter: google options"))
1414
1509
 
@@ -1459,6 +1554,7 @@ class HybridRnIap : HybridRnIapSpec() {
1459
1554
 
1460
1555
  // Convert OpenIAP result to Nitro result
1461
1556
  val result = NitroPurchaseVerificationResultAndroid(
1557
+ isValid = androidResult.isValid,
1462
1558
  autoRenewing = androidResult.autoRenewing,
1463
1559
  betaProduct = androidResult.betaProduct,
1464
1560
  cancelDate = androidResult.cancelDate.wrapVariant(),
@@ -1479,7 +1575,7 @@ class HybridRnIap : HybridRnIapSpec() {
1479
1575
  testTransaction = androidResult.testTransaction
1480
1576
  )
1481
1577
 
1482
- Variant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid.Second(result)
1578
+ Variant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid_NitroPurchaseVerificationResultHorizon.Second(result)
1483
1579
 
1484
1580
  } catch (e: OpenIapException) {
1485
1581
  RnIapLog.failure("verifyPurchase", e)
@@ -67,7 +67,7 @@ internal object RnIapLog {
67
67
  }
68
68
  }
69
69
 
70
- private fun sanitizeMap(source: Map<*, *>): Map<String, Any?> {
70
+ internal fun sanitizeMap(source: Map<*, *>): Map<String, Any?> {
71
71
  fun isSensitiveKey(key: String): Boolean {
72
72
  val normalized = key.lowercase(Locale.ROOT).filter { it.isLetterOrDigit() }
73
73
  return SENSITIVE_KEY_FRAGMENTS.any { normalized.contains(it) } ||
@@ -77,6 +77,10 @@ internal object RnIapLog {
77
77
  val sanitized = linkedMapOf<String, Any?>()
78
78
  for ((rawKey, rawValue) in source) {
79
79
  val key = rawKey as? String ?: continue
80
+ if (rawValue == null || rawValue === JSONObject.NULL) {
81
+ sanitized[key] = JSONObject.NULL
82
+ continue
83
+ }
80
84
  if (isSensitiveKey(key)) {
81
85
  sanitized[key] = "hidden"
82
86
  continue
@@ -399,7 +399,7 @@ class HybridRnIap: HybridRnIapSpec {
399
399
  }
400
400
  }
401
401
 
402
- func verifyPurchase(params: NitroPurchaseVerificationParams) throws -> Promise<Variant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid> {
402
+ func verifyPurchase(params: NitroPurchaseVerificationParams) throws -> Promise<Variant_NitroPurchaseVerificationResultIOS_NitroPurchaseVerificationResultAndroid_NitroPurchaseVerificationResultHorizon> {
403
403
  return Promise.async {
404
404
  do {
405
405
  // Extract SKU from apple options (new platform-specific structure)
@@ -105,22 +105,22 @@ enum RnIapLog {
105
105
  }
106
106
 
107
107
  private static func sanitize(_ value: Any?) -> Any? {
108
- guard let value else { return nil }
109
-
110
- if let dictionary = value as? [String: Any] {
111
- return sanitizeDictionary(dictionary)
112
- }
108
+ guard let value, !(value is NSNull) else { return nil }
113
109
 
114
110
  if let optionalDictionary = value as? [String: Any?] {
115
111
  var compact: [String: Any] = [:]
116
112
  for (key, optionalValue) in optionalDictionary {
117
- if let optionalValue {
113
+ if let optionalValue, !(optionalValue is NSNull) {
118
114
  compact[key] = optionalValue
119
115
  }
120
116
  }
121
117
  return sanitizeDictionary(compact)
122
118
  }
123
119
 
120
+ if let dictionary = value as? [String: Any] {
121
+ return sanitizeDictionary(dictionary)
122
+ }
123
+
124
124
  if let array = value as? [Any] {
125
125
  return array.compactMap { sanitize($0) }
126
126
  }
@@ -621,7 +621,10 @@ export const fetchProducts = async request => {
621
621
  } = request;
622
622
  try {
623
623
  if (!skus?.length) {
624
- throw new Error('No SKUs provided');
624
+ throw createPurchaseError({
625
+ message: 'No SKUs provided',
626
+ code: ErrorCode.EmptySkuList
627
+ });
625
628
  }
626
629
  const normalizedType = normalizeProductQueryType(type);
627
630
  const fetchAndConvert = async nitroType => {
@@ -1269,6 +1272,12 @@ export const requestPurchase = async request => {
1269
1272
  type
1270
1273
  } = request;
1271
1274
  const normalizedType = normalizeProductQueryType(type ?? 'in-app');
1275
+ if (normalizedType === 'all') {
1276
+ throw createPurchaseError({
1277
+ code: ErrorCode.DeveloperError,
1278
+ message: 'Product type all is only supported for product queries.'
1279
+ });
1280
+ }
1272
1281
  const isSubs = isSubscriptionQuery(normalizedType);
1273
1282
  const perPlatformRequest = platformRequest;
1274
1283
  if (!perPlatformRequest) {
@@ -1278,11 +1287,18 @@ export const requestPurchase = async request => {
1278
1287
  const androidRequestSource = isAndroidStoreRuntime() ? perPlatformRequest.google : undefined;
1279
1288
  if (Platform.OS === 'ios') {
1280
1289
  if (!iosRequestSource?.sku) {
1281
- throw new Error('Invalid request for iOS. The `sku` property is required.');
1290
+ throw createPurchaseError({
1291
+ message: 'Invalid request for iOS. The `sku` property is required.',
1292
+ code: ErrorCode.EmptySkuList
1293
+ });
1282
1294
  }
1283
1295
  } else if (isAndroidStoreRuntime()) {
1284
- if (!androidRequestSource?.skus?.length) {
1285
- throw new Error('Invalid request for Android. The `skus` property is required and must be a non-empty array.');
1296
+ const skus = androidRequestSource?.skus;
1297
+ if (!Array.isArray(skus) || skus.length === 0 || skus.some(sku => typeof sku !== 'string' || sku.trim() === '')) {
1298
+ throw createPurchaseError({
1299
+ message: 'Invalid request for Android. The `skus` property must contain only non-empty strings.',
1300
+ code: ErrorCode.EmptySkuList
1301
+ });
1286
1302
  }
1287
1303
  } else {
1288
1304
  throw unsupportedPlatformError();
@@ -1327,6 +1343,14 @@ export const requestPurchase = async request => {
1327
1343
  unifiedRequest.apple = iosPayload;
1328
1344
  }
1329
1345
  if (isAndroidStoreRuntime() && androidRequestSource) {
1346
+ const rawAndroidRequest = androidRequestSource;
1347
+ const subscriptionOnlyFields = ['subscriptionOffers', 'subscriptionProductReplacementParams', 'purchaseToken', 'originalExternalTransactionId'];
1348
+ if (!isSubs && subscriptionOnlyFields.some(field => rawAndroidRequest[field] != null) || isSubs && rawAndroidRequest.offerToken != null) {
1349
+ throw createPurchaseError({
1350
+ message: 'Invalid request for Android. Purchase options must match the selected product type.',
1351
+ code: ErrorCode.DeveloperError
1352
+ });
1353
+ }
1330
1354
  const androidRequest = isSubs ? androidRequestSource : androidRequestSource;
1331
1355
  const androidPayload = {
1332
1356
  skus: androidRequest.skus
@@ -1353,6 +1377,19 @@ export const requestPurchase = async request => {
1353
1377
  }
1354
1378
  if (isSubs) {
1355
1379
  const subsRequest = androidRequest;
1380
+ const subscriptionOffers = subsRequest.subscriptionOffers;
1381
+ if (subscriptionOffers != null && (!Array.isArray(subscriptionOffers) || subscriptionOffers.some(offer => {
1382
+ if (offer == null || typeof offer !== 'object') {
1383
+ return true;
1384
+ }
1385
+ const candidate = offer;
1386
+ return typeof candidate.sku !== 'string' || candidate.sku.trim() === '' || typeof candidate.offerToken !== 'string' || candidate.offerToken.trim() === '';
1387
+ }))) {
1388
+ throw createPurchaseError({
1389
+ message: 'Invalid request for Android. Every subscription offer must include non-empty `sku` and `offerToken` strings.',
1390
+ code: ErrorCode.DeveloperError
1391
+ });
1392
+ }
1356
1393
  if (subsRequest.purchaseToken) {
1357
1394
  androidPayload.purchaseToken = subsRequest.purchaseToken;
1358
1395
  }
@@ -1362,7 +1399,7 @@ export const requestPurchase = async request => {
1362
1399
  if (subsRequest.subscriptionProductReplacementParams) {
1363
1400
  androidPayload.subscriptionProductReplacementParams = subsRequest.subscriptionProductReplacementParams;
1364
1401
  }
1365
- androidPayload.subscriptionOffers = (subsRequest.subscriptionOffers ?? []).filter(offer => offer != null).map(offer => ({
1402
+ androidPayload.subscriptionOffers = (subscriptionOffers ?? []).map(offer => ({
1366
1403
  sku: offer.sku,
1367
1404
  offerToken: offer.offerToken
1368
1405
  }));
@@ -1642,10 +1679,18 @@ export const verifyPurchase = async options => {
1642
1679
  latestTransaction: iosResult.latestTransaction ? convertNitroPurchaseToPurchase(iosResult.latestTransaction) : undefined
1643
1680
  };
1644
1681
  return result;
1682
+ } else if (params.horizon !== null) {
1683
+ const horizonResult = nitroResult;
1684
+ const result = {
1685
+ isValid: horizonResult.isValid,
1686
+ grantTime: horizonResult.grantTime,
1687
+ success: horizonResult.success
1688
+ };
1689
+ return result;
1645
1690
  } else {
1646
- // Android
1647
1691
  const androidResult = nitroResult;
1648
1692
  const result = {
1693
+ isValid: androidResult.isValid,
1649
1694
  autoRenewing: androidResult.autoRenewing,
1650
1695
  betaProduct: androidResult.betaProduct,
1651
1696
  cancelDate: androidResult.cancelDate,