diff --git a/README.md b/README.md index 5dd4bbf..1391ac2 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 + + 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 @@ -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) @@ -177,3 +235,5 @@ command. ## License StoreTransactionKit is available under the MIT License. + +[understanding]: https://lynnswap.github.io/StoreTransactionKit/documentation/storetransactionkit/understandingtransactionhandling diff --git a/Sources/StoreTransactionKit/StoreTransactionFailure.swift b/Sources/StoreTransactionKit/StoreTransactionFailure.swift index 2fd2bf6..2b01604 100644 --- a/Sources/StoreTransactionKit/StoreTransactionFailure.swift +++ b/Sources/StoreTransactionKit/StoreTransactionFailure.swift @@ -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 @@ -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. diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md index c0cf6a1..0f437e7 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md @@ -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(_:)``. + + 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 @@ -43,6 +41,10 @@ begin outside the app. ## Topics +### Essentials + +- + ### Creating a store - ``TransactionStore`` diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md new file mode 100644 index 0000000..14cf719 --- /dev/null +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md @@ -0,0 +1,131 @@ +# Understanding transaction handling + +How StoreTransactionKit turns StoreKit 2 deliveries into durable, observable +entitlement state — and what that model asks of your handler and your UI. + +## Delivery paths and deduplication + +Every purchase converges on one FIFO transaction processor, whichever path it +arrives by: a direct `Product.PurchaseResult` passed to +``TransactionStore/process(_:)``, a delivery from `Transaction.updates`, or an +unfinished transaction reconciled at startup and on entitlement refreshes. + +Each delivery is identified by its exact JWS revision. When the same revision +arrives through several paths — StoreKit delivers launch-time unfinished +transactions through `Transaction.updates` as well — concurrent deliveries +join the in-flight attempt and completed revisions are suppressed. The +suppression cache is process-local and bounded, which is why the handler +contract is at-least-once rather than exactly-once: the handler must stay +idempotent across process launches and cache eviction. + +## Reconciliation before publication + +Startup and every entitlement refresh query `Transaction.unfinished` and +durably handle each verified delivery — including consumables — before the +entitlement projection is published, so published entitlement state never +runs ahead of the durable ledger for transactions this device still reports +as unfinished. A transaction that was already finished elsewhere — on another +device, or by a previous process — can appear in the projection without a +local handler invocation; while the app is running, purchases completed on +other devices reach the handler through `Transaction.updates`. + +When the handler throws, the transaction is not finished and that refresh (or +startup readiness) fails with the handler's error. The failed work is not +retried in a loop; the next refresh, or the next arriving transaction, opens a +new attempt and retries it. Because public operations other than +``TransactionStore/close()`` wait for the startup attempt, a handler that +hangs blocks the store — return or throw promptly and let a later refresh +retry. + +## Verification + +Snapshots exist only for transactions that StoreKit verified; the handler and +the projection never observe unverified data. An unverified purchase result +passed to ``TransactionStore/process(_:)`` throws a +``StoreTransactionVerificationError`` to that caller and is not reported to +the failure callback. Unverified elements accepted by background monitoring, +reconciliation, or projection-query paths are reported through the failure +callback instead: + +- From `Transaction.updates`, with source + ``StoreTransactionBackgroundFailure/Source/updates``. +- From `Transaction.unfinished` reconciliation, with source + ``StoreTransactionBackgroundFailure/Source/unfinished``. +- From a current-entitlement query, with source + ``StoreTransactionBackgroundFailure/Source/currentEntitlementVerification``; + the element is omitted and the verified remainder still publishes. + +## The entitlement projection + +``TransactionStore/entitlements`` is `nil` until the first query resolves and +non-`nil` empty when nothing is currently entitled. Its transactions follow +the stable order documented on ``StoreEntitlements/transactions``. StoreKit +itself excludes refunded and revoked transactions from current entitlements; +the store additionally excludes transactions superseded by a subscription +upgrade from ``TransactionStore/activeEntitlements`` while keeping them in the +complete snapshot. + +The projection refreshes at startup, after each processed transaction, on +subscription status changes, and on explicit +``TransactionStore/refreshEntitlements()`` or +``TransactionStore/restorePurchases()`` calls. + +Two subscription nuances live outside this projection. A subscription in a +billing grace period stays entitled while its snapshot's `expirationDate` is +already past, so render renewal and billing state from +`Product.SubscriptionInfo.Status` rather than from dates. And entitlement +identifiers map 1:1 to product identifiers, so gate access on the tier set or +on `StoreTransactionSnapshot.subscriptionGroupID` when any tier of a group +grants the same access. + +## Failure reporting and readiness + +Failure delivery and readiness state are related but separate contracts. +Public operations deliver terminal errors to their attached callers by +throwing. Background-owned physical work reports a failure once through the +failure callback as a ``StoreTransactionBackgroundFailure``. This includes +background deliveries, unfinished and current-entitlement verification, and +direct operations whose every waiting caller cancelled. + +Some public operations contain child work whose physical attempt may already +be owned by another producer. A failed `Transaction.unfinished` +reconciliation attempt both fails the enclosing startup or refresh and is +reported once by that attempt's reporting owner. Its source is +``StoreTransactionBackgroundFailure/Source/unfinished`` when reconciliation +owns the attempt, or the existing owner's source — such as +``StoreTransactionBackgroundFailure/Source/updates`` — when reconciliation +joins in-flight work. The enclosing operation propagates the underlying error +but doesn't report that physical failure a second time. + +The initial readiness attempt has no throwing public caller. +``TransactionStore/startupError`` reflects its error for observable UI state. +A failed startup unfinished reconciliation therefore appears both as one +owner-sourced report and as `startupError`; that report isn't necessarily +`.unfinished` when the attempt was already in flight. An entitlement query +failure with no separate reporting owner appears only as `startupError`. A +later successful entitlement refresh — which also retries failed unfinished +work — clears the property; call +``TransactionStore/refreshEntitlements()`` from a retry affordance in the UI. + +The failure callback receives admitted failures serially and losslessly with +backpressure. Every reporting path waits for the callback to return, and +``TransactionStore/close()`` drains admitted callbacks. Record or enqueue each +failure promptly instead of waiting indefinitely. + +## Lifecycle + +Create one store per process. A second store would run its own listeners and +hold independent `finish()` authority over the same transactions, so one +store's handler failure could be masked by the other store finishing first. + +``TransactionStore/close()`` stops the producers and drains every accepted +operation and callback; dropping the last reference is not an awaitable +shutdown. Production apps normally retain the store for the process lifetime +and never call it. + +The injected callbacks must not call back into the same store, directly or +through an awaited child or detached task: the callback runs on the same +worker the re-entrant operation would wait for, so the call becomes a +dependency cycle. Propagated-context reentrancy is rejected with +``StoreTransactionError/reentrantOperation(operation:)``; a detached task +escapes that guard but still forms the cycle. diff --git a/Sources/StoreTransactionKit/StoreTransactionSession.swift b/Sources/StoreTransactionKit/StoreTransactionSession.swift index e79df6e..1f06574 100644 --- a/Sources/StoreTransactionKit/StoreTransactionSession.swift +++ b/Sources/StoreTransactionKit/StoreTransactionSession.swift @@ -39,10 +39,15 @@ package actor StoreTransactionSession { /// successfully. /// - entitlementsDidChange: Receives complete, ordered entitlement /// snapshots when the current entitlement content changes. - /// - reportFailure: Receives failures from process-owned work that has no - /// attached public caller. The callback is lossless and backpressured. + /// - reportFailure: Receives failures owned by process background work, + /// including background deliveries, reconciliation verification, and + /// direct operations abandoned by every caller. Admitted failures are + /// delivered serially and losslessly with backpressure. The callback + /// must return promptly; ``close()`` waits for every admitted callback + /// to finish. /// - /// None of the callbacks may call back into the same session. + /// None of the callbacks may call back into the same session, directly or + /// through an awaited child or detached task. package init( sessionID: UUID = UUID(), handleTransaction: @@ -211,7 +216,7 @@ package actor StoreTransactionSession { /// - Returns: The entitlement publication from a query reserved after sync succeeds. /// - Throws: A synchronization, verification, query, lifecycle, callback /// reentrancy, or caller cancellation error. StoreKit may throw - /// ``StoreKit/StoreKitError/userCancelled`` when the user dismisses + /// `StoreKitError.userCancelled` when the user dismisses /// authentication; callers should treat that as a normal user outcome. package func restorePurchases() async throws -> StoreEntitlements { let runtime = try runningRuntime(operation: .restorePurchases) diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index a7c9bb2..3cd518d 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -15,6 +15,9 @@ import StoreKit /// ``activeEntitlements``. The complete verified projection remains available /// through ``entitlements`` so identifiers outside that app-defined type are /// never hidden. +/// +/// describes the delivery, +/// reconciliation, and failure-reporting model behind this type. @MainActor @Observable public final class TransactionStore @@ -68,11 +71,16 @@ where /// handler-delivery contract, so the handler must be idempotent. /// StoreTransactionKit calls `finish()` only after the closure returns /// successfully. The handler must not call back into the same store, - /// directly or through an awaited child task, because doing so creates a - /// dependency cycle with the operation being handled. - /// - reportFailure: Receives failures from process-owned transaction work - /// that has no attached public caller. This callback must not call back - /// into the same store, directly or through an awaited child task. + /// directly or through an awaited child or detached task, because doing + /// so creates a dependency cycle with the operation being handled. + /// - reportFailure: Receives failures owned by process background work, + /// including background deliveries, unfinished and current-entitlement + /// verification, and direct operations abandoned by every caller. A + /// background-owned failure can also fail an enclosing startup or + /// refresh. Admitted failures are delivered serially with backpressure, + /// and ``close()`` waits for every callback to return. Record or enqueue + /// each failure promptly. This callback must not call back into the same + /// store, directly or through an awaited child or detached task. public convenience init( handleTransaction: @escaping @Sendable (StoreTransactionSnapshot) async throws -> Void, @@ -176,7 +184,7 @@ where /// /// This method can present authentication UI. It refreshes observable /// entitlement state before returning. StoreKit may throw - /// ``StoreKit/StoreKitError/userCancelled`` when the user dismisses + /// `StoreKitError.userCancelled` when the user dismisses /// authentication; treat that as a normal user outcome rather than a /// diagnostic failure. @discardableResult