fix(google): handle DEFERRED replacement mode correctly#25
Conversation
When using DEFERRED mode (6), Google Billing returns OK status but null purchases list since the change is scheduled for future renewal. Updated to treat this as success instead of error. Also updated to only set replacementMode when explicitly provided, allowing Google Play Console defaults to be used. Closes: hyochan/expo-iap#246
WalkthroughDocs, example app, and native OpenIAP modules were updated to use numeric Changes
Sequence Diagram(s)sequenceDiagram
actor User
participant App
participant BillingClient
participant OpenIapModule
participant Callback
User->>App: Initiate subscription change (replacementModeAndroid set)
App->>BillingClient: requestPurchase(..., replacementModeAndroid: 6)
alt DEFERRED (Billing OK, purchases == null)
BillingClient->>OpenIapModule: onPurchasesUpdated(OK, purchases=null)
note right of OpenIapModule #FFF3CD: Log DEFERRED\ninvoke single-shot callback with []
OpenIapModule->>Callback: currentPurchaseCallback([])
Callback->>App: Empty purchase list (deferred)
else Immediate (Billing OK, purchases != null)
BillingClient->>OpenIapModule: onPurchasesUpdated(OK, purchases=[...])
note right of OpenIapModule #DFF0D8: Determine product type via cache\nmap → internal model\ncache & notify listeners
OpenIapModule->>Callback: currentPurchaseCallback(mappedPurchases)
Callback->>App: Purchase list with details
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
📜 Recent review detailsConfiguration used: CodeRabbit UI Review profile: CHILL Plan: Pro 📒 Files selected for processing (2)
🧰 Additional context used🧠 Learnings (2)📚 Learning: 2025-10-18T05:46:51.596ZApplied to files:
📚 Learning: 2025-10-18T05:54:54.802ZApplied to files:
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
🔇 Additional comments (3)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
packages/docs/src/pages/docs/subscription-upgrade-downgrade.tsx (1)
684-688: Fix incorrect parameter name.The text references
prorationModeAndroid, but based on the code examples throughout this file (lines 705, 786, 925), the correct parameter name isreplacementModeAndroid.Apply this diff:
<p> - Specify the <code>prorationModeAndroid</code> parameter when + Specify the <code>replacementModeAndroid</code> parameter when calling <code>requestPurchase()</code> to control upgrade behavior. </p>packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt (2)
845-850: Redact purchaseToken and orderId in logspurchaseToken and full orderId are sensitive. Log redacted values.
Apply this diff:
- purchases?.forEachIndexed { index, purchase -> - Log.d( - TAG, - "[Purchase $index] token=${purchase.purchaseToken} orderId=${purchase.orderId} state=${purchase.purchaseState} autoRenew=${purchase.isAutoRenewing} acknowledged=${purchase.isAcknowledged} products=${purchase.products}" - ) - } + purchases?.forEachIndexed { index, purchase -> + val redactedToken = purchase.purchaseToken.take(8) + "…" + val redactedOrder = purchase.orderId?.take(8)?.plus("…") + Log.d( + TAG, + "[Purchase $index] token=$redactedToken orderId=$redactedOrder state=${purchase.purchaseState} autoRenew=${purchase.isAutoRenewing} acknowledged=${purchase.isAcknowledged} products=${purchase.products}" + ) + }
592-596: Two locations force incorrect replacementMode defaults; both need fixingThe original issue is real but broader: both
playandhorizonimplementations default to CHARGE_FULL_PRICE (5) when null, preventing DEFERRED mode and Play Console defaults.Apply the suggested fix to:
packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt(lines 592–596)packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt(lines 416–420)- // Set replacement mode - this is critical for upgrades - val replacementMode = androidArgs.replacementModeAndroid ?: 5 // Default to CHARGE_FULL_PRICE - updateParamsBuilder.setSubscriptionReplacementMode(replacementMode) - OpenIapLog.d(" - Final replacement mode: $replacementMode", TAG) + // Only set replacement mode if explicitly provided; otherwise let Play Console default apply + androidArgs.replacementModeAndroid?.let { replacementMode -> + updateParamsBuilder.setSubscriptionReplacementMode(replacementMode) + OpenIapLog.d(" - Replacement mode explicitly set: $replacementMode", TAG) + } ?: OpenIapLog.d(" - Replacement mode not provided; using Play Console default", TAG)packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt (1)
417-419: Remove hardcoded default replacement mode; respect caller's intentThe hardcoded default of 5 (CHARGE_FULL_PRICE) removes flexibility. Callers should provide an explicit replacement mode when needed; if not provided, defer to the platform's default instead of forcing one.
Apply this diff:
- // Set replacement mode - this is critical for upgrades - val replacementMode = androidArgs.replacementModeAndroid ?: 5 // Default to CHARGE_FULL_PRICE - updateParamsBuilder.setSubscriptionReplacementMode(replacementMode) - OpenIapLog.d(" - Final replacement mode: $replacementMode", TAG) + // Only set replacement mode if explicitly provided; otherwise let console default apply + androidArgs.replacementModeAndroid?.let { replacementMode -> + updateParamsBuilder.setSubscriptionReplacementMode(replacementMode) + OpenIapLog.d(" - Replacement mode explicitly set: $replacementMode", TAG) + } ?: OpenIapLog.d(" - Replacement mode not provided; using console default", TAG)
🧹 Nitpick comments (2)
packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt (1)
227-231: Good error handling at 227-231; apply consistently to unprotected calls on lines 248, 272, 637, 639, 881, 884.The try-catch wrapper here appropriately handles transient failures in the DEFERRED purchase scenario. However, seven other calls to
getAvailablePurchaseswithin the same file lack similar protection: lines 248, 272, 637, 639, 881, and 884. Consider wrapping these to match the error handling approach you've established.packages/docs/src/pages/docs/subscription-upgrade-downgrade.tsx (1)
597-600: Clarify parameter terminology for consistency.This section uses the generic term
prorationMode, but the actual API parameter isreplacementModeAndroid. While this may be intentional to describe the concept generally, it could confuse developers. Consider clarifying that this describes the replacement mode concept, and the actual parameter isreplacementModeAndroid.Apply this diff to clarify:
<ul> <li> <strong> - <code>prorationMode</code> + <code>replacementModeAndroid</code> </strong> : Determines when the change takes effect and how billing is handled </li>
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
packages/docs/src/pages/docs/subscription-upgrade-downgrade.tsx(7 hunks)packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt(1 hunks)packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt(2 hunks)packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt(1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
packages/docs/src/**/*.{ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
packages/docs/src/**/*.{ts,tsx}: Platform suffixes in function names for documentation site examples: iOS-only functions end with IOS; Android-only functions end with Android; cross-platform functions have no suffix
Use kebab-case for search modal IDs in docs (e.g., id: 'request-products')
Trigger opening modals from pages/components by calling openAuthModal from the signals module
Components must not overflow parent boundaries: ensure children fit; use overflow-hidden on parents as needed; apply break-words for long text; use whitespace-nowrap for nav items
Delete unused components, functions, imports; avoid commented-out code; remove unused variables/parameters
Wrap any Promise-returning function used in a void context (e.g., React event handlers) with the void operator
Files:
packages/docs/src/pages/docs/subscription-upgrade-downgrade.tsx
packages/docs/src/**/*.{md,mdx,ts,tsx}
📄 CodeRabbit inference engine (CLAUDE.md)
Respect deprecations in documentation: replace buy-promoted-product-ios with requestPurchaseOnPromotedProductIOS; requestProducts with fetchProducts; get-storefront-ios with getStorefront
Files:
packages/docs/src/pages/docs/subscription-upgrade-downgrade.tsx
🧠 Learnings (2)
📚 Learning: 2025-10-18T05:54:54.802Z
Learnt from: CR
PR: hyodotdev/openiap#0
File: CLAUDE.md:0-0
Timestamp: 2025-10-18T05:54:54.802Z
Learning: Applies to packages/google/openiap/src/main/**/*.kt : In Android-only package, do not add Android suffix to function names (e.g., acknowledgePurchase, not acknowledgePurchaseAndroid)
Applied to files:
packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.ktpackages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
📚 Learning: 2025-10-18T05:46:51.596Z
Learnt from: hyochan
PR: hyodotdev/openiap#17
File: packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt:620-629
Timestamp: 2025-10-18T05:46:51.596Z
Learning: In packages/google/openiap/src/main/java/**/*.kt (Android-only package): DO NOT add Android suffix to function names, even for Android-specific APIs. Exception: Only use Android suffix for cross-platform API types (e.g., ProductAndroid, PurchaseAndroid) that contrast with iOS types. Examples of correct naming: isHorizonEnvironment(context: Context), buildModule(context: Context), acknowledgePurchase(), consumePurchase().
Applied to files:
packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: Test Android
🔇 Additional comments (10)
packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt (1)
222-222: Improved LaunchedEffect dependency granularity.The change to depend on specific properties (
productIdandstatus) rather than the entirestatusMessageobject is a good improvement. This follows Compose best practices and ensures the effect only re-triggers when these specific values change, improving performance and clarity.packages/docs/src/pages/docs/subscription-upgrade-downgrade.tsx (6)
615-637: Excellent documentation of numeric replacement modes.The updated documentation clearly maps numeric values to Android SDK constants and includes a helpful note about default behavior. This aligns well with the PR objectives to clarify replacement mode usage.
693-709: LGTM! Code example updated correctly.The upgrade example now uses the numeric
replacementModeAndroid: 1value with a helpful inline comment. This is clear and aligns with the Android SDK constants.
737-769: Excellent addition: Critical DEFERRED mode behavior explained.This accordion is a valuable addition that directly addresses the PR objectives. It clearly explains that an empty purchase list with DEFERRED mode is expected behavior, not an error. The explanation of why this happens (change scheduled for future) will prevent developer confusion and incorrect error handling.
774-791: LGTM! Downgrade example correctly demonstrates DEFERRED mode.The code example properly uses
replacementModeAndroid: 6and includes a helpful comment about the expected empty purchase list behavior. This reinforces the accordion explanation above and provides practical guidance.
864-890: Well-updated best practices section.The best practices have been thoroughly updated to:
- Clarify that
replacementModeAndroidis optional (defaults apply)- Recommend specific numeric modes for upgrades (1) and downgrades (6)
- Include critical guidance on handling DEFERRED mode's empty purchase list
- Emphasize backend tracking for deferred changes
These updates align perfectly with the PR objectives and provide clear, actionable guidance.
900-943: Comprehensive example demonstrates complete workflow.This complete example effectively demonstrates:
- Correct numeric
replacementModeAndroidusage (1 for upgrades, 6 for downgrades)- Backend tracking pattern for deferred changes
- Proper error handling
The example provides developers with a solid implementation pattern to follow.
packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt (2)
852-871: DEFERRED mode: correct success handling for null purchasesTreating OK + null purchases as success and returning an empty list matches DEFERRED semantics. Good addition.
If helpful, verify behavior on a real downgrade with DEFERRED mode to ensure no spurious errors are emitted.
608-621: Code has already been properly fixed—review comment references wrong file and linesThe code at lines 608–621 in the actual file (
src/horizon/java/..., notsrc/play/java/...) is therestorePurchaseshandler, which does not calllaunchBillingFlow. The actuallaunchBillingFlowcall is in therequestPurchasehandler (lines 438–456), and it already wraps the call withactivity.runOnUiThread { ... }as required. The exact fix you're requesting has already been applied.Likely an incorrect or invalid review comment.
packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt (1)
724-786: DEFERRED mode: correct success handling and robust mappingOK + null purchases handled as success; mapping consults cache with sensible fallback. Looks good.
1d7f345 to
925ddf3
Compare
925ddf3 to
3835600
Compare
When using DEFERRED mode (6), Google Billing returns OK status but null purchases list since the change is scheduled for future renewal. Updated to treat this as success instead of error.
Also updated to only set replacementMode when explicitly provided, allowing Google Play Console defaults to be used.
Closes: hyochan/expo-iap#246
Summary by CodeRabbit
Bug Fixes
Documentation