UNPKG

react-native-iap

Version:

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

585 lines (535 loc) 18 kB
/** * Type Bridge Utilities * * Converts the loose Nitro shapes coming from native into the strongly typed * structures that our generated TypeScript types expect. */ import type { NitroProduct, NitroPurchase, NitroSubscriptionStatus, } from '../specs/RnIap.nitro'; import type { IapPlatform, IapStore, PaymentModeIOS, ProductType, ProductTypeIOS, PurchaseState, SubscriptionPeriodIOS, } from '../types'; import type { Product, ProductSubscription, Purchase, PurchaseAndroid, PurchaseIOS, SubscriptionStatusIOS, } from '../types'; import {RnIapConsole} from './debug'; const PLATFORM_IOS: IapPlatform = 'ios'; const PLATFORM_ANDROID: IapPlatform = 'android'; const STORE_UNKNOWN: IapStore = 'unknown'; const STORE_APPLE: IapStore = 'apple'; const STORE_GOOGLE: IapStore = 'google'; const STORE_HORIZON: IapStore = 'horizon'; const PRODUCT_TYPE_SUBS: ProductType = 'subs'; const PRODUCT_TYPE_IN_APP: ProductType = 'in-app'; const PURCHASE_STATE_PENDING: PurchaseState = 'pending'; const PURCHASE_STATE_PURCHASED: PurchaseState = 'purchased'; const PURCHASE_STATE_UNKNOWN: PurchaseState = 'unknown'; const PAYMENT_MODE_EMPTY: PaymentModeIOS = 'empty'; const PAYMENT_MODE_FREE_TRIAL: PaymentModeIOS = 'free-trial'; const PAYMENT_MODE_PAY_AS_YOU_GO: PaymentModeIOS = 'pay-as-you-go'; const PAYMENT_MODE_PAY_UP_FRONT: PaymentModeIOS = 'pay-up-front'; const SUBSCRIPTION_PERIOD_DAY: SubscriptionPeriodIOS = 'day'; const SUBSCRIPTION_PERIOD_WEEK: SubscriptionPeriodIOS = 'week'; const SUBSCRIPTION_PERIOD_MONTH: SubscriptionPeriodIOS = 'month'; const SUBSCRIPTION_PERIOD_YEAR: SubscriptionPeriodIOS = 'year'; const SUBSCRIPTION_PERIOD_EMPTY: SubscriptionPeriodIOS = 'empty'; const DEFAULT_JSON_REPR = '{}'; type Nullable<T> = T | null | undefined; function normalizePlatform(value?: Nullable<string>): IapPlatform { return value?.toLowerCase() === PLATFORM_IOS ? PLATFORM_IOS : PLATFORM_ANDROID; } function normalizeStore(value?: Nullable<string>): IapStore { switch (value?.toLowerCase()) { case 'apple': return STORE_APPLE; case 'google': return STORE_GOOGLE; case 'horizon': return STORE_HORIZON; default: return STORE_UNKNOWN; } } function normalizeProductType(value?: Nullable<string>): ProductType { return value?.toLowerCase() === PRODUCT_TYPE_SUBS ? PRODUCT_TYPE_SUBS : PRODUCT_TYPE_IN_APP; } function normalizeProductTypeIOS(value?: Nullable<string>): ProductTypeIOS { switch ((value ?? '').toLowerCase()) { case 'consumable': return 'consumable'; case 'nonconsumable': case 'non_consumable': case 'non-consumable': return 'non-consumable'; case 'autorenewablesubscription': case 'auto_renewable_subscription': case 'auto-renewable-subscription': case 'autorenewable': return 'auto-renewable-subscription'; case 'nonrenewingsubscription': case 'non_renewing_subscription': return 'non-renewing-subscription'; default: if (value) { RnIapConsole.warn( `[react-native-iap] Unknown iOS product type "${value}", defaulting to NonConsumable.`, ); } return 'non-consumable'; } } function normalizePaymentMode(value?: Nullable<string>): PaymentModeIOS { switch ((value ?? '').toUpperCase()) { case 'FREE_TRIAL': case 'FREETRIAL': case 'FREE-TRIAL': return PAYMENT_MODE_FREE_TRIAL; case 'PAY_AS_YOU_GO': case 'PAYASYOUGO': case 'PAY-AS-YOU-GO': return PAYMENT_MODE_PAY_AS_YOU_GO; case 'PAY_UP_FRONT': case 'PAYUPFRONT': case 'PAY-UP-FRONT': return PAYMENT_MODE_PAY_UP_FRONT; default: return PAYMENT_MODE_EMPTY; } } function normalizeSubscriptionPeriod( value?: Nullable<string>, ): SubscriptionPeriodIOS | null { switch ((value ?? '').toUpperCase()) { case 'DAY': return SUBSCRIPTION_PERIOD_DAY; case 'WEEK': return SUBSCRIPTION_PERIOD_WEEK; case 'MONTH': return SUBSCRIPTION_PERIOD_MONTH; case 'YEAR': return SUBSCRIPTION_PERIOD_YEAR; default: return SUBSCRIPTION_PERIOD_EMPTY; } } function normalizePurchaseState(state: unknown): PurchaseState { if (typeof state === 'string') { switch (state.toLowerCase()) { case PURCHASE_STATE_PURCHASED: case 'restored': // Restored purchases are treated as purchased return PURCHASE_STATE_PURCHASED; case PURCHASE_STATE_PENDING: case 'deferred': // Deferred is treated as pending return PURCHASE_STATE_PENDING; default: return PURCHASE_STATE_UNKNOWN; } } if (typeof state === 'number') { switch (state) { case 1: return PURCHASE_STATE_PURCHASED; case 2: return PURCHASE_STATE_PENDING; default: return PURCHASE_STATE_UNKNOWN; } } return PURCHASE_STATE_UNKNOWN; } function toNullableString(value: unknown): string | null { if (value == null) return null; return String(value); } function toNullableNumber(value: unknown): number | null { if (value == null) return null; const num = Number(value); return Number.isFinite(num) ? num : null; } function toNullableBoolean(value: unknown): boolean | null { if (value == null) return null; if (typeof value === 'boolean') return value; if (typeof value === 'number') return value !== 0; if (typeof value === 'string') return value.toLowerCase() === 'true'; return null; } function parseSubscriptionOffers(value?: Nullable<string> | any[]) { if (!value) return undefined; // If it's already an array (from mocks), return it as-is if (Array.isArray(value)) { return value; } // Otherwise, try to parse it as JSON string try { const parsed = JSON.parse(value as string); if (Array.isArray(parsed)) { return parsed; } } catch (error) { RnIapConsole.warn( 'Failed to parse subscriptionOfferDetailsAndroid:', error, ); } return undefined; } /** * Convert NitroProduct (from native) to generated Product type */ export function convertNitroProductToProduct( nitroProduct: NitroProduct, ): Product { const platform = normalizePlatform(nitroProduct.platform); const type = normalizeProductType(nitroProduct.type); const base: any = { id: nitroProduct.id, title: nitroProduct.title, description: nitroProduct.description, type, displayName: nitroProduct.displayName ?? null, displayPrice: nitroProduct.displayPrice ?? '', currency: nitroProduct.currency ?? '', price: toNullableNumber(nitroProduct.price), debugDescription: null, platform, }; if (platform === PLATFORM_IOS) { const iosProduct: any = { ...base, displayNameIOS: nitroProduct.displayName ?? nitroProduct.title, isFamilyShareableIOS: Boolean(nitroProduct.isFamilyShareableIOS ?? false), jsonRepresentationIOS: nitroProduct.jsonRepresentationIOS ?? DEFAULT_JSON_REPR, typeIOS: normalizeProductTypeIOS(nitroProduct.typeIOS), subscriptionInfoIOS: undefined, }; iosProduct.introductoryPriceAsAmountIOS = toNullableString( nitroProduct.introductoryPriceAsAmountIOS, ); iosProduct.introductoryPriceIOS = toNullableString( nitroProduct.introductoryPriceIOS, ); iosProduct.introductoryPriceNumberOfPeriodsIOS = toNullableString( nitroProduct.introductoryPriceNumberOfPeriodsIOS, ); iosProduct.introductoryPricePaymentModeIOS = normalizePaymentMode( nitroProduct.introductoryPricePaymentModeIOS, ); iosProduct.introductoryPriceSubscriptionPeriodIOS = normalizeSubscriptionPeriod( nitroProduct.introductoryPriceSubscriptionPeriodIOS, ); iosProduct.subscriptionPeriodNumberIOS = toNullableString( nitroProduct.subscriptionPeriodNumberIOS, ); iosProduct.subscriptionPeriodUnitIOS = normalizeSubscriptionPeriod( nitroProduct.subscriptionPeriodUnitIOS, ); // Parse discountsIOS from JSON string if present if (nitroProduct.discountsIOS) { try { iosProduct.discountsIOS = JSON.parse(nitroProduct.discountsIOS); } catch { iosProduct.discountsIOS = null; } } else { iosProduct.discountsIOS = null; } return iosProduct as Product; } const androidProduct: any = { ...base, nameAndroid: nitroProduct.nameAndroid ?? nitroProduct.title, oneTimePurchaseOfferDetailsAndroid: nitroProduct.oneTimePurchaseOfferDetailsAndroid ?? null, subscriptionOfferDetailsAndroid: parseSubscriptionOffers( nitroProduct.subscriptionOfferDetailsAndroid, ), }; if (type === PRODUCT_TYPE_SUBS) { if (!Array.isArray(androidProduct.subscriptionOfferDetailsAndroid)) { androidProduct.subscriptionOfferDetailsAndroid = []; } } return androidProduct as Product; } /** * Convert Product to ProductSubscription (type-safe casting helper) */ export function convertProductToProductSubscription( product: Product, ): ProductSubscription { if (product.type !== PRODUCT_TYPE_SUBS) { RnIapConsole.warn( 'Converting non-subscription product to ProductSubscription:', product.id, ); } const output: any = {...(product as any)}; if (output.platform === PLATFORM_ANDROID) { if (!Array.isArray(output.subscriptionOfferDetailsAndroid)) { output.subscriptionOfferDetailsAndroid = []; } } return output; } /** * Convert NitroPurchase (from native) to generated Purchase type */ export function convertNitroPurchaseToPurchase( nitroPurchase: NitroPurchase, ): Purchase { const platform = normalizePlatform(nitroPurchase.platform); let purchaseState = normalizePurchaseState( nitroPurchase.purchaseState ?? nitroPurchase.purchaseStateAndroid, ); // Fallback for unknown purchase state if ( purchaseState === PURCHASE_STATE_UNKNOWN && nitroPurchase.purchaseStateAndroid != null ) { purchaseState = normalizePurchaseState(nitroPurchase.purchaseStateAndroid); } const store = normalizeStore(nitroPurchase.store); if (platform === PLATFORM_IOS) { const iosPurchase: PurchaseIOS = { id: nitroPurchase.id, productId: nitroPurchase.productId, transactionDate: nitroPurchase.transactionDate ?? Date.now(), purchaseToken: nitroPurchase.purchaseToken ?? null, platform, store, quantity: nitroPurchase.quantity ?? 1, purchaseState, isAutoRenewing: Boolean(nitroPurchase.isAutoRenewing), // PurchaseIOS requires both id and transactionId (they are the same value) transactionId: nitroPurchase.id, quantityIOS: toNullableNumber(nitroPurchase.quantityIOS), originalTransactionDateIOS: toNullableNumber( nitroPurchase.originalTransactionDateIOS, ), originalTransactionIdentifierIOS: toNullableString( nitroPurchase.originalTransactionIdentifierIOS, ), appAccountToken: toNullableString(nitroPurchase.appAccountToken), appBundleIdIOS: toNullableString(nitroPurchase.appBundleIdIOS), countryCodeIOS: toNullableString(nitroPurchase.countryCodeIOS), currencyCodeIOS: toNullableString(nitroPurchase.currencyCodeIOS), currencySymbolIOS: toNullableString(nitroPurchase.currencySymbolIOS), environmentIOS: toNullableString(nitroPurchase.environmentIOS), expirationDateIOS: toNullableNumber(nitroPurchase.expirationDateIOS), isUpgradedIOS: toNullableBoolean(nitroPurchase.isUpgradedIOS), offerIOS: nitroPurchase.offerIOS ? (() => { try { return JSON.parse(nitroPurchase.offerIOS!); } catch { return null; } })() : null, ownershipTypeIOS: toNullableString(nitroPurchase.ownershipTypeIOS), reasonIOS: toNullableString(nitroPurchase.reasonIOS), reasonStringRepresentationIOS: toNullableString( nitroPurchase.reasonStringRepresentationIOS, ), revocationDateIOS: toNullableNumber(nitroPurchase.revocationDateIOS), revocationReasonIOS: toNullableString(nitroPurchase.revocationReasonIOS), storefrontCountryCodeIOS: toNullableString( nitroPurchase.storefrontCountryCodeIOS, ), subscriptionGroupIdIOS: toNullableString( nitroPurchase.subscriptionGroupIdIOS, ), transactionReasonIOS: toNullableString( nitroPurchase.transactionReasonIOS, ), webOrderLineItemIdIOS: toNullableString( nitroPurchase.webOrderLineItemIdIOS, ), renewalInfoIOS: nitroPurchase.renewalInfoIOS ? { autoRenewPreference: toNullableString( nitroPurchase.renewalInfoIOS.autoRenewPreference, ), expirationReason: toNullableString( nitroPurchase.renewalInfoIOS.expirationReason, ), gracePeriodExpirationDate: toNullableNumber( nitroPurchase.renewalInfoIOS.gracePeriodExpirationDate, ), isInBillingRetry: toNullableBoolean( nitroPurchase.renewalInfoIOS.isInBillingRetry, ), jsonRepresentation: toNullableString( nitroPurchase.renewalInfoIOS.jsonRepresentation, ), pendingUpgradeProductId: toNullableString( nitroPurchase.renewalInfoIOS.pendingUpgradeProductId, ), priceIncreaseStatus: toNullableString( nitroPurchase.renewalInfoIOS.priceIncreaseStatus, ), renewalDate: toNullableNumber( nitroPurchase.renewalInfoIOS.renewalDate, ), renewalOfferId: toNullableString( nitroPurchase.renewalInfoIOS.renewalOfferId, ), renewalOfferType: toNullableString( nitroPurchase.renewalInfoIOS.renewalOfferType, ), willAutoRenew: nitroPurchase.renewalInfoIOS.willAutoRenew ?? false, } : null, }; return iosPurchase; } const androidPurchase: PurchaseAndroid = { id: nitroPurchase.id, productId: nitroPurchase.productId, transactionDate: nitroPurchase.transactionDate ?? Date.now(), purchaseToken: nitroPurchase.purchaseToken ?? nitroPurchase.purchaseTokenAndroid ?? null, platform, store, quantity: nitroPurchase.quantity ?? 1, purchaseState, isAutoRenewing: Boolean(nitroPurchase.isAutoRenewing), // PurchaseAndroid has optional transactionId (may differ from id/orderId) transactionId: toNullableString(nitroPurchase.id), autoRenewingAndroid: toNullableBoolean( nitroPurchase.autoRenewingAndroid ?? nitroPurchase.isAutoRenewing, ), dataAndroid: toNullableString(nitroPurchase.dataAndroid), signatureAndroid: toNullableString(nitroPurchase.signatureAndroid), isAcknowledgedAndroid: toNullableBoolean( nitroPurchase.isAcknowledgedAndroid, ), packageNameAndroid: toNullableString(nitroPurchase.packageNameAndroid), obfuscatedAccountIdAndroid: toNullableString( nitroPurchase.obfuscatedAccountIdAndroid, ), obfuscatedProfileIdAndroid: toNullableString( nitroPurchase.obfuscatedProfileIdAndroid, ), developerPayloadAndroid: toNullableString( nitroPurchase.developerPayloadAndroid, ), isSuspendedAndroid: toNullableBoolean(nitroPurchase.isSuspendedAndroid), }; return androidPurchase; } /** * Convert Nitro subscription status (iOS) to generated type */ export function convertNitroSubscriptionStatusToSubscriptionStatusIOS( nitro: NitroSubscriptionStatus, ): SubscriptionStatusIOS { return { state: String(nitro.state ?? ''), renewalInfo: nitro.renewalInfo ? { autoRenewPreference: toNullableString( nitro.renewalInfo.autoRenewPreference, ), jsonRepresentation: JSON.stringify(nitro.renewalInfo), willAutoRenew: Boolean(nitro.renewalInfo.autoRenewStatus), } : undefined, }; } /** * Validate that a NitroProduct has the expected shape */ export function validateNitroProduct(nitroProduct: NitroProduct): boolean { if (!nitroProduct || typeof nitroProduct !== 'object') { return false; } const required = ['id', 'title', 'description', 'type', 'platform']; for (const field of required) { if ( !(field in nitroProduct) || nitroProduct[field as keyof NitroProduct] == null ) { RnIapConsole.error( `NitroProduct missing required field: ${field}`, nitroProduct, ); return false; } } return true; } /** * Validate that a NitroPurchase has the expected shape */ export function validateNitroPurchase(nitroPurchase: NitroPurchase): boolean { if (!nitroPurchase || typeof nitroPurchase !== 'object') { return false; } const required = ['id', 'productId', 'transactionDate', 'platform']; for (const field of required) { if ( !(field in nitroPurchase) || nitroPurchase[field as keyof NitroPurchase] == null ) { RnIapConsole.error( `NitroPurchase missing required field: ${field}`, nitroPurchase, ); return false; } } return true; } /** * Development helper to check that type conversions stay valid */ export function checkTypeSynchronization(): { isSync: boolean; issues: string[]; } { const issues: string[] = []; try { const testNitroProduct: NitroProduct = { id: 'test', title: 'Test', description: 'Test product', type: 'inapp', platform: PLATFORM_IOS, displayPrice: '$1.00', currency: 'USD', price: 1, introductoryPricePaymentModeIOS: PAYMENT_MODE_EMPTY, }; const converted = convertNitroProductToProduct(testNitroProduct); if (!converted.id || !converted.title) { issues.push('Type conversion failed'); } } catch (error) { issues.push(`Type conversion error: ${String(error)}`); } return { isSync: issues.length === 0, issues, }; }