UNPKG

react-native-iap

Version:

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

437 lines (386 loc) 13.5 kB
/** * Error mapping utilities for react-native-iap. * Provides helpers for working with platform-specific error codes * and constructing structured purchase errors. */ import {ErrorCode, type IapPlatform} from '../types'; const ERROR_CODE_ALIASES: Record<string, ErrorCode> = { E_USER_CANCELED: ErrorCode.UserCancelled, USER_CANCELED: ErrorCode.UserCancelled, E_USER_CANCELLED: ErrorCode.UserCancelled, USER_CANCELLED: ErrorCode.UserCancelled, }; const toKebabCase = (str: string): string => { if (str.includes('_')) { return str .split('_') .map((word) => word.toLowerCase()) .join('-'); } else { return str .replace(/([A-Z])/g, '-$1') .toLowerCase() .replace(/^-/, ''); } }; export interface PurchaseErrorProps { message?: string; responseCode?: number; debugMessage?: string; code?: ErrorCode | string | number; productId?: string; platform?: IapPlatform; } export interface PurchaseError extends Error { responseCode?: number; debugMessage?: string; code?: ErrorCode; productId?: string; platform?: IapPlatform; } const normalizePlatform = (platform: IapPlatform): 'ios' | 'android' => typeof platform === 'string' && platform.toLowerCase() === 'ios' ? 'ios' : 'android'; const COMMON_ERROR_CODE_MAP: Record<ErrorCode, string> = { [ErrorCode.Unknown]: ErrorCode.Unknown, [ErrorCode.UserCancelled]: ErrorCode.UserCancelled, [ErrorCode.UserError]: ErrorCode.UserError, [ErrorCode.ItemUnavailable]: ErrorCode.ItemUnavailable, [ErrorCode.RemoteError]: ErrorCode.RemoteError, [ErrorCode.NetworkError]: ErrorCode.NetworkError, [ErrorCode.ServiceError]: ErrorCode.ServiceError, [ErrorCode.ReceiptFailed]: ErrorCode.ReceiptFailed, [ErrorCode.ReceiptFinished]: ErrorCode.ReceiptFinished, [ErrorCode.ReceiptFinishedFailed]: ErrorCode.ReceiptFinishedFailed, [ErrorCode.NotPrepared]: ErrorCode.NotPrepared, [ErrorCode.NotEnded]: ErrorCode.NotEnded, [ErrorCode.AlreadyOwned]: ErrorCode.AlreadyOwned, [ErrorCode.DeveloperError]: ErrorCode.DeveloperError, [ErrorCode.BillingResponseJsonParseError]: ErrorCode.BillingResponseJsonParseError, [ErrorCode.DeferredPayment]: ErrorCode.DeferredPayment, [ErrorCode.Interrupted]: ErrorCode.Interrupted, [ErrorCode.IapNotAvailable]: ErrorCode.IapNotAvailable, [ErrorCode.PurchaseError]: ErrorCode.PurchaseError, [ErrorCode.SyncError]: ErrorCode.SyncError, [ErrorCode.TransactionValidationFailed]: ErrorCode.TransactionValidationFailed, [ErrorCode.ActivityUnavailable]: ErrorCode.ActivityUnavailable, [ErrorCode.AlreadyPrepared]: ErrorCode.AlreadyPrepared, [ErrorCode.Pending]: ErrorCode.Pending, [ErrorCode.ConnectionClosed]: ErrorCode.ConnectionClosed, [ErrorCode.InitConnection]: ErrorCode.InitConnection, [ErrorCode.ServiceDisconnected]: ErrorCode.ServiceDisconnected, [ErrorCode.QueryProduct]: ErrorCode.QueryProduct, [ErrorCode.SkuNotFound]: ErrorCode.SkuNotFound, [ErrorCode.SkuOfferMismatch]: ErrorCode.SkuOfferMismatch, [ErrorCode.ItemNotOwned]: ErrorCode.ItemNotOwned, [ErrorCode.BillingUnavailable]: ErrorCode.BillingUnavailable, [ErrorCode.FeatureNotSupported]: ErrorCode.FeatureNotSupported, [ErrorCode.EmptySkuList]: ErrorCode.EmptySkuList, }; export const ErrorCodeMapping = { ios: COMMON_ERROR_CODE_MAP, android: COMMON_ERROR_CODE_MAP, } as const; const OPENIAP_ERROR_CODE_SET: Set<string> = new Set(Object.values(ErrorCode)); export const createPurchaseError = ( props: PurchaseErrorProps, ): PurchaseError => { const errorCode = props.code ? typeof props.code === 'string' || typeof props.code === 'number' ? ErrorCodeUtils.fromPlatformCode(props.code, props.platform || 'ios') : props.code : undefined; const error = new Error( props.message ?? 'Unknown error occurred', ) as PurchaseError; error.name = '[react-native-iap]: PurchaseError'; error.responseCode = props.responseCode; error.debugMessage = props.debugMessage; error.code = errorCode; error.productId = props.productId; error.platform = props.platform; return error; }; export const createPurchaseErrorFromPlatform = ( errorData: PurchaseErrorProps, platform: IapPlatform, ): PurchaseError => { const normalizedPlatform = normalizePlatform(platform); const errorCode = errorData.code ? typeof errorData.code === 'string' || typeof errorData.code === 'number' ? ErrorCodeUtils.fromPlatformCode(errorData.code, normalizedPlatform) : errorData.code : ErrorCode.Unknown; return createPurchaseError({ message: errorData.message ?? 'Unknown error occurred', responseCode: errorData.responseCode, debugMessage: errorData.debugMessage, code: errorCode, productId: errorData.productId, platform, }); }; export const ErrorCodeUtils = { getNativeErrorCode: (errorCode: ErrorCode): string => { return errorCode; }, fromPlatformCode: ( platformCode: string | number, _platform: IapPlatform, ): ErrorCode => { if (typeof platformCode === 'string') { // Handle direct ErrorCode enum values if (OPENIAP_ERROR_CODE_SET.has(platformCode)) { return platformCode as ErrorCode; } // Handle E_ prefixed codes if (platformCode.startsWith('E_')) { const withoutE = platformCode.substring(2); const kebabCase = toKebabCase(withoutE); if (OPENIAP_ERROR_CODE_SET.has(kebabCase)) { return kebabCase as ErrorCode; } } // Handle kebab-case codes const kebabCase = toKebabCase(platformCode); if (OPENIAP_ERROR_CODE_SET.has(kebabCase)) { return kebabCase as ErrorCode; } // Handle legacy formats like USER_CANCELED const upperCase = platformCode.toUpperCase(); if (upperCase === 'USER_CANCELED' || upperCase === 'E_USER_CANCELED') { return ErrorCode.UserCancelled; } } return ErrorCode.Unknown; }, toPlatformCode: ( errorCode: ErrorCode, _platform: IapPlatform, ): string | number => { return COMMON_ERROR_CODE_MAP[errorCode] ?? 'E_UNKNOWN'; }, isValidForPlatform: ( errorCode: ErrorCode, platform: IapPlatform, ): boolean => { return errorCode in ErrorCodeMapping[normalizePlatform(platform)]; }, }; // --------------------------------------------------------------------------- // Convenience helpers for interpreting error objects // --------------------------------------------------------------------------- type ErrorLike = string | {code?: ErrorCode | string; message?: string}; const ERROR_CODES = new Set<string>(Object.values(ErrorCode)); const normalizeErrorCode = ( code?: string | ErrorCode | null, ): string | undefined => { if (!code) { return undefined; } // If it's already an ErrorCode enum value, return it as string if (typeof code !== 'string' && ERROR_CODES.has(code as string)) { return code as string; } if (ERROR_CODES.has(code as string)) { return code as string; } const camelCased = toKebabCase(code as string); if (ERROR_CODES.has(camelCased)) { return camelCased; } if (typeof code === 'string' && code.startsWith('E_')) { const trimmed = code.substring(2); if (ERROR_CODES.has(trimmed)) { return trimmed; } const camelTrimmed = toKebabCase(trimmed); if (ERROR_CODES.has(camelTrimmed)) { return camelTrimmed; } } // Handle legacy formats if (code === 'E_USER_CANCELED') { return ErrorCode.UserCancelled; } return code as string; }; function extractCode(error: unknown): string | undefined { if (typeof error === 'string') { return normalizeErrorCode(error); } if (error && typeof error === 'object' && 'code' in error) { const code = (error as {code?: string | ErrorCode}).code; return normalizeErrorCode(typeof code === 'string' ? code : code); } return undefined; } export function isUserCancelledError(error: unknown): boolean { return extractCode(error) === ErrorCode.UserCancelled; } export function isNetworkError(error: unknown): boolean { const networkErrors: ErrorCode[] = [ ErrorCode.NetworkError, ErrorCode.RemoteError, ErrorCode.ServiceError, ErrorCode.ServiceDisconnected, ErrorCode.BillingUnavailable, ]; const code = extractCode(error); return !!code && (networkErrors as string[]).includes(code); } export function isRecoverableError(error: unknown): boolean { const recoverableErrors: string[] = [ ErrorCode.NetworkError, ErrorCode.RemoteError, ErrorCode.ServiceError, ErrorCode.Interrupted, ErrorCode.ServiceDisconnected, ErrorCode.BillingUnavailable, ErrorCode.QueryProduct, ErrorCode.InitConnection, ErrorCode.SyncError, ErrorCode.ConnectionClosed, ]; const code = extractCode(error); return !!code && recoverableErrors.includes(code); } export function getUserFriendlyErrorMessage(error: ErrorLike): string { const errorCode = extractCode(error); switch (errorCode) { case ErrorCode.UserCancelled: return 'Purchase cancelled'; case ErrorCode.NetworkError: return 'Network connection error. Please check your internet connection and try again.'; case ErrorCode.ReceiptFinished: return 'Receipt already finished'; case ErrorCode.ServiceDisconnected: return 'Billing service disconnected. Please try again.'; case ErrorCode.BillingUnavailable: return 'Billing is unavailable on this device or account.'; case ErrorCode.ItemUnavailable: return 'This item is not available for purchase'; case ErrorCode.ItemNotOwned: return "You don't own this item"; case ErrorCode.AlreadyOwned: return 'You already own this item'; case ErrorCode.SkuNotFound: return 'Requested product could not be found'; case ErrorCode.SkuOfferMismatch: return 'Selected offer does not match the SKU'; case ErrorCode.DeferredPayment: return 'Payment is pending approval'; case ErrorCode.NotPrepared: return 'In-app purchase is not ready. Please try again later.'; case ErrorCode.ServiceError: return 'Store service error. Please try again later.'; case ErrorCode.FeatureNotSupported: return 'This feature is not supported on this device.'; case ErrorCode.TransactionValidationFailed: return 'Transaction could not be verified'; case ErrorCode.ReceiptFailed: return 'Receipt processing failed'; case ErrorCode.EmptySkuList: return 'No product IDs provided'; case ErrorCode.InitConnection: return 'Failed to initialize billing connection'; case ErrorCode.IapNotAvailable: return 'In-app purchases are not available on this device'; case ErrorCode.QueryProduct: return 'Failed to query products. Please try again later.'; default: { if (error && typeof error === 'object' && 'message' in error) { return ( (error as {message?: string}).message ?? 'An unexpected error occurred' ); } return 'An unexpected error occurred'; } } } export const normalizeErrorCodeFromNative = (code: unknown): ErrorCode => { if (typeof code === 'string') { const upper = code.toUpperCase(); // Check aliases first const alias = ERROR_CODE_ALIASES[upper]; if (alias) { return alias; } // Handle various user cancelled formats if ( upper === 'USER_CANCELLED' || upper === 'USER_CANCELED' || upper === 'E_USER_CANCELLED' || upper === 'E_USER_CANCELED' || upper === 'USER_CANCEL' || upper === 'CANCELLED' || upper === 'CANCELED' || code === 'user-cancelled' || code === 'user-canceled' ) { return ErrorCode.UserCancelled; } // Handle E_ prefixed codes if (upper.startsWith('E_')) { const trimmed = upper.slice(2); // Try direct match first if ((ErrorCode as any)[trimmed]) { return (ErrorCode as any)[trimmed]; } // Try camelCase conversion const camel = trimmed .toLowerCase() .split('_') .map((segment) => { if (!segment) return segment; return segment.charAt(0).toUpperCase() + segment.slice(1); }) .join(''); if ((ErrorCode as any)[camel]) { return (ErrorCode as any)[camel]; } // Try kebab-case conversion const kebab = trimmed.toLowerCase().replace(/_/g, '-'); if ((ErrorCode as any)[kebab]) { return (ErrorCode as any)[kebab]; } } // Handle direct kebab-case codes if (code.includes('-')) { if ((ErrorCode as any)[code]) { return (ErrorCode as any)[code]; } } // Handle snake_case codes if (code.includes('_')) { const camel = code .toLowerCase() .split('_') .map((segment) => { if (!segment) return segment; return segment.charAt(0).toUpperCase() + segment.slice(1); }) .join(''); if ((ErrorCode as any)[camel]) { return (ErrorCode as any)[camel]; } const kebab = code.toLowerCase().replace(/_/g, '-'); if ((ErrorCode as any)[kebab]) { return (ErrorCode as any)[kebab]; } } // Try direct match with ErrorCode enum if ((ErrorCode as any)[code]) { return (ErrorCode as any)[code]; } // Try uppercase match if ((ErrorCode as any)[upper]) { return (ErrorCode as any)[upper]; } } return ErrorCode.Unknown; };