1 post tagged

Entitlements

StoreKit trials and double entitlements — a real-world edge case explained

Recently, we had an interesting case with in-app purchases in our app — so unexpected that we initially thought it was a bug in StoreKit. But then it turned out — everything works exactly as intended. There’s just nowhere this behavior is properly documented.

We’re building an app that helps users in the US prepare for driver’s license exams — for cars, motorcycles, and commercial vehicles. During this process, users go through a series of quizzes and exam marathons, and after successful completion, they receive a certificate required for the DMV. Access to the app is subscription-based, and we recently started testing a 3-day free trial to improve conversion.

During the trial, users get access to the entire app: all tests, marathons, and exams. But the certificate becomes available only after full payment. That’s intentional: it has legal value, and we can’t give it out for free.

About a week after launching the trial, support messages started coming in: “I paid for a subscription, but the certificate is still locked.” We first investigated progress sync issues. Then we were just confused. The classic “my hand is the same but it doesn’t hurt.” We looked into the receipts — the Apple payment looked valid, but the app still showed the user as being on a trial. How was that possible? We started to suspect StoreKit caching issues or bugs. But then more users reported the same. All had real Apple receipts. Strange!

We kept digging and finally figured it out:

  • Users completed all the tests in just 1–2 days — before the trial ended.
  • They saw the certificate was locked and didn’t want to wait.
  • They went to app settings → Manage Subscriptions → picked another subscription from the same group but without a trial — and purchased it.
  • Both subscriptions shared the same subscriptionGroupID, and both appeared in currentEntitlements.

But one of them had a trial, and the other was a full paid subscription. We didn’t expect that two active transactions could exist at the same time. We assumed that switching to a different subscription in the same group would cancel the trial and leave only one active transaction. In fact, we thought such a combo wasn’t even possible — we had already removed the no-trial option from all our paywalls.

So, our logic looked something like this (simplified):


extension Transaction {
    /// Is this a trial transaction?
    var isTrial: Bool {
        guard 
                let expirationDate = expirationDate, 
                Date() < expirationDate 
        else { return false }
        return offer?.paymentMode == .freeTrial
    }

    /// Does the user have a trial subscription?
    static var hasTrialSubscription: Bool {
        get async {
            for await result in Transaction.currentEntitlements {
                do {
                    if try result.payloadValue.isTrial {
                        return true
                    }
                } catch { continue }
            }
            return false
        }
    }
}


☝🏼

When a user switches subscriptions from settings — within the same group — during an active trial, two active transactions appear: one for the trial period, and another for the paid subscription, both sharing the same originalID.



extension Transaction {
    /// Is this a trial transaction?
    var isTrial: Bool {
        guard let expirationDate = expirationDate, Date() < expirationDate else { return false }
        return offer?.paymentMode == .freeTrial
    }

    /// Does the user have a trial subscription?
    static var hasTrialSubscription: Bool {
        get async {
            for await result in Transaction.currentEntitlements {
                do {
                    if try result.payloadValue.isTrial {
                        return true
                    }
                } catch { continue }
            }
            return false
        }
    }
}

Solution

The fix is simple — you need to group transactions by originalID and check the offer status only on the most recent one:


extension Transaction {
    /// Does the user have a trial subscription?
    static var hasTrialSubscription: Bool {
        get async {
            var latestTransactions: [UInt64: StoreKit.Transaction] = [:]
            for await result in Transaction.currentEntitlements {
                do {
                    let transaction = try result.payloadValue
                    if
                        let latestTransaction = latestTransactions[transaction.originalID],
                        transaction.purchaseDate < latestTransaction.purchaseDate
                    {  continue }
                    latestTransactions[transaction.originalID] = transaction
                } catch { continue }
            }
            return latestTransactions.values.contains(where: { $0.isTrial })
        }
    }
}

Final note

StoreKit doesn’t invalidate the original trial transaction in currentEntitlements even if the user switches subscriptions within the same group. It’s not a bug — it’s expected behavior. You just need to be aware of it, especially if you restrict functionality during the trial period.

A note to self in the “write it down so you don’t have to dig later” spirit. Hopefully this saves someone a few hours and some support tickets.

 No comments   1 mon   Entitlements   free trial   In-App Purchases   ios   StoreKit   StoreKit2   Subscriptions   trial