Managers and Utils

Purchase Manager

PurchaseManager is the entitlement brain of the app. It loads your products from the App Store, runs a verified purchase, and keeps a live hasUnlockedPro flag that your whole UI can react to — built on StoreKit 2 with async/await and on-device transaction verification, no receipt-validation server required.

It's deliberately thin and modern: one @MainActor @Observable class, a Set of purchased product IDs, and a background task that keeps that set honest even when a transaction arrives from outside the app (a renewal, a refund, a Family Sharing change).

Pair with the paywalls

This page covers the manager. For the three ready-made paywall screens that drive it, the RevenueCat configuration, and the App Store Connect product setup, read In-App Purchases.

Where it lives

ShipThatApp/Services/Purchases/PurchaseManager.swift

What it owns

@MainActor
@Observable
final class PurchaseManager {
    private let productIds = Config.Purchases.productIds
    var products: [Product] = []
    var productsLoaded = false
    var purchasedProductIDs = Set<String>()

    var hasUnlockedPro: Bool { !self.purchasedProductIDs.isEmpty }
}
  • products — the StoreKit.Product values fetched for Config.Purchases.productIds, ready to render in a paywall.
  • productsLoaded — guards against refetching once products are in hand.
  • purchasedProductIDs — the source of truth for entitlement, rebuilt from StoreKit's current entitlements.
  • hasUnlockedPro — the one computed flag the rest of the app gates features on. Because the class is @Observable, flipping this re-renders every view that reads it.

Product IDs come from Config.Purchases.productIds (["sta_999_1m_1w0", "sta_499_1w"] out of the box) — change them there to match your own App Store Connect products.

Public API

Load products

func loadProducts() async throws

Fetches Product.products(for: productIds) once and flips productsLoaded. It early-returns if products are already loaded, so calling it on every paywall .task is cheap.

Purchase

func purchase(_ product: Product) async throws

Runs product.purchase() and handles every StoreKit result explicitly:

switch result {
case let .success(.verified(transaction)):
    TelemetryManager.send("succesfullySubscribe")
    await transaction.finish()
    await self.updatePurchasedProducts()
case .success(.unverified):
    break        // e.g. a jailbroken device — entitlement is not granted
case .pending:
    break        // Ask to Buy / Strong Customer Authentication
case .userCancelled:
    TelemetryManager.send("cancelledSubscribe")
@unknown default:
    break
}

Only a verified transaction grants entitlement — it's finished, then updatePurchasedProducts() refreshes the entitlement set. Successful and cancelled purchases emit TelemetryDeck events so you can measure paywall conversion out of the box.

Refresh entitlements

func updatePurchasedProducts() async

Walks Transaction.currentEntitlements, ignores unverified results, and adds or removes each productID from purchasedProductIDs based on its revocationDate. This is what makes refunds and lapsed subscriptions correctly remove access, not just grant it. ContentView calls it on launch so entitlement is fresh before any paywall appears.

Restore purchases

There's no separate "restore" method — and you don't need one. updatePurchasedProducts() reads StoreKit's current entitlements directly, so simply calling it restores any active purchase tied to the signed-in Apple ID. Wire a "Restore Purchases" button to Task { await purchaseManager.updatePurchasedProducts() }.

Always-on transaction observation

The manager starts a background task in init that listens to Transaction.updates for the app's entire lifetime, so an entitlement change that happens outside a purchase flow (a renewal, a refund, a subscription started on another device) is reflected immediately:

init() {
    self.updates = self.observeTransactionUpdates()
}

private func observeTransactionUpdates() -> Task<Void, Never> {
    Task(priority: .background) { [unowned self] in
        for await _ in Transaction.updates {
            await self.updatePurchasedProducts()
        }
    }
}

The task is held on @ObservationIgnored nonisolated(unsafe) private var updates so it stays out of the observation graph and can be cancelled from the nonisolated deinit.

How it's used

PurchaseManager is instantiated once in ContentView, injected into the environment, and refreshed on launch:

// ContentView.swift
@State private var purchaseManager = PurchaseManager()

LandingView()
    .environment(purchaseManager)
    .task { await purchaseManager.updatePurchasedProducts() }

A paywall reads it from the environment, loads products on appear, and runs a purchase:

// Paywall3View.swift
@Environment(PurchaseManager.self) private var purchaseManager

ForEach(purchaseManager.products) { product in /* product cell */ }

// on appear
try await purchaseManager.loadProducts()

// on tap
try await purchaseManager.purchase(selectedProduct)

Gating a feature is just reading the flag — SettingsView does exactly this:

// SettingsView.swift
@Environment(PurchaseManager.self) private var purchaseManager

Text(purchaseManager.hasUnlockedPro ? "Pro unlocked" : "Upgrade to Pro")
if !purchaseManager.hasUnlockedPro {
    // show the upsell row
}

Customize & extend

  • Use your own products. Edit Config.Purchases.productIds and create matching products in App Store Connect. loadProducts() and the paywalls pick them up automatically.
  • Gate a premium feature. Read purchaseManager.hasUnlockedPro from the environment anywhere — it stays in sync via observation, so the UI flips the instant a purchase verifies or a refund lands.
  • Track per-product access. For tiered offerings, check purchaseManager.purchasedProductIDs.contains("your_product_id") instead of the all-or-nothing hasUnlockedPro.
  • Add a "Restore Purchases" button. Call await purchaseManager.updatePurchasedProducts() — no extra API needed.

Verified transactions only

Entitlement is granted only for .success(.verified) transactions. The .unverified case is intentionally left as a no-op — don't "fix" it by granting access there, or you'll hand Pro to tampered devices. This is the StoreKit 2 safety net working as designed.

Previous
AuthManager