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() {

+ +

+ 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) {
  1. - Use DEFERRED replacement mode + Use DEFERRED replacement mode (value: 6)
  2. No immediate charge to the user
  3. User keeps premium access until current period ends
  4. @@ -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: +

    + + + +

    + 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) {
    1. - 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
    2. - Use WITH_TIME_PRORATION for upgrades to + Use WITH_TIME_PRORATION (1) for upgrades to give users credit for unused time
    3. - Use DEFERRED for downgrades to let users + Use DEFERRED (6) for downgrades to let users keep premium features until period ends
    4. +
    5. + Handle DEFERRED mode correctly: When using + DEFERRED, expect an empty purchase list - this is success, not an error +
    6. Track pending changes in your backend since - Android doesn't expose this in the API + Android doesn't expose deferred changes in the API
    7. 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 -> {