StoreKit 2 en Swift: In-App Purchases con Product, Transaction y subscripciones

StoreKit 2 es la API de Apple para compras dentro de la app, disponible desde iOS 15. Frente a la versión original, StoreKit 2 usa async/await de forma nativa, elimina la necesidad del SKPaymentQueue y hace que la verificación de recibos sea directa en el cliente sin servidor. El resultado es un código más limpio y una integración más sencilla.

Cargar productos: Product.products

El primer paso es cargar los productos configurados en App Store Connect. Product.products(for:) devuelve solo los identificadores válidos; los que no existen se ignoran:

import StoreKit

class TiendaVM: ObservableObject {
    @Published var productos: [Product] = []
    @Published var cargando = false

    let identificadores = [
        "com.miapp.pro_mensual",
        "com.miapp.pro_anual",
        "com.miapp.pack_creditos_10"
    ]

    func cargarProductos() async {
        cargando = true
        defer { Task { @MainActor in cargando = false } }
        do {
            productos = try await Product.products(for: identificadores)
        } catch {
            print("Error cargando productos: (error)")
        }
    }
}

Cada Product tiene propiedades como displayName, description, displayPrice (precio formateado con la moneda local) y type (consumable, non-consumable, autoRenewable, nonRenewable).

Iniciar compras: PurchaseResult

Para comprar un producto, llama a product.purchase() y maneja los tres estados de resultado:

func comprar(_ producto: Product) async {
    do {
        let resultado = try await producto.purchase()

        switch resultado {
        case .success(let verificacion):
            // Verificar que la transacción es auténtica
            switch verificacion {
            case .verified(let transaccion):
                await activarAcceso(para: transaccion)
                await transaccion.finish()
            case .unverified(_, let error):
                print("Transacción no verificada: (error)")
            }

        case .pending:
            // Compra pendiente de aprobación parental (Ask to Buy)
            print("Compra pendiente de aprobación.")

        case .userCancelled:
            print("El usuario canceló.")

        @unknown default:
            break
        }
    } catch {
        print("Error en compra: (error)")
    }
}

func activarAcceso(para transaccion: Transaction) async {
    switch transaccion.productType {
    case .consumable:
        await agregarCreditos(cantidad: 10)
    case .nonConsumable, .autoRenewable:
        await activarVersionPro()
    default:
        break
    }
}

Verificar acceso: Transaction.currentEntitlements

Para comprobar si el usuario tiene derecho a contenido premium al arrancar la app, usa Transaction.currentEntitlements:

func verificarAccesoAlArrancar() async {
    for await resultado in Transaction.currentEntitlements {
        guard case .verified(let transaccion) = resultado else { continue }

        switch transaccion.productID {
        case "com.miapp.pro_mensual", "com.miapp.pro_anual":
            if transaccion.revocationDate == nil {
                await activarVersionPro()
            }
        default:
            break
        }
    }
}

currentEntitlements itera sobre todas las transacciones activas del usuario: no-consumibles que no han sido reembolsadas y suscripciones activas. Es la forma correcta de comprobar acceso al arrancar la app.

Escuchar renovaciones: Transaction.updates

Para suscripciones que se renuevan automáticamente, es necesario escuchar las actualizaciones de transacciones durante toda la sesión:

class TiendaVM: ObservableObject {
    private var tareaActualizaciones: Task?

    func iniciarEscuchaDeActualizaciones() {
        tareaActualizaciones = Task {
            for await resultado in Transaction.updates {
                if case .verified(let transaccion) = resultado {
                    await activarAcceso(para: transaccion)
                    await transaccion.finish()
                }
            }
        }
    }

    deinit {
        tareaActualizaciones?.cancel()
    }
}

Este listener debe estar activo mientras la app está en primer plano para procesar renovaciones, restauraciones y revocaciones en tiempo real.

Suscripciones: SubscriptionInfo

Para suscripciones de renovación automática, Product.SubscriptionInfo proporciona información sobre el estado actual, el período de prueba y el grupo de suscripción:

if let info = producto.subscription {
    print("Período: (info.subscriptionPeriod.value) (info.subscriptionPeriod.unit)")

    // Obtener el estado actual de la suscripción
    if let estado = try? await info.status.first {
        switch estado.state {
        case .subscribed:
            print("Suscripción activa")
        case .expired:
            print("Suscripción expirada")
        case .inGracePeriod:
            print("En período de gracia por renovación fallida")
        case .inBillingRetryPeriod:
            print("Reintentando cobro")
        case .revoked:
            print("Suscripción revocada")
        default:
            break
        }
    }

    // Período de prueba gratuita
    if let pruebaGratuita = info.introductoryOffer,
       pruebaGratuita.paymentMode == .freeTrial {
        print("Prueba de (pruebaGratuita.period.value) (pruebaGratuita.period.unit)")
    }
}

Restaurar compras

En StoreKit 2, las compras se restauran automáticamente al arrancar la app mediante Transaction.currentEntitlements. El botón de "Restaurar compras" sigue siendo recomendable en la UI por directrices de App Store, pero su implementación es un simple llamado a:

Button("Restaurar compras") {
    Task {
        try? await AppStore.sync()
    }
}

Resumen

StoreKit 2 moderniza las compras en iOS con un API async/await que elimina el antiguo SKPaymentQueue. Product.products(for:) carga el catálogo, product.purchase() inicia la compra y devuelve un PurchaseResult verificado, Transaction.currentEntitlements comprueba el acceso al arrancar y Transaction.updates escucha renovaciones en tiempo real. La verificación de recibos en el cliente con VerificationResult elimina la necesidad de un servidor backend para la mayoría de apps.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP