15

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.

Theo
  • 3,826
  • 30
  • 59
  • 3
    If anyone else sees this issue too, please leave a comment. I haven't seen any other reports about this issue, but it would help me to know if it's not just my app. I also filed bug FB10587873. – Theo Oct 10 '22 at 16:08
  • 3
    I am experiencing the same problem. I can reproduce this issue in Xcode when using auto-renewable subscriptions. Install the app, purchase a subscription and then quit Xcode. Wait for xx seconds for subscription to expire and start the app again. Transaction.currentEntitlement will return nil, probably because Xcode acts as IAP server. Then start the app through Xcode and subscription will renew after xx seconds which is causing the problems in UX. Did you manage to fix this? – Josip B. Nov 22 '22 at 10:40
  • I found the same happening with `Transaction.currentEntitlements` - see my question here with some more detail that might help debugging: https://stackoverflow.com/questions/75376106/transaction-currententitlements-doesnt-return-auto-renewals – Ashley Mills Feb 07 '23 at 16:24
  • I think we are seeing this same issues on macOS 13.4.1 but it's hard to track down as it's only happening to some customers and I have been unable to directly reproduce it. The other behaviour we observe is that `Transaction.updates` closes the stream immediately which is totally undocumented. I have some more info in [this post on the apple forums](https://developer.apple.com/forums/thread/733137) and have filed FB12509606 with Apple. – andykent Jul 03 '23 at 16:04

1 Answers1

6

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.

Theo
  • 3,826
  • 30
  • 59
  • Thanks for all your hardworking, I have the same issue. But I can't figure out how to use your workaround: when a subscription is automatically renewed, the transaction remains unfinished (who knows why?). Though, it doesn't appear in any of `Transaction.all`, `.unfinished`, `.updates` before it expires... there is nothing to merge. I can see them in the simulator transaction manager, but I don't know how to get them in the app. Would you have any idea? – HunterLion May 18 '23 at 07:40
  • I think we may need this work-around too unfortunately but it makes me a little nervous. How do you match/de-dupe transactions and how do you detect/handle the situation of a customer signing into a different App Store account? – andykent Jul 03 '23 at 16:06
  • 1
    You can match transactions using their identifier. Observe NSUbiquityIdentityDidChange notifications to detect App Store account changes. – Theo Jul 04 '23 at 17:23