From 3f3e4c55a2b6adb4a456f3ff8e59d3670e5bd677 Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 21 Jan 2026 06:00:01 +0900 Subject: [PATCH 1/4] fix(google): correct SubscriptionProductReplacementParams ReplacementMode constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix incorrect replacementModeConstant mapping in applySubscriptionProductReplacementParams(). The code was using values from the legacy SubscriptionUpdateParams.ReplacementMode API instead of the new SubscriptionProductReplacementParams.ReplacementMode API (Billing 8.1.0+). Affected modes: - CHARGE_FULL_PRICE: 5 → 4 - DEFERRED: 6 → 5 - KEEP_EXISTING: 7 → 6 Also: - Add SubscriptionReplacementModeTest.kt with comprehensive test coverage - Update subscription-upgrade-downgrade docs with correct API values table - Add v1.3.27 release notes Closes #71 Co-Authored-By: Claude Opus 4.5 --- .../subscription-upgrade-downgrade.tsx | 86 +++++--- .../docs/src/pages/docs/updates/notes.tsx | 43 ++++ .../java/dev/hyo/openiap/OpenIapModule.kt | 10 +- .../SubscriptionReplacementModeTest.kt | 201 ++++++++++++++++++ 4 files changed, 308 insertions(+), 32 deletions(-) create mode 100644 packages/google/openiap/src/test/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt 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..5a4063b5 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,60 @@ 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 +1337,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 +1355,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. {' '} @@ -1707,11 +1737,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. 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/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index c0a6eb65..07214e4a 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,15 +1605,17 @@ class OpenIapModule( params: SubscriptionProductReplacementParamsAndroid ) { try { - // Convert our enum to BillingClient replacement mode constant + // Convert our enum to BillingClient SubscriptionProductReplacementParams.ReplacementMode constant + // Reference: https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode + // Note: These values differ from SubscriptionUpdateParams.ReplacementMode (legacy API) 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 + SubscriptionReplacementModeAndroid.ChargeFullPrice -> 4 + SubscriptionReplacementModeAndroid.Deferred -> 5 + SubscriptionReplacementModeAndroid.KeepExisting -> 6 } // Build SubscriptionProductReplacementParams using reflection 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..a0026f7b --- /dev/null +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt @@ -0,0 +1,201 @@ +package dev.hyo.openiap + +import org.junit.Assert.assertEquals +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 { + + /** + * Maps SubscriptionReplacementModeAndroid enum to + * SubscriptionProductReplacementParams.ReplacementMode constants (Billing Library 8.1.0+). + * + * These are the CORRECT values for the new API: + * - UNKNOWN_REPLACEMENT_MODE = 0 + * - WITH_TIME_PRORATION = 1 + * - CHARGE_PRORATED_PRICE = 2 + * - WITHOUT_PRORATION = 3 + * - CHARGE_FULL_PRICE = 4 + * - DEFERRED = 5 + * - KEEP_EXISTING = 6 + */ + private fun getSubscriptionProductReplacementModeConstant(mode: SubscriptionReplacementModeAndroid): Int { + return when (mode) { + SubscriptionReplacementModeAndroid.UnknownReplacementMode -> 0 + SubscriptionReplacementModeAndroid.WithTimeProration -> 1 + SubscriptionReplacementModeAndroid.ChargeProratedPrice -> 2 + SubscriptionReplacementModeAndroid.WithoutProration -> 3 + SubscriptionReplacementModeAndroid.ChargeFullPrice -> 4 + SubscriptionReplacementModeAndroid.Deferred -> 5 + SubscriptionReplacementModeAndroid.KeepExisting -> 6 + } + } + + /** + * Maps SubscriptionReplacementModeAndroid enum to the LEGACY + * SubscriptionUpdateParams.ReplacementMode constants. + * + * These are the OLD values (DO NOT USE with SubscriptionProductReplacementParams): + * - UNKNOWN_REPLACEMENT_MODE = 0 + * - WITH_TIME_PRORATION = 1 + * - CHARGE_PRORATED_PRICE = 2 + * - WITHOUT_PRORATION = 3 + * - CHARGE_FULL_PRICE = 5 <-- Different! + * - DEFERRED = 6 <-- Different! + * - (KEEP_EXISTING not available in legacy API) + */ + @Suppress("unused") + private fun getLegacySubscriptionUpdateModeConstant(mode: SubscriptionReplacementModeAndroid): Int { + return when (mode) { + SubscriptionReplacementModeAndroid.UnknownReplacementMode -> 0 + SubscriptionReplacementModeAndroid.WithTimeProration -> 1 + SubscriptionReplacementModeAndroid.ChargeProratedPrice -> 2 + SubscriptionReplacementModeAndroid.WithoutProration -> 3 + SubscriptionReplacementModeAndroid.ChargeFullPrice -> 5 // Legacy value + SubscriptionReplacementModeAndroid.Deferred -> 6 // Legacy value + SubscriptionReplacementModeAndroid.KeepExisting -> 7 // Not in legacy API + } + } + + // MARK: - SubscriptionProductReplacementParams.ReplacementMode constants (8.1.0+) + + @Test + fun `UNKNOWN_REPLACEMENT_MODE constant is 0`() { + assertEquals(0, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.UnknownReplacementMode)) + } + + @Test + fun `WITH_TIME_PRORATION constant is 1`() { + assertEquals(1, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.WithTimeProration)) + } + + @Test + fun `CHARGE_PRORATED_PRICE constant is 2`() { + assertEquals(2, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.ChargeProratedPrice)) + } + + @Test + fun `WITHOUT_PRORATION constant is 3`() { + assertEquals(3, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.WithoutProration)) + } + + @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, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.ChargeFullPrice)) + } + + @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, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.Deferred)) + } + + @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, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.KeepExisting)) + } + + // 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, + getSubscriptionProductReplacementModeConstant(mode) + ) + } + } + + // 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, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.ChargeFullPrice)) + // Document that legacy value is different + assert(legacyValue != newValue) { "Legacy and new API values should differ for CHARGE_FULL_PRICE" } + } + + @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, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.Deferred)) + // Document that legacy value is different + assert(legacyValue != newValue) { "Legacy and new API values should differ for DEFERRED" } + } + + // 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()) + } +} From a1bb6537cbb379e2dcc28b91ea640ffcc42ef2a4 Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 21 Jan 2026 06:12:00 +0900 Subject: [PATCH 2/4] fix: address PR review comments - Wrap replacement mode table in scrollable container for narrow screens - Replace Kotlin assert() with JUnit assertNotEquals() for reliable test execution Co-Authored-By: Claude --- .../subscription-upgrade-downgrade.tsx | 106 ++++++++++-------- .../SubscriptionReplacementModeTest.kt | 13 ++- 2 files changed, 69 insertions(+), 50 deletions(-) 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 5a4063b5..4dca5c41 100644 --- a/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx +++ b/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx @@ -1082,54 +1082,64 @@ 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)
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    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, 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 index a0026f7b..d299f0ae 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt @@ -1,6 +1,7 @@ package dev.hyo.openiap import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Test /** @@ -149,7 +150,11 @@ class SubscriptionReplacementModeTest { assertEquals(newValue, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.ChargeFullPrice)) // Document that legacy value is different - assert(legacyValue != newValue) { "Legacy and new API values should differ for CHARGE_FULL_PRICE" } + assertNotEquals( + "Legacy and new API values should differ for CHARGE_FULL_PRICE", + legacyValue, + newValue + ) } @Test @@ -161,7 +166,11 @@ class SubscriptionReplacementModeTest { assertEquals(newValue, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.Deferred)) // Document that legacy value is different - assert(legacyValue != newValue) { "Legacy and new API values should differ for DEFERRED" } + assertNotEquals( + "Legacy and new API values should differ for DEFERRED", + legacyValue, + newValue + ) } // MARK: - Enum JSON serialization tests From 5c99380d9860be5d0621cef57524c94b991dda28 Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 21 Jan 2026 06:20:49 +0900 Subject: [PATCH 3/4] refactor: extract ReplacementMode mapping to shared extension function - Create SubscriptionReplacementModeAndroidExt.kt with toReplacementModeConstant() - Update OpenIapModule.kt to use the shared extension function - Simplify test file by removing duplicated mapping logic This ensures tests verify actual production logic instead of a reimplementation. Co-Authored-By: Claude --- .../SubscriptionReplacementModeAndroidExt.kt | 25 +++++++ .../java/dev/hyo/openiap/OpenIapModule.kt | 12 +--- .../SubscriptionReplacementModeTest.kt | 71 +++---------------- 3 files changed, 36 insertions(+), 72 deletions(-) create mode 100644 packages/google/openiap/src/main/java/dev/hyo/openiap/SubscriptionReplacementModeAndroidExt.kt 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 07214e4a..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 @@ -1606,17 +1606,7 @@ class OpenIapModule( ) { try { // Convert our enum to BillingClient SubscriptionProductReplacementParams.ReplacementMode constant - // Reference: https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.ProductDetailsParams.SubscriptionProductReplacementParams.ReplacementMode - // Note: These values differ from SubscriptionUpdateParams.ReplacementMode (legacy API) - val replacementModeConstant = when (params.replacementMode) { - SubscriptionReplacementModeAndroid.UnknownReplacementMode -> 0 - SubscriptionReplacementModeAndroid.WithTimeProration -> 1 - SubscriptionReplacementModeAndroid.ChargeProratedPrice -> 2 - SubscriptionReplacementModeAndroid.WithoutProration -> 3 - SubscriptionReplacementModeAndroid.ChargeFullPrice -> 4 - SubscriptionReplacementModeAndroid.Deferred -> 5 - SubscriptionReplacementModeAndroid.KeepExisting -> 6 - } + 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 index d299f0ae..d6de6146 100644 --- a/packages/google/openiap/src/test/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt +++ b/packages/google/openiap/src/test/java/dev/hyo/openiap/SubscriptionReplacementModeTest.kt @@ -16,91 +16,40 @@ import org.junit.Test */ class SubscriptionReplacementModeTest { - /** - * Maps SubscriptionReplacementModeAndroid enum to - * SubscriptionProductReplacementParams.ReplacementMode constants (Billing Library 8.1.0+). - * - * These are the CORRECT values for the new API: - * - UNKNOWN_REPLACEMENT_MODE = 0 - * - WITH_TIME_PRORATION = 1 - * - CHARGE_PRORATED_PRICE = 2 - * - WITHOUT_PRORATION = 3 - * - CHARGE_FULL_PRICE = 4 - * - DEFERRED = 5 - * - KEEP_EXISTING = 6 - */ - private fun getSubscriptionProductReplacementModeConstant(mode: SubscriptionReplacementModeAndroid): Int { - return when (mode) { - SubscriptionReplacementModeAndroid.UnknownReplacementMode -> 0 - SubscriptionReplacementModeAndroid.WithTimeProration -> 1 - SubscriptionReplacementModeAndroid.ChargeProratedPrice -> 2 - SubscriptionReplacementModeAndroid.WithoutProration -> 3 - SubscriptionReplacementModeAndroid.ChargeFullPrice -> 4 - SubscriptionReplacementModeAndroid.Deferred -> 5 - SubscriptionReplacementModeAndroid.KeepExisting -> 6 - } - } - - /** - * Maps SubscriptionReplacementModeAndroid enum to the LEGACY - * SubscriptionUpdateParams.ReplacementMode constants. - * - * These are the OLD values (DO NOT USE with SubscriptionProductReplacementParams): - * - UNKNOWN_REPLACEMENT_MODE = 0 - * - WITH_TIME_PRORATION = 1 - * - CHARGE_PRORATED_PRICE = 2 - * - WITHOUT_PRORATION = 3 - * - CHARGE_FULL_PRICE = 5 <-- Different! - * - DEFERRED = 6 <-- Different! - * - (KEEP_EXISTING not available in legacy API) - */ - @Suppress("unused") - private fun getLegacySubscriptionUpdateModeConstant(mode: SubscriptionReplacementModeAndroid): Int { - return when (mode) { - SubscriptionReplacementModeAndroid.UnknownReplacementMode -> 0 - SubscriptionReplacementModeAndroid.WithTimeProration -> 1 - SubscriptionReplacementModeAndroid.ChargeProratedPrice -> 2 - SubscriptionReplacementModeAndroid.WithoutProration -> 3 - SubscriptionReplacementModeAndroid.ChargeFullPrice -> 5 // Legacy value - SubscriptionReplacementModeAndroid.Deferred -> 6 // Legacy value - SubscriptionReplacementModeAndroid.KeepExisting -> 7 // Not in legacy API - } - } - // MARK: - SubscriptionProductReplacementParams.ReplacementMode constants (8.1.0+) @Test fun `UNKNOWN_REPLACEMENT_MODE constant is 0`() { - assertEquals(0, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.UnknownReplacementMode)) + assertEquals(0, SubscriptionReplacementModeAndroid.UnknownReplacementMode.toReplacementModeConstant()) } @Test fun `WITH_TIME_PRORATION constant is 1`() { - assertEquals(1, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.WithTimeProration)) + assertEquals(1, SubscriptionReplacementModeAndroid.WithTimeProration.toReplacementModeConstant()) } @Test fun `CHARGE_PRORATED_PRICE constant is 2`() { - assertEquals(2, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.ChargeProratedPrice)) + assertEquals(2, SubscriptionReplacementModeAndroid.ChargeProratedPrice.toReplacementModeConstant()) } @Test fun `WITHOUT_PRORATION constant is 3`() { - assertEquals(3, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.WithoutProration)) + 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, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.ChargeFullPrice)) + 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, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.Deferred)) + assertEquals(5, SubscriptionReplacementModeAndroid.Deferred.toReplacementModeConstant()) } @Test @@ -108,7 +57,7 @@ class SubscriptionReplacementModeTest { // 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, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.KeepExisting)) + assertEquals(6, SubscriptionReplacementModeAndroid.KeepExisting.toReplacementModeConstant()) } // MARK: - Verify all enum values are covered @@ -134,7 +83,7 @@ class SubscriptionReplacementModeTest { assertEquals( "Mapping for $mode should be $expectedConstant", expectedConstant, - getSubscriptionProductReplacementModeConstant(mode) + mode.toReplacementModeConstant() ) } } @@ -148,7 +97,7 @@ class SubscriptionReplacementModeTest { val legacyValue = 5 val newValue = 4 - assertEquals(newValue, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.ChargeFullPrice)) + assertEquals(newValue, SubscriptionReplacementModeAndroid.ChargeFullPrice.toReplacementModeConstant()) // Document that legacy value is different assertNotEquals( "Legacy and new API values should differ for CHARGE_FULL_PRICE", @@ -164,7 +113,7 @@ class SubscriptionReplacementModeTest { val legacyValue = 6 val newValue = 5 - assertEquals(newValue, getSubscriptionProductReplacementModeConstant(SubscriptionReplacementModeAndroid.Deferred)) + assertEquals(newValue, SubscriptionReplacementModeAndroid.Deferred.toReplacementModeConstant()) // Document that legacy value is different assertNotEquals( "Legacy and new API values should differ for DEFERRED", From f6bbfad22c8e9491ec9ff4f6abee1e0a94195f94 Mon Sep 17 00:00:00 2001 From: Hyo Date: Wed, 21 Jan 2026 06:23:51 +0900 Subject: [PATCH 4/4] docs: annotate DEFERRED replacement mode examples with Legacy API version Add "(Legacy API value)" annotation to all code examples that use DEFERRED = 6 to clarify this is for the legacy SubscriptionUpdateParams API, not the new SubscriptionProductReplacementParams API (8.1.0+) where DEFERRED = 5. Co-Authored-By: Claude --- .../features/subscription-upgrade-downgrade.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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 4dca5c41..e8b8bd91 100644 --- a/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx +++ b/packages/docs/src/pages/docs/features/subscription-upgrade-downgrade.tsx @@ -1417,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'); @@ -1438,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 } } @@ -1462,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 ), ), ), @@ -1490,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) @@ -1799,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({ @@ -1847,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 { @@ -1898,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( @@ -1948,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()