Transaction.updates sending me duplicated transactions after marking finished()

Hey y'all,

I'm reaching out because of an observed issue that I am experience both in sandbox and in production environments. This issue does not occur when using the Local StoreKit configurations.

For context, my app only implements auto-renewing subscriptions. I'm trying to track with my own analytics every time a successful purchase is made, whether in the app or externally through Subscription Settings. I'm seeming too many events for just one purchase.

My app is observing Transaction.updates. When I make a purchase with Product.purchase(_:), I successfully handle the purchase result. After about 10-20 seconds, I receive 2-3 new transactions in my Transaction.updates, even though I already handled and finished the Purchase result. This happens on production, where renewals are one week. This also happens in Sandbox, where at minimum renewals are every 3 minutes.

The transactions do not differ in transactionId, revocationDate, expirationDate, nor isUpgraded... so not sure why they're coming in through Transaction.updates if there are no "updates" to be processing.

For purchases made outside the app, I get the same issue. Transaction gets handled in updates several times.

Note that this is not an issue if a subscription renews. I use `Transaction.reason


I want to assume that StoreKit is a perfect API and can do no wrong (I know, a poor assumption but hear me out)... so where am I going wrong?

My current thought is a Swift concurrency issue. This is a contrived example:

// Assume Task is on MainActor
Task(priority: .background) { @MainActor in
  for await result in Transaction.updates in {
    // We suspend current process,
    // so will we go to next item in the `for-await-in` loop?
    // Because we didn't finish the first transaction, will we see it again in the updates queue?
    await self.handle(result)
  }
}

@MainActor
func handle(result) async {
  ...
  await Analytics.sendEvent("purchase_success")
  transaction.finish()
}

Thanks for your post as it seems very well written explaining the issue with the code in the end, but following your code as you just looking at a subscription is difficult to understand what is the cause of the issue.

I’m not an expert in subscriptions but I wonder looking at your code if could be a Swift's concurrency issue as you are not setting the for await result in Transaction.updates ?

I think the problem may b3e that your current handler, though running on @MainActor, processes transactions sequentially within that single loop. If processing takes even a few milliseconds, new updates can arrive and pile up behind the currently processing one, potentially leading to them being handled more than once if logic isn't airtight?

I do believe by encapsulating your logic within an actor and using a set for deduplication, you create a predictable and thread-safe environment for handling StoreKit's asynchronous transaction updates, effectively solving the duplicate problem you've encountered.

Something like this?

// No good code, just the idea to add the await on the handler. The code is not from Xcode, wrote it by hand from my simple mind:

.addTask {
                    await self.handle(transaction)
                }

Albert Pascual
  Worldwide Developer Relations.

Thanks for the reply, @DTS Engineer!

I've been down a rabbit hole since your initial reply.

If processing takes even a few milliseconds, new updates can arrive and pile up behind the currently processing one, potentially leading to them being handled more than once if logic isn't airtight?

This is kinda what I was thinking. If processing takes too long, then it would resurface somehow. I didn't rule this out, but I did rule out Actor reentrancy issues.


I've also done some digging in the sample Projects, mainly SKDemo found here.

As a reminder, my use case is solely focussed on tiered subscriptions. I do not support consumable transactions, so I'm really just looking at how subscriptions are handled.

What I found, in my opinion, is not reflected well in documentation:

1. Transaction updates do not handle subscription enablements.

When reading the StoreKit API docs, I saw that Transcation.updates tells you of incoming transactions. You must call transaction.finish(), but only after you've delivered the feature to the customer. This inferred to me that I had to unlock features for a subscription here, and also that this is a good place to send a purchase_success event.

However, based on the SKDemo sample app...

    public func process(transaction: Transaction) async {
        // Only handle consumables and non consumables here. Check the subscription status each time
        // before unlocking a premium subscription feature.
        switch transaction.productType {
        case .nonConsumable:
            await processNonConsumableTransaction(transaction)
        case .consumable:
            await processConsumableTransaction(transaction)
        case _:
            // Finish the transaction. Grant access to the subscription based on the subscription status.
            await transaction.finish()
        }

We simply finish the transaction for a subscription.

My assumption has caused me to put a lot of logic into Transaction.updates. I think it's better to move my analytics and feature enablement out of this observer.


encapsulating your logic within an actor and using a set for deduplication @DTS Engineer

I think that what you said here lines up very well with the Demo. The Demo places work on the MainActor, and incoming transactions are stored in a Set in CustomerEntitlements.ownedNonConsumables

Similarly, we also store active subscriptions in a dictionary (Not in a Set), but when we set this property, the rest of the app can respond accordingly to any changes made here.


TLDR* Based on your comment and on the SKDemo app, I think I need to rewrite my code such that I'm deduplicating using Sets and storing activeSubscriptions in memory, handling analytics and feature enablement when my custom properties change. I should only finish() transactions in Transaction.updates and handle all of my other logic in another API, such as SubscriptionStatus.

Thank you for your reply and going over the exploration path to find the issue. That's an excellent and very insightful breakdown. I just checked the SKDemo's approach of await transaction.finish() for subscriptions in the process method is the correct pattern. But then again. I’m not an expert in subscriptions nor that API, hope someone from that team can jump in this thread as I believe, unlike consumables or non-consumables, where a single transaction often directly grants an entitlement, subscriptions have a lifecycle. They can renew, expire, be upgraded, downgraded.

Looking at that sample I believe this is why the SKDemo suggests: "Check the subscription status each time before unlocking a premium subscription feature."

This is the robust and recommended pattern for handling StoreKit 2 subscriptions. It separates the concerns of transaction receipt from entitlement management, leading to a more reliable and maintainable purchase flow in your app.

Great work on the digging and connecting the dots! But if you think the documentation is not good enough, I would recommend to request and enhance for that documentation, I’m sure that team will be more than happy to improve that documentation.

Once you file the request, please post the FB number here.

If you're not familiar with how to file enhancement requests, take a look at Bug Reporting: How and Why?

Albert Pascual
  Worldwide Developer Relations.

Transaction.updates sending me duplicated transactions after marking finished()
 
 
Q