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:
- Sintaxis moderna de Swift: Utiliza la potencia de
async/await, eliminando el desorden de los patrones de delegados antiguos. - Seguridad integrada: Todas las transacciones están firmadas criptográficamente mediante JSON Web Tokens (JWT). Apple valida los recibos de forma nativa por ti.
- 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.
- 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.
- En Xcode, ve a File > New > File…
- Busca StoreKit Configuration File y nombralo
Products.storekit. - 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).
- Non-Consumable (No consumibles): Por ejemplo, «Desbloquear versión Pro» (
- 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!