markbattistella/skmanager
`SKManager` is a strongly typed StoreKit 2 framework for Swift. It handles product fetching, purchasing, and entitlement tracking across all four IAP types (auto-renewable, non-renewable, non-consumable, consumable), and adds a visibility rules system purpose-built for custom pay
Why SKManager?
Most StoreKit wrappers handle purchasing but leave display logic up to you. SKManager solves the harder problem: how do you build a paywall where ownership of one product changes what else is shown?
- Owning a family lifetime should hide the subscription tier entirely
- Being on a basic subscription should reveal a lifetime upgrade option
- Owning an annual plan should hide the monthly option
- Pro subscribers can export; free users cannot; family plans get everything
All of this is expressed declaratively through StoreRules and TierCapabilities, not ad-hoc if statements scattered through view code.
Requirements
- iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+
- Swift 6
Installation
// Package.swift
dependencies: [
.package(url: "https://github.com/markbattistella/SKManager", from: "1.0.0")
]Or in Xcode: File → Add Package Dependencies → https://github.com/markbattistella/SKManager
Core Concepts
| Type | Role | | --- | --- | | EntitlementManager | Source of truth for what the user owns | | StoreManager | Fetches products, drives purchases, exposes per-product states | | ConsumableManager | Dedicated manager for consumable products (credits, tips, tokens) | | StoreRules | Declarative visibility rules — what to show/hide based on ownership | | TierCapabilities | Maps features to tiers with CapabilityRule values | | StoreConfig | Declares which tiers/products conflict (should not coexist) |
Setup
1. Define your tiers
enum AppTier: Int, ProductTierRepresentable {
case pro // tierLevel = 1 (most premium)
case basic // tierLevel = 2
case family // tierLevel = 3 (could be same or different)
var displayName: LocalizedStringResource { ... }
var description: LocalizedStringResource { ... }
var tierLevel: Int { rawValue }
}Lower tierLevel = more premium. This drives upgrade/downgrade detection.
2. Define your products
enum AppProduct: String, StoreProductRepresentable, CaseIterable {
case proMonthly = "com.app.pro.monthly"
case proYearly = "com.app.pro.yearly"
case basicMonthly = "com.app.basic.monthly"
case familyLifetime = "com.app.family.lifetime"
typealias Tier = AppTier
var sortOrder: Int { ... }
var productType: Product.ProductType {
switch self {
case .familyLifetime: return .nonConsumable
default: return .autoRenewable
}
}
static var groupedByTier: [AppTier: [AppProduct]] {
[
.pro: [.proMonthly, .proYearly],
.basic: [.basicMonthly],
.family: [.familyLifetime]
]
}
}3. Define your capabilities
enum Feature { case export, darkMode, cloudSync }
struct AppCapabilities: TierCapabilities {
typealias Tier = AppTier
typealias Feature = Feature
typealias CapabilityValue = CapabilityRule
var capabilities: [Feature: [AppTier: CapabilityRule]] {
[
.export: [.pro: .unrestricted, .basic: .unavailable, .family: .unrestricted],
.darkMode: [.pro: .allowed(true), .basic: .allowed(true), .family: .allowed(true)],
.cloudSync: [.pro: .unrestricted, .basic: .limit(30), .family: .unrestricted]
]
}
}4. Define your store rules
This is the key differentiator. StoreRules controls what appears on your paywall based on what the user already owns.
let rules = StoreRules<AppProduct>(
// Products shown when user owns nothing
defaultVisible: [.proMonthly, .proYearly, .basicMonthly],
// When user owns X, hide Y from storefront
hideMap: [
.proYearly: [.proMonthly], // owning annual → hide monthly
.basicMonthly: [.proMonthly] // owning basic → hide pro monthly (show yearly upgrade instead)
],
// When user owns X, show Y (normally hidden)
showMap: [
.basicMonthly: [.familyLifetime] // on basic sub → reveal family lifetime
],
// When user owns X, hide the entire tier group
groupHideMap: [
.familyLifetime: [.pro, .basic] // owning family lifetime → hide all subscription tiers
]
)5. Wire it together
@main
struct MyApp: App {
@State private var entitlementManager = EntitlementManager<AppProduct, AppTier, AppCapabilities>(
config: AppCapabilities(),
defaultTier: nil // nil = unauthenticated/free state
)
@State private var storeManager: MyStoreManager
init() {
let em = EntitlementManager<AppProduct, AppTier, AppCapabilities>(
config: AppCapabilities()
)
_entitlementManager = State(initialValue: em)
_storeManager = State(initialValue: MyStoreManager(
entitlementManager: em,
config: .defaultConfig,
rules: rules
))
}
var body: some Scene {
WindowGroup {
ContentView()
.environment(entitlementManager)
.environment(storeManager)
}
}
}
// Subclass StoreManager to add your app-specific logic
@MainActor @Observable
final class MyStoreManager: StoreManager<AppProduct, AppTier, EntitlementManager<AppProduct, AppTier, AppCapabilities>> {}Note: Call
entitlementManager.invalidate()andconsumableManager.invalidate()when tearing down to cancel background tasks. This is a temporary workaround for a Swift 6.2 compiler issue.
Displaying Products
StoreManager.products(for:) returns only the products the user should see for a given tier, after applying StoreRules.
struct PaywallView: View {
@Environment(MyStoreManager.self) var store
var body: some View {
VStack {
// Only shows products that pass visibility rules for the user's current ownership
ForEach(store.products(for: .pro), id: \.id) { product in
ProductRow(product: product, store: store)
}
ForEach(store.products(for: .basic), id: \.id) { product in
ProductRow(product: product, store: store)
}
}
.task { await store.refreshAll() }
}
}Purchasing
let outcome = await store.purchase(product)
// or with promotional offers:
let outcome = await store.purchase(product, options: [.promotionalOffer(offerID: id, keyID: keyID, nonce: nonce, signature: signature, timestamp: ts)])
switch outcome {
case .success: dismiss()
case .cancelled: break
case .pending: showPendingBanner()
case .failed(let error):
if let storeError = error as? StoreError, storeError == .purchasesUnavailable {
showParentalControlsAlert()
} else {
showGenericError(error)
}
}Before showing a "Buy" button, use canPurchase to avoid presenting already-active products:
Button("Subscribe") {
Task { await store.purchase(product) }
}
.disabled(!store.canPurchase(product))Purchase States
Each product has a PurchaseState you can read from store.purchaseState(for: product):
| State | Meaning | | --- | --- | | .ready(price:) | Available for purchase | | .purchasing | Purchase flow in progress | | .pending | Awaiting parent/Ask to Buy approval | | .failed(Error) | Purchase failed | | .active(type:) | Currently owned and active | | .cancelled(timeRemaining:) | Cancelled but access continues until expiry | | .upcoming(activationDate:) | Scheduled upgrade or downgrade |
switch store.purchaseState(for: product) {
case .active:
Label("Current plan", systemImage: "checkmark.circle.fill")
case .cancelled(let remaining):
Text("Expires in \(remaining.formatted())")
case .upcoming(let date):
Text("Activates \(date?.formatted() ?? "soon")")
default:
Text(product.displayPrice)
}Feature Access
Check capabilities from EntitlementManager:
@Environment(EntitlementManager<AppProduct, AppTier, AppCapabilities>.self) var entitlements
// Can the user export?
if entitlements.hasAccess(to: .export) { ... }
// How many months of history can they see?
let months = entitlements.limit(for: .cloudSync) ?? 0
// When does their trial feature expire?
let expiry = entitlements.expiry(for: .darkMode)
// What tier are they on?
switch entitlements.effectiveTier {
case .pro: showProUI()
case .basic: showBasicUI()
case .family: showFamilyUI()
case nil: showFreeUI()
}CapabilityRule values
| Rule | isAccessible | Use case | | --- | --- | --- | | .allowed(true) | true | Simple on/off toggle | | .allowed(false) | false | Feature blocked for tier | | .limit(n) | true | Access with quantity cap (check .limit) | | .until(date) | date > now | Time-bounded access | | .unrestricted | true | Full access, no limit | | .unavailable | false | Feature does not exist at this tier |
Family Sharing
Both LifetimeEntitlement and SubscriptionEntitlement expose ownership type:
if let sub = entitlements.activeSubscription, sub.isFamilyShared {
Text("Shared via Family Sharing")
}
for lifetime in entitlements.lifetimeEntitlements where lifetime.isFamilyShared {
Text("\(lifetime.productID) is family shared")
}Consumables
Use ConsumableManager for credits, hearts, tip-jar purchases, or anything that can be bought multiple times.
@State private var consumableManager = ConsumableManager<AppProduct>()
// Set the delivery handler before any purchase can occur.
// This is called with a verified transaction before it is finished.
consumableManager.onDeliver = { transaction in
switch AppProduct(rawValue: transaction.productID) {
case .credits100: await creditsStore.add(100)
case .credits500: await creditsStore.add(500)
default: break
}
}
// Load products
await consumableManager.fetchProducts()
// Purchase
let outcome = await consumableManager.purchase(product)The handler is guaranteed to be called before transaction.finish(). If the app crashes between purchase and delivery, the transaction re-delivers on the next launch.
Conflict Detection
Use StoreConfig to declare which tiers or products should never coexist. StoreManager.hasConflictingPlans reports whether the current user state violates any rule — useful for support diagnostics or admin tooling.
let config = StoreConfig<AppTier, AppProduct>(
conflictGroups: [.pro: [.basic]], // pro and basic subscriptions simultaneously
conflictProducts: [.proYearly: [.proMonthly]] // both billing periods
)
if store.hasConflictingPlans {
// Prompt user to contact support
}You can also call config.hasConflicts(activeTiers:ownedProducts:) directly in your own code or tests, without needing a store instance.
Transaction History
// Full history (for a "Purchases" screen or refund support)
let transactions = await store.allTransactions()
// Most recent transaction for a specific product
if let tx = await store.latestTransaction(for: "com.app.pro.monthly") {
// Pass tx.id to Apple's refund request API if needed
}SwiftUI Sheet Helpers
StoreManager exposes two booleans that wire directly to SwiftUI's subscription management modifiers:
Button("Manage Subscription") {
store.showManageSubscriptionsSheet = true
}
.manageSubscriptionsSheet(isPresented: Binding(
get: { store.showManageSubscriptionsSheet },
set: { store.showManageSubscriptionsSheet = $0 }
))
Button("Redeem Offer Code") {
store.showOfferCodeRedemption = true
}
.offerCodeRedemption(isPresented: Binding(
get: { store.showOfferCodeRedemption },
set: { store.showOfferCodeRedemption = $0 }
))Protocols
Implement these to integrate SKManager with your app's product/tier model:
| Protocol | Required members | | --- | --- | | StoreProductRepresentable | rawValue: String, sortOrder, productType, groupedByTier, Tier | | ProductTierRepresentable | displayName, description, tierLevel: Int | | TierCapabilities | capabilities, isAccessible(_:) | | EntitlementProvider | Implement a custom entitlement backend instead of EntitlementManager |
Contributing
Pull requests for bug fixes, improvements, and documentation are welcome.
License
SKManager is available under the MIT License. See the LICENCE file for details.
Package Metadata
Repository: markbattistella/skmanager
Default branch: main
README: README.md