Skip to content

Commit 15c64da

Browse files
hyochanclaude
andauthored
feat: sync with openiap v1.3.12 (#608)
## Summary - Sync with openiap v1.3.12 (gql: 1.3.12, apple: 1.3.10, google: 1.3.22) - Add new cross-platform `DiscountOffer` and `SubscriptionOffer` types - Add `_parseSubscriptionOffers` helper to convert legacy offer details - New `discountOffers` and `subscriptionOffers` fields on Product types ## Changes ### Types (auto-generated from openiap-gql) - New `DiscountOffer` type with cross-platform fields - New `SubscriptionOffer` type with cross-platform fields - Platform-specific fields use `Android`/`IOS` suffixes ### lib/helpers.dart - Add `_parseSubscriptionOffers` function to convert legacy `ProductSubscriptionAndroidOfferDetails` to new `SubscriptionOffer` type - Update `ProductSubscriptionAndroid` parsing to include `subscriptionOffers` field ### test/types_platform_test.dart - Update test to include required `subscriptionOffers` field ## Test plan - [x] `flutter analyze` passes (no issues found) - [x] `flutter test` passes (176 tests) 🤖 Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4fac74e commit 15c64da

26 files changed

+2859
-1458
lines changed

android/src/main/kotlin/io/github/hyochan/flutter_inapp_purchase/AndroidInappPurchasePlugin.kt

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import dev.hyo.openiap.ProductQueryType
2424
import dev.hyo.openiap.ProductRequest
2525
import dev.hyo.openiap.Purchase
2626
import dev.hyo.openiap.RequestPurchaseProps
27+
import dev.hyo.openiap.SubscriptionProductReplacementParamsAndroid
28+
import dev.hyo.openiap.SubscriptionReplacementModeAndroid
2729
import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener
2830
import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener
2931
import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener
@@ -135,7 +137,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
135137
subscriptionOffers: List<AndroidSubscriptionOfferInput>,
136138
purchaseTokenAndroid: String?,
137139
replacementModeAndroid: Int?,
138-
developerBillingOption: DeveloperBillingOptionParamsAndroid? = null
140+
developerBillingOption: DeveloperBillingOptionParamsAndroid? = null,
141+
subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid? = null
139142
): RequestPurchaseProps {
140143
val androidPayload = mutableMapOf<String, Any?>().apply {
141144
put(KEY_SKUS, skus)
@@ -153,6 +156,9 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
153156
ProductQueryType.Subs -> {
154157
purchaseTokenAndroid?.let { androidPayload[KEY_PURCHASE_TOKEN] = it }
155158
replacementModeAndroid?.let { androidPayload[KEY_REPLACEMENT_MODE] = it }
159+
subscriptionProductReplacementParams?.let {
160+
androidPayload[KEY_SUBSCRIPTION_PRODUCT_REPLACEMENT_PARAMS] = it.toJson()
161+
}
156162
if (subscriptionOffers.isNotEmpty()) {
157163
androidPayload[KEY_SUBSCRIPTION_OFFERS] = subscriptionOffers.map { it.toJson() }
158164
}
@@ -459,6 +465,27 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
459465
}
460466
}
461467

468+
// Parse subscriptionProductReplacementParams for item-level replacement (8.1.0+)
469+
val subscriptionProductReplacementParamsMap = params[KEY_SUBSCRIPTION_PRODUCT_REPLACEMENT_PARAMS] as? Map<*, *>
470+
val subscriptionProductReplacementParams = subscriptionProductReplacementParamsMap?.let { paramsMap ->
471+
try {
472+
val oldProductId = paramsMap["oldProductId"] as? String
473+
val replacementModeStr = paramsMap["replacementMode"] as? String
474+
if (!oldProductId.isNullOrBlank() && !replacementModeStr.isNullOrBlank()) {
475+
val replacementMode = SubscriptionReplacementModeAndroid.fromJson(replacementModeStr)
476+
SubscriptionProductReplacementParamsAndroid(
477+
oldProductId = oldProductId,
478+
replacementMode = replacementMode
479+
)
480+
} else {
481+
null
482+
}
483+
} catch (e: Exception) {
484+
OpenIapLog.w(TAG, "Failed to parse subscriptionProductReplacementParams: ${e.message}")
485+
null
486+
}
487+
}
488+
462489
// Validate SKUs
463490
if (skusNormalized.isEmpty()) {
464491
safe.error(OpenIapError.EmptySkuList.CODE, OpenIapError.EmptySkuList.MESSAGE, "Empty SKUs provided")
@@ -515,7 +542,8 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
515542
subscriptionOffers = offers,
516543
purchaseTokenAndroid = purchaseTokenAndroid,
517544
replacementModeAndroid = replacementModeAndroid,
518-
developerBillingOption = developerBillingOption
545+
developerBillingOption = developerBillingOption,
546+
subscriptionProductReplacementParams = subscriptionProductReplacementParams
519547
)
520548

521549
iap.requestPurchase(requestProps)
@@ -1254,5 +1282,6 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
12541282
private const val KEY_REPLACEMENT_MODE = "replacementModeAndroid"
12551283
private const val KEY_SUBSCRIPTION_OFFERS = "subscriptionOffers"
12561284
private const val KEY_DEVELOPER_BILLING_OPTION = "developerBillingOption"
1285+
private const val KEY_SUBSCRIPTION_PRODUCT_REPLACEMENT_PARAMS = "subscriptionProductReplacementParams"
12571286
}
12581287
}

docs/docs/api/types.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,134 @@ enum IapPlatform {
278278
}
279279
```
280280

281+
## Standardized Offer Types
282+
283+
:::tip New in v8.3+
284+
These cross-platform types replace the deprecated platform-specific offer types (`SubscriptionOfferIOS`, `ProductSubscriptionAndroidOfferDetails`). Use these for cross-platform compatibility.
285+
:::
286+
287+
### SubscriptionOffer
288+
289+
Cross-platform subscription offer type (introductory offers, promotional offers).
290+
291+
```dart
292+
class SubscriptionOffer {
293+
final String id; // Unique offer identifier
294+
final String displayPrice; // Formatted price string
295+
final double price; // Numeric price value
296+
final String? currency; // ISO 4217 currency code
297+
final DiscountOfferType type; // Introductory or Promotional
298+
final PaymentMode? paymentMode; // How user pays during offer
299+
final SubscriptionPeriod? period; // Billing period
300+
final int? periodCount; // Number of billing periods
301+
302+
// Android-specific fields
303+
final String? basePlanIdAndroid; // Android base plan ID
304+
final String? offerTokenAndroid; // Token for purchase
305+
final List<String>? offerTagsAndroid; // Offer tags
306+
final PricingPhasesAndroid? pricingPhasesAndroid; // Pricing phases
307+
308+
// iOS-specific fields
309+
final String? keyIdentifierIOS; // Signature validation key
310+
final String? nonceIOS; // Cryptographic nonce
311+
final String? signatureIOS; // Server-generated signature
312+
final double? timestampIOS; // Signature timestamp
313+
final String? localizedPriceIOS; // Localized price string
314+
final int? numberOfPeriodsIOS; // Number of periods
315+
}
316+
```
317+
318+
### DiscountOffer
319+
320+
Cross-platform one-time product discount (Android Google Play Billing 7.0+).
321+
322+
```dart
323+
class DiscountOffer {
324+
final String? id; // Unique offer identifier
325+
final String displayPrice; // Formatted price string
326+
final double price; // Numeric price value
327+
final String currency; // ISO 4217 currency code
328+
final DiscountOfferType type; // Discount type
329+
330+
// Android-specific fields
331+
final String? offerTokenAndroid; // Token for purchase
332+
final List<String>? offerTagsAndroid; // Offer tags
333+
final String? fullPriceMicrosAndroid; // Original price in micro-units
334+
final int? percentageDiscountAndroid; // Percentage discount
335+
final String? discountAmountMicrosAndroid; // Fixed discount amount
336+
final String? formattedDiscountAmountAndroid; // Formatted discount string
337+
final ValidTimeWindowAndroid? validTimeWindowAndroid; // Valid time window
338+
final LimitedQuantityInfoAndroid? limitedQuantityInfoAndroid; // Quantity limits
339+
final PreorderDetailsAndroid? preorderDetailsAndroid; // Pre-order info
340+
final RentalDetailsAndroid? rentalDetailsAndroid; // Rental details
341+
}
342+
```
343+
344+
### DiscountOfferType
345+
346+
Type of discount/subscription offer.
347+
348+
```dart
349+
enum DiscountOfferType {
350+
Introductory, // New subscriber discount
351+
Promotional, // Existing subscriber discount
352+
OneTime, // One-time product discount (Android only)
353+
}
354+
```
355+
356+
### PaymentMode
357+
358+
How the user pays during an offer period.
359+
360+
```dart
361+
enum PaymentMode {
362+
FreeTrial, // No charge during offer (price = 0)
363+
PayAsYouGo, // Reduced price per period (recurring)
364+
PayUpFront, // Discounted amount upfront (non-recurring)
365+
Unknown, // Unknown or unspecified
366+
}
367+
```
368+
369+
### SubscriptionPeriod
370+
371+
Billing period for subscription offers.
372+
373+
```dart
374+
class SubscriptionPeriod {
375+
final SubscriptionPeriodUnit unit; // Period unit (day, week, month, year)
376+
final int value; // Number of units (e.g., 1 for monthly)
377+
}
378+
379+
enum SubscriptionPeriodUnit {
380+
Day,
381+
Week,
382+
Month,
383+
Year,
384+
Unknown,
385+
}
386+
```
387+
388+
### Using Standardized Offers
389+
390+
Products now include both legacy and standardized offer fields:
391+
392+
```dart
393+
// Access standardized offers (recommended)
394+
final product = await fetchProducts(['subscription_id']);
395+
if (product is ProductSubscriptionAndroid) {
396+
final offers = product.subscriptionOffers;
397+
for (final offer in offers ?? []) {
398+
print('Offer: ${offer.id}');
399+
print('Price: ${offer.displayPrice}');
400+
print('Type: ${offer.type}');
401+
print('Payment Mode: ${offer.paymentMode}');
402+
}
403+
}
404+
405+
// Legacy access (deprecated but still available)
406+
final legacyOffers = product.subscriptionOfferDetailsAndroid;
407+
```
408+
281409
## Additional Types
282410

283411
### ActiveSubscription

docs/docs/guides/subscription-offers.md

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,68 @@ final subscriptions = await iap.fetchProducts(
2323

2424
## Basic Subscription Purchase
2525

26+
### Access Subscription Offers (Cross-Platform)
27+
28+
:::tip New in v8.3+
29+
Use the standardized `subscriptionOffers` field for cross-platform compatibility.
30+
:::
31+
32+
```dart
33+
// Get standardized subscription offers (recommended)
34+
void displaySubscriptionOffers(ProductCommon product) {
35+
List<SubscriptionOffer>? offers;
36+
37+
if (product is ProductSubscriptionAndroid) {
38+
offers = product.subscriptionOffers;
39+
} else if (product is ProductSubscriptionIOS) {
40+
offers = product.subscriptionOffers;
41+
}
42+
43+
for (final offer in offers ?? []) {
44+
print('Offer ID: ${offer.id}');
45+
print('Price: ${offer.displayPrice}');
46+
print('Type: ${offer.type}'); // Introductory, Promotional
47+
print('Payment Mode: ${offer.paymentMode}'); // FreeTrial, PayAsYouGo, PayUpFront
48+
49+
// Period info
50+
if (offer.period != null) {
51+
print('Period: ${offer.period!.value} ${offer.period!.unit}');
52+
}
53+
54+
// Android-specific: offerToken required for purchase
55+
if (offer.offerTokenAndroid != null) {
56+
print('Offer Token: ${offer.offerTokenAndroid}');
57+
}
58+
}
59+
}
60+
```
61+
2662
### Android with Offers
2763

2864
```dart
29-
// Get available offers for Android
65+
// Get available offers for Android (using standardized offers)
3066
List<AndroidSubscriptionOfferInput> getAndroidOffers(ProductCommon product) {
31-
if (product is ProductAndroid) {
67+
if (product is ProductSubscriptionAndroid) {
68+
// Use new standardized subscriptionOffers (recommended)
69+
final offers = product.subscriptionOffers;
70+
if (offers != null && offers.isNotEmpty) {
71+
return [
72+
for (final offer in offers)
73+
AndroidSubscriptionOfferInput(
74+
offerToken: offer.offerTokenAndroid ?? '',
75+
sku: product.id, // Use productId, not basePlanId
76+
),
77+
];
78+
}
79+
80+
// Fallback to legacy field (deprecated)
3281
final details = product.subscriptionOfferDetailsAndroid;
3382
if (details != null && details.isNotEmpty) {
3483
return [
3584
for (final offer in details)
3685
AndroidSubscriptionOfferInput(
3786
offerToken: offer.offerToken,
38-
sku: product.id, // Use productId, not basePlanId
87+
sku: product.id,
3988
),
4089
];
4190
}

0 commit comments

Comments
 (0)