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:
-
- -
-
WITH_TIME_PRORATION - Immediate change
- with prorated credit
-
- -
-
CHARGE_PRORATED_PRICE - Immediate change,
- charge difference (upgrade only)
-
- -
-
WITHOUT_PRORATION - Immediate change, no
- proration
-
- -
-
CHARGE_FULL_PRICE - Immediate change,
- charge full price
-
- -
-
DEFERRED - Change at next billing cycle
-
- -
-
KEEP_EXISTING - Keep the existing payment schedule unchanged (8.1.0+)
-
-
+
+
+
+
+ | Mode |
+ Legacy API |
+ 8.1.0+ API |
+ Description |
+
+
+
+
+ WITH_TIME_PRORATION |
+ 1 |
+ 1 |
+ Immediate change with prorated credit |
+
+
+ CHARGE_PRORATED_PRICE |
+ 2 |
+ 2 |
+ Immediate change, charge difference (upgrade only) |
+
+
+ WITHOUT_PRORATION |
+ 3 |
+ 3 |
+ Immediate change, no proration |
+
+
+ CHARGE_FULL_PRICE |
+ 5 |
+ 4 |
+ Immediate change, charge full price |
+
+
+ DEFERRED |
+ 6 |
+ 5 |
+ Change at next billing cycle |
+
+
+ KEEP_EXISTING |
+ — |
+ 6 |
+ Keep 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:
-
- Use DEFERRED replacement mode (value: 6)
+ Use DEFERRED replacement mode (Legacy API: 6, 8.1.0+ API: 5)
- No immediate charge to the user
- User keeps premium access until current period ends
@@ -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
-
- Use WITH_TIME_PRORATION (1) for upgrades to
+ Use WITH_TIME_PRORATION for upgrades to
give users credit for unused time
-
- Use DEFERRED (6) for downgrades to let
+ Use DEFERRED for downgrades to let
users keep premium features until period ends
-
@@ -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
+
+
+
+
+
+ | Mode |
+ Before (Wrong) |
+ After (Correct) |
+
+
+
+ | CHARGE_FULL_PRICE | 5 | 4 |
+ | DEFERRED | 6 | 5 |
+ | KEEP_EXISTING | 7 | 6 |
+
+
+
+
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())
+ }
+}