Skip to content
This repository was archived by the owner on Oct 17, 2025. It is now read-only.

Commit c0d224a

Browse files
authored
feat: alternative billing (#14)
## Description Followed by the request in hyochan/react-native-iap#2803, add support for alternative billing. ## Reference - [Learn about Google Play & alternative billing systems](https://support.google.com/googleplay/answer/11174377) - [Alternative Billing APIs](https://developer.android.com/google/play/billing/alternative)
1 parent c48b65a commit c0d224a

File tree

13 files changed

+1645
-35
lines changed

13 files changed

+1645
-35
lines changed

ALTERNATIVE_BILLING.md

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
# Alternative Billing Implementation Guide
2+
3+
This document explains how to implement Alternative Billing Only mode in your Android app using OpenIAP.
4+
5+
## Overview
6+
7+
Alternative Billing Only allows you to use your own payment system instead of Google Play billing, while still distributing your app through Google Play Store.
8+
9+
## Requirements
10+
11+
- ✅ Google Play Console enrollment in Alternative Billing program
12+
- ✅ Google approval (can take several weeks)
13+
- ✅ Billing Library 6.2+ (this library uses 8.0.0)
14+
- ✅ Country/region eligibility
15+
- ✅ Backend server for reporting transactions to Google Play
16+
17+
## Quick Start
18+
19+
### 1. Initialize OpenIapStore with Alternative Billing Mode
20+
21+
```kotlin
22+
val iapStore = OpenIapStore(
23+
context = applicationContext,
24+
alternativeBillingMode = AlternativeBillingMode.ALTERNATIVE_ONLY
25+
)
26+
```
27+
28+
### 2. Implementation (Step-by-Step Only)
29+
30+
⚠️ **CRITICAL**: You **MUST** use the step-by-step approach below.
31+
32+
**DO NOT use `requestPurchase()` for production** - it creates the token BEFORE payment, which means:
33+
- User hasn't paid yet
34+
- Reporting to Google would be fraud
35+
- Your app will be banned
36+
37+
The step-by-step approach is the **ONLY correct way**:
38+
39+
#### Using OpenIapStore (Recommended)
40+
41+
```kotlin
42+
val iapStore = OpenIapStore(context, AlternativeBillingMode.ALTERNATIVE_ONLY)
43+
44+
// Step 1: Check availability
45+
val isAvailable = iapStore.checkAlternativeBillingAvailability()
46+
if (!isAvailable) {
47+
// Handle unavailable case
48+
return
49+
}
50+
51+
// Step 2: Show information dialog
52+
val dialogAccepted = iapStore.showAlternativeBillingInformationDialog(activity)
53+
if (!dialogAccepted) {
54+
// User canceled
55+
return
56+
}
57+
58+
// Step 3: Process payment in YOUR payment system
59+
// ⚠️ Note: onPurchaseUpdated will NOT be called - handle success/failure here
60+
val paymentResult = YourPaymentSystem.processPayment(
61+
productId = productId,
62+
amount = product.price,
63+
userId = currentUserId,
64+
onSuccess = { transactionId ->
65+
// Step 4: Create token AFTER successful payment
66+
lifecycleScope.launch {
67+
val token = iapStore.createAlternativeBillingReportingToken()
68+
if (token != null) {
69+
// Step 5: Send token to your backend
70+
YourBackendApi.reportTransaction(
71+
externalTransactionToken = token,
72+
productId = productId,
73+
userId = currentUserId,
74+
transactionId = transactionId
75+
)
76+
77+
// Update your UI - purchase complete!
78+
showSuccessMessage("Purchase successful")
79+
} else {
80+
showErrorMessage("Failed to create reporting token")
81+
}
82+
}
83+
},
84+
onFailure = { error ->
85+
// Handle payment failure in your UI
86+
showErrorMessage("Payment failed: ${error.message}")
87+
}
88+
)
89+
```
90+
91+
#### Using OpenIapModule Directly (Advanced)
92+
93+
If you're not using OpenIapStore wrapper, you can call OpenIapModule methods directly:
94+
95+
```kotlin
96+
val openIapModule = OpenIapModule(
97+
context = context,
98+
alternativeBillingMode = AlternativeBillingMode.ALTERNATIVE_ONLY
99+
)
100+
101+
// Initialize connection first
102+
val connected = openIapModule.initConnection()
103+
if (!connected) {
104+
// Handle connection failure
105+
return
106+
}
107+
108+
// Step 1: Check availability
109+
val isAvailable = openIapModule.checkAlternativeBillingAvailability()
110+
if (!isAvailable) {
111+
// Handle unavailable case
112+
return
113+
}
114+
115+
// Step 2: Show information dialog
116+
openIapModule.setActivity(activity)
117+
val dialogAccepted = openIapModule.showAlternativeBillingInformationDialog(activity)
118+
if (!dialogAccepted) {
119+
// User canceled
120+
return
121+
}
122+
123+
// Step 3: Process payment in YOUR payment system
124+
// ⚠️ Note: onPurchaseUpdated will NOT be called - handle success/failure here
125+
val paymentResult = YourPaymentSystem.processPayment(
126+
productId = productId,
127+
amount = product.price,
128+
userId = currentUserId,
129+
onSuccess = { transactionId ->
130+
// Step 4: Create token AFTER successful payment
131+
lifecycleScope.launch {
132+
val token = openIapModule.createAlternativeBillingReportingToken()
133+
if (token != null) {
134+
// Step 5: Send token to your backend
135+
YourBackendApi.reportTransaction(
136+
externalTransactionToken = token,
137+
productId = productId,
138+
userId = currentUserId,
139+
transactionId = transactionId
140+
)
141+
142+
// Update your UI - purchase complete!
143+
showSuccessMessage("Purchase successful")
144+
} else {
145+
showErrorMessage("Failed to create reporting token")
146+
}
147+
}
148+
},
149+
onFailure = { error ->
150+
// Handle payment failure in your UI
151+
showErrorMessage("Payment failed: ${error.message}")
152+
}
153+
)
154+
```
155+
156+
### Why This Order Matters
157+
158+
```kotlin
159+
// ❌ WRONG (what requestPurchase does - DO NOT USE)
160+
1. Check availability ✓
161+
2. Show dialog ✓
162+
3. Create token ✓ ← Token created WITHOUT payment!
163+
4. [No payment] ← User never paid anything
164+
5. Report to GoogleThis is FRAUD - claiming user paid when they didn't
165+
166+
// ✅ CORRECT (step-by-step - MUST USE)
167+
1. checkAvailability()
168+
2. showDialog()
169+
3. YOUR_PAYMENT.charge($9.99) ← User ACTUALLY pays here
170+
4. createToken() ← Token created AFTER successful payment
171+
5. backend.reportToGoogle() ← Report REAL transaction to Google
172+
```
173+
174+
The token is **proof of payment**. Creating it before payment is like writing a receipt before the customer pays - it's fraud.
175+
176+
## Backend Implementation
177+
178+
### Important: No `onPurchaseUpdated` Callback
179+
180+
⚠️ **Alternative Billing does NOT trigger `onPurchaseUpdated` or `onPurchaseError` callbacks.**
181+
182+
Why? Because you're **not using Google Play billing system** - you're using your own payment system (Stripe, PayPal, Toss, etc.). The callbacks only fire for Google Play transactions.
183+
184+
```kotlin
185+
// ❌ This will NOT work with Alternative Billing
186+
iapStore.addPurchaseUpdateListener { purchase ->
187+
// This is NEVER called in Alternative Billing mode
188+
}
189+
190+
// ✅ Instead, handle payment completion in YOUR payment system
191+
YourPaymentSystem.processPayment(
192+
onSuccess = { transactionId ->
193+
// Your payment succeeded - NOW create token
194+
val token = iapStore.createAlternativeBillingReportingToken()
195+
sendToBackend(token, transactionId)
196+
},
197+
onFailure = { error ->
198+
// Handle payment failure in your UI
199+
}
200+
)
201+
```
202+
203+
### Backend Reporting API
204+
205+
Your backend must report the transaction to Google Play Developer API within **24 hours**:
206+
207+
```http
208+
POST https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/externalTransactions
209+
210+
Authorization: Bearer {oauth_token}
211+
Content-Type: application/json
212+
213+
{
214+
"externalTransactionToken": "token_from_step_4",
215+
"productId": "your_product_id",
216+
"externalTransactionId": "your_transaction_id",
217+
"transactionTime": "2025-10-02T12:00:00Z",
218+
"currentTaxAmount": {
219+
"currencyCode": "USD",
220+
"amountMicros": "1000000" // $1.00
221+
},
222+
"currentPreTaxAmount": {
223+
"currencyCode": "USD",
224+
"amountMicros": "9000000" // $9.00
225+
}
226+
}
227+
```
228+
229+
## API Reference
230+
231+
### OpenIapStore Methods
232+
233+
Available in both `OpenIapStore` and `OpenIapModule`:
234+
235+
#### `suspend fun checkAlternativeBillingAvailability(): Boolean`
236+
- **Purpose**: Check if alternative billing is available for current user/device
237+
- **Returns**: `true` if available, `false` otherwise
238+
- **When to call**: Before starting purchase flow (Step 1)
239+
- **Throws**: `OpenIapError.NotPrepared` if billing client not ready
240+
241+
#### `suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean`
242+
- **Purpose**: Show required information dialog to user
243+
- **Parameters**: `activity` - Current activity context
244+
- **Returns**: `true` if user accepted, `false` if canceled
245+
- **When to call**: BEFORE processing payment (Step 2)
246+
- **Note**: Google requires this dialog to be shown every purchase
247+
- **Throws**: `OpenIapError.NotPrepared` if billing client not ready
248+
249+
#### `suspend fun createAlternativeBillingReportingToken(): String?`
250+
- **Purpose**: Create external transaction token for reporting to Google
251+
- **Returns**: Token string or `null` if failed
252+
- **When to call**: AFTER successful payment in your system (Step 4)
253+
- **Note**: Token must be reported to Google within 24 hours
254+
- **Throws**: `OpenIapError.NotPrepared` if billing client not ready
255+
256+
### OpenIapModule Only Methods
257+
258+
If using `OpenIapModule` directly, you also need:
259+
260+
#### `suspend fun initConnection(): Boolean`
261+
- **Purpose**: Initialize billing client connection
262+
- **Returns**: `true` if connection successful
263+
- **When to call**: Before any billing operations
264+
- **Note**: OpenIapStore handles this automatically
265+
266+
#### `fun setActivity(activity: Activity?)`
267+
- **Purpose**: Set current activity for billing flows
268+
- **Parameters**: `activity` - Current activity or null
269+
- **When to call**: Before showing dialogs or launching billing flows
270+
- **Note**: OpenIapStore handles this automatically
271+
272+
## Example App
273+
274+
See [AlternativeBillingScreen.kt](Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt) for a complete example.
275+
276+
⚠️ **Important**: The example app skips Step 3 (actual payment) because it's a demo. You **MUST** implement Step 3 with your real payment system (Stripe, PayPal, Toss, etc.) before calling `createAlternativeBillingReportingToken()`.
277+
278+
## Testing
279+
280+
1. **Enroll in Play Console**:
281+
- Go to Play Console → Your App → Monetization setup
282+
- Enable "Alternative billing"
283+
- Select eligible countries
284+
285+
2. **Add Test Accounts**:
286+
- Go to Settings → License testing
287+
- Add test account emails
288+
289+
3. **Test Flow**:
290+
- Build signed APK/Bundle
291+
- Upload to Internal Testing track
292+
- Install on device with test account
293+
- Check logs for initialization status
294+
295+
4. **Expected Logs**:
296+
```
297+
✓ Alternative billing only enabled successfully
298+
✓ Alternative billing is available
299+
✓ Dialog shown to user
300+
✓ External transaction token created: eyJhbG...
301+
```
302+
303+
## Common Issues
304+
305+
### "Alternative billing not available"
306+
- **Cause**: App not enrolled, user not in eligible country, or console setup incomplete
307+
- **Fix**: Check Play Console enrollment status and test account country
308+
309+
### "enableAlternativeBillingOnly() method not found"
310+
- **Cause**: Billing Library version < 6.2
311+
- **Fix**: Update to Billing Library 6.2+ (this library uses 8.0.0)
312+
313+
### "Google Play dialog appears instead of alternative billing"
314+
- **Cause**: `enableAlternativeBillingOnly()` not called on BillingClient
315+
- **Fix**: Check initialization logs for "✓ Alternative billing only enabled successfully"
316+
317+
### "`onPurchaseUpdated` is not called after payment"
318+
- **Cause**: This is EXPECTED behavior - Alternative Billing bypasses Google Play callbacks
319+
- **Fix**: Handle payment completion in your payment system's callback (Step 3), not in `onPurchaseUpdated`
320+
321+
## Resources
322+
323+
- [Official Google Documentation](https://developer.android.com/google/play/billing/alternative)
324+
- [Alternative Billing Reporting API](https://developer.android.com/google/play/billing/alternative/reporting)
325+
- [Play Console Help](https://support.google.com/googleplay/android-developer/answer/12419624)

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,10 @@ Welcome! This repository hosts the Android implementation of OpenIAP.
1616
3. Put all reusable Kotlin helpers (e.g., safe map accessors) into the `utils` package so they can be used without modifying generated output.
1717
4. After code generation or dependency changes, compile with `./gradlew :openiap:compileDebugKotlin` (or the appropriate target) to verify the build stays green.
1818

19+
## Updating openiap-gql Version
20+
21+
1. Edit `openiap-versions.json` and update the `gql` field to the desired version
22+
2. Run `./scripts/generate-types.sh` to download and regenerate Types.kt
23+
3. Compile to verify: `./gradlew :openiap:compileDebugKotlin`
24+
1925
Refer back to this document and `CONVENTION.md` whenever you are unsure about workflow expectations.

Example/src/main/java/dev/hyo/martie/MainActivity.kt

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ fun AppNavigation() {
3838
val context = androidx.compose.ui.platform.LocalContext.current
3939
val startRoute = remember {
4040
val route = (context as? android.app.Activity)?.intent?.getStringExtra("openiap_route")
41-
if (route in setOf("home", "purchase_flow", "subscription_flow", "available_purchases", "offer_code")) route!! else "home"
41+
if (route in setOf("home", "purchase_flow", "subscription_flow", "available_purchases", "offer_code", "alternative_billing")) route!! else "home"
4242
}
4343

4444
NavHost(
@@ -48,26 +48,30 @@ fun AppNavigation() {
4848
composable("home") {
4949
HomeScreen(navController)
5050
}
51-
51+
5252
composable("all_products") {
5353
AllProductsScreen(navController)
5454
}
55-
55+
5656
composable("purchase_flow") {
5757
PurchaseFlowScreen(navController)
5858
}
59-
59+
6060
composable("subscription_flow") {
6161
SubscriptionFlowScreen(navController)
6262
}
63-
63+
6464
composable("available_purchases") {
6565
AvailablePurchasesScreen(navController)
6666
}
67-
67+
6868
composable("offer_code") {
6969
OfferCodeScreen(navController)
7070
}
71+
72+
composable("alternative_billing") {
73+
AlternativeBillingScreen(navController)
74+
}
7175
}
7276
}
7377

0 commit comments

Comments
 (0)