StoreKit 2 en entretien : gestion des abonnements et receipts validation

Préparez vos entretiens iOS avec ce guide complet sur StoreKit 2, la gestion des abonnements, la validation des receipts et les questions techniques fréquentes.

StoreKit 2 iOS architecture des abonnements et validation receipts

StoreKit 2 représente une évolution majeure pour la gestion des achats in-app sur iOS. Introduit avec iOS 15, ce framework moderne simplifie considérablement le code nécessaire pour gérer les abonnements et la validation des transactions. Les entretiens iOS techniques abordent régulièrement ce sujet pour évaluer la maîtrise des développeurs sur la monétisation des applications.

Point clé pour l'entretien

StoreKit 2 utilise une API Swift native avec async/await, éliminant le besoin de callbacks complexes et de la validation manuelle des receipts côté client. Les recruteurs apprécient les candidats capables d'expliquer les différences fondamentales avec StoreKit 1.

Architecture de StoreKit 2

StoreKit 2 repose sur une architecture moderne qui tire parti des fonctionnalités de Swift Concurrency. Contrairement à StoreKit 1, toutes les opérations sont asynchrones et utilisent les types natifs Swift.

Récupération des produits disponibles

La première étape consiste à charger les produits configurés dans App Store Connect. StoreKit 2 simplifie cette opération avec une API déclarative.

ProductService.swiftswift
import StoreKit

actor ProductService {

    // Identifiants des produits configurés dans App Store Connect
    private let productIdentifiers: Set<String> = [
        "com.app.subscription.monthly",
        "com.app.subscription.yearly",
        "com.app.premium.lifetime"
    ]

    // Cache des produits pour éviter des appels réseau répétés
    private var cachedProducts: [Product] = []

    func loadProducts() async throws -> [Product] {
        // Retourner le cache si disponible
        guard cachedProducts.isEmpty else {
            return cachedProducts
        }

        // Requête asynchrone vers l'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 }
    }
}

L'utilisation d'un actor garantit la thread-safety lors de la gestion du cache des produits.

Types de produits StoreKit 2

StoreKit 2 distingue clairement les différents types de produits. Cette distinction est importante à connaître pour les entretiens.

ProductType+Extensions.swiftswift
import StoreKit

extension Product.ProductType {

    var displayName: String {
        switch self {
        case .consumable:
            // Produits consommables (crédits, vies, etc.)
            return "Consommable"
        case .nonConsumable:
            // Achats permanents (déblocage de fonctionnalité)
            return "Non-consommable"
        case .autoRenewable:
            // Abonnements avec renouvellement automatique
            return "Abonnement auto-renouvelable"
        case .nonRenewable:
            // Abonnements sans renouvellement (pass temporaire)
            return "Abonnement non-renouvelable"
        @unknown default:
            return "Inconnu"
        }
    }
}

Les abonnements auto-renouvelables constituent le modèle économique dominant pour les applications iOS modernes.

Gestion des transactions d'achat

Le processus d'achat avec StoreKit 2 devient linéaire grâce à async/await. Fini les delegates et les callbacks imbriqués de StoreKit 1.

Initiation d'un achat

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 {
        // Initiation de l'achat avec la modale système
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            // Vérification de la signature de la transaction
            let transaction = try checkVerification(verification)

            // Marquer la transaction comme terminée
            await transaction.finish()

            return transaction

        case .userCancelled:
            // L'utilisateur a annulé la modale d'achat
            throw PurchaseError.purchaseCancelled

        case .pending:
            // Achat en attente (approbation parentale, etc.)
            throw PurchaseError.purchasePending

        @unknown default:
            throw PurchaseError.unknown
        }
    }

    private func checkVerification<T>(_ result: VerificationResult<T>) throws -> T {
        switch result {
        case .verified(let safe):
            // Transaction signée et vérifiée par l'App Store
            return safe
        case .unverified(_, let error):
            // Signature invalide ou corrompue
            throw PurchaseError.verificationFailed
        }
    }
}

La méthode finish() est cruciale : elle indique à l'App Store que le contenu a été livré à l'utilisateur.

Attention en entretien

Ne jamais oublier d'appeler transaction.finish() après avoir délivré le contenu. Une transaction non terminée sera restaurée au prochain lancement de l'application, causant des comportements inattendus.

Écoute des transactions en arrière-plan

StoreKit 2 introduit Transaction.updates, un stream asynchrone qui émet les transactions survenues en dehors de l'application (renouvellements, achats familiaux, etc.).

TransactionObserver.swiftswift
import StoreKit

actor TransactionObserver {

    private var updateTask: Task<Void, Never>?

    func startObserving() {
        // Annuler toute observation précédente
        updateTask?.cancel()

        updateTask = Task(priority: .background) {
            // Stream infini des mises à jour de transactions
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerification(result)

                    // Traiter la transaction selon son type
                    await self.handleTransaction(transaction)

                    // Toujours terminer la transaction
                    await transaction.finish()
                } catch {
                    // Logger l'erreur pour le debugging
                    print("Transaction verification failed: \(error)")
                }
            }
        }
    }

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

    private func handleTransaction(_ transaction: Transaction) async {
        switch transaction.productType {
        case .autoRenewable:
            // Mettre à jour l'état de l'abonnement
            await updateSubscriptionStatus(transaction)
        case .nonConsumable:
            // Débloquer la fonctionnalité 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 {
        // Implémentation de la mise à jour du statut
    }

    private func unlockFeature(_ productID: String) async {
        // Implémentation du déblocage
    }

    enum PurchaseError: Error {
        case verificationFailed
    }
}

L'observation des transactions doit démarrer dès le lancement de l'application pour ne manquer aucune mise à jour.

Questions fréquentes sur les abonnements StoreKit 2

Les entretiens iOS comportent souvent des questions spécifiques sur la gestion des abonnements. Voici les plus courantes avec leurs réponses détaillées.

Question 1 : Comment vérifier le statut actuel d'un abonnement ?

StoreKit 2 fournit un accès direct aux entitlements actifs via 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 {
        // Récupérer toutes les transactions actives
        var latestTransaction: Transaction?

        for await result in Transaction.currentEntitlements {
            if case .verified(let transaction) = result {
                // Filtrer uniquement les abonnements
                if transaction.productType == .autoRenewable {
                    // Garder la transaction la plus récente
                    if latestTransaction == nil ||
                       transaction.purchaseDate > latestTransaction!.purchaseDate {
                        latestTransaction = transaction
                    }
                }
            }
        }

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

        // Vérifier si l'abonnement est toujours actif
        let isActive = transaction.expirationDate ?? Date.distantPast > Date()

        // Récupérer les informations de renouvellement
        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
        }

        // Récupérer le statut de renouvellement
        let status = try? await subscription.status.first
        return status?.renewalInfo
    }
}

Cette approche permet d'obtenir un état précis de l'abonnement sans appel serveur.

Prêt à réussir tes entretiens iOS ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Question 2 : Comment gérer les périodes d'essai et les offres promotionnelles ?

StoreKit 2 simplifie l'accès aux informations sur les offres. Les recruteurs testent souvent la compréhension des différents types d'offres.

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:
            // Période d'essai gratuite
            return .freeTrial(duration: formatPeriod(offer.period))

        case .payAsYouGo:
            // Prix réduit sur plusieurs périodes
            return .payAsYouGo(
                periods: offer.periodCount,
                price: offer.price
            )

        case .payUpFront:
            // Paiement unique pour une période
            return .payUpFront(
                duration: formatPeriod(offer.period),
                price: offer.price
            )

        @unknown default:
            return .none
        }
    }

    static func checkEligibility(for product: Product) async -> Bool {
        // Vérifier si l'utilisateur peut bénéficier de l'offre
        guard let subscription = product.subscription else {
            return false
        }

        // isEligibleForIntroOffer indique si c'est un nouvel abonné
        return await subscription.isEligibleForIntroOffer
    }

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

La méthode isEligibleForIntroOffer est essentielle pour éviter d'afficher des offres non disponibles.

Question 3 : Comment restaurer les achats précédents ?

La restauration des achats est une fonctionnalité obligatoire selon les guidelines 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] = []

        // Synchroniser avec l'App Store
        try await AppStore.sync()

        // Parcourir toutes les transactions de l'utilisateur
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else {
                continue
            }

            switch transaction.productType {
            case .nonConsumable:
                // Restaurer les achats permanents
                restoredProducts.append(transaction.productID)

            case .autoRenewable:
                // Vérifier si l'abonnement est actif
                if let expiration = transaction.expirationDate,
                   expiration > Date() {
                    activeSubscriptions.append(transaction.productID)
                }

            default:
                // Les consommables ne sont pas restaurables
                break
            }
        }

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

AppStore.sync() force une synchronisation avec les serveurs Apple, utile après une réinstallation.

Validation des receipts côté serveur

La validation serveur reste recommandée pour les applications avec des abonnements critiques. StoreKit 2 introduit les JWS (JSON Web Signatures) pour une validation moderne.

Extraction des données de transaction pour le serveur

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 {
        // Créer le payload pour le serveur
        let payload = TransactionPayload(
            transactionID: String(transaction.id),
            originalTransactionID: String(transaction.originalID),
            productID: transaction.productID,
            purchaseDate: transaction.purchaseDate,
            expirationDate: transaction.expirationDate,
            jwsRepresentation: transaction.jwsRepresentation ?? ""
        )

        // Encoder en JSON
        let encoder = JSONEncoder()
        encoder.dateEncodingStrategy = .iso8601
        let jsonData = try encoder.encode(payload)

        // Envoyer au serveur
        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
        }

        // Décoder la réponse serveur
        let validationResponse = try JSONDecoder().decode(
            ValidationResponse.self,
            from: data
        )

        return validationResponse.isValid
    }
}

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

Le jwsRepresentation contient la signature cryptographique vérifiable par le serveur.

App Store Server API

Apple propose désormais l'App Store Server API pour la validation côté serveur. Cette API moderne remplace le deprecated verifyReceipt endpoint et offre des fonctionnalités avancées comme les notifications serveur-à-serveur.

Configuration des notifications serveur

Les Server Notifications V2 permettent de recevoir les événements en temps réel.

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?
    }
}

Ces notifications permettent de maintenir l'état des abonnements synchronisé avec le backend.

Gestion des erreurs et cas limites

Les entretiens testent souvent la compréhension des scénarios d'erreur et leur gestion appropriée.

Erreurs courantes 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 "Connexion impossible. Vérifiez votre connexion internet."
            case .paymentFailed:
                return "Le paiement a échoué. Vérifiez vos informations de paiement."
            case .notAuthorized:
                return "Les achats in-app sont désactivés sur cet appareil."
            case .productNotAvailable:
                return "Ce produit n'est pas disponible actuellement."
            case .unknown:
                return "Une erreur inattendue s'est produite."
            }
        }
    }

    static func handle(_ error: Error) -> UserFacingError {
        // Vérifier si c'est une erreur StoreKit
        if let storeKitError = error as? StoreKitError {
            switch storeKitError {
            case .networkError:
                return .networkError
            case .userCancelled:
                // Ne pas afficher d'erreur, l'utilisateur a annulé
                return .unknown
            case .notAvailableInStorefront:
                return .productNotAvailable
            case .notEntitled:
                return .notAuthorized
            default:
                return .unknown
            }
        }

        // Erreurs de purchase
        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
    }
}

Une gestion des erreurs claire améliore l'expérience utilisateur et facilite le debugging.

Gestion du mode hors ligne

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
            }
        }

        // Sauvegarder localement
        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 [:]
        }

        // Filtrer les entitlements expirés
        let now = Date()
        return entitlements.filter { _, expiration in
            expiration > now
        }
    }

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

Le cache local permet de maintenir l'accès aux fonctionnalités premium même sans connexion.

Bonnes pratiques pour la production

Architecture recommandée

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() {
        // Démarrer l'observation des transactions
        startTransactionObserver()

        // Charger l'état initial
        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()
    }
}

Cette architecture centralisée facilite la gestion de l'état des achats dans toute l'application.

Conclusion

StoreKit 2 transforme la gestion des achats in-app en une expérience de développement moderne et sécurisée. Les points essentiels à retenir pour un entretien :

✅ API async/await native : fin des delegates et callbacks complexes de StoreKit 1

✅ Vérification automatique : VerificationResult gère la validation cryptographique des transactions

Transaction.updates : stream asynchrone pour les transactions en arrière-plan

Transaction.currentEntitlements : accès direct aux droits actifs de l'utilisateur

✅ Offres promotionnelles : isEligibleForIntroOffer pour vérifier l'éligibilité

✅ Validation serveur : JWS et App Store Server API pour une sécurité renforcée

transaction.finish() : obligatoire après livraison du contenu

✅ Gestion hors ligne : cache local des entitlements pour une expérience fluide

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

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

Partager

Articles similaires