expo-iap 3.0.7 → 3.1.0

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 (52) hide show
  1. package/CLAUDE.md +14 -2
  2. package/CONTRIBUTING.md +19 -0
  3. package/README.md +18 -6
  4. package/android/build.gradle +24 -1
  5. package/android/src/main/java/expo/modules/iap/ExpoIapLog.kt +69 -0
  6. package/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +190 -59
  7. package/build/index.d.ts +32 -111
  8. package/build/index.d.ts.map +1 -1
  9. package/build/index.js +198 -243
  10. package/build/index.js.map +1 -1
  11. package/build/modules/android.d.ts +7 -12
  12. package/build/modules/android.d.ts.map +1 -1
  13. package/build/modules/android.js +15 -12
  14. package/build/modules/android.js.map +1 -1
  15. package/build/modules/ios.d.ts +35 -36
  16. package/build/modules/ios.d.ts.map +1 -1
  17. package/build/modules/ios.js +101 -35
  18. package/build/modules/ios.js.map +1 -1
  19. package/build/types.d.ts +107 -82
  20. package/build/types.d.ts.map +1 -1
  21. package/build/types.js +1 -0
  22. package/build/types.js.map +1 -1
  23. package/build/useIAP.d.ts +7 -12
  24. package/build/useIAP.d.ts.map +1 -1
  25. package/build/useIAP.js +49 -23
  26. package/build/useIAP.js.map +1 -1
  27. package/build/utils/errorMapping.d.ts +32 -23
  28. package/build/utils/errorMapping.d.ts.map +1 -1
  29. package/build/utils/errorMapping.js +117 -22
  30. package/build/utils/errorMapping.js.map +1 -1
  31. package/ios/ExpoIap.podspec +3 -2
  32. package/ios/ExpoIapHelper.swift +96 -0
  33. package/ios/ExpoIapLog.swift +127 -0
  34. package/ios/ExpoIapModule.swift +218 -340
  35. package/openiap-versions.json +5 -0
  36. package/package.json +2 -2
  37. package/plugin/build/withIAP.js +6 -4
  38. package/plugin/src/withIAP.ts +14 -4
  39. package/scripts/update-types.mjs +20 -1
  40. package/src/index.ts +280 -356
  41. package/src/modules/android.ts +25 -23
  42. package/src/modules/ios.ts +138 -48
  43. package/src/types.ts +139 -91
  44. package/src/useIAP.ts +91 -58
  45. package/src/utils/errorMapping.ts +203 -23
  46. package/.copilot-instructions.md +0 -321
  47. package/.cursorrules +0 -321
  48. package/build/purchase-error.d.ts +0 -67
  49. package/build/purchase-error.d.ts.map +0 -1
  50. package/build/purchase-error.js +0 -166
  51. package/build/purchase-error.js.map +0 -1
  52. package/src/purchase-error.ts +0 -265
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  validateReceiptIOS,
10
10
  deepLinkToSubscriptionsIOS,
11
11
  syncIOS,
12
+ getStorefrontIOS,
12
13
  } from './modules/ios';
13
14
  import {
14
15
  isProductAndroid,
@@ -17,29 +18,31 @@ import {
17
18
  } from './modules/android';
18
19
 
19
20
  // Types
20
- import {
21
+ import type {
22
+ AndroidSubscriptionOfferInput,
23
+ DeepLinkOptions,
24
+ FetchProductsResult,
25
+ MutationField,
26
+ MutationRequestPurchaseArgs,
27
+ MutationValidateReceiptArgs,
21
28
  Product,
29
+ ProductQueryType,
30
+ ProductSubscription,
22
31
  Purchase,
23
- ErrorCode,
24
- RequestPurchaseProps,
32
+ PurchaseOptions,
33
+ QueryField,
25
34
  RequestPurchasePropsByPlatforms,
26
35
  RequestPurchaseAndroidProps,
27
36
  RequestPurchaseIosProps,
28
37
  RequestSubscriptionPropsByPlatforms,
29
38
  RequestSubscriptionAndroidProps,
30
39
  RequestSubscriptionIosProps,
31
- ProductSubscription,
32
- PurchaseAndroid,
33
- DiscountOfferInputIOS,
34
- VoidResult,
35
- ReceiptValidationResult,
36
- AndroidSubscriptionOfferInput,
37
40
  } from './types';
38
- import {PurchaseError} from './purchase-error';
41
+ import {ErrorCode} from './types';
42
+ import {createPurchaseError, type PurchaseError} from './utils/errorMapping';
39
43
 
40
44
  // Export all types
41
45
  export * from './types';
42
- export {ErrorCodeUtils, ErrorCodeMapping} from './purchase-error';
43
46
  export * from './modules/android';
44
47
  export * from './modules/ios';
45
48
 
@@ -50,18 +53,12 @@ export {
50
53
  } from './helpers/subscription';
51
54
 
52
55
  // Get the native constant value
53
- export const PI = ExpoIapModule.PI;
54
-
55
56
  export enum OpenIapEvent {
56
57
  PurchaseUpdated = 'purchase-updated',
57
58
  PurchaseError = 'purchase-error',
58
59
  PromotedProductIOS = 'promoted-product-ios',
59
60
  }
60
61
 
61
- export function setValueAsync(value: string) {
62
- return ExpoIapModule.setValueAsync(value);
63
- }
64
-
65
62
  type ExpoIapEventPayloads = {
66
63
  [OpenIapEvent.PurchaseUpdated]: Purchase;
67
64
  [OpenIapEvent.PurchaseError]: PurchaseError;
@@ -90,32 +87,7 @@ export const emitter = (ExpoIapModule ||
90
87
  /**
91
88
  * TODO(v3.1.0): Remove legacy 'inapp' alias once downstream apps migrate to 'in-app'.
92
89
  */
93
- export type ProductTypeInput = 'inapp' | 'in-app' | 'subs';
94
- export type InAppTypeInput = Exclude<ProductTypeInput, 'subs'>;
95
-
96
- type PurchaseRequestInApp = {
97
- request: RequestPurchasePropsByPlatforms;
98
- type?: InAppTypeInput;
99
- };
100
-
101
- type PurchaseRequestSubscription = {
102
- request: RequestSubscriptionPropsByPlatforms;
103
- type: 'subs';
104
- };
105
-
106
- export type PurchaseRequestInput =
107
- | PurchaseRequestInApp
108
- | PurchaseRequestSubscription;
109
-
110
- export type PurchaseRequest =
111
- | {
112
- request: RequestPurchaseProps;
113
- type?: InAppTypeInput;
114
- }
115
- | {
116
- request: RequestSubscriptionPropsByPlatforms;
117
- type: 'subs';
118
- };
90
+ export type ProductTypeInput = ProductQueryType | 'inapp';
119
91
 
120
92
  const normalizeProductType = (type?: ProductTypeInput) => {
121
93
  if (type === 'inapp') {
@@ -126,48 +98,66 @@ const normalizeProductType = (type?: ProductTypeInput) => {
126
98
 
127
99
  if (!type || type === 'inapp' || type === 'in-app') {
128
100
  return {
129
- canonical: 'in-app' as const,
130
- native: 'inapp' as const,
101
+ canonical: 'in-app' as ProductQueryType,
102
+ native: 'in-app' as const,
131
103
  };
132
104
  }
133
105
  if (type === 'subs') {
134
106
  return {
135
- canonical: 'subs' as const,
107
+ canonical: 'subs' as ProductQueryType,
136
108
  native: 'subs' as const,
137
109
  };
138
110
  }
111
+ if (type === 'all') {
112
+ return {
113
+ canonical: 'all' as ProductQueryType,
114
+ native: 'all' as const,
115
+ };
116
+ }
139
117
  throw new Error(`Unsupported product type: ${type}`);
140
118
  };
141
119
 
120
+ const normalizePurchasePlatform = (purchase: Purchase): Purchase => {
121
+ const platform = purchase.platform;
122
+ if (typeof platform !== 'string') {
123
+ return purchase;
124
+ }
125
+
126
+ const lowered = platform.toLowerCase();
127
+ if (lowered === platform || (lowered !== 'ios' && lowered !== 'android')) {
128
+ return purchase;
129
+ }
130
+
131
+ return {...purchase, platform: lowered};
132
+ };
133
+
134
+ const normalizePurchaseArray = (purchases: Purchase[]): Purchase[] =>
135
+ purchases.map((purchase) => normalizePurchasePlatform(purchase));
136
+
142
137
  export const purchaseUpdatedListener = (
143
138
  listener: (event: Purchase) => void,
144
139
  ) => {
145
- console.log('[JS] Registering purchaseUpdatedListener');
146
140
  const wrappedListener = (event: Purchase) => {
147
- console.log('[JS] purchaseUpdatedListener fired:', event);
148
- listener(event);
141
+ const normalized = normalizePurchasePlatform(event);
142
+ listener(normalized);
149
143
  };
150
144
  const emitterSubscription = emitter.addListener(
151
145
  OpenIapEvent.PurchaseUpdated,
152
146
  wrappedListener,
153
147
  );
154
- console.log('[JS] purchaseUpdatedListener registered successfully');
155
148
  return emitterSubscription;
156
149
  };
157
150
 
158
151
  export const purchaseErrorListener = (
159
152
  listener: (error: PurchaseError) => void,
160
153
  ) => {
161
- console.log('[JS] Registering purchaseErrorListener');
162
154
  const wrappedListener = (error: PurchaseError) => {
163
- console.log('[JS] purchaseErrorListener fired:', error);
164
155
  listener(error);
165
156
  };
166
157
  const emitterSubscription = emitter.addListener(
167
158
  OpenIapEvent.PurchaseError,
168
159
  wrappedListener,
169
160
  );
170
- console.log('[JS] purchaseErrorListener registered successfully');
171
161
  return emitterSubscription;
172
162
  };
173
163
 
@@ -203,158 +193,114 @@ export const promotedProductListenerIOS = (
203
193
  return emitter.addListener(OpenIapEvent.PromotedProductIOS, listener);
204
194
  };
205
195
 
206
- export function initConnection(): Promise<boolean> {
207
- const result = ExpoIapModule.initConnection();
208
- return Promise.resolve(result);
209
- }
196
+ export const initConnection: MutationField<'initConnection'> = async () =>
197
+ ExpoIapModule.initConnection();
210
198
 
211
- export async function endConnection(): Promise<boolean> {
212
- return ExpoIapModule.endConnection();
213
- }
199
+ export const endConnection: MutationField<'endConnection'> = async () =>
200
+ ExpoIapModule.endConnection();
214
201
 
215
202
  /**
216
203
  * Fetch products with unified API (v2.7.0+)
217
204
  *
218
- * @param params - Product fetch configuration
219
- * @param params.skus - Array of product SKUs to fetch
220
- * @param params.type - Type of products: 'in-app' for regular products (default) or 'subs' for subscriptions
221
- *
222
- * @example
223
- * ```typescript
224
- * // Regular products
225
- * const products = await fetchProducts({
226
- * skus: ['product1', 'product2'],
227
- * type: 'in-app'
228
- * });
229
- *
230
- * // Subscriptions
231
- * const subscriptions = await fetchProducts({
232
- * skus: ['sub1', 'sub2'],
233
- * type: 'subs'
234
- * });
235
- * ```
205
+ * @param request - Product fetch configuration
206
+ * @param request.skus - Array of product SKUs to fetch
207
+ * @param request.type - Product query type: 'in-app', 'subs', or 'all'
236
208
  */
237
- export const fetchProducts = async ({
238
- skus,
239
- type,
240
- }: {
241
- skus: string[];
242
- type?: ProductTypeInput;
243
- }): Promise<Product[] | ProductSubscription[]> => {
244
- if (!skus?.length) {
245
- throw new PurchaseError({
209
+ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => {
210
+ console.log('fetchProducts called with:', request);
211
+ const {skus, type} = request ?? {};
212
+
213
+ if (!Array.isArray(skus) || skus.length === 0) {
214
+ throw createPurchaseError({
246
215
  message: 'No SKUs provided',
247
216
  code: ErrorCode.EmptySkuList,
248
217
  });
249
218
  }
250
219
 
251
- const {canonical, native} = normalizeProductType(type);
252
-
253
- if (Platform.OS === 'ios') {
254
- const rawItems = await ExpoIapModule.fetchProducts({skus, type: native});
220
+ const {canonical, native} = normalizeProductType(
221
+ type as ProductTypeInput | undefined,
222
+ );
223
+ const skuSet = new Set(skus);
255
224
 
256
- const filteredItems = rawItems.filter((item: unknown) => {
225
+ const filterIosItems = (
226
+ items: unknown[],
227
+ ): Product[] | ProductSubscription[] =>
228
+ items.filter((item): item is Product | ProductSubscription => {
257
229
  if (!isProductIOS(item)) {
258
230
  return false;
259
231
  }
260
- const isValid =
261
- typeof item === 'object' &&
262
- item !== null &&
263
- 'id' in item &&
264
- typeof item.id === 'string' &&
265
- skus.includes(item.id);
266
- return isValid;
232
+ const candidate = item as Product | ProductSubscription;
233
+ return typeof candidate.id === 'string' && skuSet.has(candidate.id);
234
+ });
235
+
236
+ const filterAndroidItems = (
237
+ items: unknown[],
238
+ ): Product[] | ProductSubscription[] =>
239
+ items.filter((item): item is Product | ProductSubscription => {
240
+ if (!isProductAndroid(item)) {
241
+ return false;
242
+ }
243
+ const candidate = item as Product | ProductSubscription;
244
+ return typeof candidate.id === 'string' && skuSet.has(candidate.id);
267
245
  });
268
246
 
269
- return canonical === 'in-app'
270
- ? (filteredItems as Product[])
271
- : (filteredItems as ProductSubscription[]);
247
+ const castResult = (
248
+ items: Product[] | ProductSubscription[],
249
+ ): FetchProductsResult => {
250
+ if (canonical === 'in-app') {
251
+ return items as Product[];
252
+ }
253
+ if (canonical === 'subs') {
254
+ return items as ProductSubscription[];
255
+ }
256
+ return items;
257
+ };
258
+
259
+ if (Platform.OS === 'ios') {
260
+ const rawItems = await ExpoIapModule.fetchProducts({skus, type: native});
261
+ return castResult(filterIosItems(rawItems));
272
262
  }
273
263
 
274
264
  if (Platform.OS === 'android') {
275
- const items = await ExpoIapModule.fetchProducts(native, skus);
276
- const filteredItems = items.filter((item: unknown) => {
277
- if (!isProductAndroid(item)) return false;
278
- return (
279
- typeof item === 'object' &&
280
- item !== null &&
281
- 'id' in item &&
282
- typeof item.id === 'string' &&
283
- skus.includes(item.id)
284
- );
285
- });
286
-
287
- return canonical === 'in-app'
288
- ? (filteredItems as Product[])
289
- : (filteredItems as ProductSubscription[]);
265
+ const rawItems = await ExpoIapModule.fetchProducts(native, skus);
266
+ return castResult(filterAndroidItems(rawItems));
290
267
  }
291
268
 
292
269
  throw new Error('Unsupported platform');
293
270
  };
294
271
 
295
- export const getAvailablePurchases = ({
296
- alsoPublishToEventListenerIOS = false,
297
- onlyIncludeActiveItemsIOS = true,
298
- }: {
299
- alsoPublishToEventListenerIOS?: boolean;
300
- onlyIncludeActiveItemsIOS?: boolean;
301
- } = {}): Promise<Purchase[]> =>
302
- (
272
+ export const getAvailablePurchases: QueryField<
273
+ 'getAvailablePurchases'
274
+ > = async (options) => {
275
+ const normalizedOptions: PurchaseOptions = {
276
+ alsoPublishToEventListenerIOS:
277
+ options?.alsoPublishToEventListenerIOS ?? false,
278
+ onlyIncludeActiveItemsIOS: options?.onlyIncludeActiveItemsIOS ?? true,
279
+ };
280
+
281
+ const resolvePurchases: () => Promise<Purchase[]> =
303
282
  Platform.select({
304
283
  ios: () =>
305
284
  ExpoIapModule.getAvailableItems(
306
- alsoPublishToEventListenerIOS,
307
- onlyIncludeActiveItemsIOS,
308
- ),
309
- android: () => ExpoIapModule.getAvailableItems(),
310
- }) || (() => Promise.resolve([]))
311
- )();
312
-
313
- /**
314
- * Restore completed transactions (cross-platform behavior)
315
- *
316
- * - iOS: perform a lightweight sync to refresh transactions and ignore sync errors,
317
- * then fetch available purchases to surface restored items to the app.
318
- * - Android: simply fetch available purchases (restoration happens via query).
319
- *
320
- * This helper returns the restored/available purchases so callers can update UI/state.
321
- *
322
- * @param options.alsoPublishToEventListenerIOS - iOS only: whether to also publish to the event listener
323
- * @param options.onlyIncludeActiveItemsIOS - iOS only: whether to only include active items
324
- * @returns Promise resolving to the list of available/restored purchases
325
- */
326
- export const restorePurchases = async (
327
- options: {
328
- alsoPublishToEventListenerIOS?: boolean;
329
- onlyIncludeActiveItemsIOS?: boolean;
330
- } = {},
331
- ): Promise<Purchase[]> => {
332
- if (Platform.OS === 'ios') {
333
- // Perform best-effort sync on iOS and ignore sync errors to avoid blocking restore flow
334
- await syncIOS().catch(() => undefined);
335
- }
336
-
337
- // Then, fetch available purchases for both platforms
338
- const purchases = await getAvailablePurchases({
339
- alsoPublishToEventListenerIOS:
340
- options.alsoPublishToEventListenerIOS ?? false,
341
- onlyIncludeActiveItemsIOS: options.onlyIncludeActiveItemsIOS ?? true,
342
- });
343
-
344
- return purchases;
285
+ normalizedOptions.alsoPublishToEventListenerIOS,
286
+ normalizedOptions.onlyIncludeActiveItemsIOS,
287
+ ) as Promise<Purchase[]>,
288
+ android: () => ExpoIapModule.getAvailableItems() as Promise<Purchase[]>,
289
+ }) ?? (() => Promise.resolve([] as Purchase[]));
290
+
291
+ const purchases = await resolvePurchases();
292
+ return normalizePurchaseArray(purchases as Purchase[]);
345
293
  };
346
294
 
347
- const offerToRecordIOS = (
348
- offer: DiscountOfferInputIOS | undefined,
349
- ): Record<keyof DiscountOfferInputIOS, string> | undefined => {
350
- if (!offer) return undefined;
351
- return {
352
- identifier: offer.identifier,
353
- keyIdentifier: offer.keyIdentifier,
354
- nonce: offer.nonce,
355
- signature: offer.signature,
356
- timestamp: offer.timestamp.toString(),
357
- };
295
+ export const getStorefront: QueryField<'getStorefrontIOS'> = async () => {
296
+ // Cross-platform storefront
297
+ if (Platform.OS === 'android') {
298
+ if (typeof ExpoIapModule.getStorefrontAndroid === 'function') {
299
+ return ExpoIapModule.getStorefrontAndroid();
300
+ }
301
+ return '';
302
+ }
303
+ return getStorefrontIOS();
358
304
  };
359
305
 
360
306
  /**
@@ -417,11 +363,11 @@ function normalizeRequestProps(
417
363
  * });
418
364
  * ```
419
365
  */
420
- export const requestPurchase = (
421
- requestObj: PurchaseRequestInput,
422
- ): Promise<Purchase | Purchase[] | void> => {
423
- const {request, type} = requestObj;
424
- const {canonical, native} = normalizeProductType(type);
366
+ export const requestPurchase: MutationField<'requestPurchase'> = async (
367
+ args,
368
+ ) => {
369
+ const {request, type} = args;
370
+ const {canonical, native} = normalizeProductType(type as ProductTypeInput);
425
371
  const isInAppPurchase = canonical === 'in-app';
426
372
 
427
373
  if (Platform.OS === 'ios') {
@@ -433,35 +379,43 @@ export const requestPurchase = (
433
379
  );
434
380
  }
435
381
 
436
- const {
437
- sku,
438
- andDangerouslyFinishTransactionAutomatically = false,
439
- appAccountToken,
440
- quantity,
441
- withOffer,
442
- } = normalizedRequest;
443
-
444
- return (async () => {
445
- const offer = offerToRecordIOS(withOffer ?? undefined);
446
- const purchase = await ExpoIapModule.requestPurchase({
447
- sku,
448
- andDangerouslyFinishTransactionAutomatically,
449
- appAccountToken,
450
- quantity,
451
- withOffer: offer,
452
- });
382
+ let payload: MutationRequestPurchaseArgs;
383
+ if (canonical === 'in-app') {
384
+ payload = {
385
+ type: 'in-app',
386
+ request: request as RequestPurchasePropsByPlatforms,
387
+ };
388
+ } else if (canonical === 'subs') {
389
+ payload = {
390
+ type: 'subs',
391
+ request: request as RequestSubscriptionPropsByPlatforms,
392
+ };
393
+ } else {
394
+ throw new Error(`Unsupported product type: ${canonical}`);
395
+ }
396
+
397
+ const purchase = (await ExpoIapModule.requestPurchase(payload)) as
398
+ | Purchase
399
+ | Purchase[]
400
+ | null;
401
+
402
+ if (Array.isArray(purchase)) {
403
+ return normalizePurchaseArray(purchase);
404
+ }
405
+
406
+ if (purchase) {
407
+ return normalizePurchasePlatform(purchase);
408
+ }
453
409
 
454
- return purchase as Purchase;
455
- })();
410
+ return canonical === 'subs' ? [] : null;
456
411
  }
457
412
 
458
413
  if (Platform.OS === 'android') {
459
414
  if (isInAppPurchase) {
460
- const normalizedRequest: RequestPurchaseAndroidProps | null | undefined =
461
- normalizeRequestProps(
462
- request as RequestPurchasePropsByPlatforms,
463
- 'android',
464
- );
415
+ const normalizedRequest = normalizeRequestProps(
416
+ request as RequestPurchasePropsByPlatforms,
417
+ 'android',
418
+ ) as RequestPurchaseAndroidProps | null | undefined;
465
419
 
466
420
  if (!normalizedRequest?.skus?.length) {
467
421
  throw new Error(
@@ -476,28 +430,25 @@ export const requestPurchase = (
476
430
  isOfferPersonalized,
477
431
  } = normalizedRequest;
478
432
 
479
- return (async () => {
480
- return ExpoIapModule.requestPurchase({
481
- type: native,
482
- skuArr: skus,
483
- purchaseToken: undefined,
484
- replacementMode: -1,
485
- obfuscatedAccountId: obfuscatedAccountIdAndroid,
486
- obfuscatedProfileId: obfuscatedProfileIdAndroid,
487
- offerTokenArr: [],
488
- isOfferPersonalized: isOfferPersonalized ?? false,
489
- }) as Promise<Purchase[]>;
490
- })();
433
+ const result = (await ExpoIapModule.requestPurchase({
434
+ type: native,
435
+ skuArr: skus,
436
+ purchaseToken: undefined,
437
+ replacementMode: -1,
438
+ obfuscatedAccountId: obfuscatedAccountIdAndroid,
439
+ obfuscatedProfileId: obfuscatedProfileIdAndroid,
440
+ offerTokenArr: [],
441
+ isOfferPersonalized: isOfferPersonalized ?? false,
442
+ })) as Purchase[];
443
+
444
+ return normalizePurchaseArray(result);
491
445
  }
492
446
 
493
447
  if (canonical === 'subs') {
494
- const normalizedRequest:
495
- | RequestSubscriptionAndroidProps
496
- | null
497
- | undefined = normalizeRequestProps(
448
+ const normalizedRequest = normalizeRequestProps(
498
449
  request as RequestSubscriptionPropsByPlatforms,
499
450
  'android',
500
- );
451
+ ) as RequestSubscriptionAndroidProps | null | undefined;
501
452
 
502
453
  if (!normalizedRequest?.skus?.length) {
503
454
  throw new Error(
@@ -519,21 +470,21 @@ export const requestPurchase = (
519
470
  const replacementMode = replacementModeAndroid ?? -1;
520
471
  const purchaseToken = purchaseTokenAndroid ?? undefined;
521
472
 
522
- return (async () => {
523
- return ExpoIapModule.requestPurchase({
524
- type: native,
525
- skuArr: skus,
526
- purchaseToken,
527
- replacementMode,
528
- obfuscatedAccountId: obfuscatedAccountIdAndroid,
529
- obfuscatedProfileId: obfuscatedProfileIdAndroid,
530
- offerTokenArr: normalizedOffers.map(
531
- (so: AndroidSubscriptionOfferInput) => so.offerToken,
532
- ),
533
- subscriptionOffers: normalizedOffers,
534
- isOfferPersonalized: isOfferPersonalized ?? false,
535
- }) as Promise<Purchase[]>;
536
- })();
473
+ const result = (await ExpoIapModule.requestPurchase({
474
+ type: native,
475
+ skuArr: skus,
476
+ purchaseToken,
477
+ replacementMode,
478
+ obfuscatedAccountId: obfuscatedAccountIdAndroid,
479
+ obfuscatedProfileId: obfuscatedProfileIdAndroid,
480
+ offerTokenArr: normalizedOffers.map(
481
+ (offer: AndroidSubscriptionOfferInput) => offer.offerToken,
482
+ ),
483
+ subscriptionOffers: normalizedOffers,
484
+ isOfferPersonalized: isOfferPersonalized ?? false,
485
+ })) as Purchase[];
486
+
487
+ return normalizePurchaseArray(result);
537
488
  }
538
489
 
539
490
  throw new Error(
@@ -541,93 +492,95 @@ export const requestPurchase = (
541
492
  );
542
493
  }
543
494
 
544
- return Promise.resolve(); // Fallback for unsupported platforms
495
+ throw new Error('Platform not supported');
545
496
  };
546
497
 
547
- export const finishTransaction = ({
498
+ export const finishTransaction: MutationField<'finishTransaction'> = async ({
548
499
  purchase,
549
500
  isConsumable = false,
550
- }: {
551
- purchase: Purchase;
552
- isConsumable?: boolean;
553
- }): Promise<VoidResult | boolean> => {
554
- return (
555
- Platform.select({
556
- ios: async () => {
557
- const transactionId = purchase.id;
558
- if (!transactionId) {
559
- return Promise.reject(
560
- new Error('purchase.id required to finish iOS transaction'),
561
- );
562
- }
563
- await ExpoIapModule.finishTransaction(transactionId);
564
- return Promise.resolve(true);
565
- },
566
- android: async () => {
567
- const androidPurchase = purchase as PurchaseAndroid;
568
-
569
- // Use purchaseToken if available, fallback to purchaseTokenAndroid for backward compatibility
570
- const token = androidPurchase.purchaseToken;
571
-
572
- if (!token) {
573
- return Promise.reject(
574
- new PurchaseError({
575
- message: 'Purchase token is required to finish transaction',
576
- code: ErrorCode.DeveloperError,
577
- productId: androidPurchase.productId,
578
- platform: 'android',
579
- }),
580
- );
581
- }
582
-
583
- if (isConsumable) {
584
- return ExpoIapModule.consumePurchaseAndroid(token);
585
- }
586
-
587
- return ExpoIapModule.acknowledgePurchaseAndroid(token);
588
- },
589
- }) || (() => Promise.reject(new Error('Unsupported Platform')))
590
- )();
501
+ }) => {
502
+ if (Platform.OS === 'ios') {
503
+ await ExpoIapModule.finishTransaction(purchase, isConsumable);
504
+ return;
505
+ }
506
+
507
+ if (Platform.OS === 'android') {
508
+ const token = purchase.purchaseToken ?? undefined;
509
+
510
+ if (!token) {
511
+ throw createPurchaseError({
512
+ message: 'Purchase token is required to finish transaction',
513
+ code: ErrorCode.DeveloperError,
514
+ productId: purchase.productId,
515
+ platform: 'android',
516
+ });
517
+ }
518
+
519
+ if (isConsumable) {
520
+ await ExpoIapModule.consumePurchaseAndroid(token);
521
+ return;
522
+ }
523
+
524
+ await ExpoIapModule.acknowledgePurchaseAndroid(token);
525
+ return;
526
+ }
527
+
528
+ throw new Error('Unsupported Platform');
591
529
  };
592
530
 
593
531
  /**
594
- * Retrieves the current storefront information from iOS App Store
595
- *
596
- * @returns Promise resolving to the storefront country code
597
- * @throws Error if called on non-iOS platform
532
+ * Restore completed transactions (cross-platform behavior)
598
533
  *
599
- * @example
600
- * ```typescript
601
- * const storefront = await getStorefrontIOS();
602
- * console.log(storefront); // 'US'
603
- * ```
534
+ * - iOS: perform a lightweight sync to refresh transactions and ignore sync errors,
535
+ * then fetch available purchases to surface restored items to the app.
536
+ * - Android: simply fetch available purchases (restoration happens via query).
604
537
  *
605
- * @platform iOS
538
+ * This helper triggers the refresh flows but does not return the purchases; consumers should
539
+ * call `getAvailablePurchases` or rely on hook state to inspect the latest items.
606
540
  */
607
- export const getStorefrontIOS = (): Promise<string> => {
608
- if (Platform.OS !== 'ios') {
609
- console.warn('getStorefrontIOS: This method is only available on iOS');
610
- return Promise.resolve('');
541
+ export const restorePurchases: MutationField<'restorePurchases'> = async () => {
542
+ if (Platform.OS === 'ios') {
543
+ await syncIOS().catch(() => undefined);
611
544
  }
612
- return ExpoIapModule.getStorefrontIOS();
545
+
546
+ await getAvailablePurchases({
547
+ alsoPublishToEventListenerIOS: false,
548
+ onlyIncludeActiveItemsIOS: true,
549
+ });
613
550
  };
614
551
 
615
552
  /**
616
- * Gets the storefront country code from the underlying native store.
617
- * Returns a two-letter country code such as 'US', 'KR', or empty string on failure.
553
+ * Deeplinks to native interface that allows users to manage their subscriptions
554
+ * @param options.skuAndroid - Required for Android to locate specific subscription (ignored on iOS)
555
+ * @param options.packageNameAndroid - Required for Android to identify your app (ignored on iOS)
618
556
  *
619
- * @platform ios
620
- * @platform android
557
+ * @returns Promise that resolves when the deep link is successfully opened
558
+ *
559
+ * @throws {Error} When called on unsupported platform or when required Android parameters are missing
560
+ *
561
+ * @example
562
+ * import { deepLinkToSubscriptions } from 'expo-iap';
563
+ *
564
+ * // Works on both iOS and Android
565
+ * await deepLinkToSubscriptions({
566
+ * skuAndroid: 'your_subscription_sku',
567
+ * packageNameAndroid: 'com.example.app'
568
+ * });
621
569
  */
622
- export const getStorefront = (): Promise<string> => {
623
- // Cross-platform storefront
570
+ export const deepLinkToSubscriptions: MutationField<
571
+ 'deepLinkToSubscriptions'
572
+ > = async (options) => {
573
+ if (Platform.OS === 'ios') {
574
+ await deepLinkToSubscriptionsIOS();
575
+ return;
576
+ }
577
+
624
578
  if (Platform.OS === 'android') {
625
- if (typeof ExpoIapModule.getStorefrontAndroid === 'function') {
626
- return ExpoIapModule.getStorefrontAndroid();
627
- }
628
- return Promise.resolve('');
579
+ await deepLinkToSubscriptionsAndroid((options as DeepLinkOptions) ?? null);
580
+ return;
629
581
  }
630
- return getStorefrontIOS();
582
+
583
+ throw new Error(`Unsupported platform: ${Platform.OS}`);
631
584
  };
632
585
 
633
586
  /**
@@ -638,18 +591,16 @@ export const getStorefront = (): Promise<string> => {
638
591
  * - iOS: Send receipt data to Apple's verification endpoint from your server
639
592
  * - Android: Use Google Play Developer API with service account credentials
640
593
  */
641
- export const validateReceipt = async (
642
- sku: string,
643
- androidOptions?: {
644
- packageName: string;
645
- productToken: string;
646
- accessToken: string;
647
- isSub?: boolean;
648
- },
649
- ): Promise<ReceiptValidationResult> => {
594
+ export const validateReceipt: MutationField<'validateReceipt'> = async (
595
+ options,
596
+ ) => {
597
+ const {sku, androidOptions} = options as MutationValidateReceiptArgs;
598
+
650
599
  if (Platform.OS === 'ios') {
651
- return await validateReceiptIOS(sku);
652
- } else if (Platform.OS === 'android') {
600
+ return validateReceiptIOS({sku});
601
+ }
602
+
603
+ if (Platform.OS === 'android') {
653
604
  if (
654
605
  !androidOptions ||
655
606
  !androidOptions.packageName ||
@@ -660,53 +611,26 @@ export const validateReceipt = async (
660
611
  'Android validation requires packageName, productToken, and accessToken',
661
612
  );
662
613
  }
663
- return await validateReceiptAndroid({
614
+ return validateReceiptAndroid({
664
615
  packageName: androidOptions.packageName,
665
616
  productId: sku,
666
617
  productToken: androidOptions.productToken,
667
618
  accessToken: androidOptions.accessToken,
668
- isSub: androidOptions.isSub,
619
+ isSub: androidOptions.isSub ?? undefined,
669
620
  });
670
- } else {
671
- throw new Error('Platform not supported');
672
621
  }
673
- };
674
622
 
675
- /**
676
- * Deeplinks to native interface that allows users to manage their subscriptions
677
- * @param options.skuAndroid - Required for Android to locate specific subscription (ignored on iOS)
678
- * @param options.packageNameAndroid - Required for Android to identify your app (ignored on iOS)
679
- *
680
- * @returns Promise that resolves when the deep link is successfully opened
681
- *
682
- * @throws {Error} When called on unsupported platform or when required Android parameters are missing
683
- *
684
- * @example
685
- * import { deepLinkToSubscriptions } from 'expo-iap';
686
- *
687
- * // Works on both iOS and Android
688
- * await deepLinkToSubscriptions({
689
- * skuAndroid: 'your_subscription_sku',
690
- * packageNameAndroid: 'com.example.app'
691
- * });
692
- */
693
- export const deepLinkToSubscriptions = (options: {
694
- skuAndroid?: string;
695
- packageNameAndroid?: string;
696
- }): Promise<void> => {
697
- if (Platform.OS === 'ios') {
698
- return deepLinkToSubscriptionsIOS();
699
- }
700
-
701
- if (Platform.OS === 'android') {
702
- return deepLinkToSubscriptionsAndroid({
703
- sku: options?.skuAndroid,
704
- packageName: options?.packageNameAndroid,
705
- });
706
- }
707
-
708
- return Promise.reject(new Error(`Unsupported platform: ${Platform.OS}`));
623
+ throw new Error('Platform not supported');
709
624
  };
710
625
 
711
626
  export * from './useIAP';
712
- export * from './utils/errorMapping';
627
+ export {
628
+ ErrorCodeUtils,
629
+ ErrorCodeMapping,
630
+ createPurchaseError,
631
+ createPurchaseErrorFromPlatform,
632
+ } from './utils/errorMapping';
633
+ export type {
634
+ PurchaseError as ExpoPurchaseError,
635
+ PurchaseErrorProps,
636
+ } from './utils/errorMapping';