Skip to content

fix(ios): prioritize entitlements over error in handleEntitlementsResult#303

Open
NickSxti wants to merge 1 commit intomainfrom
fix/sup3-30-entitlements-priority
Open

fix(ios): prioritize entitlements over error in handleEntitlementsResult#303
NickSxti wants to merge 1 commit intomainfrom
fix/sup3-30-entitlements-priority

Conversation

@NickSxti
Copy link
Copy Markdown

Summary

  • When iOS SDK returns both entitlements and an error simultaneously (e.g. Stripe users with empty Apple receipt), the bridge now returns entitlements instead of discarding them
  • Only returns error to the caller when entitlements dictionary is empty
  • Adds test script validating all edge cases

Context

Linear: SUP3-30
Customer: Jumpspeak (tier 1) - users with Stripe subscriptions lose entitlements on sign-in due to PaymentInvalid error from empty Apple receipt.
Previous PRs: #290, #291 (both closed without merge; #291 was approved by @SpertsyanKM)

Root cause

handleEntitlementsResult in QonversionSandwich.swift checks error before entitlements. When the iOS SDK callback fires with both valid Stripe entitlements AND a StoreKit error (SKError.paymentInvalid from empty Apple receipt), the entitlements are discarded and only the error is returned to Flutter/RN/Unity.

Change

// Before: error takes priority, entitlements discarded
if let error = error { return completion(nil, error) }

// After: entitlements take priority when present
if !entitlements.isEmpty {
    completion(entitlementsDict, nil)
    return
}
if let error = error { return completion(nil, error) }

Test plan

  • Test: entitlements + error present -> entitlements returned, error suppressed
  • Test: empty entitlements + error -> error returned
  • Test: entitlements + no error -> entitlements returned (normal case)
  • Test: no entitlements + no error -> empty dict returned
  • Test: multiple entitlements + error -> all entitlements preserved
  • Verified diff matches previously approved PR fix(ios): prioritize entitlements over error in handleEntitlementsResult #291
  • Xcode build verification (requires signing cert / CI)

🤖 Generated with Claude Code

Note: This fix addresses Bug 2 (sandwich layer). Bug 1 (iOS SDK actualizeEntitlements overwriting backend entitlements with empty cache) requires a separate PR in qonversion-ios-sdk.

When the iOS SDK returns both entitlements and an error (e.g. Stripe
users with empty Apple receipt triggering SKError.paymentInvalid),
the bridge layer now returns the entitlements instead of discarding
them in favor of the error.

Fixes SUP3-30: Jumpspeak users losing Stripe entitlements on sign-in.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Author

@NickSxti NickSxti left a comment

Choose a reason for hiding this comment

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

Self-review @SpertsyanKM

What changed

Single method handleEntitlementsResult (lines 452-473) - reordered the priority: entitlements are checked before error.

Correctness

  • When both entitlements and error are present: returns entitlements, suppresses error. This is correct for the Stripe-on-iOS case where the error is a StoreKit artifact (empty receipt) and entitlements are valid backend data.
  • When entitlements are empty and error is present: returns error as before. No behavior change for pure error scenarios.
  • When entitlements are present and no error: returns entitlements. No behavior change for the normal path.
  • New branch: empty entitlements + no error returns [:] instead of going through the original mapValues path on empty dict. Functionally equivalent but more explicit.

Risk assessment

  • Low risk. This method is a bridge between native iOS SDK and Flutter/RN/Unity. It only transforms data, no side effects.
  • The error is suppressed only when entitlements are non-empty. If the error carries meaningful information beyond the StoreKit artifact, it will be lost. However, the native iOS SDK already handles error logging upstream, and the bridge layer's job is to deliver data to the caller.
  • clearEmptyValues() is still called on the entitlements dict, which could theoretically strip entries and make the dict empty after filtering. In that case we'd fall through to the error path - which is actually correct (if all entitlements are empty/invalid, the error is more useful).

Edge cases considered

  • entitlements parameter is [String: Qonversion.Entitlement] (non-optional). The iOS SDK always passes a dict (possibly empty), never nil.
  • clearEmptyValues() on a dict with valid toMap() output should not strip real entitlements - only removes nil/empty nested values.
  • No threading concerns - this is called from the iOS SDK callback and dispatches to BridgeCompletion synchronously.

Test coverage

  • 6 test cases covering all branches (standalone script, not XCTest target - project has no test target currently)
  • Matches the diff from PR #291 which was previously approved

@NickSxti NickSxti requested a review from SpertsyanKM March 23, 2026 11:20
Copy link
Copy Markdown
Collaborator

@SpertsyanKM SpertsyanKM left a comment

Choose a reason for hiding this comment

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

The only concern I have: is the case with non-empty entitlements and an error truly possible only in the described scenario in iOS? Could it be that with this fix we might inadvertently change the behavior of some other case?

Copy link
Copy Markdown
Author

@NickSxti NickSxti left a comment

Choose a reason for hiding this comment

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

I traced every iOS SDK code path that can fire handleEntitlementsResult with both non-empty entitlements and a non-nil error.

Short answer

Yes, there is one other case beyond the Stripe scenario where the fix changes behavior. It's a timing-dependent case during initial launch failure, and the behavior change is an improvement (graceful offline degradation).

Exhaustive trace

handleEntitlementsResult is called from 4 places: checkEntitlements, restore, promoPurchase, getPurchaseCompletionHandler.

Purchase paths (old API) - no change. The old purchase API in QNProductCenterManager.m:392-406 wraps QONPurchaseResult and always sends either (entitlements, nil) on success or (@{}, error) on failure. Never both. The fix is a no-op for purchases.

Restore path - no change without the ios-sdk PR. restoreReceipt: passes result.entitlements from launchWithTrigger:. In the native API client, dict is always nil when error is present, so result.entitlements is always empty on error. Without the ios-sdk PR #653, the Sandwich fix is a no-op for restore too.

checkEntitlements called after launch completed - no change. When called after launch, checkEntitlements: (line 364) queues the block and calls handlePendingRequests:nil with nil error. Since launchingFinished is true, it reaches executeEntitlementsBlocksWithError:nil which takes the else branch (line 625) -> prepareEntitlementsResultWithCompletion: -> actualizeEntitlements:. This path handles errors internally and never sends both to the callback.

checkEntitlements called before launch completes + launch fails - CHANGES BEHAVIOR. This is the one case:

  1. App initializes, launchWithTrigger:QONRequestTriggerInit fires async (Qonversion.m:97)
  2. Flutter calls checkEntitlements() before launch finishes
  3. checkEntitlements: queues the block in entitlementsBlocks, calls handlePendingRequests:nil which returns immediately (launchingFinished is false, line 1432)
  4. Launch fails (e.g. network down)
  5. launchWithTrigger: sets launchingFinished = YES, calls handlePendingRequests:error (line 243)
  6. executeEntitlementsBlocksWithError:error fires (line 1442)
  7. Since pendingIdentityUserID is not set, it loads cached entitlements from a previous session (line 621-623):
NSDictionary *cachedEntitlements = [self getActualEntitlementsForDefaultState:NO];
cachedEntitlements = cachedEntitlements ?: @{};
[self fireEntitlementsBlocks:[_blocks copy] result:cachedEntitlements error:error];
  1. Callback fires with (cachedEntitlements, networkError) - both non-nil

Before the Sandwich fix: error takes priority, Flutter gets an exception.
After the Sandwich fix: cached entitlements take priority, Flutter gets entitlements silently.

Assessment

This behavior change is an improvement. The iOS SDK already returns cached entitlements alongside the error in this path - the Sandwich bridge was just discarding them. The actualizeEntitlements: path (used for all post-launch checkEntitlements calls) already silently returns cached entitlements on error. This fix makes the initial-launch-failure path consistent with that behavior.

The only downside: developers who relied on getting an error to display "offline" UI during initial launch failure would now get stale entitlements instead. But this window is narrow (only checkEntitlements calls concurrent with initial launch), and subsequent calls go through actualizeEntitlements: which already suppresses errors when cache is available.

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

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants