Detect already-subscribed purchases in StoreKit 2#6357
Conversation
StoreKit 2 returns `.success` with the existing transaction when a user tries to purchase a subscription they already have, unlike SK1 which returns `ASDServerError.Code.currentlySubscribed`. This causes the SDK to treat it as a new purchase and post the receipt unnecessarily. Compare the transaction ID before and after the SK2 purchase call. If they match, throw `productAlreadyPurchasedError` to align with SK1 behavior and avoid redundant receipt posts. Co-authored-by: Cursor <cursoragent@cursor.com>
- Condense PurchaseStrings.swift to stay within 400-line SwiftLint limit - Add clearTransactions() to testPurchaseDoesNotPostAdServicesTokenIfNotEnabled and testPurchasePostsAdServicesTokenAndSubscriberAttributes to prevent false productAlreadyPurchasedError from the new SK2 detection logic Co-authored-by: Cursor <cursoragent@cursor.com>
The naive latestTransaction ID comparison blocked legitimate retries (e.g., purchase succeeded but receipt posting failed, user tries again). Now checks Transaction.unfinished via transactionFetcher: if the existing transaction hasn't been finished yet, the purchase is allowed through so the receipt can be posted. Only finished (already-processed) transactions trigger productAlreadyPurchasedError. Co-authored-by: Cursor <cursoragent@cursor.com>
The transaction-finished check alone wasn't sufficient: when a user switches RC accounts (same Apple ID), the existing transaction is finished for user 1 but user 2 still needs the receipt posted. Now only throws productAlreadyPurchasedError when all three conditions are met: 1. Product has a finished (already-processed) latest transaction 2. The purchase returned the same transaction ID 3. The current user already has this product in their active subscriptions This preserves receipt transfer across RC account switches. Co-authored-by: Cursor <cursoragent@cursor.com>
Promotional and win-back offers are explicitly intended for existing subscribers, so returning the same transaction is expected behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Add tests for the 3 cases that allow a purchase through: - Promotional/win-back offers skip the check - Unfinished transactions allow retries - Different RC user (no cached active sub) allows receipt transfer Also reverts the unnecessary workaround in testPurchaseWithPurchaseParamsReturnsCorrectValues since the offer check already handles that case. Co-authored-by: Cursor <cursoragent@cursor.com>
- Extend check to also cover non-subscription products (non-consumables) by checking both activeSubscriptions and nonSubscriptions in cached customer info. - Revert testPurchaseWithPurchaseParamsReturnsCorrectValues to its original simpler form (the offer check already handles it). - Add tests for expired subscription re-purchase and nil cached info. - Remove unrelated BackendIntegrationTests.xcscheme change. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
📸 Snapshot Test254 unchanged
🛸 Powered by Emerge Tools |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable autofix in the Cursor dashboard.
tonidero
left a comment
There was a problem hiding this comment.
This is looking great to me, but would like catforms to take a look 🙏 Just a couple comments but nothing blocking for me.
| self.cachePresentedOfferingContext(package: package, productIdentifier: sk2Product.id) | ||
|
|
||
| // Promotional and win-back offers are for existing subscribers, | ||
| // so SK2 returning the same transaction is expected — skip the check. |
There was a problem hiding this comment.
skip the check probably won't mean anything to us down the line... I would say we might want to add more details of what this is doing?
| /// - The existing transaction is unfinished (receipt may not have been posted yet) | ||
| /// - The current user hasn't purchased this product (e.g., after switching RC accounts) | ||
| @available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *) | ||
| private func finishedTransactionID(for product: SK2Product) async -> String? { |
There was a problem hiding this comment.
I do wonder if there is a chance to extract most of this logic to a helper... Again, mostly trying to keep PurchasesOrchestrator from growing more 😅
| /// Returns `nil` (allowing the purchase through) when: | ||
| /// - There is no existing transaction | ||
| /// - The existing transaction is unfinished (receipt may not have been posted yet) | ||
| /// - The current user hasn't purchased this product (e.g., after switching RC accounts) |
There was a problem hiding this comment.
I do wonder that these docs do not reflect the name of this method IMO... Maybe we should make the method name more specific to this use case somehow? Maybe like? Maybe like alreadyPurchasedTransactionID?
- Include `promotionalOfferOptions` in the offer check so custom JWS promotional offers don't incorrectly trigger the already-subscribed error - Extract detection logic into `SK2AlreadySubscribedDetector` to keep `PurchasesOrchestrator` from growing further - Rename `finishedTransactionID` to `alreadyPurchasedTransactionID` so the method name matches its documented behavior - Expand the comment explaining why offer purchases skip the check Made-with: Cursor
Tests the detector helper directly, covering: - No existing transaction returns nil - Unfinished transaction allows retry (returns nil) - Different RC user allows receipt transfer (returns nil) - Nil cached customer info (cold start) returns nil - Expired subscription allows re-purchase (returns nil) - Active subscription with finished transaction returns transaction ID Made-with: Cursor
Generated by 🚫 Danger |
Checklist
purchases-androidand hybridsMotivation
StoreKit 1 returns an
ASDServerError.currentlySubscribederror when a user tries to purchase a subscription they already have. StoreKit 2, however, returns.successwith the existing transaction — making it indistinguishable from a new purchase at the API level.This means the SDK would re-post the receipt and the backend could create duplicate purchase events, which was identified while investigating inflated analytics for a customer using RevenueCat paywalls.
Description
Before calling
Product.purchase(), the SDK now captures the ID of the product's latest finished transaction. If the purchase returns the same transaction ID, it throwsproductAlreadyPurchasedError, aligning SK2 behavior with SK1.The check is deliberately conservative — it only throws when all of these conditions are met:
This is implemented in
PurchasesOrchestratorvia a newfinishedTransactionID(for:)helper that checksProduct.latestTransactionagainstTransaction.unfinishedand the cachedCustomerInfo.Edge cases that correctly allow the purchase through:
Note
Medium Risk
Changes StoreKit 2 purchase behavior by throwing
productAlreadyPurchasedErrorwhen SK2 returns an existing transaction, which could affect purchase flows and error handling in production if the detection misfires. Risk is mitigated by conservative checks and extensive unit test coverage for edge cases (offers, unfinished transactions, account switches, expired subs).Overview
Prevents duplicate receipt posts / purchase events in StoreKit 2 by detecting when
Product.purchase()returns an existing transaction for an already-owned product and surfacing this asproductAlreadyPurchasedError.Adds
SK2AlreadySubscribedDetectorand integrates it intoPurchasesOrchestrator(skipping the check for promotional/win-back/custom offer purchases), plus a new log message for the detected condition. Expands SK2 unit tests to cover the new detection logic and adjusts existing tests (e.g., clearing StoreKit test-session transactions) to avoid false positives.Written by Cursor Bugbot for commit f3cb3e4. This will update automatically on new commits. Configure here.