Compras In-App en iOS con StoreKit 2: Guía de implementación rápida en SwiftUI (Sin RevenueCat)

Durante años, integrar compras dentro de la aplicación (In-App Purchases o IAP) en iOS era una tarea temida por los desarrolladores. El marco original de StoreKit era complejo, dependía en exceso de delegados pesados de Objective-C y requería la validación manual de recibos en un servidor externo para evitar el fraude. Debido a esto, librerías de terceros como RevenueCat se convirtieron en el estándar de la industria para simplificar el proceso.

Sin embargo, con la llegada de StoreKit 2 (disponible desde iOS 15) y las mejoras continuas en SwiftUI, Apple ha cambiado las reglas del juego. Ahora es posible construir un sistema de compras in-app nativo, ultra seguro, moderno y eficiente en unas pocas líneas de código, sin pagar suscripciones a terceros ni añadir dependencias externas a tu proyecto.

En esta guía rápida paso a paso, aprenderás a implementar compras in-app utilizando únicamente StoreKit 2 y SwiftUI.


¿Por qué elegir StoreKit 2 en lugar de SDKs de terceros?

Antes de escribir la primera línea de código, es importante entender por qué StoreKit 2 es suficiente para el 90% de los proyectos independientes y aplicaciones comerciales:

  1. Sintaxis moderna de Swift: Utiliza la potencia de async/await, eliminando el desorden de los patrones de delegados antiguos.
  2. Seguridad integrada: Todas las transacciones están firmadas criptográficamente mediante JSON Web Tokens (JWT). Apple valida los recibos de forma nativa por ti.
  3. Sincronización en tiempo real: Detecta automáticamente si una compra fue reembolsada, si una suscripción renovó o si hubo un problema de pago.
  4. Cero dependencias y costes: Reduces el tamaño del ejecutable (IPA), mejoras la privacidad de los usuarios al no compartir datos con terceros y te ahorras costes por volumen de facturación.

Paso 1: Configurar el entorno de pruebas local (StoreKit Configuration)

Para probar compras in-app no necesitas crear primero los productos en App Store Connect. Xcode nos permite simular todo el entorno de la tienda localmente.

  1. En Xcode, ve a File > New > File…
  2. Busca StoreKit Configuration File y nombralo Products.storekit.
  3. Abre el archivo generado, haz clic en el botón + e introduce tus productos:
    • Non-Consumable (No consumibles): Por ejemplo, «Desbloquear versión Pro» (com.miapp.pro).
    • Auto-Renewable Subscription (Suscripciones): Por ejemplo, «Suscripción Mensual» (com.miapp.sub.mensual).
  4. Asigna un ProductID único a cada elemento.

(Nota: Guarda estos IDs, ya que los utilizaremos en el código).


Paso 2: Crear el gestor de la tienda (StoreManager)

Crearemos un gestor de estado global (ObservableObject o usando la macro @Observable si usas iOS 17+) para administrar la carga de productos y el procesamiento de pagos.

import Foundation
import StoreKit

@MainActor
class StoreManager: ObservableObject {
    @Published private(generated) var products: [Product] = []
    @Published private(generated) var purchasedProductIDs: Set = []

    // Define los Product IDs definidos en Xcode / App Store Connect
    private let productIDs = ["com.miapp.pro", "com.miapp.sub.mensual"]

    private var updatesTask: Task? = nil

    init() {
        // Escuchar transacciones en segundo plano
        updatesTask = listenForTransactions()

        Task {
            await fetchProducts()
            await updatePurchasedProducts()
        }
    }

    deinit {
        updatesTask?.cancel()
    }

    // 1. Obtener productos desde los servidores de Apple
    func fetchProducts() async {
        do {
            self.products = try await Product.products(for: productIDs)
        } catch {
            print("Error al cargar productos: \(error)")
        }
    }

    // 2. Comprar un producto
    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            // Verificar la autenticidad de la transacción
            let transaction = try checkVerified(verification)

            // Actualizar el estado de la app
            await updatePurchasedProducts()

            // Finalizar la transacción (muy importante)
            await transaction.finish()

        case .userCancelled:
            print("El usuario canceló la compra.")
        case .pending:
            print("La compra está pendiente de aprobación (ej. control parental).")
        @unknown default:
            break
        }
    }

    // 3. Verificar la validez de las transacciones (Cryptographic JWT)
    func checkVerified(_ result: VerificationResult) throws -> T {
        switch result {
        case .unverified:
            throw StoreError.failedVerification
        case .verified(let safe):
            return safe
        }
    }

    // 4. Comprobar qué productos ya posee el usuario
    func updatePurchasedProducts() async {
        var purchasedIDs: Set = []

        for await result in Transaction.currentEntitlements {
            do {
                let transaction = try checkVerified(result)
                purchasedIDs.insert(transaction.productID)
            } catch {
                print("Transacción no verificada")
            }
        }

        self.purchasedProductIDs = purchasedIDs
    }

    // 5. Escuchador en tiempo real para compras fuera de la app o renovaciones
    private func listenForTransactions() -> Task {
        Task.detached {
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerified(result)
                    await self.updatePurchasedProducts()
                    await transaction.finish()
                } catch {
                    print("Error en actualización de transacción")
                }
            }
        }
    }
}

enum StoreError: Error {
    case failedVerification
}

Paso 3: Diseñar la vista de Paywall con SwiftUI

Con la lógica del negocio estructurada en nuestro StoreManager, construir una interfaz de usuario descriptiva y moderna en SwiftUI es cuestión de un par de minutos.

import SwiftUI
import StoreKit

struct PaywallView: View {
    @StateObject private var storeManager = StoreManager()

    var body: some View {
        VStack(spacing: 20) {
            Text("Hazte Premium")
                .font(.largeTitle)
                .bold()

            Text("Desbloquea todas las funciones ilimitadas.")
                .font(.subheadline)
                .foregroundColor(.secondary)

            List(storeManager.products) { product in
                HStack {
                    VStack(alignment: .leading) {
                        Text(product.displayName)
                            .font(.headline)
                        Text(product.description)
                            .font(.caption)
                            .foregroundColor(.gray)
                    }

                    Spacer()

                    if storeManager.purchasedProductIDs.contains(product.id) {
                        Image(systemName: "checkmark.circle.fill")
                            .foregroundColor(.green)
                    } else {
                        Button(action: {
                            Task {
                                try? await storeManager.purchase(product)
                            }
                        }) {
                            Text(product.displayPrice)
                                .bold()
                                .padding(.horizontal, 10)
                                .padding(.vertical, 5)
                                .background(Color.blue)
                                .foregroundColor(.white)
                                .cornerRadius(8)
                        }
                    }
                }
            }

            Button("Restaurar Compras") {
                Task {
                    // En StoreKit 2 basta con sincronizar con la App Store
                    try? await AppStore.sync()
                }
            }
            .font(.footnote)
            .padding(.top)
        }
        .padding()
    }
}

La magia de iOS 17+: Componentes automáticos de StoreKit

Si tu aplicación apunta a iOS 17 o superior, la cantidad de código necesaria se reduce drásticamente gracias a las vistas nativas StoreView y ProductView.

Puedes renderizar una tienda completa simplemente haciendo esto:

import SwiftUI
import StoreKit

struct QuickStoreView: View {
    let productIDs = ["com.miapp.pro", "com.miapp.sub.mensual"]

    var body: some View {
        StoreView(ids: productIDs)
            .productViewStyle(.compact)
            .onInAppPurchaseCompletion { product, result in
                if case .success(.verified(let transaction)) = result {
                    print("Compra exitosa de \(transaction.productID)")
                    await transaction.finish()
                }
            }
    }
}

Apple ahora provee la UI estándar, el diseño responsivo, la internacionalización de precios y el manejo del proceso de pago directamente.


Manejo de Restauración de Compras y Reembolsos

Un requisito obligatorio de las guías de revisión del App Store es ofrecer un mecanismo para Restaurar Compras.

Con StoreKit 2, la llamada AppStore.sync() fuerza la re-sincronización del historial del usuario con los servidores de Apple. Además, el método Transaction.currentEntitlements siempre devuelve las compras activas del usuario (suscripciones en curso o productos no consumibles), eliminando el clásico botón de restauración defectuoso que causaba rechazos en la revisión.

Para gestionar reembolsos directamente dentro de la app sin enviar al usuario a un navegador externo:

func requestRefund(for productID: String, in scene: UIWindowScene) async {
    guard let status = try? await Transaction.latest(for: productID) else { return }
    if case .verified(let transaction) = status {
        do {
            let refundStatus = try await transaction.beginRefundRequest(in: scene)
            switch refundStatus {
            case .userCancelled:
                print("El usuario canceló la solicitud.")
            case .success:
                print("Solicitud de reembolso enviada con éxito.")
            @unknown default:
                break
            }
        } catch {
            print("Error al solicitar el reembolso: \(error)")
        }
    }
}

Conclusión

El ecosistema de desarrollo para iOS ha evolucionado drásticamente. Lo que antes requería servicios de terceros para evitar semanas de dolor de cabeza, hoy en día con StoreKit 2 y SwiftUI se resuelve con un código nativo, limpio y totalmente bajo tu control.

Al prescindir de soluciones intermedias como RevenueCat:

  • Mantienes el control absolute sobre los datos de tus usuarios.
  • No pagas comisiones adicionales a plataformas externas.
  • Eliminas posibles puntos únicos de fallo (SPOF) en tu app.

Implementar tu propio sistema de monetización nativo en iOS ya no es complicado; es rápido, robusto y está preparado para el futuro de Swift. ¡Es hora de subir tu paywall a producción!

Deja una respuesta