Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 121 additions & 61 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,34 @@
StoreTransactionKit moves StoreKit 2 transaction monitoring, verification,
durable processing, and `Transaction.finish()` into one process-owned,
observable store.
It supports iOS 18.4 and later and macOS 15.4 and later.

## Create a TransactionStore
## Requirements

- iOS 18.4+
- macOS 15.4+
- Swift 6.3+

## What it owns — and what your app owns

The store owns the durable transaction path for the process lifetime:

- Monitoring: `Transaction.updates`, `Transaction.unfinished` reconciliation,
and subscription status changes
- Verification: only verified transactions reach your code; unverified
deliveries surface as thrown errors or reported failures
- Ordering: durable handling first, then `finish()`, with at-least-once
delivery to an idempotent handler and exact-revision deduplication
- State: the observable current-entitlement projection, restore
synchronization, background failure delivery, and explicit shutdown

Your app owns everything the user sees and everything it persists:

- Paywall and purchase UI (StoreKit views or `Product.purchase`)
- The durable ledger that the transaction handler writes to
- Subscription status presentation (`Product.SubscriptionInfo.Status`)
- Purchases that begin outside the app (`PurchaseIntent.intents`)

## Quick start

Define the entitlement identifiers in the app. A string-backed enum keeps
StoreKit product identifiers typed without requiring a framework protocol.
Expand Down Expand Up @@ -59,46 +84,41 @@ func makeStore(
}
```

`TransactionStore` is `@MainActor` and `@Observable`. It monitors
`Transaction.unfinished`, `Transaction.updates`, and subscription status
changes during initialization. Retain one instance in the application's
process-lifetime composition.

Startup and each entitlement refresh reconcile `Transaction.unfinished`
before publishing entitlement state. Every verified delivery is durably
handled, including consumables. If the handler fails, startup or the refresh
fails, the transaction remains unfinished, and a later refresh retries it.
An unverified unfinished delivery is sent to `reportFailure` with source
`.unfinished`.

`activeEntitlements` contains the app-defined identifiers represented by
StoreKit's current entitlements. It is `nil` while the initial entitlement
query is unresolved and becomes a non-`nil` empty set when no known
identifier matches a current entitlement. A subscription superseded by an
upgrade is excluded from this set. `entitlements` contains the complete
verified snapshot, including superseded transactions and product identifiers
outside `SubscriptionID`. Use `StoreTransactionSnapshot.subscriptionGroupID`
when the app grants access at subscription-group rather than product-tier
granularity. A current-entitlement element that StoreKit can't verify is
omitted from the projection and delivered to `reportFailure` with source
`.currentEntitlementVerification`.

For renewal dates, grace periods, billing retry, and expiration messaging,
read `Product.SubscriptionInfo.Status` directly. `TransactionStore` owns the
durable transaction path and current-entitlement projection, not subscription
status presentation.

## Use SubscriptionStoreView

Place the same store in the SwiftUI environment. StoreKit presents and
completes the purchase; the store's transaction listener updates observable
state when the entitlement changes.
`TransactionStore` is `@MainActor` and `@Observable`. It starts monitoring
during initialization. Create the app-owned dependencies once at the
process-lifetime composition root, retain one store with SwiftUI state, and
inject that same instance into the environment:

```swift
PremiumStoreView()
.environment(store)
import SwiftUI

@main
struct ExampleApp: App {
@State private var store: TransactionStore<SubscriptionID>

init() {
let ledger = PurchaseLedger()
let diagnostics = StoreDiagnostics()
_store = State(
initialValue: makeStore(
ledger: ledger,
diagnostics: diagnostics
)
)
}

var body: some Scene {
WindowGroup {
PremiumStoreView()
.environment(store)
}
}
}
```

The view reads the store directly and renders the three entitlement states —
resolving, failed, and resolved:

```swift
import StoreKit
import SwiftUI
Expand Down Expand Up @@ -139,29 +159,67 @@ struct PremiumStoreView: View {
}
```

No `onInAppPurchaseCompletion` modifier is needed here. By default, successful
StoreKit view purchases are delivered through `Transaction.updates`, which the
store already monitors. A non-`nil` completion action replaces that default;
if you add one, pass each `.success` value (the `Product.PurchaseResult`) to
`store.process(_:)`. The action also replaces StoreKit's default failure alert,
so it must own `.failure` presentation or diagnostics.

StoreTransactionKit exposes an at-least-once handler-delivery contract, so
`PurchaseLedger.apply(_:)` must be idempotent. It must return only after the
business effect is durable. The store calls `finish()` after that return. Treat
purchase and revocation revisions as distinct durable business events;
transaction ID alone is not a sufficient idempotency key for both. The handler
must not call methods on the same store, including through an awaited detached
task, because that creates a dependency cycle with the transaction being
handled.

Apps that support promoted purchases or applicable win-back flows own
`PurchaseIntent.intents`: complete each intent's product purchase and pass its
result to `store.process(_:)`.

Call `store.restorePurchases()` only from an explicit user action because
`AppStore.sync()` presents authentication UI. Treat `StoreKitError.userCancelled`
as a normal user outcome rather than a diagnostic failure.
No `onInAppPurchaseCompletion` modifier is needed: successful StoreKit view
purchases arrive through `Transaction.updates`, which the store already
monitors. If you add a non-`nil` completion action, it replaces that default
*and* StoreKit's failure alert — pass each `.success` value to
`store.process(_:)` and own `.failure` presentation yourself.

## The callback contracts

`handleTransaction` owns the app's durable transaction correctness:

- **Be idempotent.** Delivery is at least once; key the ledger on transaction
identity plus the business event it applies.
- **Treat purchase and revocation as distinct events.** A refund or
family-sharing revocation arrives as the same transaction with
`revocationDate` set; transaction ID alone is not a sufficient key.
- **Return only after the business effect is durable.** The store calls
`finish()` after the handler returns. Throwing keeps the transaction
unfinished, and a later refresh retries it.
- **Never call back into the same store** from `handleTransaction` or
`reportFailure`, even through an awaited detached task — doing so creates a
dependency cycle with the work being handled.

`reportFailure` is also a liveness boundary. StoreTransactionKit delivers
admitted failures serially with backpressure and waits for each callback to
return; `close()` waits for those callbacks too. Record or enqueue the failure
promptly instead of performing work that can wait indefinitely.

Both callback contracts are documented on `TransactionStore.init`.

## How entitlement state behaves

- `activeEntitlements` is `nil` until the first entitlement query resolves; an
empty set means the query resolved and no known identifier matched.
- Startup and every refresh reconcile `Transaction.unfinished` — including
consumables — before publishing state. A handler failure fails that refresh;
the next refresh retries the unfinished work.
- Transactions superseded by a subscription upgrade stay in `entitlements` but
leave `activeEntitlements`.
- Unverified current-entitlement elements are omitted and reported to
`reportFailure` with source `.currentEntitlementVerification`.
- Identifiers map 1:1 to product IDs. Gate access on the tier set, or use
`StoreTransactionSnapshot.subscriptionGroupID` to grant at
subscription-group granularity.

For the full delivery, reconciliation, and failure-reporting model, see
[Understanding transaction handling][understanding].

## Beyond the basics

- **Custom purchase UI** — load products and purchase with StoreKit, then pass
the `Product.PurchaseResult` to `store.process(_:)`. `.pending` outcomes
arrive later through the handler.
- **Restore** — call `store.restorePurchases()` only from an explicit user
action; `AppStore.sync()` presents authentication UI, and
`StoreKitError.userCancelled` is a normal outcome, not a diagnostic failure.
- **Promoted purchases and win-back offers** — the app owns
`PurchaseIntent.intents`: complete each intent's purchase and pass the
result to `store.process(_:)`.
- **Renewal, grace-period, and billing-retry UI** — read
`Product.SubscriptionInfo.Status` directly; the store owns the durable
transaction path, not subscription status presentation.

For product merchandising and UI composition, use Apple's
[Getting started with In-App Purchase using StoreKit views](https://developer.apple.com/documentation/storekit/getting-started-with-in-app-purchases-using-storekit-views)
Expand All @@ -177,3 +235,5 @@ command.
## License

StoreTransactionKit is available under the MIT License.

[understanding]: https://lynnswap.github.io/StoreTransactionKit/documentation/storetransactionkit/understandingtransactionhandling
6 changes: 3 additions & 3 deletions Sources/StoreTransactionKit/StoreTransactionFailure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ package enum StoreTransactionCallback: Sendable {
case failureReporter
}

/// A failure produced by work that has no attached public caller.
/// A failure delivered through the process-owned background reporting path.
public struct StoreTransactionBackgroundFailure: Error, Sendable {
/// The background path that produced the failure.
/// The background owner that reported the failure.
public enum Source: Sendable, Hashable {
/// A delivery from `Transaction.updates`.
case updates
Expand All @@ -44,7 +44,7 @@ public struct StoreTransactionBackgroundFailure: Error, Sendable {
case abandonedDirectOperation(StoreTransactionOperation)
}

/// The background path that produced the failure.
/// The background owner that reported the failure.
public let source: Source

/// The verified transaction identifier, when verification reached a transaction snapshot.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,30 @@ verified transaction only after its durable business effect succeeds.
StoreKit can deliver a purchase as a direct `Product.PurchaseResult`, through
`Transaction.updates`, or as unfinished work on a later launch.
``TransactionStore`` normalizes these paths into one verified, FIFO transaction
processor and publishes observable current-entitlement state.

Startup and entitlement refreshes reconcile every verified transaction still
reported by `Transaction.unfinished`, including consumables, before publishing
entitlement state. A durable handler failure fails readiness or the refresh and
leaves that transaction available for a later retry. Unverified unfinished
deliveries are reported through ``StoreTransactionBackgroundFailure/Source/unfinished``.

Create one store in the application composition root. Supply an idempotent
transaction handler that commits the app's business effect before returning.
The app defines a string-backed entitlement identifier type;
processor and publishes observable current-entitlement state. It supports
iOS 18.4 and later and macOS 15.4 and later.

Create one store in the application composition root and retain it for the
process lifetime; call ``TransactionStore/close()`` only from controlled
shutdown and test lifecycles. Supply an idempotent transaction handler that
commits the app's business effect before returning. The app defines a
string-backed entitlement identifier type;
``TransactionStore/activeEntitlements`` then exposes an optional typed set
derived from StoreKit's verified current entitlements. `nil` means the initial
entitlement query remains unresolved; an empty set means the query completed
with no matching entitlement. Transactions superseded by a subscription upgrade
remain in the complete snapshot but don't appear in the typed active set. Pass
direct results from custom purchase UI into ``TransactionStore/process(_:)``.
derived from StoreKit's verified current entitlements. Pass direct results
from custom purchase UI into ``TransactionStore/process(_:)``.

<doc:UnderstandingTransactionHandling> describes the full model: delivery
paths and deduplication, unfinished-transaction reconciliation before each
entitlement publication, verification-failure reporting, and how the
projection behaves across upgrades, revocations, and grace periods.

The framework owns StoreKit verification, process-local exact-revision
coalescing, `finish()`, entitlement refresh, history ordering, restore
synchronization, background failure delivery, and explicit shutdown. The app
continues to own persistence, server communication, access presentation, and
the concrete purchase scene or window. It also owns raw
`Product.SubscriptionInfo.Status` interpretation for renewal, grace-period,
and billing-retry UI, and `PurchaseIntent.intents` handling for purchases that
begin outside the app.
continues to own persistence, server communication, access presentation, the
concrete purchase scene or window, raw `Product.SubscriptionInfo.Status`
interpretation for renewal UI, and `PurchaseIntent.intents` handling for
purchases that begin outside the app.

> Important: StoreTransactionKit exposes an at-least-once handler-delivery
> contract. Make the injected transaction handler durably idempotent using
Expand All @@ -43,6 +41,10 @@ begin outside the app.

## Topics

### Essentials

- <doc:UnderstandingTransactionHandling>

### Creating a store

- ``TransactionStore``
Expand Down
Loading