I offer subscriptions in my iOS app. If the app runs on iOS 15 or later, I use StoreKit 2
to handle subscription starts and renewals. My implementation closely follows Apple's example code.
A very small fraction of my users (<1%) report that their active subscription is not recognized - usually after a renewal (Starting a new subscription seems to always work). It appears as if no StoreKit transactions are showing up for the renewals.
After some troubleshooting I found out:
- Force quitting and restarting the app never helps.
- A call to
AppStore.sync()
never helps. - Restarting the device helps for some but not for all users.
- Deleting and re-downloading the app from the App Store always works.
I could never reproduce this bug on my devices.
Here's the gist of my implementation: I have a StoreManager
class that handles all interactions with StoreKit
. After initialization, I immediately iterate over Transaction.all
to obtain the user's complete purchase history, and also start a task that listens for Transaction.updates
. PurchasedItem
is a custom struct that I use to group all relevant information about a transaction. Purchased items are collected in the dictionary purchasedItems
, where I use the transactions' identifier
s as keys. All writes to that dictionary only happen in the method updatePurchasedItemFor()
which is bound to the MainActor
.
class StoreManager {
static let shared = StoreManager()
private var updateListenerTask: Task<Void, Error>? = nil
init() {
updateListenerTask = listenForTransactions()
loadAllTransactions()
}
func loadAllTransactions() {
Task { @MainActor in
for await result in Transaction.all {
if let transaction = try? checkVerified(result) {
await updatePurchasedItemFor(transaction)
}
}
}
}
func listenForTransactions() -> Task<Void, Error> {
return Task(priority: .background) {
// Iterate through any transactions which didn't come from a direct call to `purchase()`.
for await result in Transaction.updates {
do {
let transaction = try self.checkVerified(result)
// Deliver content to the user.
await self.updatePurchasedItemFor(transaction)
// Always finish a transaction.
await transaction.finish()
} catch {
//StoreKit has a receipt it can read but it failed verification. Don't deliver content to the user.
Analytics.logError(error, forActivity: "Verification on transaction update")
}
}
}
private(set) var purchasedItems: [UInt64: PurchasedItem] = [:]
@MainActor
func updatePurchasedItemFor(_ transaction: Transaction) async {
let item = PurchasedItem(productId: transaction.productID,
originalPurchaseDate: transaction.originalPurchaseDate,
transactionId: transaction.id,
originalTransactionId: transaction.originalID,
expirationDate: transaction.expirationDate,
isInTrial: transaction.offerType == .introductory)
if transaction.revocationDate == nil {
// If the App Store has not revoked the transaction, add it to the list of `purchasedItems`.
purchasedItems[transaction.id] = item
} else {
// If the App Store has revoked this transaction, remove it from the list of `purchasedItems`.
purchasedItems[transaction.id] = nil
}
NotificationCenter.default.post(name: StoreManager.purchasesDidUpdateNotification, object: self)
}
private func checkVerified<T>(_ result: VerificationResult<T>) throws -> T {
// Check if the transaction passes StoreKit verification.
switch result {
case .unverified(_, let error):
// StoreKit has parsed the JWS but failed verification. Don't deliver content to the user.
throw error
case .verified(let safe):
// If the transaction is verified, unwrap and return it.
return safe
}
}
}
To find out if a user is subscribed, I use this short method, implemented elsewhere in the app:
var subscriberState: SubscriberState {
for (_, item) in StoreManager.shared.purchasedItems {
if let expirationDate = item.expirationDate,
expirationDate > Date() {
return .subscribed(expirationDate: expirationDate, isInTrial: item.isInTrial)
}
}
return .notSubscribed
}
All this code looks very simple to me, and is very similar to Apple's example code. Still, there's a bug somewhere and I cannot find it.
I can imagine that it's one of the following three issues:
- I misunderstand how Swift actors and async/await work, and there is a race condition.
- I misunderstand how StoreKit 2 transactions work. For example, I currently assume that a subscription renewal transaction has its own, unique
identifier
, which I can use as a key to collect it in the dictionary. - There actually is a bug in StoreKit 2, some transactions are in fact missing and the bug is not in my code.
To rule out 3., I have submitted a TSI request at Apple. Their response was, essentially: You are expected to use Transaction.currentEntitlements
instead of Transaction.all
to determine the user's current subscription state, but actually this implementation should also work. If it doesn't please file a bug.
I am using Transaction.all
because I need the complete transaction history of the user to customize messaging and special offers in the app, not only to decide if the user has an active subscription or not. So I filed a bug, but haven't received any response yet.