Skip to content

Commit ba89dd5

Browse files
committed
docs: add Android basePlanId limitation warning
Add warning about basePlanId limitation in subscription-related docs: - subscription-offers.md: Warning tip linking to limitation section - subscription-validation.md: Full documentation with solutions Solutions include client-side tracking, IAPKit backend validation, and single base plan per subscription group. Ref: hyochan/react-native-iap#3096 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent a7452c5 commit ba89dd5

File tree

6 files changed

+308
-0
lines changed

6 files changed

+308
-0
lines changed

docs/docs/guides/subscription-offers.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,12 @@ interface ProductSubscriptionAndroidOfferDetails {
128128
}
129129
```
130130

131+
:::warning Android basePlanId Limitation
132+
The `basePlanId` is available when fetching products, but not when retrieving purchases via `getAvailablePurchases()`. This is a limitation of Google Play Billing Library - the purchase token alone doesn't reveal which base plan was purchased.
133+
134+
See [GitHub Issue #3096](https://github.com/hyochan/react-native-iap/issues/3096) for more details. See the [basePlanId Limitation](./subscription-validation.md#android-baseplanid-limitation) section for details and workarounds.
135+
:::
136+
131137
### iOS Subscription Offers
132138

133139
iOS handles subscription offers differently - the base plan is used by default, and promotional offers are optional.

docs/docs/guides/subscription-validation.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,104 @@ For each purchase you can inspect fields such as:
6363

6464
StoreKit does **not** bake "current phase" indicators into these records—`offerIOS.paymentMode` tells you which introductory offer was used initially, but does not tell you whether the user is still inside that offer window. To answer questions like "is the user still in a free trial?" you need either the StoreKit status API or server-side purchase verification.
6565

66+
#### Android `basePlanId` Limitation
67+
68+
:::warning Critical Limitation
69+
On Android, the `currentPlanId` and `basePlanIdAndroid` fields may return **incorrect values** for subscription groups with multiple base plans.
70+
:::
71+
72+
**Root Cause:** Google Play Billing API's `Purchase` object does NOT include `basePlanId` information. When a subscription group has multiple base plans (weekly, monthly, yearly), there is no way to determine which specific plan was purchased from the client-side `Purchase` object.
73+
74+
You may see this warning in logs:
75+
76+
```
77+
Multiple offers (3) found for premium_subscription, using first basePlanId (may be inaccurate)
78+
```
79+
80+
**What Works Correctly:**
81+
- `productId` — Subscription group ID
82+
- `purchaseToken` — Purchase token
83+
- `isActive` — Subscription active status
84+
- `transactionId` — Transaction ID
85+
86+
**What May Be Incorrect:**
87+
- `currentPlanId` / `basePlanIdAndroid` — May return first plan instead of purchased plan
88+
89+
##### Solutions
90+
91+
**1. Client-side Tracking (Recommended for most apps)**
92+
93+
Track `basePlanId` yourself during the purchase flow:
94+
95+
```ts
96+
// Track basePlanId BEFORE calling requestPurchase
97+
let purchasedBasePlanId: string | null = null;
98+
99+
const handlePurchase = async (basePlanId: string) => {
100+
const offers = product.subscriptionOfferDetailsAndroid ?? [];
101+
const offer = offers.find(o => o.basePlanId === basePlanId && !o.offerId);
102+
103+
// Store it before purchase
104+
purchasedBasePlanId = basePlanId;
105+
106+
await requestPurchase({
107+
request: {
108+
google: {
109+
skus: [subscriptionGroupId],
110+
subscriptionOffers: [
111+
{ sku: subscriptionGroupId, offerToken: offer.offerToken },
112+
],
113+
},
114+
},
115+
type: 'subs',
116+
});
117+
};
118+
119+
// Use YOUR tracked value in onPurchaseSuccess
120+
onPurchaseSuccess: async (purchase) => {
121+
// DON'T rely on purchase.currentPlanId - it may be wrong!
122+
const actualBasePlanId = purchasedBasePlanId;
123+
124+
await saveToBackend({
125+
purchaseToken: purchase.purchaseToken,
126+
basePlanId: actualBasePlanId, // Use YOUR tracked value
127+
productId: purchase.productId,
128+
});
129+
}
130+
```
131+
132+
**2. <a href="https://iapkit.com" target="_blank" rel="noopener noreferrer" onClick={() => fetch('https://www.hyo.dev/api/ad-banner', {method: 'POST'}).catch(() => {})}>IAPKit</a> Backend Validation (Recommended)**
133+
134+
Use [`verifyPurchaseWithProvider`](../api/methods/unified-apis.md#verifypurchasewithprovider) with <a href="https://iapkit.com" target="_blank" rel="noopener noreferrer" onClick={() => fetch('https://www.hyo.dev/api/ad-banner', {method: 'POST'}).catch(() => {})}>IAPKit</a> to get accurate `basePlanId` from Google Play Developer API:
135+
136+
```ts
137+
import {verifyPurchaseWithProvider} from 'expo-iap';
138+
139+
const result = await verifyPurchaseWithProvider({
140+
provider: 'iapkit',
141+
iapkit: {
142+
apiKey: 'your-iapkit-api-key',
143+
google: { purchaseToken: purchase.purchaseToken },
144+
},
145+
});
146+
147+
// Access basePlanId from the response
148+
const basePlanId = result.iapkit?.google?.lineItems?.[0]?.offerDetails?.basePlanId;
149+
console.log('Actual basePlanId:', basePlanId);
150+
```
151+
152+
**3. Single Base Plan Per Subscription Group**
153+
154+
If your subscription group has only one base plan, the `basePlanId` will always be accurate. This is the simplest solution if your product design allows it.
155+
156+
:::note
157+
This is a fundamental limitation of Google Play Billing API, not a bug in this library. The `Purchase` object from Google simply does not include `basePlanId` information.
158+
:::
159+
160+
**See also:**
161+
- [SubscriptionOfferDetailsAndroid](https://www.openiap.dev/docs/types#subscriptionofferdetailsandroid) — Each offer contains `basePlanId`, `offerId`, `offerTags`, `offerToken`, and `pricingPhases`.
162+
- [GitHub Issue #3096](https://github.com/hyochan/react-native-iap/issues/3096) — Original discussion about this limitation.
163+
66164
## Using `getActiveSubscriptions`
67165

68166
[`getActiveSubscriptions`](../api/methods/core-methods.md#getactivesubscriptions) is a thin helper that filters `getAvailablePurchases` down to subscription products. It returns an array of `ActiveSubscription` objects with convenience fields:

docs/versioned_docs/version-3.1/guides/subscription-offers.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,12 @@ interface ProductSubscriptionAndroidOfferDetails {
128128
}
129129
```
130130

131+
:::warning Android basePlanId Limitation
132+
The `basePlanId` is available when fetching products, but not when retrieving purchases via `getAvailablePurchases()`. This is a limitation of Google Play Billing Library - the purchase token alone doesn't reveal which base plan was purchased.
133+
134+
See [GitHub Issue #3096](https://github.com/hyochan/react-native-iap/issues/3096) for more details. See the [basePlanId Limitation](./subscription-validation.md#android-baseplanid-limitation) section for details and workarounds.
135+
:::
136+
131137
### iOS Subscription Offers
132138

133139
iOS handles subscription offers differently - the base plan is used by default, and promotional offers are optional.

docs/versioned_docs/version-3.1/guides/subscription-validation.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,102 @@ For each purchase you can inspect fields such as:
6363

6464
StoreKit does **not** bake "current phase" indicators into these records—`offerIOS.paymentMode` tells you which introductory offer was used initially, but does not tell you whether the user is still inside that offer window. To answer questions like "is the user still in a free trial?" you need either the StoreKit status API or server-side purchase verification.
6565

66+
#### Android `basePlanId` Limitation
67+
68+
:::warning Critical Limitation
69+
On Android, the `currentPlanId` and `basePlanIdAndroid` fields may return **incorrect values** for subscription groups with multiple base plans.
70+
:::
71+
72+
**Root Cause:** Google Play Billing API's `Purchase` object does NOT include `basePlanId` information. When a subscription group has multiple base plans (weekly, monthly, yearly), there is no way to determine which specific plan was purchased from the client-side `Purchase` object.
73+
74+
You may see this warning in logs:
75+
76+
```
77+
Multiple offers (3) found for premium_subscription, using first basePlanId (may be inaccurate)
78+
```
79+
80+
**What Works Correctly:**
81+
- `productId` — Subscription group ID
82+
- `purchaseToken` — Purchase token
83+
- `isActive` — Subscription active status
84+
- `transactionId` — Transaction ID
85+
86+
**What May Be Incorrect:**
87+
- `currentPlanId` / `basePlanIdAndroid` — May return first plan instead of purchased plan
88+
89+
##### Solutions
90+
91+
**1. Client-side Tracking (Recommended for most apps)**
92+
93+
Track `basePlanId` yourself during the purchase flow:
94+
95+
```ts
96+
// Track basePlanId BEFORE calling requestPurchase
97+
let purchasedBasePlanId: string | null = null;
98+
99+
const handlePurchase = async (basePlanId: string) => {
100+
const offers = product.subscriptionOfferDetailsAndroid ?? [];
101+
const offer = offers.find(o => o.basePlanId === basePlanId && !o.offerId);
102+
103+
// Store it before purchase
104+
purchasedBasePlanId = basePlanId;
105+
106+
await requestPurchase({
107+
request: {
108+
google: {
109+
skus: [subscriptionGroupId],
110+
subscriptionOffers: [
111+
{ sku: subscriptionGroupId, offerToken: offer.offerToken },
112+
],
113+
},
114+
},
115+
type: 'subs',
116+
});
117+
};
118+
119+
// Use YOUR tracked value in onPurchaseSuccess
120+
onPurchaseSuccess: async (purchase) => {
121+
// DON'T rely on purchase.currentPlanId - it may be wrong!
122+
const actualBasePlanId = purchasedBasePlanId;
123+
124+
await saveToBackend({
125+
purchaseToken: purchase.purchaseToken,
126+
basePlanId: actualBasePlanId, // Use YOUR tracked value
127+
productId: purchase.productId,
128+
});
129+
}
130+
```
131+
132+
**2. <a href="https://iapkit.com" target="_blank" rel="noopener noreferrer" onClick={() => fetch('https://www.hyo.dev/api/ad-banner', {method: 'POST'}).catch(() => {})}>IAPKit</a> Backend Validation (Recommended)**
133+
134+
Use [`verifyPurchaseWithProvider`](../api/methods/unified-apis.md#verifypurchasewithprovider) with <a href="https://iapkit.com" target="_blank" rel="noopener noreferrer" onClick={() => fetch('https://www.hyo.dev/api/ad-banner', {method: 'POST'}).catch(() => {})}>IAPKit</a> to get accurate `basePlanId` from Google Play Developer API:
135+
136+
```ts
137+
import {verifyPurchaseWithProvider} from 'expo-iap';
138+
139+
const result = await verifyPurchaseWithProvider({
140+
provider: 'iapkit',
141+
iapkit: {
142+
apiKey: 'your-iapkit-api-key',
143+
google: { purchaseToken: purchase.purchaseToken },
144+
},
145+
});
146+
147+
// Access basePlanId from the response
148+
const basePlanId = result.iapkit?.google?.lineItems?.[0]?.offerDetails?.basePlanId;
149+
console.log('Actual basePlanId:', basePlanId);
150+
```
151+
152+
**3. Single Base Plan Per Subscription Group**
153+
154+
If your subscription group has only one base plan, the `basePlanId` will always be accurate. This is the simplest solution if your product design allows it.
155+
156+
:::note
157+
This is a fundamental limitation of Google Play Billing API, not a bug in this library. The `Purchase` object from Google simply does not include `basePlanId` information.
158+
:::
159+
160+
**See also:** [SubscriptionOfferDetailsAndroid](https://www.openiap.dev/docs/types#subscriptionofferdetailsandroid) — Each offer contains `basePlanId`, `offerId`, `offerTags`, `offerToken`, and `pricingPhases`.
161+
66162
## Using `getActiveSubscriptions`
67163

68164
[`getActiveSubscriptions`](../api/methods/core-methods.md#getactivesubscriptions) is a thin helper that filters `getAvailablePurchases` down to subscription products. It returns an array of `ActiveSubscription` objects with convenience fields:

docs/versioned_docs/version-3.2/guides/subscription-offers.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,12 @@ interface ProductSubscriptionAndroidOfferDetails {
128128
}
129129
```
130130

131+
:::warning Android basePlanId Limitation
132+
The `basePlanId` is available when fetching products, but not when retrieving purchases via `getAvailablePurchases()`. This is a limitation of Google Play Billing Library - the purchase token alone doesn't reveal which base plan was purchased.
133+
134+
See [GitHub Issue #3096](https://github.com/hyochan/react-native-iap/issues/3096) for more details. See the [basePlanId Limitation](./subscription-validation.md#android-baseplanid-limitation) section for details and workarounds.
135+
:::
136+
131137
### iOS Subscription Offers
132138

133139
iOS handles subscription offers differently - the base plan is used by default, and promotional offers are optional.

docs/versioned_docs/version-3.2/guides/subscription-validation.md

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,102 @@ For each purchase you can inspect fields such as:
6363

6464
StoreKit does **not** bake "current phase" indicators into these records—`offerIOS.paymentMode` tells you which introductory offer was used initially, but does not tell you whether the user is still inside that offer window. To answer questions like "is the user still in a free trial?" you need either the StoreKit status API or server-side purchase verification.
6565

66+
#### Android `basePlanId` Limitation
67+
68+
:::warning Critical Limitation
69+
On Android, the `currentPlanId` and `basePlanIdAndroid` fields may return **incorrect values** for subscription groups with multiple base plans.
70+
:::
71+
72+
**Root Cause:** Google Play Billing API's `Purchase` object does NOT include `basePlanId` information. When a subscription group has multiple base plans (weekly, monthly, yearly), there is no way to determine which specific plan was purchased from the client-side `Purchase` object.
73+
74+
You may see this warning in logs:
75+
76+
```
77+
Multiple offers (3) found for premium_subscription, using first basePlanId (may be inaccurate)
78+
```
79+
80+
**What Works Correctly:**
81+
- `productId` — Subscription group ID
82+
- `purchaseToken` — Purchase token
83+
- `isActive` — Subscription active status
84+
- `transactionId` — Transaction ID
85+
86+
**What May Be Incorrect:**
87+
- `currentPlanId` / `basePlanIdAndroid` — May return first plan instead of purchased plan
88+
89+
##### Solutions
90+
91+
**1. Client-side Tracking (Recommended for most apps)**
92+
93+
Track `basePlanId` yourself during the purchase flow:
94+
95+
```ts
96+
// Track basePlanId BEFORE calling requestPurchase
97+
let purchasedBasePlanId: string | null = null;
98+
99+
const handlePurchase = async (basePlanId: string) => {
100+
const offers = product.subscriptionOfferDetailsAndroid ?? [];
101+
const offer = offers.find(o => o.basePlanId === basePlanId && !o.offerId);
102+
103+
// Store it before purchase
104+
purchasedBasePlanId = basePlanId;
105+
106+
await requestPurchase({
107+
request: {
108+
google: {
109+
skus: [subscriptionGroupId],
110+
subscriptionOffers: [
111+
{ sku: subscriptionGroupId, offerToken: offer.offerToken },
112+
],
113+
},
114+
},
115+
type: 'subs',
116+
});
117+
};
118+
119+
// Use YOUR tracked value in onPurchaseSuccess
120+
onPurchaseSuccess: async (purchase) => {
121+
// DON'T rely on purchase.currentPlanId - it may be wrong!
122+
const actualBasePlanId = purchasedBasePlanId;
123+
124+
await saveToBackend({
125+
purchaseToken: purchase.purchaseToken,
126+
basePlanId: actualBasePlanId, // Use YOUR tracked value
127+
productId: purchase.productId,
128+
});
129+
}
130+
```
131+
132+
**2. <a href="https://iapkit.com" target="_blank" rel="noopener noreferrer" onClick={() => fetch('https://www.hyo.dev/api/ad-banner', {method: 'POST'}).catch(() => {})}>IAPKit</a> Backend Validation (Recommended)**
133+
134+
Use [`verifyPurchaseWithProvider`](../api/methods/unified-apis.md#verifypurchasewithprovider) with <a href="https://iapkit.com" target="_blank" rel="noopener noreferrer" onClick={() => fetch('https://www.hyo.dev/api/ad-banner', {method: 'POST'}).catch(() => {})}>IAPKit</a> to get accurate `basePlanId` from Google Play Developer API:
135+
136+
```ts
137+
import {verifyPurchaseWithProvider} from 'expo-iap';
138+
139+
const result = await verifyPurchaseWithProvider({
140+
provider: 'iapkit',
141+
iapkit: {
142+
apiKey: 'your-iapkit-api-key',
143+
google: { purchaseToken: purchase.purchaseToken },
144+
},
145+
});
146+
147+
// Access basePlanId from the response
148+
const basePlanId = result.iapkit?.google?.lineItems?.[0]?.offerDetails?.basePlanId;
149+
console.log('Actual basePlanId:', basePlanId);
150+
```
151+
152+
**3. Single Base Plan Per Subscription Group**
153+
154+
If your subscription group has only one base plan, the `basePlanId` will always be accurate. This is the simplest solution if your product design allows it.
155+
156+
:::note
157+
This is a fundamental limitation of Google Play Billing API, not a bug in this library. The `Purchase` object from Google simply does not include `basePlanId` information.
158+
:::
159+
160+
**See also:** [SubscriptionOfferDetailsAndroid](https://www.openiap.dev/docs/types#subscriptionofferdetailsandroid) — Each offer contains `basePlanId`, `offerId`, `offerTags`, `offerToken`, and `pricingPhases`.
161+
66162
## Using `getActiveSubscriptions`
67163

68164
[`getActiveSubscriptions`](../api/methods/core-methods.md#getactivesubscriptions) is a thin helper that filters `getAvailablePurchases` down to subscription products. It returns an array of `ActiveSubscription` objects with convenience fields:

0 commit comments

Comments
 (0)