Detect a subscription has expired

I'm following the example code that comes with SKDemo to understand where in the lifecycle of my app to detect the subscription has expired to block users from having access to functionality they shouldn't have access to anymore.

This is pretty straight forward to implement and check on app launch. However, my app has a pretty low memory footprint so it's often not getting terminated over the course of several days. Still, I would like to check for a valid subscription whenever the app is foregrounded. Otherwise users might have access to subscriber-only features for numerous days before the app finally has to cold start again.

Is there a way to listen to these kind of 'subscription-expired' updates or do I need to figure out myself to verify the subscription, for instance, by listening to UIApplication.willEnterForegroundNotification and then run some business logic on my end to confirm the user is still within the subscription period?

Accepted Reply

Listen to updates in Transaction.updates.

See Implement proactive in-app purchase restore for more info.

Replies

Listen to updates in Transaction.updates.

See Implement proactive in-app purchase restore for more info.

Thanks a lot for coming back to me. The sample code for Transaction.updates looks very similar, albeit not identical to the listener task code in the SKDemo app provided as part of the session you linked to.

func listenForTransactions() -> Task<Void, Error> {
        return Task.detached {
            for await result in Transaction.updates {
                do {
                    let transaction = try self.checkVerified(result)
                    //Deliver products to the user.
                    await self.updateCustomerProductStatus()
                    //Always finish a transaction.
                    await transaction.finish()
                } catch {
                    //StoreKit has a transaction that fails verification. Don't deliver content to the user.
                    print("Transaction failed verification")
                }
            }
        }
    }

I can see how this code is executed whenever the subscription status changes triggered by an event outside the app. However, in my case, it's never called when the app enters the foreground and the current date is past the subscription's expiration date.

Actually, I think this is more of a problem with how sandbox testing might work. In fact, listening to Transaction.updates works for a sandbox test user with a cleared purchase history. In my experience the information presented here is not reliable. This page says:

You can choose a subscription renewal rate for each tester to speed up or slow down how often subscriptions renew. Subscriptions renew up to 12 times before auto-renewal turns off on the thirteenth renewal attempt.

This is true for users with a clear purchase history. I would expect that after the auto-renewal subscription has ended, re-subscribing to the same subscription would trigger another 12 auto-renewables. But at this point, the renew rate seems totally random. Sometimes it would renew one or two times but then completely stop. In addition, the listener task code in Transaction.updates posted in the previous posts is not called anymore (if the subscription renews at all), hence my confusion.

I'm having the same issue. Any updates on this? @kersten83 were you able to fix this issue, or was it simply a sandbox environment problem and in production, in fact, when the subscription expires while the app is still running, Transaction.updates is triggered and you were able to ensure that Transaction.currentEntitlements does not return the just expired subscription anymore so you could hide your content accordingly?

Exact same issue on 1/12/24. Basically not getting the control in "Transaction.updates" if the product has expired. This is the sandbox scenario. Can anybody please confirm/help? If this doesn't work in production. then its a big issue. Any other pointers on where/how to listen for expired products?

I've run into the same while testing in the simulator. Here's how I got my app to work as I wanted in the simulator.

I have an array that keeps track of any active subscription transactions. Whenever it is set, it checks the expiration date of all items and removes them if they have expired. I also have a method that runs through the activeSubscriptions array and does the same thing (I know... similar code in two places... I'm a horrible coder). I call this every time the app enters the foreground. If there are any items in the activeSubscriptions array, I unlock the paid features. If activeSubscriptions.isEmpty then I lock my paid features.

When I finalize a transaction I also check to see if it should be added or removed (if it exists) from the activeSubscriptions array. This way I don't unnecessarily add (and then immediately remove) any transactions that I receive from Transactions.currentEntitlements that have occurred and then already expired. Yes this is unlikely since the subscription period is normally a month or a year instead of as little as 10 seconds in the simulator but I like to cover all bases.

My activeSubscriptions array:

// Store transaction for the active subscriptions.
    var activeSubscriptions: [StoreKit.Transaction] = [] {
        didSet {
            var unlockApp = false
            
            for transaction in activeSubscriptions {
                if let expirationDate = transaction.expirationDate {
                    if expirationDate < Date.now {
                        logger.info("activeSubscriptions didSet is removing transaction \(transaction.id) becuase it has an expiration date of \(expirationDate). It is currently \(Date.now).")
                        activeSubscriptions.removeAll(where: {transaction.id == $0.id})
                    }
                }
            }
            
            if activeSubscriptions.isEmpty {
                unlockApp = false
            } else {
                unlockApp = true
            }
            
            if appUnlocked != unlockApp {
                objectWillChange.send()
                appUnlocked = unlockApp
            }
            
            logger.info("There are \(self.activeSubscriptions.count) subscriptions in activeSubscriptions. The app is \(self.appUnlocked ? "unlocked" : "locked").")
            
            for subscription in activeSubscriptions {
                logger.info("Transaction id \(subscription.id) is in activeSubscriptions.")
            }
        }
    }

My listener method:

func monitorTransactions() async {
        // Check for previous purchases.
        for await entitlement in Transaction.currentEntitlements {
            print("monitorTransactions is checking entitlement \(String(describing: try? entitlement.payloadValue.id))")
            if case let .verified(transaction) = entitlement {
                await finalize(transaction)
            }
        }
        
        // Watch for future transactions coming in.
        for await update in Transaction.updates {
            if let transaction = try? update.payloadValue {
                print("monitorTransactions is updating transaction \(transaction.id)")
                await finalize(transaction)
            }
        }
    }

My check for expired subscriptions (this is what I run whenever my app enters the foreground).

func validateActiveSubscriptions(_ note: Notification) {
        var activeSubs = activeSubscriptions
        
        for transaction in activeSubs {
            if let expirationDate = transaction.expirationDate {
                if expirationDate < Date.now {
                    logger.info("Removing transaction \(transaction.id) becuase it has an expiration date of \(expirationDate). It is currently \(Date.now).")
                    activeSubs.removeAll(where: {transaction.id == $0.id})
                }
            }
        }
        
        if activeSubs != activeSubscriptions {
            activeSubscriptions = activeSubs
        }
    }

And finally my transaction finalizer...

@MainActor
    func finalize(_ transaction: Transaction) async {
        var activeSubs = activeSubscriptions
        var dispositionOfTransaction: addOrRemove = .doNothing
        
        enum addOrRemove {
            case doNothing
            case addToActiveSubscriptions
            case removeFromActiveSubscriptions
        }
        
        logger.info("Finalizing \(transaction.id)")
        
        if Self.unlockIDs.contains(transaction.productID) {
            if transaction.expirationDate == nil {
                logger.info("Adding \(transaction.id) because it has no expiration date.")
                dispositionOfTransaction = .addToActiveSubscriptions
            } else if let expirationDate = transaction.expirationDate {
                if expirationDate > Date.now {
                    logger.info("Adding \(transaction.id) because its expiration date is in the future.")
                    dispositionOfTransaction = .addToActiveSubscriptions
                }
                else {
                    logger.info("Not adding (i.e. removing) \(transaction.id) because it has expired.")
                    dispositionOfTransaction = .removeFromActiveSubscriptions
                }
            }
        } else {
            logger.info("Removing transaction \(transaction.id) for product \(transaction.productID) because it is not included in \(Self.unlockIDs)")
            dispositionOfTransaction = .removeFromActiveSubscriptions
        }
        
        if transaction.revocationDate != nil {
            logger.info("Removing transaction id \(transaction.productID) because it has a revocation date.")
            dispositionOfTransaction = .removeFromActiveSubscriptions
        }
        
        switch dispositionOfTransaction {
        case .addToActiveSubscriptions:
            activeSubs.append(transaction)
        case .removeFromActiveSubscriptions:
            activeSubs.removeAll(where: {transaction.id == $0.id})
        case .doNothing:
            logger.warning("Transaction \(transaction.id) was not categorized! This should never happen... 🤞")
        }
        
        // Avoid resetting activeSubscriptions if no changes have been made.
        if activeSubs != activeSubscriptions {
            activeSubscriptions = activeSubs
        }
        
        await transaction.finish()
    }