Skip to content

fix(google): handle DEFERRED replacement mode correctly#25

Merged
hyochan merged 3 commits into
mainfrom
fix/google-deffered-replace-mode
Oct 23, 2025
Merged

fix(google): handle DEFERRED replacement mode correctly#25
hyochan merged 3 commits into
mainfrom
fix/google-deffered-replace-mode

Conversation

@hyochan
Copy link
Copy Markdown
Member

@hyochan hyochan commented Oct 23, 2025

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

    • Improved Android purchase flow: DEFERRED downgrades now yield a clear empty purchase result, purchase callbacks are guarded for null results, refresh-on-purchase is protected against errors, and purchase-handling logs are clearer.
  • Documentation

    • Updated subscription docs and UI text to use numeric Android replacement mode values (e.g., 1, 6), explain DEFERRED behavior with a warning and troubleshooting guidance, and align best-practice examples.

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
@hyochan hyochan added 🛠 bugfix All kinds of bug fixes 🤖 android Related to android labels Oct 23, 2025
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Oct 23, 2025

Walkthrough

Docs, example app, and native OpenIAP modules were updated to use numeric replacementModeAndroid values and explicitly handle DEFERRED flows where Billing returns OK with null purchases. Added defensive error handling in the example app and made sourceSet configuration explicit in the OpenIAP Gradle build.

Changes

Cohort / File(s) Summary
Documentation page
packages/docs/src/pages/docs/subscription-upgrade-downgrade.tsx
Replaced string proration identifiers with numeric replacementModeAndroid values (e.g., 1 = WITH_TIME_PRORATION, 6 = DEFERRED). Updated UI text, examples, best-practices, added DEFERRED explanation and warning accordion, troubleshooting guidance, defaults, and backend-tracking notes.
Android example app
packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt
Changed LaunchedEffect dependencies to statusMessage?.productId and statusMessage?.status; added try/catch around iapStore.getAvailablePurchases(null) and guarded refresh-on-purchase to avoid uncaught errors.
OpenIAP — horizon flavor
packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
onPurchasesUpdated treats BillingResult OK + purchases == null as DEFERRED: log and invoke currentPurchaseCallback with an empty list (single-shot). When purchases exist, determine productType via ProductManager cache (fallback), map purchases, cache results, notify listeners, and invoke callback once; standardized logging to OpenIapLog and wrapped listener calls with error handling.
OpenIAP — play flavor
packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
Same DEFERRED handling: OK + null purchases → empty-list callback. When purchases present, map using ProductManager cache (fallback substring check), cache and notify listeners, and conditionally invoke currentPurchaseCallback. Standardized logging and single-shot callback semantics.
Build config
packages/google/openiap/build.gradle.kts
Added explicit sourceSets configuration (main, play, horizon) with explicit java.srcDirs, replacing implicit per-flavor source handling.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through docs and native code today,

numbers now match Android's proper way.
When deferred returns null, I give an empty list,
callbacks tidy, logs no longer missed.
The rabbit stamps a foot and bounds away.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The pull request title "fix(google): handle DEFERRED replacement mode correctly" is clear, concise, and directly describes the primary purpose of the changeset. It accurately reflects the main objective to address DEFERRED mode handling in the Google Billing integration, which is evidenced by the substantial changes to OpenIapModule.kt (both play and horizon variants) where null purchases on OK responses are now properly handled as valid deferred operations. The title uses conventional commit formatting and avoids vague terminology, making it immediately understandable to team members reviewing the change history.
Linked Issues Check ✅ Passed The PR successfully addresses the core coding requirements from issue #246. The OpenIapModule.kt changes in both play and horizon variants now properly handle DEFERRED mode by treating Google Billing's OK response with null purchases as a valid success case, invoking the currentPurchaseCallback with an empty list and logging appropriately. The documentation updates in subscription-upgrade-downgrade.tsx replace misleading string identifiers with numeric values (1 for WITH_TIME_PRORATION, 6 for DEFERRED) that align with official Android SDK constants, resolving the type inconsistency issue where docs previously showed string values while types expected numbers. The PR objectives confirm that replacementMode is now only applied when explicitly provided, addressing all primary objectives from the linked issue.
Out of Scope Changes Check ✅ Passed The changes are appropriately scoped to the DEFERRED mode handling fix and related supporting improvements. The core fix in OpenIapModule.kt (both play and horizon) directly addresses DEFERRED mode handling, while the documentation updates in subscription-upgrade-downgrade.tsx clarify the numeric values and expected behavior as required by issue #246. The SubscriptionFlowScreen.kt changes adding error handling around getAvailablePurchases are supporting changes demonstrating proper error handling in the example app context. The build.gradle.kts changes providing explicit sourceSets configuration are infrastructure improvements that support the dual-flavor code organization (play and horizon) necessary for the implementation. All modifications are reasonably justified by the core objective of properly implementing DEFERRED replacement mode support.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch fix/google-deffered-replace-mode

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1d7f345 and 3835600.

📒 Files selected for processing (2)
  • packages/google/openiap/build.gradle.kts (1 hunks)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt (2 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 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
📚 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/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 (3)
packages/google/openiap/build.gradle.kts (1)

66-77: LGTM! Explicit sourceSets improve build clarity.

Making the source set configuration explicit is a good practice that improves maintainability and makes the build structure clear for developers working with multiple product flavors.

packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt (2)

724-785: DEFERRED mode handling implemented correctly.

The code properly addresses the PR objective by treating OK responses with null purchases as valid DEFERRED-mode success. The single-shot callback pattern in both branches (non-null and null purchases) ensures the callback fires only once, addressing the previous review concern.


731-752: LGTM! Robust product type determination.

The product type determination logic is well-designed with a defensive fallback strategy: first checking the ProductManager cache for both SUBS and INAPP types, then falling back to substring matching if the product isn't cached. The logging at lines 747 and 750 provides good visibility into the mapping process.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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 is replacementModeAndroid.

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 logs

purchaseToken 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 fixing

The original issue is real but broader: both play and horizon implementations 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 intent

The 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 getAvailablePurchases within 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 is replacementModeAndroid. 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 is replacementModeAndroid.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 77c67cb and ade707b.

📒 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.kt
  • packages/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 (productId and status) rather than the entire statusMessage object 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: 1 value 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: 6 and 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:

  1. Clarify that replacementModeAndroid is optional (defaults apply)
  2. Recommend specific numeric modes for upgrades (1) and downgrades (6)
  3. Include critical guidance on handling DEFERRED mode's empty purchase list
  4. 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 replacementModeAndroid usage (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 purchases

Treating 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 lines

The code at lines 608–621 in the actual file (src/horizon/java/..., not src/play/java/...) is the restorePurchases handler, which does not call launchBillingFlow. The actual launchBillingFlow call is in the requestPurchase handler (lines 438–456), and it already wraps the call with activity.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 mapping

OK + null purchases handled as success; mapping consults cache with sensible fallback. Looks good.

Comment thread packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt Outdated
@hyochan hyochan force-pushed the fix/google-deffered-replace-mode branch from 1d7f345 to 925ddf3 Compare October 23, 2025 18:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🤖 android Related to android 🛠 bugfix All kinds of bug fixes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

replacementModeAndroid always applies subscription changes immediately and docs are inconsistent

1 participant