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/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 34f6d261..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
@@ -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
@@ -741,40 +744,45 @@ 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)
+ OpenIapLog.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()}")
+ OpenIapLog.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)
+ OpenIapLog.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)
+ 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 {
- 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)
}.onFailure { e ->
- android.util.Log.e("HORIZON_CALLBACK", "Listener notification failed", e)
OpenIapLog.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)
+ OpenIapLog.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?.let { cb ->
+ currentPurchaseCallback = null
+ cb.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..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
@@ -849,18 +849,37 @@ 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 ->
+ // 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)}")
+ 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 -> {