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
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,9 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
obfuscatedProfileId: String?,
isOfferPersonalized: Boolean,
subscriptionOffers: List<AndroidSubscriptionOfferInput>,
purchaseTokenAndroid: String?,
replacementModeAndroid: Int?,
purchaseToken: String?,
replacementMode: Int?,
offerToken: String? = null,
developerBillingOption: DeveloperBillingOptionParamsAndroid? = null,
subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid? = null
): RequestPurchaseProps {
Expand All @@ -154,8 +155,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act

return when (type) {
ProductQueryType.Subs -> {
purchaseTokenAndroid?.let { androidPayload[KEY_PURCHASE_TOKEN] = it }
replacementModeAndroid?.let { androidPayload[KEY_REPLACEMENT_MODE] = it }
purchaseToken?.let { androidPayload[KEY_PURCHASE_TOKEN] = it }
replacementMode?.let { androidPayload[KEY_REPLACEMENT_MODE] = it }
subscriptionProductReplacementParams?.let {
androidPayload[KEY_SUBSCRIPTION_PRODUCT_REPLACEMENT_PARAMS] = it.toJson()
}
Expand All @@ -166,6 +167,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
RequestPurchaseProps.fromJson(root)
}
ProductQueryType.InApp -> {
// offerToken for one-time purchase discounts (Android 7.0+)
offerToken?.let { androidPayload[KEY_OFFER_TOKEN] = it }
root[KEY_REQUEST_PURCHASE] = mapOf(KEY_ANDROID to androidPayload)
RequestPurchaseProps.fromJson(root)
}
Expand Down Expand Up @@ -439,12 +442,16 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
?: emptyList()
val skusNormalized = skus.filter { it.isNotBlank() }
val obfuscatedAccountId =
(params["obfuscatedAccountIdAndroid"] ?: params["obfuscatedAccountId"]) as? String
(params["obfuscatedAccountId"] ?: params["obfuscatedAccountIdAndroid"]) as? String
val obfuscatedProfileId =
(params["obfuscatedProfileIdAndroid"] ?: params["obfuscatedProfileId"]) as? String
(params["obfuscatedProfileId"] ?: params["obfuscatedProfileIdAndroid"]) as? String
val isOfferPersonalized = params["isOfferPersonalized"] as? Boolean ?: false
val purchaseTokenAndroid = params["purchaseTokenAndroid"] as? String
val replacementModeAndroid = (params["replacementModeAndroid"] as? Number)?.toInt()
val purchaseTokenAndroid =
(params["purchaseToken"] ?: params["purchaseTokenAndroid"]) as? String
val replacementModeAndroid =
((params["replacementMode"] ?: params["replacementModeAndroid"]) as? Number)?.toInt()
// offerToken for one-time purchase discounts (Android 7.0+)
val offerToken = params["offerToken"] as? String
val useAlternativeBilling = params["useAlternativeBilling"] as? Boolean

// Parse developerBillingOption for External Payments (8.3.0+)
Expand Down Expand Up @@ -548,8 +555,9 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
obfuscatedProfileId = obfuscatedProfileId,
isOfferPersonalized = isOfferPersonalized,
subscriptionOffers = offers,
purchaseTokenAndroid = purchaseTokenAndroid,
replacementModeAndroid = replacementModeAndroid,
purchaseToken = purchaseTokenAndroid,
replacementMode = replacementModeAndroid,
offerToken = offerToken,
developerBillingOption = developerBillingOption,
subscriptionProductReplacementParams = subscriptionProductReplacementParams
)
Expand Down Expand Up @@ -928,8 +936,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
obfuscatedProfileId = obfuscatedProfileId,
isOfferPersonalized = isOfferPersonalized,
subscriptionOffers = emptyList(),
purchaseTokenAndroid = null,
replacementModeAndroid = null
purchaseToken = null,
replacementMode = null
)

iap.requestPurchase(requestProps)
Expand Down Expand Up @@ -1148,6 +1156,10 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
}

val props = dev.hyo.openiap.VerifyPurchaseWithProviderProps.fromJson(propsMap)
if (props == null) {
safe.error(OpenIapError.DeveloperError.CODE, "Invalid props for verifyPurchaseWithProvider", null)
return@withBillingReady
}
val result = iap.verifyPurchaseWithProvider(props)

// Convert result to JSON
Expand Down Expand Up @@ -1284,10 +1296,12 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
private const val KEY_TYPE = "type"
private const val KEY_SKUS = "skus"
private const val KEY_IS_OFFER_PERSONALIZED = "isOfferPersonalized"
private const val KEY_OBFUSCATED_ACCOUNT = "obfuscatedAccountIdAndroid"
private const val KEY_OBFUSCATED_PROFILE = "obfuscatedProfileIdAndroid"
private const val KEY_PURCHASE_TOKEN = "purchaseTokenAndroid"
private const val KEY_REPLACEMENT_MODE = "replacementModeAndroid"
// Input field names use simplified naming (without Android suffix) per OpenIAP 1.3.15+
private const val KEY_OBFUSCATED_ACCOUNT = "obfuscatedAccountId"
private const val KEY_OBFUSCATED_PROFILE = "obfuscatedProfileId"
private const val KEY_PURCHASE_TOKEN = "purchaseToken"
private const val KEY_REPLACEMENT_MODE = "replacementMode"
private const val KEY_OFFER_TOKEN = "offerToken"
private const val KEY_SUBSCRIPTION_OFFERS = "subscriptionOffers"
private const val KEY_DEVELOPER_BILLING_OPTION = "developerBillingOption"
private const val KEY_SUBSCRIPTION_PRODUCT_REPLACEMENT_PARAMS = "subscriptionProductReplacementParams"
Expand Down
135 changes: 135 additions & 0 deletions docs/blog/2026-01-20-8.2.3.release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
---
slug: 8.2.3-release
title: "8.2.3 - One-Time Purchase Discounts and Simplified Field Names"
authors: [hyochan]
tags: [release, android, billing]
---

v8.2.3 syncs with [OpenIAP v1.3.15](https://www.openiap.dev/docs/updates/notes#gql-1-3-15-google-1-3-26-apple-1-3-13) introducing one-time purchase discount support for Android 7.0+ and simplified input field naming conventions.

<!-- truncate -->

## New Features

### One-Time Purchase Discounts (Android 7.0+)

Apply discount offers to one-time (non-subscription) purchases using the new `offerToken` field:

```dart
// Fetch products and get available discount offers
final products = await iap.fetchProducts(
skus: ['premium_unlock'],
type: ProductQueryType.InApp,
);

final product = products.firstWhere((p) => p.id == 'premium_unlock');

// Check for available discount offers
if (product is ProductAndroid) {
final discountOffer = product.oneTimePurchaseOfferDetailsAndroid
?.discountOffers
?.firstOrNull;

if (discountOffer != null) {
// Purchase with discount applied
await iap.requestPurchase(
RequestPurchaseProps.inApp((
apple: RequestPurchaseIosProps(sku: 'premium_unlock'),
google: RequestPurchaseAndroidProps(
skus: ['premium_unlock'],
offerToken: discountOffer.offerToken, // Apply discount
),
useAlternativeBilling: null,
)),
);
}
}
```

### Simplified Input Field Names

Input field names for Android-specific request props have been simplified by removing redundant `Android` suffixes. This follows the OpenIAP naming convention where input types inside platform-specific props don't need platform suffixes (they're already scoped to Android).

**Migration Guide:**

```dart
// Before (8.2.2 and earlier)
RequestPurchaseAndroidProps(
skus: ['sku'],
obfuscatedAccountIdAndroid: 'account-123',
obfuscatedProfileIdAndroid: 'profile-456',
);

RequestSubscriptionAndroidProps(
skus: ['sub'],
purchaseTokenAndroid: 'token',
replacementModeAndroid: 1,
obfuscatedAccountIdAndroid: 'account-123',
obfuscatedProfileIdAndroid: 'profile-456',
);

// After (8.2.3+)
RequestPurchaseAndroidProps(
skus: ['sku'],
obfuscatedAccountId: 'account-123',
obfuscatedProfileId: 'profile-456',
offerToken: 'discount-token', // NEW: for one-time purchase discounts
);

RequestSubscriptionAndroidProps(
skus: ['sub'],
purchaseToken: 'token',
replacementMode: 1,
obfuscatedAccountId: 'account-123',
obfuscatedProfileId: 'profile-456',
);
```

**Builder Updates:**

```dart
// Before
final builder = RequestPurchaseAndroidBuilder()
..skus = ['sku']
..obfuscatedAccountIdAndroid = 'account-123'
..obfuscatedProfileIdAndroid = 'profile-456';

// After
final builder = RequestPurchaseAndroidBuilder()
..skus = ['sku']
..obfuscatedAccountId = 'account-123'
..obfuscatedProfileId = 'profile-456'
..offerToken = 'discount-token';
```

## Field Changes Summary

### RequestPurchaseAndroidProps

| Old Name | New Name |
|----------|----------|
| `obfuscatedAccountIdAndroid` | `obfuscatedAccountId` |
| `obfuscatedProfileIdAndroid` | `obfuscatedProfileId` |
| - | `offerToken` (NEW) |

### RequestSubscriptionAndroidProps

| Old Name | New Name |
|----------|----------|
| `obfuscatedAccountIdAndroid` | `obfuscatedAccountId` |
| `obfuscatedProfileIdAndroid` | `obfuscatedProfileId` |
| `purchaseTokenAndroid` | `purchaseToken` |
| `replacementModeAndroid` | `replacementMode` |

## Dependencies

| Package | Previous | Current |
|---------|----------|---------|
| openiap-gql | 1.3.14 | 1.3.15 |
| openiap-google | 1.3.25 | 1.3.26 |
| openiap-apple | 1.3.12 | 1.3.13 |

## References

- [OpenIAP Release Notes](https://www.openiap.dev/docs/updates/notes#gql-1-3-15-google-1-3-26-apple-1-3-13)
- [Google Play Billing 7.0 One-Time Purchase Discounts](https://developer.android.com/google/play/billing/migrate-billing-7)
18 changes: 8 additions & 10 deletions docs/static/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1429,16 +1429,14 @@ class SubscriptionService {
}

Future<void> upgradeSubscription(String newProductId, String oldPurchaseToken) async {
await iap.requestPurchaseWithBuilder(
build: (builder) {
builder
..type = ProductQueryType.Subs
..ios.sku = newProductId
..android.skus = [newProductId]
..android.purchaseTokenAndroid = oldPurchaseToken
..android.replacementModeAndroid = 2; // CHARGE_PRORATED_PRICE
},
);
final subBuilder = RequestSubscriptionBuilder()
..withIOS((ios) => ios..sku = newProductId)
..withAndroid((android) => android
..skus = [newProductId]
..purchaseToken = oldPurchaseToken
..replacementMode = 2); // CHARGE_PRORATED_PRICE

await iap.requestPurchase(subBuilder.build());
}
}
```
Expand Down
4 changes: 2 additions & 2 deletions example/lib/src/screens/builder_demo_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,8 @@ class _BuilderDemoScreenState extends State<BuilderDemoScreen> {
final subBuilder = RequestSubscriptionBuilder()
..withAndroid((RequestSubscriptionAndroidBuilder a) => a
..skus = [IapConstants.subscriptionProductIds[0]]
..replacementModeAndroid = prorationMode
..purchaseTokenAndroid = token);
..replacementMode = prorationMode
..purchaseToken = token);

await _iap.requestPurchase(subBuilder.build());
setState(() => _status = 'Subscription upgrade initiated');
Expand Down
36 changes: 18 additions & 18 deletions lib/builders.dart
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,21 @@ class RequestSubscriptionIosBuilder {
/// Builder for Android purchase props
class RequestPurchaseAndroidBuilder {
List<String> skus = const [];
String? obfuscatedAccountIdAndroid;
String? obfuscatedProfileIdAndroid;
String? obfuscatedAccountId;
String? obfuscatedProfileId;
bool? isOfferPersonalized;
String? offerToken;
DeveloperBillingOptionParamsAndroid? developerBillingOption;

RequestPurchaseAndroidBuilder();

RequestPurchaseAndroidProps build() {
return RequestPurchaseAndroidProps(
skus: skus,
obfuscatedAccountIdAndroid: obfuscatedAccountIdAndroid,
obfuscatedProfileIdAndroid: obfuscatedProfileIdAndroid,
obfuscatedAccountId: obfuscatedAccountId,
obfuscatedProfileId: obfuscatedProfileId,
isOfferPersonalized: isOfferPersonalized,
offerToken: offerToken,
developerBillingOption: developerBillingOption,
);
}
Expand All @@ -85,10 +87,10 @@ class RequestPurchaseAndroidBuilder {
class RequestSubscriptionAndroidBuilder {
List<String> skus = const [];
List<AndroidSubscriptionOfferInput> subscriptionOffers = const [];
String? obfuscatedAccountIdAndroid;
String? obfuscatedProfileIdAndroid;
String? purchaseTokenAndroid;
int? replacementModeAndroid;
String? obfuscatedAccountId;
String? obfuscatedProfileId;
String? purchaseToken;
int? replacementMode;
bool? isOfferPersonalized;
DeveloperBillingOptionParamsAndroid? developerBillingOption;

Expand All @@ -99,10 +101,10 @@ class RequestSubscriptionAndroidBuilder {
skus: skus,
subscriptionOffers:
subscriptionOffers.isEmpty ? null : subscriptionOffers,
obfuscatedAccountIdAndroid: obfuscatedAccountIdAndroid,
obfuscatedProfileIdAndroid: obfuscatedProfileIdAndroid,
purchaseTokenAndroid: purchaseTokenAndroid,
replacementModeAndroid: replacementModeAndroid,
obfuscatedAccountId: obfuscatedAccountId,
obfuscatedProfileId: obfuscatedProfileId,
purchaseToken: purchaseToken,
replacementMode: replacementMode,
isOfferPersonalized: isOfferPersonalized,
developerBillingOption: developerBillingOption,
);
Expand Down Expand Up @@ -179,12 +181,10 @@ class RequestPurchaseBuilder {
: RequestSubscriptionAndroidProps(
skus: androidProps.skus,
isOfferPersonalized: androidProps.isOfferPersonalized,
obfuscatedAccountIdAndroid:
androidProps.obfuscatedAccountIdAndroid,
obfuscatedProfileIdAndroid:
androidProps.obfuscatedProfileIdAndroid,
purchaseTokenAndroid: null,
replacementModeAndroid: null,
obfuscatedAccountId: androidProps.obfuscatedAccountId,
obfuscatedProfileId: androidProps.obfuscatedProfileId,
purchaseToken: null,
replacementMode: null,
subscriptionOffers: null,
developerBillingOption: androidProps.developerBillingOption,
);
Expand Down
Loading