russell-archer / StoreHelper

Implementing In-App Purchases with StoreKit2 in Xcode 13 - 15 using SwiftUI, Swift 5.7 - 5.9, iOS 15 - 17 and macOS 12 - 14. Also supports tvOS and visionOS.
MIT License
422 stars 52 forks source link

Cancellation not detected #54

Closed ashayk closed 1 year ago

ashayk commented 1 year ago

Hi,

I'm revisiting an issue that I thought had seemed to be resolved, specifically, cancelling auto-renewable subscriptions using either XCode->Storekit->Manage Transactions or AppStore.showManageSubscriptions() from within an app. I've tested this with the StoreHelperDemo app and with my own code, and both consistently fail for me.

I'm on iOS 16.2 and the latest version of StoreHelper. While I can correctly monitor subscription status using: Product.SubscriptionInfo.Status.updates, the various transactions in StoreHelper don't seem to pick this up consistently.

What I believe needs to happen is that Status updates need to be monitored and the StoreHelper caches updated appropriately... Something along the lines of:

`for await status in Product.SubscriptionInfo.Status.updates {

            let result: VerificationResult<Product.SubscriptionInfo.RenewalInfo> = status.renewalInfo
            switch result {
                case .unverified(let unverifiedTransaction, let error):
                    print("unverified")

                case .verified(let verifiedTransaction):
                    let productId = verifiedTransaction.currentProductID
                    print("\(pid) verified")

                   if status.state == .subscribed || status.state == .inGracePeriod {
                      print("status subscribed or in grace")
                  } else if status.state == .revoked || status.state == .expired {
                     print("status cancelled")
                     updatePurchasedIdentifiers(productId, purchased: false)
                 } 
            }                

}`

Thanks.

russell-archer commented 1 year ago

Sorry for the delay in replying - been on a short vacation! Re your issue, I'm certain that StoreHelper does (or did) pick up subscription cancellations correctly. It's possible I've done something to break it, but I'll take a look and let you know.

russell-archer commented 1 year ago

Yes, you're right. Cancellations are not being correctly picked up. I'll put out a fix asap!

ashayk commented 1 year ago

Hi Russel, thanks for picking it up. Sorry to bother you post-holiday :). Generally speaking, I've been finding using currentEntitlements() with requestProductsFromAppStore() to give a pretty reliable result. Eventually, things are updated or timed out in such a way that the Product returned is nil once expired. Hope that helps a bit.

Thanks,

--Alex

russell-archer commented 1 year ago

I've added a few things to help with the situation where subscriptions are renewed or expire when the app's not running. See AppStoreHelper.paymentQueue(_:updatedTransactions:) and StoreHelper.handleStoreKit1Transactions(productId:status:transaction:).

From the research I've done, it looks like when using Xcode StoreKit Testing and Sandbox Testing subscription renewal transactions that happen when the app's not running are NEVER picked up by StoreKit2! That is, the transactions don't appear in StoreKit.Transaction.all or Transaction.currentEntitlement(for:). This seems to have been a known issue since the release of StoreKit2. So, you can have situations where a user has paid to renew their subscription but StoreKit2 has no knowledge of it. However, this only seems to affect Xcode StoreKit testing and sandbox testing. Production builds using the live App Store DO NOT appear to suffer from this issue.

That's great, but you really want to be convinced during testing that your app works correctly! So, as a workaround, StoreHelper now maintains a transactionUpdateCache that keeps track of subscription transactions that happen when the app's not running. This cache is used in isPurchased(productId:) when a call to Transaction.currentEntitlement(for: productId) returns nil.

ashayk commented 1 year ago

Thanks Russel. Can confirm that's working nicely now. FWIW, I'd found a decent workaround by introspecting currentEntitlements. That seemed to work in the Debugger, etc... Eventually, entitlements would be updated, but likely not in the background as you've mentioned, so you do have to be more generous in checking subscription status. That is, not simply relying on the state at the very beginning of app execution. The following code was working consistently via an extension on StoreHelper...

public func purchasedSubscription() async -> Product? {

    if self.hasStarted == false {
        await self.start()
    }

    let result : Set<ProductId> = await self.currentEntitlements()
    guard let productId = result.first else {
        return nil
    }

    guard let products : [Product] = await self.requestProductsFromAppStore(productIds: [productId]) else {
        return nil
    }
    guard let product = products.first else {
        return nil
    }

   return product
}