react-native-iap
Version:
React Native In-App Purchases module for iOS and Android using Nitro
1,425 lines (1,382 loc) • 74 kB
JavaScript
"use strict";
// 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 { ErrorCode } from "./types.js";
import { convertNitroProductToProduct, convertNitroPurchaseToPurchase, convertProductToProductSubscription, validateNitroProduct, validateNitroPurchase, convertNitroSubscriptionStatusToSubscriptionStatusIOS } from "./utils/type-bridge.js";
import { parseErrorStringToJsonObj } from "./utils/error.js";
import { normalizeErrorCodeFromNative, createPurchaseError } from "./utils/errorMapping.js";
import { RnIapConsole } from "./utils/debug.js";
import { getSuccessFromPurchaseVariant } from "./utils/purchase.js";
import { parseAppTransactionPayload } from "./utils.js";
// ------------------------------
// 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.
// Export all types
export * from "./types.js";
export * from "./utils/error.js";
const LEGACY_INAPP_WARNING = "[react-native-iap] `type: 'inapp'` is deprecated and will be removed in v14.4.0. Use 'in-app' instead.";
const toErrorMessage = error => {
if (typeof error === 'object' && error !== null && 'message' in error && error.message != null) {
return String(error.message);
}
return String(error ?? '');
};
// ActiveSubscription and PurchaseError types are already exported via 'export * from ./types'
// Export hooks
export { useIAP } from "./hooks/useIAP.js";
// 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 = null;
const IAP = {
get instance() {
if (iapRef) return iapRef;
// Attempt to create the HybridObject and map common Nitro/JSI readiness errors
try {
iapRef = NitroModules.createHybridObject('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();
const purchaseErrorListenerMap = new WeakMap();
const promotedProductListenerMap = new WeakMap();
export const purchaseUpdatedListener = listener => {
const wrappedListener = 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 => {
const wrapped = 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 => {
if (Platform.OS !== 'ios') {
RnIapConsole.warn('promotedProductListenerIOS: This listener is only available on iOS');
return {
remove: () => {}
};
}
const wrappedListener = 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();
* ```
*/
const userChoiceBillingListenerMap = new WeakMap();
export const userChoiceBillingListenerAndroid = listener => {
if (Platform.OS !== 'android') {
RnIapConsole.warn('userChoiceBillingListenerAndroid: This listener is only available on Android');
return {
remove: () => {}
};
}
const wrappedListener = 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();
* ```
*/
const developerProvidedBillingListenerMap = new WeakMap();
export const developerProvidedBillingListenerAndroid = listener => {
if (Platform.OS !== 'android') {
RnIapConsole.warn('developerProvidedBillingListenerAndroid: This listener is only available on Android');
return {
remove: () => {}
};
}
const wrappedListener = 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 = async request => {
const {
skus,
type
} = request;
try {
if (!skus?.length) {
throw new Error('No SKUs provided');
}
const normalizedType = normalizeProductQueryType(type);
const fetchAndConvert = async nitroType => {
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');
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 = [];
const subscriptionItems = [];
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;
productItems.push({
...productFields,
type: 'in-app'
});
}
} 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];
}
const convertedProducts = await fetchAndConvert(toNitroProductType(normalizedType));
if (normalizedType === 'subs') {
return convertedProducts.map(convertProductToProductSubscription);
}
return convertedProducts;
} 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 = async options => {
const alsoPublishToEventListenerIOS = Boolean(options?.alsoPublishToEventListenerIOS ?? false);
const onlyIncludeActiveItemsIOS = Boolean(options?.onlyIncludeActiveItemsIOS ?? true);
try {
if (Platform.OS === 'ios') {
const nitroOptions = {
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 = 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 : 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 = 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 = 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 = 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;
}
return null;
} catch (error) {
RnIapConsole.error('Failed to get app transaction:', error);
throw error;
}
};
export const 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 != 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 = 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 : 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 = 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 : 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 = async () => {
if (Platform.OS !== 'ios') {
return [];
}
try {
const nitroPurchases = await IAP.instance.getPendingTransactionsIOS();
return nitroPurchases.map(convertNitroPurchaseToPurchase).filter(purchase => 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 = async () => {
if (Platform.OS !== 'ios') {
return [];
}
try {
const nitroPurchases = await IAP.instance.showManageSubscriptionsIOS();
return nitroPurchases.map(convertNitroPurchaseToPurchase).filter(purchase => 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 = 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 = 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 () => {
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 () => {
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 = 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 = 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 = async config => {
try {
return await IAP.instance.initConnection(config);
} 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 = 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 = 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 = async request => {
try {
const {
request: platformRequest,
type
} = request;
const normalizedType = normalizeProductQueryType(type ?? 'in-app');
const isSubs = isSubscriptionQuery(normalizedType);
const perPlatformRequest = platformRequest;
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 = {};
// Support both 'apple' (recommended) and 'ios' (deprecated) fields
const iosRequestSource = perPlatformRequest.apple ?? perPlatformRequest.ios;
if (Platform.OS === 'ios' && iosRequestSource) {
const iosRequest = isSubs ? iosRequestSource : iosRequestSource;
const iosPayload = {
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 : androidRequestSource;
const androidPayload = {
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;
if (subsRequest.purchaseTokenAndroid) {
androidPayload.purchaseTokenAndroid = subsRequest.purchaseTokenAndroid;
}
if (subsRequest.replacementModeAndroid != null) {
androidPayload.replacementModeAndroid = subsRequest.replacementModeAndroid;
}
androidPayload.subscriptionOffers = (subsRequest.subscriptionOffers ?? []).filter(offer => 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 = async args => {
const {
purchase,
isConsumable
} = args;
try {
let params;
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 = 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 = 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 = 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 = ['sku', 'accessToken', 'packageName', 'purchaseToken'];
for (const field of requiredFields) {
if (!google[field]) {
throw new Error(`Missing or empty required parameter: google.${field}`);
}
}
}
}
const params = {
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;
const result = {
isValid: iosResult.isValid,
receiptData: iosResult.receiptData,
jwsRepresentation: iosResult.jwsRepresentation,
latestTransaction: iosResult.latestTransaction ? convertNitroPurchaseToPurchase(iosResult.latestTransaction) : undefined
};
return result;
} else {
// Android
const androidResult = nitroResult;
const result = {
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
* which validates purchases using platform-specific methods.
* This is an alias for validateReceipt for API consistency with OpenIAP.
*
* @param options - Receipt validation options containing the SKU
* @returns Promise resolving to receipt validation result
*/
export const verifyPurchase = validateReceipt;
/**
* Verify purchase with a specific provider (e.g., IAPKit)
*
* This function allows you to verify purchases using external verification
* services like IAPKit, which provide additional validation and security.
*
* @param options - Verification options including provider and credentials
* @returns Promise resolving to provider-specific verification result
*
* @example
* ```typescript
* const result = await verifyPurchaseWithProvider({
* provider: 'iapkit',
* iapkit: {
* apiKey: 'your-api-key',
* apple: { jws: purchase.purchaseToken },
* google: { purchaseToken: purchase.purchaseToken },
* },
* });
* ```
*/
export const verifyPurchaseWithProvider = async options => {
try {
const result = await IAP.instance.verifyPurchaseWithProvider({
provider: options.provider,
iapkit: options.iapkit ?? null
});
// Validate provider - Nitro spec allows 'none' for compatibility, but this function only supports 'iapkit'
if (result.provider !== 'iapkit') {
throw createPurchaseError({
code: ErrorCode.DeveloperError,
message: `Unsupported provider: ${result.provider}. Only 'iapkit' is supported.`
});
}
return {
provider: result.provider,
iapkit: result.iapkit ? {
isValid: result.iapkit.isValid,
state: result.iapkit.state,
store: result.iapkit.store
} : null,
errors: result.errors ?? null
};
} catch (error) {
RnIapConsole.error('[verifyPurchaseWithProvider] Failed:', error);
const parsedError = parseErrorStringToJsonObj(error);
throw createPurchaseError({
code: parsedError.code,
message: parsedError.message,
responseCode: parsedError.responseCode,
debugMessage: parsedError.debugMessage
});
}
};
/**
* Sync iOS purchases with App Store (iOS only)
* @returns Promise<boolean>
* @platform iOS
*/
export const syncIOS = async () => {
if (Platform.OS !== 'ios') {
throw new Error('syncIOS is only available on iOS');
}
try {
const result = await IAP.instance.syncIOS();
return Boolean(result);
} catch (error) {
RnIapConsole.error('[syncIOS] Failed:', error);
const parsedError = parseErrorStringToJsonObj(error);
throw createPurchaseError({
code: parsedError.code,
message: parsedError.message,
responseCode: parsedError.responseCode,
debugMessage: parsedError.debugMessage
});
}
};
/**
* Present the code redemption sheet for offer codes (iOS only)
* @returns Promise<boolean> - Indicates whether the redemption sheet was presented
* @platform iOS
*/
export const presentCodeRedemptionSheetIOS = async () => {
if (Platform.OS !== 'ios') {
return false;
}
try {
const result = await IAP.instance.presentCodeRedemptionSheetIOS();
return Boolean(result);
} catch (error) {
RnIapConsole.error('[presentCodeRedemptionSheetIOS] Failed:', error);
const parsedError = parseErrorStringToJsonObj(error);
throw createPurchaseError({
code: parsedError.code,
message: parsedError.message,
responseCode: parsedError.responseCode,
debugMessage: parsedError.debugMessage
});
}
};
/**
* Buy promoted product on iOS
* @deprecated In StoreKit 2, promoted products can be purchased directly via the standard `requestPurchase()` flow.
* Use `promotedProductListenerIOS` to receive the product ID when a user taps a promoted product,
* then call `requestPurchase()` with the received SKU directly.
*
* @example
* ```typescript
* // Recommended approach
* promotedProductListenerIOS(async (product) => {
* await requestPurchase({
* request: { apple: { sku: product.id } },
* type: 'in-app'
* });
* });
* ```
*
* @returns Promise<boolean> - true when the request triggers successfully
* @platform iOS
*/
export const requestPurchaseOnPromotedProductIOS = async () => {
if (Platform.OS !== 'ios') {
throw new Error('requestPurchaseOnPromotedProductIOS is only available on iOS');
}
try {
await IAP.instance.buyPromotedProductIOS();
const pending = await IAP.instance.getPendingTransactionsIOS();
const latest = pending.find(purchase => purchase != null);
if (!latest) {
throw new Error('No promoted purchase available after request');
}
const converted = convertNitroPurchaseToPurchase(latest);
if (converted.platform !== 'ios') {
throw new Error('Promoted purchase result not available for iOS');
}
return true;
} catch (error) {
RnIapConsole.error('[requestPurchaseOnPromotedProductIOS] Failed:', error);
const parsedError = parseErrorStringToJsonObj(error);
throw createPurchaseError({
code: parsedError.code,
message: parsedError.message,
responseCode: parsedError.responseCode,
debugMessage: parsedError.debugMessage
});
}
};
/**
* Clear unfinished transactions on iOS
* @returns Promise<boolean>
* @platform iOS
*/
export const clearTransactionIOS = async () => {
if (Platform.OS !== 'ios') {
return false;
}
try {
await IAP.instance.clearTransactionIOS();
return true;
} catch (error) {
RnIapConsole.error('[clearTransactionIOS] Failed:', error);
const parsedError = parseErrorStringToJsonObj(error);
throw createPurchaseError({
code: parsedError.code,
message: parsedError.message,
responseCode: parsedError.responseCode,
debugMessage: parsedError.debugMessage
});
}
};
/**
* Begin a refund request for a product on iOS 15+
* @param sku - The product SKU to refund
* @returns Promise<string | null> - The refund status or null if not available
* @platform iOS
*/
export const beginRefundRequestIOS = async sku => {
if (Platform.OS !== 'ios') {
throw new Error('beginRefundRequestIOS is only available on iOS');
}
try {
const status = await IAP.instance.beginRefundRequestIOS(sku);
return status ?? null;
} catch (error) {
RnIapConsole.error('[beginRefundRequestIOS] Failed:', error);
const parsedError = parseErrorStringToJsonObj(error);
throw createPurchaseError({
code: parsedError.code,
message: parsedError.