From 835e9f3c0612d187bd805c8503fa97d3dabfbb65 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:56:22 +0900 Subject: [PATCH 1/7] feat(store)!: handle unrecognized subscriptions Add a dedicated delegate that can leave, finish, or project valid undeclared subscriptions in the catalog group. Reuse successful decisions by exact transaction revision and keep thrown decisions retryable. BREAKING CHANGE: StorePurchaseOutcome adds leftUnfinished, and AutoRenewableSubscriptionCatalogError no longer includes undeclaredProduct. --- .../EntitlementRefreshCoordinator.swift | 26 +- .../TransactionProcessingCore.swift | 114 ++- .../Runtime/StoreTransactionRuntime.swift | 128 +++- ...recognizedSubscriptionPolicyResolver.swift | 122 +++ .../StorePurchaseOutcome.swift | 3 + .../AutoRenewableSubscriptionCatalog.swift | 50 +- ...utoRenewableSubscriptionCatalogError.swift | 10 - ...oRenewableSubscriptionClassification.swift | 7 +- .../TransactionStore.swift | 46 +- .../UnrecognizedSubscriptionDelegate.swift | 36 + ...utoRenewableSubscriptionCatalogTests.swift | 156 ++-- .../EntitlementRefreshCoordinatorTests.swift | 20 +- .../TransactionProcessingCoreTests.swift | 47 ++ .../TransactionStoreTests.swift | 8 +- .../UnrecognizedSubscriptionTests.swift | 715 ++++++++++++++++++ 15 files changed, 1229 insertions(+), 259 deletions(-) create mode 100644 Sources/StoreTransactionKit/Runtime/UnrecognizedSubscriptionPolicyResolver.swift create mode 100644 Sources/StoreTransactionKit/UnrecognizedSubscriptionDelegate.swift create mode 100644 Tests/StoreTransactionKitTests/UnrecognizedSubscriptionTests.swift diff --git a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift index ce5edc3..a3433e4 100644 --- a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift +++ b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift @@ -46,8 +46,7 @@ where Entitlement: Hashable & Sendable { private let query: @Sendable (Bool) async throws -> CurrentEntitlementReconciliation private let project: - @Sendable (StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) - -> Set + @Sendable (StoreEntitlements) async throws -> Set private let didComplete: @Sendable (EntitlementRefreshOutcome) async -> Void private let failures: FailureReporterDispatcher private let lifetime: TransactionStoreLifecycle? @@ -63,7 +62,7 @@ where Entitlement: Hashable & Sendable { @escaping @Sendable (Bool) async throws -> CurrentEntitlementReconciliation, project: - @escaping @Sendable (StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) + @escaping @Sendable (StoreEntitlements) async throws -> Set, didComplete: @escaping @Sendable (EntitlementRefreshOutcome) async @@ -261,7 +260,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)) @@ -271,14 +270,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) } diff --git a/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift b/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift index d8a6ff8..7214be7 100644 --- a/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift +++ b/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift @@ -1,18 +1,25 @@ import Foundation +package enum TransactionProcessingDisposition: Equatable, Sendable { + case finish + case leaveUnfinished +} + package struct TransactionCausalResolutionClaim: Sendable { package let value: Value package let reportingAuthority: DirectOperationReportingAuthority - private let receipt: ProcessingReceipt - private let finishSuccess: @Sendable () async -> Void + private let receipt: ProcessingReceipt + private let finishSuccess: + @Sendable () async -> TransactionProcessingDisposition private let finishFailure: @Sendable () async -> Void fileprivate init( value: Value, reportingAuthority: DirectOperationReportingAuthority, - receipt: ProcessingReceipt, - finishSuccess: @escaping @Sendable () async -> Void, + receipt: ProcessingReceipt, + finishSuccess: + @escaping @Sendable () async -> TransactionProcessingDisposition, finishFailure: @escaping @Sendable () async -> Void ) { self.value = value @@ -23,8 +30,8 @@ package struct TransactionCausalResolutionClaim: Sendable { } package func succeed() async { - await finishSuccess() - receipt.succeed(value) + let disposition = await finishSuccess() + receipt.succeed(disposition) } package func fail(_ error: any Error) async { @@ -41,11 +48,12 @@ package struct ProcessingAcceptance: Sendable { case completedObserver } - /// Completes after the handling policy and `finish()` complete. - package let receipt: ProcessingReceipt + /// Completes after policy and the selected finish or leave action complete. + package let receipt: ProcessingReceipt /// Completes after the exact revision's causal entitlement publication. - package let causalReceipt: ProcessingReceipt + package let causalReceipt: + ProcessingReceipt package let role: Role package let reportingAuthority: DirectOperationReportingAuthority package let directBinding: DirectOperationObservation.Binding? @@ -53,8 +61,8 @@ package struct ProcessingAcceptance: Sendable { private let claimCausalResolution: @Sendable () async -> TransactionCausalResolutionClaim? fileprivate init( - receipt: ProcessingReceipt, - causalReceipt: ProcessingReceipt, + receipt: ProcessingReceipt, + causalReceipt: ProcessingReceipt, role: Role, reportingAuthority: DirectOperationReportingAuthority, directBinding: DirectOperationObservation.Binding?, @@ -81,14 +89,16 @@ package actor TransactionProcessingCore { private enum AttemptPhase: Sendable { case deciding case decisionFailed - case finished + case resolved(TransactionProcessingDisposition) } private struct Attempt: Sendable { let id: UUID let value: Value - let decisionReceipt: ProcessingReceipt - let causalReceipt: ProcessingReceipt + let decisionReceipt: + ProcessingReceipt + let causalReceipt: + ProcessingReceipt let reportingAuthority: DirectOperationReportingAuthority var causalResolutionClaimed: Bool var phase: AttemptPhase @@ -101,7 +111,8 @@ package actor TransactionProcessingCore { private let sessionID: UUID private let lifetime: TransactionStoreLifecycle? - private let handle: @Sendable (Value) async throws -> Void + private let handle: + @Sendable (Value) async throws -> TransactionProcessingDisposition private var queue: [QueuedOperation] = [] private var inFlight: [Data: Attempt] = [:] private var failed: [Data: Attempt] = [:] @@ -114,7 +125,9 @@ package actor TransactionProcessingCore { package init( sessionID: UUID = UUID(), lifetime: TransactionStoreLifecycle? = nil, - handle: @escaping @Sendable (Value) async throws -> Void + handle: + @escaping @Sendable (Value) async throws + -> TransactionProcessingDisposition ) { self.sessionID = sessionID self.lifetime = lifetime @@ -156,8 +169,8 @@ package actor TransactionProcessingCore { if completed.state(for: envelope.revision) == .satisfied { let reportingAuthority = DirectOperationReportingAuthority() return ProcessingAcceptance( - receipt: .succeeded(envelope.value), - causalReceipt: .succeeded(envelope.value), + receipt: .succeeded(.finish), + causalReceipt: .succeeded(.finish), role: .completedObserver, reportingAuthority: reportingAuthority, directBinding: directObservation?.bind( @@ -169,7 +182,7 @@ package actor TransactionProcessingCore { if completed.state(for: envelope.revision) == .needsRefresh { let attempt = makeAttempt( value: envelope.value, - decisionReceipt: .succeeded(envelope.value), + decisionReceipt: .succeeded(.finish), alreadyFinished: true ) inFlight[envelope.revision] = attempt @@ -234,17 +247,20 @@ package actor TransactionProcessingCore { private func makeAttempt( value: Value, - decisionReceipt: ProcessingReceipt = ProcessingReceipt(), + decisionReceipt: + ProcessingReceipt = + ProcessingReceipt(), alreadyFinished: Bool = false ) -> Attempt { Attempt( id: UUID(), value: value, decisionReceipt: decisionReceipt, - causalReceipt: ProcessingReceipt(), + causalReceipt: + ProcessingReceipt(), reportingAuthority: DirectOperationReportingAuthority(), causalResolutionClaimed: false, - phase: alreadyFinished ? .finished : .deciding + phase: alreadyFinished ? .resolved(.finish) : .deciding ) } @@ -291,14 +307,19 @@ package actor TransactionProcessingCore { reportingAuthority: attempt.reportingAuthority, receipt: attempt.causalReceipt, finishSuccess: { [weak self] in - await self?.finishCausalResolution( + guard let self else { + preconditionFailure( + "A causal resolution outlived its processing core." + ) + } + return await self.finishCausalResolution( revision: revision, attemptID: attemptID, succeeded: true ) }, finishFailure: { [weak self] in - await self?.finishCausalResolution( + _ = await self?.finishCausalResolution( revision: revision, attemptID: attemptID, succeeded: false @@ -311,22 +332,30 @@ package actor TransactionProcessingCore { revision: Data, attemptID: UUID, succeeded: Bool - ) { + ) -> TransactionProcessingDisposition { if let attempt = inFlight[revision], attempt.id == attemptID { precondition(attempt.causalResolutionClaimed) inFlight.removeValue(forKey: revision) if succeeded { - precondition(attempt.phase == .finished) - completed.insert(revision, state: .satisfied) - } else if attempt.phase == .decisionFailed { + guard case .resolved(let disposition) = attempt.phase else { + preconditionFailure( + "Causal resolution completed before transaction policy." + ) + } + if disposition == .finish { + completed.insert(revision, state: .satisfied) + } + return disposition + } else if case .decisionFailed = attempt.phase { failed[revision] = attempt } - return + return .leaveUnfinished } if let attempt = failed[revision], attempt.id == attemptID { precondition(attempt.causalResolutionClaimed) - return + return .leaveUnfinished } + preconditionFailure("A causal resolution lost its processing attempt.") } private func startWorkerIfNeeded() { @@ -349,16 +378,19 @@ package actor TransactionProcessingCore { preconditionFailure("A queued transaction lost its processing attempt.") } do { - try await StoreTransactionCallbackContext.$current.withValue( - StoreTransactionCallbackInvocation( - sessionID: sessionID, - callback: .transactionHandler - ) - ) { - try await handle(operation.envelope.value) + let disposition = + try await StoreTransactionCallbackContext.$current.withValue( + StoreTransactionCallbackInvocation( + sessionID: sessionID, + callback: .transactionHandler + ) + ) { + try await handle(operation.envelope.value) + } + if disposition == .finish { + await operation.envelope.finish() + completed.insert(operation.envelope.revision) } - await operation.envelope.finish() - completed.insert(operation.envelope.revision) guard var finishedAttempt = inFlight[operation.envelope.revision], finishedAttempt.id == operation.attemptID @@ -367,9 +399,9 @@ package actor TransactionProcessingCore { "A finished transaction lost its processing attempt." ) } - finishedAttempt.phase = .finished + finishedAttempt.phase = .resolved(disposition) inFlight[operation.envelope.revision] = finishedAttempt - attempt.decisionReceipt.succeed(operation.envelope.value) + attempt.decisionReceipt.succeed(disposition) } catch { guard var failedAttempt = inFlight[operation.envelope.revision], diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift index 61ded61..dba30f5 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift @@ -16,6 +16,8 @@ where Entitlement: Hashable & Sendable { private let source: StoreTransactionSource private let lifecycle: TransactionStoreLifecycle private let delegate: TransactionStoreDelegateReference + private let unrecognizedSubscriptions: + UnrecognizedSubscriptionPolicyResolver private let core: TransactionProcessingCore private let entitlements: EntitlementRefreshCoordinator private let failures: FailureReporterDispatcher @@ -33,6 +35,8 @@ where Entitlement: Hashable & Sendable { lifecycle: TransactionStoreLifecycle, subscriptionCatalog: AutoRenewableSubscriptionCatalog, delegate: (any TransactionStoreDelegate)?, + unrecognizedSubscriptionDelegate: + (any UnrecognizedSubscriptionDelegate)? = nil, entitlementOutcome: @escaping @Sendable (EntitlementRefreshOutcome) async -> Void, @@ -45,6 +49,12 @@ where Entitlement: Hashable & Sendable { let delegateReference = TransactionStoreDelegateReference(delegate) self.delegate = delegateReference + let unrecognizedSubscriptions = + UnrecognizedSubscriptionPolicyResolver( + sessionID: sessionID, + delegate: unrecognizedSubscriptionDelegate + ) + self.unrecognizedSubscriptions = unrecognizedSubscriptions let failures = FailureReporterDispatcher( sessionID: sessionID, lifetime: lifecycle, @@ -58,7 +68,8 @@ where Entitlement: Hashable & Sendable { sessionID: sessionID, lifetime: lifecycle, handle: { transaction in - let classification: AutoRenewableSubscriptionClassification + let classification: + AutoRenewableSubscriptionClassification do { classification = try subscriptionCatalog.classification( of: transaction @@ -67,19 +78,34 @@ where Entitlement: Hashable & Sendable { throw StoreTransactionCatalogFailure(error: error) } - let policy = try await delegateReference.decidePolicy( - for: transaction - ) - switch (classification, policy) { - case (.managed, .automatic), - (.managed, .finish), - (.unmanaged, .finish): - return - case (.unmanaged, .automatic): - throw StoreTransactionError.unhandledTransaction( - productID: transaction.productID, - productType: transaction.productType + switch classification { + case .unrecognized: + switch try await unrecognizedSubscriptions.policy( + for: transaction + ) { + case .leaveUnfinished: + return .leaveUnfinished + case .finish, .treatAs: + return .finish + } + + case .declared, .retiredUpgraded: + _ = try await delegateReference.decidePolicy( + for: transaction + ) + return .finish + + case .unmanaged: + let policy = try await delegateReference.decidePolicy( + for: transaction ) + guard policy == .finish else { + throw StoreTransactionError.unhandledTransaction( + productID: transaction.productID, + productType: transaction.productType + ) + } + return .finish } } ) @@ -96,10 +122,39 @@ where Entitlement: Hashable & Sendable { retryFailedTransactions: retryFailedTransactions ) }, - project: { - (entitlements: StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) - -> Set in - try subscriptionCatalog.activeEntitlements(in: entitlements) + project: { entitlements in + var activeEntitlements: Set = [] + for transaction in entitlements.transactions { + let classification: + AutoRenewableSubscriptionClassification + do { + classification = try subscriptionCatalog.classification( + of: transaction + ) + } catch let error + as AutoRenewableSubscriptionCatalogError + { + throw StoreTransactionCatalogFailure(error: error) + } + + switch classification { + case .declared(let entitlement): + if !transaction.isUpgraded { + activeEntitlements.insert(entitlement) + } + case .retiredUpgraded, .unmanaged: + continue + case .unrecognized: + if case .treatAs(let entitlement) = + try await unrecognizedSubscriptions.policy( + for: transaction + ) + { + activeEntitlements.insert(entitlement) + } + } + } + return activeEntitlements }, didComplete: entitlementOutcome, failures: failures, @@ -288,15 +343,17 @@ where Entitlement: Hashable & Sendable { } let claim = await accepted.acceptance.claimCausalResolutionIfOwner() await didAdmit() - let operationReceipt = ProcessingReceipt() + let operationReceipt = ProcessingReceipt() let registration = finiteTasks.reserve() let task = Task { defer { leases.work.end() registration.complete() } + let disposition: TransactionProcessingDisposition do { - _ = try await accepted.acceptance.receipt.terminalValue() + disposition = + try await accepted.acceptance.receipt.terminalValue() } catch { if let claim { await entitlements.resolve(claim, failure: error) @@ -330,16 +387,26 @@ where Entitlement: Hashable & Sendable { } do { - _ = try await accepted.acceptance.causalReceipt.terminalValue() + let causalDisposition = + try await accepted.acceptance.causalReceipt.terminalValue() + precondition(causalDisposition == disposition) observation.succeed(binding) - operationReceipt.succeed(accepted.snapshot) + switch disposition { + case .finish: + operationReceipt.succeed(.completed(accepted.snapshot)) + case .leaveUnfinished: + operationReceipt.succeed( + .leftUnfinished(accepted.snapshot) + ) + } } catch { operationReceipt.fail( await directFailure( observation: observation, binding: binding, - propagating: completedTransactionFailure( + propagating: causalTransactionFailure( snapshot: accepted.snapshot, + disposition: disposition, error: error ), reportsWhenAbandoned: true, @@ -355,7 +422,7 @@ where Entitlement: Hashable & Sendable { receipt: operationReceipt, observation: observation, observerLease: leases.observer - ) { .completed($0) } + ) { $0 } } private func failAdmittedDelivery( @@ -566,6 +633,7 @@ where Entitlement: Hashable & Sendable { await finiteTasks.waitForAll() await entitlements.sealAndDrain() await core.finishInputAndDrain() + await unrecognizedSubscriptions.sealAndDrain() await failures.sealAndDrain() delegate.release() producerCancellation.removeAll() @@ -578,6 +646,7 @@ where Entitlement: Hashable & Sendable { restoreCoordinator.cancelSynchronously() core.cancelSynchronously() entitlements.cancelSynchronously() + unrecognizedSubscriptions.cancelSynchronously() failures.cancelSynchronously() } @@ -672,6 +741,19 @@ where Entitlement: Hashable & Sendable { return publicError } + private func causalTransactionFailure( + snapshot: StoreTransactionSnapshot, + disposition: TransactionProcessingDisposition, + error: any Error + ) -> any Error { + switch disposition { + case .finish: + completedTransactionFailure(snapshot: snapshot, error: error) + case .leaveUnfinished: + error + } + } + private func exposedError(_ error: any Error) -> any Error { let propagation = StoreTransactionFailurePropagation(error) if let catalogFailure = diff --git a/Sources/StoreTransactionKit/Runtime/UnrecognizedSubscriptionPolicyResolver.swift b/Sources/StoreTransactionKit/Runtime/UnrecognizedSubscriptionPolicyResolver.swift new file mode 100644 index 0000000..fc561b1 --- /dev/null +++ b/Sources/StoreTransactionKit/Runtime/UnrecognizedSubscriptionPolicyResolver.swift @@ -0,0 +1,122 @@ +import Foundation + +package actor UnrecognizedSubscriptionPolicyResolver +where Entitlement: Hashable & Sendable { + private typealias Policy = UnrecognizedSubscriptionPolicy + + private enum State: Sendable { + case pending(ProcessingReceipt) + case resolved(Policy) + } + + private struct Request: Sendable { + let revision: Data + let transaction: StoreTransactionSnapshot + let receipt: ProcessingReceipt + } + + private let sessionID: UUID + private var delegate: (any UnrecognizedSubscriptionDelegate)? + private var states: [Data: State] = [:] + private var queue: [Request] = [] + private var worker: Task? + private var acceptsInput = true + private nonisolated let workerCancellation = TaskCancellationBag() + + package init( + sessionID: UUID, + delegate: (any UnrecognizedSubscriptionDelegate)? + ) { + self.sessionID = sessionID + self.delegate = delegate + } + + package func policy( + for transaction: StoreTransactionSnapshot + ) async throws -> UnrecognizedSubscriptionPolicy { + precondition(acceptsInput) + let revision = Data(transaction.jwsRepresentation.utf8) + let receipt: ProcessingReceipt + switch states[revision] { + case .resolved(let policy): + return policy + case .pending(let pending): + receipt = pending + case nil: + let pending = ProcessingReceipt() + states[revision] = .pending(pending) + queue.append( + Request( + revision: revision, + transaction: transaction, + receipt: pending + ) + ) + receipt = pending + startWorkerIfNeeded() + } + return try await receipt.terminalValue() + } + + package func sealAndDrain() async { + acceptsInput = false + let activeWorker = worker + await activeWorker?.value + precondition(queue.isEmpty) + delegate = nil + states.removeAll(keepingCapacity: false) + } + + package nonisolated func cancelSynchronously() { + workerCancellation.cancel() + } + + private func startWorkerIfNeeded() { + guard worker == nil else { return } + let task = Task.detached { [weak self] in + guard let self else { return } + await self.drainQueue() + } + worker = task + workerCancellation.insert(task) + } + + private func drainQueue() async { + while !queue.isEmpty { + let request = queue.removeFirst() + let delegate = delegate + do { + let policy = try await StoreTransactionCallbackContext.$current + .withValue( + StoreTransactionCallbackInvocation( + sessionID: sessionID, + callback: .transactionHandler + ) + ) { + try await delegate?.decidePolicy( + forUnrecognizedSubscription: request.transaction + ) ?? .leaveUnfinished + } + cache(policy, for: request.revision) + request.receipt.succeed(policy) + } catch { + if case .pending(let receipt) = states[request.revision], + receipt === request.receipt + { + states.removeValue(forKey: request.revision) + } + request.receipt.fail(error) + } + } + worker = nil + workerCancellation.removeAll() + } + + private func cache(_ policy: Policy, for revision: Data) { + states[revision] = .resolved(policy) + } + + isolated deinit { + worker?.cancel() + } +} diff --git a/Sources/StoreTransactionKit/StorePurchaseOutcome.swift b/Sources/StoreTransactionKit/StorePurchaseOutcome.swift index 2b93ee6..4ba87e1 100644 --- a/Sources/StoreTransactionKit/StorePurchaseOutcome.swift +++ b/Sources/StoreTransactionKit/StorePurchaseOutcome.swift @@ -3,6 +3,9 @@ public enum StorePurchaseOutcome: Sendable, Hashable { /// StoreTransactionKit verified, applied policy, finished, reconciled, and published the transaction. case completed(StoreTransactionSnapshot) + /// StoreTransactionKit verified and published the transaction without finishing it. + case leftUnfinished(StoreTransactionSnapshot) + /// The purchase is awaiting an external action and may arrive through transaction updates later. case pending diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift index b40438d..80477fc 100644 --- a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalog.swift @@ -3,8 +3,8 @@ import StoreKit /// A validated mapping from one auto-renewable subscription group to app entitlements. /// /// The catalog treats ``AutoRenewableSubscriptionGroup/subscriptions`` as the -/// complete declaration of product identifiers that can grant typed access and -/// validates StoreKit product type and group metadata before publication. +/// product identifiers known to this binary and validates StoreKit product type +/// and group metadata before publication. public struct AutoRenewableSubscriptionCatalog: Sendable where Entitlement: Hashable & Sendable { package let subscriptionGroupID: SubscriptionGroupID @@ -49,38 +49,12 @@ where Entitlement: Hashable & Sendable { self.entitlementsByProductID = entitlementsByProductID } - package func activeEntitlements( - in entitlements: StoreEntitlements - ) throws(AutoRenewableSubscriptionCatalogError) -> Set { - var activeEntitlements: Set = [] - - for transaction in entitlements.transactions { - switch try validatedTransaction(transaction) { - case let .declared(entitlement): - if !transaction.isUpgraded { - activeEntitlements.insert(entitlement) - } - - case .retiredUpgraded, .unmanaged: - continue - } - } - - return activeEntitlements - } - package func classification( of transaction: StoreTransactionSnapshot ) throws(AutoRenewableSubscriptionCatalogError) - -> AutoRenewableSubscriptionClassification + -> AutoRenewableSubscriptionClassification { - switch try validatedTransaction(transaction) { - case .declared, .retiredUpgraded: - .managed - - case .unmanaged: - .unmanaged - } + try validatedTransaction(transaction) } package func isDeclared(by groupType: Any.Type) -> Bool { @@ -117,13 +91,6 @@ where Entitlement: Hashable & Sendable { return .unmanaged } - guard transaction.isUpgraded else { - throw AutoRenewableSubscriptionCatalogError.undeclaredProduct( - productID: transaction.productID, - subscriptionGroupID: subscriptionGroupID - ) - } - guard transaction.productType == .autoRenewable else { throw AutoRenewableSubscriptionCatalogError.productTypeMismatch( productID: transaction.productID, @@ -131,12 +98,9 @@ where Entitlement: Hashable & Sendable { ) } - return .retiredUpgraded + return transaction.isUpgraded ? .retiredUpgraded : .unrecognized } - private enum ValidatedTransaction { - case declared(Entitlement) - case retiredUpgraded - case unmanaged - } + private typealias ValidatedTransaction = + AutoRenewableSubscriptionClassification } diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift index 3ce1fd4..3b7df3d 100644 --- a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift @@ -3,12 +3,6 @@ import StoreKit /// An inconsistency between a transaction snapshot and a subscription catalog. public enum AutoRenewableSubscriptionCatalogError: LocalizedError, Sendable { - /// A current product in the managed group has no catalog declaration. - case undeclaredProduct( - productID: Product.ID, - subscriptionGroupID: SubscriptionGroupID - ) - /// A catalog product isn't an auto-renewable subscription. case productTypeMismatch( productID: Product.ID, @@ -25,10 +19,6 @@ public enum AutoRenewableSubscriptionCatalogError: LocalizedError, Sendable { /// A localized description of the catalog inconsistency. public var errorDescription: String? { switch self { - case let .undeclaredProduct(productID, subscriptionGroupID): - "Product \(productID) is not declared in subscription group " - + "\(subscriptionGroupID.rawValue)." - case let .productTypeMismatch(productID, actual): "Product \(productID) has type \(actual); expected an " + "auto-renewable subscription." diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift index bd2b1f3..5ee6141 100644 --- a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift @@ -1,7 +1,10 @@ -package enum AutoRenewableSubscriptionClassification: +package enum AutoRenewableSubscriptionClassification: Equatable, Sendable +where Entitlement: Hashable & Sendable { - case managed + case declared(Entitlement) + case retiredUpgraded + case unrecognized case unmanaged } diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index 14d1d56..54c2cd6 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -89,12 +89,14 @@ where Entitlement: Hashable & Sendable { /// Creates the process's live StoreKit store for an auto-renewable subscription catalog. /// /// Initialization starts transaction monitoring and the first entitlement - /// reconciliation. The store strongly retains `delegate` until terminal + /// reconciliation. The store strongly retains both delegates until terminal /// shutdown. Creating a second live store in the same process before the /// first store finishes ``close()`` is a programmer error. public convenience init( subscriptionCatalog: AutoRenewableSubscriptionCatalog, - delegate: (any TransactionStoreDelegate)? = nil + delegate: (any TransactionStoreDelegate)? = nil, + unrecognizedSubscriptionDelegate: + (any UnrecognizedSubscriptionDelegate)? = nil ) { let liveLease = LiveTransactionStoreLease.acquire() let lifecycle = TransactionStoreLifecycle(liveLease: liveLease) @@ -103,7 +105,9 @@ where Entitlement: Hashable & Sendable { lifecycle: lifecycle, backendKind: .live, subscriptionCatalog: subscriptionCatalog, - delegate: delegate + delegate: delegate, + unrecognizedSubscriptionDelegate: + unrecognizedSubscriptionDelegate ) } @@ -129,13 +133,17 @@ where Entitlement: Hashable & Sendable { convenience init( source: StoreTransactionSource, subscriptionCatalog: AutoRenewableSubscriptionCatalog, - delegate: (any TransactionStoreDelegate)? = nil + delegate: (any TransactionStoreDelegate)? = nil, + unrecognizedSubscriptionDelegate: + (any UnrecognizedSubscriptionDelegate)? = nil ) { self.init( source: source, lifecycle: TransactionStoreLifecycle(), subscriptionCatalog: subscriptionCatalog, - delegate: delegate + delegate: delegate, + unrecognizedSubscriptionDelegate: + unrecognizedSubscriptionDelegate ) } @@ -143,14 +151,18 @@ where Entitlement: Hashable & Sendable { source: StoreTransactionSource, lifecycle: TransactionStoreLifecycle, subscriptionCatalog: AutoRenewableSubscriptionCatalog, - delegate: (any TransactionStoreDelegate)? = nil + delegate: (any TransactionStoreDelegate)? = nil, + unrecognizedSubscriptionDelegate: + (any UnrecognizedSubscriptionDelegate)? = nil ) { self.init( source: source, lifecycle: lifecycle, backendKind: .live, subscriptionCatalog: subscriptionCatalog, - delegate: delegate + delegate: delegate, + unrecognizedSubscriptionDelegate: + unrecognizedSubscriptionDelegate ) } @@ -158,6 +170,8 @@ where Entitlement: Hashable & Sendable { subscriptionCatalog: AutoRenewableSubscriptionCatalog, syntheticSource: SyntheticStoreTransactionSource, delegate: (any TransactionStoreDelegate)? = nil, + unrecognizedSubscriptionDelegate: + (any UnrecognizedSubscriptionDelegate)? = nil, unavailableOperationError: @escaping @Sendable (StoreTransactionOperation) -> any Error ) { @@ -166,7 +180,9 @@ where Entitlement: Hashable & Sendable { lifecycle: TransactionStoreLifecycle(), backendKind: .synthetic(unavailableOperationError), subscriptionCatalog: subscriptionCatalog, - delegate: delegate + delegate: delegate, + unrecognizedSubscriptionDelegate: + unrecognizedSubscriptionDelegate ) } @@ -180,7 +196,9 @@ where Entitlement: Hashable & Sendable { lifecycle: TransactionStoreLifecycle, backendKind: BackendKind, subscriptionCatalog: AutoRenewableSubscriptionCatalog, - delegate: (any TransactionStoreDelegate)? + delegate: (any TransactionStoreDelegate)?, + unrecognizedSubscriptionDelegate: + (any UnrecognizedSubscriptionDelegate)? ) { let sessionID = UUID() let owner = TransactionStoreAvailabilityOwner() @@ -190,6 +208,8 @@ where Entitlement: Hashable & Sendable { lifecycle: lifecycle, subscriptionCatalog: subscriptionCatalog, delegate: delegate, + unrecognizedSubscriptionDelegate: + unrecognizedSubscriptionDelegate, entitlementOutcome: { outcome in await owner.apply(outcome) } @@ -233,10 +253,10 @@ where Entitlement: Hashable & Sendable { /// Processes a direct result from custom purchase UI. /// - /// A successful verified purchase completes only after policy selection, - /// finishing, causal entitlement reconciliation, and main-actor publication. - /// Pending and user-cancelled results return their corresponding semantic - /// outcome without transaction processing. + /// A verified purchase returns only after policy selection, the selected + /// finish or leave action, causal entitlement reconciliation, and main-actor + /// publication. Pending and user-cancelled results return their corresponding + /// semantic outcome without transaction processing. public func process( _ result: Product.PurchaseResult ) async throws -> StorePurchaseOutcome { diff --git a/Sources/StoreTransactionKit/UnrecognizedSubscriptionDelegate.swift b/Sources/StoreTransactionKit/UnrecognizedSubscriptionDelegate.swift new file mode 100644 index 0000000..57c74e9 --- /dev/null +++ b/Sources/StoreTransactionKit/UnrecognizedSubscriptionDelegate.swift @@ -0,0 +1,36 @@ +/// The action StoreTransactionKit takes for a valid, undeclared subscription +/// in the catalog's subscription group. +public enum UnrecognizedSubscriptionPolicy: Sendable, Hashable +where Entitlement: Hashable & Sendable { + /// Leaves the transaction unfinished and grants no typed entitlement. + case leaveUnfinished + + /// Finishes the transaction without granting a typed entitlement. + case finish + + /// Finishes the transaction and projects it as a known app entitlement. + case treatAs(Entitlement) +} + +/// Resolves valid subscriptions that this binary does not declare in its catalog. +/// +/// StoreTransactionKit invokes this delegate only for a non-upgraded +/// auto-renewable subscription whose group matches the catalog and whose Product +/// ID is undeclared. Decisions are serialized and reused for the exact +/// transaction revision. Do not call an admission-bearing operation on the same +/// ``TransactionStore`` from this method. +public protocol UnrecognizedSubscriptionDelegate: + AnyObject, + Sendable +{ + associatedtype Entitlement: Hashable & Sendable + + /// Chooses how to handle and project an undeclared subscription. + /// + /// Throwing leaves an unfinished transaction unfinished and makes the + /// current entitlement refresh fail transiently. A later independent + /// attempt may retry the decision. + func decidePolicy( + forUnrecognizedSubscription transaction: StoreTransactionSnapshot + ) async throws -> UnrecognizedSubscriptionPolicy +} diff --git a/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift b/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift index 9785270..68fb3f8 100644 --- a/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift +++ b/Tests/StoreTransactionKitTests/AutoRenewableSubscriptionCatalogTests.swift @@ -26,47 +26,42 @@ struct AutoRenewableSubscriptionCatalogTests { productID: CountingPlans.ProductID.monthly.rawValue, subscriptionGroupID: CountingPlans.id.rawValue ) - #expect(try catalog.classification(of: transaction) == .managed) #expect( - try catalog.activeEntitlements( - in: StoreEntitlements(transactions: [transaction]) - ) == [.tier1] + try catalog.classification(of: transaction) + == .declared(.tier1) ) #expect(countingSubscriptionsAccessCount.withLock { $0 } == 1) } - @Test("monthly and yearly products project to their declared entitlements") - func projectsDeclaredEntitlements() throws { + @Test("monthly and yearly products classify with their declared entitlements") + func classifiesDeclaredEntitlements() throws { let catalog = AutoRenewableSubscriptionCatalog(Plans.self) - let entitlements = StoreEntitlements( - transactions: [ - subscriptionSnapshot( - id: 1, - productID: Plans.ProductID.tier1_Monthly.rawValue - ), - subscriptionSnapshot( - id: 2, - productID: Plans.ProductID.tier1_Yearly.rawValue - ), - subscriptionSnapshot( - id: 3, - productID: Plans.ProductID.tier2_Monthly.rawValue - ), - subscriptionSnapshot( - id: 4, - productID: Plans.ProductID.tier2_Yearly.rawValue - ), - ] - ) + let classifications = try [ + Plans.ProductID.tier1_Monthly, + .tier1_Yearly, + .tier2_Monthly, + .tier2_Yearly, + ].enumerated().map { offset, productID in + try catalog.classification( + of: subscriptionSnapshot( + id: UInt64(offset + 1), + productID: productID.rawValue + ) + ) + } #expect( - try catalog.activeEntitlements(in: entitlements) - == [.tier1, .tier2] + classifications == [ + .declared(.tier1), + .declared(.tier1), + .declared(.tier2), + .declared(.tier2), + ] ) } - @Test("an upgraded declared product remains managed without granting access") - func upgradedDeclaredProductDoesNotGrantAccess() throws { + @Test("an upgraded declared product remains declared") + func upgradedDeclaredProductRemainsDeclared() throws { let catalog = AutoRenewableSubscriptionCatalog(Plans.self) let transaction = subscriptionSnapshot( id: 5, @@ -74,16 +69,14 @@ struct AutoRenewableSubscriptionCatalogTests { isUpgraded: true ) - #expect(try catalog.classification(of: transaction) == .managed) #expect( - try catalog.activeEntitlements( - in: StoreEntitlements(transactions: [transaction]) - ).isEmpty + try catalog.classification(of: transaction) + == .declared(.tier1) ) } - @Test("a retired upgraded product remains managed without granting access") - func retiredUpgradedProductDoesNotGrantAccess() throws { + @Test("a retired upgraded product has a non-projecting classification") + func retiredUpgradedProductIsClassified() throws { let catalog = AutoRenewableSubscriptionCatalog(Plans.self) let transaction = subscriptionSnapshot( id: 6, @@ -91,11 +84,9 @@ struct AutoRenewableSubscriptionCatalogTests { isUpgraded: true ) - #expect(try catalog.classification(of: transaction) == .managed) #expect( - try catalog.activeEntitlements( - in: StoreEntitlements(transactions: [transaction]) - ).isEmpty + try catalog.classification(of: transaction) + == .retiredUpgraded ) } @@ -109,11 +100,6 @@ struct AutoRenewableSubscriptionCatalogTests { ) #expect(try catalog.classification(of: transaction) == .unmanaged) - #expect( - try catalog.activeEntitlements( - in: StoreEntitlements(transactions: [transaction]) - ).isEmpty - ) } @Test("a declared product with a wrong StoreKit type fails validation") @@ -122,18 +108,14 @@ struct AutoRenewableSubscriptionCatalogTests { let productID = Plans.ProductID.tier1_Monthly.rawValue do { - _ = try catalog.activeEntitlements( - in: StoreEntitlements( - transactions: [ - subscriptionSnapshot( - id: 8, - productID: productID, - productType: .nonConsumable - ) - ] + _ = try catalog.classification( + of: subscriptionSnapshot( + id: 8, + productID: productID, + productType: .nonConsumable ) ) - Issue.record("Projection unexpectedly accepted a non-consumable product.") + Issue.record("Classification unexpectedly accepted a non-consumable product.") } catch let error { guard case let .productTypeMismatch(actualProductID, actual) = error else { Issue.record("Unexpected catalog error: \(error)") @@ -177,37 +159,19 @@ struct AutoRenewableSubscriptionCatalogTests { } } - @Test("an undeclared current product in the managed group fails validation") - func undeclaredCurrentProduct() { + @Test("an undeclared current product in the managed group is unrecognized") + func undeclaredCurrentProductIsUnrecognized() throws { let catalog = AutoRenewableSubscriptionCatalog(Plans.self) let productID = "com.example.subscription.undeclared" - do { - _ = try catalog.activeEntitlements( - in: StoreEntitlements( - transactions: [ - subscriptionSnapshot( - id: 10, - productID: productID - ) - ] + #expect( + try catalog.classification( + of: subscriptionSnapshot( + id: 10, + productID: productID ) - ) - Issue.record("Projection unexpectedly accepted an undeclared product.") - } catch let error { - guard - case let .undeclaredProduct( - actualProductID, - subscriptionGroupID - ) = error - else { - Issue.record("Unexpected catalog error: \(error)") - return - } - - #expect(actualProductID == productID) - #expect(subscriptionGroupID == Plans.id) - } + ) == .unrecognized + ) } @Test("an undeclared upgraded product still requires an auto-renewable type") @@ -236,31 +200,27 @@ struct AutoRenewableSubscriptionCatalogTests { } } - @Test("projection validates the complete candidate before returning a set") - func projectionIsAllOrNothing() { + @Test("an unrecognized current product still requires an auto-renewable type") + func unrecognizedProductTypeMismatch() { let catalog = AutoRenewableSubscriptionCatalog(Plans.self) + let productID = "com.example.subscription.undeclared" do { - _ = try catalog.activeEntitlements( - in: StoreEntitlements( - transactions: [ - subscriptionSnapshot( - id: 12, - productID: Plans.ProductID.tier1_Monthly.rawValue - ), - subscriptionSnapshot( - id: 13, - productID: "com.example.subscription.undeclared" - ), - ] + _ = try catalog.classification( + of: subscriptionSnapshot( + id: 12, + productID: productID, + productType: .nonRenewable ) ) - Issue.record("Projection unexpectedly returned a partial set.") + Issue.record("Classification unexpectedly accepted the wrong type.") } catch let error { - guard case .undeclaredProduct = error else { + guard case let .productTypeMismatch(actualProductID, actual) = error else { Issue.record("Unexpected catalog error: \(error)") return } + #expect(actualProductID == productID) + #expect(actual == .nonRenewable) } } diff --git a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift index cf6aecd..7888d08 100644 --- a/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift +++ b/Tests/StoreTransactionKitTests/EntitlementRefreshCoordinatorTests.swift @@ -36,9 +36,7 @@ struct EntitlementRefreshCoordinatorTests { let receiptCompleted = TestSignal() let coordinator = EntitlementRefreshCoordinator( query: { _ in try await query.next() }, - project: { - (_: StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) - -> Set in + project: { _ in [.tier1] }, didComplete: { _ in @@ -71,16 +69,14 @@ struct EntitlementRefreshCoordinatorTests { let query = ControlledReconciliationQuery() let failures = FailureReporterDispatcher() let outcomes = RefreshOutcomeRecorder() - let catalogError = AutoRenewableSubscriptionCatalogError.undeclaredProduct( - productID: "undeclared", - subscriptionGroupID: TestPlans.id + let catalogError = AutoRenewableSubscriptionCatalogError.productTypeMismatch( + productID: "invalid", + actual: .nonRenewable ) let coordinator = EntitlementRefreshCoordinator( query: { _ in try await query.next() }, - project: { - (_: StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) - -> Set in - throw catalogError + project: { _ in + throw StoreTransactionCatalogFailure(error: catalogError) }, didComplete: { outcome in await outcomes.append(outcome) }, failures: failures @@ -104,9 +100,7 @@ struct EntitlementRefreshCoordinatorTests { ) -> EntitlementRefreshCoordinator { EntitlementRefreshCoordinator( query: { _ in try await query.next() }, - project: { - (_: StoreEntitlements) throws(AutoRenewableSubscriptionCatalogError) - -> Set in + project: { _ in [] }, didComplete: { _ in }, diff --git a/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift b/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift index 6ea0e86..3964f56 100644 --- a/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift +++ b/Tests/StoreTransactionKitTests/TransactionProcessingCoreTests.swift @@ -14,6 +14,7 @@ struct TransactionProcessingCoreTests { await handlerStarted.send() try await gate.wait() await events.append("handle-end") + return .finish } let snapshot = makeSnapshot(id: 1) let acceptance = await core.accept( @@ -44,6 +45,7 @@ struct TransactionProcessingCoreTests { let core = TransactionProcessingCore { _ in await started.send() try await gate.wait() + return .finish } let acceptance = await core.accept( makeEnvelope(snapshot: makeSnapshot(id: 101)) @@ -69,6 +71,7 @@ struct TransactionProcessingCoreTests { if await attempts.value() == 1 { throw TestFailure() } + return .finish } let snapshot = makeSnapshot(id: 2) let envelope = makeEnvelope(snapshot: snapshot) { @@ -141,6 +144,7 @@ struct TransactionProcessingCoreTests { await handles.send() await handlerStarted.send() try await handlerGate.wait() + return .finish } let snapshot = makeSnapshot(id: 3) let first = await core.accept( @@ -198,6 +202,7 @@ struct TransactionProcessingCoreTests { await firstStarted.send() try await firstGate.wait() } + return .finish } let first = await core.accept( makeEnvelope(snapshot: makeSnapshot(id: 1)) { @@ -226,4 +231,46 @@ struct TransactionProcessingCoreTests { ]) await core.finishInputAndDrain() } + + @Test("leaving unfinished skips finish and permits a later processing attempt") + func leaveUnfinishedIsRetryable() async throws { + let handles = TestSignal() + let finishes = TestSignal() + let core = TransactionProcessingCore { _ in + await handles.send() + return .leaveUnfinished + } + let snapshot = makeSnapshot(id: 4) + let envelope = makeEnvelope(snapshot: snapshot) { + await finishes.send() + } + + let first = await core.accept(envelope) + let firstClaim = try #require( + await first.claimCausalResolutionIfOwner() + ) + #expect( + try await first.receipt.terminalValue() == .leaveUnfinished + ) + await firstClaim.succeed() + #expect( + try await first.causalReceipt.terminalValue() + == .leaveUnfinished + ) + + let second = await core.accept(envelope) + let secondClaim = try #require( + await second.claimCausalResolutionIfOwner() + ) + #expect( + try await second.receipt.terminalValue() == .leaveUnfinished + ) + await secondClaim.succeed() + + #expect(first.role == .owner) + #expect(second.role == .owner) + #expect(await handles.value() == 2) + #expect(await finishes.value() == 0) + await core.finishInputAndDrain() + } } diff --git a/Tests/StoreTransactionKitTests/TransactionStoreTests.swift b/Tests/StoreTransactionKitTests/TransactionStoreTests.swift index bc61336..cbd7fff 100644 --- a/Tests/StoreTransactionKitTests/TransactionStoreTests.swift +++ b/Tests/StoreTransactionKitTests/TransactionStoreTests.swift @@ -148,13 +148,13 @@ struct TransactionStoreTests { ) try await store.waitForInitialReadiness() - let undeclared = makeSnapshot( + let contradictory = makeSnapshot( id: 5, - productID: "test.subscription.retired.current", - productType: .autoRenewable, + productID: TestPlans.ProductID.tier1Monthly.rawValue, + productType: .nonConsumable, subscriptionGroupID: TestPlans.id.rawValue ) - await current.replace(with: [undeclared]) + await current.replace(with: [contradictory]) await #expect(throws: AutoRenewableSubscriptionCatalogError.self) { _ = try await store.refreshEntitlements() diff --git a/Tests/StoreTransactionKitTests/UnrecognizedSubscriptionTests.swift b/Tests/StoreTransactionKitTests/UnrecognizedSubscriptionTests.swift new file mode 100644 index 0000000..c4bae6f --- /dev/null +++ b/Tests/StoreTransactionKitTests/UnrecognizedSubscriptionTests.swift @@ -0,0 +1,715 @@ +import Foundation +import StoreKit +import Testing +@testable import StoreTransactionKit + +@Suite("Unrecognized subscription handling", .timeLimit(.minutes(1))) +struct UnrecognizedSubscriptionTests { + @MainActor + @Test("the default policy leaves an unrecognized subscription unfinished") + func defaultPolicyLeavesUnfinished() async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let generalDelegate = UnrecognizedTestGeneralDelegate(policy: .finish) + let finishes = TestSignal() + let snapshot = makeUnrecognizedSubscriptionSnapshot( + id: 401, + revision: "unknown-default" + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: generalDelegate + ) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + let outcome = try await store.process( + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + } + ) + ) + + #expect(outcome == .leftUnfinished(snapshot)) + #expect(await finishes.value() == 0) + #expect(await generalDelegate.decisionCount() == 0) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == []) + try await store.close() + } + + @MainActor + @Test("leave policy is reused without finishing a later delivery") + func leavePolicyIsReused() async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let generalDelegate = UnrecognizedTestGeneralDelegate(policy: .finish) + let unrecognizedDelegate = RecordingUnrecognizedDelegate( + policy: .leaveUnfinished + ) + let finishes = TestSignal() + let snapshot = makeUnrecognizedSubscriptionSnapshot( + id: 402, + revision: "unknown-leave" + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: generalDelegate, + unrecognizedSubscriptionDelegate: unrecognizedDelegate + ) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + #expect(try await store.process(delivery) == .leftUnfinished(snapshot)) + #expect(try await store.process(delivery) == .leftUnfinished(snapshot)) + + #expect(await unrecognizedDelegate.decisionCount() == 1) + #expect(await generalDelegate.decisionCount() == 0) + #expect(await finishes.value() == 0) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == []) + try await store.close() + } + + @MainActor + @Test("finish policy completes without granting an entitlement") + func finishPolicyCompletesWithoutEntitlement() async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let generalDelegate = UnrecognizedTestGeneralDelegate(policy: .finish) + let unrecognizedDelegate = RecordingUnrecognizedDelegate( + policy: .finish + ) + let finishes = TestSignal() + let snapshot = makeUnrecognizedSubscriptionSnapshot( + id: 403, + revision: "unknown-finish" + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: generalDelegate, + unrecognizedSubscriptionDelegate: unrecognizedDelegate + ) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + let outcome = try await store.process( + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + } + ) + ) + + #expect(outcome == .completed(snapshot)) + #expect(await unrecognizedDelegate.decisionCount() == 1) + #expect(await generalDelegate.decisionCount() == 0) + #expect(await finishes.value() == 1) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == []) + try await store.close() + } + + @MainActor + @Test("treat-as policy completes and projects the selected entitlement") + func treatAsPolicyProjectsEntitlement() async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let generalDelegate = UnrecognizedTestGeneralDelegate(policy: .finish) + let unrecognizedDelegate = RecordingUnrecognizedDelegate( + policy: .treatAs(.tier1) + ) + let finishes = TestSignal() + let snapshot = makeUnrecognizedSubscriptionSnapshot( + id: 404, + revision: "unknown-treat-as" + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: generalDelegate, + unrecognizedSubscriptionDelegate: unrecognizedDelegate + ) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + let outcome = try await store.process( + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + } + ) + ) + + #expect(outcome == .completed(snapshot)) + #expect(await unrecognizedDelegate.decisionCount() == 1) + #expect(await generalDelegate.decisionCount() == 0) + #expect(await finishes.value() == 1) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @MainActor + @Test("a thrown policy is transient, preserves ready state, and retries") + func thrownPolicyPreservesReadyStateAndRetries() async throws { + let previous = makeSubscriptionSnapshot( + id: 405, + productID: .tier2Monthly + ) + let replacement = makeUnrecognizedSubscriptionSnapshot( + id: 406, + revision: "unknown-retry" + ) + let current = EntitlementValueSource([previous]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let unrecognizedDelegate = RecordingUnrecognizedDelegate { + _, attempt in + if attempt == 1 { + throw TestFailure() + } + return .treatAs(.tier1) + } + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + unrecognizedSubscriptionDelegate: unrecognizedDelegate + ) + try await store.waitForInitialReadiness() + await current.replace(with: [replacement]) + + await #expect(throws: TestFailure.self) { + _ = try await store.refreshEntitlements() + } + + guard case .ready = store.entitlementStatus else { + Issue.record("A transient policy failure discarded ready state.") + try await store.close() + return + } + #expect(store.entitlements?.transactions == [previous]) + #expect(store.activeEntitlements == [.tier2]) + #expect(await unrecognizedDelegate.decisionCount() == 1) + + _ = try await store.refreshEntitlements() + + #expect(store.entitlements?.transactions == [replacement]) + #expect(store.activeEntitlements == [.tier1]) + #expect(await unrecognizedDelegate.decisionCount() == 2) + try await store.close() + } + + @MainActor + @Test( + "known, retired, and out-of-group transactions bypass the unrecognized delegate" + ) + func otherClassificationsBypassUnrecognizedDelegate() async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let generalDelegate = UnrecognizedTestGeneralDelegate(policy: .finish) + let unrecognizedDelegate = RecordingUnrecognizedDelegate( + policy: .treatAs(.tier1) + ) + let finishes = TestSignal() + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: generalDelegate, + unrecognizedSubscriptionDelegate: unrecognizedDelegate + ) + try await store.waitForInitialReadiness() + + let known = makeSubscriptionSnapshot( + id: 407, + productID: .tier2Monthly, + revision: "known" + ) + await current.replace(with: [known]) + #expect( + try await store.process( + .verified( + makeEnvelope(snapshot: known) { + await finishes.send() + } + ) + ) == .completed(known) + ) + #expect(store.activeEntitlements == [.tier2]) + + let retired = makeUnrecognizedSubscriptionSnapshot( + id: 408, + revision: "retired", + isUpgraded: true + ) + await current.replace(with: [retired]) + #expect( + try await store.process( + .verified( + makeEnvelope(snapshot: retired) { + await finishes.send() + } + ) + ) == .completed(retired) + ) + #expect(store.activeEntitlements == []) + + let outOfGroup = makeUnrecognizedSubscriptionSnapshot( + id: 409, + revision: "out-of-group", + subscriptionGroupID: "test.subscription.other-group" + ) + await current.replace(with: [outOfGroup]) + #expect( + try await store.process( + .verified( + makeEnvelope(snapshot: outOfGroup) { + await finishes.send() + } + ) + ) == .completed(outOfGroup) + ) + + #expect(await unrecognizedDelegate.decisionCount() == 0) + #expect(await generalDelegate.decisionCount() == 3) + #expect(await finishes.value() == 3) + #expect(store.entitlements?.transactions == [outOfGroup]) + #expect(store.activeEntitlements == []) + try await store.close() + } + + @MainActor + @Test("metadata contradictions fail before either delegate is called") + func metadataContradictionBypassesDelegates() async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let generalDelegate = UnrecognizedTestGeneralDelegate(policy: .finish) + let unrecognizedDelegate = RecordingUnrecognizedDelegate( + policy: .finish + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: generalDelegate, + unrecognizedSubscriptionDelegate: unrecognizedDelegate + ) + try await store.waitForInitialReadiness() + + let contradiction = makeSnapshot( + id: 410, + productID: TestPlans.ProductID.tier1Monthly.rawValue, + productType: .nonConsumable, + subscriptionGroupID: TestPlans.id.rawValue + ) + await current.replace(with: [contradiction]) + + await #expect(throws: AutoRenewableSubscriptionCatalogError.self) { + _ = try await store.refreshEntitlements() + } + #expect(await unrecognizedDelegate.decisionCount() == 0) + #expect(await generalDelegate.decisionCount() == 0) + try await store.close() + } + + @MainActor + @Test("same-revision deliveries join and reuse one policy decision") + func sameRevisionDeliveriesReuseDecision() async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let decisionStarted = TestSignal() + let decisionGate = TestGate() + let secondAdmitted = TestSignal() + let unrecognizedDelegate = RecordingUnrecognizedDelegate { + _, _ in + await decisionStarted.send() + try await decisionGate.wait() + return .treatAs(.tier1) + } + let generalDelegate = UnrecognizedTestGeneralDelegate(policy: .finish) + let finishes = TestSignal() + let snapshot = makeUnrecognizedSubscriptionSnapshot( + id: 411, + revision: "unknown-joined" + ) + let delivery = StoreTransactionDelivery.verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + } + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + delegate: generalDelegate, + unrecognizedSubscriptionDelegate: unrecognizedDelegate + ) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + let first = Task { @MainActor in + try await store.process(delivery) + } + try await decisionStarted.wait(for: 1) + let second = Task { @MainActor in + try await store.process(delivery) { + await secondAdmitted.send() + } + } + try await secondAdmitted.wait(for: 1) + #expect(await unrecognizedDelegate.decisionCount() == 1) + + await decisionGate.open() + #expect(try await first.value == .completed(snapshot)) + #expect(try await second.value == .completed(snapshot)) + #expect(try await store.process(delivery) == .completed(snapshot)) + + #expect(await unrecognizedDelegate.decisionCount() == 1) + #expect(await generalDelegate.decisionCount() == 0) + #expect(await finishes.value() == 1) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @Test("different revisions are decided serially and cached for the session") + func differentRevisionsAreSerialized() async throws { + let gate = TestGate() + let delegate = SerializedUnrecognizedDelegate(gate: gate) + let resolver = UnrecognizedSubscriptionPolicyResolver( + sessionID: UUID(), + delegate: delegate + ) + let firstSnapshot = makeUnrecognizedSubscriptionSnapshot( + id: 412, + revision: "serialized-first" + ) + let secondSnapshot = makeUnrecognizedSubscriptionSnapshot( + id: 413, + revision: "serialized-second" + ) + + async let first = resolver.policy(for: firstSnapshot) + async let second = resolver.policy(for: secondSnapshot) + try await delegate.waitForDecision(1) + + #expect(await delegate.maximumConcurrentDecisionCount() == 1) + await gate.open() + let policies = try await (first, second) + + #expect(policies.0 == .finish) + #expect(policies.1 == .finish) + #expect(await delegate.decisionCount() == 2) + #expect(await delegate.maximumConcurrentDecisionCount() == 1) + #expect(try await resolver.policy(for: firstSnapshot) == .finish) + #expect(await delegate.decisionCount() == 2) + await resolver.sealAndDrain() + } + + @MainActor + @Test("unrecognized policy callbacks reject same-store reentrancy") + func callbackReentrancyIsRejected() async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let holder = TransactionStoreHolder() + let unrecognizedDelegate = ReentrantUnrecognizedDelegate(holder: holder) + let snapshot = makeUnrecognizedSubscriptionSnapshot( + id: 414, + revision: "unknown-reentrant" + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + unrecognizedSubscriptionDelegate: unrecognizedDelegate + ) + holder.set(store) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + #expect( + try await store.process( + .verified(makeEnvelope(snapshot: snapshot)) + ) == .completed(snapshot) + ) + + #expect(await unrecognizedDelegate.sawReentrantError()) + #expect(store.activeEntitlements == [.tier1]) + try await store.close() + } + + @MainActor + @Test("close drains a policy decision before releasing its delegate") + func closeDrainsAndReleasesDelegate() async throws { + let current = EntitlementValueSource([]) + let fixture = TestSourceFixture( + currentEntitlements: { await current.read() } + ) + let decisionStarted = TestSignal() + let decisionGate = TestGate() + let delegateDeinitialized = TestSignal() + var token: UnrecognizedDelegateLifetimeToken? = + UnrecognizedDelegateLifetimeToken(signal: delegateDeinitialized) + weak let weakToken = token + var unrecognizedDelegate: RetainedUnrecognizedDelegate? = + RetainedUnrecognizedDelegate( + token: token!, + started: decisionStarted, + gate: decisionGate + ) + let finishes = TestSignal() + let snapshot = makeUnrecognizedSubscriptionSnapshot( + id: 415, + revision: "unknown-close" + ) + let store = TransactionStore( + source: fixture.source, + subscriptionCatalog: testSubscriptionCatalog, + unrecognizedSubscriptionDelegate: unrecognizedDelegate + ) + try await store.waitForInitialReadiness() + await current.replace(with: [snapshot]) + + let processing = Task { @MainActor in + try await store.process( + .verified( + makeEnvelope(snapshot: snapshot) { + await finishes.send() + } + ) + ) + } + try await decisionStarted.wait(for: 1) + unrecognizedDelegate = nil + token = nil + + let closeCompleted = TestSignal() + let close = Task { @MainActor in + try await store.close() + await closeCompleted.send() + } + await store.waitUntilClosing() + + #expect(await closeCompleted.value() == 0) + #expect(weakToken != nil) + + await decisionGate.open() + #expect(try await processing.value == .completed(snapshot)) + try await close.value + try await delegateDeinitialized.wait(for: 1) + + #expect(await finishes.value() == 1) + #expect(await closeCompleted.value() == 1) + #expect(weakToken == nil) + } +} + +private typealias TestUnrecognizedPolicy = + UnrecognizedSubscriptionPolicy + +private actor RecordingUnrecognizedDelegate: + UnrecognizedSubscriptionDelegate +{ + typealias Entitlement = TestEntitlement + + private let decide: + @Sendable (StoreTransactionSnapshot, Int) async throws + -> TestUnrecognizedPolicy + private var transactions: [StoreTransactionSnapshot] = [] + + init(policy: TestUnrecognizedPolicy) { + decide = { _, _ in policy } + } + + init( + decide: + @escaping @Sendable (StoreTransactionSnapshot, Int) async throws + -> TestUnrecognizedPolicy + ) { + self.decide = decide + } + + func decidePolicy( + forUnrecognizedSubscription transaction: StoreTransactionSnapshot + ) async throws -> TestUnrecognizedPolicy { + transactions.append(transaction) + return try await decide(transaction, transactions.count) + } + + func decisionCount() -> Int { + transactions.count + } +} + +private actor UnrecognizedTestGeneralDelegate: TransactionStoreDelegate { + private let policy: StoreTransactionHandlingPolicy + private var decisions = 0 + + init(policy: StoreTransactionHandlingPolicy) { + self.policy = policy + } + + func decidePolicy( + for transaction: StoreTransactionSnapshot + ) async throws -> StoreTransactionHandlingPolicy { + decisions += 1 + return policy + } + + func decisionCount() -> Int { + decisions + } +} + +private actor SerializedUnrecognizedDelegate: + UnrecognizedSubscriptionDelegate +{ + typealias Entitlement = TestEntitlement + + private let gate: TestGate + private let started = TestSignal() + private var decisions = 0 + private var concurrentDecisions = 0 + private var maximumConcurrentDecisions = 0 + + init(gate: TestGate) { + self.gate = gate + } + + func decidePolicy( + forUnrecognizedSubscription transaction: StoreTransactionSnapshot + ) async throws -> TestUnrecognizedPolicy { + decisions += 1 + concurrentDecisions += 1 + maximumConcurrentDecisions = max( + maximumConcurrentDecisions, + concurrentDecisions + ) + await started.send() + try await gate.wait() + concurrentDecisions -= 1 + return .finish + } + + func waitForDecision(_ count: Int) async throws { + try await started.wait(for: count) + } + + func decisionCount() -> Int { + decisions + } + + func maximumConcurrentDecisionCount() -> Int { + maximumConcurrentDecisions + } +} + +private actor ReentrantUnrecognizedDelegate: + UnrecognizedSubscriptionDelegate +{ + typealias Entitlement = TestEntitlement + + private let holder: TransactionStoreHolder + private var rejected = false + + init(holder: TransactionStoreHolder) { + self.holder = holder + } + + func decidePolicy( + forUnrecognizedSubscription transaction: StoreTransactionSnapshot + ) async throws -> TestUnrecognizedPolicy { + do { + _ = try await holder.get().refreshEntitlements() + } catch StoreTransactionError.reentrantOperation( + operation: .refreshEntitlements + ) { + rejected = true + } + return .treatAs(.tier1) + } + + func sawReentrantError() -> Bool { + rejected + } +} + +private actor RetainedUnrecognizedDelegate: + UnrecognizedSubscriptionDelegate +{ + typealias Entitlement = TestEntitlement + + private let token: UnrecognizedDelegateLifetimeToken + private let started: TestSignal + private let gate: TestGate + + init( + token: UnrecognizedDelegateLifetimeToken, + started: TestSignal, + gate: TestGate + ) { + self.token = token + self.started = started + self.gate = gate + } + + func decidePolicy( + forUnrecognizedSubscription transaction: StoreTransactionSnapshot + ) async throws -> TestUnrecognizedPolicy { + _ = token + await started.send() + try await gate.wait() + return .finish + } +} + +private final class UnrecognizedDelegateLifetimeToken: Sendable { + private let signal: TestSignal + + init(signal: TestSignal) { + self.signal = signal + } + + deinit { + let signal = signal + Task { await signal.send() } + } +} + +private func makeUnrecognizedSubscriptionSnapshot( + id: UInt64, + revision: String, + isUpgraded: Bool = false, + subscriptionGroupID: String = TestPlans.id.rawValue +) -> StoreTransactionSnapshot { + makeSnapshot( + id: id, + productID: "test.subscription.unrecognized", + productType: .autoRenewable, + subscriptionGroupID: subscriptionGroupID, + jws: revision, + isUpgraded: isUpgraded + ) +} From 7943e71d5391615aac1a9fcd584ff67305a94d3b Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:13:25 +0900 Subject: [PATCH 2/7] docs(subscription): document unrecognized product handling --- README.md | 61 ++++++++++++-- .../Entitlements/EntitlementStatus.swift | 3 +- .../StorePurchaseOutcome.swift | 6 +- .../StoreTransactionFailure.swift | 3 +- .../DefiningSubscriptionAccess.md | 28 ++++++- .../StoreTransactionKit.md | 14 +++- .../TestingSubscriptionAccess.md | 41 +++++++++ .../UnderstandingTransactionHandling.md | 83 ++++++++++++------- ...utoRenewableSubscriptionCatalogError.swift | 2 +- .../AutoRenewableSubscriptionGroup.swift | 4 +- .../TransactionStore.swift | 15 ++-- .../TransactionStoreDelegate.swift | 17 ++-- .../UnrecognizedSubscriptionDelegate.swift | 20 +++-- 13 files changed, 227 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index a2d1211..3a26395 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,9 @@ struct ContentView: View { } } .sheet(isPresented: $isShowingPaywall) { - SubscriptionStoreView(groupID: Plans.id.rawValue) + SubscriptionStoreView( + productIDs: Plans.subscriptions.map(\.id.rawValue) + ) } } } @@ -135,7 +137,8 @@ struct ContentView: View { 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 @@ -166,12 +169,53 @@ let store = TransactionStore( ) ``` +## Unrecognized subscriptions + +A 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 +{ + func decidePolicy( + forUnrecognizedSubscription transaction: StoreTransactionSnapshot + ) async throws + -> UnrecognizedSubscriptionPolicy + { + 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 or out-of-group transaction, or needs +background-failure notifications. Without it, automatic handling finishes +catalog-declared 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. @@ -193,8 +237,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 diff --git a/Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift b/Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift index d12e4ac..0c5b34c 100644 --- a/Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift +++ b/Sources/StoreTransactionKit/Entitlements/EntitlementStatus.swift @@ -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. diff --git a/Sources/StoreTransactionKit/StorePurchaseOutcome.swift b/Sources/StoreTransactionKit/StorePurchaseOutcome.swift index 4ba87e1..96edce0 100644 --- a/Sources/StoreTransactionKit/StorePurchaseOutcome.swift +++ b/Sources/StoreTransactionKit/StorePurchaseOutcome.swift @@ -1,9 +1,11 @@ /// The semantic result of processing a direct StoreKit purchase result. public enum StorePurchaseOutcome: Sendable, Hashable { - /// StoreTransactionKit verified, applied policy, finished, reconciled, and published the transaction. + /// StoreTransactionKit verified, finished, reconciled, and published the + /// transaction. case completed(StoreTransactionSnapshot) - /// StoreTransactionKit verified and published the transaction without finishing it. + /// StoreTransactionKit verified and published an unrecognized subscription + /// without finishing it. case leftUnfinished(StoreTransactionSnapshot) /// The purchase is awaiting an external action and may arrive through transaction updates later. diff --git a/Sources/StoreTransactionKit/StoreTransactionFailure.swift b/Sources/StoreTransactionKit/StoreTransactionFailure.swift index 3ddd2fd..6388b3d 100644 --- a/Sources/StoreTransactionKit/StoreTransactionFailure.swift +++ b/Sources/StoreTransactionKit/StoreTransactionFailure.swift @@ -115,7 +115,8 @@ public enum StoreTransactionError: Error, Sendable { /// StoreKit returned a purchase result unknown to this framework version. case unknownPurchaseResult - /// Automatic handling was requested for a product outside the managed catalog. + /// Automatic handling was requested for a product outside the catalog's + /// subscription group. case unhandledTransaction( productID: Product.ID, productType: Product.ProductType diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md index 16541f5..64cc6ae 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md @@ -42,15 +42,35 @@ let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) Use the group ID and Product ID raw values exactly as configured in App Store Connect. ``AutoRenewableSubscriptionGroup/subscriptions`` is the complete -entitlement-granting declaration; a case that is present only in `ProductID` -doesn't grant typed access. The catalog requires at least one product, nonempty -raw identifiers, and no duplicate raw Product IDs. Several products may grant -the same entitlement. +entitlement mapping known to this binary; it isn't a promise that the live +subscription group contains no other Product IDs. A case that is present only +in `ProductID` doesn't grant typed access. The catalog requires at least one +product, nonempty raw identifiers, and no duplicate raw Product IDs. Several +products may grant the same entitlement. 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. +## Merchandise declared products + +Pass the same declared Product IDs to StoreKit's subscription view: + +```swift +import StoreKit + +SubscriptionStoreView( + productIDs: Plans.subscriptions.map(\.id.rawValue) +) +``` + +This keeps the paywall aligned with the catalog in this binary. A valid +same-group product can still arrive from another device or purchase path. An +unrecognized product remains in the raw projection, grants no typed access by +default, and doesn't by itself make entitlement readiness fail. See + to choose another policy with +``UnrecognizedSubscriptionDelegate``. + ## Read access without blocking the UI ``TransactionStore/isEntitled(to:)`` performs exact set membership and returns diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md index 2488885..f62289d 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md @@ -8,8 +8,8 @@ and observable subscription access in one process-owned store. StoreKit can deliver the same purchase through a direct `Product.PurchaseResult`, `Transaction.updates`, and unfinished-transaction reconciliation. ``TransactionStore`` joins those paths by exact transaction -revision, applies one handling policy, and publishes raw and app-defined -entitlement state together on the main actor. +revision and publishes raw and app-defined entitlement state together on the +main actor. Define one ``AutoRenewableSubscriptionCatalog`` from the Product IDs in an App Store Connect subscription group. Several products, such as monthly and yearly @@ -23,11 +23,17 @@ override. The optional ``TransactionStoreDelegate`` owns only app-specific durable effects and background-failure reactions. Without a delegate, the default -policy finishes catalog-managed auto-renewable subscriptions. StoreTransactionKit +policy finishes catalog-declared auto-renewable subscriptions. StoreTransactionKit still verifies deliveries, reconciles unfinished work, validates catalog metadata, orders history, restores purchases, reports background failures, and drains admitted work during explicit shutdown. +Products in the managed group that this binary doesn't recognize remain in the +raw projection and don't invalidate entitlement readiness. The optional +``UnrecognizedSubscriptionDelegate`` can leave an unfinished delivery +unfinished, finish it without a typed grant, or map its exact revision to a +known app entitlement. + Start with , then read before adding an app-owned transaction effect. shows StoreKit-free ViewModel tests @@ -59,6 +65,8 @@ that use the production store data flow. - ``TransactionStoreDelegate`` - ``StoreTransactionHandlingPolicy`` +- ``UnrecognizedSubscriptionDelegate`` +- ``UnrecognizedSubscriptionPolicy`` - ``StorePurchaseOutcome`` - ``StoreTransactionSnapshot`` diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md index 171e48c..83b12ab 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md @@ -51,6 +51,47 @@ 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. +## Exercise an unrecognized subscription + +Create an undeclared same-group revision separately from delivering it. The +split lets a test replay the exact revision and verify both successful policy +caching and retry after a thrown decision: + +```swift +actor LegacyPlanDelegate: + UnrecognizedSubscriptionDelegate +{ + func decidePolicy( + forUnrecognizedSubscription transaction: StoreTransactionSnapshot + ) async throws + -> UnrecognizedSubscriptionPolicy + { + .treatAs(.tier1) + } +} + +let delegate = LegacyPlanDelegate() + +try await withTransactionStoreTestHarness( + subscriptionCatalog: subscriptionCatalog, + unrecognizedSubscriptionDelegate: delegate +) { harness in + let transaction = try harness.makeUnrecognizedSubscription( + productID: "com.example.subscription.tier1.weekly", + in: Plans.self + ) + + let outcome = try await harness.deliver(transaction) + + #expect(outcome == .completed(transaction)) + #expect(harness.store.isEntitled(to: .tier1)) +} +``` + +`purchase(_:,in:)` remains the typed shortcut for a catalog-declared product. +`deliver(_:)` accepts only an exact synthetic snapshot registered by that +harness. + 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 diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md index 2f9c153..9da5715 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md @@ -18,29 +18,45 @@ ledger: process launch or eviction can present the revision again. If a durably idempotent for the business event. Transaction ID alone is insufficient because a later signed revision can describe a revocation or another change. +The successful ``UnrecognizedSubscriptionDelegate`` decision for an exact +revision is instead reused for the store session so transaction handling and +typed projection can't disagree. A thrown decision isn't cached; a later +independent delivery may ask again. + ## Policy and finishing -The catalog validates and classifies every verified transaction before asking -the delegate for ``TransactionStoreDelegate/decidePolicy(for:)``: +The catalog validates and classifies every verified transaction before choosing +its policy owner: - A declared auto-renewable subscription with matching group metadata is - managed. ``StoreTransactionHandlingPolicy/automatic`` and + managed by ``TransactionStoreDelegate``. + ``StoreTransactionHandlingPolicy/automatic`` and ``StoreTransactionHandlingPolicy/finish`` both allow finishing it. - A product outside the catalog's group is unmanaged. `.automatic` throws ``StoreTransactionError/unhandledTransaction(productID:productType:)``; `.finish` allows finishing only after the app has durably handled it. -- Contradictory metadata or an undeclared current product inside the managed - group fails catalog validation before the delegate runs and is never - finished. - -If the delegate throws, the framework does not finish the transaction or start -its causal entitlement refresh. A later independent StoreKit delivery opens a -new attempt; the framework adds no timer or retry loop. - -For a successful purchase, -``TransactionStore/process(_:)`` returns -``StorePurchaseOutcome/completed(_:)`` only after policy, `finish()`, unfinished -reconciliation, catalog projection, and main-actor publication complete. +- A valid, non-upgraded auto-renewable subscription in the catalog's group whose + Product ID this binary doesn't declare is resolved by + ``UnrecognizedSubscriptionDelegate``. ``UnrecognizedSubscriptionPolicy/leaveUnfinished`` + grants no typed access and doesn't call `finish()`; + ``UnrecognizedSubscriptionPolicy/finish`` finishes without a typed grant; and + ``UnrecognizedSubscriptionPolicy/treatAs(_:)`` finishes and maps the exact + revision to a known app entitlement. +- Contradictory metadata, such as a declared Product ID arriving with the wrong + product type or group, fails catalog validation before either delegate runs. + +Without an unrecognized-subscription delegate, `.leaveUnfinished` is the +default. It is a successful policy rather than a failure or retry request. +If either policy delegate throws, the framework doesn't finish the transaction +or start its causal entitlement refresh. A later independent StoreKit delivery +opens a new attempt; the framework adds no timer or retry loop. + +For a verified purchase, ``TransactionStore/process(_:)`` returns only after +policy, the selected finish or leave action, unfinished reconciliation, catalog +projection, and main-actor publication complete. +``StorePurchaseOutcome/completed(_:)`` means the transaction was finished. +``StorePurchaseOutcome/leftUnfinished(_:)`` means the unrecognized-product +decision and publication completed without calling `finish()`. If the refresh fails after `finish()`, the method instead throws ``StoreTransactionError/entitlementRefreshFailed(after:underlyingError:)`` with ``StoreTransactionError/CompletedOperation/finishedTransaction(_:)``. Retry @@ -53,11 +69,13 @@ with ``StoreTransactionError/CompletedOperation/synchronizedPurchases``. ## Reconciliation and publication -The initial load and every entitlement refresh handle all verified unfinished +The initial load and every entitlement refresh evaluate verified unfinished transactions before publishing `Transaction.currentEntitlements`. This keeps -the observable projection from running ahead of unfinished durable work. +the observable projection from running ahead of unfinished durable work. A +revision resolved as `.leaveUnfinished` remains unfinished in StoreKit but +doesn't block publication or repeat its decision in the same store session. Current entitlements that were finished by another process or device may still -appear without a local policy invocation. +require an unrecognized-subscription decision for typed projection. ``TransactionStore/entitlements``, ``TransactionStore/activeEntitlements``, and @@ -79,9 +97,13 @@ refresh publishes a new ready snapshot. The catalog maps declared Product IDs to app entitlement values. It does not copy StoreKit group levels or subscription periods into the entitlement type, and multiple Product IDs may map to the same value. A transaction that StoreKit -marks as upgraded grants no typed access. The raw projection otherwise mirrors -the verified current-entitlement items StoreKit returns, including products -outside the managed group. +marks as upgraded grants no typed access. A valid unrecognized same-group +product remains in the raw projection without making readiness fail. +`.leaveUnfinished` and `.finish` grant no typed access; +`.treatAs(entitlement)` adds the selected value. A thrown unrecognized decision +is a transient failure, not a catalog contradiction. The raw projection +otherwise mirrors the verified current-entitlement items StoreKit returns, +including products outside the managed group. StoreKit excludes revoked or refunded transactions from current entitlements. For billing retry, grace period, and renewal presentation, use @@ -102,11 +124,15 @@ unfinished reconciliation, current-entitlement verification failures, and abandoned direct operations. The callback is a notification after the failure; it cannot change policy or request retry. +An unrecognized `.leaveUnfinished` decision is successful and produces no +background failure. If the unrecognized-subscription delegate throws, the same +direct-versus-background failure ownership rules apply. + Failures that affect observable entitlement state commit that state before notification begins. Notifications are serialized with backpressure and ``TransactionStore/close()`` drains every admitted notification. Policy -decisions are also serialized, but policy and notification execution may -overlap. +decisions are serialized by their respective owners, but the policy and +notification paths may overlap. An unverified direct purchase throws ``StoreTransactionVerificationError`` to its caller. An unverified background element is reported instead. Unverified @@ -127,9 +153,9 @@ When closing has sealed admission, a new operation throws backend and its StoreKit operations throw ``StoreTransactionError/operationUnavailableInOverride(operation:)``. -Delegate methods must not call an admission-bearing operation on the same -store. Such a call can wait behind the callback that is making it. Reentry with -inherited callback context throws +Methods of either delegate must not call an admission-bearing operation on the +same store. Such a call can wait behind the callback that is making it. Reentry +with inherited callback context throws ``StoreTransactionError/reentrantOperation(operation:)``. A detached task does not inherit that detection context, but awaiting one from the callback still creates the same dependency cycle and must also be avoided. @@ -145,8 +171,9 @@ The first ``TransactionStore/close()`` call atomically seals public and producer admission and starts one terminal shutdown. Concurrent close calls join the same completion, and caller cancellation does not abandon it. Shutdown stops StoreKit producers, drains admitted producer elements and public operations, drains -transaction, refresh, restore, and failure workers, releases the delegate, and -then releases the live-store lease. The last entitlement state remains readable. +transaction, refresh, restore, policy, and failure workers, clears the +unrecognized-policy cache, releases both delegates, and then releases the live +store lease. The last entitlement state remains readable. Calling `close()` from a callback owned by the same store throws a reentrancy error. Production apps normally retain the store for process lifetime; explicit diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift index 3b7df3d..3d04c4e 100644 --- a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionCatalogError.swift @@ -3,7 +3,7 @@ import StoreKit /// An inconsistency between a transaction snapshot and a subscription catalog. public enum AutoRenewableSubscriptionCatalogError: LocalizedError, Sendable { - /// A catalog product isn't an auto-renewable subscription. + /// A declared or same-group product isn't an auto-renewable subscription. case productTypeMismatch( productID: Product.ID, actual: Product.ProductType diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift index 4b1a63a..42c8fad 100644 --- a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionGroup.swift @@ -15,7 +15,9 @@ public protocol AutoRenewableSubscriptionGroup { /// The group's identifier in App Store Connect. static var id: SubscriptionGroupID { get } - /// The complete set of declared products and the entitlement each one grants. + /// The complete entitlement mapping known to this binary. + /// + /// The live App Store Connect group may contain additional Product IDs. @StoreSubscriptionsBuilder< Self.ProductID, Self.Entitlement diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index 54c2cd6..58f8934 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -59,7 +59,8 @@ where Entitlement: Hashable & Sendable { /// The latest complete raw StoreKit entitlement projection. /// /// This value is non-`nil` only in ``EntitlementStatus/ready``. Override - /// mode has no synthetic raw StoreKit projection. + /// mode has no synthetic raw StoreKit projection. A ready projection can + /// include a valid same-group product that this binary doesn't declare. public var entitlements: StoreEntitlements? { guard case .ready(let entitlements, _) = availability else { return nil @@ -69,9 +70,11 @@ where Entitlement: Hashable & Sendable { /// The app-defined entitlements granted by the current catalog projection. /// - /// A non-`nil` empty set authoritatively means that no declared entitlement - /// is active. The value is `nil` while loading or when no usable projection - /// is available after failure. + /// A non-`nil` empty set authoritatively means that no app entitlement is + /// active. Declared products use the catalog mapping; an unrecognized + /// subscription contributes only when its delegate policy uses + /// ``UnrecognizedSubscriptionPolicy/treatAs(_:)``. The value is `nil` while + /// loading or when no usable projection is available after failure. public var activeEntitlements: Set? { switch availability { case .ready(_, let activeEntitlements), @@ -91,7 +94,9 @@ where Entitlement: Hashable & Sendable { /// Initialization starts transaction monitoring and the first entitlement /// reconciliation. The store strongly retains both delegates until terminal /// shutdown. Creating a second live store in the same process before the - /// first store finishes ``close()`` is a programmer error. + /// first store finishes ``close()`` is a programmer error. Without + /// `unrecognizedSubscriptionDelegate`, valid undeclared same-group + /// subscriptions use ``UnrecognizedSubscriptionPolicy/leaveUnfinished``. public convenience init( subscriptionCatalog: AutoRenewableSubscriptionCatalog, delegate: (any TransactionStoreDelegate)? = nil, diff --git a/Sources/StoreTransactionKit/TransactionStoreDelegate.swift b/Sources/StoreTransactionKit/TransactionStoreDelegate.swift index d63a05a..238af93 100644 --- a/Sources/StoreTransactionKit/TransactionStoreDelegate.swift +++ b/Sources/StoreTransactionKit/TransactionStoreDelegate.swift @@ -1,9 +1,9 @@ /// The action StoreTransactionKit takes after classifying a verified transaction. public enum StoreTransactionHandlingPolicy: Sendable, Hashable { - /// Finishes a catalog-managed transaction without an app-owned business effect. + /// Finishes a catalog-declared transaction without an app-owned business + /// effect. /// - /// StoreTransactionKit rejects this policy for a transaction outside the - /// managed subscription catalog. + /// StoreTransactionKit rejects this policy for an out-of-group transaction. case automatic /// Finishes a transaction after the app has durably applied its business effect. @@ -17,14 +17,17 @@ public enum StoreTransactionHandlingPolicy: Sendable, Hashable { /// /// The delegate is optional because both requirements have default /// implementations. Policy decisions and failure notifications are each -/// serialized, but the two streams may overlap. +/// serialized, but the two streams may overlap. Valid undeclared subscriptions +/// in the catalog's group are resolved separately by +/// ``UnrecognizedSubscriptionDelegate``. public protocol TransactionStoreDelegate: AnyObject, Sendable { /// Chooses how to handle a verified transaction. /// /// StoreTransactionKit invokes decisions serially after catalog - /// classification. Throwing prevents the transaction from being finished - /// and prevents its causal entitlement refresh. A later independent - /// StoreKit delivery may retry the exact revision. + /// classification for declared and out-of-group transactions. Throwing + /// prevents the transaction from being finished and prevents its causal + /// entitlement refresh. A later independent StoreKit delivery may retry the + /// exact revision. func decidePolicy( for transaction: StoreTransactionSnapshot ) async throws -> StoreTransactionHandlingPolicy diff --git a/Sources/StoreTransactionKit/UnrecognizedSubscriptionDelegate.swift b/Sources/StoreTransactionKit/UnrecognizedSubscriptionDelegate.swift index 57c74e9..f00aaf8 100644 --- a/Sources/StoreTransactionKit/UnrecognizedSubscriptionDelegate.swift +++ b/Sources/StoreTransactionKit/UnrecognizedSubscriptionDelegate.swift @@ -2,13 +2,14 @@ /// in the catalog's subscription group. public enum UnrecognizedSubscriptionPolicy: Sendable, Hashable where Entitlement: Hashable & Sendable { - /// Leaves the transaction unfinished and grants no typed entitlement. + /// Grants no typed entitlement and leaves an unfinished delivery unfinished. case leaveUnfinished - /// Finishes the transaction without granting a typed entitlement. + /// Grants no typed entitlement and finishes an unfinished delivery. case finish - /// Finishes the transaction and projects it as a known app entitlement. + /// Projects the revision as a known app entitlement and finishes an + /// unfinished delivery. case treatAs(Entitlement) } @@ -16,9 +17,10 @@ where Entitlement: Hashable & Sendable { /// /// StoreTransactionKit invokes this delegate only for a non-upgraded /// auto-renewable subscription whose group matches the catalog and whose Product -/// ID is undeclared. Decisions are serialized and reused for the exact -/// transaction revision. Do not call an admission-bearing operation on the same -/// ``TransactionStore`` from this method. +/// ID is undeclared. Decisions are serialized. A successful decision is reused +/// for the exact transaction revision until the store closes. Do not call an +/// admission-bearing operation on the same ``TransactionStore`` from this +/// method. public protocol UnrecognizedSubscriptionDelegate: AnyObject, Sendable @@ -27,9 +29,9 @@ public protocol UnrecognizedSubscriptionDelegate: /// Chooses how to handle and project an undeclared subscription. /// - /// Throwing leaves an unfinished transaction unfinished and makes the - /// current entitlement refresh fail transiently. A later independent - /// attempt may retry the decision. + /// Throwing isn't cached. It leaves an unfinished delivery unfinished, + /// makes the current entitlement refresh fail transiently, and allows a + /// later independent attempt to ask again. func decidePolicy( forUnrecognizedSubscription transaction: StoreTransactionSnapshot ) async throws -> UnrecognizedSubscriptionPolicy From b8394c7d05721fe62a1fde31968715c3d09f12f1 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:12:50 +0900 Subject: [PATCH 3/7] feat(testing): support unrecognized subscriptions Expose registered synthetic unrecognized subscriptions through the scoped test harness and return semantic delivery outcomes. Prove delegate policy behavior through package tests and the external consumer fixture. --- .../Sources/Consumer/Consumer.swift | 27 +- .../SubscriptionAccessTests.swift | 25 ++ .../TransactionStore.swift | 9 +- ...swift => SyntheticTransactionLedger.swift} | 20 +- .../TransactionStoreTestHarness.swift | 114 ++++- .../TransactionStoreTestHarnessError.swift | 15 + .../WithTransactionStoreTestHarness.swift | 9 +- .../TransactionStoreTestHarnessTests.swift | 402 ++++++++++++++++++ .../RuntimeOwnerTests.swift | 41 +- 9 files changed, 626 insertions(+), 36 deletions(-) rename Sources/StoreTransactionKitTesting/{SyntheticCurrentEntitlements.swift => SyntheticTransactionLedger.swift} (71%) diff --git a/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift index 1ce4248..5076833 100644 --- a/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift +++ b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift @@ -27,6 +27,9 @@ public enum Plans: AutoRenewableSubscriptionGroup { public let subscriptionCatalog = AutoRenewableSubscriptionCatalog(Plans.self) +public let legacySubscriptionProductID = + "external-consumer.subscription.legacy" + @MainActor public final class NotesViewModel { private let store: TransactionStore @@ -56,14 +59,36 @@ public actor AppTransactionDelegate: TransactionStoreDelegate { } } +public actor AppUnrecognizedSubscriptionDelegate: + UnrecognizedSubscriptionDelegate +{ + public typealias Entitlement = SubscriptionEntitlement + + public init() {} + + public func decidePolicy( + forUnrecognizedSubscription transaction: StoreTransactionSnapshot + ) async throws -> UnrecognizedSubscriptionPolicy { + 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)") diff --git a/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift b/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift index b378285..0b2d421 100644 --- a/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift +++ b/Fixtures/ExternalConsumer/Tests/ConsumerTests/SubscriptionAccessTests.swift @@ -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) + } +} diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index 58f8934..829a4d6 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -284,10 +284,9 @@ where Entitlement: Hashable & Sendable { ) } - @discardableResult package func processSyntheticDelivery( _ delivery: StoreTransactionDelivery - ) async throws -> StoreTransactionSnapshot { + ) async throws -> StorePurchaseOutcome { try rejectReentrancy(operation: .processPurchase) guard case .syntheticRuntime(let runtime, let lifecycle, _) = backend else { preconditionFailure( @@ -295,11 +294,7 @@ where Entitlement: Hashable & Sendable { ) } let leases = try lifecycle.beginOperation() - let outcome = try await runtime.process(delivery, leases: leases) - guard case .completed(let snapshot) = outcome else { - preconditionFailure("A synthetic delivery must complete a transaction.") - } - return snapshot + return try await runtime.process(delivery, leases: leases) } /// Reconciles unfinished transactions and publishes current entitlements. diff --git a/Sources/StoreTransactionKitTesting/SyntheticCurrentEntitlements.swift b/Sources/StoreTransactionKitTesting/SyntheticTransactionLedger.swift similarity index 71% rename from Sources/StoreTransactionKitTesting/SyntheticCurrentEntitlements.swift rename to Sources/StoreTransactionKitTesting/SyntheticTransactionLedger.swift index 44301ee..5ebdb2f 100644 --- a/Sources/StoreTransactionKitTesting/SyntheticCurrentEntitlements.swift +++ b/Sources/StoreTransactionKitTesting/SyntheticTransactionLedger.swift @@ -3,8 +3,9 @@ import StoreKit import StoreTransactionKit @MainActor -final class SyntheticCurrentEntitlements: Sendable { +final class SyntheticTransactionLedger: Sendable { private var nextTransactionID: UInt64 = 1 + private var registeredSnapshots: [UInt64: StoreTransactionSnapshot] = [:] private var activeSnapshot: StoreTransactionSnapshot? func snapshots() -> [StoreTransactionSnapshot] { @@ -15,7 +16,7 @@ final class SyntheticCurrentEntitlements: Sendable { } } - func makeSnapshot( + func makeRegisteredSnapshot( productID: String, subscriptionGroupID: SubscriptionGroupID ) -> StoreTransactionSnapshot { @@ -27,7 +28,7 @@ final class SyntheticCurrentEntitlements: Sendable { nextTransactionID += 1 let date = Date(timeIntervalSince1970: TimeInterval(transactionID)) - return StoreTransactionSnapshot( + let snapshot = StoreTransactionSnapshot( id: transactionID, originalID: transactionID, productID: productID, @@ -53,9 +54,20 @@ final class SyntheticCurrentEntitlements: Sendable { jwsRepresentation: "StoreTransactionKitTesting.synthetic.\(transactionID)" ) + precondition(registeredSnapshots[transactionID] == nil) + registeredSnapshots[transactionID] = snapshot + return snapshot } - func replace(with snapshot: StoreTransactionSnapshot) { + func contains(_ snapshot: StoreTransactionSnapshot) -> Bool { + registeredSnapshots[snapshot.id] == snapshot + } + + func activate(_ snapshot: StoreTransactionSnapshot) { + precondition( + contains(snapshot), + "Only a transaction registered by this test harness can become current." + ) activeSnapshot = snapshot } } diff --git a/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift index ce8294e..ce9b568 100644 --- a/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift +++ b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift @@ -8,17 +8,17 @@ where Entitlement: Hashable & Sendable { public let store: TransactionStore private let subscriptionCatalog: AutoRenewableSubscriptionCatalog - private let currentEntitlements: SyntheticCurrentEntitlements + private let ledger: SyntheticTransactionLedger private init( store: TransactionStore, subscriptionCatalog: AutoRenewableSubscriptionCatalog, - currentEntitlements: SyntheticCurrentEntitlements + ledger: SyntheticTransactionLedger ) { self.store = store self.subscriptionCatalog = subscriptionCatalog - self.currentEntitlements = currentEntitlements + self.ledger = ledger } /// Simulates an immediately active purchase of a declared subscription product. @@ -31,17 +31,7 @@ where Entitlement: Hashable & Sendable { in groupType: Group.Type ) async throws -> StoreTransactionSnapshot where Group: AutoRenewableSubscriptionGroup { - guard subscriptionCatalog.subscriptionGroupID == Group.id else { - throw TransactionStoreTestHarnessError.subscriptionGroupMismatch( - expected: subscriptionCatalog.subscriptionGroupID, - actual: Group.id - ) - } - guard subscriptionCatalog.isDeclared(by: groupType) else { - throw TransactionStoreTestHarnessError.subscriptionGroupTypeMismatch( - subscriptionGroupID: Group.id - ) - } + try validate(groupType) let rawProductID = productID.rawValue guard subscriptionCatalog.contains(productID: rawProductID) else { @@ -52,30 +42,112 @@ where Entitlement: Hashable & Sendable { } try Task.checkCancellation() - let snapshot = currentEntitlements.makeSnapshot( + let snapshot = ledger.makeRegisteredSnapshot( productID: rawProductID, subscriptionGroupID: Group.id ) + let outcome = try await store.processSyntheticDelivery( + .synthetic(snapshot: snapshot) { [ledger] in + await ledger.activate(snapshot) + } + ) + guard case .completed(let completed) = outcome else { + preconditionFailure( + "A declared synthetic purchase must complete its transaction." + ) + } + return completed + } + + /// Creates an unrecognized subscription revision without delivering it. + /// + /// The product identifier must be nonempty and absent from the supplied + /// group's catalog. The returned snapshot is registered to this harness and + /// can be passed to ``deliver(_:)`` repeatedly to exercise retry and replay + /// behavior. + public func makeUnrecognizedSubscription( + productID: String, + in groupType: Group.Type + ) throws -> StoreTransactionSnapshot + where Group: AutoRenewableSubscriptionGroup { + try validate(groupType) + precondition( + !productID.isEmpty, + "A synthetic subscription product identifier must not be empty." + ) + guard !subscriptionCatalog.contains(productID: productID) else { + throw TransactionStoreTestHarnessError.declaredProduct( + productID: productID, + subscriptionGroupID: Group.id + ) + } + + return ledger.makeRegisteredSnapshot( + productID: productID, + subscriptionGroupID: Group.id + ) + } + + /// Delivers an exact synthetic snapshot value registered by this harness. + /// + /// Delivery first makes the revision authoritative for current-entitlement + /// reconciliation, then runs the production policy and publication path. + /// Re-delivering the same revision exercises the store's session policy and + /// acknowledgement idempotency. A value not exactly registered by this + /// harness throws ``TransactionStoreTestHarnessError/unregisteredTransaction(transactionID:)`` + /// before changing the synthetic current-entitlement source. + @discardableResult + public func deliver( + _ transaction: StoreTransactionSnapshot + ) async throws -> StorePurchaseOutcome { + guard ledger.contains(transaction) else { + throw TransactionStoreTestHarnessError.unregisteredTransaction( + transactionID: transaction.id + ) + } + + try Task.checkCancellation() + ledger.activate(transaction) return try await store.processSyntheticDelivery( - .synthetic(snapshot: snapshot) { [currentEntitlements] in - await currentEntitlements.replace(with: snapshot) + .synthetic(snapshot: transaction) { [ledger] in + await ledger.activate(transaction) } ) } + private func validate( + _ groupType: Group.Type + ) throws where Group: AutoRenewableSubscriptionGroup { + guard subscriptionCatalog.subscriptionGroupID == Group.id else { + throw TransactionStoreTestHarnessError.subscriptionGroupMismatch( + expected: subscriptionCatalog.subscriptionGroupID, + actual: Group.id + ) + } + guard subscriptionCatalog.isDeclared(by: groupType) else { + throw TransactionStoreTestHarnessError.subscriptionGroupTypeMismatch( + subscriptionGroupID: Group.id + ) + } + } + static func make( subscriptionCatalog: AutoRenewableSubscriptionCatalog, - delegate: (any TransactionStoreDelegate)? + delegate: (any TransactionStoreDelegate)?, + unrecognizedSubscriptionDelegate: + (any UnrecognizedSubscriptionDelegate)? ) async throws -> TransactionStoreTestHarness { - let currentEntitlements = SyntheticCurrentEntitlements() + let ledger = SyntheticTransactionLedger() let syntheticSource = SyntheticStoreTransactionSource { - await currentEntitlements.snapshots() + await ledger.snapshots() } let store = TransactionStore( subscriptionCatalog: subscriptionCatalog, syntheticSource: syntheticSource, delegate: delegate, + unrecognizedSubscriptionDelegate: + unrecognizedSubscriptionDelegate, unavailableOperationError: { TransactionStoreTestHarnessError.operationUnavailable( operation: $0 @@ -85,7 +157,7 @@ where Entitlement: Hashable & Sendable { let harness = TransactionStoreTestHarness( store: store, subscriptionCatalog: subscriptionCatalog, - currentEntitlements: currentEntitlements + ledger: ledger ) do { diff --git a/Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift index ab742bf..6ca370f 100644 --- a/Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift +++ b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarnessError.swift @@ -24,6 +24,15 @@ public enum TransactionStoreTestHarnessError: subscriptionGroupID: SubscriptionGroupID ) + /// The supplied product is already declared by the catalog's subscription group. + case declaredProduct( + productID: String, + subscriptionGroupID: SubscriptionGroupID + ) + + /// The supplied value doesn't exactly match a snapshot registered by this harness. + case unregisteredTransaction(transactionID: UInt64) + /// The synthetic store doesn't provide the requested live StoreKit operation. case operationUnavailable(operation: StoreTransactionOperation) @@ -39,6 +48,12 @@ public enum TransactionStoreTestHarnessError: case .undeclaredProduct(let productID, let subscriptionGroupID): "Product \(productID) is not declared by subscription group \(subscriptionGroupID.rawValue)." + case .declaredProduct(let productID, let subscriptionGroupID): + "Product \(productID) is already declared by subscription group \(subscriptionGroupID.rawValue)." + + case .unregisteredTransaction(let transactionID): + "Transaction \(transactionID) does not exactly match a snapshot registered by this test harness." + case .operationUnavailable(let operation): "The synthetic transaction store does not provide \(operation.description)." } diff --git a/Sources/StoreTransactionKitTesting/WithTransactionStoreTestHarness.swift b/Sources/StoreTransactionKitTesting/WithTransactionStoreTestHarness.swift index a0454e5..95f9248 100644 --- a/Sources/StoreTransactionKitTesting/WithTransactionStoreTestHarness.swift +++ b/Sources/StoreTransactionKitTesting/WithTransactionStoreTestHarness.swift @@ -2,11 +2,16 @@ import StoreTransactionKit /// Runs a scoped test with a ready-empty synthetic transaction store. /// +/// Supply the same transaction delegates as the app's live composition root to +/// exercise their production policy paths without StoreKit. +/// /// The store is closed and drained before this function returns or throws. @MainActor public func withTransactionStoreTestHarness( subscriptionCatalog: AutoRenewableSubscriptionCatalog, delegate: (any TransactionStoreDelegate)? = nil, + unrecognizedSubscriptionDelegate: + (any UnrecognizedSubscriptionDelegate)? = nil, _ operation: @MainActor ( TransactionStoreTestHarness @@ -15,7 +20,9 @@ public func withTransactionStoreTestHarness( where Entitlement: Hashable & Sendable { let harness = try await TransactionStoreTestHarness.make( subscriptionCatalog: subscriptionCatalog, - delegate: delegate + delegate: delegate, + unrecognizedSubscriptionDelegate: + unrecognizedSubscriptionDelegate ) let operationResult: Swift.Result diff --git a/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift index f503567..2e6ad0d 100644 --- a/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift +++ b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift @@ -1,3 +1,4 @@ +import Foundation import StoreTransactionKit import StoreTransactionKitTesting import StoreKit @@ -76,6 +77,275 @@ struct TransactionStoreTestHarnessTests { } } + @MainActor + @Test("default unrecognized policy publishes raw ready state without entitlement") + func defaultUnrecognizedPolicy() async throws { + let generalDelegate = ActorRecordingDelegate() + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: generalDelegate + ) { harness in + let transaction = try harness.makeUnrecognizedSubscription( + productID: "testing.subscription.legacy", + in: HarnessPlans.self + ) + + #expect( + try await harness.deliver(transaction) + == .leftUnfinished(transaction) + ) + #expect(harness.store.entitlements?.transactions == [transaction]) + #expect(harness.store.activeEntitlements == []) + #expect( + await generalDelegate.snapshot() + == .init(decisions: 0, failures: 0) + ) + } + } + + @MainActor + @Test("explicit leave policy is reused for an exact revision") + func explicitLeavePolicyReplay() async throws { + let delegate = HarnessUnrecognizedDelegate(policy: .leaveUnfinished) + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + unrecognizedSubscriptionDelegate: delegate + ) { harness in + let transaction = try harness.makeUnrecognizedSubscription( + productID: "testing.subscription.legacy", + in: HarnessPlans.self + ) + + #expect( + try await harness.deliver(transaction) + == .leftUnfinished(transaction) + ) + #expect( + try await harness.deliver(transaction) + == .leftUnfinished(transaction) + ) + #expect(await delegate.decisionCount() == 1) + #expect(harness.store.entitlements?.transactions == [transaction]) + #expect(harness.store.activeEntitlements == []) + } + } + + @MainActor + @Test("finish policy completes without a typed entitlement") + func unrecognizedFinishPolicy() async throws { + let delegate = HarnessUnrecognizedDelegate(policy: .finish) + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + unrecognizedSubscriptionDelegate: delegate + ) { harness in + let transaction = try harness.makeUnrecognizedSubscription( + productID: "testing.subscription.legacy", + in: HarnessPlans.self + ) + + #expect( + try await harness.deliver(transaction) + == .completed(transaction) + ) + #expect(await delegate.decisionCount() == 1) + #expect(harness.store.entitlements?.transactions == [transaction]) + #expect(harness.store.activeEntitlements == []) + } + } + + @MainActor + @Test("treat-as policy persists through explicit refresh") + func unrecognizedTreatAsPolicy() async throws { + let delegate = HarnessUnrecognizedDelegate(policy: .treatAs(.tier1)) + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + unrecognizedSubscriptionDelegate: delegate + ) { harness in + let transaction = try harness.makeUnrecognizedSubscription( + productID: "testing.subscription.legacy", + in: HarnessPlans.self + ) + + #expect( + try await harness.deliver(transaction) + == .completed(transaction) + ) + #expect(harness.store.activeEntitlements == [.tier1]) + + let refreshed = try await harness.store.refreshEntitlements() + + #expect(refreshed.transactions == [transaction]) + #expect(harness.store.activeEntitlements == [.tier1]) + #expect(await delegate.decisionCount() == 1) + } + } + + @MainActor + @Test("a thrown decision can retry the same registered revision") + func unrecognizedDecisionRetry() async throws { + let delegate = HarnessUnrecognizedDelegate { _, attempt in + if attempt == 1 { + throw HarnessTestError.decision + } + return .treatAs(.tier1) + } + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + unrecognizedSubscriptionDelegate: delegate + ) { harness in + let transaction = try harness.makeUnrecognizedSubscription( + productID: "testing.subscription.legacy", + in: HarnessPlans.self + ) + + await #expect(throws: HarnessTestError.decision) { + _ = try await harness.deliver(transaction) + } + #expect(harness.store.entitlements?.transactions.isEmpty == true) + #expect(harness.store.activeEntitlements == []) + #expect(await delegate.decisionCount() == 1) + + #expect( + try await harness.deliver(transaction) + == .completed(transaction) + ) + #expect(harness.store.entitlements?.transactions == [transaction]) + #expect(harness.store.activeEntitlements == [.tier1]) + #expect(await delegate.decisionCount() == 2) + } + } + + @MainActor + @Test("concurrent delivery of one revision joins one policy decision") + func concurrentUnrecognizedDelivery() async throws { + let decisionStarted = HarnessTestSignal() + let decisionGate = HarnessTestGate() + let secondStarted = HarnessTestSignal() + let delegate = HarnessUnrecognizedDelegate { _, _ in + decisionStarted.send() + try await decisionGate.wait() + return .treatAs(.tier1) + } + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + unrecognizedSubscriptionDelegate: delegate + ) { harness in + let transaction = try harness.makeUnrecognizedSubscription( + productID: "testing.subscription.legacy", + in: HarnessPlans.self + ) + let first = Task { @MainActor in + try await harness.deliver(transaction) + } + await decisionStarted.wait() + + let second = Task { @MainActor in + secondStarted.send() + return try await harness.deliver(transaction) + } + await secondStarted.wait() + #expect(await delegate.decisionCount() == 1) + + await decisionGate.open() + #expect(try await first.value == .completed(transaction)) + #expect(try await second.value == .completed(transaction)) + #expect(await delegate.decisionCount() == 1) + #expect(harness.store.activeEntitlements == [.tier1]) + } + } + + @MainActor + @Test("unrecognized validation completes before production admission") + func unrecognizedValidationPrecedesAdmission() async throws { + let generalDelegate = ActorRecordingDelegate() + let unrecognizedDelegate = HarnessUnrecognizedDelegate(policy: .finish) + var foreignTransaction: StoreTransactionSnapshot? + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + delegate: generalDelegate, + unrecognizedSubscriptionDelegate: unrecognizedDelegate + ) { harness in + await expectHarnessError( + .subscriptionGroupMismatch( + expected: HarnessPlans.id, + actual: DifferentIDPlans.id + ) + ) { + _ = try harness.makeUnrecognizedSubscription( + productID: "testing.subscription.legacy", + in: DifferentIDPlans.self + ) + } + await expectHarnessError( + .subscriptionGroupTypeMismatch( + subscriptionGroupID: HarnessPlans.id + ) + ) { + _ = try harness.makeUnrecognizedSubscription( + productID: "testing.subscription.legacy", + in: SubstitutedPlans.self + ) + } + await expectHarnessError( + .declaredProduct( + productID: + HarnessPlans.ProductID.tier1_Monthly.rawValue, + subscriptionGroupID: HarnessPlans.id + ) + ) { + _ = try harness.makeUnrecognizedSubscription( + productID: + HarnessPlans.ProductID.tier1_Monthly.rawValue, + in: HarnessPlans.self + ) + } + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { foreignHarness in + foreignTransaction = + try foreignHarness.makeUnrecognizedSubscription( + productID: "testing.subscription.foreign", + in: HarnessPlans.self + ) + } + let foreignTransaction = try #require(foreignTransaction) + await expectHarnessError( + .unregisteredTransaction( + transactionID: foreignTransaction.id + ) + ) { + _ = try await harness.deliver(foreignTransaction) + } + + #expect( + await generalDelegate.snapshot() + == .init(decisions: 0, failures: 0) + ) + #expect(await unrecognizedDelegate.decisionCount() == 0) + #expect(harness.store.entitlements?.transactions.isEmpty == true) + #expect(harness.store.activeEntitlements == []) + + let firstRegistered = + try harness.makeUnrecognizedSubscription( + productID: "testing.subscription.legacy", + in: HarnessPlans.self + ) + #expect(firstRegistered.id == 1) + #expect( + try await harness.deliver(firstRegistered) + == .completed(firstRegistered) + ) + } + } + @MainActor @Test("group and product validation completes before production admission") func validationPrecedesAdmission() async throws { @@ -288,6 +558,28 @@ struct TransactionStoreTestHarnessTests { try await expectClosed(try #require(retained)) } + @MainActor + @Test("scoped cleanup closes a store with a left-unfinished revision") + func scopedUnfinishedCleanup() async throws { + var retained: TransactionStoreTestHarness? + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog + ) { harness in + retained = harness + let transaction = try harness.makeUnrecognizedSubscription( + productID: "testing.subscription.legacy", + in: HarnessPlans.self + ) + #expect( + try await harness.deliver(transaction) + == .leftUnfinished(transaction) + ) + } + + try await expectClosed(try #require(retained)) + } + @MainActor @Test("scoped cleanup closes a retained harness after operation failure") func scopedFailureCleanup() async throws { @@ -451,6 +743,116 @@ private actor ThrowingDelegate: TransactionStoreDelegate { } } +private typealias HarnessUnrecognizedPolicy = + UnrecognizedSubscriptionPolicy + +private actor HarnessUnrecognizedDelegate: + UnrecognizedSubscriptionDelegate +{ + typealias Entitlement = HarnessEntitlement + + private let decide: + @Sendable (StoreTransactionSnapshot, Int) async throws + -> HarnessUnrecognizedPolicy + private var transactions: [StoreTransactionSnapshot] = [] + + init(policy: HarnessUnrecognizedPolicy) { + decide = { _, _ in policy } + } + + init( + decide: + @escaping @Sendable (StoreTransactionSnapshot, Int) async throws + -> HarnessUnrecognizedPolicy + ) { + self.decide = decide + } + + func decidePolicy( + forUnrecognizedSubscription transaction: StoreTransactionSnapshot + ) async throws -> HarnessUnrecognizedPolicy { + transactions.append(transaction) + return try await decide(transaction, transactions.count) + } + + func decisionCount() -> Int { + transactions.count + } +} + +private final class HarnessTestSignal: Sendable { + private struct State { + var isSignaled = false + var waiter: CheckedContinuation? + } + + private let state = Mutex(State()) + + func send() { + let waiter: CheckedContinuation? = state.withLock { state in + guard !state.isSignaled else { return nil } + state.isSignaled = true + defer { state.waiter = nil } + return state.waiter + } + waiter?.resume() + } + + func wait() async { + await withCheckedContinuation { continuation in + let shouldResume = state.withLock { state in + if state.isSignaled { + return true + } else { + precondition(state.waiter == nil) + state.waiter = continuation + return false + } + } + if shouldResume { + continuation.resume() + } + } + } +} + +private actor HarnessTestGate { + private var isOpen = false + private var waiters: [UUID: CheckedContinuation] = [:] + + func wait() async throws { + guard !isOpen else { return } + try Task.checkCancellation() + let id = UUID() + try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + if isOpen { + continuation.resume() + } else { + waiters[id] = continuation + } + } + } onCancel: { + Task { await self.cancelWaiter(id) } + } + } + + func open() { + isOpen = true + let pending = Array(waiters.values) + waiters.removeAll(keepingCapacity: false) + for waiter in pending { + waiter.resume() + } + } + + private func cancelWaiter(_ id: UUID) { + waiters.removeValue(forKey: id)?.resume( + throwing: CancellationError() + ) + } +} + private final class DelayedClassDelegate: TransactionStoreDelegate { private let clock: any Clock private let decisions = Mutex(0) diff --git a/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift b/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift index dbbd719..0415a1e 100644 --- a/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift +++ b/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift @@ -382,18 +382,55 @@ struct RuntimeOwnerTests { #expect(error.operation == .history) } - let completed = try await store.processSyntheticDelivery( + let outcome = try await store.processSyntheticDelivery( .synthetic(snapshot: snapshot) { await current.replace(with: [snapshot]) } ) - #expect(completed == snapshot) + #expect(outcome == .completed(snapshot)) #expect(store.entitlements?.transactions == [snapshot]) #expect(store.activeEntitlements == [.tier1]) try await store.close() } + @MainActor + @Test("synthetic delivery returns an unfinished semantic outcome") + func syntheticDeliveryReturnsUnfinishedOutcome() async throws { + let snapshot = makeSnapshot( + id: 25, + productID: "test.subscription.unrecognized", + productType: .autoRenewable, + subscriptionGroupID: TestPlans.id.rawValue, + jws: "synthetic-unrecognized-25" + ) + let current = EntitlementValueSource([snapshot]) + let syntheticSource = SyntheticStoreTransactionSource( + currentEntitlements: { await current.read() } + ) + let finishes = TestSignal() + let store = TransactionStore( + subscriptionCatalog: testSubscriptionCatalog, + syntheticSource: syntheticSource, + unavailableOperationError: { + SyntheticOperationError(operation: $0) + } + ) + try await store.waitForInitialReadiness() + + let outcome = try await store.processSyntheticDelivery( + .synthetic(snapshot: snapshot) { + await finishes.send() + } + ) + + #expect(outcome == .leftUnfinished(snapshot)) + #expect(await finishes.value() == 0) + #expect(store.entitlements?.transactions == [snapshot]) + #expect(store.activeEntitlements == []) + try await store.close() + } + @MainActor @Test( "delegate decisions reject direct and inherited-child reentrancy", From 95cf3671a959f8ff30d209d5d3438bbc8bd16cec Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:15:50 +0900 Subject: [PATCH 4/7] style(store): apply Swift format --- .../EntitlementRefreshCoordinator.swift | 3 +-- .../Processing/TransactionProcessingCore.swift | 17 ++++++----------- .../Runtime/StoreTransactionRuntime.swift | 9 +++------ ...utoRenewableSubscriptionClassification.swift | 3 +-- .../UnrecognizedSubscriptionTests.swift | 2 +- 5 files changed, 12 insertions(+), 22 deletions(-) diff --git a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift index a3433e4..54fdc4d 100644 --- a/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift +++ b/Sources/StoreTransactionKit/Entitlements/EntitlementRefreshCoordinator.swift @@ -45,8 +45,7 @@ where Entitlement: Hashable & Sendable { } private let query: @Sendable (Bool) async throws -> CurrentEntitlementReconciliation - private let project: - @Sendable (StoreEntitlements) async throws -> Set + private let project: @Sendable (StoreEntitlements) async throws -> Set private let didComplete: @Sendable (EntitlementRefreshOutcome) async -> Void private let failures: FailureReporterDispatcher private let lifetime: TransactionStoreLifecycle? diff --git a/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift b/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift index 7214be7..7c945cd 100644 --- a/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift +++ b/Sources/StoreTransactionKit/Processing/TransactionProcessingCore.swift @@ -10,8 +10,7 @@ package struct TransactionCausalResolutionClaim: Sendable { package let reportingAuthority: DirectOperationReportingAuthority private let receipt: ProcessingReceipt - private let finishSuccess: - @Sendable () async -> TransactionProcessingDisposition + private let finishSuccess: @Sendable () async -> TransactionProcessingDisposition private let finishFailure: @Sendable () async -> Void fileprivate init( @@ -52,8 +51,7 @@ package struct ProcessingAcceptance: Sendable { package let receipt: ProcessingReceipt /// Completes after the exact revision's causal entitlement publication. - package let causalReceipt: - ProcessingReceipt + package let causalReceipt: ProcessingReceipt package let role: Role package let reportingAuthority: DirectOperationReportingAuthority package let directBinding: DirectOperationObservation.Binding? @@ -95,10 +93,8 @@ package actor TransactionProcessingCore { private struct Attempt: Sendable { let id: UUID let value: Value - let decisionReceipt: - ProcessingReceipt - let causalReceipt: - ProcessingReceipt + let decisionReceipt: ProcessingReceipt + let causalReceipt: ProcessingReceipt let reportingAuthority: DirectOperationReportingAuthority var causalResolutionClaimed: Bool var phase: AttemptPhase @@ -111,8 +107,7 @@ package actor TransactionProcessingCore { private let sessionID: UUID private let lifetime: TransactionStoreLifecycle? - private let handle: - @Sendable (Value) async throws -> TransactionProcessingDisposition + private let handle: @Sendable (Value) async throws -> TransactionProcessingDisposition private var queue: [QueuedOperation] = [] private var inFlight: [Data: Attempt] = [:] private var failed: [Data: Attempt] = [:] @@ -249,7 +244,7 @@ package actor TransactionProcessingCore { value: Value, decisionReceipt: ProcessingReceipt = - ProcessingReceipt(), + ProcessingReceipt(), alreadyFinished: Bool = false ) -> Attempt { Attempt( diff --git a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift index dba30f5..6920b21 100644 --- a/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift +++ b/Sources/StoreTransactionKit/Runtime/StoreTransactionRuntime.swift @@ -16,8 +16,7 @@ where Entitlement: Hashable & Sendable { private let source: StoreTransactionSource private let lifecycle: TransactionStoreLifecycle private let delegate: TransactionStoreDelegateReference - private let unrecognizedSubscriptions: - UnrecognizedSubscriptionPolicyResolver + private let unrecognizedSubscriptions: UnrecognizedSubscriptionPolicyResolver private let core: TransactionProcessingCore private let entitlements: EntitlementRefreshCoordinator private let failures: FailureReporterDispatcher @@ -68,8 +67,7 @@ where Entitlement: Hashable & Sendable { sessionID: sessionID, lifetime: lifecycle, handle: { transaction in - let classification: - AutoRenewableSubscriptionClassification + let classification: AutoRenewableSubscriptionClassification do { classification = try subscriptionCatalog.classification( of: transaction @@ -125,8 +123,7 @@ where Entitlement: Hashable & Sendable { project: { entitlements in var activeEntitlements: Set = [] for transaction in entitlements.transactions { - let classification: - AutoRenewableSubscriptionClassification + let classification: AutoRenewableSubscriptionClassification do { classification = try subscriptionCatalog.classification( of: transaction diff --git a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift index 5ee6141..bbfab6c 100644 --- a/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift +++ b/Sources/StoreTransactionKit/Subscriptions/AutoRenewableSubscriptionClassification.swift @@ -1,8 +1,7 @@ package enum AutoRenewableSubscriptionClassification: Equatable, Sendable -where Entitlement: Hashable & Sendable -{ +where Entitlement: Hashable & Sendable { case declared(Entitlement) case retiredUpgraded case unrecognized diff --git a/Tests/StoreTransactionKitTests/UnrecognizedSubscriptionTests.swift b/Tests/StoreTransactionKitTests/UnrecognizedSubscriptionTests.swift index c4bae6f..ce5c14c 100644 --- a/Tests/StoreTransactionKitTests/UnrecognizedSubscriptionTests.swift +++ b/Tests/StoreTransactionKitTests/UnrecognizedSubscriptionTests.swift @@ -536,7 +536,7 @@ private actor RecordingUnrecognizedDelegate: private let decide: @Sendable (StoreTransactionSnapshot, Int) async throws - -> TestUnrecognizedPolicy + -> TestUnrecognizedPolicy private var transactions: [StoreTransactionSnapshot] = [] init(policy: TestUnrecognizedPolicy) { From e6c33e0a01f9de484ac261e7ff7680adb53647a9 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:29:37 +0900 Subject: [PATCH 5/7] fix(testing): preserve synthetic current ordering --- .../TestingSubscriptionAccess.md | 3 +- .../TransactionStore.swift | 14 +++++-- .../SyntheticTransactionLedger.swift | 3 ++ .../TransactionStoreTestHarness.swift | 18 +++++---- .../TransactionStoreTestHarnessTests.swift | 39 +++++++++++++++++++ .../RuntimeOwnerTests.swift | 37 ++++++++++++++++++ 6 files changed, 102 insertions(+), 12 deletions(-) diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md index 83b12ab..a2d0a28 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/TestingSubscriptionAccess.md @@ -90,7 +90,8 @@ try await withTransactionStoreTestHarness( `purchase(_:,in:)` remains the typed shortcut for a catalog-declared product. `deliver(_:)` accepts only an exact synthetic snapshot registered by that -harness. +harness. Replaying an older revision does not replace a later synthetic +subscription that is already current. The returned ``StoreTransactionSnapshot`` is synthetic. Its ``StoreTransactionSnapshot/jwsRepresentation`` is a deterministic sentinel, not diff --git a/Sources/StoreTransactionKit/TransactionStore.swift b/Sources/StoreTransactionKit/TransactionStore.swift index 829a4d6..aa97cf6 100644 --- a/Sources/StoreTransactionKit/TransactionStore.swift +++ b/Sources/StoreTransactionKit/TransactionStore.swift @@ -95,8 +95,9 @@ where Entitlement: Hashable & Sendable { /// reconciliation. The store strongly retains both delegates until terminal /// shutdown. Creating a second live store in the same process before the /// first store finishes ``close()`` is a programmer error. Without - /// `unrecognizedSubscriptionDelegate`, valid undeclared same-group - /// subscriptions use ``UnrecognizedSubscriptionPolicy/leaveUnfinished``. + /// `unrecognizedSubscriptionDelegate`, valid non-upgraded undeclared + /// same-group subscriptions use + /// ``UnrecognizedSubscriptionPolicy/leaveUnfinished``. public convenience init( subscriptionCatalog: AutoRenewableSubscriptionCatalog, delegate: (any TransactionStoreDelegate)? = nil, @@ -285,7 +286,8 @@ where Entitlement: Hashable & Sendable { } package func processSyntheticDelivery( - _ delivery: StoreTransactionDelivery + _ delivery: StoreTransactionDelivery, + didAdmit: @escaping @Sendable () async -> Void = {} ) async throws -> StorePurchaseOutcome { try rejectReentrancy(operation: .processPurchase) guard case .syntheticRuntime(let runtime, let lifecycle, _) = backend else { @@ -294,7 +296,11 @@ where Entitlement: Hashable & Sendable { ) } let leases = try lifecycle.beginOperation() - return try await runtime.process(delivery, leases: leases) + return try await runtime.process( + delivery, + leases: leases, + didAdmit: didAdmit + ) } /// Reconciles unfinished transactions and publishes current entitlements. diff --git a/Sources/StoreTransactionKitTesting/SyntheticTransactionLedger.swift b/Sources/StoreTransactionKitTesting/SyntheticTransactionLedger.swift index 5ebdb2f..f66c53d 100644 --- a/Sources/StoreTransactionKitTesting/SyntheticTransactionLedger.swift +++ b/Sources/StoreTransactionKitTesting/SyntheticTransactionLedger.swift @@ -68,6 +68,9 @@ final class SyntheticTransactionLedger: Sendable { contains(snapshot), "Only a transaction registered by this test harness can become current." ) + guard activeSnapshot.map({ $0.id <= snapshot.id }) ?? true else { + return + } activeSnapshot = snapshot } } diff --git a/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift index ce9b568..9274446 100644 --- a/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift +++ b/Sources/StoreTransactionKitTesting/TransactionStoreTestHarness.swift @@ -90,11 +90,12 @@ where Entitlement: Hashable & Sendable { /// Delivers an exact synthetic snapshot value registered by this harness. /// - /// Delivery first makes the revision authoritative for current-entitlement - /// reconciliation, then runs the production policy and publication path. - /// Re-delivering the same revision exercises the store's session policy and - /// acknowledgement idempotency. A value not exactly registered by this - /// harness throws ``TransactionStoreTestHarnessError/unregisteredTransaction(transactionID:)`` + /// After admission, delivery makes the revision current unless a later + /// synthetic transaction is already current, then runs the production + /// policy and publication path. Re-delivering the same revision exercises + /// the store's session policy and acknowledgement idempotency without + /// rolling back a later subscription. A value not exactly registered by + /// this harness throws ``TransactionStoreTestHarnessError/unregisteredTransaction(transactionID:)`` /// before changing the synthetic current-entitlement source. @discardableResult public func deliver( @@ -107,9 +108,12 @@ where Entitlement: Hashable & Sendable { } try Task.checkCancellation() - ledger.activate(transaction) return try await store.processSyntheticDelivery( - .synthetic(snapshot: transaction) { [ledger] in + .synthetic( + snapshot: transaction, + acknowledge: {} + ), + didAdmit: { [ledger] in await ledger.activate(transaction) } ) diff --git a/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift index 2e6ad0d..3fb3c9d 100644 --- a/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift +++ b/Tests/StoreTransactionKitTestingTests/TransactionStoreTestHarnessTests.swift @@ -184,6 +184,45 @@ struct TransactionStoreTestHarnessTests { } } + @MainActor + @Test("replaying an older revision does not replace a later subscription") + func olderUnrecognizedReplayPreservesCurrentSubscription() async throws { + let delegate = HarnessUnrecognizedDelegate(policy: .treatAs(.tier1)) + + try await withTransactionStoreTestHarness( + subscriptionCatalog: harnessSubscriptionCatalog, + unrecognizedSubscriptionDelegate: delegate + ) { harness in + let older = try harness.makeUnrecognizedSubscription( + productID: "testing.subscription.legacy", + in: HarnessPlans.self + ) + #expect( + try await harness.deliver(older) + == .completed(older) + ) + + let current = try await harness.purchase( + .tier2_Monthly, + in: HarnessPlans.self + ) + #expect(harness.store.entitlements?.transactions == [current]) + #expect(harness.store.activeEntitlements == [.tier2]) + + #expect( + try await harness.deliver(older) + == .completed(older) + ) + #expect(harness.store.entitlements?.transactions == [current]) + #expect(harness.store.activeEntitlements == [.tier2]) + + let refreshed = try await harness.store.refreshEntitlements() + #expect(refreshed.transactions == [current]) + #expect(harness.store.activeEntitlements == [.tier2]) + #expect(await delegate.decisionCount() == 1) + } + } + @MainActor @Test("a thrown decision can retry the same registered revision") func unrecognizedDecisionRetry() async throws { diff --git a/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift b/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift index 0415a1e..ae17d03 100644 --- a/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift +++ b/Tests/StoreTransactionKitTests/RuntimeOwnerTests.swift @@ -431,6 +431,43 @@ struct RuntimeOwnerTests { try await store.close() } + @MainActor + @Test("rejected synthetic delivery does not cross its admission boundary") + func rejectedSyntheticDeliveryDoesNotRunAdmissionEffect() async throws { + let snapshot = makeSubscriptionSnapshot( + id: 26, + productID: .tier1Monthly, + revision: "synthetic-rejected-26" + ) + let didAdmit = TestSignal() + let store = TransactionStore( + subscriptionCatalog: testSubscriptionCatalog, + syntheticSource: SyntheticStoreTransactionSource( + currentEntitlements: { [] } + ), + unavailableOperationError: { + SyntheticOperationError(operation: $0) + } + ) + try await store.waitForInitialReadiness() + try await store.close() + + do { + _ = try await store.processSyntheticDelivery( + .synthetic( + snapshot: snapshot, + acknowledge: {} + ), + didAdmit: { + await didAdmit.send() + } + ) + Issue.record("A closed synthetic store admitted a delivery.") + } catch StoreTransactionError.closed {} + + #expect(await didAdmit.value() == 0) + } + @MainActor @Test( "delegate decisions reject direct and inherited-child reentrancy", From 66ab6730909a3c5daaaf72ca33653c0341aa083c Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:29:43 +0900 Subject: [PATCH 6/7] docs(subscription): clarify upgraded transaction ownership --- README.md | 15 ++++++++------- .../DefiningSubscriptionAccess.md | 8 ++++---- .../StoreTransactionKit.md | 17 +++++++++-------- .../UnderstandingTransactionHandling.md | 7 +++++-- .../TransactionStoreDelegate.swift | 19 +++++++++++-------- .../UnrecognizedSubscriptionDelegate.swift | 4 ++-- 6 files changed, 39 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 3a26395..e1c91a6 100644 --- a/README.md +++ b/README.md @@ -171,10 +171,10 @@ let store = TransactionStore( ## Unrecognized subscriptions -A 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. +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: @@ -213,9 +213,10 @@ for the complete policy contract. `TransactionStoreDelegate` is separate from `UnrecognizedSubscriptionDelegate`. Supply it only when the app owns an -additional durable effect for a declared or out-of-group transaction, or needs -background-failure notifications. Without it, automatic handling finishes -catalog-declared auto-renewable subscriptions. +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. diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md index 64cc6ae..6ecf163 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/DefiningSubscriptionAccess.md @@ -65,10 +65,10 @@ SubscriptionStoreView( ``` This keeps the paywall aligned with the catalog in this binary. A valid -same-group product can still arrive from another device or purchase path. An -unrecognized product remains in the raw projection, grants no typed access by -default, and doesn't by itself make entitlement readiness fail. See - to choose another policy with +same-group product can still arrive from another device or purchase path. A +non-upgraded unrecognized product remains in the raw projection, grants no +typed access by default, and doesn't by itself make entitlement readiness fail. +See to choose another policy with ``UnrecognizedSubscriptionDelegate``. ## Read access without blocking the UI diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md index f62289d..a69cc96 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/StoreTransactionKit.md @@ -23,14 +23,15 @@ override. The optional ``TransactionStoreDelegate`` owns only app-specific durable effects and background-failure reactions. Without a delegate, the default -policy finishes catalog-declared auto-renewable subscriptions. StoreTransactionKit -still verifies deliveries, reconciles unfinished work, validates catalog -metadata, orders history, restores purchases, reports background failures, and -drains admitted work during explicit shutdown. - -Products in the managed group that this binary doesn't recognize remain in the -raw projection and don't invalidate entitlement readiness. The optional -``UnrecognizedSubscriptionDelegate`` can leave an unfinished delivery +policy finishes catalog-declared and upgraded same-group auto-renewable +subscriptions. StoreTransactionKit still verifies deliveries, reconciles +unfinished work, validates catalog metadata, orders history, restores +purchases, reports background failures, and drains admitted work during +explicit shutdown. + +A non-upgraded product in the managed group that this binary doesn't recognize +remains in the raw projection and doesn't invalidate entitlement readiness. The +optional ``UnrecognizedSubscriptionDelegate`` can leave an unfinished delivery unfinished, finish it without a typed grant, or map its exact revision to a known app entitlement. diff --git a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md index 9da5715..368ab95 100644 --- a/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md +++ b/Sources/StoreTransactionKit/StoreTransactionKit.docc/UnderstandingTransactionHandling.md @@ -32,6 +32,9 @@ its policy owner: managed by ``TransactionStoreDelegate``. ``StoreTransactionHandlingPolicy/automatic`` and ``StoreTransactionHandlingPolicy/finish`` both allow finishing it. +- An undeclared same-group subscription that StoreKit marks as upgraded grants + no typed access and is also managed by ``TransactionStoreDelegate``. + `.automatic` and `.finish` both allow finishing it. - A product outside the catalog's group is unmanaged. `.automatic` throws ``StoreTransactionError/unhandledTransaction(productID:productType:)``; `.finish` allows finishing only after the app has durably handled it. @@ -97,8 +100,8 @@ refresh publishes a new ready snapshot. The catalog maps declared Product IDs to app entitlement values. It does not copy StoreKit group levels or subscription periods into the entitlement type, and multiple Product IDs may map to the same value. A transaction that StoreKit -marks as upgraded grants no typed access. A valid unrecognized same-group -product remains in the raw projection without making readiness fail. +marks as upgraded grants no typed access. A valid non-upgraded unrecognized +same-group product remains in the raw projection without making readiness fail. `.leaveUnfinished` and `.finish` grant no typed access; `.treatAs(entitlement)` adds the selected value. A thrown unrecognized decision is a transient failure, not a catalog contradiction. The raw projection diff --git a/Sources/StoreTransactionKit/TransactionStoreDelegate.swift b/Sources/StoreTransactionKit/TransactionStoreDelegate.swift index 238af93..b60ff32 100644 --- a/Sources/StoreTransactionKit/TransactionStoreDelegate.swift +++ b/Sources/StoreTransactionKit/TransactionStoreDelegate.swift @@ -1,9 +1,11 @@ /// The action StoreTransactionKit takes after classifying a verified transaction. public enum StoreTransactionHandlingPolicy: Sendable, Hashable { - /// Finishes a catalog-declared transaction without an app-owned business + /// Finishes a framework-managed transaction without an app-owned business /// effect. /// - /// StoreTransactionKit rejects this policy for an out-of-group transaction. + /// This applies to catalog-declared subscriptions and undeclared same-group + /// subscriptions that StoreKit marks as upgraded. StoreTransactionKit + /// rejects this policy for an out-of-group transaction. case automatic /// Finishes a transaction after the app has durably applied its business effect. @@ -17,17 +19,18 @@ public enum StoreTransactionHandlingPolicy: Sendable, Hashable { /// /// The delegate is optional because both requirements have default /// implementations. Policy decisions and failure notifications are each -/// serialized, but the two streams may overlap. Valid undeclared subscriptions -/// in the catalog's group are resolved separately by +/// serialized, but the two streams may overlap. Valid non-upgraded undeclared +/// subscriptions in the catalog's group are resolved separately by /// ``UnrecognizedSubscriptionDelegate``. public protocol TransactionStoreDelegate: AnyObject, Sendable { /// Chooses how to handle a verified transaction. /// /// StoreTransactionKit invokes decisions serially after catalog - /// classification for declared and out-of-group transactions. Throwing - /// prevents the transaction from being finished and prevents its causal - /// entitlement refresh. A later independent StoreKit delivery may retry the - /// exact revision. + /// classification for declared transactions, undeclared same-group + /// transactions that StoreKit marks as upgraded, and out-of-group + /// transactions. Throwing prevents the transaction from being finished and + /// prevents its causal entitlement refresh. A later independent StoreKit + /// delivery may retry the exact revision. func decidePolicy( for transaction: StoreTransactionSnapshot ) async throws -> StoreTransactionHandlingPolicy diff --git a/Sources/StoreTransactionKit/UnrecognizedSubscriptionDelegate.swift b/Sources/StoreTransactionKit/UnrecognizedSubscriptionDelegate.swift index f00aaf8..af1043f 100644 --- a/Sources/StoreTransactionKit/UnrecognizedSubscriptionDelegate.swift +++ b/Sources/StoreTransactionKit/UnrecognizedSubscriptionDelegate.swift @@ -1,5 +1,5 @@ -/// The action StoreTransactionKit takes for a valid, undeclared subscription -/// in the catalog's subscription group. +/// The action StoreTransactionKit takes for a valid, non-upgraded undeclared +/// subscription in the catalog's subscription group. public enum UnrecognizedSubscriptionPolicy: Sendable, Hashable where Entitlement: Hashable & Sendable { /// Grants no typed entitlement and leaves an unfinished delivery unfinished. From 2d62ba4e4718505baacc403371455e0b7a2c3c40 Mon Sep 17 00:00:00 2001 From: Kazuki Nakashima <65545348+lynnswap@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:45:32 +0900 Subject: [PATCH 7/7] test(consumer): compile specialized delegate conformance --- Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift index 5076833..2e2fc7a 100644 --- a/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift +++ b/Fixtures/ExternalConsumer/Sources/Consumer/Consumer.swift @@ -60,10 +60,8 @@ public actor AppTransactionDelegate: TransactionStoreDelegate { } public actor AppUnrecognizedSubscriptionDelegate: - UnrecognizedSubscriptionDelegate + UnrecognizedSubscriptionDelegate { - public typealias Entitlement = SubscriptionEntitlement - public init() {} public func decidePolicy(