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
29 changes: 29 additions & 0 deletions packages/docs/src/components/SearchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
125 changes: 125 additions & 0 deletions packages/docs/src/pages/docs/apis.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,131 @@ if (paymentSuccess) {
}}
</PlatformTabs>
</section>

<section>
<AnchorLink id="debugging-logging" level="h2">
Debugging & Logging
</AnchorLink>
<p>
Enable verbose logging to see internal operations, warnings, and debug
information. This is especially useful during development to diagnose
issues and understand library behavior.
</p>

<AnchorLink id="enable-logging" level="h3">
Enable Logging
</AnchorLink>
<p>
Logging is <strong>disabled by default</strong> in production. Enable
it only during development to see detailed logs.
</p>

<PlatformTabs>
{{
ios: (
<CodeBlock language="swift">{`// Enable logging for debug builds only
#if DEBUG
OpenIapLog.enable(true)
#endif

// Or enable unconditionally
OpenIapLog.enable(true)

// Disable logging
OpenIapLog.enable(false)`}</CodeBlock>
),
android: (
<CodeBlock language="kotlin">{`// Enable logging for debug builds only
if (BuildConfig.DEBUG) {
OpenIapLog.enable(true)
}

// Or enable unconditionally
OpenIapLog.enable(true)

// Disable logging
OpenIapLog.enable(false)`}</CodeBlock>
),
}}
</PlatformTabs>

<AnchorLink id="common-warnings" level="h3">
Common Warnings
</AnchorLink>
<p>
When logging is enabled, you may see warnings about specific
scenarios:
</p>

<h4>Multiple Subscription Offers</h4>
<blockquote className="info-note">
<p>
<strong>Warning:</strong>{' '}
<code>
Multiple offers (3) found for premium_subscription, using first
basePlanId (may be inaccurate)
</code>
</p>
</blockquote>
<p>
This warning appears when a subscription product has multiple offers
(e.g., monthly, annual, promotional). Due to Google Play Billing
Library limitations, the <code>Purchase</code> object doesn't expose
which specific offer was purchased. The library uses the first offer's{' '}
<code>basePlanId</code> as a best-effort approach.
</p>

<p>
<strong>Impact:</strong> The <code>currentPlanId</code> field in{' '}
<code>PurchaseAndroid</code> and <code>basePlanIdAndroid</code> in{' '}
<code>ActiveSubscription</code> may be inaccurate if users purchase
different offers.
</p>

<p>
<strong>Solutions:</strong>
</p>
<ul>
<li>
<strong>Backend Validation</strong> (Recommended): Use Google Play
Developer API's{' '}
<code>purchases.subscriptionsv2:get</code> endpoint with the{' '}
<code>purchaseToken</code> to get accurate{' '}
<code>basePlanId</code> and <code>offerId</code>
</li>
<li>
<strong>Single Offer</strong>: Design your subscription products
with a single offer per product (most common approach)
</li>
<li>
<strong>Offer Tags</strong>: Use offer tags in Google Play Console
to help identify offers, though this doesn't solve the client-side
tracking issue
</li>
</ul>

<CodeBlock language="kotlin">{`// 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"
// }
// }]
// }`}</CodeBlock>

<blockquote className="info-note">
<p>
<strong>Note:</strong> This limitation only affects products with
multiple offers. If your subscription has a single offer (the most
common case), the <code>basePlanId</code> will always be accurate.
</p>
</blockquote>
</section>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
dataAndroid = originalJson,
developerPayloadAndroid = developerPayload,
id = orderId ?: token,
Expand All @@ -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
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand Down Expand Up @@ -841,11 +856,11 @@ class OpenIapModule(
}

override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List<BillingPurchase>?) {
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
)
}

Expand All @@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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) }
Expand All @@ -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 {
Expand Down Expand Up @@ -1049,7 +1076,7 @@ class OpenIapModule(
}

override fun onBillingServiceDisconnected() {
Log.i(TAG, "Billing service disconnected")
OpenIapLog.i("Billing service disconnected", TAG)
}
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
Expand Down
Loading