Fix: revoked subscriptions incorrectly reported as active#445
Merged
Fix: revoked subscriptions incorrectly reported as active#445
Conversation
When Apple processes a refund, the subscription-level state from StoreKit's subscriptionStatus correctly returns .revoked. However, the SDK was not using this authoritative state to override the per-transaction isActive check. Because Transaction.all returns the full transaction history and Apple may not set revocationDate on every transaction under the same originalTransactionId, an unrevoked transaction with a future expirationDate could keep the entitlement active even after a refund. This fix ensures that when the subscription-level state is .revoked or .expired, isActive is forced to false in both: 1. EntitlementProcessor — so CustomerInfo.entitlements is correct 2. SK2ReceiptManager — so Purchase.isActive is correct, which flows into AutomaticPurchaseController.syncSubscriptionStatus and ultimately gates paywall presentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Both .revoked and .expired are necessary overrides — the first pass can incorrectly compute isActive = true from stale transactions in Transaction.all regardless of the subscription-level state. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
subscriptionStatuscorrectly returns.revoked. However, the SDK was not using this to override the per-transactionisActivecheck.Transaction.allreturns the full transaction history. Apple may not setrevocationDateon every transaction under the sameoriginalTransactionId, so an unrevoked transaction with a futureexpirationDatekeeps the entitlement active even after a refund.state: .revokedbutisActive: trueon the entitlement — and the user continues to bypass paywalls.Changes
EntitlementProcessor.swift— InbuildEntitlementsWithLiveSubscriptionData, when the live subscription state is.revokedor.expired, overrideisActivetofalseinstead of carrying forward the first-pass value.SK2ReceiptManager.swift— After entitlements are resolved, correctPurchase.isActivefor any product whose entitlements are all revoked/expired. This flows intoAutomaticPurchaseController.syncSubscriptionStatus, which is what actually setsSuperwall.shared.subscriptionStatusand gates paywall presentation.Root cause
The entitlement resolution has two passes:
buildEntitlementsFromTransactions): iteratesTransaction.alland setsisActive = trueif any transaction has!isRevoked && expirationDate > nowbuildEntitlementsWithLiveSubscriptionData): queriessubscriptionStatusfor the authoritative subscription-group-level stateThe second pass set
statefromsubscriptionStatusbut never used it to correctisActivefrom the first pass. When a refund revokes one transaction but leaves others in the history withoutrevocationDate, the first pass incorrectly computesisActive = true, and the second pass never overrides it.Test plan
subscriptionStatus == .revokedcorrectly results inisActive: falseon the entitlementSuperwall.shared.subscriptionStatustransitions to.inactiveafter a refundEntitlementProcessorTeststo confirm no regressions🤖 Generated with Claude Code
Greptile Summary
This PR fixes a critical bug where refunded subscriptions were incorrectly reported as active, allowing users to bypass paywalls after receiving a refund.
Root cause: When Apple processes a refund,
subscriptionStatuscorrectly returns.revoked, butTransaction.allmay contain transactions withoutrevocationDateset. The first-pass entitlement logic checked individual transactions and incorrectly computedisActive = true, while the second-pass never overrode this value using the authoritative subscription state.The fix:
EntitlementProcessor.swift: WhensubscriptionStatusreturns.revokedor.expired, now authoritatively overridesisActivetofalseSK2ReceiptManager.swift: After entitlements are resolved, correctsPurchase.isActivefor products where all entitlements are inactiveKey changes:
.expiredstate (redundant but safe)The previous review feedback has been addressed: the code now uses
isActivedirectly and includes clarifying comments about the.expiredcase being defensive.Confidence Score: 5/5
Important Files Changed
isActive = falsewhen subscription state is.revokedor.expired, fixing the bug where refunded subscriptions stayed activePurchase.isActivebased on entitlement state after resolution, ensuring purchases are marked inactive when all entitlements are revoked/expiredLast reviewed commit: 5650528