Files
gh-glittercowboy-taches-cc-…/skills/expertise/iphone-apps/references/storekit.md
2025-11-29 18:28:37 +08:00

15 KiB

StoreKit 2

In-app purchases, subscriptions, and paywalls for iOS apps.

Basic Setup

Product Configuration

Define products in App Store Connect, then load in app:

import StoreKit

@Observable
class PurchaseService {
    private(set) var products: [Product] = []
    private(set) var purchasedProductIDs: Set<String> = []
    private(set) var subscriptionStatus: SubscriptionStatus = .unknown

    private var transactionListener: Task<Void, Error>?

    enum SubscriptionStatus {
        case unknown
        case subscribed
        case expired
        case inGracePeriod
        case notSubscribed
    }

    init() {
        transactionListener = listenForTransactions()
    }

    deinit {
        transactionListener?.cancel()
    }

    func loadProducts() async throws {
        let productIDs = [
            "com.app.premium.monthly",
            "com.app.premium.yearly",
            "com.app.lifetime"
        ]
        products = try await Product.products(for: productIDs)
            .sorted { $0.price < $1.price }
    }

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

        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await updatePurchasedProducts()
            await transaction.finish()
            return .success

        case .userCancelled:
            return .cancelled

        case .pending:
            return .pending

        @unknown default:
            return .failed
        }
    }

    func restorePurchases() async throws {
        try await AppStore.sync()
        await updatePurchasedProducts()
    }

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

    func updatePurchasedProducts() async {
        var purchased: Set<String> = []

        // Check non-consumables and subscriptions
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result else { continue }
            purchased.insert(transaction.productID)
        }

        purchasedProductIDs = purchased
        await updateSubscriptionStatus()
    }

    private func updateSubscriptionStatus() async {
        // Check subscription group status
        guard let groupID = products.first?.subscription?.subscriptionGroupID else {
            subscriptionStatus = .notSubscribed
            return
        }

        do {
            let statuses = try await Product.SubscriptionInfo.status(for: groupID)
            guard let status = statuses.first else {
                subscriptionStatus = .notSubscribed
                return
            }

            switch status.state {
            case .subscribed:
                subscriptionStatus = .subscribed
            case .expired:
                subscriptionStatus = .expired
            case .inGracePeriod:
                subscriptionStatus = .inGracePeriod
            case .revoked:
                subscriptionStatus = .notSubscribed
            default:
                subscriptionStatus = .unknown
            }
        } catch {
            subscriptionStatus = .unknown
        }
    }

    private func listenForTransactions() -> Task<Void, Error> {
        Task.detached {
            for await result in Transaction.updates {
                guard case .verified(let transaction) = result else { continue }
                await self.updatePurchasedProducts()
                await transaction.finish()
            }
        }
    }
}

enum PurchaseResult {
    case success
    case cancelled
    case pending
    case failed
}

enum StoreError: LocalizedError {
    case verificationFailed(Error)
    case productNotFound

    var errorDescription: String? {
        switch self {
        case .verificationFailed:
            return "Purchase verification failed"
        case .productNotFound:
            return "Product not found"
        }
    }
}

Paywall UI

struct PaywallView: View {
    @Environment(PurchaseService.self) private var purchaseService
    @Environment(\.dismiss) private var dismiss
    @State private var selectedProduct: Product?
    @State private var isPurchasing = false
    @State private var error: Error?

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 24) {
                    headerSection
                    featuresSection
                    productsSection
                    termsSection
                }
                .padding()
            }
            .navigationTitle("Go Premium")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Close") { dismiss() }
                }
            }
            .task {
                try? await purchaseService.loadProducts()
            }
            .alert("Error", isPresented: .constant(error != nil)) {
                Button("OK") { error = nil }
            } message: {
                Text(error?.localizedDescription ?? "")
            }
        }
    }

    private var headerSection: some View {
        VStack(spacing: 8) {
            Image(systemName: "crown.fill")
                .font(.system(size: 60))
                .foregroundStyle(.yellow)

            Text("Unlock Premium")
                .font(.title.bold())

            Text("Get access to all features")
                .foregroundStyle(.secondary)
        }
        .padding(.top)
    }

    private var featuresSection: some View {
        VStack(alignment: .leading, spacing: 12) {
            FeatureRow(icon: "checkmark.circle.fill", title: "Unlimited items")
            FeatureRow(icon: "checkmark.circle.fill", title: "Cloud sync")
            FeatureRow(icon: "checkmark.circle.fill", title: "Priority support")
            FeatureRow(icon: "checkmark.circle.fill", title: "No ads")
        }
        .padding()
        .background(.background.secondary)
        .clipShape(RoundedRectangle(cornerRadius: 12))
    }

    private var productsSection: some View {
        VStack(spacing: 12) {
            ForEach(purchaseService.products) { product in
                ProductButton(
                    product: product,
                    isSelected: selectedProduct == product,
                    action: { selectedProduct = product }
                )
            }

            Button {
                Task {
                    await purchase()
                }
            } label: {
                if isPurchasing {
                    ProgressView()
                } else {
                    Text("Subscribe")
                }
            }
            .buttonStyle(.borderedProminent)
            .controlSize(.large)
            .disabled(selectedProduct == nil || isPurchasing)

            Button("Restore Purchases") {
                Task {
                    try? await purchaseService.restorePurchases()
                }
            }
            .font(.caption)
        }
    }

    private var termsSection: some View {
        VStack(spacing: 4) {
            Text("Subscription automatically renews unless canceled.")
            HStack {
                Link("Terms", destination: URL(string: "https://example.com/terms")!)
                Text("•")
                Link("Privacy", destination: URL(string: "https://example.com/privacy")!)
            }
        }
        .font(.caption)
        .foregroundStyle(.secondary)
    }

    private func purchase() async {
        guard let product = selectedProduct else { return }

        isPurchasing = true
        defer { isPurchasing = false }

        do {
            let result = try await purchaseService.purchase(product)
            if result == .success {
                dismiss()
            }
        } catch {
            self.error = error
        }
    }
}

struct FeatureRow: View {
    let icon: String
    let title: String

    var body: some View {
        HStack {
            Image(systemName: icon)
                .foregroundStyle(.green)
            Text(title)
            Spacer()
        }
    }
}

struct ProductButton: View {
    let product: Product
    let isSelected: Bool
    let action: () -> Void

    var body: some View {
        Button(action: action) {
            HStack {
                VStack(alignment: .leading) {
                    Text(product.displayName)
                        .font(.headline)
                    if let subscription = product.subscription {
                        Text(subscription.subscriptionPeriod.debugDescription)
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                }
                Spacer()
                Text(product.displayPrice)
                    .font(.headline)
            }
            .padding()
            .background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
            .overlay(
                RoundedRectangle(cornerRadius: 12)
                    .stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: isSelected ? 2 : 1)
            )
        }
        .buttonStyle(.plain)
    }
}

Subscription Management

Check Subscription Status

extension PurchaseService {
    var isSubscribed: Bool {
        subscriptionStatus == .subscribed || subscriptionStatus == .inGracePeriod
    }

    func checkAccess(for feature: Feature) -> Bool {
        switch feature {
        case .basic:
            return true
        case .premium:
            return isSubscribed || purchasedProductIDs.contains("com.app.lifetime")
        }
    }
}

enum Feature {
    case basic
    case premium
}

Show Manage Subscriptions

Button("Manage Subscription") {
    Task {
        if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene {
            try? await AppStore.showManageSubscriptions(in: windowScene)
        }
    }
}

Handle Subscription Renewal

extension PurchaseService {
    func getSubscriptionRenewalInfo() async -> RenewalInfo? {
        for await result in Transaction.currentEntitlements {
            guard case .verified(let transaction) = result,
                  transaction.productType == .autoRenewable else { continue }

            guard let renewalInfo = try? await transaction.subscriptionStatus?.renewalInfo,
                  case .verified(let info) = renewalInfo else { continue }

            return RenewalInfo(
                willRenew: info.willAutoRenew,
                expirationDate: transaction.expirationDate,
                isInBillingRetry: info.isInBillingRetry
            )
        }
        return nil
    }
}

struct RenewalInfo {
    let willRenew: Bool
    let expirationDate: Date?
    let isInBillingRetry: Bool
}

Consumables

extension PurchaseService {
    func purchaseConsumable(_ product: Product, quantity: Int = 1) async throws {
        let result = try await product.purchase()

        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)

            // Grant content
            await grantConsumable(product.id, quantity: quantity)

            // Must finish transaction for consumables
            await transaction.finish()

        case .userCancelled, .pending:
            break

        @unknown default:
            break
        }
    }

    private func grantConsumable(_ productID: String, quantity: Int) async {
        // Add to user's balance (e.g., coins, credits)
        // This should be tracked in your own storage
    }
}

Promotional Offers

extension PurchaseService {
    func purchaseWithOffer(_ product: Product, offerID: String) async throws -> PurchaseResult {
        // Generate signature on your server
        guard let keyID = await fetchKeyID(),
              let nonce = UUID().uuidString.data(using: .utf8),
              let signature = await generateSignature(productID: product.id, offerID: offerID) else {
            throw StoreError.offerSigningFailed
        }

        let result = try await product.purchase(options: [
            .promotionalOffer(
                offerID: offerID,
                keyID: keyID,
                nonce: UUID(),
                signature: signature,
                timestamp: Int(Date().timeIntervalSince1970 * 1000)
            )
        ])

        // Handle result same as regular purchase
        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await updatePurchasedProducts()
            await transaction.finish()
            return .success
        case .userCancelled:
            return .cancelled
        case .pending:
            return .pending
        @unknown default:
            return .failed
        }
    }
}

Testing

StoreKit Configuration File

Create Configuration.storekit for local testing:

  1. File > New > File > StoreKit Configuration File
  2. Add products matching your App Store Connect configuration
  3. Run with: Edit Scheme > Run > Options > StoreKit Configuration

Test Purchase Scenarios

#if DEBUG
extension PurchaseService {
    func simulatePurchase() async {
        purchasedProductIDs.insert("com.app.premium.monthly")
        subscriptionStatus = .subscribed
    }

    func clearPurchases() async {
        purchasedProductIDs.removeAll()
        subscriptionStatus = .notSubscribed
    }
}
#endif

Transaction Manager (Testing)

Use Transaction Manager in Xcode to:

  • Clear purchase history
  • Simulate subscription expiration
  • Test renewal scenarios
  • Simulate billing issues

App Store Server Notifications

Configure in App Store Connect to receive:

  • Subscription renewals
  • Cancellations
  • Refunds
  • Grace period events

Handle on your server to update user access accordingly.

Best Practices

Always Update UI After Purchase

func purchase(_ product: Product) async throws -> PurchaseResult {
    let result = try await product.purchase()
    // ...
    await updatePurchasedProducts()  // Always update
    return result
}

Handle Grace Period

if purchaseService.subscriptionStatus == .inGracePeriod {
    // Show warning but allow access
    showGracePeriodBanner()
}

Finish Transactions Promptly

// Always finish after granting content
await transaction.finish()

Test on Real Device

StoreKit Testing is great for development, but always test with sandbox accounts on real devices before release.