Skip to content

Fix: revoked subscriptions incorrectly reported as active#445

Merged
yusuftor merged 5 commits intodevelopfrom
fix/revoked-subscription-entitlement-active
Mar 2, 2026
Merged

Fix: revoked subscriptions incorrectly reported as active#445
yusuftor merged 5 commits intodevelopfrom
fix/revoked-subscription-entitlement-active

Conversation

@yusuftor
Copy link
Copy Markdown
Collaborator

@yusuftor yusuftor commented Mar 2, 2026

Summary

  • When Apple processes a refund, the subscription-level state from StoreKit's subscriptionStatus correctly returns .revoked. However, the SDK was not using this to override the per-transaction isActive check.
  • Transaction.all returns the full transaction history. Apple may not set revocationDate on every transaction under the same originalTransactionId, so an unrevoked transaction with a future expirationDate keeps the entitlement active even after a refund.
  • This results in state: .revoked but isActive: true on the entitlement — and the user continues to bypass paywalls.

Changes

EntitlementProcessor.swift — In buildEntitlementsWithLiveSubscriptionData, when the live subscription state is .revoked or .expired, override isActive to false instead of carrying forward the first-pass value.

SK2ReceiptManager.swift — After entitlements are resolved, correct Purchase.isActive for any product whose entitlements are all revoked/expired. This flows into AutomaticPurchaseController.syncSubscriptionStatus, which is what actually sets Superwall.shared.subscriptionStatus and gates paywall presentation.

Root cause

The entitlement resolution has two passes:

  1. First pass (buildEntitlementsFromTransactions): iterates Transaction.all and sets isActive = true if any transaction has !isRevoked && expirationDate > now
  2. Second pass (buildEntitlementsWithLiveSubscriptionData): queries subscriptionStatus for the authoritative subscription-group-level state

The second pass set state from subscriptionStatus but never used it to correct isActive from the first pass. When a refund revokes one transaction but leaves others in the history without revocationDate, the first pass incorrectly computes isActive = true, and the second pass never overrides it.

Test plan

  • Verify that a subscription with subscriptionStatus == .revoked correctly results in isActive: false on the entitlement
  • Verify that Superwall.shared.subscriptionStatus transitions to .inactive after a refund
  • Verify that paywalls are presented to users with revoked subscriptions
  • Verify that active (non-revoked) subscriptions are unaffected
  • Verify that expired subscriptions are also correctly marked inactive
  • Run existing EntitlementProcessorTests to 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, subscriptionStatus correctly returns .revoked, but Transaction.all may contain transactions without revocationDate set. The first-pass entitlement logic checked individual transactions and incorrectly computed isActive = true, while the second-pass never overrode this value using the authoritative subscription state.

The fix:

  • EntitlementProcessor.swift: When subscriptionStatus returns .revoked or .expired, now authoritatively overrides isActive to false
  • SK2ReceiptManager.swift: After entitlements are resolved, corrects Purchase.isActive for products where all entitlements are inactive

Key changes:

  • Added authoritative state override in the second pass of entitlement resolution
  • Included defensive handling for .expired state (redundant but safe)
  • Corrects purchase active status based on entitlement-level state
  • Well-documented with inline comments explaining the Apple API quirk

The previous review feedback has been addressed: the code now uses isActive directly and includes clarifying comments about the .expired case being defensive.

Confidence Score: 5/5

  • This PR is safe to merge with minimal risk
  • The fix is targeted, well-documented, and addresses a specific bug without introducing new complexity. The logic is sound and follows the existing two-pass pattern. Previous review feedback has been incorporated.
  • No files require special attention

Important Files Changed

Filename Overview
Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift Adds authoritative override to force isActive = false when subscription state is .revoked or .expired, fixing the bug where refunded subscriptions stayed active
Sources/SuperwallKit/StoreKit/Products/Receipt Manager/Receipt Manager/SK2ReceiptManager.swift Corrects Purchase.isActive based on entitlement state after resolution, ensuring purchases are marked inactive when all entitlements are revoked/expired

Last reviewed commit: 5650528

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

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

2 files reviewed, 2 comments

Edit Code Review Agent Settings | Greptile

Comment thread Sources/SuperwallKit/StoreKit/Products/Receipt Manager/EntitlementProcessor.swift Outdated
Lever and others added 4 commits March 2, 2026 15:38
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>
@yusuftor yusuftor merged commit ea4e20a into develop Mar 2, 2026
3 checks passed
@yusuftor yusuftor deleted the fix/revoked-subscription-entitlement-active branch March 2, 2026 16:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant