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:
AppStore.sync() never helps.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' identifiers 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:
identifier, which I can use as a key to collect it in the dictionary.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.
After collecting lots of low-level events from a large number of users with analytics, I am very confident that this is caused by a bug in StoreKit 2, and
Transaction.all does not reliably return all old, finished transactions for subscription starts and renewals.I checked this by collecting all transaction identifiers in the for await result in Transaction.all loop and then sending these identifiers to my analytics backend as a single event. I can clearly see that for some users, identifiers that were previously present are sometimes missing on subsequent launches of the app.
Unfortunately, Apple's only advice from the TSI was to report a bug, and Apple did never respond to my detailed bug report.*
As a workaround, I now cache all transactions on disk and after launch I merge the cached transactions with all new transactions from Transaction.all and Transaction.updates.
This works flawlessly - since I implemented that I didn't receive a single complaint about unrecognized subscriptions from my customers.
* Figuring all this out and finding a reliable fix took several months - I'm so glad that Apple provides such a fantastic, reliable service for as little as 30% of my revenue, tysm.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With