Skip to content

Commit e6a7a4d

Browse files
authored
fix(google): handle DEFERRED replacement mode correctly (#25)
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 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 77c67cb commit e6a7a4d

5 files changed

Lines changed: 139 additions & 51 deletions

File tree

packages/docs/src/pages/docs/subscription-upgrade-downgrade.tsx

Lines changed: 65 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -612,21 +612,29 @@ function SubscriptionStatus() {
612612
</p>
613613
<ul>
614614
<li>
615-
<code>WITH_TIME_PRORATION</code> - Immediate change with
615+
<code>1 (WITH_TIME_PRORATION)</code> - Immediate change with
616616
prorated credit
617617
</li>
618618
<li>
619-
<code>CHARGE_PRORATED_PRICE</code> - Immediate change,
620-
charge difference
619+
<code>2 (CHARGE_PRORATED_PRICE)</code> - Immediate change,
620+
charge difference (upgrade only)
621621
</li>
622622
<li>
623-
<code>WITHOUT_PRORATION</code> - Immediate change, no
624-
credit
623+
<code>3 (WITHOUT_PRORATION)</code> - Immediate change, no
624+
proration
625625
</li>
626626
<li>
627-
<code>DEFERRED</code> - Change at next billing cycle
627+
<code>5 (CHARGE_FULL_PRICE)</code> - Immediate change, charge full price
628+
</li>
629+
<li>
630+
<code>6 (DEFERRED)</code> - Change at next billing cycle
628631
</li>
629632
</ul>
633+
634+
<p>
635+
<strong>Note:</strong> If you don't specify a replacement mode, the system uses
636+
the default configured in your Google Play Console subscription settings.
637+
</p>
630638
</Accordion>
631639
</section>
632640

@@ -694,7 +702,7 @@ if (currentSub) {
694702
await requestPurchase({
695703
sku: 'premium_monthly',
696704
purchaseTokenAndroid: currentSub.purchaseToken,
697-
prorationModeAndroid: 'WITH_TIME_PRORATION',
705+
replacementModeAndroid: 1, // WITH_TIME_PRORATION
698706
});
699707
700708
console.log('✅ Upgrade initiated');
@@ -714,7 +722,7 @@ if (currentSub) {
714722

715723
<ol>
716724
<li>
717-
<strong>Use DEFERRED replacement mode</strong>
725+
<strong>Use DEFERRED replacement mode (value: 6)</strong>
718726
</li>
719727
<li>No immediate charge to the user</li>
720728
<li>User keeps premium access until current period ends</li>
@@ -726,6 +734,40 @@ if (currentSub) {
726734
until the end of their paid period.
727735
</p>
728736

737+
<Accordion
738+
title={<>⚠️ Important: DEFERRED Mode Behavior</>}
739+
variant="warning"
740+
>
741+
<p>
742+
<strong>
743+
When using DEFERRED replacement mode (6), the purchase callback
744+
completes successfully with an empty purchase list.
745+
</strong>{' '}
746+
This is expected behavior, not an error:
747+
</p>
748+
749+
<ul>
750+
<li>
751+
The subscription change request succeeds immediately (status: OK)
752+
</li>
753+
<li>
754+
But <code>onPurchaseUpdated</code> receives an empty/null purchases list
755+
</li>
756+
<li>
757+
The actual subscription change won't take effect until the next renewal period
758+
</li>
759+
<li>
760+
Your app should treat this as a successful operation, not an error
761+
</li>
762+
</ul>
763+
764+
<p>
765+
<strong>Why this happens:</strong> Since the subscription change is deferred to the future,
766+
Google Play Billing doesn't create a new purchase transaction immediately. The change will
767+
be reflected when the subscription renews.
768+
</p>
769+
</Accordion>
770+
729771
<Accordion
730772
title={<>📝 Code Example: Downgrading Subscription</>}
731773
>
@@ -741,10 +783,11 @@ if (premiumPurchase) {
741783
await requestPurchase({
742784
sku: 'basic_monthly',
743785
purchaseTokenAndroid: premiumPurchase.purchaseToken,
744-
prorationModeAndroid: 'DEFERRED', // Change at renewal
786+
replacementModeAndroid: 6, // DEFERRED - Change at renewal
745787
});
746788
747789
console.log('✅ Downgrade scheduled for next billing cycle');
790+
// Note: Purchase callback will complete with empty list - this is expected!
748791
}`}</CodeBlock>
749792
</Accordion>
750793
</section>
@@ -820,21 +863,25 @@ for (const purchase of purchases) {
820863

821864
<ol>
822865
<li>
823-
<strong>Always specify the replacement mode</strong> when
824-
calling <code>requestPurchase</code> with an existing
825-
subscription
866+
<strong>Specify replacement mode when needed</strong>: Pass{' '}
867+
<code>replacementModeAndroid</code> when you want to override
868+
the default configured in Google Play Console
826869
</li>
827870
<li>
828-
<strong>Use WITH_TIME_PRORATION for upgrades</strong> to
871+
<strong>Use WITH_TIME_PRORATION (1) for upgrades</strong> to
829872
give users credit for unused time
830873
</li>
831874
<li>
832-
<strong>Use DEFERRED for downgrades</strong> to let users
875+
<strong>Use DEFERRED (6) for downgrades</strong> to let users
833876
keep premium features until period ends
834877
</li>
878+
<li>
879+
<strong>Handle DEFERRED mode correctly</strong>: When using
880+
DEFERRED, expect an empty purchase list - this is success, not an error
881+
</li>
835882
<li>
836883
<strong>Track pending changes in your backend</strong> since
837-
Android doesn't expose this in the API
884+
Android doesn't expose deferred changes in the API
838885
</li>
839886
<li>
840887
<strong>Implement RTDN webhooks</strong> to receive
@@ -868,14 +915,14 @@ async function changeSubscription(
868915
869916
// Choose appropriate replacement mode
870917
const replacementMode = isUpgrade
871-
? 'WITH_TIME_PRORATION' // Upgrade: give credit
872-
: 'DEFERRED'; // Downgrade: change at renewal
918+
? 1 // WITH_TIME_PRORATION - Upgrade: give credit
919+
: 6; // DEFERRED - Downgrade: change at renewal
873920
874921
try {
875922
await requestPurchase({
876923
sku: newSku,
877924
purchaseTokenAndroid: currentSub.purchaseToken,
878-
prorationModeAndroid: replacementMode,
925+
replacementModeAndroid: replacementMode,
879926
});
880927
881928
// If DEFERRED, store pending change in your backend

packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,16 @@ fun SubscriptionFlowScreen(
219219
val statusMessage = status.lastPurchaseResult
220220

221221
// Auto-refresh purchases after successful purchase (like React Native implementation)
222-
LaunchedEffect(statusMessage) {
222+
LaunchedEffect(statusMessage?.productId, statusMessage?.status) {
223223
if (statusMessage?.status == PurchaseResultStatus.Success) {
224224
println("SubscriptionFlow: Purchase success detected, refreshing purchases...")
225225
println("SubscriptionFlow: Success message productId: ${statusMessage.productId}")
226226
delay(1000) // Wait 1 second for server to process
227-
iapStore.getAvailablePurchases(null)
227+
try {
228+
iapStore.getAvailablePurchases(null)
229+
} catch (e: Exception) {
230+
println("SubscriptionFlow: Error refreshing purchases: ${e.message}")
231+
}
228232
}
229233
}
230234

packages/google/openiap/build.gradle.kts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,18 @@ android {
6363
buildConfig = true
6464
}
6565

66-
// Source sets are automatically configured per flavor
67-
// play/ and horizon/ directories are used by their respective flavors
66+
// Explicit source set configuration for shared code
67+
sourceSets {
68+
named("main") {
69+
java.srcDirs("src/main/java")
70+
}
71+
named("play") {
72+
java.srcDirs("src/play/java")
73+
}
74+
named("horizon") {
75+
java.srcDirs("src/horizon/java")
76+
}
77+
}
6878
}
6979

7080
dependencies {

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

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -721,11 +721,14 @@ class OpenIapModule(
721721
)
722722
}
723723

724-
if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
725-
android.util.Log.i("HORIZON_CALLBACK", "Processing ${purchases.size} purchases")
726-
OpenIapLog.i("Processing ${purchases.size} successful purchases", TAG)
724+
if (result.responseCode == BillingClient.BillingResponseCode.OK) {
725+
// When using DEFERRED replacement mode, purchases will be null
726+
// This is expected behavior - the change will take effect at next renewal
727+
if (purchases != null) {
728+
android.util.Log.i("HORIZON_CALLBACK", "Processing ${purchases.size} purchases")
729+
OpenIapLog.i("Processing ${purchases.size} successful purchases", TAG)
727730

728-
val mapped = purchases.map { purchase ->
731+
val mapped = purchases.map { purchase ->
729732
// CRITICAL FIX: Determine product type from ProductManager cache, not from product ID string
730733
val firstProductId = purchase.products?.firstOrNull()
731734
// Try both types since we don't know which one was used
@@ -741,40 +744,45 @@ class OpenIapModule(
741744
BillingClient.ProductType.INAPP
742745
}
743746
}
744-
android.util.Log.i("HORIZON_CALLBACK", "Mapping purchase products=${purchase.products} to type=$type (cached=${cachedProduct != null})")
745-
OpenIapLog.d("Mapped purchase productIds=${purchase.products} to type=$type (from cache: ${cachedProduct != null})", TAG)
747+
OpenIapLog.d("Mapping purchase products=${purchase.products} to type=$type (cached=${cachedProduct != null})", TAG)
746748

747749
val converted = purchase.toPurchase()
748-
android.util.Log.i("HORIZON_CALLBACK", "Converted purchase: productId=${converted.productId}, acknowledged=${purchase.isAcknowledged()}")
750+
OpenIapLog.d("Converted purchase: productId=${converted.productId}, acknowledged=${purchase.isAcknowledged()}", TAG)
749751
converted
750752
}
751753

752-
android.util.Log.i("HORIZON_CALLBACK", "Mapped ${mapped.size} purchases, notifying ${purchaseUpdateListeners.size} listeners")
753-
OpenIapLog.i("Notifying ${purchaseUpdateListeners.size} purchase update listeners", TAG)
754+
OpenIapLog.i("Mapped ${mapped.size} purchases, notifying ${purchaseUpdateListeners.size} listeners", TAG)
754755

755756
mapped.forEach { converted ->
756757
// CRITICAL FIX: Cache the purchase locally
757758
sharedPurchaseCache[converted.productId] = converted
758-
android.util.Log.i("HORIZON_CALLBACK", "Cached purchase: productId=${converted.productId}, cache size=${sharedPurchaseCache.size}")
759-
760-
android.util.Log.i("HORIZON_CALLBACK", "Notifying about purchase: productId=${converted.productId}")
761-
OpenIapLog.d("Notifying listeners about purchase: productId=${converted.productId}", TAG)
759+
OpenIapLog.d("Cached purchase: productId=${converted.productId}, cache size=${sharedPurchaseCache.size}", TAG)
760+
OpenIapLog.d("Notifying ${purchaseUpdateListeners.size} listeners about purchase: productId=${converted.productId}", TAG)
762761
purchaseUpdateListeners.forEach { listener ->
763762
runCatching {
764-
android.util.Log.i("HORIZON_CALLBACK", "Calling listener.onPurchaseUpdated")
765763
listener.onPurchaseUpdated(converted)
766-
android.util.Log.i("HORIZON_CALLBACK", "Listener notified successfully")
767764
OpenIapLog.d("Listener notified successfully", TAG)
768765
}.onFailure { e ->
769-
android.util.Log.e("HORIZON_CALLBACK", "Listener notification failed", e)
770766
OpenIapLog.e("Listener notification failed", e, TAG)
771767
}
772768
}
773769
}
774770

775-
android.util.Log.i("HORIZON_CALLBACK", "Invoking currentPurchaseCallback with ${mapped.size} purchases")
776-
currentPurchaseCallback?.invoke(Result.success(mapped))
777-
OpenIapLog.i("Purchase callback invoked with ${mapped.size} purchases", TAG)
771+
OpenIapLog.d("Invoking currentPurchaseCallback with ${mapped.size} purchases (single-shot)", TAG)
772+
currentPurchaseCallback?.let { cb ->
773+
currentPurchaseCallback = null
774+
cb.invoke(Result.success(mapped))
775+
}
776+
OpenIapLog.i("Purchase callback invoked", TAG)
777+
} else {
778+
// Purchases is null - likely DEFERRED mode
779+
android.util.Log.d("HORIZON_CALLBACK", "Purchase successful but purchases list is null (DEFERRED mode)")
780+
OpenIapLog.d("Purchase successful but purchases list is null (DEFERRED mode)", TAG)
781+
currentPurchaseCallback?.let { cb ->
782+
currentPurchaseCallback = null
783+
cb.invoke(Result.success(emptyList()))
784+
}
785+
}
778786
} else {
779787
android.util.Log.w("HORIZON_CALLBACK", "Purchase failed: code=${result.responseCode}")
780788
OpenIapLog.w("Purchase failed or cancelled: code=${result.responseCode}", TAG)

packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -849,18 +849,37 @@ class OpenIapModule(
849849
)
850850
}
851851

852-
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
853-
val mapped = purchases.map { purchase ->
854-
val productType = if (purchase.products.any { it.contains("subs") }) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP
855-
purchase.toPurchase(productType)
856-
}
857-
Log.d(TAG, "Mapped purchases=${gson.toJson(mapped)}")
858-
mapped.forEach { converted ->
859-
purchaseUpdateListeners.forEach { listener ->
860-
runCatching { listener.onPurchaseUpdated(converted) }
852+
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
853+
// When using DEFERRED replacement mode, purchases will be null
854+
// This is expected behavior - the change will take effect at next renewal
855+
if (purchases != null) {
856+
val mapped = purchases.map { purchase ->
857+
// CRITICAL FIX: Use ProductManager cache to determine product type, not substring matching
858+
val firstProductId = purchase.products.firstOrNull()
859+
val cached = firstProductId?.let { productManager.get(it) }
860+
val productType = cached?.productType ?: run {
861+
// Fallback: if not in cache, check if product ID contains "subs"
862+
if (purchase.products.any { it.contains("subs", ignoreCase = true) }) {
863+
BillingClient.ProductType.SUBS
864+
} else {
865+
BillingClient.ProductType.INAPP
866+
}
867+
}
868+
Log.d(TAG, "Mapping purchase products=${purchase.products} to type=$productType (cached=${cached != null})")
869+
purchase.toPurchase(productType)
861870
}
871+
Log.d(TAG, "Mapped purchases=${gson.toJson(mapped)}")
872+
mapped.forEach { converted ->
873+
purchaseUpdateListeners.forEach { listener ->
874+
runCatching { listener.onPurchaseUpdated(converted) }
875+
}
876+
}
877+
currentPurchaseCallback?.invoke(Result.success(mapped))
878+
} else {
879+
// Purchases is null - likely DEFERRED mode
880+
Log.d(TAG, "Purchase successful but purchases list is null (DEFERRED mode)")
881+
currentPurchaseCallback?.invoke(Result.success(emptyList()))
862882
}
863-
currentPurchaseCallback?.invoke(Result.success(mapped))
864883
} else {
865884
when (billingResult.responseCode) {
866885
BillingClient.BillingResponseCode.USER_CANCELED -> {

0 commit comments

Comments
 (0)