Skip to content

Commit 32b0d9b

Browse files
hyochanclaude
andauthored
feat: sync with openiap v1.3.14 (#609)
## Summary - Sync with OpenIAP v1.3.14 (gql: 1.3.14, apple: 1.3.12, google: 1.3.25) - Add Win-back offers support (iOS 18+) - Add JWS promotional offers (iOS 15+, WWDC 2025) - Add introductory offer eligibility override (iOS 15+) - Add product-level status codes (Android 8.0+) - Add sub-response codes for purchase errors (Android 8.0+) - Add suspended subscriptions support (Android 8.1+) - Update Dart types with new features - Add `includeSuspendedAndroid` parameter to `getAvailablePurchases` - Bump version to 8.3.0 ## Test plan - [x] `flutter analyze` passes - [x] `flutter test` passes (198 tests) - [x] Code formatting verified ## Breaking Changes Subscription-only fields (`winBackOffer`, `promotionalOfferJWS`, `introductoryOfferEligibility`) have been removed from `RequestPurchaseIosProps` and are now only in `RequestSubscriptionIosProps`. 🤖 Generated with [Claude Code](https://claude.ai/code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Win‑Back Offers for iOS 18+ * Promotional Offers (JWS) for iOS 15+ * Introductory Offer eligibility override for iOS 15+ * Product status and sub‑response codes for Android 8.0+ * Suspended subscription support for Android 8.1+ * Option to include suspended Android subscriptions when listing/restoring purchases * **Breaking Changes** * Subscription-only fields (winBackOffer, promotionalOfferJWS, introductoryOfferEligibility) moved to subscription request types * **Chores** * Dependencies updated; package version bumped to 8.3.0 <sub>✏️ Tip: You can customize this high-level summary in your review settings.</sub> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.5 <[email protected]>
1 parent 08e8114 commit 32b0d9b

File tree

8 files changed

+499
-7
lines changed

8 files changed

+499
-7
lines changed

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,9 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
377377

378378
// Expo parity: getAvailableItems()
379379
"getAvailableItems" -> {
380+
val params = call.arguments as? Map<*, *>
381+
val includeSuspended = params?.get("includeSuspendedAndroid") as? Boolean ?: false
382+
380383
scope.launch {
381384
withBillingReady(safe, autoInit = true) {
382385
try {
@@ -385,7 +388,12 @@ class AndroidInappPurchasePlugin internal constructor() : MethodCallHandler, Act
385388
safe.error(OpenIapError.NotPrepared.CODE, OpenIapError.NotPrepared.MESSAGE, "IAP module not initialized.")
386389
return@withBillingReady
387390
}
388-
val purchases = iap.getAvailablePurchases(null)
391+
val options = if (includeSuspended) {
392+
dev.hyo.openiap.PurchaseOptions(includeSuspendedAndroid = true)
393+
} else {
394+
null
395+
}
396+
val purchases = iap.getAvailablePurchases(options)
389397
val arr = purchasesToJsonArray(purchases)
390398
safe.success(arr.toString())
391399
} catch (e: Exception) {
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
---
2+
slug: 8.3.0-release
3+
title: "8.3.0 - Win-Back Offers, Product Status, and Suspended Subscriptions"
4+
authors: [hyochan]
5+
tags: [release, ios, android, storekit, billing]
6+
---
7+
8+
v8.3.0 syncs with [OpenIAP v1.3.14](https://www.openiap.dev/docs/updates/notes#gql-1-3-14-google-1-3-25-apple-1-3-12) bringing Win-Back offers for iOS 18+, product-level status codes for Android 8.0+, and suspended subscription support.
9+
10+
<!-- truncate -->
11+
12+
## New Features
13+
14+
### Win-Back Offers (iOS 18+)
15+
16+
Win-back offers help re-engage churned subscribers with discounts or free trials:
17+
18+
```dart
19+
// Request subscription with win-back offer
20+
final props = RequestSubscriptionIosProps(
21+
sku: 'premium_monthly',
22+
winBackOffer: WinBackOfferInputIOS(offerId: 'winback_50_off'),
23+
);
24+
```
25+
26+
### JWS Promotional Offers (iOS 15+, WWDC 2025)
27+
28+
New simplified JWS format for promotional offers, back-deployed to iOS 15:
29+
30+
```dart
31+
final props = RequestSubscriptionIosProps(
32+
sku: 'premium_monthly',
33+
promotionalOfferJWS: PromotionalOfferJWSInputIOS(
34+
jws: 'header.payload.signature',
35+
offerId: 'promo_offer_id',
36+
),
37+
);
38+
```
39+
40+
### Introductory Offer Eligibility Override (iOS 15+)
41+
42+
Override system-determined introductory offer eligibility:
43+
44+
```dart
45+
final props = RequestSubscriptionIosProps(
46+
sku: 'premium_monthly',
47+
introductoryOfferEligibility: true, // Force eligible
48+
);
49+
```
50+
51+
### Product Status Codes (Android 8.0+)
52+
53+
Products now include status codes explaining fetch results:
54+
55+
```dart
56+
final products = await iap.fetchProducts(skus: ['sku1', 'sku2']);
57+
for (final product in products) {
58+
if (product is ProductAndroid) {
59+
switch (product.productStatusAndroid) {
60+
case ProductStatusAndroid.Ok:
61+
// Product fetched successfully
62+
break;
63+
case ProductStatusAndroid.NotFound:
64+
// SKU doesn't exist in Play Console
65+
break;
66+
case ProductStatusAndroid.NoOffersAvailable:
67+
// User not eligible for any offers
68+
break;
69+
case ProductStatusAndroid.Unknown:
70+
// Unknown error
71+
break;
72+
}
73+
}
74+
}
75+
```
76+
77+
### Suspended Subscriptions (Android 8.1+)
78+
79+
Include suspended subscriptions when restoring purchases:
80+
81+
```dart
82+
// Get all purchases including suspended ones
83+
final purchases = await iap.getAvailablePurchases(
84+
includeSuspendedAndroid: true,
85+
);
86+
87+
// Check suspension status
88+
for (final purchase in purchases) {
89+
if (purchase is PurchaseAndroid && purchase.isSuspendedAndroid == true) {
90+
// Subscription is suspended - do NOT grant entitlements
91+
// Direct user to subscription center to resolve payment issues
92+
}
93+
}
94+
```
95+
96+
### Sub-Response Codes (Android 8.0+)
97+
98+
More granular error information for billing operations via `BillingResultAndroid`:
99+
100+
```dart
101+
// BillingResultAndroid contains sub-response codes for detailed error info
102+
// Available when handling billing operation results
103+
104+
// SubResponseCodeAndroid enum values:
105+
// - NoApplicableSubResponseCode: No specific sub-response
106+
// - PaymentDeclinedDueToInsufficientFunds: Payment method has insufficient funds
107+
// - UserIneligible: User doesn't meet offer eligibility requirements
108+
109+
// Example: Checking sub-response code from billing result
110+
void handleBillingResult(BillingResultAndroid result) {
111+
if (result.responseCode != 0) {
112+
// Error occurred
113+
switch (result.subResponseCode) {
114+
case SubResponseCodeAndroid.PaymentDeclinedDueToInsufficientFunds:
115+
// Show user-friendly message about insufficient funds
116+
print('Payment failed: insufficient funds');
117+
break;
118+
case SubResponseCodeAndroid.UserIneligible:
119+
// User doesn't qualify for this offer
120+
print('You are not eligible for this offer');
121+
break;
122+
default:
123+
print('Error: ${result.debugMessage}');
124+
}
125+
}
126+
}
127+
```
128+
129+
### Builder Support for New iOS Features
130+
131+
The `RequestSubscriptionIosBuilder` now supports all new iOS 18+ features:
132+
133+
```dart
134+
final builder = RequestSubscriptionBuilder();
135+
builder.ios
136+
..sku = 'premium_monthly'
137+
..winBackOffer = WinBackOfferInputIOS(offerId: 'winback_50_off')
138+
..promotionalOfferJWS = PromotionalOfferJWSInputIOS(
139+
jws: 'header.payload.signature',
140+
offerId: 'promo_offer_id',
141+
)
142+
..introductoryOfferEligibility = true;
143+
144+
await iap.requestPurchase(builder.build());
145+
```
146+
147+
## New Types
148+
149+
### Enums
150+
151+
- `ProductStatusAndroid` - Product fetch status (Ok, NotFound, NoOffersAvailable, Unknown)
152+
- `SubResponseCodeAndroid` - Detailed error codes for purchases
153+
- `SubscriptionOfferTypeIOS.WinBack` - New offer type for win-back offers
154+
155+
### Classes
156+
157+
- `WinBackOfferInputIOS` - Win-back offer configuration
158+
- `PromotionalOfferJWSInputIOS` - JWS promotional offer input
159+
- `BillingResultAndroid` - Extended billing result with sub-response code
160+
161+
## Breaking Changes
162+
163+
### Subscription-Only Fields Moved (iOS)
164+
165+
The following fields have been removed from `RequestPurchaseIosProps` and are now only available in `RequestSubscriptionIosProps`:
166+
167+
- `winBackOffer`
168+
- `promotionalOfferJWS`
169+
- `introductoryOfferEligibility`
170+
171+
This is a type-safety improvement - these fields only apply to subscription purchases.
172+
173+
## Dependencies
174+
175+
| Package | Previous | Current |
176+
|---------|----------|---------|
177+
| openiap-gql | 1.3.12 | 1.3.14 |
178+
| openiap-google | 1.3.23 | 1.3.25 |
179+
| openiap-apple | 1.3.10 | 1.3.12 |
180+
181+
## References
182+
183+
- [OpenIAP Release Notes](https://www.openiap.dev/docs/updates/notes#gql-1-3-14-google-1-3-25-apple-1-3-12)
184+
- [Win-Back Offers Guide](https://developer.apple.com/documentation/storekit/win-back-offers)
185+
- [Google Billing 8.0 Migration](https://developer.android.com/google/play/billing/migrate-billing-8)

docs/static/llms.txt

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ Future<void> finishTransaction({
111111
Future<List<Purchase>> getAvailablePurchases({
112112
bool? onlyIncludeActiveItemsIOS,
113113
bool? alsoPublishToEventListenerIOS,
114+
bool? includeSuspendedAndroid, // Include suspended subscriptions (Android 8.1+)
114115
});
115116

116117
// Restore purchases
@@ -257,6 +258,13 @@ enum DiscountOfferType {
257258
OneTime, // One-time product discount (Android only)
258259
}
259260

261+
// iOS Subscription Offer Types (v8.3+)
262+
enum SubscriptionOfferTypeIOS {
263+
Introductory, // Intro offer for new subscribers
264+
Promotional, // Promotional offer (requires server signature)
265+
WinBack, // Win-back offer (iOS 18+) for churned subscribers
266+
}
267+
260268
enum PaymentMode {
261269
FreeTrial, // No charge during offer
262270
PayAsYouGo, // Reduced price per period
@@ -348,6 +356,60 @@ await iap.requestPurchaseWithBuilder(
348356
);
349357
```
350358

359+
### iOS Win-Back Offers (v8.3+, iOS 18+)
360+
361+
```dart
362+
// Using RequestSubscriptionBuilder for subscription-specific features
363+
final builder = RequestSubscriptionBuilder();
364+
builder.ios
365+
..sku = 'monthly_sub'
366+
..winBackOffer = WinBackOfferInputIOS(offerId: 'winback_50_off');
367+
await iap.requestPurchase(builder.build());
368+
```
369+
370+
### iOS JWS Promotional Offers (v8.3+, iOS 15+)
371+
372+
```dart
373+
// New JWS format for promotional offers (WWDC 2025)
374+
final builder = RequestSubscriptionBuilder();
375+
builder.ios
376+
..sku = 'monthly_sub'
377+
..promotionalOfferJWS = PromotionalOfferJWSInputIOS(
378+
jws: 'header.payload.signature',
379+
offerId: 'promo_offer_id',
380+
);
381+
await iap.requestPurchase(builder.build());
382+
```
383+
384+
### Android Product Status (v8.3+, Billing 8.0+)
385+
386+
```dart
387+
// Check why a product fetch failed
388+
final products = await iap.fetchProducts<ProductCommon>(skus: [...]);
389+
for (final product in products) {
390+
if (product is ProductAndroid) {
391+
final status = product.productStatusAndroid;
392+
// ProductStatusAndroid.Ok - fetched successfully
393+
// ProductStatusAndroid.NotFound - SKU doesn't exist
394+
// ProductStatusAndroid.NoOffersAvailable - user not eligible
395+
}
396+
}
397+
```
398+
399+
### Android Suspended Subscriptions (v8.3+, Billing 8.1+)
400+
401+
```dart
402+
// Include suspended subscriptions in restore
403+
final purchases = await iap.getAvailablePurchases(
404+
includeSuspendedAndroid: true,
405+
);
406+
for (final purchase in purchases) {
407+
if (purchase is PurchaseAndroid && purchase.isSuspendedAndroid == true) {
408+
// DO NOT grant entitlements - direct user to fix payment
409+
}
410+
}
411+
```
412+
351413
## Error Handling
352414

353415
```dart

lib/builders.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,15 @@ class RequestSubscriptionIosBuilder {
3333
DiscountOfferInputIOS? withOffer;
3434
String? advancedCommerceData;
3535

36+
/// Win-back offer to apply (iOS 18+)
37+
WinBackOfferInputIOS? winBackOffer;
38+
39+
/// JWS promotional offer (iOS 15+, WWDC 2025)
40+
PromotionalOfferJWSInputIOS? promotionalOfferJWS;
41+
42+
/// Override introductory offer eligibility (iOS 15+)
43+
bool? introductoryOfferEligibility;
44+
3645
RequestSubscriptionIosBuilder();
3746

3847
RequestSubscriptionIosProps build() {
@@ -44,6 +53,9 @@ class RequestSubscriptionIosBuilder {
4453
quantity: quantity,
4554
withOffer: withOffer,
4655
advancedCommerceData: advancedCommerceData,
56+
winBackOffer: winBackOffer,
57+
promotionalOfferJWS: promotionalOfferJWS,
58+
introductoryOfferEligibility: introductoryOfferEligibility,
4759
);
4860
}
4961
}
@@ -159,6 +171,7 @@ class RequestPurchaseBuilder {
159171
appAccountToken: iosProps.appAccountToken,
160172
quantity: iosProps.quantity,
161173
withOffer: iosProps.withOffer,
174+
advancedCommerceData: iosProps.advancedCommerceData,
162175
);
163176

164177
final androidSub = androidProps == null

lib/flutter_inapp_purchase.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,8 +479,11 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
479479
/// [options] - Optional configuration for the method behavior
480480
/// - onlyIncludeActiveItemsIOS: Whether to only include active items (default: true)
481481
/// Set to false to include expired subscriptions
482+
/// - includeSuspendedAndroid: Include suspended subscriptions (Android 8.1+, default: false)
483+
/// Suspended subscriptions have isSuspendedAndroid=true and should NOT be granted entitlements.
482484
gentype.QueryGetAvailablePurchasesHandler get getAvailablePurchases => ({
483485
bool? alsoPublishToEventListenerIOS,
486+
bool? includeSuspendedAndroid,
484487
bool? onlyIncludeActiveItemsIOS,
485488
}) async {
486489
if (!_isInitialized) {
@@ -494,6 +497,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
494497
final normalizedOptions = gentype.PurchaseOptions(
495498
alsoPublishToEventListenerIOS:
496499
alsoPublishToEventListenerIOS ?? false,
500+
includeSuspendedAndroid: includeSuspendedAndroid ?? false,
497501
onlyIncludeActiveItemsIOS: onlyIncludeActiveItemsIOS ?? true,
498502
);
499503

@@ -533,8 +537,13 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi {
533537
_acknowledgedAndroidPurchaseTokens,
534538
);
535539
} else if (_platform.isAndroid) {
540+
final args = <String, dynamic>{
541+
'includeSuspendedAndroid':
542+
normalizedOptions.includeSuspendedAndroid ?? false,
543+
};
536544
final dynamic result = await _channel.invokeMethod(
537545
'getAvailableItems',
546+
args,
538547
);
539548
raw = extractPurchases(
540549
result,

0 commit comments

Comments
 (0)