expo-iap 5.3.0 → 5.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.
@@ -40,6 +40,12 @@ enum ExpoIapHelper {
40
40
  private static var purchaseUpdatedSub: OpenIAP.Subscription?
41
41
  private static var purchaseUpdatedHandler: ((Purchase) -> Void)?
42
42
  private static var purchaseUpdatedOptions = PurchaseUpdatedListenerOptions()
43
+ private static var listenerGeneration: UInt64 = 0
44
+ private static var activeListenerGeneration: UInt64?
45
+ private static var pendingConnectionCleanup: (
46
+ generation: UInt64,
47
+ task: Task<Void, Never>
48
+ )?
43
49
 
44
50
  static func sanitizeDictionary(_ dictionary: [String: Any?]) -> [String: Any] {
45
51
  var result: [String: Any] = [:]
@@ -91,9 +97,18 @@ enum ExpoIapHelper {
91
97
  }
92
98
  }
93
99
 
94
- static func parseProductQueryType(_ rawValue: String?) throws -> ProductQueryType {
95
- guard let raw = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty
96
- else {
100
+ static func parseProductQueryType(_ rawValue: Any?) throws -> ProductQueryType {
101
+ if rawValue == nil || rawValue is NSNull {
102
+ return .inApp
103
+ }
104
+ guard let stringValue = rawValue as? String else {
105
+ throw PurchaseError.make(
106
+ code: .developerError,
107
+ message: "Product type must be a string"
108
+ )
109
+ }
110
+ let raw = stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
111
+ if raw.isEmpty {
97
112
  return .inApp
98
113
  }
99
114
  switch raw.lowercased() {
@@ -111,9 +126,26 @@ enum ExpoIapHelper {
111
126
  }
112
127
  }
113
128
 
129
+ static func parsePurchaseProductQueryType(_ rawValue: Any?) throws -> ProductQueryType {
130
+ if let rawValue, !(rawValue is String), !(rawValue is NSNull) {
131
+ throw PurchaseError.make(
132
+ code: .developerError,
133
+ message: "Purchase type must be a string"
134
+ )
135
+ }
136
+ let type = try parseProductQueryType(rawValue)
137
+ guard type != .all else {
138
+ throw PurchaseError.make(
139
+ code: .developerError,
140
+ message: "Product type all is only supported for product queries."
141
+ )
142
+ }
143
+ return type
144
+ }
145
+
114
146
  static func decodeProductRequest(from payload: [String: Any]) throws -> ProductRequest {
115
147
  if let skus = payload["skus"] as? [String], !skus.isEmpty {
116
- let type = try parseProductQueryType(payload["type"] as? String)
148
+ let type = try parseProductQueryType(payload["type"])
117
149
  return try OpenIapSerialization.productRequest(skus: skus, type: type)
118
150
  }
119
151
 
@@ -137,6 +169,7 @@ enum ExpoIapHelper {
137
169
  static func decodeRequestPurchaseProps(from payload: [String: Any]) throws
138
170
  -> RequestPurchaseProps
139
171
  {
172
+ let parsedType = try parsePurchaseProductQueryType(payload["type"])
140
173
  if payload.keys.contains("requestPurchase"),
141
174
  payload["requestPurchase"] != nil
142
175
  {
@@ -151,10 +184,8 @@ enum ExpoIapHelper {
151
184
  }
152
185
 
153
186
  if payload.keys.contains("request"), let request = payload["request"] {
154
- let parsedType = try parseProductQueryType(payload["type"] as? String)
155
- let purchaseType: ProductQueryType = parsedType == .all ? .inApp : parsedType
156
- var normalized: [String: Any] = ["type": purchaseType.rawValue]
157
- switch purchaseType {
187
+ var normalized: [String: Any] = ["type": parsedType.rawValue]
188
+ switch parsedType {
158
189
  case .subs:
159
190
  normalized["requestSubscription"] = request
160
191
  case .inApp:
@@ -170,17 +201,18 @@ enum ExpoIapHelper {
170
201
  }
171
202
 
172
203
  static func setupListeners(
173
- module: ExpoIapModule,
174
204
  purchaseUpdated: @escaping (Purchase) -> Void,
175
205
  purchaseError: @escaping (PurchaseError) -> Void,
176
206
  promotedProduct: @escaping (String) async -> Void,
177
207
  subscriptionBillingIssue: @escaping (Purchase) -> Void
178
- ) {
208
+ ) -> UInt64 {
179
209
  listenerLock.lock()
180
210
  defer { listenerLock.unlock() }
181
211
 
182
- // Clean up any existing listeners first
183
212
  cleanupListenersLocked()
213
+ listenerGeneration &+= 1
214
+ let generation = listenerGeneration
215
+ activeListenerGeneration = generation
184
216
 
185
217
  purchaseUpdatedHandler = purchaseUpdated
186
218
  attachPurchaseUpdatedListenerLocked()
@@ -208,6 +240,7 @@ enum ExpoIapHelper {
208
240
  promotedProductSub,
209
241
  billingIssueSub,
210
242
  ]
243
+ return generation
211
244
  }
212
245
 
213
246
  static func setPurchaseUpdatedListenerOptions(_ options: PurchaseUpdatedListenerOptions?) {
@@ -233,11 +266,37 @@ enum ExpoIapHelper {
233
266
  }, options: purchaseUpdatedOptions)
234
267
  }
235
268
 
236
- static func cleanupListeners() {
269
+ private static func beginStoreCleanup(
270
+ ifOwnedBy listenerGeneration: UInt64
271
+ ) -> Task<Void, Never>? {
237
272
  listenerLock.lock()
238
273
  defer { listenerLock.unlock() }
239
274
 
275
+ guard activeListenerGeneration == listenerGeneration else { return nil }
240
276
  cleanupListenersLocked()
277
+ activeListenerGeneration = nil
278
+ let previousTask = pendingConnectionCleanup?.task
279
+ let task = Task {
280
+ await previousTask?.value
281
+ _ = try? await OpenIapModule.shared.endConnection()
282
+ }
283
+ pendingConnectionCleanup = (listenerGeneration, task)
284
+ return task
285
+ }
286
+
287
+ private static func pendingConnectionCleanupTask() -> Task<Void, Never>? {
288
+ listenerLock.lock()
289
+ defer { listenerLock.unlock() }
290
+
291
+ return pendingConnectionCleanup?.task
292
+ }
293
+
294
+ private static func finishStoreCleanup(listenerGeneration: UInt64) {
295
+ listenerLock.lock()
296
+ defer { listenerLock.unlock() }
297
+
298
+ guard pendingConnectionCleanup?.generation == listenerGeneration else { return }
299
+ pendingConnectionCleanup = nil
241
300
  }
242
301
 
243
302
  private static func cleanupListenersLocked() {
@@ -253,9 +312,8 @@ enum ExpoIapHelper {
253
312
  purchaseUpdatedOptions = PurchaseUpdatedListenerOptions()
254
313
  }
255
314
 
256
- static func setupStore(module: ExpoIapModule) {
315
+ static func setupStore(module: ExpoIapModule) -> UInt64 {
257
316
  setupListeners(
258
- module: module,
259
317
  purchaseUpdated: { [weak module] purchase in
260
318
  guard let module else { return }
261
319
  let payload = sanitizeDictionary(OpenIapSerialization.purchase(purchase))
@@ -291,8 +349,13 @@ enum ExpoIapHelper {
291
349
  )
292
350
  }
293
351
 
294
- static func cleanupStore() async {
295
- cleanupListeners()
296
- _ = try? await OpenIapModule.shared.endConnection()
352
+ static func cleanupStore(listenerGeneration: UInt64) async {
353
+ guard let task = beginStoreCleanup(ifOwnedBy: listenerGeneration) else { return }
354
+ await task.value
355
+ finishStoreCleanup(listenerGeneration: listenerGeneration)
356
+ }
357
+
358
+ static func waitForStoreCleanup() async {
359
+ await pendingConnectionCleanupTask()?.value
297
360
  }
298
361
  }
@@ -109,7 +109,7 @@ enum ExpoIapLog {
109
109
  }
110
110
 
111
111
  private static func sanitize(_ value: Any?) -> Any? {
112
- guard let value else { return nil }
112
+ guard let value, !(value is NSNull) else { return nil }
113
113
 
114
114
  if let string = value as? String {
115
115
  let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -121,20 +121,20 @@ enum ExpoIapLog {
121
121
  return sanitize(json) ?? string
122
122
  }
123
123
 
124
- if let dictionary = value as? [String: Any] {
125
- return sanitizeDictionary(dictionary)
126
- }
127
-
128
124
  if let optionalDictionary = value as? [String: Any?] {
129
125
  var compact: [String: Any] = [:]
130
126
  for (key, optionalValue) in optionalDictionary {
131
- if let optionalValue {
127
+ if let optionalValue, !(optionalValue is NSNull) {
132
128
  compact[key] = optionalValue
133
129
  }
134
130
  }
135
131
  return sanitizeDictionary(compact)
136
132
  }
137
133
 
134
+ if let dictionary = value as? [String: Any] {
135
+ return sanitizeDictionary(dictionary)
136
+ }
137
+
138
138
  if let array = value as? [Any] {
139
139
  return array.compactMap { sanitize($0) }
140
140
  }
@@ -10,6 +10,7 @@ import UIKit
10
10
  @MainActor
11
11
  public final class ExpoIapModule: Module {
12
12
  private var isInitialized = false
13
+ private var listenerGeneration: UInt64?
13
14
 
14
15
  nonisolated public func definition() -> ModuleDefinition {
15
16
  Name("ExpoIap")
@@ -25,21 +26,26 @@ public final class ExpoIapModule: Module {
25
26
  IapEvent.subscriptionBillingIssue.rawValue
26
27
  )
27
28
 
28
- OnCreate {
29
- Task { @MainActor in
30
- ExpoIapHelper.setupStore(module: self)
29
+ OnCreate { [weak self] in
30
+ Task { @MainActor [weak self] in
31
+ guard let self else { return }
32
+ self.listenerGeneration = ExpoIapHelper.setupStore(module: self)
31
33
  }
32
34
  }
33
35
 
34
- OnDestroy {
35
- Task { @MainActor in
36
- await ExpoIapHelper.cleanupStore()
36
+ OnDestroy { [weak self] in
37
+ guard let self else { return }
38
+ Task { @MainActor [self] in
39
+ guard let listenerGeneration = self.listenerGeneration else { return }
40
+ self.listenerGeneration = nil
41
+ await ExpoIapHelper.cleanupStore(listenerGeneration: listenerGeneration)
37
42
  }
38
43
  }
39
44
 
40
45
  AsyncFunction("initConnection") { (config: [String: Any]?) async throws -> Bool in
41
46
  // Note: iOS doesn't support alternative billing config parameter
42
47
  // Config is ignored on iOS platform
48
+ await ExpoIapHelper.waitForStoreCleanup()
43
49
  let isConnected = try await OpenIapModule.shared.initConnection()
44
50
  await MainActor.run { self.isInitialized = isConnected }
45
51
  return isConnected
@@ -210,7 +216,14 @@ public final class ExpoIapModule: Module {
210
216
  }
211
217
 
212
218
  AsyncFunction("verifyPurchase") { (params: [String: Any]) async throws -> [String: Any] in
213
- ExpoIapLog.payload("verifyPurchase", payload: params)
219
+ ExpoIapLog.payload(
220
+ "verifyPurchase",
221
+ payload: [
222
+ "hasApple": params["apple"] != nil,
223
+ "hasGoogle": params["google"] != nil,
224
+ "hasHorizon": params["horizon"] != nil,
225
+ ]
226
+ )
214
227
  do {
215
228
  let props = try OpenIapSerialization.verifyPurchaseProps(from: params)
216
229
  let result = try await OpenIapModule.shared.verifyPurchase(props)
@@ -73,14 +73,15 @@ public final class ExpoIapOnsideModule: Module {
73
73
  OnsideEvent.subscriptionBillingIssue.rawValue
74
74
  )
75
75
 
76
- OnCreate {
77
- Task { @MainActor in
78
- self.configureObserverCallbacks()
76
+ OnCreate { [weak self] in
77
+ Task { @MainActor [weak self] in
78
+ self?.configureObserverCallbacks()
79
79
  }
80
80
  }
81
81
 
82
- OnDestroy {
83
- Task { @MainActor in
82
+ OnDestroy { [weak self] in
83
+ guard let self else { return }
84
+ Task { @MainActor [self] in
84
85
  self.cleanup()
85
86
  }
86
87
  }
@@ -104,6 +105,10 @@ public final class ExpoIapOnsideModule: Module {
104
105
 
105
106
  AsyncFunction("fetchProducts") { (params: [String: Any]) async throws -> [[String: Any]] in
106
107
  ExpoIapLog.payload("fetchProductsOnside", payload: params)
108
+ let request = try ExpoIapHelper.decodeProductRequest(from: params)
109
+ guard !request.skus.isEmpty else {
110
+ throw OnsideBridgeError.emptySkuList
111
+ }
107
112
  try await ensureObserverRegistered()
108
113
 
109
114
  var storefront = await Onside.defaultPaymentQueue().storefront
@@ -164,11 +169,6 @@ public final class ExpoIapOnsideModule: Module {
164
169
  }
165
170
  }
166
171
 
167
- let request = try ExpoIapHelper.decodeProductRequest(from: params)
168
- guard !request.skus.isEmpty else {
169
- throw OnsideBridgeError.emptySkuList
170
- }
171
-
172
172
  let response = try await productFetcher.fetch(identifiers: Set(request.skus))
173
173
 
174
174
  if !response.invalidProductIdentifiers.isEmpty {
@@ -198,14 +198,10 @@ public final class ExpoIapOnsideModule: Module {
198
198
  AsyncFunction("requestPurchase") { (payload: [String: Any]) async throws -> Any? in
199
199
  ExpoIapLog.payload("requestPurchaseOnside", payload: payload)
200
200
 
201
- try await ensureObserverRegistered()
201
+ let purchaseRequest = try ExpoIapHelper.decodeRequestPurchaseProps(from: payload)
202
+ let sku = try resolveSku(from: purchaseRequest)
202
203
 
203
- let sku: String = try await MainActor.run {
204
- guard let s = resolveSku(from: payload) else {
205
- throw OnsideBridgeError.emptySkuList
206
- }
207
- return s
208
- }
204
+ try await ensureObserverRegistered()
209
205
 
210
206
  try await ensureProductAvailable(sku: sku)
211
207
 
@@ -655,47 +651,23 @@ public final class ExpoIapOnsideModule: Module {
655
651
  }
656
652
  }
657
653
 
658
- private func resolveSku(from payload: [String: Any]) -> String? {
659
- if let sku = payload["sku"] as? String, !sku.isEmpty {
660
- return sku
661
- }
662
-
663
- if let request = payload["request"] as? [String: Any] {
664
- if let apple = resolveAppleRequest(from: request) {
665
- if let sku = apple["sku"] as? String, !sku.isEmpty {
666
- return sku
667
- }
668
- if let skus = apple["skus"] as? [String], let first = skus.first, !first.isEmpty {
669
- return first
670
- }
671
- }
672
- }
673
-
674
- if
675
- let requestPurchase = payload["requestPurchase"] as? [String: Any],
676
- let apple = resolveAppleRequest(from: requestPurchase),
677
- let sku = apple["sku"] as? String, !sku.isEmpty
678
- {
679
- return sku
680
- }
681
-
682
- if
683
- let requestSubscription = payload["requestSubscription"] as? [String: Any],
684
- let apple = resolveAppleRequest(from: requestSubscription),
685
- let sku = apple["sku"] as? String, !sku.isEmpty
686
- {
687
- return sku
654
+ nonisolated private func resolveSku(from request: RequestPurchaseProps) throws -> String {
655
+ let sku: String?
656
+ switch (request.type, request.request) {
657
+ case (.inApp, .purchase(let platforms)):
658
+ sku = platforms.apple?.sku
659
+ case (.subs, .subscription(let platforms)):
660
+ sku = platforms.apple?.sku
661
+ default:
662
+ throw PurchaseError.make(
663
+ code: .developerError,
664
+ message: "Purchase type must match the request branch."
665
+ )
688
666
  }
689
-
690
- if let skus = payload["skus"] as? [String], let first = skus.first, !first.isEmpty {
691
- return first
667
+ guard let sku, !sku.isEmpty else {
668
+ throw OnsideBridgeError.emptySkuList
692
669
  }
693
-
694
- return nil
695
- }
696
-
697
- private func resolveAppleRequest(from request: [String: Any]) -> [String: Any]? {
698
- request["apple"] as? [String: Any]
670
+ return sku
699
671
  }
700
672
  }
701
673
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "spec": "3.2.0",
3
- "google": "3.3.0",
4
- "apple": "3.2.0"
2
+ "spec": "3.2.1",
3
+ "google": "3.3.2",
4
+ "apple": "3.2.1"
5
5
  }
@@ -0,0 +1,9 @@
1
+ [[IgnoredVulns]]
2
+ id = "GHSA-5p2g-fcmc-qvqq"
3
+ ignoreUntil = 2026-09-14
4
+ reason = "No patched image-size release exists; Metro uses it only during trusted local builds."
5
+
6
+ [[IgnoredVulns]]
7
+ id = "GHSA-w3rx-r6r6-pgpr"
8
+ ignoreUntil = 2026-09-14
9
+ reason = "No patched image-size release exists; Metro uses it only during trusted local builds."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expo-iap",
3
- "version": "5.3.0",
3
+ "version": "5.3.2",
4
4
  "description": "In App Purchase module in Expo",
5
5
  "main": "build/index.js",
6
6
  "types": "build/index.d.ts",
@@ -59,14 +59,17 @@
59
59
  "eslint-config-expo": "^57.0.1",
60
60
  "eslint-config-prettier": "^10.1.8",
61
61
  "eslint-plugin-prettier": "^5.5.6",
62
+ "expo": "^57.0.12",
62
63
  "expo-module-scripts": "^56.0.3",
63
64
  "expo-modules-core": "^57.0.10",
64
65
  "globals": "^17.9.0",
65
66
  "husky": "^9.1.7",
66
67
  "jest": "^29.7.0",
67
68
  "react": "19.2.3",
69
+ "react-native": "0.86.2",
68
70
  "test-renderer": "^1.2.0",
69
- "ts-jest": "^29.4.12"
71
+ "ts-jest": "^29.4.12",
72
+ "typescript": "^5.9.2"
70
73
  },
71
74
  "peerDependencies": {
72
75
  "@amazon-devices/keplerscript-appstore-iap-lib": "~2.13.0",
@@ -83,6 +86,9 @@
83
86
  "optional": true
84
87
  }
85
88
  },
89
+ "overrides": {
90
+ "uuid": "11.1.1"
91
+ },
86
92
  "expo": {
87
93
  "plugin": "./app.plugin.js"
88
94
  }
@@ -42,6 +42,9 @@ const GENERATED_JS_MARKER = '// @generated by expo-iap';
42
42
  const DEFAULT_ICON_FILE = 'icon.png';
43
43
  const DEFAULT_BUILD_TYPE = 'Release';
44
44
  const DEFAULT_RUNTIME_MODULE = '/com.amazon.kepler.keplerscript.runtime.loader_2@IKeplerScript_2_0';
45
+ // Vega SDK 0.24 requires both the OS module and an [os.version] block.
46
+ const VEGA_OS_MODULE = '/com.amazon.vega.os@IVega_1_2';
47
+ const VEGA_OS_VERSION = '1.2';
45
48
  const logOnce = (() => {
46
49
  const printed = new Set();
47
50
  return (msg) => {
@@ -133,6 +136,13 @@ id = "/com.amazon.iap.core@IIAPCoreUI"
133
136
  [needs]
134
137
  [[needs.module]]
135
138
  id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService"
139
+
140
+ [[needs.module]]
141
+ id = ${escapeTomlString(VEGA_OS_MODULE)}
142
+
143
+ [os.version]
144
+ target = ${escapeTomlString(VEGA_OS_VERSION)}
145
+ min = ${escapeTomlString(VEGA_OS_VERSION)}
136
146
  `;
137
147
  exports.createVegaManifest = createVegaManifest;
138
148
  const createVegaEntryPoint = () => `${GENERATED_JS_MARKER}
@@ -13,6 +13,9 @@ const DEFAULT_ICON_FILE = 'icon.png';
13
13
  const DEFAULT_BUILD_TYPE = 'Release';
14
14
  const DEFAULT_RUNTIME_MODULE =
15
15
  '/com.amazon.kepler.keplerscript.runtime.loader_2@IKeplerScript_2_0';
16
+ // Vega SDK 0.24 requires both the OS module and an [os.version] block.
17
+ const VEGA_OS_MODULE = '/com.amazon.vega.os@IVega_1_2';
18
+ const VEGA_OS_VERSION = '1.2';
16
19
 
17
20
  const logOnce = (() => {
18
21
  const printed = new Set<string>();
@@ -155,6 +158,13 @@ id = "/com.amazon.iap.core@IIAPCoreUI"
155
158
  [needs]
156
159
  [[needs.module]]
157
160
  id = "/com.amazon.kepler.appstore.iap.purchase.core@IAppstoreIAPPurchaseCoreService"
161
+
162
+ [[needs.module]]
163
+ id = ${escapeTomlString(VEGA_OS_MODULE)}
164
+
165
+ [os.version]
166
+ target = ${escapeTomlString(VEGA_OS_VERSION)}
167
+ min = ${escapeTomlString(VEGA_OS_VERSION)}
158
168
  `;
159
169
 
160
170
  export const createVegaEntryPoint = (): string => `${GENERATED_JS_MARKER}
@@ -13,6 +13,7 @@ import type {
13
13
  RequestSubscriptionPropsByPlatforms,
14
14
  } from './types';
15
15
  import {decodeAndroidPurchases} from './utils/availablePurchases';
16
+ import {createPurchaseError} from './utils/errorMapping';
16
17
 
17
18
  export * from './types';
18
19
  export * from './vega';
@@ -148,6 +149,13 @@ export const fetchProducts = async (
148
149
  export const requestPurchase: MutationField<'requestPurchase'> = async (
149
150
  args,
150
151
  ) => {
152
+ const type = normalizeProductType(args.type);
153
+ if (type === 'all') {
154
+ throw createPurchaseError({
155
+ code: ErrorCode.DeveloperError,
156
+ message: 'Product type all is only supported for product queries.',
157
+ });
158
+ }
151
159
  const androidRequest = getAndroidRequest(args.request);
152
160
  if (!androidRequest?.skus?.length) {
153
161
  throw new Error(
@@ -158,7 +166,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
158
166
  return normalizePurchaseArray(
159
167
  await getModule().requestPurchase({
160
168
  skus: androidRequest.skus,
161
- type: normalizeProductType(args.type),
169
+ type,
162
170
  }),
163
171
  );
164
172
  };
package/src/index.ts CHANGED
@@ -929,6 +929,31 @@ function normalizeRequestProps(
929
929
  return request.google;
930
930
  }
931
931
 
932
+ function validateAndroidPurchaseBranchOptions(
933
+ request: unknown,
934
+ type: ProductQueryType,
935
+ ): void {
936
+ if (request == null || typeof request !== 'object') return;
937
+ const fields = request as Record<string, unknown>;
938
+ const subscriptionOnlyFields = [
939
+ 'subscriptionOffers',
940
+ 'subscriptionProductReplacementParams',
941
+ 'purchaseToken',
942
+ 'originalExternalTransactionId',
943
+ ];
944
+ if (
945
+ (type === 'in-app' &&
946
+ subscriptionOnlyFields.some((field) => fields[field] != null)) ||
947
+ (type === 'subs' && fields.offerToken != null)
948
+ ) {
949
+ throw createPurchaseError({
950
+ code: ErrorCode.DeveloperError,
951
+ message:
952
+ 'Invalid request for Android. Purchase options must match the selected product type.',
953
+ });
954
+ }
955
+ }
956
+
932
957
  /**
933
958
  * Initiate a purchase or subscription flow. The result is delivered through
934
959
  * `purchaseUpdatedListener` — NOT the return value.
@@ -960,14 +985,21 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
960
985
  ) => {
961
986
  const {request, type} = args;
962
987
  const {canonical, native} = normalizeProductType(type as ProductTypeInput);
988
+ if (canonical === 'all') {
989
+ throw createPurchaseError({
990
+ code: ErrorCode.DeveloperError,
991
+ message: 'Product type all is only supported for product queries.',
992
+ });
993
+ }
963
994
  const isInAppPurchase = canonical === 'in-app';
964
995
 
965
996
  if (Platform.OS === 'ios') {
966
997
  const normalizedRequest = normalizeRequestProps(request, 'ios');
967
998
 
968
999
  if (!normalizedRequest?.sku) {
969
- throw new Error(
970
- 'Invalid request for Apple. The `sku` property is required and must be a string.\n\n' +
1000
+ throw createPurchaseError({
1001
+ message:
1002
+ 'Invalid request for Apple. The `sku` property is required and must be a string.\n\n' +
971
1003
  'Expected format:\n' +
972
1004
  ' requestPurchase({\n' +
973
1005
  ' request: {\n' +
@@ -977,7 +1009,8 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
977
1009
  ' type: "in-app"\n' +
978
1010
  ' })\n\n' +
979
1011
  'See: https://openiap.dev/docs/apis/request-purchase',
980
- );
1012
+ code: ErrorCode.EmptySkuList,
1013
+ });
981
1014
  }
982
1015
 
983
1016
  if (canonical !== 'in-app' && canonical !== 'subs') {
@@ -1013,6 +1046,14 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
1013
1046
  }
1014
1047
 
1015
1048
  if (isAndroidStoreRuntime()) {
1049
+ validateAndroidPurchaseBranchOptions(
1050
+ (
1051
+ request as
1052
+ | RequestPurchasePropsByPlatforms
1053
+ | RequestSubscriptionPropsByPlatforms
1054
+ )?.google,
1055
+ canonical,
1056
+ );
1016
1057
  if (isInAppPurchase) {
1017
1058
  const normalizedRequest = normalizeRequestProps(
1018
1059
  request as RequestPurchasePropsByPlatforms,
@@ -1020,8 +1061,9 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
1020
1061
  ) as RequestPurchaseAndroidProps | null | undefined;
1021
1062
 
1022
1063
  if (!normalizedRequest?.skus?.length) {
1023
- throw new Error(
1024
- 'Invalid request for Google. The `skus` property is required and must be a non-empty array.\n\n' +
1064
+ throw createPurchaseError({
1065
+ message:
1066
+ 'Invalid request for Google. The `skus` property is required and must be a non-empty array.\n\n' +
1025
1067
  'Expected format:\n' +
1026
1068
  ' requestPurchase({\n' +
1027
1069
  ' request: {\n' +
@@ -1031,7 +1073,8 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
1031
1073
  ' type: "in-app"\n' +
1032
1074
  ' })\n\n' +
1033
1075
  'See: https://openiap.dev/docs/apis/request-purchase',
1034
- );
1076
+ code: ErrorCode.EmptySkuList,
1077
+ });
1035
1078
  }
1036
1079
 
1037
1080
  const {
@@ -1075,8 +1118,9 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
1075
1118
  ) as RequestSubscriptionAndroidProps | null | undefined;
1076
1119
 
1077
1120
  if (!normalizedRequest?.skus?.length) {
1078
- throw new Error(
1079
- 'Invalid request for Google. The `skus` property is required and must be a non-empty array.\n\n' +
1121
+ throw createPurchaseError({
1122
+ message:
1123
+ 'Invalid request for Google. The `skus` property is required and must be a non-empty array.\n\n' +
1080
1124
  'Expected format:\n' +
1081
1125
  ' requestPurchase({\n' +
1082
1126
  ' request: {\n' +
@@ -1086,7 +1130,8 @@ export const requestPurchase: MutationField<'requestPurchase'> = async (
1086
1130
  ' type: "subs"\n' +
1087
1131
  ' })\n\n' +
1088
1132
  'See: https://openiap.dev/docs/apis/request-purchase',
1089
- );
1133
+ code: ErrorCode.EmptySkuList,
1134
+ });
1090
1135
  }
1091
1136
 
1092
1137
  const {