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: +

+ + + {`// 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. +

+
+
); } 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/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..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 @@ -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 @@ -213,7 +212,23 @@ 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 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) { + purchase.copy(currentPlanId = basePlanId).toActiveSubscription() + } else { + purchase.toActiveSubscription() + } + } } } @@ -841,11 +856,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 ) } @@ -865,10 +880,22 @@ 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) { + 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 + } + + 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) } @@ -877,7 +904,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 { @@ -1049,7 +1076,7 @@ class OpenIapModule( } override fun onBillingServiceDisconnected() { - Log.i(TAG, "Billing service disconnected") + OpenIapLog.i("Billing service disconnected", TAG) } }) } 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 )