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
7 changes: 6 additions & 1 deletion Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ public let legacySubscriptionProductID =
public final class NotesViewModel {
private let store: TransactionStore<SubscriptionEntitlement>

public var hasPremiumAccess: Bool {
store.isEntitled(to: .tier1)
|| store.isEntitled(to: .tier2)
}

public var canExportPDF: Bool {
store.isEntitled(to: .tier1)
}
Expand Down Expand Up @@ -89,7 +94,7 @@ struct Consumer {
unrecognizedSubscriptionDelegate
)
let viewModel = NotesViewModel(store: store)
print("Can export PDF: \(viewModel.canExportPDF)")
print("Has premium access: \(viewModel.hasPremiumAccess)")
try await store.close()
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import Consumer
import StoreTransactionKit
import StoreTransactionKitTesting
import StoreKit
import Testing

@Test
@MainActor
func subscriptionUpdatesViewModel() async throws {
func tier1PurchaseAndExpirationUpdateViewModel() async throws {
try await withTransactionStoreTestHarness(
subscriptionCatalog: subscriptionCatalog
) { harness in
let viewModel = NotesViewModel(store: harness.store)

#expect(!viewModel.hasPremiumAccess)
#expect(!viewModel.canExportPDF)

let transaction = try await harness.purchase(
Expand All @@ -19,7 +21,33 @@ func subscriptionUpdatesViewModel() async throws {
)

#expect(transaction.productID == Plans.ProductID.tier1_Monthly.rawValue)
#expect(viewModel.hasPremiumAccess)
#expect(viewModel.canExportPDF)

#expect(
try await harness.expireActiveSubscription()
== transaction
)
#expect(!viewModel.hasPremiumAccess)
#expect(!viewModel.canExportPDF)
}
}

@Test
@MainActor
func tier2GrantsCommonPremiumWithoutTier1Export() async throws {
try await withTransactionStoreTestHarness(
subscriptionCatalog: subscriptionCatalog
) { harness in
let viewModel = NotesViewModel(store: harness.store)

_ = try await harness.purchase(
.tier2_Monthly,
in: Plans.self
)

#expect(viewModel.hasPremiumAccess)
#expect(!viewModel.canExportPDF)
}
}

Expand Down Expand Up @@ -47,3 +75,38 @@ func unrecognizedSubscriptionUpdatesViewModel() async throws {
#expect(viewModel.canExportPDF)
}
}

@Test
@MainActor
func unmanagedTransactionUsesProductionDelegateRoute() async throws {
let delegate = ExternalUnmanagedTransactionDelegate()

try await withTransactionStoreTestHarness(
subscriptionCatalog: subscriptionCatalog,
delegate: delegate
) { harness in
let transaction = harness.makeTransaction(
productID: "external-consumer.tokens",
productType: .consumable
)
let outcome = try await harness.deliver(transaction)

#expect(outcome == .completed(transaction))
#expect(await delegate.transactions == [transaction])
#expect(harness.store.entitlements?.transactions == [])
#expect(harness.store.activeEntitlements == [])
}
}

private actor ExternalUnmanagedTransactionDelegate:
TransactionStoreDelegate
{
private(set) var transactions: [StoreTransactionSnapshot] = []

func decidePolicy(
for transaction: StoreTransactionSnapshot
) async throws -> StoreTransactionHandlingPolicy {
transactions.append(transaction)
return .finish
}
}
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self)

Use the subscription group ID and Product IDs exactly as configured in
[App Store Connect][subscription-setup]. Monthly and yearly subscriptions can
grant the same app entitlement.
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:

Expand Down Expand Up @@ -89,8 +91,8 @@ struct ExampleApp: App {
}
```

Read the store directly and gate only the paid feature. Entitlement
availability does not need to block the rest of the UI:
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:

```swift
import StoreKit
Expand All @@ -101,6 +103,11 @@ 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)
}
Expand All @@ -114,6 +121,11 @@ struct ContentView: View {
}

Section {
NavigationLink("Premium templates") {
PremiumTemplatesView()
}
.disabled(!canUsePremiumFeatures)

Button("Export as PDF") {
exportPDF()
}
Expand Down Expand Up @@ -268,13 +280,19 @@ func subscriptionUpdatesViewModel() async throws {
)

#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. Inject `TransactionStoreTestClock` into the app
component that owns a delay or deadline.
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.
Expand Down
72 changes: 71 additions & 1 deletion Sources/StoreTransactionKit/StoreTransactionFailure.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ package struct StoreTransactionFailurePropagation: Sendable {
}

/// An error produced while operating a transaction store.
public enum StoreTransactionError: Error, Sendable {
public enum StoreTransactionError: LocalizedError, Sendable {
/// An irreversible StoreKit action that completed before a later operation failed.
public enum CompletedOperation: Sendable, Hashable {
/// The framework finished the exact transaction revision.
Expand Down Expand Up @@ -144,6 +144,76 @@ public enum StoreTransactionError: Error, Sendable {
after: CompletedOperation,
underlyingError: any Error
)

/// A localized description of the transaction-store failure.
public var errorDescription: String? {
switch self {
case .closing:
"The transaction store is closing and cannot accept new operations."

case .closed:
"The transaction store is closed."

case .unknownPurchaseResult:
"StoreKit returned an unknown purchase result."

case .unhandledTransaction(let productID, let productType):
"Transaction handling is required for product \(productID) of type "
+ "\(productType.rawValue)."

case .reentrantOperation(let operation):
"The transaction store cannot perform \(operation.errorDescription) "
+ "from a callback owned by the same store."

case .operationUnavailableInOverride(let operation):
"The transaction store cannot perform \(operation.errorDescription) "
+ "when entitlements are overridden."

case .entitlementRefreshFailed(let operation, let underlyingError):
"Entitlement refresh failed after \(operation.errorDescription): "
+ underlyingError.localizedDescription
}
}

/// A localized suggestion for recovering from the failure, when available.
public var recoverySuggestion: String? {
switch self {
case .entitlementRefreshFailed:
"Call refreshEntitlements() instead of repeating the completed StoreKit action."

case .closing, .closed, .unknownPurchaseResult, .unhandledTransaction,
.reentrantOperation, .operationUnavailableInOverride:
nil
}
}
}

private extension StoreTransactionOperation {
var errorDescription: String {
switch self {
case .processPurchase:
"purchase processing"
case .refreshEntitlements:
"entitlement refresh"
case .history:
"a transaction history query"
case .restorePurchases:
"purchase restoration"
case .close:
"store closure"
}
}
}

private extension StoreTransactionError.CompletedOperation {
var errorDescription: String {
switch self {
case .finishedTransaction(let transaction):
"finishing transaction \(transaction.id) for product \(transaction.productID)"
case .synchronizedPurchases:
"synchronizing purchases"
}
}
}

package enum StoreTransactionLifecycleError: Error, Sendable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ describe access in your app.
## Declare the catalog

An App Store Connect subscription group can contain several service levels and
several durations at each level. StoreKit owns group-level ordering, duration,
renewal, and billing state. Your app owns the access meaning.
several durations at each level. App Store Connect numbers service levels from
highest to lowest, so level 1 is the highest. StoreKit owns that upgrade and
downgrade ordering, duration, renewal, and billing state. Your app owns the
access meaning.

```swift
import StoreTransactionKit
Expand Down Expand Up @@ -52,6 +54,10 @@ The compiler keeps group-specific Product IDs and app entitlements typed. At
runtime, the catalog also validates each matching transaction's auto-renewable
product type and subscription group before publishing access.

In this example, `.tier1` and `.tier2` are exact active plan identities. The
catalog doesn't interpret StoreKit's service-level order as app feature
inclusion.

## Merchandise declared products

Pass the same declared Product IDs to StoreKit's subscription view:
Expand All @@ -74,15 +80,25 @@ See <doc:UnderstandingTransactionHandling> to choose another policy with
## Read access without blocking the UI

``TransactionStore/isEntitled(to:)`` performs exact set membership and returns
`false` while access is unavailable. Keep ordinary app content usable and gate
only the paid feature:
`false` while access is unavailable. Derive feature access from the active plan
while keeping ordinary app content usable:

```swift
private var canUsePremiumFeatures: Bool {
store.isEntitled(to: .tier1)
|| store.isEntitled(to: .tier2)
}

private var canExportPDF: Bool {
store.isEntitled(to: .tier1)
}
```

Here, tier 1 grants the features shared by both plans and the tier-1-only PDF
export, while tier 2 grants only the shared features. If several screens use the
same inclusion rule, centralize it in the app's ViewModel or feature policy
instead of repeating the membership checks.

Use ``TransactionStore/entitlementStatus`` only when the interface needs to
explain why access is unavailable. In `.ready`, an empty
``TransactionStore/activeEntitlements`` set means the query succeeded and no
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,25 @@ synthetic acknowledgement, reconciliation, and observable-state publication.
No fixed delay or global “idle” wait is needed.

A later purchase in the same group replaces the active synthetic product. The
harness models immediate current access; it does not simulate renewal timing,
billing retry, upgrade scheduling, expiration, or revocation.
harness models immediate current access, not StoreKit's billing and
subscription-timing machinery.

## Remove active subscription access

`expireActiveSubscription()` removes the harness's active subscription and
returns its snapshot after the store publishes ready-empty raw and typed state:

```swift
let expired = try await harness.expireActiveSubscription()

#expect(expired.productID == Plans.ProductID.tier1_Monthly.rawValue)
#expect(harness.store.entitlements?.transactions == [])
#expect(harness.store.activeEntitlements == [])
```

This command models the deterministic loss of current access needed by app and
ViewModel tests. It doesn't simulate StoreKit renewal, billing, time passage,
revocation, or expiration metadata.

## Exercise an unrecognized subscription

Expand Down Expand Up @@ -93,6 +110,25 @@ try await withTransactionStoreTestHarness(
harness. Replaying an older revision does not replace a later synthetic
subscription that is already current.

## Deliver an arbitrary transaction

Use `makeTransaction(productID:productType:subscriptionGroupID:isUpgraded:)`
to exercise the production classification and general delegate path with a
delivery-only snapshot:

```swift
let transaction = harness.makeTransaction(
productID: "com.example.tokens",
productType: .consumable
)

let outcome = try await harness.deliver(transaction)
```

An arbitrary transaction never changes the synthetic current-entitlement
source. Its transaction policy, exact-revision replay, and completion still use
the production pipeline.

The returned ``StoreTransactionSnapshot`` is synthetic. Its
``StoreTransactionSnapshot/jwsRepresentation`` is a deterministic sentinel, not
a signed JWS, and its transaction identifier is local to that harness. Use
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ For billing retry, grace period, and renewal presentation, use
`Product.SubscriptionInfo.Status`; do not infer subscription status only from
snapshot dates.

## Complete history queries

``TransactionStore/history(for:)`` is an all-or-nothing audit query. Its result
represents the complete verified history for the requested Product ID, so an
unverified revision causes the operation to throw instead of silently returning
an incomplete history as if it were complete.

Current entitlements serve a different, continuity-oriented purpose: their
verified remainder can publish while omitted verification failures are reported
through the background path. History accepts a raw `Product.ID` intentionally so
an app can also inspect products outside the subscription catalog.

## Failure ownership

An admitted physical failure has one delivery owner. While a direct caller
Expand Down
Loading