fix(ios): prioritize entitlements over error in handleEntitlementsResult#303
fix(ios): prioritize entitlements over error in handleEntitlementsResult#303
Conversation
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>
NickSxti
left a comment
There was a problem hiding this comment.
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 originalmapValuespath 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
entitlementsparameter is[String: Qonversion.Entitlement](non-optional). The iOS SDK always passes a dict (possibly empty), never nil.clearEmptyValues()on a dict with validtoMap()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
SpertsyanKM
left a comment
There was a problem hiding this comment.
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?
NickSxti
left a comment
There was a problem hiding this comment.
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:
- App initializes,
launchWithTrigger:QONRequestTriggerInitfires async (Qonversion.m:97) - Flutter calls
checkEntitlements()before launch finishes checkEntitlements:queues the block inentitlementsBlocks, callshandlePendingRequests:nilwhich returns immediately (launchingFinishedis false, line 1432)- Launch fails (e.g. network down)
launchWithTrigger:setslaunchingFinished = YES, callshandlePendingRequests:error(line 243)executeEntitlementsBlocksWithError:errorfires (line 1442)- Since
pendingIdentityUserIDis 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];- 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.
Summary
Context
Linear: SUP3-30
Customer: Jumpspeak (tier 1) - users with Stripe subscriptions lose entitlements on sign-in due to
PaymentInvaliderror from empty Apple receipt.Previous PRs: #290, #291 (both closed without merge; #291 was approved by @SpertsyanKM)
Root cause
handleEntitlementsResultinQonversionSandwich.swiftcheckserrorbeforeentitlements. 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
Test plan
🤖 Generated with Claude Code
Note: This fix addresses Bug 2 (sandwich layer). Bug 1 (iOS SDK
actualizeEntitlementsoverwriting backend entitlements with empty cache) requires a separate PR in qonversion-ios-sdk.