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— theStoreKit.Productvalues fetched forConfig.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.productIdsand create matching products in App Store Connect.loadProducts()and the paywalls pick them up automatically. - Gate a premium feature. Read
purchaseManager.hasUnlockedProfrom 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-nothinghasUnlockedPro. - 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.