Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 65 additions & 18 deletions packages/docs/src/pages/docs/subscription-upgrade-downgrade.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -612,21 +612,29 @@ function SubscriptionStatus() {
</p>
<ul>
<li>
<code>WITH_TIME_PRORATION</code> - Immediate change with
<code>1 (WITH_TIME_PRORATION)</code> - Immediate change with
prorated credit
</li>
<li>
<code>CHARGE_PRORATED_PRICE</code> - Immediate change,
charge difference
<code>2 (CHARGE_PRORATED_PRICE)</code> - Immediate change,
charge difference (upgrade only)
</li>
<li>
<code>WITHOUT_PRORATION</code> - Immediate change, no
credit
<code>3 (WITHOUT_PRORATION)</code> - Immediate change, no
proration
</li>
<li>
<code>DEFERRED</code> - Change at next billing cycle
<code>5 (CHARGE_FULL_PRICE)</code> - Immediate change, charge full price
</li>
<li>
<code>6 (DEFERRED)</code> - Change at next billing cycle
</li>
</ul>

<p>
<strong>Note:</strong> If you don't specify a replacement mode, the system uses
the default configured in your Google Play Console subscription settings.
</p>
</Accordion>
</section>

Expand Down Expand Up @@ -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');
Expand All @@ -714,7 +722,7 @@ if (currentSub) {

<ol>
<li>
<strong>Use DEFERRED replacement mode</strong>
<strong>Use DEFERRED replacement mode (value: 6)</strong>
</li>
<li>No immediate charge to the user</li>
<li>User keeps premium access until current period ends</li>
Expand All @@ -726,6 +734,40 @@ if (currentSub) {
until the end of their paid period.
</p>

<Accordion
title={<>⚠️ Important: DEFERRED Mode Behavior</>}
variant="warning"
>
<p>
<strong>
When using DEFERRED replacement mode (6), the purchase callback
completes successfully with an empty purchase list.
</strong>{' '}
This is expected behavior, not an error:
</p>

<ul>
<li>
The subscription change request succeeds immediately (status: OK)
</li>
<li>
But <code>onPurchaseUpdated</code> receives an empty/null purchases list
</li>
<li>
The actual subscription change won't take effect until the next renewal period
</li>
<li>
Your app should treat this as a successful operation, not an error
</li>
</ul>

<p>
<strong>Why this happens:</strong> 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.
</p>
</Accordion>

<Accordion
title={<>📝 Code Example: Downgrading Subscription</>}
>
Expand All @@ -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!
}`}</CodeBlock>
</Accordion>
</section>
Expand Down Expand Up @@ -820,21 +863,25 @@ for (const purchase of purchases) {

<ol>
<li>
<strong>Always specify the replacement mode</strong> when
calling <code>requestPurchase</code> with an existing
subscription
<strong>Specify replacement mode when needed</strong>: Pass{' '}
<code>replacementModeAndroid</code> when you want to override
the default configured in Google Play Console
</li>
<li>
<strong>Use WITH_TIME_PRORATION for upgrades</strong> to
<strong>Use WITH_TIME_PRORATION (1) for upgrades</strong> to
give users credit for unused time
</li>
<li>
<strong>Use DEFERRED for downgrades</strong> to let users
<strong>Use DEFERRED (6) for downgrades</strong> to let users
keep premium features until period ends
</li>
<li>
<strong>Handle DEFERRED mode correctly</strong>: When using
DEFERRED, expect an empty purchase list - this is success, not an error
</li>
<li>
<strong>Track pending changes in your backend</strong> since
Android doesn't expose this in the API
Android doesn't expose deferred changes in the API
</li>
<li>
<strong>Implement RTDN webhooks</strong> to receive
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
}
}
}

Expand Down
14 changes: 12 additions & 2 deletions packages/google/openiap/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> {
Expand Down
Loading