diff --git a/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx b/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx index 613cec2f..e8b8bd91 100644 --- a/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx +++ b/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx @@ -1082,30 +1082,70 @@ func format_date(timestamp: int) -> String:

Available replacement modes:

- +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ModeLegacy API8.1.0+ APIDescription
WITH_TIME_PRORATION11Immediate change with prorated credit
CHARGE_PRORATED_PRICE22Immediate change, charge difference (upgrade only)
WITHOUT_PRORATION33Immediate change, no proration
CHARGE_FULL_PRICE54Immediate change, charge full price
DEFERRED65Change at next billing cycle
KEEP_EXISTING6Keep existing payment schedule (8.1.0+ only)
+
+ +

+ Note: Legacy API refers to SubscriptionUpdateParams.ReplacementMode, + 8.1.0+ API refers to SubscriptionProductReplacementParams.ReplacementMode. + The integer values differ for CHARGE_FULL_PRICE and DEFERRED between APIs. +

Note: If you don't specify a replacement @@ -1307,7 +1347,7 @@ if current_sub:

  1. - Use DEFERRED replacement mode (value: 6) + Use DEFERRED replacement mode (Legacy API: 6, 8.1.0+ API: 5)
  2. No immediate charge to the user
  3. User keeps premium access until current period ends
  4. @@ -1325,7 +1365,7 @@ if current_sub: >

    - When using DEFERRED replacement mode (6), the purchase + When using DEFERRED replacement mode, the purchase callback completes successfully with an empty purchase list. {' '} @@ -1377,7 +1417,7 @@ if (premiumPurchase) { await requestPurchase({ sku: 'basic_monthly', purchaseToken: premiumPurchase.purchaseToken, - replacementMode: 6, // DEFERRED - Change at renewal + replacementMode: 6, // DEFERRED (Legacy API value) - Change at renewal }); console.log('✅ Downgrade scheduled for next billing cycle'); @@ -1398,7 +1438,7 @@ premiumPurchase?.let { purchase -> android { skus = listOf("basic_monthly") purchaseToken = purchase.purchaseToken - replacementMode = 6 // DEFERRED - Change at renewal + replacementMode = 6 // DEFERRED (Legacy API value) - Change at renewal } } @@ -1422,7 +1462,7 @@ if (premiumPurchase != null) { google: RequestPurchaseAndroidProps( skus: ['basic_monthly'], purchaseToken: premiumPurchase.purchaseToken, - replacementMode: 6, // DEFERRED - Change at renewal + replacementMode: 6, // DEFERRED (Legacy API value) - Change at renewal ), ), ), @@ -1450,7 +1490,7 @@ if premium_purchase: props.request.google = RequestPurchaseAndroidProps.new() props.request.google.skus = ["basic_monthly"] props.request.google.purchase_token = premium_purchase.purchase_token - props.request.google.replacement_mode = 6 # DEFERRED - Change at renewal + props.request.google.replacement_mode = 6 # DEFERRED (Legacy API value) - Change at renewal props.type = ProductType.SUBS await iap.request_purchase(props) @@ -1707,11 +1747,11 @@ for purchase in purchases: override the default configured in Google Play Console

  5. - Use WITH_TIME_PRORATION (1) for upgrades to + Use WITH_TIME_PRORATION for upgrades to give users credit for unused time
  6. - Use DEFERRED (6) for downgrades to let + Use DEFERRED for downgrades to let users keep premium features until period ends
  7. @@ -1759,7 +1799,7 @@ async function changeSubscription( // Choose appropriate replacement mode const replacementMode = isUpgrade ? 1 // WITH_TIME_PRORATION - Upgrade: give credit - : 6; // DEFERRED - Downgrade: change at renewal + : 6; // DEFERRED (Legacy API value) - Downgrade: change at renewal try { await requestPurchase({ @@ -1807,7 +1847,7 @@ suspend fun changeSubscription( val replacementMode = if (isUpgrade) { 1 // WITH_TIME_PRORATION - Upgrade: give credit } else { - 6 // DEFERRED - Downgrade: change at renewal + 6 // DEFERRED (Legacy API value) - Downgrade: change at renewal } try { @@ -1858,7 +1898,7 @@ Future changeSubscription( // Choose appropriate replacement mode final replacementMode = isUpgrade ? 1 // WITH_TIME_PRORATION - Upgrade: give credit - : 6; // DEFERRED - Downgrade: change at renewal + : 6; // DEFERRED (Legacy API value) - Downgrade: change at renewal try { await FlutterInappPurchase.instance.requestPurchase( @@ -1908,7 +1948,7 @@ func change_subscription(new_sku: String, is_upgrade: bool) -> void: # Choose appropriate replacement mode var replacement_mode = 1 if is_upgrade else 6 # 1 = WITH_TIME_PRORATION - Upgrade: give credit - # 6 = DEFERRED - Downgrade: change at renewal + # 6 = DEFERRED (Legacy API value) - Downgrade: change at renewal var props = RequestPurchaseProps.new() props.request = RequestPurchasePropsByPlatforms.new() diff --git a/packages/docs/src/pages/docs/updates/notes.tsx b/packages/docs/src/pages/docs/updates/notes.tsx index 5c7dd0df..58308036 100644 --- a/packages/docs/src/pages/docs/updates/notes.tsx +++ b/packages/docs/src/pages/docs/updates/notes.tsx @@ -26,6 +26,49 @@ function Notes() { useScrollToHash(); const allNotes: Note[] = [ + // GQL 1.3.15 / Google 1.3.27 / Apple 1.3.13 - Jan 21, 2026 + { + id: 'gql-1-3-15-google-1-3-27-apple-1-3-13', + date: new Date('2026-01-21'), + element: ( +
    + + 📅 openiap-gql v1.3.15 / openiap-google v1.3.27 / openiap-apple v1.3.13 - Bug Fix + + +

    Android - Fix SubscriptionProductReplacementParams ReplacementMode Mapping:

    +

    + Fixed incorrect replacementModeConstant mapping in applySubscriptionProductReplacementParams. + The function was using values from the legacy SubscriptionUpdateParams.ReplacementMode API instead of + the new SubscriptionProductReplacementParams.ReplacementMode API (Billing Library 8.1.0+). +

    +

    + Issue: #71 +

    + + + + + + + + + + + + + + +
    ModeBefore (Wrong)After (Correct)
    CHARGE_FULL_PRICE54
    DEFERRED65
    KEEP_EXISTING76
    + +

    References:

    + +
    + ), + }, // GQL 1.3.14 / Google 1.3.25 / Apple 1.3.13 - Jan 19, 2026 { id: 'gql-1-3-14-google-1-3-25-apple-1-3-13', diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt new file mode 100644 index 00000000..c1b7a74b --- /dev/null +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt @@ -0,0 +1,25 @@ +package dev.hyo.openiap + +/** + * Extension function to convert SubscriptionReplacementModeAndroid enum to + * BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode constants. + * + * Reference (Billing Library 8.1.0+): + * https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode + * + * Note: These constants differ from the legacy SubscriptionUpdateParams.ReplacementMode API. + * See: https://github.com/hyodotdev/openiap/issues/71 + * + * @return The integer constant for SubscriptionProductReplacementParams.ReplacementMode + */ +internal fun SubscriptionReplacementModeAndroid.toReplacementModeConstant(): Int { + return when (this) { + SubscriptionReplacementModeAndroid.UnknownReplacementMode -> 0 + SubscriptionReplacementModeAndroid.WithTimeProration -> 1 + SubscriptionReplacementModeAndroid.ChargeProratedPrice -> 2 + SubscriptionReplacementModeAndroid.WithoutProration -> 3 + SubscriptionReplacementModeAndroid.ChargeFullPrice -> 4 + SubscriptionReplacementModeAndroid.Deferred -> 5 + SubscriptionReplacementModeAndroid.KeepExisting -> 6 + } +} diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index c0a6eb65..51fed5da 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -1605,16 +1605,8 @@ class OpenIapModule( params: SubscriptionProductReplacementParamsAndroid ) { try { - // Convert our enum to BillingClient replacement mode constant - val replacementModeConstant = when (params.replacementMode) { - SubscriptionReplacementModeAndroid.UnknownReplacementMode -> 0 - SubscriptionReplacementModeAndroid.WithTimeProration -> 1 - SubscriptionReplacementModeAndroid.ChargeProratedPrice -> 2 - SubscriptionReplacementModeAndroid.WithoutProration -> 3 - SubscriptionReplacementModeAndroid.Deferred -> 6 - SubscriptionReplacementModeAndroid.ChargeFullPrice -> 5 - SubscriptionReplacementModeAndroid.KeepExisting -> 7 // New in 8.1.0 - } + // Convert our enum to BillingClient SubscriptionProductReplacementParams.ReplacementMode constant + val replacementModeConstant = params.replacementMode.toReplacementModeConstant() // Build SubscriptionProductReplacementParams using reflection // Note: SubscriptionProductReplacementParams is nested under ProductDetailsParams (Billing Library 8.1.0+) diff --git a/packages/google/openiap/src/test/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt b/packages/google/openiap/src/test/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt new file mode 100644 index 00000000..d6de6146 --- /dev/null +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt @@ -0,0 +1,159 @@ +package dev.hyo.openiap + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +/** + * Tests for SubscriptionReplacementModeAndroid enum and its mapping to + * BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode constants. + * + * Reference (Billing Library 8.1.0+): + * https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode + * + * Note: These constants differ from the legacy SubscriptionUpdateParams.ReplacementMode API. + * See: https://github.com/hyodotdev/openiap/issues/71 + */ +class SubscriptionReplacementModeTest { + + // MARK: - SubscriptionProductReplacementParams.ReplacementMode constants (8.1.0+) + + @Test + fun `UNKNOWN_REPLACEMENT_MODE constant is 0`() { + assertEquals(0, SubscriptionReplacementModeAndroid.UnknownReplacementMode.toReplacementModeConstant()) + } + + @Test + fun `WITH_TIME_PRORATION constant is 1`() { + assertEquals(1, SubscriptionReplacementModeAndroid.WithTimeProration.toReplacementModeConstant()) + } + + @Test + fun `CHARGE_PRORATED_PRICE constant is 2`() { + assertEquals(2, SubscriptionReplacementModeAndroid.ChargeProratedPrice.toReplacementModeConstant()) + } + + @Test + fun `WITHOUT_PRORATION constant is 3`() { + assertEquals(3, SubscriptionReplacementModeAndroid.WithoutProration.toReplacementModeConstant()) + } + + @Test + fun `CHARGE_FULL_PRICE constant is 4 for SubscriptionProductReplacementParams`() { + // This was incorrectly mapped to 5 (legacy value) before the fix + // Correct value for SubscriptionProductReplacementParams.ReplacementMode is 4 + assertEquals(4, SubscriptionReplacementModeAndroid.ChargeFullPrice.toReplacementModeConstant()) + } + + @Test + fun `DEFERRED constant is 5 for SubscriptionProductReplacementParams`() { + // This was incorrectly mapped to 6 (legacy value) before the fix + // Correct value for SubscriptionProductReplacementParams.ReplacementMode is 5 + assertEquals(5, SubscriptionReplacementModeAndroid.Deferred.toReplacementModeConstant()) + } + + @Test + fun `KEEP_EXISTING constant is 6 for SubscriptionProductReplacementParams`() { + // This was incorrectly mapped to 7 before the fix + // Correct value for SubscriptionProductReplacementParams.ReplacementMode is 6 + // Note: KEEP_EXISTING is only available in Billing Library 8.1.0+ + assertEquals(6, SubscriptionReplacementModeAndroid.KeepExisting.toReplacementModeConstant()) + } + + // MARK: - Verify all enum values are covered + + @Test + fun `all SubscriptionReplacementModeAndroid values have correct constants`() { + val expectedMappings = mapOf( + SubscriptionReplacementModeAndroid.UnknownReplacementMode to 0, + SubscriptionReplacementModeAndroid.WithTimeProration to 1, + SubscriptionReplacementModeAndroid.ChargeProratedPrice to 2, + SubscriptionReplacementModeAndroid.WithoutProration to 3, + SubscriptionReplacementModeAndroid.ChargeFullPrice to 4, + SubscriptionReplacementModeAndroid.Deferred to 5, + SubscriptionReplacementModeAndroid.KeepExisting to 6 + ) + + // Verify all enum values are tested + assertEquals(7, SubscriptionReplacementModeAndroid.entries.size) + assertEquals(7, expectedMappings.size) + + // Verify each mapping + for ((mode, expectedConstant) in expectedMappings) { + assertEquals( + "Mapping for $mode should be $expectedConstant", + expectedConstant, + mode.toReplacementModeConstant() + ) + } + } + + // MARK: - Document the difference between legacy and new API + + @Test + fun `CHARGE_FULL_PRICE differs between legacy and new API`() { + // Legacy SubscriptionUpdateParams.ReplacementMode: CHARGE_FULL_PRICE = 5 + // New SubscriptionProductReplacementParams.ReplacementMode: CHARGE_FULL_PRICE = 4 + val legacyValue = 5 + val newValue = 4 + + assertEquals(newValue, SubscriptionReplacementModeAndroid.ChargeFullPrice.toReplacementModeConstant()) + // Document that legacy value is different + assertNotEquals( + "Legacy and new API values should differ for CHARGE_FULL_PRICE", + legacyValue, + newValue + ) + } + + @Test + fun `DEFERRED differs between legacy and new API`() { + // Legacy SubscriptionUpdateParams.ReplacementMode: DEFERRED = 6 + // New SubscriptionProductReplacementParams.ReplacementMode: DEFERRED = 5 + val legacyValue = 6 + val newValue = 5 + + assertEquals(newValue, SubscriptionReplacementModeAndroid.Deferred.toReplacementModeConstant()) + // Document that legacy value is different + assertNotEquals( + "Legacy and new API values should differ for DEFERRED", + legacyValue, + newValue + ) + } + + // MARK: - Enum JSON serialization tests + + @Test + fun `SubscriptionReplacementModeAndroid fromJson parses correctly`() { + assertEquals(SubscriptionReplacementModeAndroid.UnknownReplacementMode, SubscriptionReplacementModeAndroid.fromJson("unknown-replacement-mode")) + assertEquals(SubscriptionReplacementModeAndroid.WithTimeProration, SubscriptionReplacementModeAndroid.fromJson("with-time-proration")) + assertEquals(SubscriptionReplacementModeAndroid.ChargeProratedPrice, SubscriptionReplacementModeAndroid.fromJson("charge-prorated-price")) + assertEquals(SubscriptionReplacementModeAndroid.WithoutProration, SubscriptionReplacementModeAndroid.fromJson("without-proration")) + assertEquals(SubscriptionReplacementModeAndroid.ChargeFullPrice, SubscriptionReplacementModeAndroid.fromJson("charge-full-price")) + assertEquals(SubscriptionReplacementModeAndroid.Deferred, SubscriptionReplacementModeAndroid.fromJson("deferred")) + assertEquals(SubscriptionReplacementModeAndroid.KeepExisting, SubscriptionReplacementModeAndroid.fromJson("keep-existing")) + } + + @Test + fun `SubscriptionReplacementModeAndroid fromJson parses PascalCase correctly`() { + assertEquals(SubscriptionReplacementModeAndroid.UnknownReplacementMode, SubscriptionReplacementModeAndroid.fromJson("UnknownReplacementMode")) + assertEquals(SubscriptionReplacementModeAndroid.WithTimeProration, SubscriptionReplacementModeAndroid.fromJson("WithTimeProration")) + assertEquals(SubscriptionReplacementModeAndroid.ChargeProratedPrice, SubscriptionReplacementModeAndroid.fromJson("ChargeProratedPrice")) + assertEquals(SubscriptionReplacementModeAndroid.WithoutProration, SubscriptionReplacementModeAndroid.fromJson("WithoutProration")) + assertEquals(SubscriptionReplacementModeAndroid.ChargeFullPrice, SubscriptionReplacementModeAndroid.fromJson("ChargeFullPrice")) + assertEquals(SubscriptionReplacementModeAndroid.Deferred, SubscriptionReplacementModeAndroid.fromJson("Deferred")) + assertEquals(SubscriptionReplacementModeAndroid.KeepExisting, SubscriptionReplacementModeAndroid.fromJson("KeepExisting")) + } + + @Test + fun `SubscriptionReplacementModeAndroid toJson returns kebab-case`() { + assertEquals("unknown-replacement-mode", SubscriptionReplacementModeAndroid.UnknownReplacementMode.toJson()) + assertEquals("with-time-proration", SubscriptionReplacementModeAndroid.WithTimeProration.toJson()) + assertEquals("charge-prorated-price", SubscriptionReplacementModeAndroid.ChargeProratedPrice.toJson()) + assertEquals("without-proration", SubscriptionReplacementModeAndroid.WithoutProration.toJson()) + assertEquals("charge-full-price", SubscriptionReplacementModeAndroid.ChargeFullPrice.toJson()) + assertEquals("deferred", SubscriptionReplacementModeAndroid.Deferred.toJson()) + assertEquals("keep-existing", SubscriptionReplacementModeAndroid.KeepExisting.toJson()) + } +}