Skip to content

Detect already-subscribed purchases in StoreKit 2#6357

Open
ajpallares wants to merge 16 commits into
mainfrom
pallares/sk2-already-subscribed-error
Open

Detect already-subscribed purchases in StoreKit 2#6357
ajpallares wants to merge 16 commits into
mainfrom
pallares/sk2-already-subscribed-error

Conversation

@ajpallares
Copy link
Copy Markdown
Member

@ajpallares ajpallares commented Feb 25, 2026

Checklist

  • Unit tests
  • If applicable, create follow-up issues for purchases-android and hybrids

Motivation

StoreKit 1 returns an ASDServerError.currentlySubscribed error when a user tries to purchase a subscription they already have. StoreKit 2, however, returns .success with 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 throws productAlreadyPurchasedError, aligning SK2 behavior with SK1.

The check is deliberately conservative — it only throws when all of these conditions are met:

  1. The product has an existing transaction that has already been finished (unfinished transactions indicate a receipt that hasn't been posted yet, so retries must be allowed)
  2. The current user already owns this product — either as an active subscription or a non-subscription purchase (so receipt transfers across RC account switches and expired subscription re-purchases are not blocked)
  3. The purchase is not using a promotional or win-back offer (these are explicitly for existing subscribers)
  4. The purchase returned the same transaction ID as the pre-existing one

This is implemented in PurchasesOrchestrator via a new finishedTransactionID(for:) helper that checks Product.latestTransaction against Transaction.unfinished and the cached CustomerInfo.

Edge cases that correctly allow the purchase through:

  • Unfinished transactions: receipt may not have been posted (e.g., server was down)
  • Different RC user on same Apple ID: receipt needs to be posted for the new user
  • Expired subscriptions: SK2 creates a new transaction on re-subscribe
  • Promotional/win-back offers: these are for existing subscribers by definition
  • Consumable re-purchases: SK2 creates a new transaction ID each time
  • No cached customer info (cold start): can't determine ownership, fails open

Note

Medium Risk
Changes StoreKit 2 purchase behavior by throwing productAlreadyPurchasedError when 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 as productAlreadyPurchasedError.

Adds SK2AlreadySubscribedDetector and integrates it into PurchasesOrchestrator (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.

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>
@ajpallares ajpallares added the pr:fix A bug fix label Feb 25, 2026
ajpallares and others added 9 commits February 25, 2026 16:05
- 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>
@emerge-tools
Copy link
Copy Markdown

emerge-tools Bot commented Feb 25, 2026

📸 Snapshot Test

254 unchanged

Name Added Removed Modified Renamed Unchanged Errored Approval
RevenueCat
com.revenuecat.PaywallsTester
0 0 0 0 254 0 N/A

🛸 Powered by Emerge Tools

@ajpallares ajpallares requested a review from a team February 26, 2026 10:19
@ajpallares ajpallares marked this pull request as ready for review February 26, 2026 10:19
@ajpallares ajpallares requested a review from a team as a code owner February 26, 2026 10:19
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Sources/Purchasing/Purchases/PurchasesOrchestrator.swift Outdated
Copy link
Copy Markdown
Contributor

@tonidero tonidero left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment thread Sources/Purchasing/Purchases/PurchasesOrchestrator.swift Outdated
/// - 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? {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@RevenueCat-Danger-Bot
Copy link
Copy Markdown

RevenueCat-Danger-Bot commented Mar 4, 2026

1 Warning
⚠️ RevenueCat.xcodeproj is out of sync.

The following Swift files were added but are missing from RevenueCat.xcodeproj:
Tests/StoreKitUnitTests/SK2AlreadySubscribedDetectorTests.swift

To fix: open RevenueCat.xcodeproj in Xcode, add/remove the files above in the appropriate target. Check where similar files in the same directory are assigned if you're unsure which target to use.

Generated by 🚫 Danger

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:fix A bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants