From 8cfc34c8f8cc3bb8ecd6bf546a6a39bcbc5ba108 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 12 Nov 2025 23:17:48 +0900 Subject: [PATCH 1/4] fix(android): extract basePlanId from ProductDetails for subscriptions Purchase objects don't expose basePlanId directly. Extract it from ProductDetails cache and populate basePlanIdAndroid/currentPlanId fields in getActiveSubscriptions() and getAvailablePurchases(). - Cache basePlanId during purchase flow - Extract from ProductDetails in onPurchasesUpdated - Update both Play and Horizon flavors --- .../hyo/openiap/utils/BillingConverters.kt | 13 +++++-- .../java/dev/hyo/openiap/OpenIapModule.kt | 36 +++++++++++++++++-- .../hyo/openiap/utils/BillingConverters.kt | 6 +++- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt index be9cfb94..f825d110 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/BillingConverters.kt @@ -95,13 +95,14 @@ internal object HorizonBillingConverters { ) } - fun HorizonPurchase.toPurchase(): PurchaseAndroid { + fun HorizonPurchase.toPurchase(basePlanId: String? = null): PurchaseAndroid { val token = purchaseToken val productsList = products ?: emptyList() val state = PurchaseState.fromHorizonState(getPurchaseState()) return PurchaseAndroid( autoRenewingAndroid = isAutoRenewing(), + currentPlanId = basePlanId, dataAndroid = originalJson, developerPayloadAndroid = developerPayload, id = orderId ?: token, @@ -124,20 +125,26 @@ internal object HorizonBillingConverters { fun HorizonPurchase.toActiveSubscription(): ActiveSubscription = ActiveSubscription( autoRenewingAndroid = isAutoRenewing(), + basePlanIdAndroid = null, + currentPlanId = null, isActive = true, productId = products?.firstOrNull().orEmpty(), purchaseToken = purchaseToken, + purchaseTokenAndroid = purchaseToken, transactionDate = (purchaseTime ?: 0L).toDouble(), transactionId = orderId ?: purchaseToken ) fun PurchaseAndroid.toActiveSubscription(): ActiveSubscription = ActiveSubscription( autoRenewingAndroid = autoRenewingAndroid, + basePlanIdAndroid = currentPlanId, + currentPlanId = currentPlanId, isActive = true, productId = productId, - purchaseToken = purchaseToken.orEmpty(), + purchaseToken = purchaseToken, + purchaseTokenAndroid = purchaseToken, transactionDate = transactionDate, - transactionId = transactionId.orEmpty() + transactionId = id ) } 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 e3a3406f..b7627400 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 @@ -103,6 +103,9 @@ class OpenIapModule( private val userChoiceBillingListeners = mutableSetOf() private var currentPurchaseCallback: ((Result>) -> Unit)? = null + // Cache for offerToken -> basePlanId mapping to preserve basePlanId info + private val offerTokenToBasePlanIdCache = mutableMapOf() + override val initConnection: MutationInitConnectionHandler = { config -> // Update alternativeBillingMode if provided in config config?.alternativeBillingModeAndroid?.let { modeAndroid -> @@ -213,7 +216,19 @@ class OpenIapModule( } else { androidPurchases.filter { it.productId in ids } } - filtered.map { it.toActiveSubscription() } + + // Enrich purchases with basePlanId from ProductDetails cache + filtered.map { purchase -> + val productDetails = productManager.get(purchase.productId) + val basePlanId = productDetails?.subscriptionOfferDetails?.firstOrNull()?.basePlanId + + // If basePlanId is available and not already set, update the purchase + if (basePlanId != null && purchase.currentPlanId == null) { + purchase.copy(currentPlanId = basePlanId).toActiveSubscription() + } else { + purchase.toActiveSubscription() + } + } } } @@ -546,6 +561,13 @@ class OpenIapModule( return } + // Cache basePlanId for this offerToken + val selectedOffer = productDetails.subscriptionOfferDetails?.find { it.offerToken == resolved } + selectedOffer?.let { offer -> + offerTokenToBasePlanIdCache[resolved] = offer.basePlanId + OpenIapLog.d("Cached basePlanId '${offer.basePlanId}' for offerToken '$resolved'", TAG) + } + builder.setOfferToken(resolved) } @@ -865,8 +887,16 @@ class OpenIapModule( BillingClient.ProductType.INAPP } } - Log.d(TAG, "Mapping purchase products=${purchase.products} to type=$productType (cached=${cached != null})") - purchase.toPurchase(productType) + + // Extract basePlanId from ProductDetails for subscriptions + val basePlanId = if (productType == BillingClient.ProductType.SUBS) { + cached?.subscriptionOfferDetails?.firstOrNull()?.basePlanId + } else { + null + } + + Log.d(TAG, "Mapping purchase products=${purchase.products} to type=$productType basePlanId=$basePlanId (cached=${cached != null})") + purchase.toPurchase(productType, basePlanId) } Log.d(TAG, "Mapped purchases=${gson.toJson(mapped)}") mapped.forEach { converted -> diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt index 1224934e..59e5f600 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/utils/BillingConverters.kt @@ -97,10 +97,11 @@ internal object BillingConverters { ) } - fun BillingPurchase.toPurchase(productType: String): PurchaseAndroid { + fun BillingPurchase.toPurchase(productType: String, basePlanId: String? = null): PurchaseAndroid { val state = PurchaseState.fromBillingState(purchaseState) return PurchaseAndroid( autoRenewingAndroid = isAutoRenewing, + currentPlanId = basePlanId, dataAndroid = originalJson, developerPayloadAndroid = developerPayload, id = orderId ?: purchaseToken, @@ -132,9 +133,12 @@ fun PurchaseState.Companion.fromBillingState(state: Int): PurchaseState = when ( fun PurchaseAndroid.toActiveSubscription(): ActiveSubscription = ActiveSubscription( autoRenewingAndroid = autoRenewingAndroid, + basePlanIdAndroid = currentPlanId, + currentPlanId = currentPlanId, isActive = true, productId = productId, purchaseToken = purchaseToken, + purchaseTokenAndroid = purchaseToken, transactionDate = transactionDate, transactionId = id ) From 6cf1f66f40acb8bec4229e1177b768cba840172a Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 12 Nov 2025 23:29:33 +0900 Subject: [PATCH 2/4] refactor: change Log.d to OpenIapLog.debug --- .../play/java/dev/hyo/openiap/OpenIapModule.kt | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) 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 b7627400..7d2ccf09 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 @@ -4,7 +4,6 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri -import android.util.Log import com.android.billingclient.api.AcknowledgePurchaseParams import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClientStateListener @@ -863,11 +862,11 @@ class OpenIapModule( } override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List?) { - Log.d(TAG, "onPurchasesUpdated: code=${billingResult.responseCode} msg=${billingResult.debugMessage} count=${purchases?.size ?: 0}") + OpenIapLog.d("onPurchasesUpdated: code=${billingResult.responseCode} msg=${billingResult.debugMessage} count=${purchases?.size ?: 0}", TAG) purchases?.forEachIndexed { index, purchase -> - Log.d( - TAG, - "[Purchase $index] token=${purchase.purchaseToken} orderId=${purchase.orderId} state=${purchase.purchaseState} autoRenew=${purchase.isAutoRenewing} acknowledged=${purchase.isAcknowledged} products=${purchase.products}" + OpenIapLog.d( + "[Purchase $index] token=${purchase.purchaseToken} orderId=${purchase.orderId} state=${purchase.purchaseState} autoRenew=${purchase.isAutoRenewing} acknowledged=${purchase.isAcknowledged} products=${purchase.products}", + TAG ) } @@ -895,10 +894,10 @@ class OpenIapModule( null } - Log.d(TAG, "Mapping purchase products=${purchase.products} to type=$productType basePlanId=$basePlanId (cached=${cached != null})") + OpenIapLog.d("Mapping purchase products=${purchase.products} to type=$productType basePlanId=$basePlanId (cached=${cached != null})", TAG) purchase.toPurchase(productType, basePlanId) } - Log.d(TAG, "Mapped purchases=${gson.toJson(mapped)}") + OpenIapLog.d("Mapped purchases=${gson.toJson(mapped)}", TAG) mapped.forEach { converted -> purchaseUpdateListeners.forEach { listener -> runCatching { listener.onPurchaseUpdated(converted) } @@ -907,7 +906,7 @@ class OpenIapModule( currentPurchaseCallback?.invoke(Result.success(mapped)) } else { // Purchases is null - likely DEFERRED mode - Log.d(TAG, "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 { @@ -1079,7 +1078,7 @@ class OpenIapModule( } override fun onBillingServiceDisconnected() { - Log.i(TAG, "Billing service disconnected") + OpenIapLog.i("Billing service disconnected", TAG) } }) } From 7a06188a025f3f5152a516ab333c41ce79e25bd2 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 12 Nov 2025 23:50:13 +0900 Subject: [PATCH 3/4] fix(android): improve basePlanId extraction --- .../java/dev/hyo/openiap/OpenIapModule.kt | 16 ++++++++++++-- .../java/dev/hyo/openiap/OpenIapModule.kt | 22 +++++++++---------- 2 files changed, 24 insertions(+), 14 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 c64b7533..ef178ca7 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 @@ -713,9 +713,21 @@ class OpenIapModule( BillingClient.ProductType.INAPP } } - OpenIapLog.d("Mapping purchase products=${purchase.products} to type=$type (cached=${cachedProduct != null})", TAG) - val converted = purchase.toPurchase() + // Extract basePlanId from ProductDetails for subscriptions + val basePlanId = if (type == BillingClient.ProductType.SUBS) { + val offers = cachedProduct?.subscriptionOfferDetails.orEmpty() + if (offers.size > 1) { + OpenIapLog.w("Multiple offers (${offers.size}) found for ${firstProductId}, using first basePlanId (may be inaccurate)", TAG) + } + offers.firstOrNull()?.basePlanId + } else { + null + } + + OpenIapLog.d("Mapping purchase products=${purchase.products} to type=$type basePlanId=$basePlanId (cached=${cachedProduct != null})", TAG) + + val converted = purchase.toPurchase(basePlanId) OpenIapLog.d("Converted purchase: productId=${converted.productId}, acknowledged=${purchase.isAcknowledged()}", TAG) converted } 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 7d2ccf09..9860ea6c 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 @@ -102,9 +102,6 @@ class OpenIapModule( private val userChoiceBillingListeners = mutableSetOf() private var currentPurchaseCallback: ((Result>) -> Unit)? = null - // Cache for offerToken -> basePlanId mapping to preserve basePlanId info - private val offerTokenToBasePlanIdCache = mutableMapOf() - override val initConnection: MutationInitConnectionHandler = { config -> // Update alternativeBillingMode if provided in config config?.alternativeBillingModeAndroid?.let { modeAndroid -> @@ -219,7 +216,11 @@ class OpenIapModule( // Enrich purchases with basePlanId from ProductDetails cache filtered.map { purchase -> val productDetails = productManager.get(purchase.productId) - val basePlanId = productDetails?.subscriptionOfferDetails?.firstOrNull()?.basePlanId + val offers = productDetails?.subscriptionOfferDetails.orEmpty() + if (offers.size > 1) { + OpenIapLog.w("Multiple offers (${offers.size}) found for ${purchase.productId}, using first basePlanId (may be inaccurate)", TAG) + } + val basePlanId = offers.firstOrNull()?.basePlanId // If basePlanId is available and not already set, update the purchase if (basePlanId != null && purchase.currentPlanId == null) { @@ -560,13 +561,6 @@ class OpenIapModule( return } - // Cache basePlanId for this offerToken - val selectedOffer = productDetails.subscriptionOfferDetails?.find { it.offerToken == resolved } - selectedOffer?.let { offer -> - offerTokenToBasePlanIdCache[resolved] = offer.basePlanId - OpenIapLog.d("Cached basePlanId '${offer.basePlanId}' for offerToken '$resolved'", TAG) - } - builder.setOfferToken(resolved) } @@ -889,7 +883,11 @@ class OpenIapModule( // Extract basePlanId from ProductDetails for subscriptions val basePlanId = if (productType == BillingClient.ProductType.SUBS) { - cached?.subscriptionOfferDetails?.firstOrNull()?.basePlanId + val offers = cached?.subscriptionOfferDetails.orEmpty() + if (offers.size > 1) { + OpenIapLog.w("Multiple offers (${offers.size}) found for ${firstProductId}, using first basePlanId (may be inaccurate)", TAG) + } + offers.firstOrNull()?.basePlanId } else { null } From 7d0e464646c268f8db339b1f9b05aa997785bd09 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 12 Nov 2025 23:50:17 +0900 Subject: [PATCH 4/4] docs: add debugging and logging documentation --- packages/docs/src/components/SearchModal.tsx | 29 +++++ packages/docs/src/pages/docs/apis.tsx | 125 +++++++++++++++++++ 2 files changed, 154 insertions(+) diff --git a/packages/docs/src/components/SearchModal.tsx b/packages/docs/src/components/SearchModal.tsx index 7abd4f9f..acb20b31 100644 --- a/packages/docs/src/components/SearchModal.tsx +++ b/packages/docs/src/components/SearchModal.tsx @@ -406,6 +406,35 @@ const apiData: ApiItem[] = [ description: 'Error codes and error handling', path: '/docs/errors', }, + + // Debugging & Logging + { + id: 'debugging-logging', + title: 'Debugging & Logging', + category: 'Debugging', + description: 'Enable verbose logging for development', + parameters: '', + returns: '', + path: '/docs/apis#debugging-logging', + }, + { + id: 'enable-logging', + title: 'Enable Logging', + category: 'Debugging', + description: 'Enable or disable debug logs', + parameters: 'Boolean', + returns: '', + path: '/docs/apis#enable-logging', + }, + { + id: 'multiple-offers-warning', + title: 'Multiple Subscription Offers', + category: 'Debugging', + description: 'Understanding basePlanId limitations with multiple offers', + parameters: '', + returns: '', + path: '/docs/apis#common-warnings', + }, ]; function SearchModal({ isOpen, onClose }: SearchModalProps) { diff --git a/packages/docs/src/pages/docs/apis.tsx b/packages/docs/src/pages/docs/apis.tsx index 34728e13..09ef5414 100644 --- a/packages/docs/src/pages/docs/apis.tsx +++ b/packages/docs/src/pages/docs/apis.tsx @@ -1189,6 +1189,131 @@ if (paymentSuccess) { }} + +
+ + Debugging & Logging + +

+ Enable verbose logging to see internal operations, warnings, and debug + information. This is especially useful during development to diagnose + issues and understand library behavior. +

+ + + Enable Logging + +

+ Logging is disabled by default in production. Enable + it only during development to see detailed logs. +

+ + + {{ + ios: ( + {`// Enable logging for debug builds only +#if DEBUG +OpenIapLog.enable(true) +#endif + +// Or enable unconditionally +OpenIapLog.enable(true) + +// Disable logging +OpenIapLog.enable(false)`} + ), + android: ( + {`// Enable logging for debug builds only +if (BuildConfig.DEBUG) { + OpenIapLog.enable(true) +} + +// Or enable unconditionally +OpenIapLog.enable(true) + +// Disable logging +OpenIapLog.enable(false)`} + ), + }} + + + + Common Warnings + +

+ When logging is enabled, you may see warnings about specific + scenarios: +

+ +

Multiple Subscription Offers

+
+

+ Warning:{' '} + + Multiple offers (3) found for premium_subscription, using first + basePlanId (may be inaccurate) + +

+
+

+ This warning appears when a subscription product has multiple offers + (e.g., monthly, annual, promotional). Due to Google Play Billing + Library limitations, the Purchase object doesn't expose + which specific offer was purchased. The library uses the first offer's{' '} + basePlanId as a best-effort approach. +

+ +

+ Impact: The currentPlanId field in{' '} + PurchaseAndroid and basePlanIdAndroid in{' '} + ActiveSubscription may be inaccurate if users purchase + different offers. +

+ +

+ Solutions: +

+
    +
  • + Backend Validation (Recommended): Use Google Play + Developer API's{' '} + purchases.subscriptionsv2:get endpoint with the{' '} + purchaseToken to get accurate{' '} + basePlanId and offerId +
  • +
  • + Single Offer: Design your subscription products + with a single offer per product (most common approach) +
  • +
  • + Offer Tags: Use offer tags in Google Play Console + to help identify offers, though this doesn't solve the client-side + tracking issue +
  • +
+ + {`// Example: Backend validation to get accurate basePlanId +// GET https://androidpublisher.googleapis.com/androidpublisher/v3/ +// applications/{packageName}/purchases/subscriptionsv2/tokens/{token} +// +// Response includes: +// { +// "lineItems": [{ +// "offerDetails": { +// "basePlanId": "premium-annual", // Accurate! +// "offerId": "intro-offer" +// } +// }] +// }`} + +
+

+ Note: This limitation only affects products with + multiple offers. If your subscription has a single offer (the most + common case), the basePlanId will always be accurate. +

+
+
); }