From ade707b3414dbe619607f95770c62c7440985366 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Fri, 24 Oct 2025 02:34:09 +0900
Subject: [PATCH 1/3] fix(google): handle DEFERRED replacement mode correctly
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
---
.../docs/subscription-upgrade-downgrade.tsx | 83 +++++++++++++++----
.../martie/screens/SubscriptionFlowScreen.kt | 8 +-
.../java/dev/hyo/openiap/OpenIapModule.kt | 23 +++--
.../java/dev/hyo/openiap/OpenIapModule.kt | 28 ++++---
4 files changed, 105 insertions(+), 37 deletions(-)
diff --git a/packages/docs/src/pages/docs/subscription-upgrade-downgrade.tsx b/packages/docs/src/pages/docs/subscription-upgrade-downgrade.tsx
index 404f4a3d..e4a01200 100644
--- a/packages/docs/src/pages/docs/subscription-upgrade-downgrade.tsx
+++ b/packages/docs/src/pages/docs/subscription-upgrade-downgrade.tsx
@@ -612,21 +612,29 @@ function SubscriptionStatus() {
-
-
WITH_TIME_PRORATION - Immediate change with
+ 1 (WITH_TIME_PRORATION) - Immediate change with
prorated credit
-
-
CHARGE_PRORATED_PRICE - Immediate change,
- charge difference
+ 2 (CHARGE_PRORATED_PRICE) - Immediate change,
+ charge difference (upgrade only)
-
-
WITHOUT_PRORATION - Immediate change, no
- credit
+ 3 (WITHOUT_PRORATION) - Immediate change, no
+ proration
-
-
DEFERRED - Change at next billing cycle
+ 5 (CHARGE_FULL_PRICE) - Immediate change, charge full price
+
+ -
+
6 (DEFERRED) - Change at next billing cycle
+
+
+ Note: If you don't specify a replacement mode, the system uses
+ the default configured in your Google Play Console subscription settings.
+
@@ -694,7 +702,7 @@ if (currentSub) {
await requestPurchase({
sku: 'premium_monthly',
purchaseTokenAndroid: currentSub.purchaseToken,
- prorationModeAndroid: 'WITH_TIME_PRORATION',
+ replacementModeAndroid: 1, // WITH_TIME_PRORATION
});
console.log('✅ Upgrade initiated');
@@ -714,7 +722,7 @@ if (currentSub) {
-
- Use DEFERRED replacement mode
+ Use DEFERRED replacement mode (value: 6)
- No immediate charge to the user
- User keeps premium access until current period ends
@@ -726,6 +734,40 @@ if (currentSub) {
until the end of their paid period.
+ ⚠️ Important: DEFERRED Mode Behavior>}
+ variant="warning"
+ >
+
+
+ When using DEFERRED replacement mode (6), the purchase callback
+ completes successfully with an empty purchase list.
+ {' '}
+ This is expected behavior, not an error:
+
+
+
+ -
+ The subscription change request succeeds immediately (status: OK)
+
+ -
+ But
onPurchaseUpdated receives an empty/null purchases list
+
+ -
+ The actual subscription change won't take effect until the next renewal period
+
+ -
+ Your app should treat this as a successful operation, not an error
+
+
+
+
+ Why this happens: Since the subscription change is deferred to the future,
+ Google Play Billing doesn't create a new purchase transaction immediately. The change will
+ be reflected when the subscription renews.
+
+
+
📝 Code Example: Downgrading Subscription>}
>
@@ -741,10 +783,11 @@ if (premiumPurchase) {
await requestPurchase({
sku: 'basic_monthly',
purchaseTokenAndroid: premiumPurchase.purchaseToken,
- prorationModeAndroid: 'DEFERRED', // Change at renewal
+ replacementModeAndroid: 6, // DEFERRED - Change at renewal
});
console.log('✅ Downgrade scheduled for next billing cycle');
+ // Note: Purchase callback will complete with empty list - this is expected!
}`}
@@ -820,21 +863,25 @@ for (const purchase of purchases) {
-
- Always specify the replacement mode when
- calling
requestPurchase with an existing
- subscription
+ Specify replacement mode when needed: Pass{' '}
+ replacementModeAndroid when you want to override
+ the default configured in Google Play Console
-
- Use WITH_TIME_PRORATION for upgrades to
+ Use WITH_TIME_PRORATION (1) for upgrades to
give users credit for unused time
-
- Use DEFERRED for downgrades to let users
+ Use DEFERRED (6) for downgrades to let users
keep premium features until period ends
+ -
+ Handle DEFERRED mode correctly: When using
+ DEFERRED, expect an empty purchase list - this is success, not an error
+
-
Track pending changes in your backend since
- Android doesn't expose this in the API
+ Android doesn't expose deferred changes in the API
-
Implement RTDN webhooks to receive
@@ -868,14 +915,14 @@ async function changeSubscription(
// Choose appropriate replacement mode
const replacementMode = isUpgrade
- ? 'WITH_TIME_PRORATION' // Upgrade: give credit
- : 'DEFERRED'; // Downgrade: change at renewal
+ ? 1 // WITH_TIME_PRORATION - Upgrade: give credit
+ : 6; // DEFERRED - Downgrade: change at renewal
try {
await requestPurchase({
sku: newSku,
purchaseTokenAndroid: currentSub.purchaseToken,
- prorationModeAndroid: replacementMode,
+ replacementModeAndroid: replacementMode,
});
// If DEFERRED, store pending change in your backend
diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt
index b6000208..b752b993 100644
--- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt
+++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt
@@ -219,12 +219,16 @@ fun SubscriptionFlowScreen(
val statusMessage = status.lastPurchaseResult
// Auto-refresh purchases after successful purchase (like React Native implementation)
- LaunchedEffect(statusMessage) {
+ LaunchedEffect(statusMessage?.productId, statusMessage?.status) {
if (statusMessage?.status == PurchaseResultStatus.Success) {
println("SubscriptionFlow: Purchase success detected, refreshing purchases...")
println("SubscriptionFlow: Success message productId: ${statusMessage.productId}")
delay(1000) // Wait 1 second for server to process
- iapStore.getAvailablePurchases(null)
+ try {
+ iapStore.getAvailablePurchases(null)
+ } catch (e: Exception) {
+ println("SubscriptionFlow: Error refreshing purchases: ${e.message}")
+ }
}
}
diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
index 34f6d261..9ab0a12c 100644
--- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
@@ -721,11 +721,14 @@ class OpenIapModule(
)
}
- if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
- android.util.Log.i("HORIZON_CALLBACK", "Processing ${purchases.size} purchases")
- OpenIapLog.i("Processing ${purchases.size} successful purchases", TAG)
+ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
+ // When using DEFERRED replacement mode, purchases will be null
+ // This is expected behavior - the change will take effect at next renewal
+ if (purchases != null) {
+ android.util.Log.i("HORIZON_CALLBACK", "Processing ${purchases.size} purchases")
+ OpenIapLog.i("Processing ${purchases.size} successful purchases", TAG)
- val mapped = purchases.map { purchase ->
+ val mapped = purchases.map { purchase ->
// CRITICAL FIX: Determine product type from ProductManager cache, not from product ID string
val firstProductId = purchase.products?.firstOrNull()
// Try both types since we don't know which one was used
@@ -772,9 +775,15 @@ class OpenIapModule(
}
}
- android.util.Log.i("HORIZON_CALLBACK", "Invoking currentPurchaseCallback with ${mapped.size} purchases")
- currentPurchaseCallback?.invoke(Result.success(mapped))
- OpenIapLog.i("Purchase callback invoked with ${mapped.size} purchases", TAG)
+ android.util.Log.i("HORIZON_CALLBACK", "Invoking currentPurchaseCallback with ${mapped.size} purchases")
+ currentPurchaseCallback?.invoke(Result.success(mapped))
+ OpenIapLog.i("Purchase callback invoked with ${mapped.size} purchases", TAG)
+ } else {
+ // Purchases is null - likely DEFERRED mode
+ android.util.Log.d("HORIZON_CALLBACK", "Purchase successful but purchases list is null (DEFERRED mode)")
+ OpenIapLog.d("Purchase successful but purchases list is null (DEFERRED mode)", TAG)
+ currentPurchaseCallback?.invoke(Result.success(emptyList()))
+ }
} else {
android.util.Log.w("HORIZON_CALLBACK", "Purchase failed: code=${result.responseCode}")
OpenIapLog.w("Purchase failed or cancelled: code=${result.responseCode}", TAG)
diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
index 78d56232..40bdf057 100644
--- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
@@ -849,18 +849,26 @@ class OpenIapModule(
)
}
- if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) {
- val mapped = purchases.map { purchase ->
- val productType = if (purchase.products.any { it.contains("subs") }) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP
- purchase.toPurchase(productType)
- }
- Log.d(TAG, "Mapped purchases=${gson.toJson(mapped)}")
- mapped.forEach { converted ->
- purchaseUpdateListeners.forEach { listener ->
- runCatching { listener.onPurchaseUpdated(converted) }
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
+ // When using DEFERRED replacement mode, purchases will be null
+ // This is expected behavior - the change will take effect at next renewal
+ if (purchases != null) {
+ val mapped = purchases.map { purchase ->
+ val productType = if (purchase.products.any { it.contains("subs") }) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP
+ purchase.toPurchase(productType)
+ }
+ Log.d(TAG, "Mapped purchases=${gson.toJson(mapped)}")
+ mapped.forEach { converted ->
+ purchaseUpdateListeners.forEach { listener ->
+ runCatching { listener.onPurchaseUpdated(converted) }
+ }
}
+ currentPurchaseCallback?.invoke(Result.success(mapped))
+ } else {
+ // Purchases is null - likely DEFERRED mode
+ Log.d(TAG, "Purchase successful but purchases list is null (DEFERRED mode)")
+ currentPurchaseCallback?.invoke(Result.success(emptyList()))
}
- currentPurchaseCallback?.invoke(Result.success(mapped))
} else {
when (billingResult.responseCode) {
BillingClient.BillingResponseCode.USER_CANCELED -> {
From a50b6e54479a2c9e9fadba0c326d868767fa176d Mon Sep 17 00:00:00 2001
From: Hyo
Date: Fri, 24 Oct 2025 02:48:19 +0900
Subject: [PATCH 2/3] fix: code review
---
.../java/dev/hyo/openiap/OpenIapModule.kt | 35 +++++++++----------
.../java/dev/hyo/openiap/OpenIapModule.kt | 13 ++++++-
2 files changed, 29 insertions(+), 19 deletions(-)
diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
index 9ab0a12c..1870d63a 100644
--- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
@@ -744,45 +744,44 @@ class OpenIapModule(
BillingClient.ProductType.INAPP
}
}
- android.util.Log.i("HORIZON_CALLBACK", "Mapping purchase products=${purchase.products} to type=$type (cached=${cachedProduct != null})")
- OpenIapLog.d("Mapped purchase productIds=${purchase.products} to type=$type (from cache: ${cachedProduct != null})", TAG)
+ OpnIapLog.d("Mapping purchase products=${purchase.products} to type=$type (cached=${cachedProduct != null})", TAG)
val converted = purchase.toPurchase()
- android.util.Log.i("HORIZON_CALLBACK", "Converted purchase: productId=${converted.productId}, acknowledged=${purchase.isAcknowledged()}")
+ OpnIapLog.d("Converted purchase: productId=${converted.productId}, acknowledged=${purchase.isAcknowledged()}", TAG)
converted
}
- android.util.Log.i("HORIZON_CALLBACK", "Mapped ${mapped.size} purchases, notifying ${purchaseUpdateListeners.size} listeners")
- OpenIapLog.i("Notifying ${purchaseUpdateListeners.size} purchase update listeners", TAG)
+ OpnIapLog.i("Mapped ${mapped.size} purchases, notifying ${purchaseUpdateListeners.size} listeners", TAG)
mapped.forEach { converted ->
// CRITICAL FIX: Cache the purchase locally
sharedPurchaseCache[converted.productId] = converted
- android.util.Log.i("HORIZON_CALLBACK", "Cached purchase: productId=${converted.productId}, cache size=${sharedPurchaseCache.size}")
-
- android.util.Log.i("HORIZON_CALLBACK", "Notifying about purchase: productId=${converted.productId}")
- OpenIapLog.d("Notifying listeners about purchase: productId=${converted.productId}", TAG)
+ OpnIapLog.d("Cached purchase: productId=${converted.productId}, cache size=${sharedPurchaseCache.size}", TAG)
+ OpnIapLog.d("Notifying ${purchaseUpdateListeners.size} listeners about purchase: productId=${converted.productId}", TAG)
purchaseUpdateListeners.forEach { listener ->
runCatching {
- android.util.Log.i("HORIZON_CALLBACK", "Calling listener.onPurchaseUpdated")
listener.onPurchaseUpdated(converted)
- android.util.Log.i("HORIZON_CALLBACK", "Listener notified successfully")
- OpenIapLog.d("Listener notified successfully", TAG)
+ OpnIapLog.d("Listener notified successfully", TAG)
}.onFailure { e ->
- android.util.Log.e("HORIZON_CALLBACK", "Listener notification failed", e)
- OpenIapLog.e("Listener notification failed", e, TAG)
+ OpnIapLog.e("Listener notification failed", e, TAG)
}
}
}
- android.util.Log.i("HORIZON_CALLBACK", "Invoking currentPurchaseCallback with ${mapped.size} purchases")
- currentPurchaseCallback?.invoke(Result.success(mapped))
- OpenIapLog.i("Purchase callback invoked with ${mapped.size} purchases", TAG)
+ OpnIapLog.d("Invoking currentPurchaseCallback with ${mapped.size} purchases (single-shot)", TAG)
+ currentPurchaseCallback?.let { cb ->
+ currentPurchaseCallback = null
+ cb.invoke(Result.success(mapped))
+ }
+ OpenIapLog.i("Purchase callback invoked", TAG)
} else {
// Purchases is null - likely DEFERRED mode
android.util.Log.d("HORIZON_CALLBACK", "Purchase successful but purchases list is null (DEFERRED mode)")
OpenIapLog.d("Purchase successful but purchases list is null (DEFERRED mode)", TAG)
- currentPurchaseCallback?.invoke(Result.success(emptyList()))
+ currentPurchaseCallback?.let { cb ->
+ currentPurchaseCallback = null
+ cb.invoke(Result.success(emptyList()))
+ }
}
} else {
android.util.Log.w("HORIZON_CALLBACK", "Purchase failed: code=${result.responseCode}")
diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
index 40bdf057..e3a3406f 100644
--- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt
@@ -854,7 +854,18 @@ class OpenIapModule(
// This is expected behavior - the change will take effect at next renewal
if (purchases != null) {
val mapped = purchases.map { purchase ->
- val productType = if (purchase.products.any { it.contains("subs") }) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP
+ // CRITICAL FIX: Use ProductManager cache to determine product type, not substring matching
+ val firstProductId = purchase.products.firstOrNull()
+ val cached = firstProductId?.let { productManager.get(it) }
+ val productType = cached?.productType ?: run {
+ // Fallback: if not in cache, check if product ID contains "subs"
+ if (purchase.products.any { it.contains("subs", ignoreCase = true) }) {
+ BillingClient.ProductType.SUBS
+ } else {
+ BillingClient.ProductType.INAPP
+ }
+ }
+ Log.d(TAG, "Mapping purchase products=${purchase.products} to type=$productType (cached=${cached != null})")
purchase.toPurchase(productType)
}
Log.d(TAG, "Mapped purchases=${gson.toJson(mapped)}")
From 3835600665e22e0c6047313562601d2aa0034db9 Mon Sep 17 00:00:00 2001
From: Hyo
Date: Fri, 24 Oct 2025 02:56:38 +0900
Subject: [PATCH 3/3] chore: share openiap main sourceset
---
packages/google/openiap/build.gradle.kts | 14 ++++++++++++--
.../java/dev/hyo/openiap/OpenIapModule.kt | 16 ++++++++--------
2 files changed, 20 insertions(+), 10 deletions(-)
diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts
index 7baf35a7..d8f32140 100644
--- a/packages/google/openiap/build.gradle.kts
+++ b/packages/google/openiap/build.gradle.kts
@@ -63,8 +63,18 @@ android {
buildConfig = true
}
- // Source sets are automatically configured per flavor
- // play/ and horizon/ directories are used by their respective flavors
+ // Explicit source set configuration for shared code
+ sourceSets {
+ named("main") {
+ java.srcDirs("src/main/java")
+ }
+ named("play") {
+ java.srcDirs("src/play/java")
+ }
+ named("horizon") {
+ java.srcDirs("src/horizon/java")
+ }
+ }
}
dependencies {
diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
index 1870d63a..00c87eb1 100644
--- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
+++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt
@@ -744,31 +744,31 @@ class OpenIapModule(
BillingClient.ProductType.INAPP
}
}
- OpnIapLog.d("Mapping purchase products=${purchase.products} to type=$type (cached=${cachedProduct != null})", TAG)
+ OpenIapLog.d("Mapping purchase products=${purchase.products} to type=$type (cached=${cachedProduct != null})", TAG)
val converted = purchase.toPurchase()
- OpnIapLog.d("Converted purchase: productId=${converted.productId}, acknowledged=${purchase.isAcknowledged()}", TAG)
+ OpenIapLog.d("Converted purchase: productId=${converted.productId}, acknowledged=${purchase.isAcknowledged()}", TAG)
converted
}
- OpnIapLog.i("Mapped ${mapped.size} purchases, notifying ${purchaseUpdateListeners.size} listeners", TAG)
+ OpenIapLog.i("Mapped ${mapped.size} purchases, notifying ${purchaseUpdateListeners.size} listeners", TAG)
mapped.forEach { converted ->
// CRITICAL FIX: Cache the purchase locally
sharedPurchaseCache[converted.productId] = converted
- OpnIapLog.d("Cached purchase: productId=${converted.productId}, cache size=${sharedPurchaseCache.size}", TAG)
- OpnIapLog.d("Notifying ${purchaseUpdateListeners.size} listeners about purchase: productId=${converted.productId}", TAG)
+ OpenIapLog.d("Cached purchase: productId=${converted.productId}, cache size=${sharedPurchaseCache.size}", TAG)
+ OpenIapLog.d("Notifying ${purchaseUpdateListeners.size} listeners about purchase: productId=${converted.productId}", TAG)
purchaseUpdateListeners.forEach { listener ->
runCatching {
listener.onPurchaseUpdated(converted)
- OpnIapLog.d("Listener notified successfully", TAG)
+ OpenIapLog.d("Listener notified successfully", TAG)
}.onFailure { e ->
- OpnIapLog.e("Listener notification failed", e, TAG)
+ OpenIapLog.e("Listener notification failed", e, TAG)
}
}
}
- OpnIapLog.d("Invoking currentPurchaseCallback with ${mapped.size} purchases (single-shot)", TAG)
+ OpenIapLog.d("Invoking currentPurchaseCallback with ${mapped.size} purchases (single-shot)", TAG)
currentPurchaseCallback?.let { cb ->
currentPurchaseCallback = null
cb.invoke(Result.success(mapped))