iosxcodeswiftuistorekit

Unable to detect subscription expiration with StoreKit2


I am designing an auto-renewable subscriptions to my SwiftUI app. I am able to react on events like upgrading and downgrading to a different subscription level, as well as the automatic renewal.

The problem is that I don't receive an update when the user cancels their subscription. The cancelling itself happens outside the app (from the Settings/Profile/Subscriptions or in Xcode via 'Manage StoreKit Transactions') as there is no cancelling button in the app.

Ideally I want to store in UserDefaults just two booleans for each of my premium features to know if I should present it to the user or not. But with the current problem I am thinking instead of booleans to use the expiration dates (which will be updated on every renewal) and to check them against current date every time to see if they expired.

Do you see if I missed something in the PurchaseManager to not get subscription cancelled updates? And do you think this workaround is good enough to get the job done?

import Foundation
import StoreKit
typealias SkTransaction = StoreKit.Transaction

enum PremiumFeature: String {
    case unlimitedWallets = "unlimitedWallets"
    case unlimitedReceiptScans = "unlimitedReceiptScans"
}

@MainActor
class PurchaseManager: ObservableObject {
    static let productIds: Set<String> = Set(["plus","premium"])
    @Published var products: [Product] = []
    private var subscriptionManager: SubscriptionManager
    private var updates: Task<Void, Never>? = nil
    
    init(subscriptionManager: SubscriptionManager) {
        self.subscriptionManager = subscriptionManager
        updates = observeTransactionUpdates()
    }
    
    deinit {
        updates?.cancel()
    }
    
    func loadProducts() async throws {
        products = try await Product.products(for: PurchaseManager.productIds)
    }
    
    func purchase(_ product: Product) async throws {
        print("Purcahse initiated for \(product.id)")
        let result = try await product.purchase()
        
        switch result {
        case let .success(.verified(transaction)):
            // Successful purhcase
            await transaction.finish()
            await updatePurchasedProducts()
        case .success(.unverified(_, _)):
            // Successful purchase but transaction/receipt can't be verified
            // Could be a jailbroken phone
            break
        case .pending:
            // Transaction waiting on SCA (Strong Customer Authentication) or
            // approval from Ask to Buy
            break
        case .userCancelled:
            // ^^^
            break
        @unknown default:
            break
        }
    }
    
    func updatePurchasedProducts() async {
        for await result in SkTransaction.currentEntitlements {
            guard case .verified(let transaction) = result else {
                continue
            }

            let currentDate = Date()
            let isSubscriptionActive = transaction.expirationDate == nil || currentDate <= transaction.expirationDate!

            if transaction.revocationDate == nil && isSubscriptionActive {
                // Subscription is active
                switch transaction.productID {
                case "plus":
                    print("Plus subscription active")
                    subscriptionManager.unlimitedWallets = true
                case "premium":
                    print("Premium subscription active")
                    subscriptionManager.unlimitedWallets = true
                    subscriptionManager.unlimitedReceiptScans = true
                default:
                    print("Unknown product ID \(transaction.productID)")
                }
            } else {
                // Subscription is cancelled or expired
                switch transaction.productID {
                case "plus":
                    print("Plus subscription cancelled or expired")
                    subscriptionManager.unlimitedWallets = false
                case "premium":
                    print("Premium subscription cancelled or expired")
                    subscriptionManager.unlimitedWallets = false
                    subscriptionManager.unlimitedReceiptScans = false
                default:
                    print("Unknown product ID \(transaction.productID)")
                }
            }
            await transaction.finish()
        }
    }

    
    private func observeTransactionUpdates() -> Task<Void, Never> {
        Task(priority: .background) { [unowned self] in
            for await result in SkTransaction.updates {
                print("transaction updated observed")
                guard case .verified(let transaction) = result else {
                    continue
                }
                
                await updatePurchasedProducts()
                await transaction.finish()
            }
        }
    }
}

Here is how I call it from the @main

/*...*/
ContentView()
.task {
    await purchaseManager.updatePurchasedProducts()
    do {
        try await purchaseManager.loadProducts()
    } catch {
        print(error)
    }
}
/*...*/

I am testing the subscriptions only using Xcode and its tool 'Manage StoreKit Transactions' so I am not sure if it's not just a bug in the tool.

Another workaround for this issue I found on the internet was for using Timers, but after a quick research it seems that the Timers are used for short period of times and are not reliable, especially for a time range of 1 month.

Another valid option is to setup a server which will receive updates from AppStore and then to communicate back to my app, but I want to avoid having a server for that.


Solution

  • There is no "subscription cancelled" event. When a user cancels their subscription it continues for the rest of the subscription period and then it doesn't renew.

    You don't need all of that complex date logic in your updatePurchasedProducts method.

    With StoreKit2 all you need to do is check currentEntitlements for your subscription product(s). If a subscription product identifier is present, then there is an active subscription (or one in the grace period) and you should provide the appropriate benefits. If there is no subscription product identifier present then remove the benefits.

    You can, of course, store the expected expiration date for the current subscription and only check when that date is reached or you can simply check each time your app is launched or returns to the foreground.