fix(flutter,google): correct Android replacement mode and source values from native Billing constants#96
Conversation
…es from native Billing constants Closes #92 The Flutter int enum mapping for AndroidReplacementMode had `deferred = 4`, which is not a valid `BillingFlowParams.SubscriptionUpdateParams.ReplacementMode` value (the legacy API consumed by `setSubscriptionReplacementMode(int)` on the Android side). Verified against billing-ktx 8.3.0 bytecode: legacy values are `CHARGE_FULL_PRICE = 5`, `DEFERRED = 6`. Updated the enum extension and tests accordingly. To prevent the same class of drift from recurring on the Kotlin side, moved `SubscriptionReplacementModeAndroidExt.kt` to the play/ source set and replaced the hardcoded 0..6 literals with direct references to `BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode` constants. The matching tests were moved to testPlay/ and now assert against those same native constants, so the mapping is self-correcting if Google ever renumbers them. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (5)
📝 WalkthroughWalkthroughThis PR corrects incorrect Android replacement mode integer mappings across Flutter and Kotlin implementations. The Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request updates the Android subscription replacement mode constants to align with the native Google Play Billing Library 8.1.0+ values. In libraries/flutter_inapp_purchase, the AndroidReplacementMode enum mapping was corrected (specifically the deferred value) and unit tests were expanded to verify the legacy SubscriptionUpdateParams integers. In packages/google/openiap, the Kotlin implementation was refactored to reference the native ReplacementMode constants directly instead of using hardcoded integers, and the test suite was updated to ensure these mappings remain synchronized with the library. I have no feedback to provide as the changes correctly address the mapping drift and improve maintainability.
There was a problem hiding this comment.
Pull request overview
Fixes Android subscription replacement mode value drift between Flutter/Dart and the native Google Play Billing constants, and makes the Kotlin mapping/tests self-updating by referencing upstream Billing Library constants directly.
Changes:
- Correct Dart
AndroidReplacementMode.deferred.valueto match legacyBillingFlowParams.SubscriptionUpdateParams.ReplacementModevalues and document the legacy-vs-new API split. - Move Kotlin replacement-mode mapping to the Play flavor and source its values from
BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementModeconstants (no hardcoded ints). - Update/add targeted unit tests (Dart + Kotlin) to assert mappings against the appropriate native constants.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| packages/google/openiap/src/testPlay/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt | Updates tests to assert enum-to-constant mapping against native Billing constants in the Play test source set. |
| packages/google/openiap/src/play/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt | Replaces hardcoded integer mapping with direct references to Billing Library ReplacementMode constants (Play flavor only). |
| packages/google/openiap/build.gradle.kts | Adds testPlay source set configuration so Play-specific tests compile/run from src/testPlay/java. |
| libraries/flutter_inapp_purchase/test/enums_unit_test.dart | Expands Dart test coverage to validate the legacy integer values used by setSubscriptionReplacementMode(int). |
| libraries/flutter_inapp_purchase/lib/enums.dart | Fixes Dart legacy replacement-mode integer mapping (notably deferred) and documents preferred string-keyed API alternative. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
@hyochan Thank you for your prompt response. I have reviewed the latest release and conducted tests using Test Scenario:
Code Snippet: RequestSubscriptionAndroidProps _buildAndroidSubsProps(
ProductSubscription product,
String targetPlanId,
) {
String offerToken = '';
if (product is ProductSubscriptionAndroid) {
final offers = product.subscriptionOfferDetailsAndroid;
final offer = targetPlanId.isNotEmpty
? offers.firstWhere(
(o) => o.basePlanId == targetPlanId,
orElse: () => offers.first,
)
: offers.first;
offerToken = offer.offerToken;
}
int? replacementMode;
if (state.activeSubscription != null) {
final activeSub = state.activeSubscription!;
final currentPlanId = activeSub.currentPlanId!;
// ReplacementMode logic
final isUpgrade = _isUpgrade(currentPlanId, targetPlanId);
const chargeFullPrice = AndroidReplacementMode.chargeFullPrice;
const deferred = AndroidReplacementMode.deferred;
replacementMode = isUpgrade ? chargeFullPrice : deferred;
Logger.d("Change Price: $currentPlanId -> $targetPlanId, isUpgrade: $isUpgrade, replacementMode: $replacementMode");
}
return RequestSubscriptionAndroidProps(
skus: [_androidSubId],
obfuscatedAccountId: puid,
subscriptionOffers: offerToken.isNotEmpty
? [
AndroidSubscriptionOfferInput(
sku: _androidSubId,
offerToken: offerToken,
),
]
: null,
replacementMode: replacementMode,
);
}Execution Logs: Server-side Data (Play Store Notifications and Receipt Details):
The Issue: If Am I implementing the Notification History
[
{
"rawEventType": "PURCHASED",
"rawNotification": {
"version": "1.0",
"notificationType": 4,
"purchaseToken": "MASKED_TOKEN_02",
"subscriptionId": "sub_test"
},
"rawReceipt": {
"kind": "androidpublisher#subscriptionPurchaseV2",
"startTime": "2026-04-14T14:04:48.152Z",
"regionCode": "KR",
"subscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
"latestOrderId": "GPA.1111-1111-4976-92514",
"testPurchase": {},
"acknowledgementState": "ACKNOWLEDGEMENT_STATE_PENDING",
"externalAccountIdentifiers": {
"obfuscatedExternalAccountId": "f1111111-58cc-11cf-a447-00155d3f4b01"
},
"lineItems": [
{
"productId": "sub_test",
"expiryTime": "2026-04-14T14:09:47.404Z",
"autoRenewingPlan": {
"autoRenewEnabled": true,
"recurringPrice": {
"currencyCode": "KRW",
"units": "6500"
}
},
"offerDetails": {
"basePlanId": "sub-test-1m",
"offerTags": [
"m1",
"test"
]
},
"latestSuccessfulOrderId": "GPA.1111-1111-4976-92514",
"offerPhase": {
"basePrice": {}
}
}
],
"etag": "e8eeac79c481db704003d01ad8ce0934"
}
},
{
"rawEventType": "PURCHASED",
"rawNotification": {
"version": "1.0",
"notificationType": 4,
"purchaseToken": "MASKED_TOKEN_01",
"subscriptionId": "sub_test"
},
"rawReceipt": {
"kind": "androidpublisher#subscriptionPurchaseV2",
"startTime": "2026-04-14T14:05:39.668Z",
"regionCode": "KR",
"subscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
"latestOrderId": "GPA.1111-1111-1636-96411",
"linkedPurchaseToken": "MASKED_TOKEN_02",
"testPurchase": {},
"acknowledgementState": "ACKNOWLEDGEMENT_STATE_PENDING",
"externalAccountIdentifiers": {
"obfuscatedExternalAccountId": "f1111111-58cc-11cf-a447-00155d3f4b01"
},
"lineItems": [
{
"productId": "sub_test",
"expiryTime": "2026-04-14T14:09:57.749Z",
"autoRenewingPlan": {
"autoRenewEnabled": true,
"recurringPrice": {
"currencyCode": "KRW",
"units": "65000"
}
},
"offerDetails": {
"basePlanId": "sub-test-1y",
"offerTags": [
"test",
"y1"
]
},
"latestSuccessfulOrderId": "GPA.1111-1111-1636-96411",
"itemReplacement": {
"productId": "sub_test",
"replacementMode": "WITHOUT_PRORATION",
"basePlanId": "sub-test-1m"
},
"offerPhase": {
"prorationPeriod": {}
}
}
],
"etag": "a85638f831e0b5a77c4af2928fd405b3"
}
},
{
"rawEventType": "EXPIRED",
"rawNotification": {
"version": "1.0",
"notificationType": 13,
"purchaseToken": "MASKED_TOKEN_02",
"subscriptionId": "sub_test"
},
"rawReceipt": {
"kind": "androidpublisher#subscriptionPurchaseV2",
"startTime": "2026-04-14T14:04:48.152Z",
"regionCode": "KR",
"subscriptionState": "SUBSCRIPTION_STATE_EXPIRED",
"latestOrderId": "GPA.1111-1111-4976-92514",
"canceledStateContext": {
"replacementCancellation": {}
},
"testPurchase": {},
"acknowledgementState": "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
"externalAccountIdentifiers": {
"obfuscatedExternalAccountId": "f1111111-58cc-11cf-a447-00155d3f4b01"
},
"lineItems": [
{
"productId": "sub_test",
"expiryTime": "2026-04-14T14:05:39.541Z",
"autoRenewingPlan": {
"recurringPrice": {
"currencyCode": "KRW",
"units": "6500"
}
},
"offerDetails": {
"basePlanId": "sub-test-1m",
"offerTags": [
"m1",
"test"
]
},
"latestSuccessfulOrderId": "GPA.1111-1111-4976-92514",
"offerPhase": {
"basePrice": {}
}
}
],
"etag": "72257c4ec8bc2c6d0a943cde0ff97210"
}
},
{
"rawEventType": "RENEWED",
"rawNotification": {
"version": "1.0",
"notificationType": 2,
"purchaseToken": "MASKED_TOKEN_01",
"subscriptionId": "sub_test"
},
"rawReceipt": {
"kind": "androidpublisher#subscriptionPurchaseV2",
"startTime": "2026-04-14T14:05:39.668Z",
"regionCode": "KR",
"subscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
"latestOrderId": "GPA.1111-1111-1636-96411..0",
"linkedPurchaseToken": "MASKED_TOKEN_02",
"testPurchase": {},
"acknowledgementState": "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
"externalAccountIdentifiers": {
"obfuscatedExternalAccountId": "f1111111-58cc-11cf-a447-00155d3f4b01"
},
"lineItems": [
{
"productId": "sub_test",
"expiryTime": "2026-04-14T14:39:57.749Z",
"autoRenewingPlan": {
"autoRenewEnabled": true,
"recurringPrice": {
"currencyCode": "KRW",
"units": "65000"
}
},
"offerDetails": {
"basePlanId": "sub-test-1y",
"offerTags": [
"test",
"y1"
]
},
"latestSuccessfulOrderId": "GPA.1111-1111-1636-96411..0",
"itemReplacement": {
"productId": "sub_test",
"replacementMode": "WITHOUT_PRORATION",
"basePlanId": "sub-test-1m"
},
"offerPhase": {
"basePrice": {}
}
}
],
"etag": "823d852cc0b4ec3e9c7b68d300156e83"
}
},
{
"rawEventType": "PURCHASED",
"rawNotification": {
"version": "1.0",
"notificationType": 4,
"purchaseToken": "MASKED_TOKEN_03",
"subscriptionId": "sub_test"
},
"rawReceipt": {
"kind": "androidpublisher#subscriptionPurchaseV2",
"startTime": "2026-04-14T14:11:18.171Z",
"regionCode": "KR",
"subscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
"latestOrderId": "GPA.1111-1111-4024-68554",
"linkedPurchaseToken": "MASKED_TOKEN_01",
"testPurchase": {},
"acknowledgementState": "ACKNOWLEDGEMENT_STATE_PENDING",
"externalAccountIdentifiers": {
"obfuscatedExternalAccountId": "f1111111-58cc-11cf-a447-00155d3f4b01"
},
"lineItems": [
{
"productId": "sub_test",
"expiryTime": "2026-04-14T14:40:01.258Z",
"autoRenewingPlan": {
"autoRenewEnabled": true,
"recurringPrice": {
"currencyCode": "KRW",
"units": "6500"
}
},
"offerDetails": {
"basePlanId": "sub-test-1m",
"offerTags": [
"m1",
"test"
]
},
"latestSuccessfulOrderId": "GPA.1111-1111-4024-68554",
"itemReplacement": {
"productId": "sub_test",
"replacementMode": "WITHOUT_PRORATION",
"basePlanId": "sub-test-1y"
},
"offerPhase": {
"prorationPeriod": {}
}
}
],
"etag": "edbaadcccfbd70b0153765cad4354762"
}
},
{
"rawEventType": "EXPIRED",
"rawNotification": {
"version": "1.0",
"notificationType": 13,
"purchaseToken": "MASKED_TOKEN_01",
"subscriptionId": "sub_test"
},
"rawReceipt": {
"kind": "androidpublisher#subscriptionPurchaseV2",
"startTime": "2026-04-14T14:05:39.668Z",
"regionCode": "KR",
"subscriptionState": "SUBSCRIPTION_STATE_EXPIRED",
"latestOrderId": "GPA.1111-1111-1636-96411..0",
"linkedPurchaseToken": "MASKED_TOKEN_02",
"canceledStateContext": {
"replacementCancellation": {}
},
"testPurchase": {},
"acknowledgementState": "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
"externalAccountIdentifiers": {
"obfuscatedExternalAccountId": "f1111111-58cc-11cf-a447-00155d3f4b01"
},
"lineItems": [
{
"productId": "sub_test",
"expiryTime": "2026-04-14T14:11:18.073Z",
"autoRenewingPlan": {
"recurringPrice": {
"currencyCode": "KRW",
"units": "65000"
}
},
"offerDetails": {
"basePlanId": "sub-test-1y",
"offerTags": [
"test",
"y1"
]
},
"latestSuccessfulOrderId": "GPA.1111-1111-1636-96411..0",
"itemReplacement": {
"productId": "sub_test",
"replacementMode": "WITHOUT_PRORATION",
"basePlanId": "sub-test-1m"
},
"offerPhase": {
"basePrice": {}
}
}
],
"etag": "80aaa186a67642d08d644d60c95f62bf"
}
},
{
"rawEventType": "CANCELED",
"rawNotification": {
"version": "1.0",
"notificationType": 3,
"purchaseToken": "MASKED_TOKEN_03",
"subscriptionId": "sub_test"
},
"rawReceipt": {
"kind": "androidpublisher#subscriptionPurchaseV2",
"startTime": "2026-04-14T14:11:18.171Z",
"regionCode": "KR",
"subscriptionState": "SUBSCRIPTION_STATE_CANCELED",
"latestOrderId": "GPA.1111-1111-4024-68554",
"linkedPurchaseToken": "MASKED_TOKEN_01",
"canceledStateContext": {
"userInitiatedCancellation": {
"cancelTime": "2026-04-14T14:20:55.410Z"
}
},
"testPurchase": {},
"acknowledgementState": "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
"externalAccountIdentifiers": {
"obfuscatedExternalAccountId": "f1111111-58cc-11cf-a447-00155d3f4b01"
},
"lineItems": [
{
"productId": "sub_test",
"expiryTime": "2026-04-14T14:40:01.258Z",
"autoRenewingPlan": {
"recurringPrice": {
"currencyCode": "KRW",
"units": "6500"
}
},
"offerDetails": {
"basePlanId": "sub-test-1m",
"offerTags": [
"m1",
"test"
]
},
"latestSuccessfulOrderId": "GPA.1111-1111-4024-68554",
"itemReplacement": {
"productId": "sub_test",
"replacementMode": "WITHOUT_PRORATION",
"basePlanId": "sub-test-1y"
},
"offerPhase": {
"prorationPeriod": {}
}
}
],
"etag": "3114180e17ac529739a50e45d9c51e7e"
}
},
{
"rawEventType": "EXPIRED",
"rawNotification": {
"version": "1.0",
"notificationType": 13,
"purchaseToken": "MASKED_TOKEN_03",
"subscriptionId": "sub_test"
},
"rawReceipt": {
"kind": "androidpublisher#subscriptionPurchaseV2",
"startTime": "2026-04-14T14:11:18.171Z",
"regionCode": "KR",
"subscriptionState": "SUBSCRIPTION_STATE_EXPIRED",
"latestOrderId": "GPA.1111-1111-4024-68554",
"linkedPurchaseToken": "MASKED_TOKEN_01",
"canceledStateContext": {
"userInitiatedCancellation": {
"cancelTime": "2026-04-14T14:20:55.410Z"
}
},
"testPurchase": {},
"acknowledgementState": "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
"externalAccountIdentifiers": {
"obfuscatedExternalAccountId": "f1111111-58cc-11cf-a447-00155d3f4b01"
},
"lineItems": [
{
"productId": "sub_test",
"expiryTime": "2026-04-14T14:40:01.258Z",
"autoRenewingPlan": {
"recurringPrice": {
"currencyCode": "KRW",
"units": "6500"
}
},
"offerDetails": {
"basePlanId": "sub-test-1m",
"offerTags": [
"m1",
"test"
]
},
"latestSuccessfulOrderId": "GPA.1111-1111-4024-68554",
"itemReplacement": {
"productId": "sub_test",
"replacementMode": "WITHOUT_PRORATION",
"basePlanId": "sub-test-1y"
},
"offerPhase": {
"prorationPeriod": {}
}
}
],
"etag": "1b302e6c632e1bbee54aa1f59eabca86"
}
}
] |
|
Hi @nero-angela — thanks for the reproducible repro data, it helped narrow it down quickly. Root cause: For the legacy if (androidArgs.type == ProductQueryType.Subs && !androidArgs.purchaseToken.isNullOrBlank()) {
val updateParamsBuilder = BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(androidArgs.purchaseToken)
val replacementMode = androidArgs.replacementMode ?: 5
updateParamsBuilder.setSubscriptionReplacementMode(replacementMode)
flowBuilder.setSubscriptionUpdateParams(updateParams)
}Your Fix — pass the active subscription's purchase token: return RequestSubscriptionAndroidProps(
skus: [_androidSubId],
obfuscatedAccountId: puid,
subscriptionOffers: offerToken.isNotEmpty
? [AndroidSubscriptionOfferInput(sku: _androidSubId, offerToken: offerToken)]
: null,
purchaseToken: state.activeSubscription!.purchaseToken, // ← required
replacementMode: replacementMode?.value, // int, not the enum itself
);(Also worth double-checking the snippet you posted: Recommended path forward — subscriptionProductReplacementParams: SubscriptionProductReplacementParamsAndroid(
oldProductId: state.activeSubscription!.productId,
replacementMode: isUpgrade
? SubscriptionReplacementModeAndroid.ChargeFullPrice
: SubscriptionReplacementModeAndroid.Deferred,
),This path doesn't require |
|
Thank you for your response. I tested the two methods you suggested. Method 1) Providing
|
|
Thanks for testing both paths so thoroughly — it actually uncovered a real library bug on Method 2. Method 2 is broken in the Dart channel layer (library bug, not your code)I owe you an apology on Method 2 — the native side parses Fix is up at #97 (forwards the field + adds a channel test). It'll ship in the next flutter_inapp_purchase patch release. Method 1 downgrade
|
…#97) ## Summary - `RequestSubscriptionAndroidProps.subscriptionProductReplacementParams` was declared in `types.dart` and parsed correctly by the Android plugin, but `flutter_inapp_purchase.dart` never copied the field onto the method-channel payload when translating the Dart props into the native call. Any value callers passed was silently dropped, so Google Play fell back to its default replacement mode (`WITHOUT_PRORATION`) regardless of what the Dart side requested. - Forwards the field through the channel payload and adds a channel test asserting that the serialized `oldProductId` / `replacementMode` reach the native call, so the wiring can't silently regress again. Reported via #96 (comment). The legacy `replacementMode` int path was already wired correctly; this PR only fixes the newer per-product `SubscriptionProductReplacementParams` path (Billing Library 8.1.0+). ## Test plan - [x] `flutter analyze` — no issues - [x] `flutter test` — 243 tests pass (new `forwards subscriptionProductReplacementParams on Android subscription` test included) - [x] Format check: `dart format --page-width 80 --set-exit-if-changed` clean 🤖 Generated with [Claude Code](https://claude.ai/code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for Android subscription product replacement parameters during in-app purchase requests, enabling configuration when requesting subscriptions. * **Tests** * Added test coverage verifying proper handling of subscription product replacement parameters in Android purchase requests. <!-- end of auto-generated comment: release notes by coderabbit.ai --> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
I re-tested Method 2 on the main branch where the code from #97 has been merged. For upgrades, I confirmed that it proceeds correctly with For downgrades, similar to Method 1, the following error occurs: For the downgrade test, I attempted to switch from an annual subscription to a monthly subscription using I’ve attached the package logs below: I am planning to introduce |
|
Thanks for the retest — the upgrade path through Method 2 working confirms #97's wiring is sound. The Getting Play's actual rejection reasonThe library logs Play's raw debug message on the native side via The Library follow-upIndependently of your case, it's a papercut that this detail isn't reaching Dart callers. I'll open a patch to forward On DEFERRED for downgrades in generalDEFERRED is supported by Play for both upgrades and downgrades at the API level — there's no library-side blocker. The |
## Summary - **openiap-google (play + horizon) — major bump (2.0.0) for breaking error-type refactor**: `OpenIapError.DeveloperError`, `PurchaseFailed`, and every other error type returned by `fromBillingResponseCode` (UserCancelled, ServiceUnavailable, BillingUnavailable, ItemUnavailable, BillingError, ItemAlreadyOwned, ItemNotOwned, ServiceDisconnected, FeatureNotSupported, ServiceTimeout, UnknownError) stop being `object` singletons and become data classes that accept an optional `debugMessage: String?`. This is source-breaking for direct Kotlin consumers (`throw OpenIapError.DeveloperError` → `throw OpenIapError.DeveloperError()`), hence the major version bump per SemVer. Companion `.CODE` / `.MESSAGE` accesses and `is OpenIapError.X` type checks keep working. `OpenIapError.toJSON()` emits `debugMessage`, and `fromBillingResponseCode` forwards Google Play's raw `BillingResult.debugMessage` into the error instance for every response code instead of silently dropping it. The `launchBillingFlow` sync-failure path now also produces `DeveloperError(result.debugMessage)` (matching the `onPurchasesUpdated` async path) instead of the generic `PurchaseFailed`. Tests assert the message flows through every response code, including the `else → UnknownError` branch. - **gql / cross-platform parity**: Added `debugMessage: String` (optional) to `type PurchaseError` and `ServiceTimeout` to the `ErrorCode` enum in `packages/gql/src/error.graphql`. Regenerated Swift/Kotlin/Dart/GDScript/TypeScript types; the Swift codegen plugin now emits `= nil` defaults on nullable struct properties so existing memberwise-init call sites stay source-compatible when new optional fields land. The GDScript plugin declares nullable scalars as `Variant = null` (so `false`/`0`/`""` round-trip as themselves) and omits the key from `to_dict()` only when the value is actually `null` — preserving the absent-vs-null distinction without losing legitimate zero/false/empty values. - **apple (iOS)**: `makePurchaseError` gains an optional `debugMessage: String?`; populated at the StoreKit catch sites (`queryProduct`, promoted-product retrieval, promotional-offer failure) with `error.localizedDescription`. New `testPurchaseErrorCarriesDebugMessage` round-trips a PurchaseError through `JSONEncoder`/`Decoder` to lock the wire format. This side is purely additive (nullable optional field with default `nil`), so the Apple package is NOT a breaking change. - **flutter_inapp_purchase**: `convertToPurchaseError` now forwards `responseCode`, `debugMessage`, and the resolved `platform` from the native payload onto `PurchaseError`. Those fields already existed on `PurchaseError` but the helper only copied `code`/`message`, so `PurchaseError.debugMessage` / `.responseCode` / `.platform` were always null even when the information was available. Locked in with a new `helpers_unit_test` case. Dart-facing API is unchanged — flutter_inapp_purchase stays on the 9.x line (9.0.4 picks up openiap-google 2.0.0). - **kmp-iap**: `ErrorMapping.legacyCodeMap` gains `E_SERVICE_TIMEOUT` / `SERVICE_TIMEOUT` and `E_DUPLICATE_PURCHASE` / `DUPLICATE_PURCHASE` Android-alias entries, and the exhaustive `when` on `getErrorMessage` gets branches for both new ErrorCode values so they no longer fall back to `Unknown`. - **sync-to-platforms automation**: `packages/gql/scripts/sync-to-platforms.mjs` now also copies the generated types into every framework library (`flutter_inapp_purchase`, `godot-iap`, `react-native-iap`, `expo-iap`, `kmp-iap`) as part of `bun run generate`, with KMP receiving the required package declaration (inserted after every leading `@file:` annotation, multi-annotation safe) and enum-companion semicolon post-process. Removes the "manual edit to an auto-generated file" drift flagged by review. Addresses the diagnosis thread on #96 — callers investigating `DEVELOPER_ERROR` on subscription replacement flows (e.g. DEFERRED downgrades) now see Play's exact rejection reason via `PurchaseError.debugMessage` without needing adb. ## Impact / migration notes **Kotlin (openiap-google consumers)**: the following `OpenIapError` members are no longer `object` singletons, so value references need `()`: `DeveloperError`, `PurchaseFailed`, `UserCancelled`, `ServiceUnavailable`, `BillingUnavailable`, `ItemUnavailable`, `BillingError`, `ItemAlreadyOwned`, `ItemNotOwned`, `ServiceDisconnected`, `FeatureNotSupported`, `ServiceTimeout`, `UnknownError`. Migration: - `throw OpenIapError.DeveloperError` → `throw OpenIapError.DeveloperError()` - `continuation.resumeWithException(OpenIapError.PurchaseFailed)` → `continuation.resumeWithException(OpenIapError.PurchaseFailed())` Companion `.CODE` / `.MESSAGE` accesses and `is OpenIapError.DeveloperError` type checks keep working unchanged. All internal call sites in `packages/google` and `libraries/flutter_inapp_purchase/android` updated; tests updated accordingly. **Dart / Swift / TypeScript / GDScript consumers**: no source-breaking changes. `PurchaseError.debugMessage` is an additive optional field; existing constructor calls keep compiling. ## Versions - `openiap-google` / `openiap-google-horizon` — **2.0.0** (major bump for the error data-class refactor) - `flutter_inapp_purchase` 9.0.4 (picks up openiap-google 2.0.0; Dart API unchanged) Release notes entry consolidated into a single 2026-04-15 item in `packages/docs/src/pages/docs/updates/releases.tsx`. ## Test plan - [x] `cd packages/google && ./gradlew :openiap:compilePlayDebugKotlin :openiap:compileHorizonDebugKotlin` — both flavors compile - [x] `cd packages/google && ./gradlew :openiap:testPlayDebugUnitTest` — all `OpenIapErrorTest` assertions pass, including new `fromBillingResponseCode forwards debugMessage for every response code` / `DeveloperError carries debug message when provided` / `PurchaseFailed carries debug message when provided` cases - [x] `cd packages/apple && swift build && swift test --filter OpenIapTests` — 87 tests pass, including new `testPurchaseErrorCarriesDebugMessage` - [x] `cd libraries/flutter_inapp_purchase && flutter analyze && flutter test` — 244 tests pass (new `convertToPurchaseError forwards debugMessage and responseCode from PurchaseResult`) - [x] `cd libraries/kmp-iap && ./gradlew :library:build -x test` — builds against the auto-synced Types.kt - [x] `cd libraries/expo-iap && bun run test && bun run lint:tsc` — 46 tests pass, tsc clean - [x] `cd libraries/react-native-iap && yarn typecheck:lib && yarn test:library` — 269 tests pass - [x] `cd packages/docs && bunx prettier --check . && bun run typecheck` — clean 🤖 Generated with [Claude Code](https://claude.ai/code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added `ServiceTimeout` error code for billing service timeout scenarios. * Purchase errors now include debug information from the billing service for better diagnostics. * **Bug Fixes** * Fixed subscription replacement parameter wiring to properly forward values to the native billing layer. * **Documentation** * Updated release notes documenting billing debug message enhancements and subscription replacement fixes. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The two flutter_inapp_purchase entries shipped from the same investigation thread (#96 → #97 channel fix → #98 debugMessage pipe) so grouping them under a single header keeps the 2026-04-15 cut readable instead of splitting the narrative across two cards. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Hi @nero-angela — good news,
What this means for your DEFERRED downgrade testWhen you reproduce the failure on 9.1.0, FlutterInappPurchase.instance.purchaseErrorListener.listen((error) {
print('[${error.code}] ${error.message}');
if (error.debugMessage != null) {
print(' Play says: ${error.debugMessage}');
}
});For your DEFERRED downgrade, Could you re-run with 9.1.0 and paste the
|
|
Thanks to the method you shared, I was able to easily retrieve the reason for rejection. The reason for rejection is as follows. Since I couldn’t find any official documentation or relevant precedent cases for this issue, I will review adopting a different refund policy for now. Thank you for your prompt response. |
|
Glad the debug pipeline helped you pin it down. Unfortunately Google's documentation on which combinations are valid is sparse, so trial-and-error in the sandbox is often the only way to confirm. A refund-based flow is a reasonable workaround. Good luck, and feel free to reopen or file a new issue if you hit other billing oddities! |
Summary
AndroidReplacementMode.deferred.valuewas4, which is not a validBillingFlowParams.SubscriptionUpdateParams.ReplacementModevalue (the legacy API consumed bysetSubscriptionReplacementMode(int)on Android). Verified against billing-ktx 8.3.0 bytecode: legacy values areCHARGE_FULL_PRICE = 5,DEFERRED = 6. Fixed and added comprehensive test coverage.SubscriptionReplacementModeAndroidExt.ktfrommain/toplay/and replaced the hardcoded0..6literals with direct references toBillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementModeconstants — so this mapping tracks Google's library instead of a hand-typed copy.testPlay/and updated it to assert against the same native constants, making the mapping self-correcting if Google ever renumbers them.Closes #92
Why both changes together
#92 was caused by hand-typed integer mappings drifting from the native Billing Library values. Patching only the Flutter int wouldn't prevent the same bug on the Kotlin side, where
0..6were also literal. Sourcing the values from the native constants on Kotlin removes that whole class of bug; the Flutter int extension is also documented as the legacy path with a pointer to the new string-keyedSubscriptionProductReplacementParamsAndroidAPI for callers that want to avoid integers entirely.Test plan
cd packages/google && ./gradlew :openiap:compilePlayDebugKotlin :openiap:compileHorizonDebugKotlin— both flavors compilecd packages/google && ./gradlew :openiap:testPlayDebugUnitTest --tests dev.hyo.openiap.SubscriptionReplacementModeTest— all native-constant assertions passcd libraries/flutter_inapp_purchase && flutter analyze— no issuescd libraries/flutter_inapp_purchase && flutter test— 242 tests pass🤖 Generated with Claude Code
Summary by CodeRabbit
Bug Fixes
Tests
Refactor