|
| 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 Google ← This 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) |
0 commit comments