I’ve confirmed this is a StoreKit bug: when you dismiss the Turn Off Renewal Receipt Emails dialog by tapping Keep Renewal Emails, StoreKit treats it like the payment sheet and returns .userCancelled—even if the purchase went through.
Below is a temporary, functional (but imperfect) workaround that polls Transaction.currentEntitlements after .userCancelled:
func purchase(product: Product, userId: String) async throws -> StoreKit.Transaction {
let purchaseUUID = UUID()
let options: Set<Product.PurchaseOption> = [.appAccountToken(purchaseUUID)]
let result = try await product.purchase(options: options)
switch result {
case .success(let verificationResult):
switch verificationResult {
case .verified(let tx):
logPurchaseResult(
context: "New Purchase – verified success",
status: "processing",
transaction: tx
)
return try await processVerified(tx)
case .unverified(let tx, let verificationError):
return try await handleUnverifiedTransaction(
source: ".success",
tx: tx,
verificationError: verificationError
)
}
case .userCancelled:
// Attempting to work around the StoreKit bug by checking for valid transactions
// that may exist despite receiving .userCancelled
let maxRetries = 10
let retryIntervalSeconds: UInt64 = 1_000_000_000 // 1 second
let startTime = Date()
for retryCount in 0..<maxRetries {
do {
for await verificationResult in Transaction.currentEntitlements {
switch verificationResult {
case .verified(let tx):
if tx.productID == product.id, tx.revocationDate == nil {
logPurchaseResult(
context: "Found verified entitlement after cancel: retry \(retryCount), time \(elapsedTime(since: startTime))",
status: "recovered",
transaction: tx
)
return try await processVerified(tx)
}
case .unverified(let tx, let verificationError):
return try await handleUnverifiedTransaction(
source: ".userCancelled - retry \(retryCount), time \(elapsedTime(since: startTime))",
tx: tx,
verificationError: verificationError
)
}
}
try await Task.sleep(nanoseconds: retryIntervalSeconds)
} catch {
// Log the error but continue retrying
logPurchaseResult(
context: "Error during cancellation recovery: \(error.localizedDescription)",
status: "retrying"
)
}
}
logPurchaseResult(
context: "Purchase cancelled (no entitlement found after \(maxRetries) retries)",
status: "userCancelled"
)
throw PurchaseError.cancelled
case .pending:
logPurchaseResult(
context: "Purchase – Pending",
status: "pending"
)
throw PurchaseError.pending
@unknown default:
logPurchaseResult(
context: "Purchase – Unknown result",
status: "unknown"
)
throw PurchaseError.unknown
}
}
This workaround has drawback, on top of needless complexity:
Poor UX: Genuine cancellations stall for 10s before the UI updates.
Unreliable: In our tests, entitlement recovery takes 6-7s—and may not arrive in 10s.
This clearly needs an Apple-side fix. In the meantime:
Has anyone found a cleaner workaround?
Can an Apple engineer confirm if this is being addressed?
Is this polling approach acceptable until StoreKit is updated?
Thanks!
Topic:
App & System Services
SubTopic:
StoreKit
Tags: