💰 Payments and Subscriptions

Index

  1. RevenueCat Setup
  2. Products and Entitlements
  3. Purchases Context
  4. Paywall UI
  5. Verify Premium Access
  6. Restore Purchases
  7. Webhooks and Backend
  8. 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

  1. Ir a RevenueCat Dashboard
  2. Buscar el usuario por App User ID
  3. Ver historial de transacciones
  4. Verificar entitlements activos

Checklist de Implementación

  • Crear apps en RevenueCat (Test, iOS, Android)
  • Configurar variables de entorno
  • Crear productos y entitlements
  • Configurar offerings
  • Implementar PurchasesContext
  • Crear UI de Paywall
  • Implementar verificación de premium
  • Agregar botón de restaurar compras
  • Configurar webhooks (opcional)
  • Probar en Test Store
  • Probar en sandbox de iOS/Android
  • Configurar productos en App Store Connect / Google Play Console