Entrevista StoreKit 2: Gestión de Suscripciones y Validación de Recibos

Domina las preguntas de entrevista iOS sobre StoreKit 2, gestión de suscripciones, validación de recibos e implementación de compras integradas con ejemplos prácticos en Swift.

Arquitectura de suscripciones iOS StoreKit 2 y validación de recibos

StoreKit 2 representa un cambio fundamental en la forma en que se gestionan las compras integradas en iOS. Introducido con iOS 15, este framework moderno simplifica drásticamente el código requerido para la gestión de suscripciones y la validación de transacciones. Las entrevistas técnicas iOS abordan regularmente este tema para evaluar la experiencia de los desarrolladores en monetización de aplicaciones.

Punto Clave para la Entrevista

StoreKit 2 aprovecha una API nativa de Swift con async/await, eliminando la necesidad de callbacks complejos y validación de recibos del lado del cliente. Los entrevistadores valoran a los candidatos que pueden articular las diferencias fundamentales con StoreKit 1.

Visión General de la Arquitectura StoreKit 2

StoreKit 2 está construido sobre una arquitectura moderna que aprovecha al máximo Swift Concurrency. A diferencia de StoreKit 1, todas las operaciones son asíncronas y utilizan tipos nativos de Swift.

Obtención de Productos Disponibles

El primer paso consiste en cargar los productos configurados en App Store Connect. StoreKit 2 simplifica esta operación con una API declarativa.

ProductService.swiftswift
import StoreKit

actor ProductService {

    // Identificadores de productos configurados en App Store Connect
    private let productIdentifiers: Set<String> = [
        "com.app.subscription.monthly",
        "com.app.subscription.yearly",
        "com.app.premium.lifetime"
    ]

    // Caché de productos para evitar llamadas de red repetidas
    private var cachedProducts: [Product] = []

    func loadProducts() async throws -> [Product] {
        // Devuelve la caché si está disponible
        guard cachedProducts.isEmpty else {
            return cachedProducts
        }

        // Solicitud asíncrona a la App Store
        let products = try await Product.products(for: productIdentifiers)
        cachedProducts = products
        return products
    }

    func product(for identifier: String) async throws -> Product? {
        let products = try await loadProducts()
        return products.first { $0.id == identifier }
    }
}

El uso de un actor garantiza la seguridad de hilos al gestionar la caché de productos.

Tipos de Productos en StoreKit 2

StoreKit 2 distingue claramente entre los diferentes tipos de productos. Comprender esta distinción es esencial para las entrevistas.

ProductType+Extensions.swiftswift
import StoreKit

extension Product.ProductType {

    var displayName: String {
        switch self {
        case .consumable:
            // Productos consumibles (créditos, vidas, etc.)
            return "Consumable"
        case .nonConsumable:
            // Compras permanentes (desbloqueo de funciones)
            return "Non-Consumable"
        case .autoRenewable:
            // Suscripciones con renovación automática
            return "Auto-Renewable Subscription"
        case .nonRenewable:
            // Suscripciones sin renovación (pase temporal)
            return "Non-Renewable Subscription"
        @unknown default:
            return "Unknown"
        }
    }
}

Las suscripciones con renovación automática representan el modelo de negocio dominante para las aplicaciones iOS modernas.

Manejo de Transacciones de Compra

El proceso de compra en StoreKit 2 se vuelve lineal gracias a async/await. Se acabaron los delegados y callbacks anidados de StoreKit 1.

Iniciar una Compra

PurchaseManager.swiftswift
import StoreKit

actor PurchaseManager {

    enum PurchaseError: Error {
        case productNotFound
        case purchaseCancelled
        case purchasePending
        case verificationFailed
        case unknown
    }

    func purchase(_ product: Product) async throws -> Transaction {
        // Inicia la compra con el modal del sistema
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            // Verifica la firma de la transacción
            let transaction = try checkVerification(verification)

            // Marca la transacción como finalizada
            await transaction.finish()

            return transaction

        case .userCancelled:
            // El usuario canceló el modal de compra
            throw PurchaseError.purchaseCancelled

        case .pending:
            // Compra pendiente (aprobación parental, etc.)
            throw PurchaseError.purchasePending

        @unknown default:
            throw PurchaseError.unknown
        }
    }

    private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .verified(let safe):
            // Transacción firmada y verificada por la App Store
            return safe
        case .unverified(_, let error):
            // Firma inválida o corrupta
            throw PurchaseError.verificationFailed
        }
    }
}

El método finish() es crucial: indica a la App Store que el contenido ha sido entregado al usuario.

Alerta de Entrevista

Nunca olvides llamar a transaction.finish() después de entregar el contenido. Una transacción no finalizada se restaurará en el próximo lanzamiento de la app, causando comportamientos inesperados.

Escuchar Transacciones en Segundo Plano

StoreKit 2 introduce Transaction.updates, un flujo asíncrono que emite las transacciones que ocurren fuera de la aplicación (renovaciones, compras de family sharing, etc.).

TransactionObserver.swiftswift
import StoreKit

actor TransactionObserver {

    private var updateTask: Task<Void, Never>?

    func startObserving() {
        // Cancela cualquier observación previa
        updateTask?.cancel()

        updateTask = Task(priority: .background) {
            // Flujo infinito de actualizaciones de transacciones
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerification(result)

                    // Procesa la transacción según su tipo
                    await self.handleTransaction(transaction)

                    // Siempre finaliza la transacción
                    await transaction.finish()
                } catch {
                    // Registra el error para depuración
                    print("Transaction verification failed: \(error)")
                }
            }
        }
    }

    func stopObserving() {
        updateTask?.cancel()
        updateTask = nil
    }

    private func handleTransaction(_ transaction: Transaction) async {
        switch transaction.productType {
        case .autoRenewable:
            // Actualiza el estado de la suscripción
            await updateSubscriptionStatus(transaction)
        case .nonConsumable:
            // Desbloquea la función premium
            await unlockFeature(transaction.productID)
        default:
            break
        }
    }

    private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .verified(let safe):
            return safe
        case .unverified:
            throw PurchaseError.verificationFailed
        }
    }

    private func updateSubscriptionStatus(_ transaction: Transaction) async {
        // Implementación de la actualización del estado
    }

    private func unlockFeature(_ productID: String) async {
        // Implementación del desbloqueo de funciones
    }

    enum PurchaseError: Error {
        case verificationFailed
    }
}

La observación de transacciones debe iniciarse tan pronto como se lance la aplicación para evitar perder cualquier actualización.

Preguntas Comunes de Entrevista sobre Suscripciones en StoreKit 2

Las entrevistas iOS frecuentemente incluyen preguntas específicas sobre la gestión de suscripciones. Aquí están las más comunes con respuestas detalladas.

Pregunta 1: ¿Cómo Verificar el Estado Actual de una Suscripción?

StoreKit 2 proporciona acceso directo a los entitlements activos a través de Transaction.currentEntitlements.

SubscriptionStatusChecker.swiftswift
import StoreKit

struct SubscriptionStatusChecker {

    struct SubscriptionStatus {
        let isActive: Bool
        let expirationDate: Date?
        let willRenew: Bool
        let productID: String?
    }

    static func checkCurrentStatus() async -> SubscriptionStatus {
        // Recupera todas las transacciones activas
        var latestTransaction: Transaction?

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                // Filtra solo las suscripciones
                if transaction.productType == .autoRenewable {
                    // Conserva la transacción más reciente
                    if latestTransaction == nil ||
                       transaction.purchaseDate > latestTransaction!.purchaseDate {
                        latestTransaction = transaction
                    }
                }
            }
        }

        guard let transaction = latestTransaction else {
            return SubscriptionStatus(
                isActive: false,
                expirationDate: nil,
                willRenew: false,
                productID: nil
            )
        }

        // Verifica si la suscripción sigue activa
        let isActive = transaction.expirationDate ?? Date.distantPast > Date()

        // Recupera la información de renovación
        let renewalInfo = await getRenewalInfo(for: transaction.productID)

        return SubscriptionStatus(
            isActive: isActive,
            expirationDate: transaction.expirationDate,
            willRenew: renewalInfo?.willAutoRenew ?? false,
            productID: transaction.productID
        )
    }

    private static func getRenewalInfo(for productID: String) async -> Product.SubscriptionInfo.RenewalInfo? {
        guard let product = try? await Product.products(for: [productID]).first,
              let subscription = product.subscription else {
            return nil
        }

        // Recupera el estado de renovación
        let status = try? await subscription.status.first
        return status?.renewalInfo
    }
}

Este enfoque proporciona el estado preciso de la suscripción sin llamadas al servidor.

¿Listo para aprobar tus entrevistas de iOS?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

Pregunta 2: ¿Cómo Manejar Pruebas Gratuitas y Ofertas Promocionales?

StoreKit 2 simplifica el acceso a la información de las ofertas. Los entrevistadores a menudo prueban la comprensión de los diferentes tipos de ofertas.

OfferManager.swiftswift
import StoreKit

struct OfferManager {

    enum OfferType {
        case freeTrial(duration: String)
        case payAsYouGo(periods: Int, price: Decimal)
        case payUpFront(duration: String, price: Decimal)
        case none
    }

    static func getIntroductoryOffer(for product: Product) -> OfferType {
        guard let subscription = product.subscription,
              let offer = subscription.introductoryOffer else {
            return .none
        }

        switch offer.paymentMode {
        case .freeTrial:
            // Período de prueba gratuita
            return .freeTrial(duration: formatPeriod(offer.period))

        case .payAsYouGo:
            // Precio reducido durante varios períodos
            return .payAsYouGo(
                periods: offer.periodCount,
                price: offer.price
            )

        case .payUpFront:
            // Pago único por un período
            return .payUpFront(
                duration: formatPeriod(offer.period),
                price: offer.price
            )

        @unknown default:
            return .none
        }
    }

    static func checkEligibility(for product: Product) async -> Bool {
        // Verifica si el usuario puede beneficiarse de la oferta
        guard let subscription = product.subscription else {
            return false
        }

        // isEligibleForIntroOffer indica si es un nuevo suscriptor
        return await subscription.isEligibleForIntroOffer
    }

    private static func formatPeriod(_ period: Product.SubscriptionPeriod) -> String {
        let value = period.value
        switch period.unit {
        case .day:
            return "\(value) day\(value > 1 ? "s" : "")"
        case .week:
            return "\(value) week\(value > 1 ? "s" : "")"
        case .month:
            return "\(value) month\(value > 1 ? "s" : "")"
        case .year:
            return "\(value) year\(value > 1 ? "s" : "")"
        @unknown default:
            return ""
        }
    }
}

El método isEligibleForIntroOffer es esencial para evitar mostrar ofertas no disponibles.

Pregunta 3: ¿Cómo Restaurar Compras Anteriores?

La restauración de compras es una funcionalidad obligatoria según las directrices de la App Store.

RestoreManager.swiftswift
import StoreKit

actor RestoreManager {

    struct RestoreResult {
        let restoredProducts: [String]
        let activeSubscriptions: [String]
    }

    func restorePurchases() async throws -> RestoreResult {
        var restoredProducts: [String] = []
        var activeSubscriptions: [String] = []

        // Sincroniza con la App Store
        try await AppStore.sync()

        // Itera por todas las transacciones del usuario
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else {
                continue
            }

            switch transaction.productType {
            case .nonConsumable:
                // Restaura las compras permanentes
                restoredProducts.append(transaction.productID)

            case .autoRenewable:
                // Verifica si la suscripción está activa
                if let expiration = transaction.expirationDate,
                   expiration > Date() {
                    activeSubscriptions.append(transaction.productID)
                }

            default:
                // Los consumibles no son restaurables
                break
            }
        }

        return RestoreResult(
            restoredProducts: restoredProducts,
            activeSubscriptions: activeSubscriptions
        )
    }
}

AppStore.sync() fuerza la sincronización con los servidores de Apple, útil después de una reinstalación.

Validación de Recibos del Lado del Servidor

La validación del lado del servidor sigue siendo recomendada para aplicaciones con suscripciones críticas. StoreKit 2 introduce JWS (JSON Web Signatures) para una validación moderna.

Extracción de Datos de Transacción para el Servidor

ServerValidation.swiftswift
import StoreKit

struct TransactionPayload: Codable {
    let transactionID: String
    let originalTransactionID: String
    let productID: String
    let purchaseDate: Date
    let expirationDate: Date?
    let jwsRepresentation: String
}

actor ServerValidationService {

    private let apiEndpoint = "https://api.example.com/verify"

    func validateWithServer(_ transaction: Transaction) async throws -> Bool {
        // Crea el payload para el servidor
        let payload = TransactionPayload(
            transactionID: String(transaction.id),
            originalTransactionID: String(transaction.originalID),
            productID: transaction.productID,
            purchaseDate: transaction.purchaseDate,
            expirationDate: transaction.expirationDate,
            jwsRepresentation: transaction.jwsRepresentation ?? ""
        )

        // Codifica a JSON
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        let jsonData = try encoder.encode(payload)

        // Envía al servidor
        var request = URLRequest(url: URL(string: apiEndpoint)!)
        request.httpMethod = "POST"
        request.httpBody = jsonData
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")

        let (data, response) = try await URLSession.shared.data(for: request)

        guard let httpResponse = response as? HTTPURLResponse,
              httpResponse.statusCode == 200 else {
            return false
        }

        // Decodifica la respuesta del servidor
        let validationResponse = try JSONDecoder().decode(
            ValidationResponse.self,
            from: data
        )

        return validationResponse.isValid
    }
}

struct ValidationResponse: Codable {
    let isValid: Bool
    let subscriptionStatus: String?
}

La jwsRepresentation contiene la firma criptográfica verificable por el servidor.

App Store Server API

Apple ahora proporciona la App Store Server API para la validación del lado del servidor. Esta API moderna reemplaza el endpoint obsoleto verifyReceipt y ofrece funciones avanzadas como notificaciones servidor a servidor.

Configuración de Notificaciones del Servidor

Las Server Notifications V2 permiten la recepción de eventos en tiempo real.

ServerNotificationTypes.swiftswift
enum NotificationType: String, Codable {
    case subscribed = "SUBSCRIBED"
    case didRenew = "DID_RENEW"
    case didFailToRenew = "DID_FAIL_TO_RENEW"
    case didChangeRenewalStatus = "DID_CHANGE_RENEWAL_STATUS"
    case expired = "EXPIRED"
    case gracePeriodExpired = "GRACE_PERIOD_EXPIRED"
    case offerRedeemed = "OFFER_REDEEMED"
    case priceIncrease = "PRICE_INCREASE"
    case refund = "REFUND"
    case renewalExtended = "RENEWAL_EXTENDED"
    case revoke = "REVOKE"
}

struct ServerNotification: Codable {
    let notificationType: NotificationType
    let subtype: String?
    let data: NotificationData

    struct NotificationData: Codable {
        let bundleId: String
        let environment: String
        let signedTransactionInfo: String
        let signedRenewalInfo: String?
    }
}

Estas notificaciones permiten mantener el estado de las suscripciones sincronizado con el backend.

Manejo de Errores y Casos Límite

Las entrevistas a menudo prueban la comprensión de los escenarios de error y su manejo apropiado.

Errores Comunes en StoreKit 2

PurchaseErrorHandler.swiftswift
import StoreKit

struct PurchaseErrorHandler {

    enum UserFacingError {
        case networkError
        case paymentFailed
        case notAuthorized
        case productNotAvailable
        case unknown

        var message: String {
            switch self {
            case .networkError:
                return "Connection failed. Please check your internet connection."
            case .paymentFailed:
                return "Payment failed. Please verify your payment information."
            case .notAuthorized:
                return "In-app purchases are disabled on this device."
            case .productNotAvailable:
                return "This product is not currently available."
            case .unknown:
                return "An unexpected error occurred."
            }
        }
    }

    static func handle(_ error: Error) -> UserFacingError {
        // Verifica si es un error de StoreKit
        if let storeKitError = error as? StoreKitError {
            switch storeKitError {
            case .networkError:
                return .networkError
            case .userCancelled:
                // No muestra error, el usuario canceló
                return .unknown
            case .notAvailableInStorefront:
                return .productNotAvailable
            case .notEntitled:
                return .notAuthorized
            default:
                return .unknown
            }
        }

        // Errores de compra
        if let purchaseError = error as? Product.PurchaseError {
            switch purchaseError {
            case .invalidQuantity:
                return .unknown
            case .productUnavailable:
                return .productNotAvailable
            case .purchaseNotAllowed:
                return .notAuthorized
            default:
                return .unknown
            }
        }

        return .unknown
    }
}

Un manejo claro de errores mejora la experiencia del usuario y simplifica la depuración.

Soporte para Modo Sin Conexión

OfflineCapabilityManager.swiftswift
import StoreKit

actor OfflineCapabilityManager {

    private let userDefaults = UserDefaults.standard
    private let entitlementsKey = "cached_entitlements"

    func cacheCurrentEntitlements() async {
        var entitlements: [String: Date] = [:]

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                entitlements[transaction.productID] = transaction.expirationDate
            }
        }

        // Guarda localmente
        if let data = try? JSONEncoder().encode(entitlements) {
            userDefaults.set(data, forKey: entitlementsKey)
        }
    }

    func getCachedEntitlements() -> [String: Date] {
        guard let data = userDefaults.data(forKey: entitlementsKey),
              let entitlements = try? JSONDecoder().decode(
                  [String: Date].self,
                  from: data
              ) else {
            return [:]
        }

        // Filtra los entitlements expirados
        let now = Date()
        return entitlements.filter { _, expiration in
            expiration > now
        }
    }

    func hasValidEntitlement(for productID: String) -> Bool {
        let cached = getCachedEntitlements()
        return cached[productID] != nil
    }
}

La caché local mantiene el acceso a las funciones premium incluso sin conectividad.

Mejores Prácticas para Producción

Arquitectura Recomendada

SubscriptionService.swiftswift
import StoreKit
import Combine

@MainActor
final class SubscriptionService: ObservableObject {

    static let shared = SubscriptionService()

    @Published private(set) var products: [Product] = []
    @Published private(set) var purchasedProductIDs: Set<String> = []
    @Published private(set) var subscriptionStatus: SubscriptionStatus = .none

    private var transactionObserver: Task<Void, Never>?

    enum SubscriptionStatus {
        case none
        case active(expirationDate: Date)
        case expired
        case inGracePeriod
    }

    private init() {
        // Inicia la observación de transacciones
        startTransactionObserver()

        // Carga el estado inicial
        Task {
            await loadProducts()
            await updateSubscriptionStatus()
        }
    }

    private func startTransactionObserver() {
        transactionObserver = Task.detached(priority: .background) {
            for await result in Transaction.updates {
                if case .verified(let transaction) = result {
                    await transaction.finish()
                    await self.updateSubscriptionStatus()
                }
            }
        }
    }

    func loadProducts() async {
        do {
            let productIDs = [
                "com.app.subscription.monthly",
                "com.app.subscription.yearly"
            ]
            products = try await Product.products(for: productIDs)
        } catch {
            print("Failed to load products: \(error)")
        }
    }

    func purchase(_ product: Product) async throws {
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            if case .verified(let transaction) = verification {
                await transaction.finish()
                await updateSubscriptionStatus()
            }
        case .userCancelled:
            break
        case .pending:
            break
        @unknown default:
            break
        }
    }

    func updateSubscriptionStatus() async {
        var purchasedIDs: Set<String> = []
        var latestSubscription: Transaction?

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                purchasedIDs.insert(transaction.productID)

                if transaction.productType == .autoRenewable {
                    if latestSubscription == nil ||
                       transaction.purchaseDate > latestSubscription!.purchaseDate {
                        latestSubscription = transaction
                    }
                }
            }
        }

        purchasedProductIDs = purchasedIDs

        if let subscription = latestSubscription,
           let expiration = subscription.expirationDate {
            if expiration > Date() {
                subscriptionStatus = .active(expirationDate: expiration)
            } else {
                subscriptionStatus = .expired
            }
        } else {
            subscriptionStatus = .none
        }
    }

    deinit {
        transactionObserver?.cancel()
    }
}

Esta arquitectura centralizada simplifica la gestión del estado de las compras en toda la aplicación.

Conclusión

StoreKit 2 transforma la gestión de compras integradas en una experiencia de desarrollo moderna y segura. Puntos clave para recordar de cara a una entrevista:

✅ API nativa async/await: se acabaron los delegados y callbacks complejos de StoreKit 1

✅ Verificación automática: VerificationResult gestiona la validación criptográfica de las transacciones

Transaction.updates: flujo asíncrono para transacciones en segundo plano

Transaction.currentEntitlements: acceso directo a los entitlements activos del usuario

✅ Ofertas promocionales: isEligibleForIntroOffer para verificar la elegibilidad

✅ Validación del servidor: JWS y App Store Server API para mayor seguridad

transaction.finish(): obligatorio después de la entrega del contenido

✅ Manejo sin conexión: caché local de entitlements para una experiencia fluida

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

Etiquetas

#ios
#storekit
#in-app-purchase
#swift
#interview

Compartir

Artículos relacionados