💰 Payments and Subscriptions
Index
RevenueCat Setup
Products and Entitlements
Purchases Context
Paywall UI
Verify Premium Access
Restore Purchases
Webhooks and Backend
Testing
RevenueCat Setup
Installation
npx expo install react-native-purchases
Environment Variables
# Test Store (desarrollo y web)
EXPO_PUBLIC_REVENUECAT_TEST_API_KEY=rcb_xxxxx
# Producción iOS
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=appl_xxxxx
# Producción Android
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY=goog_xxxxx
Initialization
// lib/purchases.ts
import Purchases, { LOG_LEVEL } from 'react-native-purchases';
import { Platform } from 'react-native';
function getAPIKey(): string {
if (__DEV__ || Platform.OS === 'web') {
return process.env.EXPO_PUBLIC_REVENUECAT_TEST_API_KEY!;
}
return Platform.select({
ios: process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY!,
android: process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY!,
default: process.env.EXPO_PUBLIC_REVENUECAT_TEST_API_KEY!,
});
}
export function initPurchases() {
const apiKey = getAPIKey();
if (__DEV__) {
Purchases.setLogLevel(LOG_LEVEL.DEBUG);
}
Purchases.configure({ apiKey });
}
// Llamar en el top-level de tu app (NO en useEffect)
initPurchases();
Productos y Entitlements
Estructura Recomendada
Entitlements:
├── premium → Acceso completo
└── pro → Funciones adicionales
Products:
├── premium_monthly → $9.99/mes → premium
├── premium_yearly → $79.99/año → premium
└── pro_lifetime → $199.99 → pro
Offerings:
└── default
├── monthly_package → premium_monthly
└── annual_package → premium_yearly
Obtener Offerings
import Purchases from 'react-native-purchases';
export async function getOfferings() {
try {
const offerings = await Purchases.getOfferings();
if (offerings.current) {
return {
packages: offerings.current.availablePackages,
monthly: offerings.current.monthly,
annual: offerings.current.annual,
lifetime: offerings.current.lifetime,
};
}
return null;
} catch (error) {
console.error('Error fetching offerings:', error);
throw error;
}
}
Contexto de Compras
// contexts/PurchasesContext.tsx
import createContextHook from '@nkzw/create-context-hook';
import { useEffect, useState, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Platform } from 'react-native';
import Purchases, {
CustomerInfo,
PurchasesOfferings,
PurchasesPackage,
LOG_LEVEL,
} from 'react-native-purchases';
function getAPIKey(): string {
if (__DEV__ || Platform.OS === 'web') {
return process.env.EXPO_PUBLIC_REVENUECAT_TEST_API_KEY || '';
}
return Platform.select({
ios: process.env.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY || '',
android: process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY || '',
default: process.env.EXPO_PUBLIC_REVENUECAT_TEST_API_KEY || '',
});
}
const apiKey = getAPIKey();
if (apiKey) {
if (__DEV__) {
Purchases.setLogLevel(LOG_LEVEL.DEBUG);
}
Purchases.configure({ apiKey });
}
export const [PurchasesProvider, usePurchases] = createContextHook(() => {
const queryClient = useQueryClient();
const [isConfigured, setIsConfigured] = useState(!!apiKey);
const customerInfoQuery = useQuery({
queryKey: ['customerInfo'],
queryFn: async (): Promise<CustomerInfo> => {
return Purchases.getCustomerInfo();
},
enabled: isConfigured,
staleTime: 1000 * 60 * 5,
});
const offeringsQuery = useQuery({
queryKey: ['offerings'],
queryFn: async (): Promise<PurchasesOfferings> => {
return Purchases.getOfferings();
},
enabled: isConfigured,
staleTime: 1000 * 60 * 60,
});
useEffect(() => {
if (!isConfigured) return;
const listener = Purchases.addCustomerInfoUpdateListener((info) => {
queryClient.setQueryData(['customerInfo'], info);
});
return () => listener.remove();
}, [isConfigured, queryClient]);
const purchaseMutation = useMutation({
mutationFn: async (pkg: PurchasesPackage) => {
const { customerInfo } = await Purchases.purchasePackage(pkg);
return customerInfo;
},
onSuccess: (customerInfo) => {
queryClient.setQueryData(['customerInfo'], customerInfo);
},
});
const restoreMutation = useMutation({
mutationFn: async () => {
const customerInfo = await Purchases.restorePurchases();
return customerInfo;
},
onSuccess: (customerInfo) => {
queryClient.setQueryData(['customerInfo'], customerInfo);
},
});
const customerInfo = customerInfoQuery.data;
const offerings = offeringsQuery.data;
const isPremium = useCallback(
(entitlement = 'premium') => {
return customerInfo?.entitlements.active[entitlement]?.isActive ?? false;
},
[customerInfo]
);
const hasEntitlement = useCallback(
(entitlement: string) => {
return customerInfo?.entitlements.active[entitlement]?.isActive ?? false;
},
[customerInfo]
);
const currentPackages = offerings?.current?.availablePackages ?? [];
const monthlyPackage = offerings?.current?.monthly ?? null;
const annualPackage = offerings?.current?.annual ?? null;
const lifetimePackage = offerings?.current?.lifetime ?? null;
const loginUser = useCallback(async (userId: string) => {
await Purchases.logIn(userId);
queryClient.invalidateQueries({ queryKey: ['customerInfo'] });
}, [queryClient]);
const logoutUser = useCallback(async () => {
await Purchases.logOut();
queryClient.invalidateQueries({ queryKey: ['customerInfo'] });
}, [queryClient]);
return {
isConfigured,
isLoading: customerInfoQuery.isLoading || offeringsQuery.isLoading,
customerInfo,
offerings,
isPremium,
hasEntitlement,
currentPackages,
monthlyPackage,
annualPackage,
lifetimePackage,
purchase: purchaseMutation.mutateAsync,
isPurchasing: purchaseMutation.isPending,
purchaseError: purchaseMutation.error,
restore: restoreMutation.mutateAsync,
isRestoring: restoreMutation.isPending,
restoreError: restoreMutation.error,
loginUser,
logoutUser,
};
});
export function usePremiumGuard() {
const { isPremium, isLoading } = usePurchases();
return {
isPremium: isPremium(),
isLoading,
requiresPremium: !isLoading && !isPremium(),
};
}
Paywall UI
// components/Paywall.tsx
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
import { Check, Crown, Sparkles } from 'lucide-react-native';
import { PurchasesPackage } from 'react-native-purchases';
import { Button } from '@/components/Button';
import { useTheme } from '@/contexts/ThemeContext';
import { usePurchases } from '@/contexts/PurchasesContext';
import { useToast } from '@/contexts/ToastContext';
const FEATURES = [
'Acceso ilimitado a todo el contenido',
'Sin anuncios',
'Sincronización en la nube',
'Soporte prioritario',
'Nuevas funciones primero',
];
interface Props {
onSuccess?: () => void;
onClose?: () => void;
}
export function Paywall({ onSuccess, onClose }: Props) {
const { colors } = useTheme();
const { showToast } = useToast();
const {
currentPackages,
monthlyPackage,
annualPackage,
purchase,
restore,
isPurchasing,
isRestoring,
isLoading,
} = usePurchases();
const [selectedPackage, setSelectedPackage] = useState<PurchasesPackage | null>(
annualPackage || monthlyPackage
);
const handlePurchase = async () => {
if (!selectedPackage) return;
try {
await purchase(selectedPackage);
showToast('¡Compra exitosa!', 'success');
onSuccess?.();
} catch (error: any) {
if (error.userCancelled) return;
showToast(error.message || 'Error en la compra', 'error');
}
};
const handleRestore = async () => {
try {
const info = await restore();
const hasPremium = info.entitlements.active['premium']?.isActive;
if (hasPremium) {
showToast('¡Compras restauradas!', 'success');
onSuccess?.();
} else {
showToast('No se encontraron compras previas', 'info');
}
} catch (error: any) {
showToast(error.message || 'Error restaurando', 'error');
}
};
const formatPrice = (pkg: PurchasesPackage) => {
return pkg.product.priceString;
};
const formatPeriod = (pkg: PurchasesPackage) => {
const type = pkg.packageType;
switch (type) {
case 'MONTHLY': return '/mes';
case 'ANNUAL': return '/año';
case 'LIFETIME': return ' único';
default: return '';
}
};
const calculateSavings = () => {
if (!monthlyPackage || !annualPackage) return null;
const monthlyPrice = monthlyPackage.product.price;
const annualPrice = annualPackage.product.price;
const yearlyIfMonthly = monthlyPrice * 12;
const savings = Math.round(((yearlyIfMonthly - annualPrice) / yearlyIfMonthly) * 100);
return savings > 0 ? savings : null;
};
const savings = calculateSavings();
if (isLoading) {
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<Text style={{ color: colors.text }}>Cargando...</Text>
</View>
);
}
return (
<ScrollView
style={[styles.container, { backgroundColor: colors.background }]}
contentContainerStyle={styles.content}
>
<View style={styles.header}>
<View style={[styles.iconContainer, { backgroundColor: colors.primaryLight }]}>
<Crown size={48} color={colors.primary} />
</View>
<Text style={[styles.title, { color: colors.text }]}>
Desbloquea Premium
</Text>
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
Obtén acceso completo a todas las funciones
</Text>
</View>
<View style={styles.features}>
{FEATURES.map((feature, index) => (
<View key={index} style={styles.featureRow}>
<View style={[styles.checkIcon, { backgroundColor: colors.successLight }]}>
<Check size={16} color={colors.success} />
</View>
<Text style={[styles.featureText, { color: colors.text }]}>
{feature}
</Text>
</View>
))}
</View>
<View style={styles.packages}>
{currentPackages.map((pkg) => {
const isSelected = selectedPackage?.identifier === pkg.identifier;
const isAnnual = pkg.packageType === 'ANNUAL';
return (
<Pressable
key={pkg.identifier}
onPress={() => setSelectedPackage(pkg)}
style={[
styles.packageCard,
{
backgroundColor: isSelected ? colors.primaryLight : colors.surface,
borderColor: isSelected ? colors.primary : colors.border,
},
]}
>
{isAnnual && savings && (
<View style={[styles.badge, { backgroundColor: colors.success }]}>
<Text style={styles.badgeText}>Ahorra {savings}%</Text>
</View>
)}
<View style={styles.packageInfo}>
<Text style={[styles.packageTitle, { color: colors.text }]}>
{pkg.product.title}
</Text>
<Text style={[styles.packagePrice, { color: colors.primary }]}>
{formatPrice(pkg)}{formatPeriod(pkg)}
</Text>
</View>
<View
style={[
styles.radio,
{
borderColor: isSelected ? colors.primary : colors.border,
backgroundColor: isSelected ? colors.primary : 'transparent',
},
]}
>
{isSelected && <View style={styles.radioInner} />}
</View>
</Pressable>
);
})}
</View>
<View style={styles.actions}>
<Button
title="Continuar"
onPress={handlePurchase}
loading={isPurchasing}
disabled={!selectedPackage}
icon={<Sparkles size={20} color="#fff" />}
/>
<Button
title="Restaurar compras"
variant="ghost"
onPress={handleRestore}
loading={isRestoring}
/>
</View>
<Text style={[styles.terms, { color: colors.textTertiary }]}>
Al continuar, aceptas los Términos de Servicio y Política de Privacidad.
La suscripción se renueva automáticamente a menos que se cancele.
</Text>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
padding: 24,
},
header: {
alignItems: 'center',
marginBottom: 32,
},
iconContainer: {
width: 96,
height: 96,
borderRadius: 48,
alignItems: 'center',
justifyContent: 'center',
marginBottom: 16,
},
title: {
fontSize: 28,
fontWeight: '700' as const,
marginBottom: 8,
},
subtitle: {
fontSize: 16,
textAlign: 'center',
},
features: {
marginBottom: 32,
},
featureRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
checkIcon: {
width: 28,
height: 28,
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
featureText: {
fontSize: 16,
flex: 1,
},
packages: {
gap: 12,
marginBottom: 24,
},
packageCard: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderRadius: 12,
borderWidth: 2,
position: 'relative',
},
badge: {
position: 'absolute',
top: -10,
right: 16,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
},
badgeText: {
color: '#fff',
fontSize: 12,
fontWeight: '600' as const,
},
packageInfo: {
flex: 1,
},
packageTitle: {
fontSize: 16,
fontWeight: '600' as const,
marginBottom: 4,
},
packagePrice: {
fontSize: 18,
fontWeight: '700' as const,
},
radio: {
width: 24,
height: 24,
borderRadius: 12,
borderWidth: 2,
alignItems: 'center',
justifyContent: 'center',
},
radioInner: {
width: 12,
height: 12,
borderRadius: 6,
backgroundColor: '#fff',
},
actions: {
gap: 12,
marginBottom: 16,
},
terms: {
fontSize: 12,
textAlign: 'center',
lineHeight: 18,
},
});
Verificar Acceso Premium
En Componentes
function PremiumFeature() {
const { isPremium, isLoading } = usePurchases();
const router = useRouter();
if (isLoading) return <Loading />;
if (!isPremium()) {
return (
<View style={styles.locked}>
<Text>Esta función requiere Premium</Text>
<Button
title="Ver planes"
onPress={() => router.push('/paywall')}
/>
</View>
);
}
return <ActualFeatureContent />;
}
Hook Guard
// hooks/usePremiumFeature.ts
import { useCallback } from 'react';
import { useRouter } from 'expo-router';
import { usePurchases } from '@/contexts/PurchasesContext';
export function usePremiumFeature() {
const { isPremium, isLoading } = usePurchases();
const router = useRouter();
const requirePremium = useCallback(
(callback: () => void) => {
if (isPremium()) {
callback();
} else {
router.push('/paywall');
}
},
[isPremium, router]
);
return {
isPremium: isPremium(),
isLoading,
requirePremium,
};
}
// Uso
function MyComponent() {
const { requirePremium } = usePremiumFeature();
const handleExport = () => {
requirePremium(() => {
// Lógica de exportación premium
exportData();
});
};
return <Button title="Exportar" onPress={handleExport} />;
}
Restaurar Compras
function RestorePurchasesButton() {
const { restore, isRestoring } = usePurchases();
const { showToast } = useToast();
const handleRestore = async () => {
try {
const info = await restore();
if (Object.keys(info.entitlements.active).length > 0) {
showToast('Compras restauradas exitosamente', 'success');
} else {
showToast('No se encontraron compras previas', 'info');
}
} catch (error: any) {
showToast(error.message || 'Error al restaurar', 'error');
}
};
return (
<Button
title="Restaurar compras"
variant="outline"
onPress={handleRestore}
loading={isRestoring}
/>
);
}
Webhooks y Backend
Configurar Webhook en RevenueCat
// backend/trpc/webhooks/revenuecat/route.ts
import { z } from 'zod';
import { publicProcedure } from '../../trpc';
const webhookSchema = z.object({
api_version: z.string(),
event: z.object({
type: z.string(),
app_user_id: z.string(),
product_id: z.string().optional(),
entitlement_ids: z.array(z.string()).optional(),
}),
});
export const revenuecatWebhookProcedure = publicProcedure
.input(webhookSchema)
.mutation(async ({ input, ctx }) => {
const { type, app_user_id, product_id, entitlement_ids } = input.event;
console.log('RevenueCat webhook:', type, app_user_id);
switch (type) {
case 'INITIAL_PURCHASE':
case 'RENEWAL':
await ctx.db.user.update({
where: { id: app_user_id },
data: {
isPremium: true,
premiumSince: new Date(),
currentPlan: product_id,
},
});
break;
case 'CANCELLATION':
case 'EXPIRATION':
await ctx.db.user.update({
where: { id: app_user_id },
data: {
isPremium: false,
premiumExpired: new Date(),
},
});
break;
case 'BILLING_ISSUE':
// Enviar email de aviso
await sendBillingIssueEmail(app_user_id);
break;
}
return { received: true };
});
Testing
Test Store (Desarrollo)
// El Test Store de RevenueCat permite probar sin configurar stores reales
// Usa EXPO_PUBLIC_REVENUECAT_TEST_API_KEY
// Los productos del Test Store se crean en el dashboard de RevenueCat
// y funcionan en web y en desarrollo
Simular Compras
// Para testing manual
async function simulatePurchase() {
if (!__DEV__) return;
// Usar un producto del Test Store
const offerings = await Purchases.getOfferings();
const testPackage = offerings.current?.monthly;
if (testPackage) {
// La compra en Test Store no cobra dinero real
await Purchases.purchasePackage(testPackage);
}
}
Verificar en Dashboard
Ir a RevenueCat Dashboard
Buscar el usuario por App User ID
Ver historial de transacciones
Verificar entitlements activos
Checklist de Implementación