wandres.dev
MONETIZACIÓN · StoreKit 2

StoreKit 2: cargar productos, comprar y por qué desapareció la cola

La API original de compras convertía una operación conceptualmente sencilla en una máquina de estados global, asíncrona por delegación y llena de casos imposibles de reproducir. `StoreKit 2` la reescribió sobre concurrencia estructurada y tipos con valor, y al hacerlo eliminó categorías enteras de error en lugar de facilitarlas. Esta lección recorre el ciclo completo —cargar el catálogo, comprar, cerrar la transacción y escuchar las que llegan solas— y explica qué se ganó exactamente en el cambio.

⏱ 20 min

Hay reescrituras de API que solo cambian la sintaxis y hay reescrituras que cambian lo que es posible equivocarse. StoreKit 2 pertenece a la segunda clase, y entender por qué exige mirar qué era la API anterior: una cola global de pagos a la que se añadían operaciones, un observador único registrado en el arranque que recibía notificaciones de todo lo que ocurría en el proceso, y una correspondencia implícita entre lo que pediste y lo que te llegó que tenías que reconstruir tú comparando identificadores. Ese diseño no era gratuito —modelaba fielmente que una compra puede completarse cuando tu app ya no está en pantalla— pero pagaba el precio de convertir cualquier flujo lineal en un rompecabezas distribuido. La versión moderna conserva la parte irreductible de esa complejidad, la que viene del mundo, y elimina la accidental, la que venía del modelo de programación.

🎯 Al terminar esta lección sabrás
  • Cargar el catálogo con Product.products y modelar correctamente el fallo y la ausencia.
  • Ejecutar una compra con purchase e interpretar los tres resultados posibles sin colapsarlos.
  • Entender qué significa finish y por qué llamarlo antes de tiempo pierde dinero de verdad.
  • Escuchar Transaction.updates desde el arranque y explicar por qué esa escucha no es opcional.

El primer acto de cualquier integración es preguntar a la App Store por los productos, y conviene subrayar una asimetría que sorprende a quien viene de otras plataformas: los identificadores los defines tú en App Store Connect, pero los precios, los nombres, las descripciones y las divisas los define la tienda para cada región. La consecuencia práctica es que nunca se muestra un precio escrito en el código: se muestra el que devuelve la tienda, ya formateado, porque solo la tienda conoce la moneda, la fiscalidad local y las promociones vigentes.

import StoreKit

@MainActor
final class Tienda: ObservableObject {
    @Published private(set) var productos: [Product] = []

    private let identificadores = [
        "pro.mensual", "pro.anual", "creditos.100"
    ]

    func cargar() async {
        do {
            let encontrados = try await Product.products(for: identificadores)
            productos = encontrados.sorted { $0.price < $1.price }
        } catch {
            // Sin red o sin tienda disponible: no es un error del usuario
            productos = []
        }
    }
}

Tres detalles de ese fragmento tienen más peso del que aparentan. El primero es que la llamada devuelve solo los productos que la tienda reconoce: si escribiste mal un identificador o el producto todavía no está aprobado, no obtienes un error, obtienes una lista más corta. Comparar el número de identificadores pedidos con el de productos recibidos y registrar la diferencia es la forma más barata de detectar un despliegue mal configurado antes de que llegue a producción. El segundo es que el error que sí se lanza es casi siempre transitorio —red ausente, tienda temporalmente inaccesible— y por tanto merece un reintento y un mensaje neutro, no una pantalla de fallo grave. El tercero es que Product es una estructura con valor: se puede guardar, ordenar y pasar entre capas sin ceremonia, algo que el objeto equivalente de la API antigua, con su semántica de referencia y su ciclo de vida atado a una petición, no permitía con la misma tranquilidad.

💡
El precio ya viene formateado y el formateo es un campo minado

product.displayPrice devuelve la cadena lista para mostrar, con el símbolo, la posición y los separadores correctos para la región del comprador. Si construyes tú la cadena a partir de product.price, que es un Decimal, tarde o temprano mostrarás una coma donde iba un punto o un símbolo pospuesto donde iba antepuesto. Para el cálculo de precio mensual equivalente de un plan anual sí necesitas el Decimal, pero el resultado se vuelve a formatear con la divisa que la propia tienda expone.

Comprar y cerrar

La compra es una única llamada asíncrona que devuelve un Product.PurchaseResult con tres casos, y el error más frecuente de las integraciones apresuradas consiste en tratar dos de ellos como si fueran el mismo. El caso de éxito envuelve la transacción en un VerificationResult, que la lección siguiente desmenuza. El caso de cancelación significa que el usuario cerró la hoja de pago: no es un error, no debe producir ninguna alerta y debe dejar la interfaz exactamente como estaba. Y el caso pendiente significa que la compra ha quedado a la espera de una autorización externa —el permiso de un progenitor en una cuenta familiar, o un método de pago que exige confirmación bancaria— y que el resultado llegará más tarde por otro canal.

enum ResultadoCompra { case exito, cancelada, pendiente }

func comprar(_ producto: Product) async throws -> ResultadoCompra {
    let resultado = try await producto.purchase(options: [
        .appAccountToken(identificadorAnonimoDeCuenta)
    ])

    switch resultado {
    case .success(let verificacion):
        let transaccion = try verificacion.payloadValue
        await acreditar(transaccion)        // primero se entrega el valor
        await transaccion.finish()          // y solo entonces se cierra
        return .exito
    case .userCancelled:
        return .cancelada
    case .pending:
        return .pendiente                   // llegara por Transaction.updates
    @unknown default:
        return .pendiente
    }
}

El orden de las dos últimas operaciones no es estilístico, es contable. Llamar a finish le comunica a la App Store que has entregado lo comprado y que puede olvidar la transacción; si lo haces antes de haber persistido el derecho y la app muere en ese instante, el usuario ha pagado y no tiene nada, y ya no existe ningún mecanismo que te recuerde la deuda. La regla es absoluta y no admite optimizaciones: entregar, persistir de forma duradera y solo después cerrar. El caso simétrico también importa: si no llamas nunca a finish, la transacción reaparecerá en cada arranque y tu app intentará acreditarla una y otra vez, razón por la cual la acreditación debe ser idempotente respecto del identificador de la transacción.

Hay un detalle de aislamiento que conviene subrayar porque en Swift moderno decide si el código compila o si se comporta. purchase presenta una hoja del sistema y por tanto debe invocarse desde el actor principal; la acreditación posterior, en cambio, suele tocar almacenamiento o red y conviene que no lo haga. La forma limpia de expresar esa frontera es un tipo aislado al actor principal para la interfaz de tienda y un servicio independiente para la entrega del valor, comunicados por valores inmutables. Meter ambas responsabilidades en la misma clase produce el clásico enredo de anotaciones que acaba resolviéndose a golpe de saltos de contexto hasta que nadie sabe en qué hilo ocurre nada.

El parámetro appAccountToken merece un comentario porque resuelve un problema que la API antigua obligaba a resolver con inventiva. Es un identificador opaco que tú generas para la cuenta interna del usuario y que viaja con la transacción hasta las notificaciones de servidor, permitiendo asociar un pago con un usuario de tu sistema sin transmitir nunca datos personales a Apple. En apps con cuenta propia es lo que convierte el flujo en trazable de extremo a extremo.

sequenceDiagram
participant App
participant StoreKit
participant AppStore
App->>StoreKit: purchase del producto
StoreKit->>AppStore: hoja de pago y autenticacion
AppStore-->>StoreKit: transaccion firmada
StoreKit-->>App: success con VerificationResult
App->>App: entregar valor y persistir
App->>StoreKit: finish de la transaccion
AppStore-->>App: Transaction updates para lo que llega solo

Lo que llega sin que preguntes

Aquí está la parte que casi todas las integraciones incompletas omiten y que explica la mayoría de las incidencias de soporte de una app recién monetizada: no todas las transacciones nacen de un botón tuyo. Una renovación de suscripción ocurre mientras la app está cerrada. Una compra pendiente se aprueba horas después. Una compra iniciada desde la ficha de la App Store llega sin que tu interfaz haya intervenido. Un reembolso revoca un derecho concedido. Todos esos eventos entran por Transaction.updates, una secuencia asíncrona infinita que hay que empezar a consumir antes de que la interfaz esté lista, porque lo que ocurrió estando la app cerrada se entrega en cuanto hay alguien escuchando.

func escucharTransacciones() -> Task<Void, Never> {
    Task.detached {
        for await verificacion in Transaction.updates {
            guard let transaccion = try? verificacion.payloadValue else { continue }
            await self.acreditar(transaccion)
            await transaccion.finish()
        }
    }
}

Esa tarea se lanza en la inicialización del contenedor de dependencias, no en el onAppear de la vista de la tienda, y no se cancela mientras la app viva. La diferencia entre ambos emplazamientos es exactamente la diferencia entre una app que acredita las renovaciones y una que las pierde hasta que el usuario visita, por casualidad, la pantalla de compras. Merece la pena inventariar qué eventos entran por ese canal, porque la lista es más larga de lo que casi nadie supone y cada omisión tiene un coste identificable.

🔁

Renovaciones

Cada ciclo de una suscripción produce una transacción nueva mientras la app está cerrada. Sin escucha, el derecho parece caducar hasta que alguien abre la tienda.

Compras diferidas

Lo que quedó pendiente de aprobación llega horas después. El usuario ya no está donde lo dejaste, así que la entrega debe funcionar sin contexto de interfaz.

🏬

Compra desde la ficha

Un producto promocionado en la App Store puede comprarse fuera de tu interfaz. La transacción aparece sin que tu botón haya intervenido nunca.

↩️

Reembolsos y revocaciones

Retiran un derecho ya concedido. Si no escuchas, tu app seguirá dando acceso a algo cuyo importe se devolvió hace semanas.

Desde iOS 17 existe además una capa de vistas que resuelve buena parte de este trabajo con componentes del sistema: ProductView para un producto suelto, StoreView para un catálogo y SubscriptionStoreView para un grupo completo de suscripciones, con la elegibilidad, los precios localizados, los enlaces legales obligatorios y el botón de restaurar ya resueltos. No sustituyen a la capa de derechos —esa sigue siendo tuya— pero eliminan la parte del código de tienda que más se rompe al cambiar de región o de dispositivo.

struct PantallaDeCompra: View {
    var body: some View {
        SubscriptionStoreView(groupID: "21473986")
            .subscriptionStoreControlStyle(.prominentPicker)
            .storeButton(.visible, for: .restorePurchases)
            .onInAppPurchaseCompletion { _, resultado in
                if case .success = resultado { await derechos.recalcular() }
            }
    }
}

La decisión entre usar esas vistas o construir la pantalla a mano no es de gusto sino de control: la versión del sistema gana en corrección, localización y mantenimiento, y pierde en libertad visual y en capacidad de medir cada paso del embudo. Para la mayoría de las apps, empezar con la vista del sistema y sustituirla solo cuando exista una hipótesis concreta que probar es la secuencia que produce menos trabajo desperdiciado.

Conviene ahora poner nombre a lo que se ganó frente a la API anterior, porque no es solamente ergonomía. Desapareció el observador global de la cola de pagos y con él la clase de errores derivada de registrarlo tarde o de tener dos. Desapareció la necesidad de correlacionar manualmente petición y respuesta, porque la compra devuelve su propio resultado en el mismo punto del programa donde se pidió. Desapareció el recibo binario opaco que había que enviar a un servidor para descifrarlo, sustituido por transacciones firmadas que el dispositivo verifica solo. Y desapareció la ambigüedad entre error y cancelación, que en la API antigua compartían el mismo camino y obligaban a inspeccionar un código numérico. Lo que no desapareció, y no podía desaparecer, es que una compra es un proceso distribuido con actores fuera de tu control: por eso Transaction.updates sigue existiendo y por eso sigue siendo obligatorio.

La concurrencia estructurada no es azúcar sintáctico: cambia qué errores son representables

La comparación entre las dos generaciones de StoreKit es uno de los mejores casos de estudio disponibles sobre lo que aporta la concurrencia estructurada, y merece analizarse con calma porque la lección se generaliza mucho más allá de las compras. La API antigua modelaba el problema con una cola global y un observador: cualquier resultado podía llegar en cualquier momento, a un objeto compartido, sin relación sintáctica con el lugar donde se originó la petición. Ese modelo tiene una propiedad matemática desagradable: el conjunto de estados alcanzables del programa crece de forma combinatoria con el número de operaciones en vuelo, porque nada en el lenguaje impide que la respuesta de la compra A se procese en un contexto que asumía la compra B. El programador compensaba esa deficiencia con disciplina —diccionarios de peticiones pendientes, banderas, comprobaciones defensivas— y la disciplina falla justo en los casos raros, que en compras son precisamente los caros. La versión moderna hace algo más profundo que sustituir bloques por await: restablece la localidad. El resultado de una compra vuelve al punto léxico donde se pidió, con su tipo, dentro del mismo alcance donde viven las variables que le dan sentido, y el compilador puede razonar sobre ese flujo. Lo que era una máquina de estados implícita repartida por el archivo pasa a ser una función legible de arriba abajo. Y lo verdaderamente elegante es que la parte del problema que sigue siendo genuinamente asíncrona —las renovaciones, los reembolsos, las aprobaciones diferidas— no se disfraza de síncrona: se le da su propia forma explícita, una secuencia infinita que consumes en un bucle, lo cual comunica con exactitud lo que ocurre. La enseñanza es esta: una buena API no oculta la complejidad esencial del dominio, separa la esencial de la accidental y le da a cada una la forma que le corresponde.

📝
Lo esencial

Product.products carga el catálogo y devuelve solo lo que la tienda reconoce; los precios se muestran con displayPrice y nunca se escriben en el código. purchase devuelve tres casos que no deben colapsarse: éxito, cancelación —que no es un error— y pendiente. Se entrega el valor, se persiste y solo entonces se llama a finish. Y Transaction.updates se escucha desde el arranque del proceso porque renovaciones, reembolsos y aprobaciones diferidas llegan por ahí, no por tus botones.

⚔️ Un ciclo de compra completo y honesto
  1. Carga tu catálogo y registra explícitamente qué identificadores pediste y no llegaron; provoca el fallo escribiendo uno mal a propósito.
  2. Implementa la compra distinguiendo los tres resultados y comprueba que cancelar no produce ninguna alerta ni ningún cambio de estado.
  3. Introduce un fallo deliberado entre la entrega y finish, mata la app y observa cómo la transacción reaparece en el siguiente arranque.
  4. Haz idempotente la acreditación por identificador de transacción y demuestra con una prueba que procesarla dos veces no duplica el valor.
  5. Mueve la escucha de Transaction.updates al arranque del proceso y documenta qué eventos concretos se perdían cuando vivía en la vista.