StoreTransactionKit centralizes verified transaction handling, entitlement
reconciliation, and Transaction.finish() authority in one process-owned,
observable store.
- iOS 18.4+
- macOS 15.4+
- Mac Catalyst 18.4+
- tvOS 18.4+
- watchOS 11.4+
- visionOS 2.4+
- Swift 6.3+
In Xcode, choose File > Add Package Dependencies, enter
https://github.com/lynnswap/StoreTransactionKit, and add the
StoreTransactionKit library to your app target.
Define the app's entitlements and one App Store Connect auto-renewable subscription group:
import StoreTransactionKit
enum SubscriptionEntitlement: Hashable, Sendable {
case tier1
case tier2
}
enum Plans: AutoRenewableSubscriptionGroup<SubscriptionEntitlement> {
static let id = SubscriptionGroupID(
rawValue: "YOUR_SUBSCRIPTION_GROUP_ID"
)
enum ProductID: String, Hashable, Sendable {
case tier1_Monthly = "com.example.subscription.tier1.monthly"
case tier1_Yearly = "com.example.subscription.tier1.yearly"
case tier2_Monthly = "com.example.subscription.tier2.monthly"
case tier2_Yearly = "com.example.subscription.tier2.yearly"
}
static var subscriptions: StoreSubscriptions {
StoreSubscription(.tier1_Monthly, entitlement: .tier1)
StoreSubscription(.tier1_Yearly, entitlement: .tier1)
StoreSubscription(.tier2_Monthly, entitlement: .tier2)
StoreSubscription(.tier2_Yearly, entitlement: .tier2)
}
}
let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self)Use the subscription group ID and Product IDs exactly as configured in App Store Connect. Monthly and yearly subscriptions can grant the same app entitlement. App Store Connect level 1 is the highest service level. Here, each tier identifies the exact active plan; the app decides which features that plan includes.
Create one store at the app's process-lifetime composition root:
import StoreTransactionKit
import SwiftUI
@main
struct ExampleApp: App {
@State private var store: TransactionStore<SubscriptionEntitlement>
init() {
_store = State(
initialValue: TransactionStore(
subscriptionCatalog: subscriptionCatalog
)
)
}
var body: some Scene {
WindowGroup {
NavigationStack {
ContentView()
}
.environment(store)
}
}
}Read the store directly and derive feature access from the exact active plan. Entitlement availability does not need to block the rest of the UI:
import StoreKit
import StoreTransactionKit
import SwiftUI
struct ContentView: View {
@Environment(TransactionStore<SubscriptionEntitlement>.self) private var store
@State private var isShowingPaywall = false
private var canUsePremiumFeatures: Bool {
store.isEntitled(to: .tier1)
|| store.isEntitled(to: .tier2)
}
private var canExportPDF: Bool {
store.isEntitled(to: .tier1)
}
var body: some View {
List {
Section {
NavigationLink("All notes") {
NotesView()
}
}
Section {
NavigationLink("Premium templates") {
PremiumTemplatesView()
}
.disabled(!canUsePremiumFeatures)
Button("Export as PDF") {
exportPDF()
}
.disabled(!canExportPDF)
Button("Plans and subscriptions") {
isShowingPaywall = true
}
} header: {
Text("Premium")
}
}
.sheet(isPresented: $isShowingPaywall) {
SubscriptionStoreView(
productIDs: subscriptionCatalog.productIDs
)
}
}
}No onInAppPurchaseCompletion modifier is needed for the default StoreKit-view
flow. Successful purchases arrive through Transaction.updates, which the
store monitors. Passing this binary's declared Product IDs keeps its paywall
aligned with the catalog. Use the same Product IDs in the active .storekit
configuration when running local StoreKit tests.
activeEntitlements == nilmeans no usable entitlement snapshot is available. An empty set means the query succeeded and no app entitlement is active.entitlementStatusexplains whether the store is loading, failed, ready, or using an app-supplied override.isEntitled(to:)performs exact membership and returnsfalsewhile the entitlement set is unavailable.
Keep normal app content usable while the entitlement set is unavailable. Gate only features that require an active purchase.
Load the catalog's declared products when building custom subscription UI:
import StoreKit
let products = try await Product.products(
for: subscriptionCatalog.productIDs
)Product supplies localized presentation, and Product.SubscriptionInfo
supplies subscription level and period. StoreTransactionSnapshot remains the
verified transaction projection. See
Defining subscription access
for the complete mapping example.
An app-defined debug, preview, or distribution environment can bypass StoreKit with an exact entitlement set:
let store = TransactionStore(
subscriptionCatalog: subscriptionCatalog,
overridingEntitlements: [
SubscriptionEntitlement.tier1,
.tier2,
]
)A non-upgraded product added to the same subscription group after this binary ships can still arrive after a plan change in Manage Subscriptions, an offer code or win-back offer redeemed through the App Store, or a purchase on another device. By default, the store keeps the verified raw transaction, grants no typed entitlement, and leaves an unfinished delivery unfinished.
Supply a separate delegate only when the app knows how to handle that product:
actor AppUnrecognizedSubscriptionDelegate:
UnrecognizedSubscriptionDelegate<SubscriptionEntitlement>
{
func decidePolicy(
forUnrecognizedSubscription transaction: StoreTransactionSnapshot
) async throws
-> UnrecognizedSubscriptionPolicy<SubscriptionEntitlement>
{
switch transaction.productID {
case "com.example.subscription.tier1.weekly":
.treatAs(.tier1)
default:
.leaveUnfinished
}
}
}
let store = TransactionStore(
subscriptionCatalog: subscriptionCatalog,
unrecognizedSubscriptionDelegate:
AppUnrecognizedSubscriptionDelegate()
)Return .finish to finish without granting typed access, or
.treatAs(entitlement) to finish and grant a known entitlement. See
Understanding transaction handling
for the complete policy contract.
TransactionStoreDelegate is separate from
UnrecognizedSubscriptionDelegate. Supply it only when the app owns an
additional durable effect for a declared transaction, an upgraded same-group
transaction, or an out-of-group transaction, or needs background-failure
notifications. Without it, automatic handling finishes catalog-declared and
upgraded auto-renewable subscriptions.
Return .finish only after applying an app-owned effect durably. Throwing leaves
the transaction unfinished so a later StoreKit delivery can retry it.
actor AppTransactionDelegate: TransactionStoreDelegate {
func decidePolicy(
for transaction: StoreTransactionSnapshot
) async throws -> StoreTransactionHandlingPolicy {
try await purchaseLedger.apply(transaction)
return .finish
}
func didFail(
with failure: StoreTransactionBackgroundFailure
) async {
diagnostics.record(failure)
}
}Calls to each policy delegate are serialized; background notifications use a separate serialized path. Do not call admission-bearing operations on the same store from either delegate.
App and ViewModel tests can use StoreTransactionKitTesting without a
.storekit configuration:
import StoreTransactionKit
import StoreTransactionKitTesting
import Testing
@Test
@MainActor
func subscriptionUpdatesViewModel() async throws {
try await withTransactionStoreTestHarness(
subscriptionCatalog: subscriptionCatalog
) { harness in
let viewModel = NotesViewModel(store: harness.store)
#expect(!viewModel.canExportPDF)
try await harness.purchase(
.tier1_Monthly,
in: Plans.self
)
#expect(viewModel.canExportPDF)
try await harness.expireActiveSubscription()
#expect(!viewModel.canExportPDF)
}
}purchase(_:,in:) returns after the resulting entitlement publication, so the
test needs no timing guess. expireActiveSubscription() returns after publishing
a ready empty entitlement set; it does not simulate renewal or billing time.
Inject TransactionStoreTestClock into the app component that owns a delay or
deadline.
Add both StoreTransactionKit and StoreTransactionKitTesting to the test
target. Keep only StoreTransactionKit in the production target.
The app-hosted StoreKit integration suite continues to test the live adapter. See Tools/TestApp/README.md for its scenarios and command.
StoreTransactionKit is available under the MIT License.