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
25 changes: 24 additions & 1 deletion Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ public enum Plans: AutoRenewableSubscriptionGroup<SubscriptionEntitlement> {

public let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self)

public let legacySubscriptionProductID =
"external-consumer.subscription.legacy"

@MainActor
public final class NotesViewModel {
private let store: TransactionStore<SubscriptionEntitlement>
Expand Down Expand Up @@ -56,14 +59,34 @@ public actor AppTransactionDelegate: TransactionStoreDelegate {
}
}

public actor AppUnrecognizedSubscriptionDelegate:
UnrecognizedSubscriptionDelegate<SubscriptionEntitlement>
{
public init() {}

public func decidePolicy(
forUnrecognizedSubscription transaction: StoreTransactionSnapshot
) async throws -> UnrecognizedSubscriptionPolicy<SubscriptionEntitlement> {
if transaction.productID == legacySubscriptionProductID {
.treatAs(.tier1)
} else {
.leaveUnfinished
}
}
}

@main
@MainActor
struct Consumer {
static func main() async throws {
let delegate = AppTransactionDelegate()
let unrecognizedSubscriptionDelegate =
AppUnrecognizedSubscriptionDelegate()
let store = TransactionStore(
subscriptionCatalog: subscriptionCatalog,
delegate: delegate
delegate: delegate,
unrecognizedSubscriptionDelegate:
unrecognizedSubscriptionDelegate
)
let viewModel = NotesViewModel(store: store)
print("Can export PDF: \(viewModel.canExportPDF)")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,28 @@ func subscriptionUpdatesViewModel() async throws {
#expect(viewModel.canExportPDF)
}
}

@Test
@MainActor
func unrecognizedSubscriptionUpdatesViewModel() async throws {
let delegate = AppUnrecognizedSubscriptionDelegate()

try await withTransactionStoreTestHarness(
subscriptionCatalog: subscriptionCatalog,
unrecognizedSubscriptionDelegate: delegate
) { harness in
let viewModel = NotesViewModel(store: harness.store)
let transaction = try harness.makeUnrecognizedSubscription(
productID: legacySubscriptionProductID,
in: Plans.self
)

#expect(!viewModel.canExportPDF)
#expect(
try await harness.deliver(transaction)
== .completed(transaction)
)
#expect(harness.store.entitlements?.transactions == [transaction])
#expect(viewModel.canExportPDF)
}
}
62 changes: 54 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,15 +127,18 @@ struct ContentView: View {
}
}
.sheet(isPresented: $isShowingPaywall) {
SubscriptionStoreView(groupID: Plans.id.rawValue)
SubscriptionStoreView(
productIDs: Plans.subscriptions.map(\.id.rawValue)
)
}
}
}
```

No `onInAppPurchaseCompletion` modifier is needed for the default StoreKit-view
flow. Successful purchases arrive through `Transaction.updates`, which the
store monitors. Use the same Product IDs in the active `.storekit`
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.

## Entitlement availability
Expand Down Expand Up @@ -166,12 +169,54 @@ let store = TransactionStore(
)
```

## Unrecognized subscriptions

A non-upgraded product added to the same subscription group after this binary
ships can still arrive through another device or purchase path. 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:

```swift
actor AppUnrecognizedSubscriptionDelegate:
UnrecognizedSubscriptionDelegate<SubscriptionEntitlement>
Comment thread
lynnswap marked this conversation as resolved.
{
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](Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md)
for the complete policy contract.

## Transaction delegate

The delegate is optional. Supply one only when the app owns an additional
durable transaction effect, handles a product outside the subscription catalog,
or needs background-failure notifications. Without a delegate, automatic
handling finishes only catalog-validated auto-renewable subscriptions.
`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.
Expand All @@ -193,8 +238,9 @@ actor AppTransactionDelegate: TransactionStoreDelegate {
}
```

The store serializes policy decisions and background notifications separately.
Do not call back into the same store from either delegate method.
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.

## Testing

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,7 @@ where Entitlement: Hashable & Sendable {
}

private let query: @Sendable (Bool) async throws -> CurrentEntitlementReconciliation
private let project:
@Sendable (StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError)
-> Set<Entitlement>
private let project: @Sendable (StoreEntitlements) async throws -> Set<Entitlement>
private let didComplete: @Sendable (EntitlementRefreshOutcome<Entitlement>) async -> Void
private let failures: FailureReporterDispatcher
private let lifetime: TransactionStoreLifecycle?
Expand All @@ -63,7 +61,7 @@ where Entitlement: Hashable & Sendable {
@escaping @Sendable (Bool) async throws
-> CurrentEntitlementReconciliation,
project:
@escaping @Sendable (StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError)
@escaping @Sendable (StoreEntitlements) async throws
-> Set<Entitlement>,
didComplete:
@escaping @Sendable (EntitlementRefreshOutcome<Entitlement>) async
Expand Down Expand Up @@ -261,7 +259,7 @@ where Entitlement: Hashable & Sendable {
)
let publication = EntitlementPublication(
entitlements: entitlements,
activeEntitlements: try project(entitlements)
activeEntitlements: try await project(entitlements)
)
current = entitlements
await didComplete(.success(publication))
Expand All @@ -271,14 +269,17 @@ where Entitlement: Hashable & Sendable {
for reservation in reservations {
reservation.receipt.succeed(publication)
}
} catch let error {
await didComplete(.catalogFailure(error))
for claim in reconciliation.causalClaims {
await claim.fail(error)
}
for reservation in reservations {
reservation.receipt.fail(error)
}
} catch {
await commitFailure(
error,
causalFailures: reconciliation.causalClaims.map {
CurrentEntitlementCausalFailure(
claim: $0,
error: error
)
},
reservationReceipts: reservations.map(\.receipt)
)
}
await report(reconciliation.diagnostics)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ public enum EntitlementStatus: Sendable {
/// A complete live entitlement snapshot is available.
///
/// Both raw and typed entitlement collections are authoritative in this
/// state, including when they are empty.
/// state, including when they are empty. The raw collection may contain an
/// unrecognized same-group subscription that grants no typed entitlement.
case ready

/// App-supplied entitlements are authoritative instead of StoreKit state.
Expand Down
Loading