react-native-iap 14.7.17 → 14.7.19

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.
@@ -18,7 +18,7 @@ import {
18
18
  verifyPurchaseWithProvider as verifyPurchaseWithProviderTopLevel,
19
19
  getActiveSubscriptions,
20
20
  hasActiveSubscriptions,
21
- restorePurchases as restorePurchasesTopLevel,
21
+ syncIOS,
22
22
  getPromotedProductIOS,
23
23
  requestPurchaseOnPromotedProductIOS,
24
24
  checkAlternativeBillingAvailabilityAndroid,
@@ -40,6 +40,7 @@ import type {
40
40
  VerifyPurchaseResult,
41
41
  VerifyPurchaseWithProviderProps,
42
42
  VerifyPurchaseWithProviderResult,
43
+ PurchaseOptions,
43
44
  } from '../types';
44
45
  import type {
45
46
  ActiveSubscription,
@@ -63,7 +64,7 @@ type UseIap = {
63
64
  promotedProductIOS?: Product;
64
65
  activeSubscriptions: ActiveSubscription[];
65
66
  finishTransaction: (args: MutationFinishTransactionArgs) => Promise<void>;
66
- getAvailablePurchases: (skus?: string[]) => Promise<void>;
67
+ getAvailablePurchases: (options?: PurchaseOptions) => Promise<void>;
67
68
  fetchProducts: (params: {
68
69
  skus: string[];
69
70
  type?: ProductQueryType | null;
@@ -83,13 +84,19 @@ type UseIap = {
83
84
  verifyPurchaseWithProvider: (
84
85
  options: VerifyPurchaseWithProviderProps,
85
86
  ) => Promise<VerifyPurchaseWithProviderResult>;
86
- restorePurchases: () => Promise<void>;
87
+ restorePurchases: (options?: PurchaseOptions) => Promise<void>;
87
88
  getPromotedProductIOS: () => Promise<Product | null>;
88
89
  requestPurchaseOnPromotedProductIOS: () => Promise<boolean>;
89
90
  getActiveSubscriptions: (
90
91
  subscriptionIds?: string[],
91
92
  ) => Promise<ActiveSubscription[]>;
92
93
  hasActiveSubscriptions: (subscriptionIds?: string[]) => Promise<boolean>;
94
+ /**
95
+ * Manually retry the store connection.
96
+ * Useful when the initial auto-connect fails (e.g., Play Store not ready at mount time).
97
+ * Updates the `connected` state on success.
98
+ */
99
+ reconnect: () => Promise<boolean>;
93
100
  // Alternative billing (Android)
94
101
  checkAlternativeBillingAvailabilityAndroid?: () => Promise<boolean>;
95
102
  showAlternativeBillingDialogAndroid?: () => Promise<boolean>;
@@ -271,11 +278,13 @@ export function useIAP(options?: UseIapOptions): UseIap {
271
278
  );
272
279
 
273
280
  const getAvailablePurchasesInternal = useCallback(
274
- async (_skus?: string[]): Promise<void> => {
281
+ async (options?: PurchaseOptions): Promise<void> => {
275
282
  try {
276
283
  const result = await getAvailablePurchases({
277
- alsoPublishToEventListenerIOS: false,
278
- onlyIncludeActiveItemsIOS: true,
284
+ alsoPublishToEventListenerIOS:
285
+ options?.alsoPublishToEventListenerIOS ?? false,
286
+ onlyIncludeActiveItemsIOS: options?.onlyIncludeActiveItemsIOS ?? true,
287
+ includeSuspendedAndroid: options?.includeSuspendedAndroid ?? false,
279
288
  });
280
289
  setAvailablePurchases(result);
281
290
  } catch (error) {
@@ -333,7 +342,21 @@ export function useIAP(options?: UseIapOptions): UseIap {
333
342
  [],
334
343
  );
335
344
 
336
- // No local restorePurchases; use the top-level helper via returned API
345
+ const restorePurchases = useCallback(
346
+ async (options?: PurchaseOptions): Promise<void> => {
347
+ try {
348
+ if (Platform.OS === 'ios') {
349
+ await syncIOS();
350
+ }
351
+
352
+ await getAvailablePurchasesInternal(options);
353
+ } catch (error) {
354
+ RnIapConsole.warn('Failed to restore purchases:', error);
355
+ invokeOnError(error);
356
+ }
357
+ },
358
+ [getAvailablePurchasesInternal, invokeOnError],
359
+ );
337
360
 
338
361
  const validateReceipt = useCallback(
339
362
  async (options: VerifyPurchaseProps): Promise<VerifyPurchaseResult> =>
@@ -357,9 +380,8 @@ export function useIAP(options?: UseIapOptions): UseIap {
357
380
  [],
358
381
  );
359
382
 
360
- const initIapWithSubscriptions = useCallback(async (): Promise<void> => {
361
- // Initialize connection with config
362
- // Prefer enableBillingProgramAndroid over deprecated alternativeBillingModeAndroid
383
+ // Shared helper: build Android billing config from options
384
+ const buildAndroidConfig = useCallback(() => {
363
385
  let config:
364
386
  | {
365
387
  enableBillingProgramAndroid?: BillingProgramAndroid;
@@ -374,7 +396,6 @@ export function useIAP(options?: UseIapOptions): UseIap {
374
396
  optionsRef.current.enableBillingProgramAndroid,
375
397
  };
376
398
  } else if (optionsRef.current?.alternativeBillingModeAndroid) {
377
- // Deprecated: use alternativeBillingModeAndroid for backwards compatibility
378
399
  config = {
379
400
  alternativeBillingModeAndroid:
380
401
  optionsRef.current.alternativeBillingModeAndroid,
@@ -382,29 +403,14 @@ export function useIAP(options?: UseIapOptions): UseIap {
382
403
  }
383
404
  }
384
405
 
385
- try {
386
- // Initialize connection FIRST to ensure Nitro is ready
387
- // This fixes tvOS where Nitro may initialize later than iOS
388
- const result = await initConnection(config);
389
-
390
- // Check if component unmounted during async initConnection
391
- // to prevent listener leaks
392
- if (!isMountedRef.current) {
393
- return;
394
- }
395
-
396
- setConnected(result);
397
-
398
- if (!result) {
399
- RnIapConsole.warn('[useIAP] initConnection returned false');
400
- return;
401
- }
406
+ return config;
407
+ }, []);
402
408
 
403
- // Register listeners AFTER initConnection succeeds
404
- // This ensures Nitro runtime is fully initialized
409
+ // Shared helper: register event listeners if not already active
410
+ const registerListeners = useCallback(() => {
411
+ if (!subscriptionsRef.current.purchaseUpdate) {
405
412
  subscriptionsRef.current.purchaseUpdate = purchaseUpdatedListener(
406
413
  async (purchase: Purchase) => {
407
- // Always refresh subscription state after a purchase event
408
414
  try {
409
415
  await getActiveSubscriptionsInternal();
410
416
  await getAvailablePurchasesInternal();
@@ -416,11 +422,11 @@ export function useIAP(options?: UseIapOptions): UseIap {
416
422
  }
417
423
  },
418
424
  );
425
+ }
419
426
 
427
+ if (!subscriptionsRef.current.purchaseError) {
420
428
  subscriptionsRef.current.purchaseError = purchaseErrorListener(
421
429
  (error) => {
422
- // error is already normalized by purchaseErrorListener in src/index.ts
423
- // Ignore init error until connected
424
430
  if (
425
431
  error.code === ErrorCode.InitConnection &&
426
432
  !connectedRef.current
@@ -432,65 +438,113 @@ export function useIAP(options?: UseIapOptions): UseIap {
432
438
  }
433
439
  },
434
440
  );
441
+ }
442
+
443
+ if (isStandardIOS() && !subscriptionsRef.current.promotedProductIOS) {
444
+ subscriptionsRef.current.promotedProductIOS = promotedProductListenerIOS(
445
+ (product: Product) => {
446
+ setPromotedProductIOS(product);
447
+ if (optionsRef.current?.onPromotedProductIOS) {
448
+ optionsRef.current.onPromotedProductIOS(product);
449
+ }
450
+ },
451
+ );
452
+ }
453
+
454
+ if (
455
+ Platform.OS === 'android' &&
456
+ optionsRef.current?.onUserChoiceBillingAndroid &&
457
+ !subscriptionsRef.current.userChoiceBillingAndroid
458
+ ) {
459
+ subscriptionsRef.current.userChoiceBillingAndroid =
460
+ userChoiceBillingListenerAndroid((details) => {
461
+ if (optionsRef.current?.onUserChoiceBillingAndroid) {
462
+ optionsRef.current.onUserChoiceBillingAndroid(details);
463
+ }
464
+ });
465
+ }
466
+ }, [getActiveSubscriptionsInternal, getAvailablePurchasesInternal]);
467
+
468
+ // Shared helper: clean up all listeners
469
+ const cleanupListeners = useCallback(() => {
470
+ subscriptionsRef.current.purchaseUpdate?.remove();
471
+ subscriptionsRef.current.purchaseError?.remove();
472
+ subscriptionsRef.current.promotedProductIOS?.remove();
473
+ subscriptionsRef.current.userChoiceBillingAndroid?.remove();
474
+ subscriptionsRef.current.purchaseUpdate = undefined;
475
+ subscriptionsRef.current.purchaseError = undefined;
476
+ subscriptionsRef.current.promotedProductIOS = undefined;
477
+ subscriptionsRef.current.userChoiceBillingAndroid = undefined;
478
+ }, []);
435
479
 
436
- // iOS promoted products listener (only supported on standard iOS, not tvOS/macOS)
437
- if (isStandardIOS()) {
438
- subscriptionsRef.current.promotedProductIOS =
439
- promotedProductListenerIOS((product: Product) => {
440
- setPromotedProductIOS(product);
480
+ const initIapWithSubscriptions = useCallback(async (): Promise<void> => {
481
+ const config = buildAndroidConfig();
441
482
 
442
- if (optionsRef.current?.onPromotedProductIOS) {
443
- optionsRef.current.onPromotedProductIOS(product);
444
- }
445
- });
483
+ try {
484
+ const result = await initConnection(config);
485
+
486
+ if (!isMountedRef.current) {
487
+ return;
446
488
  }
447
489
 
448
- // Add user choice billing listener for Android (if provided)
449
- if (
450
- Platform.OS === 'android' &&
451
- optionsRef.current?.onUserChoiceBillingAndroid
452
- ) {
453
- subscriptionsRef.current.userChoiceBillingAndroid =
454
- userChoiceBillingListenerAndroid((details) => {
455
- if (optionsRef.current?.onUserChoiceBillingAndroid) {
456
- optionsRef.current.onUserChoiceBillingAndroid(details);
457
- }
458
- });
490
+ if (!result) {
491
+ setConnected(false);
492
+ RnIapConsole.warn('[useIAP] initConnection returned false');
493
+ return;
459
494
  }
495
+
496
+ registerListeners();
497
+ setConnected(true);
460
498
  } catch (error) {
461
499
  RnIapConsole.error('initConnection failed:', error);
500
+ cleanupListeners();
501
+ if (isMountedRef.current) {
502
+ setConnected(false);
503
+ }
462
504
  invokeOnError(error);
463
- // Clean up listeners on error (if any were registered)
464
- subscriptionsRef.current.purchaseUpdate?.remove();
465
- subscriptionsRef.current.purchaseError?.remove();
466
- subscriptionsRef.current.promotedProductIOS?.remove();
467
- subscriptionsRef.current.userChoiceBillingAndroid?.remove();
468
- subscriptionsRef.current.purchaseUpdate = undefined;
469
- subscriptionsRef.current.purchaseError = undefined;
470
- subscriptionsRef.current.promotedProductIOS = undefined;
471
- subscriptionsRef.current.userChoiceBillingAndroid = undefined;
472
505
  }
473
- }, [
474
- getActiveSubscriptionsInternal,
475
- getAvailablePurchasesInternal,
476
- invokeOnError,
477
- ]);
506
+ }, [buildAndroidConfig, registerListeners, cleanupListeners, invokeOnError]);
507
+
508
+ const reconnect = useCallback(async (): Promise<boolean> => {
509
+ const config = buildAndroidConfig();
510
+
511
+ try {
512
+ const result = await initConnection(config);
513
+
514
+ if (!isMountedRef.current) {
515
+ return false;
516
+ }
517
+
518
+ if (result) {
519
+ registerListeners();
520
+ setConnected(true);
521
+ return true;
522
+ }
523
+
524
+ setConnected(false);
525
+ return false;
526
+ } catch (error) {
527
+ RnIapConsole.error('[useIAP] reconnect failed:', error);
528
+ cleanupListeners();
529
+ if (isMountedRef.current) {
530
+ setConnected(false);
531
+ }
532
+ invokeOnError(error);
533
+ return false;
534
+ }
535
+ }, [buildAndroidConfig, registerListeners, cleanupListeners, invokeOnError]);
478
536
 
479
537
  useEffect(() => {
480
538
  isMountedRef.current = true;
481
539
  initIapWithSubscriptions();
482
- const currentSubscriptions = subscriptionsRef.current;
483
540
 
484
541
  return () => {
485
542
  isMountedRef.current = false;
486
- currentSubscriptions.purchaseUpdate?.remove();
487
- currentSubscriptions.purchaseError?.remove();
488
- currentSubscriptions.promotedProductIOS?.remove();
489
- currentSubscriptions.userChoiceBillingAndroid?.remove();
543
+ cleanupListeners();
490
544
  // Keep connection alive across screens to avoid race conditions
491
545
  setConnected(false);
492
546
  };
493
- }, [initIapWithSubscriptions]);
547
+ }, [initIapWithSubscriptions, cleanupListeners]);
494
548
 
495
549
  return {
496
550
  connected,
@@ -506,19 +560,12 @@ export function useIAP(options?: UseIapOptions): UseIap {
506
560
  validateReceipt,
507
561
  verifyPurchase,
508
562
  verifyPurchaseWithProvider,
509
- restorePurchases: async () => {
510
- try {
511
- await restorePurchasesTopLevel();
512
- await getAvailablePurchasesInternal();
513
- } catch (error) {
514
- RnIapConsole.warn('Failed to restore purchases:', error);
515
- invokeOnError(error);
516
- }
517
- },
563
+ restorePurchases,
518
564
  getPromotedProductIOS,
519
565
  requestPurchaseOnPromotedProductIOS,
520
566
  getActiveSubscriptions: getActiveSubscriptionsInternal,
521
567
  hasActiveSubscriptions: hasActiveSubscriptionsInternal,
568
+ reconnect,
522
569
  // Alternative billing (Android only)
523
570
  ...(Platform.OS === 'android'
524
571
  ? {
package/src/index.ts CHANGED
@@ -737,11 +737,14 @@ export const getAvailablePurchases: QueryField<
737
737
  return validPurchases.map(convertNitroPurchaseToPurchase);
738
738
  } else if (Platform.OS === 'android') {
739
739
  // For Android, we need to call twice for inapp and subs
740
+ const includeSuspended = Boolean(
741
+ options?.includeSuspendedAndroid ?? false,
742
+ );
740
743
  const inappNitroPurchases = await IAP.instance.getAvailablePurchases({
741
- android: {type: 'inapp'},
744
+ android: {type: 'inapp', includeSuspended},
742
745
  });
743
746
  const subsNitroPurchases = await IAP.instance.getAvailablePurchases({
744
- android: {type: 'subs'},
747
+ android: {type: 'subs', includeSuspended},
745
748
  });
746
749
 
747
750
  // Validate and convert both sets of purchases
@@ -95,3 +95,9 @@ export function isUserCancelledError(
95
95
  errorObj.responseCode === 1
96
96
  ); // Android BillingClient.BillingResponseCode.USER_CANCELED
97
97
  }
98
+
99
+ // Re-export from errorMapping for public API convenience
100
+ export {
101
+ isDuplicatePurchaseError,
102
+ DUPLICATE_PURCHASE_CODE,
103
+ } from './errorMapping';
@@ -6,6 +6,13 @@
6
6
 
7
7
  import {ErrorCode, type IapPlatform} from '../types';
8
8
 
9
+ /**
10
+ * Error code for duplicate purchase events detected on iOS.
11
+ * Defined here because it originates from react-native-iap's dedup logic,
12
+ * not from the OpenIAP upstream that generates src/types.ts.
13
+ */
14
+ export const DUPLICATE_PURCHASE_CODE = 'duplicate-purchase' as const;
15
+
9
16
  const ERROR_CODE_ALIASES: Record<string, ErrorCode> = {
10
17
  E_USER_CANCELED: ErrorCode.UserCancelled,
11
18
  USER_CANCELED: ErrorCode.UserCancelled,
@@ -271,6 +278,10 @@ export function isUserCancelledError(error: unknown): boolean {
271
278
  return extractCode(error) === ErrorCode.UserCancelled;
272
279
  }
273
280
 
281
+ export function isDuplicatePurchaseError(error: unknown): boolean {
282
+ return extractCode(error) === DUPLICATE_PURCHASE_CODE;
283
+ }
284
+
274
285
  export function isNetworkError(error: unknown): boolean {
275
286
  const networkErrors: ErrorCode[] = [
276
287
  ErrorCode.NetworkError,
@@ -296,6 +307,7 @@ export function isRecoverableError(error: unknown): boolean {
296
307
  ErrorCode.InitConnection,
297
308
  ErrorCode.SyncError,
298
309
  ErrorCode.ConnectionClosed,
310
+ DUPLICATE_PURCHASE_CODE,
299
311
  ];
300
312
 
301
313
  const code = extractCode(error);
@@ -326,6 +338,8 @@ export function getUserFriendlyErrorMessage(error: ErrorLike): string {
326
338
  return 'Selected offer does not match the SKU';
327
339
  case ErrorCode.DeferredPayment:
328
340
  return 'Payment is pending approval';
341
+ case DUPLICATE_PURCHASE_CODE:
342
+ return 'This purchase has already been processed. Try restoring purchases.';
329
343
  case ErrorCode.NotPrepared:
330
344
  return 'In-app purchase is not ready. Please try again later.';
331
345
  case ErrorCode.ServiceError: