Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

StoreKit renewal transactions missing in Transaction.all or Transaction.updates

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' 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:

  1. I misunderstand how Swift actors and async/await work, and there is a race condition.
  2. 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.
  3. 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.

like image 657
Theo Avatar asked Jan 29 '26 21:01

Theo


1 Answers

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.

like image 68
Theo Avatar answered Jan 31 '26 12:01

Theo