UNPKG

react-native-iap

Version:

React Native In-App Purchases module for iOS and Android using Nitro

1,599 lines (1,467 loc) 83.4 kB
// External dependencies import {Platform} from 'react-native'; // Side-effect import ensures Nitro installs its dispatcher before IAP is used (no-op in tests) import 'react-native-nitro-modules'; import {NitroModules} from 'react-native-nitro-modules'; // Internal modules import type { NitroActiveSubscription, NitroReceiptValidationParams, NitroReceiptValidationResultIOS, NitroReceiptValidationResultAndroid, NitroSubscriptionStatus, RnIap, } from './specs/RnIap.nitro'; import {ErrorCode} from './types'; import type { AndroidSubscriptionOfferInput, DiscountOfferInputIOS, FetchProductsResult, MutationField, Product, ProductIOS, ProductQueryType, ProductSubscription, Purchase, PurchaseError, PurchaseIOS, QueryField, AppTransaction, VerifyPurchaseResultAndroid, VerifyPurchaseResultIOS, RequestPurchaseAndroidProps, RequestPurchaseIosProps, RequestPurchasePropsByPlatforms, RequestSubscriptionAndroidProps, RequestSubscriptionIosProps, RequestSubscriptionPropsByPlatforms, ActiveSubscription, } from './types'; import { convertNitroProductToProduct, convertNitroPurchaseToPurchase, convertProductToProductSubscription, validateNitroProduct, validateNitroPurchase, convertNitroSubscriptionStatusToSubscriptionStatusIOS, } from './utils/type-bridge'; import {parseErrorStringToJsonObj} from './utils/error'; import { normalizeErrorCodeFromNative, createPurchaseError, } from './utils/errorMapping'; import {RnIapConsole} from './utils/debug'; import {getSuccessFromPurchaseVariant} from './utils/purchase'; import {parseAppTransactionPayload} from './utils'; // ------------------------------ // Billing Programs API (Android 8.2.0+) // ------------------------------ // Note: BillingProgramAndroid, ExternalLinkLaunchModeAndroid, and ExternalLinkTypeAndroid // are exported from './types' (auto-generated from openiap-gql). // Import them here for use in this file's interfaces and functions. import type { BillingProgramAndroid, ExternalLinkLaunchModeAndroid, ExternalLinkTypeAndroid, } from './types'; // Export all types export type { RnIap, NitroProduct, NitroPurchase, NitroPurchaseResult, } from './specs/RnIap.nitro'; export * from './types'; export * from './utils/error'; export type ProductTypeInput = 'inapp' | 'in-app' | 'subs'; const LEGACY_INAPP_WARNING = "[react-native-iap] `type: 'inapp'` is deprecated and will be removed in v14.4.0. Use 'in-app' instead."; type NitroPurchaseRequest = Parameters<RnIap['requestPurchase']>[0]; type NitroAvailablePurchasesOptions = NonNullable< Parameters<RnIap['getAvailablePurchases']>[0] >; type NitroFinishTransactionParamsInternal = Parameters< RnIap['finishTransaction'] >[0]; type NitroPurchaseListener = Parameters<RnIap['addPurchaseUpdatedListener']>[0]; type NitroPurchaseErrorListener = Parameters< RnIap['addPurchaseErrorListener'] >[0]; type NitroPromotedProductListener = Parameters< RnIap['addPromotedProductListenerIOS'] >[0]; const toErrorMessage = (error: unknown): string => { if ( typeof error === 'object' && error !== null && 'message' in error && (error as {message?: unknown}).message != null ) { return String((error as {message?: unknown}).message); } return String(error ?? ''); }; export interface EventSubscription { remove(): void; } // ActiveSubscription and PurchaseError types are already exported via 'export * from ./types' // Export hooks export {useIAP} from './hooks/useIAP'; // Restore completed transactions (cross-platform) // Development utilities removed - use type bridge functions directly if needed // Create the RnIap HybridObject instance lazily to avoid early JSI crashes let iapRef: RnIap | null = null; const IAP = { get instance(): RnIap { if (iapRef) return iapRef; // Attempt to create the HybridObject and map common Nitro/JSI readiness errors try { iapRef = NitroModules.createHybridObject<RnIap>('RnIap'); } catch (e) { const msg = toErrorMessage(e); if ( msg.includes('Nitro') || msg.includes('JSI') || msg.includes('dispatcher') || msg.includes('HybridObject') ) { throw new Error( 'Nitro runtime not installed yet. Ensure react-native-nitro-modules is initialized before calling IAP.', ); } throw e; } return iapRef; }, }; // ============================================================================ // EVENT LISTENERS // ============================================================================ const purchaseUpdatedListenerMap = new WeakMap< (purchase: Purchase) => void, NitroPurchaseListener >(); const purchaseErrorListenerMap = new WeakMap< (error: PurchaseError) => void, NitroPurchaseErrorListener >(); const promotedProductListenerMap = new WeakMap< (product: Product) => void, NitroPromotedProductListener >(); export const purchaseUpdatedListener = ( listener: (purchase: Purchase) => void, ): EventSubscription => { const wrappedListener: NitroPurchaseListener = (nitroPurchase) => { if (validateNitroPurchase(nitroPurchase)) { const convertedPurchase = convertNitroPurchaseToPurchase(nitroPurchase); listener(convertedPurchase); } else { RnIapConsole.error( 'Invalid purchase data received from native:', nitroPurchase, ); } }; purchaseUpdatedListenerMap.set(listener, wrappedListener); let attached = false; try { IAP.instance.addPurchaseUpdatedListener(wrappedListener); attached = true; } catch (e) { const msg = toErrorMessage(e); if (msg.includes('Nitro runtime not installed')) { RnIapConsole.warn( '[purchaseUpdatedListener] Nitro not ready yet; listener inert until initConnection()', ); } else { throw e; } } return { remove: () => { const wrapped = purchaseUpdatedListenerMap.get(listener); if (wrapped) { if (attached) { try { IAP.instance.removePurchaseUpdatedListener(wrapped); } catch {} } purchaseUpdatedListenerMap.delete(listener); } }, }; }; export const purchaseErrorListener = ( listener: (error: PurchaseError) => void, ): EventSubscription => { const wrapped: NitroPurchaseErrorListener = (error) => { listener({ code: normalizeErrorCodeFromNative(error.code), message: error.message, productId: undefined, }); }; purchaseErrorListenerMap.set(listener, wrapped); let attached = false; try { IAP.instance.addPurchaseErrorListener(wrapped); attached = true; } catch (e) { const msg = toErrorMessage(e); if (msg.includes('Nitro runtime not installed')) { RnIapConsole.warn( '[purchaseErrorListener] Nitro not ready yet; listener inert until initConnection()', ); } else { throw e; } } return { remove: () => { const stored = purchaseErrorListenerMap.get(listener); if (stored) { if (attached) { try { IAP.instance.removePurchaseErrorListener(stored); } catch {} } purchaseErrorListenerMap.delete(listener); } }, }; }; export const promotedProductListenerIOS = ( listener: (product: Product) => void, ): EventSubscription => { if (Platform.OS !== 'ios') { RnIapConsole.warn( 'promotedProductListenerIOS: This listener is only available on iOS', ); return {remove: () => {}}; } const wrappedListener: NitroPromotedProductListener = (nitroProduct) => { if (validateNitroProduct(nitroProduct)) { const convertedProduct = convertNitroProductToProduct(nitroProduct); listener(convertedProduct); } else { RnIapConsole.error( 'Invalid promoted product data received from native:', nitroProduct, ); } }; promotedProductListenerMap.set(listener, wrappedListener); let attached = false; try { IAP.instance.addPromotedProductListenerIOS(wrappedListener); attached = true; } catch (e) { const msg = toErrorMessage(e); if (msg.includes('Nitro runtime not installed')) { RnIapConsole.warn( '[promotedProductListenerIOS] Nitro not ready yet; listener inert until initConnection()', ); } else { throw e; } } return { remove: () => { const wrapped = promotedProductListenerMap.get(listener); if (wrapped) { if (attached) { try { IAP.instance.removePromotedProductListenerIOS(wrapped); } catch {} } promotedProductListenerMap.delete(listener); } }, }; }; /** * Add a listener for user choice billing events (Android only). * Fires when a user selects alternative billing in the User Choice Billing dialog. * * @param listener - Function to call when user chooses alternative billing * @returns EventSubscription with remove() method to unsubscribe * @platform Android * * @example * ```typescript * const subscription = userChoiceBillingListenerAndroid((details) => { * console.log('User chose alternative billing'); * console.log('Products:', details.products); * console.log('Token:', details.externalTransactionToken); * * // Send token to backend for Google Play reporting * await reportToGooglePlay(details.externalTransactionToken); * }); * * // Later, remove the listener * subscription.remove(); * ``` */ type NitroUserChoiceBillingListener = Parameters< RnIap['addUserChoiceBillingListenerAndroid'] >[0]; const userChoiceBillingListenerMap = new WeakMap< (details: any) => void, NitroUserChoiceBillingListener >(); export const userChoiceBillingListenerAndroid = ( listener: (details: any) => void, ): EventSubscription => { if (Platform.OS !== 'android') { RnIapConsole.warn( 'userChoiceBillingListenerAndroid: This listener is only available on Android', ); return {remove: () => {}}; } const wrappedListener: NitroUserChoiceBillingListener = (details) => { listener(details); }; userChoiceBillingListenerMap.set(listener, wrappedListener); let attached = false; try { IAP.instance.addUserChoiceBillingListenerAndroid(wrappedListener); attached = true; } catch (e) { const msg = toErrorMessage(e); if (msg.includes('Nitro runtime not installed')) { RnIapConsole.warn( '[userChoiceBillingListenerAndroid] Nitro not ready yet; listener inert until initConnection()', ); } else { throw e; } } return { remove: () => { const wrapped = userChoiceBillingListenerMap.get(listener); if (wrapped) { if (attached) { try { IAP.instance.removeUserChoiceBillingListenerAndroid(wrapped); } catch {} } userChoiceBillingListenerMap.delete(listener); } }, }; }; /** * Add a listener for developer provided billing events (Android 8.3.0+ only). * Fires when a user selects developer billing in the External Payments flow. * * External Payments is part of Google Play Billing Library 8.3.0+ and allows * showing a side-by-side choice between Google Play Billing and developer's * external payment option directly in the purchase flow. (Japan only) * * @param listener - Function to call when user chooses developer billing * @returns EventSubscription with remove() method to unsubscribe * @platform Android * @since Google Play Billing Library 8.3.0+ * * @example * ```typescript * const subscription = developerProvidedBillingListenerAndroid((details) => { * console.log('User chose developer billing'); * console.log('Token:', details.externalTransactionToken); * * // Process payment through your external payment system * await processExternalPayment(); * * // Report transaction to Google Play (within 24 hours) * await reportToGooglePlay(details.externalTransactionToken); * }); * * // Later, remove the listener * subscription.remove(); * ``` */ type NitroDeveloperProvidedBillingListener = Parameters< RnIap['addDeveloperProvidedBillingListenerAndroid'] >[0]; const developerProvidedBillingListenerMap = new WeakMap< (details: any) => void, NitroDeveloperProvidedBillingListener >(); export interface DeveloperProvidedBillingDetailsAndroid { /** * External transaction token used to report transactions made through developer billing. * This token must be used when reporting the external transaction to Google Play. * Must be reported within 24 hours of the transaction. */ externalTransactionToken: string; } export const developerProvidedBillingListenerAndroid = ( listener: (details: DeveloperProvidedBillingDetailsAndroid) => void, ): EventSubscription => { if (Platform.OS !== 'android') { RnIapConsole.warn( 'developerProvidedBillingListenerAndroid: This listener is only available on Android', ); return {remove: () => {}}; } const wrappedListener: NitroDeveloperProvidedBillingListener = (details) => { listener(details); }; developerProvidedBillingListenerMap.set(listener, wrappedListener); let attached = false; try { IAP.instance.addDeveloperProvidedBillingListenerAndroid(wrappedListener); attached = true; } catch (e) { const msg = toErrorMessage(e); if (msg.includes('Nitro runtime not installed')) { RnIapConsole.warn( '[developerProvidedBillingListenerAndroid] Nitro not ready yet; listener inert until initConnection()', ); } else { throw e; } } return { remove: () => { const wrapped = developerProvidedBillingListenerMap.get(listener); if (wrapped) { if (attached) { try { IAP.instance.removeDeveloperProvidedBillingListenerAndroid(wrapped); } catch {} } developerProvidedBillingListenerMap.delete(listener); } }, }; }; // ------------------------------ // Query API // ------------------------------ /** * Fetch products from the store * @param params - Product request configuration * @param params.skus - Array of product SKUs to fetch * @param params.type - Optional filter: 'in-app' (default) for products, 'subs' for subscriptions, or 'all' for both. * @returns Promise<Product[]> - Array of products from the store * * @example * ```typescript * // Regular products * const products = await fetchProducts({ skus: ['product1', 'product2'] }); * * // Subscriptions * const subscriptions = await fetchProducts({ skus: ['sub1', 'sub2'], type: 'subs' }); * ``` */ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { const {skus, type} = request; try { if (!skus?.length) { throw new Error('No SKUs provided'); } const normalizedType = normalizeProductQueryType(type); const fetchAndConvert = async ( nitroType: ReturnType<typeof toNitroProductType> | 'all', ) => { const nitroProducts = await IAP.instance.fetchProducts(skus, nitroType); const validProducts = nitroProducts.filter(validateNitroProduct); if (validProducts.length !== nitroProducts.length) { RnIapConsole.warn( `[fetchProducts] Some products failed validation: ${nitroProducts.length - validProducts.length} invalid`, ); } return validProducts.map(convertNitroProductToProduct); }; if (normalizedType === 'all') { const converted = (await fetchAndConvert('all')) as ( | Product | ProductSubscription )[]; RnIapConsole.debug( '[fetchProducts] Converted items before filtering:', converted.map((item) => ({ id: item.id, type: item.type, platform: item.platform, })), ); // For 'all' type, need to properly distinguish between products and subscriptions // On Android, check subscriptionOfferDetailsAndroid to determine if it's a real subscription const productItems: Product[] = []; const subscriptionItems: ProductSubscription[] = []; converted.forEach((item) => { // With discriminated unions, type field is now reliable if (item.type === 'in-app') { productItems.push(item); return; } // item.type === 'subs' case // For Android, check if subscription items have actual offers if ( Platform.OS === 'android' && item.platform === 'android' && item.type === 'subs' ) { // TypeScript now knows this is ProductSubscriptionAndroid const hasSubscriptionOffers = item.subscriptionOfferDetailsAndroid && Array.isArray(item.subscriptionOfferDetailsAndroid) && item.subscriptionOfferDetailsAndroid.length > 0; RnIapConsole.debug( `[fetchProducts] ${item.id}: type=${item.type}, hasOffers=${hasSubscriptionOffers}`, ); if (hasSubscriptionOffers) { subscriptionItems.push(item); } else { // Treat as product if no offers - convert type const {subscriptionOfferDetailsAndroid: _, ...productFields} = item as any; productItems.push({ ...productFields, type: 'in-app' as const, } as Product); } } else if (item.platform === 'ios' && item.type === 'subs') { // iOS: type field is reliable with discriminated unions // TypeScript now knows this is ProductSubscriptionIOS subscriptionItems.push(item); } }); RnIapConsole.debug( '[fetchProducts] After filtering - products:', productItems.length, 'subs:', subscriptionItems.length, ); return [...productItems, ...subscriptionItems] as FetchProductsResult; } const convertedProducts = await fetchAndConvert( toNitroProductType(normalizedType), ); if (normalizedType === 'subs') { return convertedProducts.map( convertProductToProductSubscription, ) as FetchProductsResult; } return convertedProducts as FetchProductsResult; } catch (error) { RnIapConsole.error('[fetchProducts] Failed:', error); throw error; } }; /** * Get available purchases (purchased items not yet consumed/finished) * @param params - Options for getting available purchases * @param params.alsoPublishToEventListener - Whether to also publish to event listener * @param params.onlyIncludeActiveItems - Whether to only include active items * * @example * ```typescript * const purchases = await getAvailablePurchases({ * onlyIncludeActiveItemsIOS: true * }); * ``` */ export const getAvailablePurchases: QueryField< 'getAvailablePurchases' > = async (options) => { const alsoPublishToEventListenerIOS = Boolean( options?.alsoPublishToEventListenerIOS ?? false, ); const onlyIncludeActiveItemsIOS = Boolean( options?.onlyIncludeActiveItemsIOS ?? true, ); try { if (Platform.OS === 'ios') { const nitroOptions: NitroAvailablePurchasesOptions = { ios: { alsoPublishToEventListenerIOS, onlyIncludeActiveItemsIOS, alsoPublishToEventListener: alsoPublishToEventListenerIOS, onlyIncludeActiveItems: onlyIncludeActiveItemsIOS, }, }; const nitroPurchases = await IAP.instance.getAvailablePurchases(nitroOptions); const validPurchases = nitroPurchases.filter(validateNitroPurchase); if (validPurchases.length !== nitroPurchases.length) { RnIapConsole.warn( `[getAvailablePurchases] Some purchases failed validation: ${nitroPurchases.length - validPurchases.length} invalid`, ); } return validPurchases.map(convertNitroPurchaseToPurchase); } else if (Platform.OS === 'android') { // For Android, we need to call twice for inapp and subs const inappNitroPurchases = await IAP.instance.getAvailablePurchases({ android: {type: 'inapp'}, }); const subsNitroPurchases = await IAP.instance.getAvailablePurchases({ android: {type: 'subs'}, }); // Validate and convert both sets of purchases const allNitroPurchases = [...inappNitroPurchases, ...subsNitroPurchases]; const validPurchases = allNitroPurchases.filter(validateNitroPurchase); if (validPurchases.length !== allNitroPurchases.length) { RnIapConsole.warn( `[getAvailablePurchases] Some Android purchases failed validation: ${allNitroPurchases.length - validPurchases.length} invalid`, ); } return validPurchases.map(convertNitroPurchaseToPurchase); } else { throw new Error('Unsupported platform'); } } catch (error) { RnIapConsole.error('Failed to get available purchases:', error); throw error; } }; /** * Request the promoted product from the App Store (iOS only) * @returns Promise<Product | null> - The promoted product or null if none available * @platform iOS */ export const getPromotedProductIOS: QueryField< 'getPromotedProductIOS' > = async () => { if (Platform.OS !== 'ios') { return null; } try { const nitroProduct = typeof IAP.instance.getPromotedProductIOS === 'function' ? await IAP.instance.getPromotedProductIOS() : await IAP.instance.requestPromotedProductIOS(); if (!nitroProduct) { return null; } const converted = convertNitroProductToProduct(nitroProduct); return converted.platform === 'ios' ? (converted as ProductIOS) : null; } catch (error) { RnIapConsole.error('[getPromotedProductIOS] Failed:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; export const requestPromotedProductIOS = getPromotedProductIOS; export const getStorefrontIOS: QueryField<'getStorefrontIOS'> = async () => { if (Platform.OS !== 'ios') { throw new Error('getStorefrontIOS is only available on iOS'); } try { const storefront = await IAP.instance.getStorefrontIOS(); return storefront; } catch (error) { RnIapConsole.error('Failed to get storefront:', error); throw error; } }; export const getStorefront: QueryField<'getStorefront'> = async () => { if (Platform.OS !== 'ios' && Platform.OS !== 'android') { RnIapConsole.warn( '[getStorefront] Storefront lookup is only supported on iOS and Android.', ); return ''; } const hasUnifiedMethod = typeof IAP.instance.getStorefront === 'function'; if (!hasUnifiedMethod && Platform.OS === 'ios') { return getStorefrontIOS(); } if (!hasUnifiedMethod) { RnIapConsole.warn( '[getStorefront] Native getStorefront is not available on this build.', ); return ''; } try { const storefront = await IAP.instance.getStorefront(); return storefront ?? ''; } catch (error) { RnIapConsole.error( `[getStorefront] Failed to get storefront on ${Platform.OS}:`, error, ); throw error; } }; export const getAppTransactionIOS: QueryField< 'getAppTransactionIOS' > = async () => { if (Platform.OS !== 'ios') { throw new Error('getAppTransactionIOS is only available on iOS'); } try { const appTransaction = await IAP.instance.getAppTransactionIOS(); if (appTransaction == null) { return null; } if (typeof appTransaction === 'string') { const parsed = parseAppTransactionPayload(appTransaction); if (parsed) { return parsed; } throw new Error('Unable to parse app transaction payload'); } if (typeof appTransaction === 'object' && appTransaction !== null) { return appTransaction as AppTransaction; } return null; } catch (error) { RnIapConsole.error('Failed to get app transaction:', error); throw error; } }; export const subscriptionStatusIOS: QueryField< 'subscriptionStatusIOS' > = async (sku) => { if (Platform.OS !== 'ios') { throw new Error('subscriptionStatusIOS is only available on iOS'); } try { const statuses = await IAP.instance.subscriptionStatusIOS(sku); if (!Array.isArray(statuses)) return []; return statuses .filter((status): status is NitroSubscriptionStatus => status != null) .map(convertNitroSubscriptionStatusToSubscriptionStatusIOS); } catch (error) { RnIapConsole.error('[subscriptionStatusIOS] Failed:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; export const currentEntitlementIOS: QueryField< 'currentEntitlementIOS' > = async (sku) => { if (Platform.OS !== 'ios') { return null; } try { const nitroPurchase = await IAP.instance.currentEntitlementIOS(sku); if (nitroPurchase) { const converted = convertNitroPurchaseToPurchase(nitroPurchase); return converted.platform === 'ios' ? (converted as PurchaseIOS) : null; } return null; } catch (error) { RnIapConsole.error('[currentEntitlementIOS] Failed:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; export const latestTransactionIOS: QueryField<'latestTransactionIOS'> = async ( sku, ) => { if (Platform.OS !== 'ios') { return null; } try { const nitroPurchase = await IAP.instance.latestTransactionIOS(sku); if (nitroPurchase) { const converted = convertNitroPurchaseToPurchase(nitroPurchase); return converted.platform === 'ios' ? (converted as PurchaseIOS) : null; } return null; } catch (error) { RnIapConsole.error('[latestTransactionIOS] Failed:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; export const getPendingTransactionsIOS: QueryField< 'getPendingTransactionsIOS' > = async () => { if (Platform.OS !== 'ios') { return []; } try { const nitroPurchases = await IAP.instance.getPendingTransactionsIOS(); return nitroPurchases .map(convertNitroPurchaseToPurchase) .filter( (purchase): purchase is PurchaseIOS => purchase.platform === 'ios', ); } catch (error) { RnIapConsole.error('[getPendingTransactionsIOS] Failed:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; export const showManageSubscriptionsIOS: MutationField< 'showManageSubscriptionsIOS' > = async () => { if (Platform.OS !== 'ios') { return []; } try { const nitroPurchases = await IAP.instance.showManageSubscriptionsIOS(); return nitroPurchases .map(convertNitroPurchaseToPurchase) .filter( (purchase): purchase is PurchaseIOS => purchase.platform === 'ios', ); } catch (error) { RnIapConsole.error('[showManageSubscriptionsIOS] Failed:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; export const isEligibleForIntroOfferIOS: QueryField< 'isEligibleForIntroOfferIOS' > = async (groupID) => { if (Platform.OS !== 'ios') { return false; } try { return await IAP.instance.isEligibleForIntroOfferIOS(groupID); } catch (error) { RnIapConsole.error('[isEligibleForIntroOfferIOS] Failed:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; export const getReceiptDataIOS: QueryField<'getReceiptDataIOS'> = async () => { if (Platform.OS !== 'ios') { throw new Error('getReceiptDataIOS is only available on iOS'); } RnIapConsole.warn( '[getReceiptDataIOS] ⚠️ iOS receipts contain ALL transactions, not just the latest one. ' + 'For individual purchase validation, use getTransactionJwsIOS(productId) instead. ' + 'See: https://react-native-iap.hyo.dev/docs/guides/receipt-validation', ); try { return await IAP.instance.getReceiptDataIOS(); } catch (error) { RnIapConsole.error('[getReceiptDataIOS] Failed:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; export const getReceiptIOS = async (): Promise<string> => { if (Platform.OS !== 'ios') { throw new Error('getReceiptIOS is only available on iOS'); } RnIapConsole.warn( '[getReceiptIOS] ⚠️ iOS receipts contain ALL transactions, not just the latest one. ' + 'For individual purchase validation, use getTransactionJwsIOS(productId) instead. ' + 'See: https://react-native-iap.hyo.dev/docs/guides/receipt-validation', ); try { if (typeof IAP.instance.getReceiptIOS === 'function') { return await IAP.instance.getReceiptIOS(); } return await IAP.instance.getReceiptDataIOS(); } catch (error) { RnIapConsole.error('[getReceiptIOS] Failed:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; export const requestReceiptRefreshIOS = async (): Promise<string> => { if (Platform.OS !== 'ios') { throw new Error('requestReceiptRefreshIOS is only available on iOS'); } RnIapConsole.warn( '[requestReceiptRefreshIOS] ⚠️ iOS receipts contain ALL transactions, not just the latest one. ' + 'For individual purchase validation, use getTransactionJwsIOS(productId) instead. ' + 'See: https://react-native-iap.hyo.dev/docs/guides/receipt-validation', ); try { if (typeof IAP.instance.requestReceiptRefreshIOS === 'function') { return await IAP.instance.requestReceiptRefreshIOS(); } return await IAP.instance.getReceiptDataIOS(); } catch (error) { RnIapConsole.error('[requestReceiptRefreshIOS] Failed:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; export const isTransactionVerifiedIOS: QueryField< 'isTransactionVerifiedIOS' > = async (sku) => { if (Platform.OS !== 'ios') { return false; } try { return await IAP.instance.isTransactionVerifiedIOS(sku); } catch (error) { RnIapConsole.error('[isTransactionVerifiedIOS] Failed:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; export const getTransactionJwsIOS: QueryField<'getTransactionJwsIOS'> = async ( sku, ) => { if (Platform.OS !== 'ios') { return null; } try { return await IAP.instance.getTransactionJwsIOS(sku); } catch (error) { RnIapConsole.error('[getTransactionJwsIOS] Failed:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; // ------------------------------ // Mutation API // ------------------------------ /** * Initialize connection to the store * @param config - Optional configuration including alternative billing mode for Android * @param config.alternativeBillingModeAndroid - Alternative billing mode: 'none', 'user-choice', or 'alternative-only' * * @example * ```typescript * // Standard billing (default) * await initConnection(); * * // User choice billing (Android) * await initConnection({ * alternativeBillingModeAndroid: 'user-choice' * }); * * // Alternative billing only (Android) * await initConnection({ * alternativeBillingModeAndroid: 'alternative-only' * }); * ``` */ export const initConnection: MutationField<'initConnection'> = async ( config, ) => { try { return await IAP.instance.initConnection( config as Record<string, unknown> | undefined, ); } catch (error) { RnIapConsole.error('Failed to initialize IAP connection:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; /** * End connection to the store */ export const endConnection: MutationField<'endConnection'> = async () => { try { if (!iapRef) return true; return await IAP.instance.endConnection(); } catch (error) { RnIapConsole.error('Failed to end IAP connection:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; export const restorePurchases: MutationField<'restorePurchases'> = async () => { try { if (Platform.OS === 'ios') { await syncIOS(); } await getAvailablePurchases({ alsoPublishToEventListenerIOS: false, onlyIncludeActiveItemsIOS: true, }); } catch (error) { RnIapConsole.error('Failed to restore purchases:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; /** * Request a purchase for products or subscriptions * ⚠️ Important: This is an event-based operation, not promise-based. * Listen for events through purchaseUpdatedListener or purchaseErrorListener. */ export const requestPurchase: MutationField<'requestPurchase'> = async ( request, ) => { try { const {request: platformRequest, type} = request; const normalizedType = normalizeProductQueryType(type ?? 'in-app'); const isSubs = isSubscriptionQuery(normalizedType); const perPlatformRequest = platformRequest as | RequestPurchasePropsByPlatforms | RequestSubscriptionPropsByPlatforms | undefined; if (!perPlatformRequest) { throw new Error('Missing purchase request configuration'); } if (Platform.OS === 'ios') { // Support both 'apple' (recommended) and 'ios' (deprecated) fields const iosRequest = perPlatformRequest.apple ?? perPlatformRequest.ios; if (!iosRequest?.sku) { throw new Error( 'Invalid request for iOS. The `sku` property is required.', ); } } else if (Platform.OS === 'android') { // Support both 'google' (recommended) and 'android' (deprecated) fields const androidRequest = perPlatformRequest.google ?? perPlatformRequest.android; if (!androidRequest?.skus?.length) { throw new Error( 'Invalid request for Android. The `skus` property is required and must be a non-empty array.', ); } } else { throw new Error('Unsupported platform'); } const unifiedRequest: NitroPurchaseRequest = {}; // Support both 'apple' (recommended) and 'ios' (deprecated) fields const iosRequestSource = perPlatformRequest.apple ?? perPlatformRequest.ios; if (Platform.OS === 'ios' && iosRequestSource) { const iosRequest = isSubs ? (iosRequestSource as RequestSubscriptionIosProps) : (iosRequestSource as RequestPurchaseIosProps); const iosPayload: NonNullable<NitroPurchaseRequest['ios']> = { sku: iosRequest.sku, }; const explicitAutoFinish = iosRequest.andDangerouslyFinishTransactionAutomatically ?? undefined; const autoFinish = explicitAutoFinish !== undefined ? explicitAutoFinish : isSubs ? true : undefined; if (autoFinish !== undefined) { iosPayload.andDangerouslyFinishTransactionAutomatically = autoFinish; } if (iosRequest.appAccountToken) { iosPayload.appAccountToken = iosRequest.appAccountToken; } if (typeof iosRequest.quantity === 'number') { iosPayload.quantity = iosRequest.quantity; } const offerRecord = toDiscountOfferRecordIOS(iosRequest.withOffer); if (offerRecord) { iosPayload.withOffer = offerRecord; } if (iosRequest.advancedCommerceData) { iosPayload.advancedCommerceData = iosRequest.advancedCommerceData; } unifiedRequest.ios = iosPayload; } // Support both 'google' (recommended) and 'android' (deprecated) fields const androidRequestSource = perPlatformRequest.google ?? perPlatformRequest.android; if (Platform.OS === 'android' && androidRequestSource) { const androidRequest = isSubs ? (androidRequestSource as RequestSubscriptionAndroidProps) : (androidRequestSource as RequestPurchaseAndroidProps); const androidPayload: NonNullable<NitroPurchaseRequest['android']> = { skus: androidRequest.skus, }; if (androidRequest.obfuscatedAccountIdAndroid) { androidPayload.obfuscatedAccountIdAndroid = androidRequest.obfuscatedAccountIdAndroid; } if (androidRequest.obfuscatedProfileIdAndroid) { androidPayload.obfuscatedProfileIdAndroid = androidRequest.obfuscatedProfileIdAndroid; } if (androidRequest.isOfferPersonalized != null) { androidPayload.isOfferPersonalized = androidRequest.isOfferPersonalized; } if (isSubs) { const subsRequest = androidRequest as RequestSubscriptionAndroidProps; if (subsRequest.purchaseTokenAndroid) { androidPayload.purchaseTokenAndroid = subsRequest.purchaseTokenAndroid; } if (subsRequest.replacementModeAndroid != null) { androidPayload.replacementModeAndroid = subsRequest.replacementModeAndroid; } androidPayload.subscriptionOffers = ( subsRequest.subscriptionOffers ?? [] ) .filter( (offer): offer is AndroidSubscriptionOfferInput => offer != null, ) .map((offer) => ({ sku: offer.sku, offerToken: offer.offerToken, })); } unifiedRequest.android = androidPayload; } return await IAP.instance.requestPurchase(unifiedRequest); } catch (error) { RnIapConsole.error('Failed to request purchase:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, productId: parsedError.productId, }); } }; /** * Finish a transaction (consume or acknowledge) * @param params - Transaction finish parameters * @param params.purchase - The purchase to finish * @param params.isConsumable - Whether this is a consumable product (Android only) * @returns Promise<void> - Resolves when the transaction is successfully finished * * @example * ```typescript * await finishTransaction({ * purchase: myPurchase, * isConsumable: true * }); * ``` */ export const finishTransaction: MutationField<'finishTransaction'> = async ( args, ) => { const {purchase, isConsumable} = args; try { let params: NitroFinishTransactionParamsInternal; if (Platform.OS === 'ios') { if (!purchase.id) { throw new Error('purchase.id required to finish iOS transaction'); } params = { ios: { transactionId: purchase.id, }, }; } else if (Platform.OS === 'android') { const token = purchase.purchaseToken ?? undefined; if (!token) { throw new Error('purchaseToken required to finish Android transaction'); } params = { android: { purchaseToken: token, isConsumable: isConsumable ?? false, }, }; } else { throw new Error('Unsupported platform'); } const result = await IAP.instance.finishTransaction(params); const success = getSuccessFromPurchaseVariant(result, 'finishTransaction'); if (!success) { throw new Error('Failed to finish transaction'); } return; } catch (error) { // If iOS transaction has already been auto-finished natively, treat as success if (Platform.OS === 'ios') { const err = parseErrorStringToJsonObj(error); const msg = (err?.message || '').toString(); const code = (err?.code || '').toString(); if ( msg.includes('Transaction not found') || code === 'E_ITEM_UNAVAILABLE' ) { // Consider already finished return; } } RnIapConsole.error('Failed to finish transaction:', error); throw error; } }; /** * Acknowledge a purchase (Android only) * @param purchaseToken - The purchase token to acknowledge * @returns Promise<boolean> - Indicates whether the acknowledgement succeeded * * @example * ```typescript * await acknowledgePurchaseAndroid('purchase_token_here'); * ``` */ export const acknowledgePurchaseAndroid: MutationField< 'acknowledgePurchaseAndroid' > = async (purchaseToken) => { try { if (Platform.OS !== 'android') { throw new Error( 'acknowledgePurchaseAndroid is only available on Android', ); } const result = await IAP.instance.finishTransaction({ android: { purchaseToken, isConsumable: false, }, }); return getSuccessFromPurchaseVariant(result, 'acknowledgePurchaseAndroid'); } catch (error) { RnIapConsole.error('Failed to acknowledge purchase Android:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; /** * Consume a purchase (Android only) * @param purchaseToken - The purchase token to consume * @returns Promise<boolean> - Indicates whether the consumption succeeded * * @example * ```typescript * await consumePurchaseAndroid('purchase_token_here'); * ``` */ export const consumePurchaseAndroid: MutationField< 'consumePurchaseAndroid' > = async (purchaseToken) => { try { if (Platform.OS !== 'android') { throw new Error('consumePurchaseAndroid is only available on Android'); } const result = await IAP.instance.finishTransaction({ android: { purchaseToken, isConsumable: true, }, }); return getSuccessFromPurchaseVariant(result, 'consumePurchaseAndroid'); } catch (error) { RnIapConsole.error('Failed to consume purchase Android:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; // ============================================================================ // iOS-SPECIFIC FUNCTIONS // ============================================================================ /** * Validate receipt on both iOS and Android platforms * @deprecated Use `verifyPurchase` instead. This function will be removed in a future version. * @param options - Platform-specific verification options * @param options.apple - Apple App Store verification options (iOS) * @param options.google - Google Play verification options (Android) * @param options.horizon - Meta Horizon (Quest) verification options * @returns Promise<VerifyPurchaseResultIOS | VerifyPurchaseResultAndroid> - Platform-specific receipt validation result * * @example * ```typescript * // Use verifyPurchase instead: * const result = await verifyPurchase({ * apple: { sku: 'premium_monthly' }, * google: { * sku: 'premium_monthly', * packageName: 'com.example.app', * purchaseToken: 'token...', * accessToken: 'oauth_token...', * isSub: true * } * }); * ``` */ export const validateReceipt: MutationField<'validateReceipt'> = async ( options, ) => { const {apple, google, horizon} = options; try { // Validate required fields based on platform if (Platform.OS === 'ios') { if (!apple?.sku) { throw new Error('Missing required parameter: apple.sku'); } } else if (Platform.OS === 'android') { // Horizon verification path (e.g., Meta Quest) - skip Google validation if (horizon?.sku) { // Validate all required Horizon fields if (!horizon.userId || !horizon.accessToken) { throw new Error( 'Missing required Horizon parameters: userId and accessToken are required when horizon.sku is provided', ); } // Horizon verification will be handled by native layer } else if (!google) { throw new Error('Missing required parameter: google options'); } else { const requiredFields: (keyof typeof google)[] = [ 'sku', 'accessToken', 'packageName', 'purchaseToken', ]; for (const field of requiredFields) { if (!google[field]) { throw new Error( `Missing or empty required parameter: google.${field}`, ); } } } } const params: NitroReceiptValidationParams = { apple: apple?.sku ? { sku: apple.sku, } : null, google: google?.sku && google.accessToken && google.packageName && google.purchaseToken ? { sku: google.sku, accessToken: google.accessToken, packageName: google.packageName, purchaseToken: google.purchaseToken, isSub: google.isSub == null ? undefined : Boolean(google.isSub), } : null, horizon: horizon?.sku && horizon.userId && horizon.accessToken ? { sku: horizon.sku, userId: horizon.userId, accessToken: horizon.accessToken, } : null, }; const nitroResult = await IAP.instance.validateReceipt(params); // Convert Nitro result to public API result if (Platform.OS === 'ios') { const iosResult = nitroResult as NitroReceiptValidationResultIOS; const result: VerifyPurchaseResultIOS = { isValid: iosResult.isValid, receiptData: iosResult.receiptData, jwsRepresentation: iosResult.jwsRepresentation, latestTransaction: iosResult.latestTransaction ? convertNitroPurchaseToPurchase(iosResult.latestTransaction) : undefined, }; return result; } else { // Android const androidResult = nitroResult as NitroReceiptValidationResultAndroid; const result: VerifyPurchaseResultAndroid = { autoRenewing: androidResult.autoRenewing, betaProduct: androidResult.betaProduct, cancelDate: androidResult.cancelDate, cancelReason: androidResult.cancelReason, deferredDate: androidResult.deferredDate, deferredSku: androidResult.deferredSku?.toString() ?? null, freeTrialEndDate: androidResult.freeTrialEndDate, gracePeriodEndDate: androidResult.gracePeriodEndDate, parentProductId: androidResult.parentProductId, productId: androidResult.productId, productType: androidResult.productType === 'subs' ? 'subs' : 'inapp', purchaseDate: androidResult.purchaseDate, quantity: androidResult.quantity, receiptId: androidResult.receiptId, renewalDate: androidResult.renewalDate, term: androidResult.term, termSku: androidResult.termSku, testTransaction: androidResult.testTransaction, }; return result; } } catch (error) { RnIapConsole.error('[validateReceipt] Failed:', error); const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, responseCode: parsedError.responseCode, debugMessage: parsedError.debugMessage, }); } }; /** * Verify purchase with the configured providers * * This function uses the native OpenIAP verifyPurchase implementation *