DEV Community

Yoshiaki Hirokawa
Yoshiaki Hirokawa

Posted on

StoreKit 2 subscriptions + a screenshot mode that bypasses purchases

Two things every paid iOS app needs but nobody enjoys building:

  1. A correct subscription manager (purchase, restore, entitlement checks, transaction updates).
  2. A way to capture App Store screenshots of the paid screens — without having a live sandbox purchase every time.

Here's how FishGo does both, with the second piece deliberately punching a hole through the first.

A compact StoreKit 2 manager

StoreKit 2 is async/await-native, so the whole manager fits in one observable class. We use @Observable (iOS 17+) so SwiftUI views react to isPro changes:

@Observable
final class StoreManager {
    static let shared = StoreManager()

    private(set) var isPro = false
    private(set) var products: [Product] = []
    private(set) var purchaseError: String?

    private let productIDs = ["pro_monthly", "pro_yearly"]
    private var transactionListener: Task<Void, Never>?
}
Enter fullscreen mode Exit fullscreen mode

Loading products

func loadProducts() async {
    do {
        let storeProducts = try await Product.products(for: productIDs)
        products = storeProducts.sorted { $0.price < $1.price }
    } catch {
        purchaseError = "商品情報の取得に失敗しました"
    }
}
Enter fullscreen mode Exit fullscreen mode

Purchasing, with verification

The important part of StoreKit 2 is that every result is a VerificationResult you must check:

func purchase(_ product: Product) async {
    purchaseError = nil
    do {
        let result = try await product.purchase()
        switch result {
        case .success(let verification):
            let transaction = try checkVerified(verification)
            await transaction.finish()
            await updatePurchaseStatus()
        case .userCancelled:
            break
        case .pending:
            purchaseError = "購入処理が保留中です"
        @unknown default:
            break
        }
    } catch {
        purchaseError = "購入に失敗しました: \(error.localizedDescription)"
    }
}

nonisolated private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
    switch result {
    case .unverified:
        throw StoreError.failedVerification
    case .verified(let safe):
        return safe
    }
}
Enter fullscreen mode Exit fullscreen mode

Listening for transactions out-of-band

Purchases can arrive outside your purchase flow (Ask to Buy approvals, renewals, another device). A long-lived listener keeps isPro correct:

private func listenForTransactions() -> Task<Void, Never> {
    Task.detached { [weak self] in
        for await result in Transaction.updates {
            if let transaction = try? self?.checkVerified(result) {
                await transaction.finish()
                await self?.updatePurchaseStatus()
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

And the source of truth for entitlement is Transaction.currentEntitlements:

private func updatePurchaseStatus() async {
    var hasEntitlement = false
    for await result in Transaction.currentEntitlements {
        if let transaction = try? checkVerified(result),
           productIDs.contains(transaction.productID) {
            hasEntitlement = true
            break
        }
    }
    isPro = hasEntitlement
}
Enter fullscreen mode Exit fullscreen mode

The hole: a screenshot "shot mode"

Now the App Store screenshot problem. The paywall and the Pro-only screens look best when isPro == true, but you don't want to depend on a sandbox purchase succeeding during an automated screenshot run.

So StoreManager's initializer short-circuits when a launch flag is set:

init() {
    // Shot mode: pin Pro state without touching StoreKit
    if ShotMode.isEnabled && ShotMode.isPro {
        isPro = true
        return
    }
    transactionListener = listenForTransactions()
    Task { await updatePurchaseStatus() }
}
Enter fullscreen mode Exit fullscreen mode

ShotMode is just a thin reader over launch arguments / UserDefaults:

enum ShotMode {
    static var isEnabled: Bool { UserDefaults.standard.bool(forKey: "SHOT_MODE") }
    static var isPro: Bool { UserDefaults.standard.bool(forKey: "SHOT_PRO") }
}
Enter fullscreen mode Exit fullscreen mode

Run the UI test with -SHOT_MODE 1 -SHOT_PRO 1 and the app boots straight into a deterministic Pro state — no network, no store, no flaky purchase. Combined with mocked forecast data, every screenshot is identical run-to-run.

Pitfalls

  • Always finish() a verified transaction. Unfinished transactions get replayed forever via Transaction.updates.
  • @unknown default is mandatory on the purchase-result switch — StoreKit can add cases.
  • Keep the shot-mode bypass narrow. It only forces isPro; it never fakes a real Transaction. The real verification path stays untouched for actual users.
  • Guard the bypass behind a launch argument, not a build flag you might ship. It only activates when explicitly passed at launch.

Takeaways

  • StoreKit 2 lets you write a full subscription manager in ~100 lines with async/await + @Observable.
  • Check VerificationResult everywhere; trust nothing unverified.
  • Drive entitlement from Transaction.currentEntitlements and keep a Transaction.updates listener alive.
  • A tiny launch-flag bypass makes paid-screen screenshots deterministic — as long as it stays scoped to UI state, not real receipts.

FishGo is on the App Store: https://apps.apple.com/app/id6774428559

Top comments (0)