From 26157e873ce7098b342219dabe7f5bf11a9beb49 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Fri, 13 Mar 2026 14:04:05 -0500 Subject: [PATCH 01/34] add StoreReplacementMode + API tests --- .../kotlin/StoreReplacementModeAPI.kt | 17 +++++++ .../purchases/galaxy/GalaxyPurchasingData.kt | 1 + purchases/api-defaults-bc7.txt | 8 ++++ purchases/api-defauts.txt | 8 ++++ purchases/api-entitlement.txt | 8 ++++ .../purchases/models/StoreReplacementMode.kt | 46 +++++++++++++++++++ 6 files changed, 88 insertions(+) create mode 100644 api-tester/src/main/java/com/revenuecat/apitester/kotlin/StoreReplacementModeAPI.kt create mode 100644 purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementMode.kt diff --git a/api-tester/src/main/java/com/revenuecat/apitester/kotlin/StoreReplacementModeAPI.kt b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/StoreReplacementModeAPI.kt new file mode 100644 index 0000000000..908c209026 --- /dev/null +++ b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/StoreReplacementModeAPI.kt @@ -0,0 +1,17 @@ +package com.revenuecat.apitester.kotlin + +import com.revenuecat.purchases.models.StoreReplacementMode + +@Suppress("unused", "UNUSED_VARIABLE") +private class StoreReplacementModeAPI { + fun check(mode: StoreReplacementMode) { + when (mode) { + StoreReplacementMode.WITHOUT_PRORATION, + StoreReplacementMode.WITH_TIME_PRORATION, + StoreReplacementMode.CHARGE_FULL_PRICE, + StoreReplacementMode.CHARGE_PRORATED_PRICE, + StoreReplacementMode.DEFERRED, + -> {} + }.exhaustive + } +} diff --git a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyPurchasingData.kt b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyPurchasingData.kt index 64bd5eadd6..dd4082594c 100644 --- a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyPurchasingData.kt +++ b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyPurchasingData.kt @@ -8,6 +8,7 @@ import dev.drewhamilton.poko.Poko @ExperimentalPreviewRevenueCatPurchasesAPI public sealed class GalaxyPurchasingData : PurchasingData { + @ExperimentalPreviewRevenueCatPurchasesAPI @Poko public class Product( override val productId: String, diff --git a/purchases/api-defaults-bc7.txt b/purchases/api-defaults-bc7.txt index bc4a471bdc..1e5d0ce454 100644 --- a/purchases/api-defaults-bc7.txt +++ b/purchases/api-defaults-bc7.txt @@ -1468,6 +1468,14 @@ package com.revenuecat.purchases.models { property public abstract com.revenuecat.purchases.ProductType type; } + @kotlinx.parcelize.Parcelize public enum StoreReplacementMode implements com.revenuecat.purchases.ReplacementMode { + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode CHARGE_FULL_PRICE; + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode CHARGE_PRORATED_PRICE; + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode DEFERRED; + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode WITHOUT_PRORATION; + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode WITH_TIME_PRORATION; + } + @dev.drewhamilton.poko.Poko @kotlinx.parcelize.Parcelize @kotlinx.parcelize.TypeParceler public final class StoreTransaction implements android.os.Parcelable { ctor public StoreTransaction(String? orderId, java.util.List productIds, com.revenuecat.purchases.ProductType type, long purchaseTime, String purchaseToken, com.revenuecat.purchases.models.PurchaseState purchaseState, Boolean? isAutoRenewing, String? signature, org.json.JSONObject originalJson, com.revenuecat.purchases.PresentedOfferingContext? presentedOfferingContext, String? storeUserID, com.revenuecat.purchases.models.PurchaseType purchaseType, String? marketplace, String? subscriptionOptionId, com.revenuecat.purchases.ReplacementMode? replacementMode); ctor @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public StoreTransaction(String? orderId, java.util.List productIds, com.revenuecat.purchases.ProductType type, long purchaseTime, String purchaseToken, com.revenuecat.purchases.models.PurchaseState purchaseState, Boolean? isAutoRenewing, String? signature, org.json.JSONObject originalJson, com.revenuecat.purchases.PresentedOfferingContext? presentedOfferingContext, String? storeUserID, com.revenuecat.purchases.models.PurchaseType purchaseType, String? marketplace, String? subscriptionOptionId, @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI java.util.Map? subscriptionOptionIdForProductIDs, com.revenuecat.purchases.ReplacementMode? replacementMode); diff --git a/purchases/api-defauts.txt b/purchases/api-defauts.txt index bc4a471bdc..1e5d0ce454 100644 --- a/purchases/api-defauts.txt +++ b/purchases/api-defauts.txt @@ -1468,6 +1468,14 @@ package com.revenuecat.purchases.models { property public abstract com.revenuecat.purchases.ProductType type; } + @kotlinx.parcelize.Parcelize public enum StoreReplacementMode implements com.revenuecat.purchases.ReplacementMode { + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode CHARGE_FULL_PRICE; + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode CHARGE_PRORATED_PRICE; + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode DEFERRED; + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode WITHOUT_PRORATION; + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode WITH_TIME_PRORATION; + } + @dev.drewhamilton.poko.Poko @kotlinx.parcelize.Parcelize @kotlinx.parcelize.TypeParceler public final class StoreTransaction implements android.os.Parcelable { ctor public StoreTransaction(String? orderId, java.util.List productIds, com.revenuecat.purchases.ProductType type, long purchaseTime, String purchaseToken, com.revenuecat.purchases.models.PurchaseState purchaseState, Boolean? isAutoRenewing, String? signature, org.json.JSONObject originalJson, com.revenuecat.purchases.PresentedOfferingContext? presentedOfferingContext, String? storeUserID, com.revenuecat.purchases.models.PurchaseType purchaseType, String? marketplace, String? subscriptionOptionId, com.revenuecat.purchases.ReplacementMode? replacementMode); ctor @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public StoreTransaction(String? orderId, java.util.List productIds, com.revenuecat.purchases.ProductType type, long purchaseTime, String purchaseToken, com.revenuecat.purchases.models.PurchaseState purchaseState, Boolean? isAutoRenewing, String? signature, org.json.JSONObject originalJson, com.revenuecat.purchases.PresentedOfferingContext? presentedOfferingContext, String? storeUserID, com.revenuecat.purchases.models.PurchaseType purchaseType, String? marketplace, String? subscriptionOptionId, @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI java.util.Map? subscriptionOptionIdForProductIDs, com.revenuecat.purchases.ReplacementMode? replacementMode); diff --git a/purchases/api-entitlement.txt b/purchases/api-entitlement.txt index 8f3efa5c6c..824707aed1 100644 --- a/purchases/api-entitlement.txt +++ b/purchases/api-entitlement.txt @@ -1345,6 +1345,14 @@ package com.revenuecat.purchases.models { property public abstract com.revenuecat.purchases.ProductType type; } + @kotlinx.parcelize.Parcelize public enum StoreReplacementMode implements com.revenuecat.purchases.ReplacementMode { + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode CHARGE_FULL_PRICE; + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode CHARGE_PRORATED_PRICE; + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode DEFERRED; + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode WITHOUT_PRORATION; + enum_constant public static final com.revenuecat.purchases.models.StoreReplacementMode WITH_TIME_PRORATION; + } + @dev.drewhamilton.poko.Poko @kotlinx.parcelize.Parcelize @kotlinx.parcelize.TypeParceler public final class StoreTransaction implements android.os.Parcelable { ctor public StoreTransaction(String? orderId, java.util.List productIds, com.revenuecat.purchases.ProductType type, long purchaseTime, String purchaseToken, com.revenuecat.purchases.models.PurchaseState purchaseState, Boolean? isAutoRenewing, String? signature, org.json.JSONObject originalJson, com.revenuecat.purchases.PresentedOfferingContext? presentedOfferingContext, String? storeUserID, com.revenuecat.purchases.models.PurchaseType purchaseType, String? marketplace, String? subscriptionOptionId, com.revenuecat.purchases.ReplacementMode? replacementMode); ctor @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public StoreTransaction(String? orderId, java.util.List productIds, com.revenuecat.purchases.ProductType type, long purchaseTime, String purchaseToken, com.revenuecat.purchases.models.PurchaseState purchaseState, Boolean? isAutoRenewing, String? signature, org.json.JSONObject originalJson, com.revenuecat.purchases.PresentedOfferingContext? presentedOfferingContext, String? storeUserID, com.revenuecat.purchases.models.PurchaseType purchaseType, String? marketplace, String? subscriptionOptionId, @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI java.util.Map? subscriptionOptionIdForProductIDs, com.revenuecat.purchases.ReplacementMode? replacementMode); diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementMode.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementMode.kt new file mode 100644 index 0000000000..5e5c2c2993 --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementMode.kt @@ -0,0 +1,46 @@ +package com.revenuecat.purchases.models + +import com.revenuecat.purchases.ReplacementMode +import kotlinx.parcelize.Parcelize + +/** + * Enum of possible replacement modes to be used when performing a subscription product change. + */ +@Parcelize +public enum class StoreReplacementMode : ReplacementMode { + /** + * Old subscription is cancelled, and new subscription takes effect immediately. + * User is charged for the full price of the new subscription on the old subscription's expiration date. + * + * This is the default behavior for the Galaxy and Play stores. + */ + WITHOUT_PRORATION, + + /** + * Old subscription is cancelled, and new subscription takes effect immediately. + * Any time remaining on the old subscription is used to push out the first payment date for the new subscription. + * User is charged the full price of new subscription once that prorated time has passed. + * + * For the Play Store, the purchase will fail if this mode is used when switching between [SubscriptionOption]s + * of the same [StoreProduct]. + */ + WITH_TIME_PRORATION, + + /** + * Replacement takes effect immediately, and the user is charged full price of new plan and is + * given a full billing cycle of subscription, plus remaining prorated time from the old plan. + * + * This mode is not supported by the Galaxy Store. If it is passed to the Galaxy Store, an error will be thrown. + */ + CHARGE_FULL_PRICE, + + /** + * Replacement takes effect immediately, and the billing cycle remains the same. + */ + CHARGE_PRORATED_PRICE, + + /** + * Replacement takes effect when the old plan expires, and the new price will be charged at the same time. + */ + DEFERRED, +} From fe1db6e8f8c157db39234b62651d28da9c131876 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Fri, 13 Mar 2026 14:22:25 -0500 Subject: [PATCH 02/34] add StoreReplacementMode -> store specific conversions + tests --- .../purchases/galaxy/GalaxyStrings.kt | 2 + .../StoreReplacementModeConversions.kt | 29 ++++++++++ .../StoreReplacementModeConversionsTest.kt | 57 +++++++++++++++++++ .../models/StoreReplacementModeConversions.kt | 28 +++++++++ .../StoreReplacementModeConversionsTest.kt | 52 +++++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/conversions/StoreReplacementModeConversions.kt create mode 100644 feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/conversions/StoreReplacementModeConversionsTest.kt create mode 100644 purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt create mode 100644 purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt diff --git a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyStrings.kt b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyStrings.kt index d5437e970e..404015c496 100644 --- a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyStrings.kt +++ b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyStrings.kt @@ -53,6 +53,8 @@ internal object GalaxyStrings { "returned no error, but also returned no result. This is likely an issue with the Galaxy Store." const val CHANGE_SUBSCRIPTION_PLAN_REQUEST_ERRORED = "An error occurred while changing subscription from " + "product ID %s to %s with the Galaxy Store. Error: %s" + const val CHARGE_FULL_PRICE_NOT_SUPPORTED = "StoreReplacementMode.CHARGE_FULL_PRICE is not supported for " + + "Galaxy Store subscription changes." // Promotion Eligibility const val EMPTY_GET_PROMOTION_ELIGIBILITY_REQUEST = "Received a promotion eligibility request for 0 " + diff --git a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/conversions/StoreReplacementModeConversions.kt b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/conversions/StoreReplacementModeConversions.kt new file mode 100644 index 0000000000..6ea5c88e7c --- /dev/null +++ b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/conversions/StoreReplacementModeConversions.kt @@ -0,0 +1,29 @@ +package com.revenuecat.purchases.galaxy.conversions + +import com.revenuecat.purchases.PurchasesError +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.galaxy.GalaxyStrings +import com.revenuecat.purchases.galaxy.logging.LogIntent +import com.revenuecat.purchases.galaxy.logging.log +import com.revenuecat.purchases.models.StoreReplacementMode +import com.samsung.android.sdk.iap.lib.constants.HelperDefine + +internal fun StoreReplacementMode.toGalaxyReplacementMode(): HelperDefine.ProrationMode { + return when (this) { + StoreReplacementMode.WITHOUT_PRORATION -> HelperDefine.ProrationMode.INSTANT_NO_PRORATION + StoreReplacementMode.WITH_TIME_PRORATION -> HelperDefine.ProrationMode.INSTANT_PRORATED_DATE + StoreReplacementMode.CHARGE_PRORATED_PRICE -> HelperDefine.ProrationMode.INSTANT_PRORATED_CHARGE + StoreReplacementMode.DEFERRED -> HelperDefine.ProrationMode.DEFERRED + StoreReplacementMode.CHARGE_FULL_PRICE -> { + val message = GalaxyStrings.CHARGE_FULL_PRICE_NOT_SUPPORTED + log(LogIntent.GALAXY_ERROR) { message } + throw PurchasesException( + PurchasesError( + PurchasesErrorCode.UnsupportedError, + message, + ), + ) + } + } +} diff --git a/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/conversions/StoreReplacementModeConversionsTest.kt b/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/conversions/StoreReplacementModeConversionsTest.kt new file mode 100644 index 0000000000..f00821daf6 --- /dev/null +++ b/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/conversions/StoreReplacementModeConversionsTest.kt @@ -0,0 +1,57 @@ +package com.revenuecat.purchases.galaxy.conversions + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.PurchasesException +import com.revenuecat.purchases.galaxy.GalaxyStrings +import com.revenuecat.purchases.models.StoreReplacementMode +import com.samsung.android.sdk.iap.lib.constants.HelperDefine +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.catchThrowableOfType +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StoreReplacementModeConversionsTest { + + @Test + fun `supported store replacement modes map to Galaxy proration modes`() { + val expectations = mapOf( + StoreReplacementMode.WITHOUT_PRORATION to HelperDefine.ProrationMode.INSTANT_NO_PRORATION, + StoreReplacementMode.WITH_TIME_PRORATION to HelperDefine.ProrationMode.INSTANT_PRORATED_DATE, + StoreReplacementMode.CHARGE_PRORATED_PRICE to HelperDefine.ProrationMode.INSTANT_PRORATED_CHARGE, + StoreReplacementMode.DEFERRED to HelperDefine.ProrationMode.DEFERRED, + ) + + StoreReplacementMode.values() + .filter { it != StoreReplacementMode.CHARGE_FULL_PRICE } + .forEach { mode -> + val expected = expectations[mode] ?: error("Missing expected mapping for $mode") + assertThat(mode.toGalaxyReplacementMode()).isEqualTo(expected) + } + + assertThat(expectations.size).isEqualTo(StoreReplacementMode.values().size - 1) + } + + @Test + fun `only charge full price is excluded from supported Galaxy mappings`() { + val unsupportedModes = StoreReplacementMode.values() + .filter { mode -> + runCatching { mode.toGalaxyReplacementMode() }.isFailure + } + + assertThat(unsupportedModes).containsExactly(StoreReplacementMode.CHARGE_FULL_PRICE) + } + + @Test + fun `charge full price throws unsupported error for Galaxy`() { + val exception = catchThrowableOfType( + { StoreReplacementMode.CHARGE_FULL_PRICE.toGalaxyReplacementMode() }, + PurchasesException::class.java, + ) + + assertThat(exception).isNotNull + assertThat(exception.error.code).isEqualTo(PurchasesErrorCode.UnsupportedError) + assertThat(exception.error.underlyingErrorMessage).isEqualTo(GalaxyStrings.CHARGE_FULL_PRICE_NOT_SUPPORTED) + } +} diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt new file mode 100644 index 0000000000..9c13b4b63a --- /dev/null +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt @@ -0,0 +1,28 @@ +package com.revenuecat.purchases.models + +import com.android.billingclient.api.BillingFlowParams + +internal fun StoreReplacementMode.toGoogleBillingClientMode(): Int { + return when (this) { + StoreReplacementMode.WITHOUT_PRORATION -> + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION + StoreReplacementMode.WITH_TIME_PRORATION -> + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION + StoreReplacementMode.CHARGE_FULL_PRICE -> + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.CHARGE_FULL_PRICE + StoreReplacementMode.CHARGE_PRORATED_PRICE -> + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.CHARGE_PRORATED_PRICE + StoreReplacementMode.DEFERRED -> + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.DEFERRED + } +} + +internal fun StoreReplacementMode.toGoogleReplacementMode(): GoogleReplacementMode { + return when (this) { + StoreReplacementMode.WITHOUT_PRORATION -> GoogleReplacementMode.WITHOUT_PRORATION + StoreReplacementMode.WITH_TIME_PRORATION -> GoogleReplacementMode.WITH_TIME_PRORATION + StoreReplacementMode.CHARGE_FULL_PRICE -> GoogleReplacementMode.CHARGE_FULL_PRICE + StoreReplacementMode.CHARGE_PRORATED_PRICE -> GoogleReplacementMode.CHARGE_PRORATED_PRICE + StoreReplacementMode.DEFERRED -> GoogleReplacementMode.DEFERRED + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt new file mode 100644 index 0000000000..49ec538c5e --- /dev/null +++ b/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt @@ -0,0 +1,52 @@ +package com.revenuecat.purchases.models + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.billingclient.api.BillingFlowParams +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StoreReplacementModeConversionsTest { + + @Test + fun `all store replacement modes map to Google BillingClient modes`() { + val expectations = mapOf( + StoreReplacementMode.WITHOUT_PRORATION to + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION, + StoreReplacementMode.WITH_TIME_PRORATION to + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION, + StoreReplacementMode.CHARGE_FULL_PRICE to + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.CHARGE_FULL_PRICE, + StoreReplacementMode.CHARGE_PRORATED_PRICE to + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.CHARGE_PRORATED_PRICE, + StoreReplacementMode.DEFERRED to + BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.DEFERRED, + ) + + StoreReplacementMode.values().forEach { mode -> + val expected = expectations[mode] ?: error("Missing expected mapping for $mode") + assertThat(mode.toGoogleBillingClientMode()).isEqualTo(expected) + } + + assertThat(expectations.size).isEqualTo(StoreReplacementMode.values().size) + } + + @Test + fun `all store replacement modes map to deprecated Google replacement modes`() { + val expectations = mapOf( + StoreReplacementMode.WITHOUT_PRORATION to GoogleReplacementMode.WITHOUT_PRORATION, + StoreReplacementMode.WITH_TIME_PRORATION to GoogleReplacementMode.WITH_TIME_PRORATION, + StoreReplacementMode.CHARGE_FULL_PRICE to GoogleReplacementMode.CHARGE_FULL_PRICE, + StoreReplacementMode.CHARGE_PRORATED_PRICE to GoogleReplacementMode.CHARGE_PRORATED_PRICE, + StoreReplacementMode.DEFERRED to GoogleReplacementMode.DEFERRED, + ) + + StoreReplacementMode.values().forEach { mode -> + val expected = expectations[mode] ?: error("Missing expected mapping for $mode") + assertThat(mode.toGoogleReplacementMode()).isEqualTo(expected) + } + + assertThat(expectations.size).isEqualTo(StoreReplacementMode.values().size) + } +} From 49a58cb5688c340ab269f9efead3be83b73c5644 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Fri, 13 Mar 2026 14:58:12 -0500 Subject: [PATCH 03/34] remove Product change --- .../com/revenuecat/purchases/galaxy/GalaxyPurchasingData.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyPurchasingData.kt b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyPurchasingData.kt index dd4082594c..64bd5eadd6 100644 --- a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyPurchasingData.kt +++ b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyPurchasingData.kt @@ -8,7 +8,6 @@ import dev.drewhamilton.poko.Poko @ExperimentalPreviewRevenueCatPurchasesAPI public sealed class GalaxyPurchasingData : PurchasingData { - @ExperimentalPreviewRevenueCatPurchasesAPI @Poko public class Product( override val productId: String, From 6d3b024c91bfad016f8c4e55fd0e1896dfbab875 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Fri, 13 Mar 2026 15:00:48 -0500 Subject: [PATCH 04/34] remove GalaxyPurchasingData.Product api.txt change --- feature/galaxy/api.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/galaxy/api.txt b/feature/galaxy/api.txt index ad31d6b123..8ccbc44f58 100644 --- a/feature/galaxy/api.txt +++ b/feature/galaxy/api.txt @@ -13,7 +13,7 @@ package com.revenuecat.purchases.galaxy { @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public abstract sealed class GalaxyPurchasingData implements com.revenuecat.purchases.models.PurchasingData { } - @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI @dev.drewhamilton.poko.Poko public static final class GalaxyPurchasingData.Product extends com.revenuecat.purchases.galaxy.GalaxyPurchasingData { + @dev.drewhamilton.poko.Poko public static final class GalaxyPurchasingData.Product extends com.revenuecat.purchases.galaxy.GalaxyPurchasingData { ctor public GalaxyPurchasingData.Product(String productId, com.revenuecat.purchases.ProductType productType); method public String getProductId(); method public com.revenuecat.purchases.ProductType getProductType(); From 3039a062ca24ccef567de377298d0510f96cf0bc Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Fri, 13 Mar 2026 15:01:01 -0500 Subject: [PATCH 05/34] add StoreReplacementMode to api.txts --- purchases/api-defaults-bc7.txt | 3 +++ purchases/api-defauts.txt | 3 +++ purchases/api-entitlement.txt | 3 +++ 3 files changed, 9 insertions(+) diff --git a/purchases/api-defaults-bc7.txt b/purchases/api-defaults-bc7.txt index 1e5d0ce454..83f4eae237 100644 --- a/purchases/api-defaults-bc7.txt +++ b/purchases/api-defaults-bc7.txt @@ -341,12 +341,14 @@ package com.revenuecat.purchases { method public com.revenuecat.purchases.models.GalaxyReplacementMode getGalaxyReplacementMode(); method public com.revenuecat.purchases.models.GoogleReplacementMode getGoogleReplacementMode(); method public String? getOldProductId(); + method public com.revenuecat.purchases.models.StoreReplacementMode getReplacementMode(); method public Boolean? isPersonalizedPrice(); property public final com.revenuecat.purchases.PurchaseParams.Builder builder; property public final com.revenuecat.purchases.models.GalaxyReplacementMode galaxyReplacementMode; property public final com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode; property public final Boolean? isPersonalizedPrice; property public final String? oldProductId; + property public final com.revenuecat.purchases.models.StoreReplacementMode replacementMode; } public static class PurchaseParams.Builder { @@ -362,6 +364,7 @@ package com.revenuecat.purchases { method public final com.revenuecat.purchases.PurchaseParams.Builder isPersonalizedPrice(boolean isPersonalizedPrice); method public final com.revenuecat.purchases.PurchaseParams.Builder oldProductId(String oldProductId); method public final com.revenuecat.purchases.PurchaseParams.Builder presentedOfferingContext(com.revenuecat.purchases.PresentedOfferingContext presentedOfferingContext); + method public final com.revenuecat.purchases.PurchaseParams.Builder replacementMode(com.revenuecat.purchases.models.StoreReplacementMode replacementMode); } @dev.drewhamilton.poko.Poko public final class PurchaseResult { diff --git a/purchases/api-defauts.txt b/purchases/api-defauts.txt index 1e5d0ce454..83f4eae237 100644 --- a/purchases/api-defauts.txt +++ b/purchases/api-defauts.txt @@ -341,12 +341,14 @@ package com.revenuecat.purchases { method public com.revenuecat.purchases.models.GalaxyReplacementMode getGalaxyReplacementMode(); method public com.revenuecat.purchases.models.GoogleReplacementMode getGoogleReplacementMode(); method public String? getOldProductId(); + method public com.revenuecat.purchases.models.StoreReplacementMode getReplacementMode(); method public Boolean? isPersonalizedPrice(); property public final com.revenuecat.purchases.PurchaseParams.Builder builder; property public final com.revenuecat.purchases.models.GalaxyReplacementMode galaxyReplacementMode; property public final com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode; property public final Boolean? isPersonalizedPrice; property public final String? oldProductId; + property public final com.revenuecat.purchases.models.StoreReplacementMode replacementMode; } public static class PurchaseParams.Builder { @@ -362,6 +364,7 @@ package com.revenuecat.purchases { method public final com.revenuecat.purchases.PurchaseParams.Builder isPersonalizedPrice(boolean isPersonalizedPrice); method public final com.revenuecat.purchases.PurchaseParams.Builder oldProductId(String oldProductId); method public final com.revenuecat.purchases.PurchaseParams.Builder presentedOfferingContext(com.revenuecat.purchases.PresentedOfferingContext presentedOfferingContext); + method public final com.revenuecat.purchases.PurchaseParams.Builder replacementMode(com.revenuecat.purchases.models.StoreReplacementMode replacementMode); } @dev.drewhamilton.poko.Poko public final class PurchaseResult { diff --git a/purchases/api-entitlement.txt b/purchases/api-entitlement.txt index 824707aed1..322bae9403 100644 --- a/purchases/api-entitlement.txt +++ b/purchases/api-entitlement.txt @@ -309,12 +309,14 @@ package com.revenuecat.purchases { method public com.revenuecat.purchases.models.GalaxyReplacementMode getGalaxyReplacementMode(); method public com.revenuecat.purchases.models.GoogleReplacementMode getGoogleReplacementMode(); method public String? getOldProductId(); + method public com.revenuecat.purchases.models.StoreReplacementMode getReplacementMode(); method public Boolean? isPersonalizedPrice(); property public final com.revenuecat.purchases.PurchaseParams.Builder builder; property public final com.revenuecat.purchases.models.GalaxyReplacementMode galaxyReplacementMode; property public final com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode; property public final Boolean? isPersonalizedPrice; property public final String? oldProductId; + property public final com.revenuecat.purchases.models.StoreReplacementMode replacementMode; } public static class PurchaseParams.Builder { @@ -330,6 +332,7 @@ package com.revenuecat.purchases { method public final com.revenuecat.purchases.PurchaseParams.Builder isPersonalizedPrice(boolean isPersonalizedPrice); method public final com.revenuecat.purchases.PurchaseParams.Builder oldProductId(String oldProductId); method public final com.revenuecat.purchases.PurchaseParams.Builder presentedOfferingContext(com.revenuecat.purchases.PresentedOfferingContext presentedOfferingContext); + method public final com.revenuecat.purchases.PurchaseParams.Builder replacementMode(com.revenuecat.purchases.models.StoreReplacementMode replacementMode); } @dev.drewhamilton.poko.Poko public final class PurchaseResult { From 9a4270494ac934256bfa83151d91276349ece23d Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Fri, 13 Mar 2026 15:06:58 -0500 Subject: [PATCH 06/34] add .replacementMode() to PurchaseParams --- .../revenuecat/purchases/PurchaseParams.kt | 21 +++++++++++++- .../purchases/PurchaseParamsTest.kt | 28 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt index dcb7669885..d572cd48b3 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt @@ -8,6 +8,7 @@ import com.revenuecat.purchases.models.GooglePurchasingData import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.PurchasingData import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.SubscriptionOption import com.revenuecat.purchases.strings.PurchaseStrings import dev.drewhamilton.poko.Poko @@ -22,6 +23,8 @@ public class PurchaseParams(public val builder: Builder) { @ExperimentalPreviewRevenueCatPurchasesAPI public val galaxyReplacementMode: GalaxyReplacementMode + public val replacementMode: StoreReplacementMode + @get:JvmSynthetic internal val purchasingData: PurchasingData @@ -43,7 +46,7 @@ public class PurchaseParams(public val builder: Builder) { this.isPersonalizedPrice = builder.isPersonalizedPrice this.oldProductId = builder.oldProductId this.googleReplacementMode = builder.googleReplacementMode - + this.replacementMode = builder.replacementMode @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) this.galaxyReplacementMode = builder.galaxyReplacementMode this.purchasingData = builder.purchasingData @@ -61,6 +64,7 @@ public class PurchaseParams(public val builder: Builder) { * - Uses [SubscriptionOption] with the longest free trial or cheapest first phase * - Falls back to use base plan */ + @Suppress("TooManyFunctions") public open class Builder private constructor( @get:JvmSynthetic internal val activity: Activity, @get:JvmSynthetic internal var purchasingData: PurchasingData, @@ -98,6 +102,10 @@ public class PurchaseParams(public val builder: Builder) { @get:JvmSynthetic internal var googleReplacementMode: GoogleReplacementMode = GoogleReplacementMode.WITHOUT_PRORATION + @set:JvmSynthetic + @get:JvmSynthetic + internal var replacementMode: StoreReplacementMode = StoreReplacementMode.WITHOUT_PRORATION + @OptIn(InternalRevenueCatAPI::class, ExperimentalPreviewRevenueCatPurchasesAPI::class) @set:JvmSynthetic @get:JvmSynthetic @@ -149,6 +157,17 @@ public class PurchaseParams(public val builder: Builder) { this.googleReplacementMode = googleReplacementMode } + /** + * The [StoreReplacementMode] to use when replacing the given oldProductId. Defaults to + * [StoreReplacementMode.WITHOUT_PRORATION]. + * + * Refer to the [StoreReplacementMode] docs for a list of + * supported replacement modes for each store. + */ + public fun replacementMode(replacementMode: StoreReplacementMode): Builder = apply { + this.replacementMode = replacementMode + } + /* * The [GalaxyReplacementMode] to use when replacing the given oldProductId. Defaults to * [GalaxyReplacementMode.IMMEDIATE_WITHOUT_PRORATION]. diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt index 4a9ab63e45..be39a343ae 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt @@ -11,6 +11,7 @@ import com.revenuecat.purchases.models.GoogleSubscriptionOption import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.PurchasingData import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.SubscriptionOption import com.revenuecat.purchases.utils.STUB_OFFERING_IDENTIFIER import com.revenuecat.purchases.utils.stubINAPPStoreProduct @@ -134,6 +135,33 @@ class PurchaseParamsTest { assertThat(purchaseParams.galaxyReplacementMode).isEqualTo(GalaxyReplacementMode.INSTANT_PRORATED_DATE) } + @Test + fun `initializing defaults replacementMode to WITHOUT_PRORATION`() { + val storeProduct = stubStoreProduct("abc") + val purchaseParams = PurchaseParams.Builder( + mockk(), + storeProduct + ).build() + + assertThat(purchaseParams.replacementMode).isEqualTo(StoreReplacementMode.WITHOUT_PRORATION) + } + + @Test + fun `replacementMode set on builder is reflected in PurchaseParams`() { + val storeProduct = stubStoreProduct("abc") + + StoreReplacementMode.values().forEach { replacementMode -> + val purchaseParams = PurchaseParams.Builder( + mockk(), + storeProduct + ) + .replacementMode(replacementMode) + .build() + + assertThat(purchaseParams.replacementMode).isEqualTo(replacementMode) + } + } + // region Add-Ons @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) @Test From e1e20171c7513ad01ad61bb9dfd4f521d58580ce Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Fri, 13 Mar 2026 15:11:37 -0500 Subject: [PATCH 07/34] set PurchaseParams.googleReplacementMode when setting replacementMode --- .../revenuecat/purchases/PurchaseParams.kt | 4 +++ .../purchases/PurchaseParamsTest.kt | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt index d572cd48b3..358f26783b 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt @@ -9,6 +9,7 @@ import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.PurchasingData import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.models.StoreReplacementMode +import com.revenuecat.purchases.models.toGoogleReplacementMode import com.revenuecat.purchases.models.SubscriptionOption import com.revenuecat.purchases.strings.PurchaseStrings import dev.drewhamilton.poko.Poko @@ -166,6 +167,9 @@ public class PurchaseParams(public val builder: Builder) { */ public fun replacementMode(replacementMode: StoreReplacementMode): Builder = apply { this.replacementMode = replacementMode + + // We can remove this once we fully remove PurchaseParams.googleReplacementMode + this.googleReplacementMode = replacementMode.toGoogleReplacementMode() } /* diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt index be39a343ae..f6a3064e3a 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt @@ -7,6 +7,7 @@ package com.revenuecat.purchases import androidx.test.ext.junit.runners.AndroidJUnit4 import com.revenuecat.purchases.models.GooglePurchasingData +import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.GoogleSubscriptionOption import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.PurchasingData @@ -162,6 +163,32 @@ class PurchaseParamsTest { } } + // We can remove this test once we fully remove PurchaseParams.googleReplacementMode + @Test + fun `replacementMode set on builder updates googleReplacementMode in PurchaseParams`() { + val storeProduct = stubStoreProduct("abc") + val expectations = mapOf( + StoreReplacementMode.WITHOUT_PRORATION to GoogleReplacementMode.WITHOUT_PRORATION, + StoreReplacementMode.WITH_TIME_PRORATION to GoogleReplacementMode.WITH_TIME_PRORATION, + StoreReplacementMode.CHARGE_FULL_PRICE to GoogleReplacementMode.CHARGE_FULL_PRICE, + StoreReplacementMode.CHARGE_PRORATED_PRICE to GoogleReplacementMode.CHARGE_PRORATED_PRICE, + StoreReplacementMode.DEFERRED to GoogleReplacementMode.DEFERRED, + ) + + StoreReplacementMode.values().forEach { replacementMode -> + val purchaseParams = PurchaseParams.Builder( + mockk(), + storeProduct + ) + .replacementMode(replacementMode) + .build() + + val expectedGoogleReplacementMode = + expectations[replacementMode] ?: error("Missing expected Google replacement mode for $replacementMode") + assertThat(purchaseParams.googleReplacementMode).isEqualTo(expectedGoogleReplacementMode) + } + } + // region Add-Ons @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) @Test From d067c44e927b0602e453e4017d0b8241c95e0428 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Fri, 13 Mar 2026 15:22:44 -0500 Subject: [PATCH 08/34] detekt --- .../src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt index 358f26783b..0791b5daae 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt @@ -9,8 +9,8 @@ import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.PurchasingData import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.models.StoreReplacementMode -import com.revenuecat.purchases.models.toGoogleReplacementMode import com.revenuecat.purchases.models.SubscriptionOption +import com.revenuecat.purchases.models.toGoogleReplacementMode import com.revenuecat.purchases.strings.PurchaseStrings import dev.drewhamilton.poko.Poko From 8782fcbbf019b8b4c074db3dd66d9baeff17c282 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Mon, 16 Mar 2026 14:03:03 -0500 Subject: [PATCH 09/34] galaxy-specific flow updates --- .../purchases/galaxy/GalaxyBillingWrapper.kt | 51 ++++++++++--------- .../purchases/galaxy/GalaxyStrings.kt | 3 ++ .../StoreReplacementModeConversions.kt | 1 + .../handler/ChangeSubscriptionPlanHandler.kt | 23 +++++++-- .../ChangeSubscriptionPlanResponseListener.kt | 4 +- 5 files changed, 50 insertions(+), 32 deletions(-) diff --git a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt index c4e922ae13..493e6852d3 100644 --- a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt +++ b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt @@ -40,6 +40,7 @@ import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.InAppMessageType import com.revenuecat.purchases.models.PurchaseState import com.revenuecat.purchases.models.PurchasingData +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.strings.PurchaseStrings import com.revenuecat.purchases.strings.RestoreStrings @@ -336,32 +337,32 @@ internal class GalaxyBillingWrapper( val productId = galaxyPurchaseInfo.productId - if (replaceProductInfo != null) { - val galaxyReplacementMode = replaceProductInfo.replacementMode as? GalaxyReplacementMode - ?: GalaxyReplacementMode.default - - serialRequestExecutor.executeSerially { finish -> - changeSubscriptionPlanHandler.changeSubscriptionPlan( - appUserID = appUserID, - oldPurchase = replaceProductInfo.oldPurchase, - newProductId = productId, - prorationMode = galaxyReplacementMode, - onSuccess = { receipt -> - handleReceipt( - receipt = receipt, - productId = productId, - presentedOfferingContext = presentedOfferingContext, - replacementMode = galaxyReplacementMode, - ) - finish() - }, - onError = { purchasesError -> - onPurchaseError(error = purchasesError) - finish() - }, - ) + replaceProductInfo?.let { replaceInfo -> + (replaceInfo.replacementMode as? StoreReplacementMode)?.let { replacementMode -> + serialRequestExecutor.executeSerially { finish -> + changeSubscriptionPlanHandler.changeSubscriptionPlan( + appUserID = appUserID, + oldPurchase = replaceInfo.oldPurchase, + newProductId = productId, + replacementMode = replacementMode, + onSuccess = { receipt -> + handleReceipt( + receipt = receipt, + productId = productId, + presentedOfferingContext = presentedOfferingContext, + // TODO: Send the replacementMode in here when handleReceipt() is updated + replacementMode = null, + ) + finish() + }, + onError = { purchasesError -> + onPurchaseError(error = purchasesError) + finish() + }, + ) + } + return // Exits makePurchaseAsync } - return } serialRequestExecutor.executeSerially { finish -> diff --git a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyStrings.kt b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyStrings.kt index d6a753a722..307cbd4662 100644 --- a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyStrings.kt +++ b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyStrings.kt @@ -54,6 +54,9 @@ internal object GalaxyStrings { "the Galaxy Store is already in progress. Please wait until that request completes and then try again." const val CHANGE_SUBSCRIPTION_PLAN_NO_OLD_PRODUCT_ID = "Cannot change subscription plan: the old purchase " + "does not have a product ID." + + const val CANNOT_CHANGE_SUBSCRIPTION_PLAN_UNSUPPORTED_REPLACEMENT_MODE = "Cannot change subscription plan: the " + + "provided replacement mode is not supported by the Galaxy Store." const val GALAXY_STORE_FAILED_TO_ACCEPT_CHANGE_SUBSCRIPTION_PLAN_REQUEST = "The Galaxy Store did not accept " + "the subscription plan change request for processing." const val CHANGE_SUBSCRIPTION_PLAN_RETURNED_SUCCESS_BUT_NO_RESULT = "The subscription plan change request " + diff --git a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/conversions/StoreReplacementModeConversions.kt b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/conversions/StoreReplacementModeConversions.kt index 6ea5c88e7c..6fec32c165 100644 --- a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/conversions/StoreReplacementModeConversions.kt +++ b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/conversions/StoreReplacementModeConversions.kt @@ -9,6 +9,7 @@ import com.revenuecat.purchases.galaxy.logging.log import com.revenuecat.purchases.models.StoreReplacementMode import com.samsung.android.sdk.iap.lib.constants.HelperDefine +@Throws(PurchasesException::class) internal fun StoreReplacementMode.toGalaxyReplacementMode(): HelperDefine.ProrationMode { return when (this) { StoreReplacementMode.WITHOUT_PRORATION -> HelperDefine.ProrationMode.INSTANT_NO_PRORATION diff --git a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/handler/ChangeSubscriptionPlanHandler.kt b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/handler/ChangeSubscriptionPlanHandler.kt index 4c85b55de7..9013952034 100644 --- a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/handler/ChangeSubscriptionPlanHandler.kt +++ b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/handler/ChangeSubscriptionPlanHandler.kt @@ -4,19 +4,21 @@ import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.PurchasesException import com.revenuecat.purchases.common.sha256 import com.revenuecat.purchases.galaxy.GalaxyStrings import com.revenuecat.purchases.galaxy.IAPHelperProvider -import com.revenuecat.purchases.galaxy.conversions.toSamsungProrationMode +import com.revenuecat.purchases.galaxy.conversions.toGalaxyReplacementMode import com.revenuecat.purchases.galaxy.listener.ChangeSubscriptionPlanResponseListener import com.revenuecat.purchases.galaxy.logging.LogIntent import com.revenuecat.purchases.galaxy.logging.log import com.revenuecat.purchases.galaxy.utils.GalaxySerialOperation import com.revenuecat.purchases.galaxy.utils.isError import com.revenuecat.purchases.galaxy.utils.toPurchasesError -import com.revenuecat.purchases.models.GalaxyReplacementMode +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.strings.PurchaseStrings +import com.samsung.android.sdk.iap.lib.constants.HelperDefine import com.samsung.android.sdk.iap.lib.vo.ErrorVo import com.samsung.android.sdk.iap.lib.vo.PurchaseVo @@ -35,13 +37,13 @@ internal class ChangeSubscriptionPlanHandler( ) @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class, InternalRevenueCatAPI::class) - @Suppress("ReturnCount") + @Suppress("ReturnCount", "LongMethod") @GalaxySerialOperation override fun changeSubscriptionPlan( appUserID: String, oldPurchase: StoreTransaction, newProductId: String, - prorationMode: GalaxyReplacementMode, + replacementMode: StoreReplacementMode, onSuccess: (PurchaseVo) -> Unit, onError: (PurchasesError) -> Unit, ) { @@ -66,6 +68,17 @@ internal class ChangeSubscriptionPlanHandler( return } + val samsungProrationMode: HelperDefine.ProrationMode + try { + samsungProrationMode = replacementMode.toGalaxyReplacementMode() + } catch (e: PurchasesException) { + log(LogIntent.GALAXY_ERROR) { + GalaxyStrings.CANNOT_CHANGE_SUBSCRIPTION_PLAN_UNSUPPORTED_REPLACEMENT_MODE + } + onError(e.error) + return + } + inFlightRequest = Request( newProductId = newProductId, oldProductId = oldProductId, @@ -83,7 +96,7 @@ internal class ChangeSubscriptionPlanHandler( val requestWasDispatched = iapHelper.changeSubscriptionPlan( oldItemId = oldProductId, newItemId = newProductId, - prorationMode = prorationMode.toSamsungProrationMode(), + prorationMode = samsungProrationMode, obfuscatedAccountId = appUserID.sha256(), obfuscatedProfileId = null, onChangeSubscriptionPlanListener = this, diff --git a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/listener/ChangeSubscriptionPlanResponseListener.kt b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/listener/ChangeSubscriptionPlanResponseListener.kt index 699d975adf..75b82a064f 100644 --- a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/listener/ChangeSubscriptionPlanResponseListener.kt +++ b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/listener/ChangeSubscriptionPlanResponseListener.kt @@ -3,7 +3,7 @@ package com.revenuecat.purchases.galaxy.listener import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.galaxy.utils.GalaxySerialOperation -import com.revenuecat.purchases.models.GalaxyReplacementMode +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.samsung.android.sdk.iap.lib.listener.OnChangeSubscriptionPlanListener import com.samsung.android.sdk.iap.lib.vo.ErrorVo @@ -22,7 +22,7 @@ internal interface ChangeSubscriptionPlanResponseListener : OnChangeSubscription appUserID: String, oldPurchase: StoreTransaction, newProductId: String, - prorationMode: GalaxyReplacementMode, + replacementMode: StoreReplacementMode, onSuccess: (PurchaseVo) -> Unit, onError: (PurchasesError) -> Unit, ) From 503e9e062ddc1eec81dfe6775c060eb9303df778 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Mon, 16 Mar 2026 14:19:37 -0500 Subject: [PATCH 10/34] add unit tests for galaxy flow --- .../purchases/galaxy/GalaxyBillingWrapper.kt | 51 +++++---- .../galaxy/GalaxyBillingWrapperTest.kt | 83 +++++++++++--- .../ChangeSubscriptionPlanHandlerTest.kt | 105 +++++++++++++++--- 3 files changed, 182 insertions(+), 57 deletions(-) diff --git a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt index 493e6852d3..07054dac75 100644 --- a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt +++ b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt @@ -338,31 +338,34 @@ internal class GalaxyBillingWrapper( val productId = galaxyPurchaseInfo.productId replaceProductInfo?.let { replaceInfo -> - (replaceInfo.replacementMode as? StoreReplacementMode)?.let { replacementMode -> - serialRequestExecutor.executeSerially { finish -> - changeSubscriptionPlanHandler.changeSubscriptionPlan( - appUserID = appUserID, - oldPurchase = replaceInfo.oldPurchase, - newProductId = productId, - replacementMode = replacementMode, - onSuccess = { receipt -> - handleReceipt( - receipt = receipt, - productId = productId, - presentedOfferingContext = presentedOfferingContext, - // TODO: Send the replacementMode in here when handleReceipt() is updated - replacementMode = null, - ) - finish() - }, - onError = { purchasesError -> - onPurchaseError(error = purchasesError) - finish() - }, - ) - } - return // Exits makePurchaseAsync + val replacementMode = when (val mode = replaceInfo.replacementMode) { + is StoreReplacementMode -> mode + else -> StoreReplacementMode.CHARGE_PRORATED_PRICE + } + + serialRequestExecutor.executeSerially { finish -> + changeSubscriptionPlanHandler.changeSubscriptionPlan( + appUserID = appUserID, + oldPurchase = replaceInfo.oldPurchase, + newProductId = productId, + replacementMode = replacementMode, + onSuccess = { receipt -> + handleReceipt( + receipt = receipt, + productId = productId, + presentedOfferingContext = presentedOfferingContext, + // TODO: Send the replacementMode in here when handleReceipt() is updated + replacementMode = null, + ) + finish() + }, + onError = { purchasesError -> + onPurchaseError(error = purchasesError) + finish() + }, + ) } + return // Exits makePurchaseAsync } serialRequestExecutor.executeSerially { finish -> diff --git a/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/GalaxyBillingWrapperTest.kt b/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/GalaxyBillingWrapperTest.kt index 330355ab3e..2a3c924e09 100644 --- a/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/GalaxyBillingWrapperTest.kt +++ b/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/GalaxyBillingWrapperTest.kt @@ -29,7 +29,6 @@ import com.revenuecat.purchases.galaxy.listener.ProductDataResponseListener import com.revenuecat.purchases.galaxy.listener.PurchaseResponseListener import com.revenuecat.purchases.galaxy.logging.currentLogHandler import com.revenuecat.purchases.galaxy.utils.GalaxySerialOperation -import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.Period import com.revenuecat.purchases.models.Price @@ -37,6 +36,7 @@ import com.revenuecat.purchases.models.PurchaseState import com.revenuecat.purchases.models.PurchaseType import com.revenuecat.purchases.models.PurchasingData import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.strings.PurchaseStrings import com.samsung.android.sdk.iap.lib.constants.HelperDefine @@ -707,7 +707,7 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { type = ProductType.SUBS, productId = "old-product", ) - val replacementMode = GalaxyReplacementMode.INSTANT_PRORATED_CHARGE + val replacementMode = StoreReplacementMode.CHARGE_PRORATED_PRICE val onSuccessSlot = slot<(PurchaseVo) -> Unit>() val onErrorSlot = slot<(PurchasesError) -> Unit>() @@ -742,7 +742,7 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { appUserID = "user", oldPurchase = oldPurchase, newProductId = storeProduct.id, - prorationMode = replacementMode, + replacementMode = replacementMode, onSuccess = any(), onError = any(), ) @@ -765,12 +765,12 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { verify(exactly = 1) { purchasesUpdatedListener.onPurchasesUpdated(capture(transactionsSlot)) } val transaction = transactionsSlot.captured.single() assertThat(transaction.productIds).containsExactly(storeProduct.id) - assertThat(transaction.replacementMode).isEqualTo(replacementMode) + assertThat(transaction.replacementMode).isNull() } @OptIn(GalaxySerialOperation::class) @Test - fun `makePurchaseAsync defaults to GalaxyReplacementMode default when non-Galaxy replacement mode provided`() { + fun `makePurchaseAsync defaults to CHARGE_PRORATED_PRICE when replacement mode is not a StoreReplacementMode`() { val changeSubscriptionPlanHandler = mockk(relaxed = true) val wrapper = createWrapper(changeSubscriptionPlanHandler = changeSubscriptionPlanHandler) wrapper.purchasesUpdatedListener = mockk(relaxed = true) @@ -781,18 +781,50 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { type = ProductType.SUBS, productId = "old-product", ) - val prorationModeSlot = slot() - every { + wrapper.makePurchaseAsync( + activity = mockk(), + appUserID = "user", + purchasingData = GalaxyPurchasingData.Product( + productId = storeProduct.id, + productType = storeProduct.type, + ), + replaceProductInfo = ReplaceProductInfo( + oldPurchase = oldPurchase, + replacementMode = GoogleReplacementMode.WITHOUT_PRORATION, + ), + presentedOfferingContext = null, + isPersonalizedPrice = null, + ) + + verify(exactly = 1) { changeSubscriptionPlanHandler.changeSubscriptionPlan( - any(), - any(), - any(), - capture(prorationModeSlot), - any(), - any(), + appUserID = "user", + oldPurchase = oldPurchase, + newProductId = storeProduct.id, + replacementMode = StoreReplacementMode.CHARGE_PRORATED_PRICE, + onSuccess = any(), + onError = any(), ) - } answers { } + } + verify(exactly = 0) { + purchaseHandlerMock.purchase(any(), any(), any(), any()) + } + } + + @OptIn(GalaxySerialOperation::class) + @Test + fun `makePurchaseAsync defaults to CHARGE_PRORATED_PRICE when replacement mode is null`() { + val changeSubscriptionPlanHandler = mockk(relaxed = true) + val wrapper = createWrapper(changeSubscriptionPlanHandler = changeSubscriptionPlanHandler) + wrapper.purchasesUpdatedListener = mockk(relaxed = true) + + val storeProduct = createStoreProduct() + val oldPurchase = storeTransaction( + token = "old-token", + type = ProductType.SUBS, + productId = "old-product", + ) wrapper.makePurchaseAsync( activity = mockk(), @@ -803,18 +835,30 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { ), replaceProductInfo = ReplaceProductInfo( oldPurchase = oldPurchase, - replacementMode = GoogleReplacementMode.WITHOUT_PRORATION, + replacementMode = null, ), presentedOfferingContext = null, isPersonalizedPrice = null, ) - assertThat(prorationModeSlot.captured).isEqualTo(GalaxyReplacementMode.default) + verify(exactly = 1) { + changeSubscriptionPlanHandler.changeSubscriptionPlan( + appUserID = "user", + oldPurchase = oldPurchase, + newProductId = storeProduct.id, + replacementMode = StoreReplacementMode.CHARGE_PRORATED_PRICE, + onSuccess = any(), + onError = any(), + ) + } + verify(exactly = 0) { + purchaseHandlerMock.purchase(any(), any(), any(), any()) + } } @OptIn(GalaxySerialOperation::class) @Test - fun `makePurchaseAsync forwards errors from change subscription plan handler`() { + fun `makePurchaseAsync forwards errors from change subscription plan handler for StoreReplacementMode`() { val purchasesUpdatedListener = mockk(relaxed = true) val changeSubscriptionPlanHandler = mockk(relaxed = true) val wrapper = createWrapper(changeSubscriptionPlanHandler = changeSubscriptionPlanHandler) @@ -846,7 +890,10 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { productId = storeProduct.id, productType = storeProduct.type, ), - replaceProductInfo = ReplaceProductInfo(oldPurchase = oldPurchase), + replaceProductInfo = ReplaceProductInfo( + oldPurchase = oldPurchase, + replacementMode = StoreReplacementMode.CHARGE_FULL_PRICE, + ), presentedOfferingContext = null, isPersonalizedPrice = null, ) diff --git a/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/handler/ChangeSubscriptionPlanHandlerTest.kt b/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/handler/ChangeSubscriptionPlanHandlerTest.kt index 0dbb966d6b..b10027a1ec 100644 --- a/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/handler/ChangeSubscriptionPlanHandlerTest.kt +++ b/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/handler/ChangeSubscriptionPlanHandlerTest.kt @@ -9,9 +9,9 @@ import com.revenuecat.purchases.common.sha256 import com.revenuecat.purchases.galaxy.GalaxyStrings import com.revenuecat.purchases.galaxy.IAPHelperProvider import com.revenuecat.purchases.galaxy.constants.GalaxyErrorCode -import com.revenuecat.purchases.galaxy.conversions.toSamsungProrationMode +import com.revenuecat.purchases.galaxy.conversions.toGalaxyReplacementMode import com.revenuecat.purchases.galaxy.utils.GalaxySerialOperation -import com.revenuecat.purchases.models.GalaxyReplacementMode +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.samsung.android.sdk.iap.lib.constants.HelperDefine import com.samsung.android.sdk.iap.lib.vo.ErrorVo @@ -59,7 +59,7 @@ class ChangeSubscriptionPlanHandlerTest { appUserID = "user", oldPurchase = transactionWithProductId("old"), newProductId = "new", - prorationMode = GalaxyReplacementMode.INSTANT_PRORATED_DATE, + replacementMode = StoreReplacementMode.WITH_TIME_PRORATION, onSuccess = unexpectedOnSuccess, onError = unexpectedOnError, ) @@ -69,7 +69,7 @@ class ChangeSubscriptionPlanHandlerTest { appUserID = "user", oldPurchase = transactionWithProductId("other-old"), newProductId = "newer", - prorationMode = GalaxyReplacementMode.INSTANT_NO_PRORATION, + replacementMode = StoreReplacementMode.WITHOUT_PRORATION, onSuccess = unexpectedOnSuccess, onError = { receivedError = it }, ) @@ -96,7 +96,7 @@ class ChangeSubscriptionPlanHandlerTest { appUserID = "user", oldPurchase = transactionWithProductId(null), newProductId = "new", - prorationMode = GalaxyReplacementMode.INSTANT_PRORATED_DATE, + replacementMode = StoreReplacementMode.WITH_TIME_PRORATION, onSuccess = unexpectedOnSuccess, onError = { receivedError = it }, ) @@ -136,7 +136,7 @@ class ChangeSubscriptionPlanHandlerTest { appUserID = "user", oldPurchase = transactionWithProductId("old"), newProductId = "new", - prorationMode = GalaxyReplacementMode.INSTANT_NO_PRORATION, + replacementMode = StoreReplacementMode.WITHOUT_PRORATION, onSuccess = unexpectedOnSuccess, onError = { receivedError = it }, ) @@ -159,7 +159,7 @@ class ChangeSubscriptionPlanHandlerTest { appUserID = "user", oldPurchase = transactionWithProductId("old"), newProductId = "new", - prorationMode = GalaxyReplacementMode.INSTANT_NO_PRORATION, + replacementMode = StoreReplacementMode.WITHOUT_PRORATION, onSuccess = mockk(relaxed = true), onError = unexpectedOnError, ) @@ -182,7 +182,7 @@ class ChangeSubscriptionPlanHandlerTest { fun `changeSubscriptionPlan dispatches request with expected args and forwards success`() { val oldProductId = "old-sku" val newProductId = "new-sku" - val prorationMode = GalaxyReplacementMode.INSTANT_PRORATED_CHARGE + val replacementMode = StoreReplacementMode.CHARGE_PRORATED_PRICE val obfuscatedAccountIdSlot = slot() val prorationModeSlot = slot() every { @@ -201,7 +201,7 @@ class ChangeSubscriptionPlanHandlerTest { appUserID = "user", oldPurchase = transactionWithProductId(oldProductId), newProductId = newProductId, - prorationMode = prorationMode, + replacementMode = replacementMode, onSuccess = onSuccess, onError = unexpectedOnError, ) @@ -223,14 +223,14 @@ class ChangeSubscriptionPlanHandlerTest { onChangeSubscriptionPlanListener = changeSubscriptionPlanHandler, ) } - assertThat(prorationModeSlot.captured).isEqualTo(prorationMode.toSamsungProrationMode()) + assertThat(prorationModeSlot.captured).isEqualTo(replacementMode.toGalaxyReplacementMode()) assertThat(obfuscatedAccountIdSlot.captured).isEqualTo("user".sha256()) changeSubscriptionPlanHandler.changeSubscriptionPlan( appUserID = "user", oldPurchase = transactionWithProductId(oldProductId), newProductId = "next", - prorationMode = GalaxyReplacementMode.INSTANT_NO_PRORATION, + replacementMode = StoreReplacementMode.WITHOUT_PRORATION, onSuccess = onSuccess, onError = unexpectedOnError, ) @@ -265,7 +265,7 @@ class ChangeSubscriptionPlanHandlerTest { appUserID = "user", oldPurchase = transactionWithProductId("old"), newProductId = "new", - prorationMode = GalaxyReplacementMode.INSTANT_NO_PRORATION, + replacementMode = StoreReplacementMode.WITHOUT_PRORATION, onSuccess = unexpectedOnSuccess, onError = { receivedError = it }, ) @@ -283,7 +283,7 @@ class ChangeSubscriptionPlanHandlerTest { appUserID = "user", oldPurchase = transactionWithProductId("old"), newProductId = "new", - prorationMode = GalaxyReplacementMode.INSTANT_NO_PRORATION, + replacementMode = StoreReplacementMode.WITHOUT_PRORATION, onSuccess = mockk(relaxed = true), onError = unexpectedOnError, ) @@ -318,7 +318,7 @@ class ChangeSubscriptionPlanHandlerTest { appUserID = "user", oldPurchase = transactionWithProductId("old"), newProductId = "new", - prorationMode = GalaxyReplacementMode.INSTANT_NO_PRORATION, + replacementMode = StoreReplacementMode.WITHOUT_PRORATION, onSuccess = unexpectedOnSuccess, onError = { receivedError = it }, ) @@ -336,7 +336,7 @@ class ChangeSubscriptionPlanHandlerTest { appUserID = "user", oldPurchase = transactionWithProductId("old"), newProductId = "new", - prorationMode = GalaxyReplacementMode.INSTANT_NO_PRORATION, + replacementMode = StoreReplacementMode.WITHOUT_PRORATION, onSuccess = mockk(relaxed = true), onError = unexpectedOnError, ) @@ -352,6 +352,81 @@ class ChangeSubscriptionPlanHandlerTest { } } + @OptIn(GalaxySerialOperation::class, ExperimentalPreviewRevenueCatPurchasesAPI::class) + @Test + fun `changeSubscriptionPlan errors when replacement mode is unsupported`() { + var receivedError: PurchasesError? = null + + changeSubscriptionPlanHandler.changeSubscriptionPlan( + appUserID = "user", + oldPurchase = transactionWithProductId("old"), + newProductId = "new", + replacementMode = StoreReplacementMode.CHARGE_FULL_PRICE, + onSuccess = unexpectedOnSuccess, + onError = { receivedError = it }, + ) + + assertThat(receivedError?.code).isEqualTo(PurchasesErrorCode.UnsupportedError) + assertThat(receivedError?.underlyingErrorMessage) + .isEqualTo(GalaxyStrings.CHARGE_FULL_PRICE_NOT_SUPPORTED) + verify(exactly = 0) { + iapHelperProvider.changeSubscriptionPlan( + any(), + any(), + any(), + any(), + any(), + any() + ) + } + } + + @OptIn(GalaxySerialOperation::class, ExperimentalPreviewRevenueCatPurchasesAPI::class, + InternalRevenueCatAPI::class + ) + @Test + fun `changeSubscriptionPlan allows supported request after unsupported replacement mode error`() { + every { + iapHelperProvider.changeSubscriptionPlan( + any(), + any(), + any(), + any(), + any(), + any() + ) + } returns true + + changeSubscriptionPlanHandler.changeSubscriptionPlan( + appUserID = "user", + oldPurchase = transactionWithProductId("old"), + newProductId = "new", + replacementMode = StoreReplacementMode.CHARGE_FULL_PRICE, + onSuccess = unexpectedOnSuccess, + onError = mockk(relaxed = true), + ) + + changeSubscriptionPlanHandler.changeSubscriptionPlan( + appUserID = "user", + oldPurchase = transactionWithProductId("old"), + newProductId = "new", + replacementMode = StoreReplacementMode.WITHOUT_PRORATION, + onSuccess = mockk(relaxed = true), + onError = unexpectedOnError, + ) + + verify(exactly = 1) { + iapHelperProvider.changeSubscriptionPlan( + any(), + any(), + any(), + any(), + any(), + any() + ) + } + } + private fun transactionWithProductId(productId: String?): StoreTransaction = mockk { val productIdsList = productId?.let { listOf(it) } ?: emptyList() every { productIds } returns productIdsList From ac40b0e130a1ddc848d56c4564c7befaa6795ace Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Mon, 16 Mar 2026 14:30:33 -0500 Subject: [PATCH 11/34] Update PurchasesOrchestrator.kt --- .../purchases/PurchasesOrchestrator.kt | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt index 33f4939fb9..c69f87055b 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt @@ -73,13 +73,12 @@ import com.revenuecat.purchases.interfaces.SyncAttributesAndOfferingsCallback import com.revenuecat.purchases.interfaces.SyncPurchasesCallback import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener import com.revenuecat.purchases.models.BillingFeature -import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.GooglePurchasingData -import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.GoogleStoreProduct import com.revenuecat.purchases.models.InAppMessageType import com.revenuecat.purchases.models.PurchasingData import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.paywalls.DownloadedFontFamily import com.revenuecat.purchases.paywalls.FontLoader @@ -630,8 +629,7 @@ internal class PurchasesOrchestrator( purchasingData, presentedOfferingContext, productId, - googleReplacementMode, - galaxyReplacementMode, + replacementMode, isPersonalizedPrice, callback, ) @@ -1524,8 +1522,7 @@ internal class PurchasesOrchestrator( purchasingData: PurchasingData, presentedOfferingContext: PresentedOfferingContext?, oldProductId: String, - googleReplacementMode: GoogleReplacementMode, - galaxyReplacementMode: GalaxyReplacementMode, + replacementMode: StoreReplacementMode, isPersonalizedPrice: Boolean?, purchaseCallback: PurchaseCallback, ) { @@ -1560,7 +1557,7 @@ internal class PurchasesOrchestrator( presentedOfferingContext?.offeringIdentifier?.let { PurchaseStrings.OFFERING + "$it" } - } oldProductId: $oldProductId googleReplacementMode $googleReplacementMode", + } oldProductId: $oldProductId replacementMode $replacementMode", ) } var userPurchasing: String? = null // Avoids race condition for userid being modified before purchase is made @@ -1575,7 +1572,7 @@ internal class PurchasesOrchestrator( // We also need to normalize oldProductId by stripping any basePlanId suffix // (e.g., "productId:basePlanId" becomes "productId") to ensure the callback key matches the productId // in the transaction returned by Google Play, which only contains the product ID without the base plan. - val productId = if (googleReplacementMode == GoogleReplacementMode.DEFERRED) { + val productId = if (replacementMode == StoreReplacementMode.DEFERRED && store == Store.PLAY_STORE) { if (oldProductId.contains(Constants.SUBS_ID_BASE_PLAN_ID_SEPARATOR)) { warnLog { PurchaseStrings.DEFERRED_PRODUCT_CHANGE_WITH_BASE_PLAN_ID.format(oldProductId) @@ -1593,11 +1590,6 @@ internal class PurchasesOrchestrator( } } userPurchasing?.let { appUserID -> - val replacementMode: ReplacementMode? = when (store) { - Store.PLAY_STORE -> googleReplacementMode - Store.GALAXY -> galaxyReplacementMode - else -> null - } replaceOldPurchaseWithNewProduct( purchasingData, oldProductId, @@ -1619,7 +1611,7 @@ internal class PurchasesOrchestrator( private fun replaceOldPurchaseWithNewProduct( purchasingData: PurchasingData, oldProductId: String, - replacementMode: ReplacementMode?, + replacementMode: StoreReplacementMode?, activity: Activity, appUserID: String, presentedOfferingContext: PresentedOfferingContext?, From 96669388200ea23c9dac6bc96cfc458b231b400a Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Mon, 16 Mar 2026 14:39:01 -0500 Subject: [PATCH 12/34] update PurchasesCommonTest --- .../revenuecat/purchases/BasePurchasesTest.kt | 7 +- .../purchases/PurchasesCommonTest.kt | 94 ++++++++++++++++--- 2 files changed, 86 insertions(+), 15 deletions(-) diff --git a/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt b/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt index c543919b38..dca7ff3468 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/BasePurchasesTest.kt @@ -35,6 +35,7 @@ import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.models.SubscriptionOption import com.revenuecat.purchases.paywalls.FontLoader @@ -549,7 +550,8 @@ internal open class BasePurchasesTest { purchaseable: Any, oldProductId: String? = null, isPersonalizedPrice: Boolean? = null, - googleReplacementMode: GoogleReplacementMode? = null + googleReplacementMode: GoogleReplacementMode? = null, + storeReplacementMode: StoreReplacementMode? = null, ): PurchaseParams { val builder = when (purchaseable) { is SubscriptionOption -> PurchaseParams.Builder(mockActivity, purchaseable) @@ -569,6 +571,9 @@ internal open class BasePurchasesTest { googleReplacementMode?.let { builder!!.googleReplacementMode(googleReplacementMode) } + storeReplacementMode?.let { + builder!!.replacementMode(storeReplacementMode) + } return builder!!.build() } diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt index 46553686ce..1c64e2c561 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchasesCommonTest.kt @@ -16,8 +16,6 @@ import com.revenuecat.purchases.google.toInAppStoreProduct import com.revenuecat.purchases.google.toStoreProduct import com.revenuecat.purchases.interfaces.GetStoreProductsCallback import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback -import com.revenuecat.purchases.models.GoogleReplacementMode -import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.GoogleStoreProduct import com.revenuecat.purchases.models.GoogleSubscriptionOption import com.revenuecat.purchases.models.Period @@ -25,6 +23,7 @@ import com.revenuecat.purchases.models.Price import com.revenuecat.purchases.models.PricingPhase import com.revenuecat.purchases.models.RecurrenceMode import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.models.SubscriptionOptions import com.revenuecat.purchases.strings.PurchaseStrings @@ -812,7 +811,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() { val productChangeParams = getPurchaseParams( storeProduct.first().subscriptionOptions!!.first(), oldPurchase.productIds.first(), - googleReplacementMode = GoogleReplacementMode.DEFERRED, + storeReplacementMode = StoreReplacementMode.DEFERRED, ) var callCount = 0 purchases.purchaseWith( @@ -882,7 +881,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() { val productChangeParams = getPurchaseParams( storeProduct.first().subscriptionOptions!!.first(), oldProductIdWithBasePlan, - googleReplacementMode = GoogleReplacementMode.DEFERRED, + storeReplacementMode = StoreReplacementMode.DEFERRED, ) var callCount = 0 purchases.purchaseWith( @@ -902,7 +901,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() { } @Test - fun `upgrade defaults to ProrationMode IMMEDIATE_WITHOUT_PRORATION`() { + fun `upgrade defaults to replacement mode WITHOUT_PRORATION`() { val productId = "gold" val oldSubId = "oldSubID" val storeProduct = mockQueryingProductDetails(productId, ProductType.SUBS) @@ -931,7 +930,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() { val expectedReplaceProductInfo = ReplaceProductInfo( oldTransaction, - GoogleReplacementMode.WITHOUT_PRORATION + StoreReplacementMode.WITHOUT_PRORATION ) verify { mockBillingAbstract.makePurchaseAsync( @@ -947,7 +946,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() { @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) @Test - fun `upgrade uses galaxyReplacementMode when store is Galaxy`() { + fun `upgrade uses replacementMode when store is Galaxy`() { buildPurchases(anonymous = false, store = Store.GALAXY) val productId = "galaxy_gold" @@ -966,13 +965,13 @@ internal class PurchasesCommonTest: BasePurchasesTest() { lambda<(StoreTransaction) -> Unit>().captured.invoke(oldTransaction) } - val replacementMode = GalaxyReplacementMode.INSTANT_PRORATED_DATE + val replacementMode = StoreReplacementMode.WITH_TIME_PRORATION val upgradePurchaseParams = PurchaseParams.Builder( mockActivity, storeProduct ) .oldProductId(oldSubId) - .galaxyReplacementMode(replacementMode) + .replacementMode(replacementMode) .build() purchases.purchaseWith( @@ -997,6 +996,73 @@ internal class PurchasesCommonTest: BasePurchasesTest() { } } + @Test + fun `when making a deferred product change on Galaxy, completion is called with the new transaction`() { + buildPurchases(anonymous = false, store = Store.GALAXY) + + val newProductId = "newproduct" + val storeProduct = stubStoreProduct(newProductId) + val oldProductId = "oldProductId" + val oldPurchase = getMockedStoreTransaction( + productId = oldProductId, + purchaseToken = "old_purchase_token", + productType = ProductType.SUBS, + ) + val newPurchase = getMockedStoreTransaction( + productId = newProductId, + purchaseToken = "new_purchase_token", + productType = ProductType.SUBS, + ) + + every { + mockBillingAbstract.findPurchaseInPurchaseHistory( + appUserID = appUserId, + productType = ProductType.SUBS, + productId = oldProductId, + onCompletion = captureLambda(), + onError = any(), + ) + } answers { + lambda<(StoreTransaction) -> Unit>().captured.invoke(oldPurchase) + } + + mockQueryingProductDetails(newProductId, ProductType.SUBS) + every { + mockPostReceiptHelper.postTransactionAndConsumeIfNeeded( + newPurchase, + any(), + any(), + isRestore = false, + appUserId, + initiationSource, + sdkOriginated = true, + captureLambda(), + any(), + ) + } answers { + lambda().captured.invoke(newPurchase, mockk(relaxed = true)) + } + + val productChangeParams = getPurchaseParams( + storeProduct, + oldProductId, + storeReplacementMode = StoreReplacementMode.DEFERRED, + ) + + var receivedPurchase: StoreTransaction? = null + purchases.purchaseWith( + productChangeParams, + onError = { _, _ -> fail("should be successful") }, + onSuccess = { purchase, _ -> + receivedPurchase = purchase + }, + ) + + capturedPurchasesUpdatedListener.captured.onPurchasesUpdated(listOf(newPurchase)) + + assertThat(receivedPurchase).isEqualTo(newPurchase) + } + @Test fun canMakePurchase() { val storeProduct = stubStoreProduct("abc") @@ -1211,7 +1277,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() { eq(mockActivity), eq(appUserId), storeProduct.defaultOption!!.purchasingData, - ReplaceProductInfo(oldPurchase, GoogleReplacementMode.WITHOUT_PRORATION), + ReplaceProductInfo(oldPurchase, StoreReplacementMode.WITHOUT_PRORATION), PresentedOfferingContext(STUB_OFFERING_IDENTIFIER), any() ) @@ -1859,7 +1925,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() { eq(mockActivity), eq(appUserId), storeProduct.subscriptionOptions!!.first().purchasingData, - ReplaceProductInfo(oldPurchase, GoogleReplacementMode.WITHOUT_PRORATION), + ReplaceProductInfo(oldPurchase, StoreReplacementMode.WITHOUT_PRORATION), PresentedOfferingContext(STUB_OFFERING_IDENTIFIER), any() ) @@ -1918,7 +1984,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() { eq(mockActivity), eq(appUserId), storeProduct.subscriptionOptions!!.first().purchasingData, - ReplaceProductInfo(oldPurchase, GoogleReplacementMode.WITHOUT_PRORATION), + ReplaceProductInfo(oldPurchase, StoreReplacementMode.WITHOUT_PRORATION), PresentedOfferingContext(STUB_OFFERING_IDENTIFIER), any() ) @@ -2008,7 +2074,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() { val expectedReplaceProductInfo = ReplaceProductInfo( oldTransaction, - GoogleReplacementMode.WITHOUT_PRORATION + StoreReplacementMode.WITHOUT_PRORATION ) verify { @@ -2072,7 +2138,7 @@ internal class PurchasesCommonTest: BasePurchasesTest() { val expectedReplaceProductInfo = ReplaceProductInfo( oldTransaction, - GoogleReplacementMode.WITHOUT_PRORATION + StoreReplacementMode.WITHOUT_PRORATION ) verify { From 4ecb493979af50ffb0f57d6e324d9df545739c22 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Tue, 17 Mar 2026 10:19:27 -0500 Subject: [PATCH 13/34] Google -> StoreReplacementMode in play store flows --- .../google/BillingFlowParamsExtensions.kt | 18 ++-- .../purchases/google/BillingWrapper.kt | 7 +- .../purchases/google/PurchaseContext.kt | 4 +- .../google/storeTransactionConversions.kt | 4 +- .../models/StoreReplacementModeConversions.kt | 2 +- .../purchases/PostReceiptHelperTest.kt | 22 ++--- .../purchases/google/BillingWrapperTest.kt | 86 +++++++++++++++++-- .../StoreReplacementModeConversionsTest.kt | 2 +- 8 files changed, 114 insertions(+), 31 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingFlowParamsExtensions.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingFlowParamsExtensions.kt index 71ab666a8c..00479e98d3 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingFlowParamsExtensions.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingFlowParamsExtensions.kt @@ -5,17 +5,25 @@ import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.common.ReplaceProductInfo import com.revenuecat.purchases.common.errorLog import com.revenuecat.purchases.models.GoogleReplacementMode +import com.revenuecat.purchases.models.StoreReplacementMode +import com.revenuecat.purchases.models.toPlayBillingClientMode @OptIn(InternalRevenueCatAPI::class) internal fun BillingFlowParams.Builder.setUpgradeInfo(replaceProductInfo: ReplaceProductInfo) { val subscriptionUpdateParams = BillingFlowParams.SubscriptionUpdateParams.newBuilder().apply { setOldPurchaseToken(replaceProductInfo.oldPurchase.purchaseToken) replaceProductInfo.replacementMode?.let { - val googleReplacementMode = it as? GoogleReplacementMode - if (googleReplacementMode == null) { - errorLog { "Got non-Google replacement mode" } - } else { - setSubscriptionReplacementMode(googleReplacementMode.playBillingClientMode) + when (it) { + is StoreReplacementMode -> { + setSubscriptionReplacementMode(it.toPlayBillingClientMode()) + } + // TO DO: Remove this when we remove GoogleReplacementMode + is GoogleReplacementMode -> { + setSubscriptionReplacementMode(it.playBillingClientMode) + } + else -> { + errorLog { "Got unidentified replacement mode" } + } } } } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingWrapper.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingWrapper.kt index 1a90fca58e..12d0d754f2 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingWrapper.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingWrapper.kt @@ -67,6 +67,7 @@ import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.InAppMessageType import com.revenuecat.purchases.models.PurchaseState import com.revenuecat.purchases.models.PurchasingData +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.strings.BillingStrings import com.revenuecat.purchases.strings.OfferingStrings @@ -296,7 +297,9 @@ internal class BillingWrapper( // When using DEFERRED proration mode, callback needs to be associated with the *old* product we are // switching from, because the transaction we receive on successful purchase is for the old product. val productId = - if (replaceProductInfo?.replacementMode == GoogleReplacementMode.DEFERRED) { + if (replaceProductInfo?.replacementMode == StoreReplacementMode.DEFERRED + || replaceProductInfo?.replacementMode == GoogleReplacementMode.DEFERRED + ) { replaceProductInfo.oldPurchase.productIds.first() } else { googlePurchasingData.productId @@ -320,7 +323,7 @@ internal class BillingWrapper( googlePurchasingData.productType, presentedOfferingContext, subscriptionOptionId, - replaceProductInfo?.replacementMode as? GoogleReplacementMode?, + replaceProductInfo?.replacementMode as? StoreReplacementMode?, subscriptionOptionIdForProductIDs, ) } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/google/PurchaseContext.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/google/PurchaseContext.kt index e2c72aac14..a19c919390 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/google/PurchaseContext.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/google/PurchaseContext.kt @@ -2,12 +2,12 @@ package com.revenuecat.purchases.google import com.revenuecat.purchases.PresentedOfferingContext import com.revenuecat.purchases.ProductType -import com.revenuecat.purchases.models.GoogleReplacementMode +import com.revenuecat.purchases.models.StoreReplacementMode internal class PurchaseContext( val productType: ProductType, val presentedOfferingContext: PresentedOfferingContext?, val selectedSubscriptionOptionId: String?, - val replacementMode: GoogleReplacementMode?, + val replacementMode: StoreReplacementMode?, val subscriptionOptionIdForProductIDs: Map?, ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/google/storeTransactionConversions.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/google/storeTransactionConversions.kt index 8a0b8751b7..e91e0482fb 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/google/storeTransactionConversions.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/google/storeTransactionConversions.kt @@ -4,8 +4,8 @@ import com.android.billingclient.api.Purchase import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI import com.revenuecat.purchases.PresentedOfferingContext import com.revenuecat.purchases.ProductType -import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.PurchaseType +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import org.json.JSONObject @@ -15,7 +15,7 @@ internal fun Purchase.toStoreTransaction( presentedOfferingContext: PresentedOfferingContext? = null, subscriptionOptionId: String? = null, subscriptionOptionIdForProductIDs: Map? = null, - replacementMode: GoogleReplacementMode? = null, + replacementMode: StoreReplacementMode? = null, ): StoreTransaction = StoreTransaction( orderId = this.orderId, productIds = this.products, diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt index 9c13b4b63a..82ab80d5f5 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt @@ -2,7 +2,7 @@ package com.revenuecat.purchases.models import com.android.billingclient.api.BillingFlowParams -internal fun StoreReplacementMode.toGoogleBillingClientMode(): Int { +internal fun StoreReplacementMode.toPlayBillingClientMode(): Int { return when (this) { StoreReplacementMode.WITHOUT_PRORATION -> BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION diff --git a/purchases/src/test/java/com/revenuecat/purchases/PostReceiptHelperTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PostReceiptHelperTest.kt index eef5b27d5a..c687e492d1 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PostReceiptHelperTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PostReceiptHelperTest.kt @@ -20,8 +20,8 @@ import com.revenuecat.purchases.common.networking.PostReceiptProductInfo import com.revenuecat.purchases.common.networking.PostReceiptResponse import com.revenuecat.purchases.common.offlineentitlements.OfflineEntitlementsManager import com.revenuecat.purchases.google.toStoreTransaction -import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.Period +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.paywalls.PaywallPresentedCache import com.revenuecat.purchases.paywalls.events.PaywallEvent @@ -76,13 +76,13 @@ class PostReceiptHelperTest { ProductType.SUBS, null, subscriptionOptionId, - replacementMode = GoogleReplacementMode.CHARGE_FULL_PRICE + replacementMode = StoreReplacementMode.CHARGE_FULL_PRICE ) private val mockPendingStoreTransaction = mockPendingPurchase.toStoreTransaction( ProductType.SUBS, null, subscriptionOptionId, - replacementMode = GoogleReplacementMode.CHARGE_FULL_PRICE + replacementMode = StoreReplacementMode.CHARGE_FULL_PRICE ) private val testReceiptInfo = ReceiptInfo( productIDs = listOf("test-product-id-1", "test-product-id-2"), @@ -757,7 +757,7 @@ class PostReceiptHelperTest { onError = { _, _ -> fail("Should succeed") } ) assertThat(postedReceiptInfoSlot.isCaptured).isTrue - assertThat(postedReceiptInfoSlot.captured.replacementMode).isEqualTo(GoogleReplacementMode.CHARGE_FULL_PRICE) + assertThat(postedReceiptInfoSlot.captured.replacementMode).isEqualTo(StoreReplacementMode.CHARGE_FULL_PRICE) } @Test @@ -1475,7 +1475,7 @@ class PostReceiptHelperTest { ProductType.SUBS, null, subscriptionOptionId, - replacementMode = GoogleReplacementMode.CHARGE_FULL_PRICE, + replacementMode = StoreReplacementMode.CHARGE_FULL_PRICE, ) every { billing.consumeAndSave( @@ -1528,7 +1528,7 @@ class PostReceiptHelperTest { ProductType.SUBS, null, subscriptionOptionId, - replacementMode = GoogleReplacementMode.CHARGE_FULL_PRICE, + replacementMode = StoreReplacementMode.CHARGE_FULL_PRICE, ) every { billing.consumeAndSave( @@ -1734,7 +1734,7 @@ class PostReceiptHelperTest { ProductType.SUBS, PresentedOfferingContext("offering_id"), subscriptionOptionId, - replacementMode = GoogleReplacementMode.CHARGE_FULL_PRICE, + replacementMode = StoreReplacementMode.CHARGE_FULL_PRICE, ) paywallPresentedCache.receiveEvent(event) @@ -2077,7 +2077,7 @@ class PostReceiptHelperTest { currency = "USD", period = Period(1, Period.Unit.YEAR, "P1Y"), pricingPhases = null, - replacementMode = GoogleReplacementMode.DEFERRED, + replacementMode = StoreReplacementMode.DEFERRED, platformProductIds = listOf(mapOf("product_id" to "cached-product")), ) val cachedMetadata = LocalTransactionMetadata( @@ -2165,7 +2165,7 @@ class PostReceiptHelperTest { ProductType.SUBS, null, // No presentedOfferingContext subscriptionOptionId, - replacementMode = GoogleReplacementMode.CHARGE_FULL_PRICE, + replacementMode = StoreReplacementMode.CHARGE_FULL_PRICE, ) // Create a paywall event with presentedOfferingContext @@ -2219,7 +2219,7 @@ class PostReceiptHelperTest { ProductType.SUBS, null, // No presentedOfferingContext subscriptionOptionId, - replacementMode = GoogleReplacementMode.CHARGE_FULL_PRICE, + replacementMode = StoreReplacementMode.CHARGE_FULL_PRICE, ) // Don't add any paywall event to the cache (no paywall context available) @@ -2255,7 +2255,7 @@ class PostReceiptHelperTest { ProductType.SUBS, transactionContext, subscriptionOptionId, - replacementMode = GoogleReplacementMode.CHARGE_FULL_PRICE, + replacementMode = StoreReplacementMode.CHARGE_FULL_PRICE, ) // Create a paywall event with a different presentedOfferingContext diff --git a/purchases/src/test/java/com/revenuecat/purchases/google/BillingWrapperTest.kt b/purchases/src/test/java/com/revenuecat/purchases/google/BillingWrapperTest.kt index d6fc550f99..29acd1ec31 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/google/BillingWrapperTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/google/BillingWrapperTest.kt @@ -48,9 +48,11 @@ import com.revenuecat.purchases.models.PricingPhase import com.revenuecat.purchases.models.PurchasingData import com.revenuecat.purchases.models.RecurrenceMode import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.models.SubscriptionOption import com.revenuecat.purchases.models.SubscriptionOptions +import com.revenuecat.purchases.models.toPlayBillingClientMode import com.revenuecat.purchases.strings.BillingStrings import com.revenuecat.purchases.utils.createMockProductDetailsNoOffers import com.revenuecat.purchases.utils.mockInstallmentPlandetails @@ -372,7 +374,7 @@ class BillingWrapperTest { assertThat(purchaseContext?.productType).isEqualTo(ProductType.SUBS) assertThat(purchaseContext?.presentedOfferingContext).isEqualTo(PresentedOfferingContext("offering_a")) assertThat(purchaseContext?.selectedSubscriptionOptionId).isEqualTo(storeProduct.subscriptionOptions!!.first().id) - assertThat(purchaseContext?.replacementMode).isEqualTo(GoogleReplacementMode.CHARGE_FULL_PRICE) + assertThat(purchaseContext?.replacementMode).isEqualTo(StoreReplacementMode.CHARGE_FULL_PRICE) } @Test @@ -384,7 +386,7 @@ class BillingWrapperTest { val storeProduct = createStoreProductWithoutOffers() val purchasingData = storeProduct.subscriptionOptions!!.first().purchasingData val oldPurchase = mockPurchaseRecordWrapper() - val replaceInfo = ReplaceProductInfo(oldPurchase, GoogleReplacementMode.DEFERRED) + val replaceInfo = ReplaceProductInfo(oldPurchase, StoreReplacementMode.DEFERRED) billingClientStateListener!!.onBillingSetupFinished(billingClientOKResult) wrapper.makePurchaseAsync( @@ -408,7 +410,7 @@ class BillingWrapperTest { assertThat(purchaseContext?.productType).isEqualTo(ProductType.SUBS) assertThat(purchaseContext?.presentedOfferingContext).isEqualTo(PresentedOfferingContext("offering_a")) assertThat(purchaseContext?.selectedSubscriptionOptionId).isEqualTo(storeProduct.subscriptionOptions!!.first().id) - assertThat(purchaseContext?.replacementMode).isEqualTo(GoogleReplacementMode.DEFERRED) + assertThat(purchaseContext?.replacementMode).isEqualTo(StoreReplacementMode.DEFERRED) } @Test @@ -471,7 +473,8 @@ class BillingWrapperTest { assertThat(subsGoogleProductType).isEqualTo(capturedProductDetailsParams[0].zza().productType) assertThat(upgradeInfo.oldPurchase.purchaseToken).isEqualTo(oldPurchaseTokenSlot.captured) - assertThat((upgradeInfo.replacementMode as GoogleReplacementMode?)?.playBillingClientMode).isEqualTo(replacementModeSlot.captured) + assertThat((upgradeInfo.replacementMode as StoreReplacementMode?)?.toPlayBillingClientMode()) + .isEqualTo(replacementModeSlot.captured) assertThat(isPersonalizedPrice).isEqualTo(isPersonalizedPriceSlot.captured) billingClientOKResult @@ -488,6 +491,74 @@ class BillingWrapperTest { ) } + @Test + fun `properly sets billingFlowParams for subscription purchase with deprecated GoogleReplacementMode`() { + mockkStatic(BillingFlowParams::class) + mockkStatic(BillingFlowParams.SubscriptionUpdateParams::class) + + val mockBuilder = mockk(relaxed = true) + every { + BillingFlowParams.newBuilder() + } returns mockBuilder + + val productDetailsParamsSlot = slot>() + every { + mockBuilder.setProductDetailsParamsList(capture(productDetailsParamsSlot)) + } returns mockBuilder + + every { + mockBuilder.setIsOfferPersonalized(any()) + } returns mockBuilder + + val mockSubscriptionUpdateParamsBuilder = + mockk(relaxed = true) + every { + BillingFlowParams.SubscriptionUpdateParams.newBuilder() + } returns mockSubscriptionUpdateParamsBuilder + + val oldPurchaseTokenSlot = slot() + every { + mockSubscriptionUpdateParamsBuilder.setOldPurchaseToken(capture(oldPurchaseTokenSlot)) + } returns mockSubscriptionUpdateParamsBuilder + + val replacementModeSlot = slot() + every { + mockSubscriptionUpdateParamsBuilder.setSubscriptionReplacementMode(capture(replacementModeSlot)) + } returns mockSubscriptionUpdateParamsBuilder + + val productId = "product_a" + val oldPurchase = mockPurchaseRecordWrapper() + val upgradeInfo = ReplaceProductInfo(oldPurchase, GoogleReplacementMode.DEFERRED) + val productDetails = mockProductDetails(productId = productId, type = subsGoogleProductType) + val storeProduct = productDetails.toStoreProduct( + productDetails.subscriptionOfferDetails!! + )!! + + every { + mockClient.launchBillingFlow(eq(mockActivity), any()) + } answers { + val capturedProductDetailsParams = productDetailsParamsSlot.captured + + assertThat(1).isEqualTo(capturedProductDetailsParams.size) + assertThat(productId).isEqualTo(capturedProductDetailsParams[0].zza().productId) + assertThat(subsGoogleProductType).isEqualTo(capturedProductDetailsParams[0].zza().productType) + + assertThat(oldPurchase.purchaseToken).isEqualTo(oldPurchaseTokenSlot.captured) + assertThat(GoogleReplacementMode.DEFERRED.playBillingClientMode).isEqualTo(replacementModeSlot.captured) + billingClientOKResult + } + + billingClientStateListener!!.onBillingSetupFinished(billingClientOKResult) + wrapper.makePurchaseAsync( + mockActivity, + appUserId, + storeProduct.subscriptionOptions!!.first().purchasingData, + upgradeInfo, + null, + null, + ) + } + @Test fun `skips setting on BillingFlowPrams when replacementmode or personalized price null for subscription purchase`() { mockkStatic(BillingFlowParams::class) @@ -1674,7 +1745,7 @@ class BillingWrapperTest { assertThat(purchaseContext?.productType).isEqualTo(ProductType.SUBS) assertThat(purchaseContext?.presentedOfferingContext).isEqualTo(PresentedOfferingContext("offering_a")) assertThat(purchaseContext?.selectedSubscriptionOptionId).isEqualTo(optionId) - assertThat(purchaseContext?.replacementMode).isEqualTo(GoogleReplacementMode.CHARGE_FULL_PRICE) + assertThat(purchaseContext?.replacementMode).isEqualTo(StoreReplacementMode.CHARGE_FULL_PRICE) val subscriptionOptionIdForProductIDs = purchaseContext?.subscriptionOptionIdForProductIDs assertThat(subscriptionOptionIdForProductIDs).isNotNull @@ -1753,7 +1824,8 @@ class BillingWrapperTest { assertThat(capturedProductDetailsParams[1].zza().productId).isEqualTo(productId2) assertThat(capturedProductDetailsParams[1].zza().productType).isEqualTo(subsGoogleProductType) assertThat(upgradeInfo.oldPurchase.purchaseToken).isEqualTo(oldPurchaseTokenSlot.captured) - assertThat((upgradeInfo.replacementMode as GoogleReplacementMode?)?.playBillingClientMode).isEqualTo(replacementModeSlot.captured) + assertThat((upgradeInfo.replacementMode as StoreReplacementMode?)?.toPlayBillingClientMode()) + .isEqualTo(replacementModeSlot.captured) assertThat(isPersonalizedPrice).isEqualTo(isPersonalizedPriceSlot.captured) billingClientOKResult @@ -2083,7 +2155,7 @@ class BillingWrapperTest { private fun mockReplaceSkuInfo(): ReplaceProductInfo { val oldPurchase = mockPurchaseRecordWrapper() - return ReplaceProductInfo(oldPurchase, GoogleReplacementMode.CHARGE_FULL_PRICE) + return ReplaceProductInfo(oldPurchase, StoreReplacementMode.CHARGE_FULL_PRICE) } private fun setUpForObfuscatedAccountIDTests(): BillingFlowParams.Builder { diff --git a/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt index 49ec538c5e..aa0f9d2ea9 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt @@ -26,7 +26,7 @@ class StoreReplacementModeConversionsTest { StoreReplacementMode.values().forEach { mode -> val expected = expectations[mode] ?: error("Missing expected mapping for $mode") - assertThat(mode.toGoogleBillingClientMode()).isEqualTo(expected) + assertThat(mode.toPlayBillingClientMode()).isEqualTo(expected) } assertThat(expectations.size).isEqualTo(StoreReplacementMode.values().size) From 344848597adbba1358babe1b43dab4f137d62d4d Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Tue, 17 Mar 2026 10:21:03 -0500 Subject: [PATCH 14/34] detekt --- .../kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt | 2 +- .../kotlin/com/revenuecat/purchases/google/BillingWrapper.kt | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt index c69f87055b..65133223b4 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchasesOrchestrator.kt @@ -629,7 +629,7 @@ internal class PurchasesOrchestrator( purchasingData, presentedOfferingContext, productId, - replacementMode, + replacementMode, isPersonalizedPrice, callback, ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingWrapper.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingWrapper.kt index 12d0d754f2..aa6b3ea86b 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingWrapper.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingWrapper.kt @@ -297,8 +297,8 @@ internal class BillingWrapper( // When using DEFERRED proration mode, callback needs to be associated with the *old* product we are // switching from, because the transaction we receive on successful purchase is for the old product. val productId = - if (replaceProductInfo?.replacementMode == StoreReplacementMode.DEFERRED - || replaceProductInfo?.replacementMode == GoogleReplacementMode.DEFERRED + if (replaceProductInfo?.replacementMode == StoreReplacementMode.DEFERRED || + replaceProductInfo?.replacementMode == GoogleReplacementMode.DEFERRED ) { replaceProductInfo.oldPurchase.productIds.first() } else { From aabb5581efbc1404b8496a702a265d74ea067d08 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Tue, 17 Mar 2026 11:32:38 -0500 Subject: [PATCH 15/34] update backend name generation to use StoreReplacementMode --- .../revenuecat/purchases/ReplacementMode.kt | 48 ++++++++++ .../revenuecat/purchases/common/Backend.kt | 2 +- .../purchases/ReplacementModeTest.kt | 95 +++++++++++++++++++ .../purchases/common/backend/BackendTest.kt | 52 +++++++++- 4 files changed, 194 insertions(+), 3 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt index 6eb51a1e47..729b5f0f12 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt @@ -3,6 +3,8 @@ package com.revenuecat.purchases import android.os.Parcelable import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.GoogleReplacementMode +import com.revenuecat.purchases.models.StoreReplacementMode +import com.revenuecat.purchases.models.toGoogleReplacementMode import kotlinx.android.parcel.IgnoredOnParcel import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException @@ -44,6 +46,33 @@ private val GoogleReplacementMode.asLegacyProrationMode: LegacyProrationMode GoogleReplacementMode.DEFERRED -> LegacyProrationMode.DEFERRED } +private val StoreReplacementMode.galaxyName: String? + get() = when (this) { + StoreReplacementMode.WITHOUT_PRORATION -> "INSTANT_NO_PRORATION" + StoreReplacementMode.WITH_TIME_PRORATION -> "INSTANT_PRORATED_DATE" + StoreReplacementMode.CHARGE_FULL_PRICE -> null // Unsupported by Galaxy Store + StoreReplacementMode.CHARGE_PRORATED_PRICE -> "INSTANT_PRORATED_CHARGE" + StoreReplacementMode.DEFERRED -> "DEFERRED" + } + +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +private val GalaxyReplacementMode.storeReplacementMode: StoreReplacementMode + get() = when (this) { + GalaxyReplacementMode.INSTANT_NO_PRORATION -> StoreReplacementMode.WITHOUT_PRORATION + GalaxyReplacementMode.INSTANT_PRORATED_DATE -> StoreReplacementMode.WITH_TIME_PRORATION + GalaxyReplacementMode.INSTANT_PRORATED_CHARGE -> StoreReplacementMode.CHARGE_PRORATED_PRICE + GalaxyReplacementMode.DEFERRED -> StoreReplacementMode.DEFERRED + } + +private val GoogleReplacementMode.storeReplacementMode: StoreReplacementMode + get() = when (this) { + GoogleReplacementMode.WITHOUT_PRORATION -> StoreReplacementMode.WITHOUT_PRORATION + GoogleReplacementMode.WITH_TIME_PRORATION -> StoreReplacementMode.WITH_TIME_PRORATION + GoogleReplacementMode.CHARGE_FULL_PRICE -> StoreReplacementMode.CHARGE_FULL_PRICE + GoogleReplacementMode.CHARGE_PRORATED_PRICE -> StoreReplacementMode.CHARGE_PRORATED_PRICE + GoogleReplacementMode.DEFERRED -> StoreReplacementMode.DEFERRED + } + /** * Returns the backend name for this [ReplacementMode]. * For [GoogleReplacementMode], this returns the legacy proration mode name. @@ -57,6 +86,25 @@ internal val ReplacementMode.backendName: String else -> this.name } +@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +internal fun ReplacementMode.backendName(store: Store): String? { + return when (store) { + Store.PLAY_STORE -> when (this) { + is GoogleReplacementMode -> this.asLegacyProrationMode.name + is GalaxyReplacementMode -> this.storeReplacementMode.toGoogleReplacementMode().asLegacyProrationMode.name + is StoreReplacementMode -> this.toGoogleReplacementMode().asLegacyProrationMode.name + else -> null + } + Store.GALAXY -> when (this) { + is GoogleReplacementMode -> this.storeReplacementMode.galaxyName + is GalaxyReplacementMode -> this.name + is StoreReplacementMode -> this.galaxyName + else -> null + } + else -> null + } +} + internal object ReplacementModeSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ReplacementMode") { element("type", String.serializer().descriptor) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt index b406df69b2..c225a11d6d 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/common/Backend.kt @@ -292,7 +292,7 @@ internal class Backend( "normal_duration" to receiptInfo.duration, "store_user_id" to receiptInfo.storeUserID, "pricing_phases" to receiptInfo.pricingPhases?.map { it.toMap() }, - "proration_mode" to receiptInfo.replacementMode?.backendName, + "proration_mode" to receiptInfo.replacementMode?.backendName(store = appConfig.store), "initiation_source" to initiationSource.postReceiptFieldValue, "paywall" to paywallPostReceiptData?.toMap(), "sdk_originated" to receiptInfo.sdkOriginated, diff --git a/purchases/src/test/java/com/revenuecat/purchases/ReplacementModeTest.kt b/purchases/src/test/java/com/revenuecat/purchases/ReplacementModeTest.kt index f20183700a..8a18a811ca 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/ReplacementModeTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/ReplacementModeTest.kt @@ -3,6 +3,7 @@ package com.revenuecat.purchases import android.os.Parcel import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.GoogleReplacementMode +import com.revenuecat.purchases.models.StoreReplacementMode import org.assertj.core.api.Assertions.assertThat import org.junit.Test @@ -49,6 +50,100 @@ class ReplacementModeTest { assertThat(mode.backendName).isEqualTo("CUSTOM_MODE") } + @Test + fun `store replacement modes map to legacy Play Store replacement mode backend names`() { + val expectations = mapOf( + StoreReplacementMode.WITHOUT_PRORATION to "IMMEDIATE_WITHOUT_PRORATION", + StoreReplacementMode.WITH_TIME_PRORATION to "IMMEDIATE_WITH_TIME_PRORATION", + StoreReplacementMode.CHARGE_FULL_PRICE to "IMMEDIATE_AND_CHARGE_FULL_PRICE", + StoreReplacementMode.CHARGE_PRORATED_PRICE to "IMMEDIATE_AND_CHARGE_PRORATED_PRICE", + StoreReplacementMode.DEFERRED to "DEFERRED", + ) + + StoreReplacementMode.values().forEach { mode -> + assertThat(expectations).containsKey(mode) + assertThat(mode.backendName(Store.PLAY_STORE)).isEqualTo(expectations.getValue(mode)) + } + assertThat(expectations.size).isEqualTo(StoreReplacementMode.values().size) + } + + @Test + fun `store replacement modes map to Galaxy backend names`() { + val expectations = mapOf( + StoreReplacementMode.WITHOUT_PRORATION to "INSTANT_NO_PRORATION", + StoreReplacementMode.WITH_TIME_PRORATION to "INSTANT_PRORATED_DATE", + StoreReplacementMode.CHARGE_PRORATED_PRICE to "INSTANT_PRORATED_CHARGE", + StoreReplacementMode.DEFERRED to "DEFERRED", + ) + + StoreReplacementMode.values().forEach { mode -> + if (mode == StoreReplacementMode.CHARGE_FULL_PRICE) { + assertThat(mode.backendName(Store.GALAXY)).isNull() + } else { + assertThat(expectations).containsKey(mode) + assertThat(mode.backendName(Store.GALAXY)).isEqualTo(expectations.getValue(mode)) + } + } + assertThat(expectations.size).isEqualTo(StoreReplacementMode.values().size - 1) + } + + @Test + fun `google replacement modes use store specific backend names`() { + val playExpectations = mapOf( + GoogleReplacementMode.WITHOUT_PRORATION to "IMMEDIATE_WITHOUT_PRORATION", + GoogleReplacementMode.WITH_TIME_PRORATION to "IMMEDIATE_WITH_TIME_PRORATION", + GoogleReplacementMode.CHARGE_FULL_PRICE to "IMMEDIATE_AND_CHARGE_FULL_PRICE", + GoogleReplacementMode.CHARGE_PRORATED_PRICE to "IMMEDIATE_AND_CHARGE_PRORATED_PRICE", + GoogleReplacementMode.DEFERRED to "DEFERRED", + ) + val galaxyExpectations = mapOf( + GoogleReplacementMode.WITHOUT_PRORATION to "INSTANT_NO_PRORATION", + GoogleReplacementMode.WITH_TIME_PRORATION to "INSTANT_PRORATED_DATE", + GoogleReplacementMode.CHARGE_PRORATED_PRICE to "INSTANT_PRORATED_CHARGE", + GoogleReplacementMode.DEFERRED to "DEFERRED", + ) + + GoogleReplacementMode.values().forEach { mode -> + assertThat(playExpectations).containsKey(mode) + assertThat(mode.backendName(Store.PLAY_STORE)).isEqualTo(playExpectations.getValue(mode)) + + if (mode == GoogleReplacementMode.CHARGE_FULL_PRICE) { + assertThat(mode.backendName(Store.GALAXY)).isNull() + } else { + assertThat(galaxyExpectations).containsKey(mode) + assertThat(mode.backendName(Store.GALAXY)).isEqualTo(galaxyExpectations.getValue(mode)) + } + } + assertThat(playExpectations.size).isEqualTo(GoogleReplacementMode.values().size) + assertThat(galaxyExpectations.size).isEqualTo(GoogleReplacementMode.values().size - 1) + } + + @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) + @Test + fun `galaxy replacement modes use store specific backend names`() { + val playExpectations = mapOf( + GalaxyReplacementMode.INSTANT_NO_PRORATION to "IMMEDIATE_WITHOUT_PRORATION", + GalaxyReplacementMode.INSTANT_PRORATED_DATE to "IMMEDIATE_WITH_TIME_PRORATION", + GalaxyReplacementMode.INSTANT_PRORATED_CHARGE to "IMMEDIATE_AND_CHARGE_PRORATED_PRICE", + GalaxyReplacementMode.DEFERRED to "DEFERRED", + ) + val galaxyExpectations = mapOf( + GalaxyReplacementMode.INSTANT_PRORATED_DATE to "INSTANT_PRORATED_DATE", + GalaxyReplacementMode.INSTANT_PRORATED_CHARGE to "INSTANT_PRORATED_CHARGE", + GalaxyReplacementMode.INSTANT_NO_PRORATION to "INSTANT_NO_PRORATION", + GalaxyReplacementMode.DEFERRED to "DEFERRED", + ) + + GalaxyReplacementMode.values().forEach { mode -> + assertThat(playExpectations).containsKey(mode) + assertThat(mode.backendName(Store.PLAY_STORE)).isEqualTo(playExpectations.getValue(mode)) + assertThat(galaxyExpectations).containsKey(mode) + assertThat(mode.backendName(Store.GALAXY)).isEqualTo(galaxyExpectations.getValue(mode)) + } + assertThat(playExpectations.size).isEqualTo(GalaxyReplacementMode.values().size) + assertThat(galaxyExpectations.size).isEqualTo(GalaxyReplacementMode.values().size) + } + private class TestReplacementMode( override val name: String, ) : ReplacementMode { diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendTest.kt index e5ec953402..7bb4a58a36 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/backend/BackendTest.kt @@ -7,6 +7,8 @@ import com.revenuecat.purchases.PresentedOfferingContext import com.revenuecat.purchases.PurchasesAreCompletedBy import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.PurchasesErrorCode +import com.revenuecat.purchases.ReplacementMode +import com.revenuecat.purchases.Store import com.revenuecat.purchases.VerificationResult import com.revenuecat.purchases.common.AppConfig import com.revenuecat.purchases.common.Backend @@ -42,6 +44,7 @@ import com.revenuecat.purchases.models.Price import com.revenuecat.purchases.models.PricingPhase import com.revenuecat.purchases.models.RecurrenceMode import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.paywalls.events.PaywallPostReceiptData import com.revenuecat.purchases.utils.Responses import com.revenuecat.purchases.utils.filterNotNullValues @@ -115,6 +118,7 @@ class BackendTest { every { baseURL } returns mockBaseURL every { customEntitlementComputation } returns false every { fallbackBaseURLs } returns emptyList() + every { store } returns Store.PLAY_STORE } private val dispatcher = spyk(SyncDispatcher()) private val backendHelper = BackendHelper(API_KEY, dispatcher, mockAppConfig, mockClient) @@ -510,7 +514,7 @@ class BackendTest { val receiptInfo = createReceiptInfoFromProduct( productIDs = productIDs, storeProduct = storeProduct, - replacementMode = GoogleReplacementMode.WITHOUT_PRORATION + replacementMode = StoreReplacementMode.WITHOUT_PRORATION ) mockPostReceiptResponseAndPost( @@ -545,6 +549,50 @@ class BackendTest { assertThat(requestBodySlot.captured["proration_mode"]).isEqualTo("IMMEDIATE_WITHOUT_PRORATION") } + @Test + fun `postReceipt uses Galaxy replacement mode names in body when configured store is Galaxy`() { + every { mockAppConfig.store } returns Store.GALAXY + + val receiptInfo = createReceiptInfoFromProduct( + productIDs = productIDs, + storeProduct = storeProduct, + replacementMode = StoreReplacementMode.WITHOUT_PRORATION, + ) + + mockPostReceiptResponseAndPost( + backend, + isRestore = false, + finishTransactions = true, + receiptInfo = receiptInfo, + initiationSource = initiationSource, + ) + + assertThat(requestBodySlot.isCaptured).isTrue + assertThat(requestBodySlot.captured["proration_mode"]).isEqualTo("INSTANT_NO_PRORATION") + } + + @Test + fun `postReceipt uses Galaxy replacement mode names for deprecated Google replacement modes when configured store is Galaxy`() { + every { mockAppConfig.store } returns Store.GALAXY + + val receiptInfo = createReceiptInfoFromProduct( + productIDs = productIDs, + storeProduct = storeProduct, + replacementMode = GoogleReplacementMode.WITHOUT_PRORATION, + ) + + mockPostReceiptResponseAndPost( + backend, + isRestore = false, + finishTransactions = true, + receiptInfo = receiptInfo, + initiationSource = initiationSource, + ) + + assertThat(requestBodySlot.isCaptured).isTrue + assertThat(requestBodySlot.captured["proration_mode"]).isEqualTo("INSTANT_NO_PRORATION") + } + @Test fun `postReceipt has product_plan_id in body if receipt is GoogleStoreProduct subscription`() { val productId = "product_id" @@ -3295,7 +3343,7 @@ class BackendTest { storeProduct: StoreProduct, productIDs: List = listOf(storeProduct.id), presentedOfferingContext: PresentedOfferingContext? = null, - replacementMode: GoogleReplacementMode? = null, + replacementMode: ReplacementMode? = null, platformProductIds: List> = listOf(mapOf("product_id" to storeProduct.id)), storeUserID: String? = null, marketplace: String? = null, From da3dd6e9b269882297c575c11ce11720deedeb16 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Tue, 17 Mar 2026 11:46:43 -0500 Subject: [PATCH 16/34] set replacementMode in PurchaseParams when providing googleReplacementMode --- .../revenuecat/purchases/PurchaseParams.kt | 2 ++ .../models/StoreReplacementModeConversions.kt | 10 +++++++ .../purchases/PurchaseParamsTest.kt | 27 +++++++++++++++++++ .../StoreReplacementModeConversionsTest.kt | 18 +++++++++++++ .../com/revenuecat/purchases/PurchasesTest.kt | 11 ++++---- 5 files changed, 63 insertions(+), 5 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt index 0791b5daae..a398261655 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt @@ -11,6 +11,7 @@ import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.SubscriptionOption import com.revenuecat.purchases.models.toGoogleReplacementMode +import com.revenuecat.purchases.models.toStoreReplacementMode import com.revenuecat.purchases.strings.PurchaseStrings import dev.drewhamilton.poko.Poko @@ -156,6 +157,7 @@ public class PurchaseParams(public val builder: Builder) { */ public fun googleReplacementMode(googleReplacementMode: GoogleReplacementMode): Builder = apply { this.googleReplacementMode = googleReplacementMode + this.replacementMode = googleReplacementMode.toStoreReplacementMode() } /** diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt index 82ab80d5f5..b29b3c0f90 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt @@ -26,3 +26,13 @@ internal fun StoreReplacementMode.toGoogleReplacementMode(): GoogleReplacementMo StoreReplacementMode.DEFERRED -> GoogleReplacementMode.DEFERRED } } + +internal fun GoogleReplacementMode.toStoreReplacementMode(): StoreReplacementMode { + return when (this) { + GoogleReplacementMode.WITHOUT_PRORATION -> StoreReplacementMode.WITHOUT_PRORATION + GoogleReplacementMode.WITH_TIME_PRORATION -> StoreReplacementMode.WITH_TIME_PRORATION + GoogleReplacementMode.CHARGE_FULL_PRICE -> StoreReplacementMode.CHARGE_FULL_PRICE + GoogleReplacementMode.CHARGE_PRORATED_PRICE -> StoreReplacementMode.CHARGE_PRORATED_PRICE + GoogleReplacementMode.DEFERRED -> StoreReplacementMode.DEFERRED + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt index f6a3064e3a..c3991df5ca 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt @@ -189,6 +189,33 @@ class PurchaseParamsTest { } } + // We can remove this test once we fully remove PurchaseParams.googleReplacementMode + @Test + fun `googleReplacementMode set on builder updates replacementMode in PurchaseParams`() { + val storeProduct = stubStoreProduct("abc") + val expectations = mapOf( + GoogleReplacementMode.WITHOUT_PRORATION to StoreReplacementMode.WITHOUT_PRORATION, + GoogleReplacementMode.WITH_TIME_PRORATION to StoreReplacementMode.WITH_TIME_PRORATION, + GoogleReplacementMode.CHARGE_FULL_PRICE to StoreReplacementMode.CHARGE_FULL_PRICE, + GoogleReplacementMode.CHARGE_PRORATED_PRICE to StoreReplacementMode.CHARGE_PRORATED_PRICE, + GoogleReplacementMode.DEFERRED to StoreReplacementMode.DEFERRED, + ) + + GoogleReplacementMode.values().forEach { googleReplacementMode -> + val purchaseParams = PurchaseParams.Builder( + mockk(), + storeProduct + ) + .googleReplacementMode(googleReplacementMode) + .build() + + val expectedStoreReplacementMode = + expectations[googleReplacementMode] + ?: error("Missing expected store replacement mode for $googleReplacementMode") + assertThat(purchaseParams.replacementMode).isEqualTo(expectedStoreReplacementMode) + } + } + // region Add-Ons @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) @Test diff --git a/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt index aa0f9d2ea9..0b36cbdbbe 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt @@ -49,4 +49,22 @@ class StoreReplacementModeConversionsTest { assertThat(expectations.size).isEqualTo(StoreReplacementMode.values().size) } + + @Test + fun `all deprecated Google replacement modes map to store replacement modes`() { + val expectations = mapOf( + GoogleReplacementMode.WITHOUT_PRORATION to StoreReplacementMode.WITHOUT_PRORATION, + GoogleReplacementMode.WITH_TIME_PRORATION to StoreReplacementMode.WITH_TIME_PRORATION, + GoogleReplacementMode.CHARGE_FULL_PRICE to StoreReplacementMode.CHARGE_FULL_PRICE, + GoogleReplacementMode.CHARGE_PRORATED_PRICE to StoreReplacementMode.CHARGE_PRORATED_PRICE, + GoogleReplacementMode.DEFERRED to StoreReplacementMode.DEFERRED, + ) + + GoogleReplacementMode.values().forEach { mode -> + val expected = expectations[mode] ?: error("Missing expected mapping for $mode") + assertThat(mode.toStoreReplacementMode()).isEqualTo(expected) + } + + assertThat(expectations.size).isEqualTo(GoogleReplacementMode.values().size) + } } diff --git a/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/PurchasesTest.kt b/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/PurchasesTest.kt index b851d92923..2704d3a16e 100644 --- a/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/PurchasesTest.kt +++ b/purchases/src/testDefaults/kotlin/com/revenuecat/purchases/PurchasesTest.kt @@ -29,6 +29,7 @@ import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.GoogleSubscriptionOption import com.revenuecat.purchases.models.PurchasingData import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.paywalls.DownloadedFontFamily import com.revenuecat.purchases.paywalls.events.PaywallEvent @@ -274,7 +275,7 @@ internal class PurchasesTest : BasePurchasesTest() { val expectedReplaceProductInfo = ReplaceProductInfo( oldTransaction, - GoogleReplacementMode.WITHOUT_PRORATION, + StoreReplacementMode.WITHOUT_PRORATION, ) verify { mockBillingAbstract.makePurchaseAsync( @@ -385,7 +386,7 @@ internal class PurchasesTest : BasePurchasesTest() { match { replaceProductInfo -> replaceProductInfo.oldPurchase.productIds.size == 1 && replaceProductInfo.oldPurchase.productIds.first() == "oldProductId" && - replaceProductInfo.replacementMode == GoogleReplacementMode.CHARGE_PRORATED_PRICE + replaceProductInfo.replacementMode == StoreReplacementMode.CHARGE_PRORATED_PRICE }, PresentedOfferingContext(STUB_OFFERING_IDENTIFIER), null, @@ -420,7 +421,7 @@ internal class PurchasesTest : BasePurchasesTest() { match { replaceProductInfo -> replaceProductInfo.oldPurchase.productIds.size == 1 && replaceProductInfo.oldPurchase.productIds.first() == "oldProductId" && - replaceProductInfo.replacementMode == GoogleReplacementMode.CHARGE_PRORATED_PRICE + replaceProductInfo.replacementMode == StoreReplacementMode.CHARGE_PRORATED_PRICE }, PresentedOfferingContext(STUB_OFFERING_IDENTIFIER), null, @@ -1932,7 +1933,7 @@ internal class PurchasesTest : BasePurchasesTest() { val capturedReplaceProductInfo = replaceProductInfoSlot.captured assertThat(capturedReplaceProductInfo.oldPurchase.productIds).isEqualTo(expectedOldPurchase.productIds) assertThat(capturedReplaceProductInfo.oldPurchase.purchaseToken).isEqualTo(expectedOldPurchase.purchaseToken) - assertThat(capturedReplaceProductInfo.replacementMode).isEqualTo(GoogleReplacementMode.WITHOUT_PRORATION) + assertThat(capturedReplaceProductInfo.replacementMode).isEqualTo(StoreReplacementMode.WITHOUT_PRORATION) } @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) @@ -1999,7 +2000,7 @@ internal class PurchasesTest : BasePurchasesTest() { val capturedReplaceProductInfo = replaceProductInfoSlot.captured assertThat(capturedReplaceProductInfo.oldPurchase.productIds).isEqualTo(expectedOldPurchase.productIds) assertThat(capturedReplaceProductInfo.oldPurchase.purchaseToken).isEqualTo(expectedOldPurchase.purchaseToken) - assertThat(capturedReplaceProductInfo.replacementMode).isEqualTo(GoogleReplacementMode.WITHOUT_PRORATION) + assertThat(capturedReplaceProductInfo.replacementMode).isEqualTo(StoreReplacementMode.WITHOUT_PRORATION) } @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) From a98959f10d68cea2999ecd015000f7535df910ff Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Tue, 17 Mar 2026 12:06:10 -0500 Subject: [PATCH 17/34] make ReplacementModeSerializer use StoreReplacementMode --- .../revenuecat/purchases/ReplacementMode.kt | 13 +++- .../purchases/common/ReceiptInfoTest.kt | 66 ++++++++++++++++++- .../LocalTransactionMetadataStoreTest.kt | 59 +++++++++++++++++ 3 files changed, 134 insertions(+), 4 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt index 729b5f0f12..360f71e060 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt @@ -5,6 +5,8 @@ import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.toGoogleReplacementMode +import com.revenuecat.purchases.models.toStoreReplacementMode +import com.revenuecat.purchases.models.toStoreReplacementMode import kotlinx.android.parcel.IgnoredOnParcel import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException @@ -115,6 +117,7 @@ internal object ReplacementModeSerializer : KSerializer { encoder.encodeStructure(descriptor) { val type = when (value) { is GoogleReplacementMode -> "GoogleReplacementMode" + is StoreReplacementMode -> "StoreReplacementMode" else -> throw SerializationException("Unknown ReplacementMode type: ${value::class.simpleName}") } encodeStringElement(descriptor, 0, type) @@ -137,9 +140,17 @@ internal object ReplacementModeSerializer : KSerializer { } when (type) { + "StoreReplacementMode" -> { + try { + StoreReplacementMode.valueOf(name) + } catch (e: IllegalArgumentException) { + throw SerializationException("Invalid StoreReplacementMode name: $name", e) + } + } "GoogleReplacementMode" -> { try { - GoogleReplacementMode.valueOf(name) + // Parse GoogleReplacementMode to StoreReplacementMode + GoogleReplacementMode.valueOf(name).toStoreReplacementMode() } catch (e: IllegalArgumentException) { throw SerializationException("Invalid GoogleReplacementMode name: $name", e) } diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/ReceiptInfoTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/ReceiptInfoTest.kt index 9a9a518b1b..37265354b2 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/ReceiptInfoTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/ReceiptInfoTest.kt @@ -12,7 +12,6 @@ import com.revenuecat.purchases.PresentedOfferingContextSerializer import com.revenuecat.purchases.ProductType import com.revenuecat.purchases.ReplacementMode import com.revenuecat.purchases.TargetingContextSerializer -import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.Period import com.revenuecat.purchases.models.PeriodSerializer import com.revenuecat.purchases.models.Price @@ -22,6 +21,7 @@ import com.revenuecat.purchases.models.PricingPhaseSerializer import com.revenuecat.purchases.models.PurchaseState import com.revenuecat.purchases.models.PurchaseType import com.revenuecat.purchases.models.RecurrenceMode +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.utils.stubGooglePurchase import com.revenuecat.purchases.utils.stubStoreProduct @@ -334,7 +334,7 @@ class ReceiptInfoTest { @Test fun `ReceiptInfo with replacement mode can be serialized and deserialized`() { - val expectedReplacementMode = GoogleReplacementMode.WITH_TIME_PRORATION + val expectedReplacementMode = StoreReplacementMode.WITH_TIME_PRORATION val original = ReceiptInfo( productIDs = listOf(productIdentifier), price = 0.99, @@ -354,7 +354,7 @@ class ReceiptInfoTest { "price":0.99, "currency":"USD", "replacementMode":{ - "type":"GoogleReplacementMode", + "type":"StoreReplacementMode", "name":"WITH_TIME_PRORATION" } } @@ -366,6 +366,46 @@ class ReceiptInfoTest { assertThat(encoded).isEqualTo(expectedJson) } + @Test + fun `ReceiptInfo with StoreReplacementMode JSON deserializes to StoreReplacementMode`() { + // language=JSON + val receiptInfoJson = """ + { + "productIDs":["com.myproduct"], + "price":0.99, + "currency":"USD", + "replacementMode":{ + "type":"StoreReplacementMode", + "name":"CHARGE_PRORATED_PRICE" + } + } + """.trimIndent().lines().joinToString("") { it.trim() } + + val decoded = json.decodeFromString(receiptInfoJson) + + assertThat(decoded.replacementMode).isEqualTo(StoreReplacementMode.CHARGE_PRORATED_PRICE) + } + + @Test + fun `ReceiptInfo with legacy Google replacement mode JSON deserializes to StoreReplacementMode`() { + // language=JSON + val receiptInfoJson = """ + { + "productIDs":["com.myproduct"], + "price":0.99, + "currency":"USD", + "replacementMode":{ + "type":"GoogleReplacementMode", + "name":"WITH_TIME_PRORATION" + } + } + """.trimIndent().lines().joinToString("") { it.trim() } + + val decoded = json.decodeFromString(receiptInfoJson) + + assertThat(decoded.replacementMode).isEqualTo(StoreReplacementMode.WITH_TIME_PRORATION) + } + @Test fun `ReceiptInfo with unknown replacement mode type fails serializing and deserializing`() { val unknownReplacementMode: ReplacementMode = object : ReplacementMode { @@ -406,6 +446,26 @@ class ReceiptInfoTest { assertThatExceptionOfType(SerializationException::class.java).isThrownBy { json.decodeFromString(unknownReplacementModeJson) } } + @Test + fun `ReceiptInfo with invalid StoreReplacementMode name fails deserializing`() { + // language=JSON + val invalidReplacementModeJson = """ + { + "productIDs":["com.myproduct"], + "price":0.99, + "currency":"USD", + "replacementMode":{ + "type":"StoreReplacementMode", + "name":"NOT_A_REAL_MODE" + } + } + """.trimIndent().lines().joinToString("") { it.trim() } + + assertThatExceptionOfType(SerializationException::class.java).isThrownBy { + json.decodeFromString(invalidReplacementModeJson) + } + } + @Test fun `ReceiptInfo with PresentedOfferingContext can be serialized and deserialized`() { val targetingContext = PresentedOfferingContext.TargetingContext( diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/caching/LocalTransactionMetadataStoreTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/caching/LocalTransactionMetadataStoreTest.kt index 4b52f182ff..24c41252f8 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/caching/LocalTransactionMetadataStoreTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/caching/LocalTransactionMetadataStoreTest.kt @@ -8,6 +8,7 @@ import com.revenuecat.purchases.PresentedOfferingContext import com.revenuecat.purchases.PurchasesAreCompletedBy import com.revenuecat.purchases.common.ReceiptInfo import com.revenuecat.purchases.common.sha1 +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.paywalls.events.PaywallPostReceiptData import io.mockk.Runs import io.mockk.every @@ -575,5 +576,63 @@ class LocalTransactionMetadataStoreTest { assertThat(deserialized).isEqualTo(transactionMetadata) } + @Test + fun `LocalTransactionMetadata with StoreReplacementMode serializes correctly`() { + val receiptInfoWithReplacementMode = receiptInfo.copy( + replacementMode = StoreReplacementMode.DEFERRED, + ) + val transactionMetadata = LocalTransactionMetadata( + token = purchaseToken, + receiptInfo = receiptInfoWithReplacementMode, + paywallPostReceiptData = paywallData, + purchasesAreCompletedBy = PurchasesAreCompletedBy.REVENUECAT, + ) + + val jsonString = json.encodeToString(LocalTransactionMetadata.serializer(), transactionMetadata) + val deserialized = json.decodeFromString(LocalTransactionMetadata.serializer(), jsonString) + + assertThat(jsonString).contains("\"type\":\"StoreReplacementMode\"") + assertThat(deserialized.receiptInfo.replacementMode).isEqualTo(StoreReplacementMode.DEFERRED) + } + + @Test + fun `LocalTransactionMetadata with legacy GoogleReplacementMode JSON deserializes to StoreReplacementMode`() { + // language=json + val jsonString = """ + { + "token":"$purchaseToken", + "receipt_info":{ + "productIDs":["product_id"], + "presentedOfferingContext":{ + "offeringIdentifier":"offering_id", + "placementIdentifier":null, + "targetingContext":null + }, + "price":4.99, + "formattedPrice":"$4.99", + "currency":"USD", + "replacementMode":{ + "type":"GoogleReplacementMode", + "name":"DEFERRED" + } + }, + "paywall_data":{ + "paywall_id":"paywall_id", + "session_id":"session_id", + "revision":1, + "display_mode":"full_screen", + "dark_mode":false, + "locale":"en_US", + "offering_id":"offering_id" + }, + "purchases_are_completed_by":"REVENUECAT" + } + """.trimIndent().lines().joinToString("") { it.trim() } + + val deserialized = json.decodeFromString(LocalTransactionMetadata.serializer(), jsonString) + + assertThat(deserialized.receiptInfo.replacementMode).isEqualTo(StoreReplacementMode.DEFERRED) + } + // endregion } From 7b76f17f8f8ff46fc27e5e4ec53ed089117c6d45 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Tue, 17 Mar 2026 13:33:47 -0500 Subject: [PATCH 18/34] update PurchaseParams public APIs --- purchases/api-defaults-bc7.txt | 9 +++---- purchases/api-defauts.txt | 9 +++---- purchases/api-entitlement.txt | 9 +++---- .../revenuecat/purchases/PurchaseParams.kt | 26 +++--------------- .../purchases/PurchaseParamsTest.kt | 27 ------------------- 5 files changed, 13 insertions(+), 67 deletions(-) diff --git a/purchases/api-defaults-bc7.txt b/purchases/api-defaults-bc7.txt index f903f4968c..fa3de45b7c 100644 --- a/purchases/api-defaults-bc7.txt +++ b/purchases/api-defaults-bc7.txt @@ -338,14 +338,12 @@ package com.revenuecat.purchases { @dev.drewhamilton.poko.Poko public final class PurchaseParams { ctor public PurchaseParams(com.revenuecat.purchases.PurchaseParams.Builder builder); method public com.revenuecat.purchases.PurchaseParams.Builder getBuilder(); - method public com.revenuecat.purchases.models.GalaxyReplacementMode getGalaxyReplacementMode(); - method public com.revenuecat.purchases.models.GoogleReplacementMode getGoogleReplacementMode(); + method @Deprecated public com.revenuecat.purchases.models.GoogleReplacementMode getGoogleReplacementMode(); method public String? getOldProductId(); method public com.revenuecat.purchases.models.StoreReplacementMode getReplacementMode(); method public Boolean? isPersonalizedPrice(); property public final com.revenuecat.purchases.PurchaseParams.Builder builder; - property public final com.revenuecat.purchases.models.GalaxyReplacementMode galaxyReplacementMode; - property public final com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode; + property @Deprecated public final com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode; property public final Boolean? isPersonalizedPrice; property public final String? oldProductId; property public final com.revenuecat.purchases.models.StoreReplacementMode replacementMode; @@ -359,8 +357,7 @@ package com.revenuecat.purchases { method @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public final com.revenuecat.purchases.PurchaseParams.Builder addOnStoreProducts(java.util.List addOnStoreProducts); method @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public final com.revenuecat.purchases.PurchaseParams.Builder addOnSubscriptionOptions(java.util.List addOnSubscriptionOptions); method public com.revenuecat.purchases.PurchaseParams build(); - method @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public final com.revenuecat.purchases.PurchaseParams.Builder galaxyReplacementMode(com.revenuecat.purchases.models.GalaxyReplacementMode galaxyReplacementMode); - method public final com.revenuecat.purchases.PurchaseParams.Builder googleReplacementMode(com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode); + method @Deprecated public final com.revenuecat.purchases.PurchaseParams.Builder googleReplacementMode(com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode); method public final com.revenuecat.purchases.PurchaseParams.Builder isPersonalizedPrice(boolean isPersonalizedPrice); method public final com.revenuecat.purchases.PurchaseParams.Builder oldProductId(String oldProductId); method public final com.revenuecat.purchases.PurchaseParams.Builder presentedOfferingContext(com.revenuecat.purchases.PresentedOfferingContext presentedOfferingContext); diff --git a/purchases/api-defauts.txt b/purchases/api-defauts.txt index f903f4968c..fa3de45b7c 100644 --- a/purchases/api-defauts.txt +++ b/purchases/api-defauts.txt @@ -338,14 +338,12 @@ package com.revenuecat.purchases { @dev.drewhamilton.poko.Poko public final class PurchaseParams { ctor public PurchaseParams(com.revenuecat.purchases.PurchaseParams.Builder builder); method public com.revenuecat.purchases.PurchaseParams.Builder getBuilder(); - method public com.revenuecat.purchases.models.GalaxyReplacementMode getGalaxyReplacementMode(); - method public com.revenuecat.purchases.models.GoogleReplacementMode getGoogleReplacementMode(); + method @Deprecated public com.revenuecat.purchases.models.GoogleReplacementMode getGoogleReplacementMode(); method public String? getOldProductId(); method public com.revenuecat.purchases.models.StoreReplacementMode getReplacementMode(); method public Boolean? isPersonalizedPrice(); property public final com.revenuecat.purchases.PurchaseParams.Builder builder; - property public final com.revenuecat.purchases.models.GalaxyReplacementMode galaxyReplacementMode; - property public final com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode; + property @Deprecated public final com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode; property public final Boolean? isPersonalizedPrice; property public final String? oldProductId; property public final com.revenuecat.purchases.models.StoreReplacementMode replacementMode; @@ -359,8 +357,7 @@ package com.revenuecat.purchases { method @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public final com.revenuecat.purchases.PurchaseParams.Builder addOnStoreProducts(java.util.List addOnStoreProducts); method @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public final com.revenuecat.purchases.PurchaseParams.Builder addOnSubscriptionOptions(java.util.List addOnSubscriptionOptions); method public com.revenuecat.purchases.PurchaseParams build(); - method @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public final com.revenuecat.purchases.PurchaseParams.Builder galaxyReplacementMode(com.revenuecat.purchases.models.GalaxyReplacementMode galaxyReplacementMode); - method public final com.revenuecat.purchases.PurchaseParams.Builder googleReplacementMode(com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode); + method @Deprecated public final com.revenuecat.purchases.PurchaseParams.Builder googleReplacementMode(com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode); method public final com.revenuecat.purchases.PurchaseParams.Builder isPersonalizedPrice(boolean isPersonalizedPrice); method public final com.revenuecat.purchases.PurchaseParams.Builder oldProductId(String oldProductId); method public final com.revenuecat.purchases.PurchaseParams.Builder presentedOfferingContext(com.revenuecat.purchases.PresentedOfferingContext presentedOfferingContext); diff --git a/purchases/api-entitlement.txt b/purchases/api-entitlement.txt index b159484a86..a2b34e11aa 100644 --- a/purchases/api-entitlement.txt +++ b/purchases/api-entitlement.txt @@ -306,14 +306,12 @@ package com.revenuecat.purchases { @dev.drewhamilton.poko.Poko public final class PurchaseParams { ctor public PurchaseParams(com.revenuecat.purchases.PurchaseParams.Builder builder); method public com.revenuecat.purchases.PurchaseParams.Builder getBuilder(); - method public com.revenuecat.purchases.models.GalaxyReplacementMode getGalaxyReplacementMode(); - method public com.revenuecat.purchases.models.GoogleReplacementMode getGoogleReplacementMode(); + method @Deprecated public com.revenuecat.purchases.models.GoogleReplacementMode getGoogleReplacementMode(); method public String? getOldProductId(); method public com.revenuecat.purchases.models.StoreReplacementMode getReplacementMode(); method public Boolean? isPersonalizedPrice(); property public final com.revenuecat.purchases.PurchaseParams.Builder builder; - property public final com.revenuecat.purchases.models.GalaxyReplacementMode galaxyReplacementMode; - property public final com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode; + property @Deprecated public final com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode; property public final Boolean? isPersonalizedPrice; property public final String? oldProductId; property public final com.revenuecat.purchases.models.StoreReplacementMode replacementMode; @@ -327,8 +325,7 @@ package com.revenuecat.purchases { method @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public final com.revenuecat.purchases.PurchaseParams.Builder addOnStoreProducts(java.util.List addOnStoreProducts); method @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public final com.revenuecat.purchases.PurchaseParams.Builder addOnSubscriptionOptions(java.util.List addOnSubscriptionOptions); method public com.revenuecat.purchases.PurchaseParams build(); - method @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public final com.revenuecat.purchases.PurchaseParams.Builder galaxyReplacementMode(com.revenuecat.purchases.models.GalaxyReplacementMode galaxyReplacementMode); - method public final com.revenuecat.purchases.PurchaseParams.Builder googleReplacementMode(com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode); + method @Deprecated public final com.revenuecat.purchases.PurchaseParams.Builder googleReplacementMode(com.revenuecat.purchases.models.GoogleReplacementMode googleReplacementMode); method public final com.revenuecat.purchases.PurchaseParams.Builder isPersonalizedPrice(boolean isPersonalizedPrice); method public final com.revenuecat.purchases.PurchaseParams.Builder oldProductId(String oldProductId); method public final com.revenuecat.purchases.PurchaseParams.Builder presentedOfferingContext(com.revenuecat.purchases.PresentedOfferingContext presentedOfferingContext); diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt index a398261655..a4e988a976 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt @@ -3,7 +3,6 @@ package com.revenuecat.purchases import android.app.Activity import com.revenuecat.purchases.common.LogIntent import com.revenuecat.purchases.common.log -import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.GooglePurchasingData import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.PurchasingData @@ -20,10 +19,9 @@ public class PurchaseParams(public val builder: Builder) { public val isPersonalizedPrice: Boolean? public val oldProductId: String? - public val googleReplacementMode: GoogleReplacementMode - @ExperimentalPreviewRevenueCatPurchasesAPI - public val galaxyReplacementMode: GalaxyReplacementMode + @Deprecated("Use replacementMode instead") + public val googleReplacementMode: GoogleReplacementMode public val replacementMode: StoreReplacementMode @@ -49,8 +47,6 @@ public class PurchaseParams(public val builder: Builder) { this.oldProductId = builder.oldProductId this.googleReplacementMode = builder.googleReplacementMode this.replacementMode = builder.replacementMode - @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) - this.galaxyReplacementMode = builder.galaxyReplacementMode this.purchasingData = builder.purchasingData this.activity = builder.activity this.presentedOfferingContext = builder.presentedOfferingContext @@ -102,17 +98,13 @@ public class PurchaseParams(public val builder: Builder) { @set:JvmSynthetic @get:JvmSynthetic + @Deprecated("Use replacementMode instead") internal var googleReplacementMode: GoogleReplacementMode = GoogleReplacementMode.WITHOUT_PRORATION @set:JvmSynthetic @get:JvmSynthetic internal var replacementMode: StoreReplacementMode = StoreReplacementMode.WITHOUT_PRORATION - @OptIn(InternalRevenueCatAPI::class, ExperimentalPreviewRevenueCatPurchasesAPI::class) - @set:JvmSynthetic - @get:JvmSynthetic - internal var galaxyReplacementMode: GalaxyReplacementMode = GalaxyReplacementMode.default - /* * Sets the data about the context in which an offering was presented. * @@ -155,6 +147,7 @@ public class PurchaseParams(public val builder: Builder) { * * Only applied for Play Store product changes. Ignored for Amazon Appstore and Galaxy Store purchases. */ + @Deprecated("Use .replacementMode() instead") public fun googleReplacementMode(googleReplacementMode: GoogleReplacementMode): Builder = apply { this.googleReplacementMode = googleReplacementMode this.replacementMode = googleReplacementMode.toStoreReplacementMode() @@ -174,17 +167,6 @@ public class PurchaseParams(public val builder: Builder) { this.googleReplacementMode = replacementMode.toGoogleReplacementMode() } - /* - * The [GalaxyReplacementMode] to use when replacing the given oldProductId. Defaults to - * [GalaxyReplacementMode.IMMEDIATE_WITHOUT_PRORATION]. - * - * Only applied for Galaxy Store product changes. Ignored for Google Play and Amazon Appstore purchases. - */ - @ExperimentalPreviewRevenueCatPurchasesAPI - public fun galaxyReplacementMode(galaxyReplacementMode: GalaxyReplacementMode): Builder = apply { - this.galaxyReplacementMode = galaxyReplacementMode - } - /* * The [Package]s to add on to the base package passed in via the [PurchaseParams.Builder]'s constructor. * This will result in a multi-line purchase whose base product is the one passed in to the diff --git a/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt index c3991df5ca..6c2d78ffa6 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/PurchaseParamsTest.kt @@ -9,7 +9,6 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.revenuecat.purchases.models.GooglePurchasingData import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.GoogleSubscriptionOption -import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.PurchasingData import com.revenuecat.purchases.models.StoreProduct import com.revenuecat.purchases.models.StoreReplacementMode @@ -110,32 +109,6 @@ class PurchaseParamsTest { assertThat(purchasePackageParams.purchasingData).isEqualTo(expectedPurchasingData) } - @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) - @Test - fun `initializing defaults galaxyReplacementMode to default`() { - val storeProduct = stubStoreProduct("abc") - val purchaseParams = PurchaseParams.Builder( - mockk(), - storeProduct - ).build() - - assertThat(purchaseParams.galaxyReplacementMode).isEqualTo(GalaxyReplacementMode.default) - } - - @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) - @Test - fun `galaxyReplacementMode set on builder is reflected in PurchaseParams`() { - val storeProduct = stubStoreProduct("abc") - val purchaseParams = PurchaseParams.Builder( - mockk(), - storeProduct - ) - .galaxyReplacementMode(GalaxyReplacementMode.INSTANT_PRORATED_DATE) - .build() - - assertThat(purchaseParams.galaxyReplacementMode).isEqualTo(GalaxyReplacementMode.INSTANT_PRORATED_DATE) - } - @Test fun `initializing defaults replacementMode to WITHOUT_PRORATION`() { val storeProduct = stubStoreProduct("abc") From 1f08ad04f7e8c048cddbe0129dd4656746cf371d Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Tue, 17 Mar 2026 13:47:42 -0500 Subject: [PATCH 19/34] remove GalaxyReplacementMode --- .../apitester/java/PurchasesCommonAPI.java | 5 --- .../kotlin/GalaxyReplacementModeAPI.kt | 21 --------- .../apitester/kotlin/PurchasesCommonAPI.kt | 5 --- .../purchases/galaxy/GalaxyBillingWrapper.kt | 3 +- .../GalaxyReplacementModeConversions.kt | 15 ------- .../GalaxyReplacementModeConversionsTest.kt | 38 ---------------- .../StoreTransactionConversionsTest.kt | 4 +- purchases/api-defaults-bc7.txt | 6 --- purchases/api-defauts.txt | 6 --- purchases/api-entitlement.txt | 6 --- .../revenuecat/purchases/ReplacementMode.kt | 16 +------ .../purchases/models/GalaxyReplacementMode.kt | 10 +---- .../purchases/ReplacementModeTest.kt | 44 ------------------- 13 files changed, 5 insertions(+), 174 deletions(-) delete mode 100644 api-tester/src/main/java/com/revenuecat/apitester/kotlin/GalaxyReplacementModeAPI.kt delete mode 100644 feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/conversions/GalaxyReplacementModeConversions.kt delete mode 100644 feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/conversions/GalaxyReplacementModeConversionsTest.kt diff --git a/api-tester/src/main/java/com/revenuecat/apitester/java/PurchasesCommonAPI.java b/api-tester/src/main/java/com/revenuecat/apitester/java/PurchasesCommonAPI.java index a7cbac6f7d..956ea4552d 100644 --- a/api-tester/src/main/java/com/revenuecat/apitester/java/PurchasesCommonAPI.java +++ b/api-tester/src/main/java/com/revenuecat/apitester/java/PurchasesCommonAPI.java @@ -27,7 +27,6 @@ import com.revenuecat.purchases.interfaces.ReceiveOfferingsCallback; import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener; import com.revenuecat.purchases.models.BillingFeature; -import com.revenuecat.purchases.models.GalaxyReplacementMode; import com.revenuecat.purchases.models.GoogleReplacementMode; import com.revenuecat.purchases.models.InAppMessageType; import com.revenuecat.purchases.models.StoreProduct; @@ -110,14 +109,12 @@ public void onError(@NonNull PurchasesError error, boolean userCancelled) { }; String oldProductId = "old"; GoogleReplacementMode replacementMode = GoogleReplacementMode.WITH_TIME_PRORATION; - GalaxyReplacementMode galaxyReplacementMode = GalaxyReplacementMode.INSTANT_PRORATED_CHARGE; Boolean isPersonalizedPrice = true; PurchaseParams.Builder purchaseProductBuilder = new PurchaseParams.Builder(activity, storeProduct); purchaseProductBuilder .oldProductId(oldProductId) .googleReplacementMode(replacementMode) - .galaxyReplacementMode(galaxyReplacementMode) .isPersonalizedPrice(isPersonalizedPrice); PurchaseParams purchaseProductParams = purchaseProductBuilder.build(); purchases.purchase(purchaseProductParams, purchaseCallback); @@ -126,7 +123,6 @@ public void onError(@NonNull PurchasesError error, boolean userCancelled) { purchaseOptionBuilder .oldProductId(oldProductId) .googleReplacementMode(replacementMode) - .galaxyReplacementMode(galaxyReplacementMode) .isPersonalizedPrice(isPersonalizedPrice); PurchaseParams purchaseOptionParams = purchaseOptionBuilder.build(); purchases.purchase(purchaseOptionParams, purchaseCallback); @@ -135,7 +131,6 @@ public void onError(@NonNull PurchasesError error, boolean userCancelled) { purchasePackageBuilder .oldProductId(oldProductId) .googleReplacementMode(replacementMode) - .galaxyReplacementMode(galaxyReplacementMode) .isPersonalizedPrice(isPersonalizedPrice); PurchaseParams purchasePackageParams = purchasePackageBuilder.build(); purchases.purchase(purchasePackageParams, purchaseCallback); diff --git a/api-tester/src/main/java/com/revenuecat/apitester/kotlin/GalaxyReplacementModeAPI.kt b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/GalaxyReplacementModeAPI.kt deleted file mode 100644 index 47843fd714..0000000000 --- a/api-tester/src/main/java/com/revenuecat/apitester/kotlin/GalaxyReplacementModeAPI.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.revenuecat.apitester.kotlin - -import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI -import com.revenuecat.purchases.InternalRevenueCatAPI -import com.revenuecat.purchases.models.GalaxyReplacementMode - -@Suppress("unused", "UNUSED_VARIABLE") -private class GalaxyReplacementModeAPI { - @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class, InternalRevenueCatAPI::class) - fun check(mode: GalaxyReplacementMode) { - when (mode) { - GalaxyReplacementMode.INSTANT_PRORATED_DATE, - GalaxyReplacementMode.INSTANT_PRORATED_CHARGE, - GalaxyReplacementMode.INSTANT_NO_PRORATION, - GalaxyReplacementMode.DEFERRED, - -> {} - }.exhaustive - - val defaultMode: GalaxyReplacementMode = GalaxyReplacementMode.default - } -} diff --git a/api-tester/src/main/java/com/revenuecat/apitester/kotlin/PurchasesCommonAPI.kt b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/PurchasesCommonAPI.kt index dd8bb0e941..40bb50ed18 100644 --- a/api-tester/src/main/java/com/revenuecat/apitester/kotlin/PurchasesCommonAPI.kt +++ b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/PurchasesCommonAPI.kt @@ -35,7 +35,6 @@ import com.revenuecat.purchases.interfaces.ReceiveCustomerInfoCallback import com.revenuecat.purchases.interfaces.ReceiveOfferingsCallback import com.revenuecat.purchases.interfaces.UpdatedCustomerInfoListener import com.revenuecat.purchases.models.BillingFeature -import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.InAppMessageType import com.revenuecat.purchases.models.StoreProduct @@ -111,14 +110,12 @@ private class PurchasesCommonAPI { val oldProductId = "old" val replacementMode = GoogleReplacementMode.WITH_TIME_PRORATION - val galaxyReplacementMode = GalaxyReplacementMode.INSTANT_PRORATED_CHARGE val isPersonalizedPrice = true val purchasePackageBuilder: PurchaseParams.Builder = PurchaseParams.Builder(activity, packageToPurchase) purchasePackageBuilder .oldProductId(oldProductId) .googleReplacementMode(replacementMode) - .galaxyReplacementMode(galaxyReplacementMode) .isPersonalizedPrice(isPersonalizedPrice) val purchasePackageParams: PurchaseParams = purchasePackageBuilder.build() purchases.purchase(purchasePackageParams, purchaseCallback) @@ -127,7 +124,6 @@ private class PurchasesCommonAPI { purchaseProductBuilder .oldProductId(oldProductId) .googleReplacementMode(replacementMode) - .galaxyReplacementMode(galaxyReplacementMode) .isPersonalizedPrice(isPersonalizedPrice) val purchaseProductParams: PurchaseParams = purchaseProductBuilder.build() purchases.purchase(purchaseProductParams, purchaseCallback) @@ -136,7 +132,6 @@ private class PurchasesCommonAPI { purchaseOptionBuilder .oldProductId(oldProductId) .googleReplacementMode(replacementMode) - .galaxyReplacementMode(galaxyReplacementMode) .isPersonalizedPrice(isPersonalizedPrice) val purchaseOptionsParams: PurchaseParams = purchaseOptionBuilder.build() purchases.purchase(purchaseOptionsParams, purchaseCallback) diff --git a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt index 07054dac75..7c2b6fe4c8 100644 --- a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt +++ b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt @@ -36,7 +36,6 @@ import com.revenuecat.purchases.galaxy.logging.log import com.revenuecat.purchases.galaxy.utils.GalaxySerialOperation import com.revenuecat.purchases.galaxy.utils.SerialRequestExecutor import com.revenuecat.purchases.galaxy.utils.parseDateFromGalaxyDateString -import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.InAppMessageType import com.revenuecat.purchases.models.PurchaseState import com.revenuecat.purchases.models.PurchasingData @@ -394,7 +393,7 @@ internal class GalaxyBillingWrapper( receipt: PurchaseVo, productId: String, presentedOfferingContext: PresentedOfferingContext?, - replacementMode: GalaxyReplacementMode?, + replacementMode: StoreReplacementMode?, ) { try { val storeTransaction = receipt.toStoreTransaction( diff --git a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/conversions/GalaxyReplacementModeConversions.kt b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/conversions/GalaxyReplacementModeConversions.kt deleted file mode 100644 index 89bc68e017..0000000000 --- a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/conversions/GalaxyReplacementModeConversions.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.revenuecat.purchases.galaxy.conversions - -import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI -import com.revenuecat.purchases.models.GalaxyReplacementMode -import com.samsung.android.sdk.iap.lib.constants.HelperDefine - -@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) -internal fun GalaxyReplacementMode.toSamsungProrationMode(): HelperDefine.ProrationMode { - return when (this) { - GalaxyReplacementMode.INSTANT_PRORATED_DATE -> HelperDefine.ProrationMode.INSTANT_PRORATED_DATE - GalaxyReplacementMode.INSTANT_PRORATED_CHARGE -> HelperDefine.ProrationMode.INSTANT_PRORATED_CHARGE - GalaxyReplacementMode.INSTANT_NO_PRORATION -> HelperDefine.ProrationMode.INSTANT_NO_PRORATION - GalaxyReplacementMode.DEFERRED -> HelperDefine.ProrationMode.DEFERRED - } -} diff --git a/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/conversions/GalaxyReplacementModeConversionsTest.kt b/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/conversions/GalaxyReplacementModeConversionsTest.kt deleted file mode 100644 index 01c764d805..0000000000 --- a/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/conversions/GalaxyReplacementModeConversionsTest.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.revenuecat.purchases.galaxy.conversions - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI -import com.revenuecat.purchases.InternalRevenueCatAPI -import com.revenuecat.purchases.models.GalaxyReplacementMode -import com.samsung.android.sdk.iap.lib.constants.HelperDefine -import kotlin.test.Test -import org.assertj.core.api.Assertions.assertThat -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class GalaxyReplacementModeConversionsTest { - - @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) - @Test - fun `all replacement modes map to Samsung proration modes`() { - val expectations = mapOf( - GalaxyReplacementMode.INSTANT_PRORATED_DATE to HelperDefine.ProrationMode.INSTANT_PRORATED_DATE, - GalaxyReplacementMode.INSTANT_PRORATED_CHARGE to HelperDefine.ProrationMode.INSTANT_PRORATED_CHARGE, - GalaxyReplacementMode.INSTANT_NO_PRORATION to HelperDefine.ProrationMode.INSTANT_NO_PRORATION, - GalaxyReplacementMode.DEFERRED to HelperDefine.ProrationMode.DEFERRED, - ) - - for (mode in GalaxyReplacementMode.values()) { - val expected = expectations[mode] ?: error("Missing expected mapping for $mode") - assertThat(mode.toSamsungProrationMode()).isEqualTo(expected) - } - - assertThat(expectations.size).isEqualTo(GalaxyReplacementMode.values().size) - } - - @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class, InternalRevenueCatAPI::class) - @Test - fun `default replacement mode is instant no proration`() { - assertThat(GalaxyReplacementMode.default).isEqualTo(GalaxyReplacementMode.INSTANT_NO_PRORATION) - } -} diff --git a/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/conversions/StoreTransactionConversionsTest.kt b/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/conversions/StoreTransactionConversionsTest.kt index 8c68be0033..af18cd9eab 100644 --- a/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/conversions/StoreTransactionConversionsTest.kt +++ b/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/conversions/StoreTransactionConversionsTest.kt @@ -5,9 +5,9 @@ import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI import com.revenuecat.purchases.PresentedOfferingContext import com.revenuecat.purchases.ProductType import com.revenuecat.purchases.galaxy.utils.parseDateFromGalaxyDateString -import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.PurchaseState import com.revenuecat.purchases.models.PurchaseType +import com.revenuecat.purchases.models.StoreReplacementMode import com.samsung.android.sdk.iap.lib.vo.PurchaseVo import io.mockk.every import io.mockk.mockk @@ -82,7 +82,7 @@ class StoreTransactionConversionsTest { purchaseDate = purchaseDateString, type = "subscription", ) - val replacementMode = GalaxyReplacementMode.INSTANT_PRORATED_DATE + val replacementMode = StoreReplacementMode.WITH_TIME_PRORATION val storeTransaction = purchaseVo.toStoreTransaction( productId = "product", diff --git a/purchases/api-defaults-bc7.txt b/purchases/api-defaults-bc7.txt index fa3de45b7c..54e0cb2af4 100644 --- a/purchases/api-defaults-bc7.txt +++ b/purchases/api-defaults-bc7.txt @@ -1179,12 +1179,6 @@ package com.revenuecat.purchases.models { enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_NO_PRORATION; enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_PRORATED_CHARGE; enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_PRORATED_DATE; - field public static final com.revenuecat.purchases.models.GalaxyReplacementMode.Companion Companion; - } - - @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public static final class GalaxyReplacementMode.Companion { - method public com.revenuecat.purchases.models.GalaxyReplacementMode getDefault(); - property public final com.revenuecat.purchases.models.GalaxyReplacementMode default; } @dev.drewhamilton.poko.Poko public final class GoogleInstallmentsInfo implements com.revenuecat.purchases.models.InstallmentsInfo { diff --git a/purchases/api-defauts.txt b/purchases/api-defauts.txt index fa3de45b7c..54e0cb2af4 100644 --- a/purchases/api-defauts.txt +++ b/purchases/api-defauts.txt @@ -1179,12 +1179,6 @@ package com.revenuecat.purchases.models { enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_NO_PRORATION; enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_PRORATED_CHARGE; enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_PRORATED_DATE; - field public static final com.revenuecat.purchases.models.GalaxyReplacementMode.Companion Companion; - } - - @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public static final class GalaxyReplacementMode.Companion { - method public com.revenuecat.purchases.models.GalaxyReplacementMode getDefault(); - property public final com.revenuecat.purchases.models.GalaxyReplacementMode default; } @dev.drewhamilton.poko.Poko public final class GoogleInstallmentsInfo implements com.revenuecat.purchases.models.InstallmentsInfo { diff --git a/purchases/api-entitlement.txt b/purchases/api-entitlement.txt index a2b34e11aa..00a42686f8 100644 --- a/purchases/api-entitlement.txt +++ b/purchases/api-entitlement.txt @@ -1056,12 +1056,6 @@ package com.revenuecat.purchases.models { enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_NO_PRORATION; enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_PRORATED_CHARGE; enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_PRORATED_DATE; - field public static final com.revenuecat.purchases.models.GalaxyReplacementMode.Companion Companion; - } - - @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI public static final class GalaxyReplacementMode.Companion { - method public com.revenuecat.purchases.models.GalaxyReplacementMode getDefault(); - property public final com.revenuecat.purchases.models.GalaxyReplacementMode default; } @dev.drewhamilton.poko.Poko public final class GoogleInstallmentsInfo implements com.revenuecat.purchases.models.InstallmentsInfo { diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt index 360f71e060..3d7e948398 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt @@ -1,12 +1,10 @@ package com.revenuecat.purchases import android.os.Parcelable -import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.toGoogleReplacementMode import com.revenuecat.purchases.models.toStoreReplacementMode -import com.revenuecat.purchases.models.toStoreReplacementMode import kotlinx.android.parcel.IgnoredOnParcel import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException @@ -57,15 +55,6 @@ private val StoreReplacementMode.galaxyName: String? StoreReplacementMode.DEFERRED -> "DEFERRED" } -@OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) -private val GalaxyReplacementMode.storeReplacementMode: StoreReplacementMode - get() = when (this) { - GalaxyReplacementMode.INSTANT_NO_PRORATION -> StoreReplacementMode.WITHOUT_PRORATION - GalaxyReplacementMode.INSTANT_PRORATED_DATE -> StoreReplacementMode.WITH_TIME_PRORATION - GalaxyReplacementMode.INSTANT_PRORATED_CHARGE -> StoreReplacementMode.CHARGE_PRORATED_PRICE - GalaxyReplacementMode.DEFERRED -> StoreReplacementMode.DEFERRED - } - private val GoogleReplacementMode.storeReplacementMode: StoreReplacementMode get() = when (this) { GoogleReplacementMode.WITHOUT_PRORATION -> StoreReplacementMode.WITHOUT_PRORATION @@ -78,13 +67,12 @@ private val GoogleReplacementMode.storeReplacementMode: StoreReplacementMode /** * Returns the backend name for this [ReplacementMode]. * For [GoogleReplacementMode], this returns the legacy proration mode name. - * For [GalaxyReplacementMode], this returns the enum name directly. */ @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) +@Deprecated("Use ReplacementMode.backendName(store: Store) instead") internal val ReplacementMode.backendName: String get() = when (this) { is GoogleReplacementMode -> this.asLegacyProrationMode.name - is GalaxyReplacementMode -> this.name else -> this.name } @@ -93,13 +81,11 @@ internal fun ReplacementMode.backendName(store: Store): String? { return when (store) { Store.PLAY_STORE -> when (this) { is GoogleReplacementMode -> this.asLegacyProrationMode.name - is GalaxyReplacementMode -> this.storeReplacementMode.toGoogleReplacementMode().asLegacyProrationMode.name is StoreReplacementMode -> this.toGoogleReplacementMode().asLegacyProrationMode.name else -> null } Store.GALAXY -> when (this) { is GoogleReplacementMode -> this.storeReplacementMode.galaxyName - is GalaxyReplacementMode -> this.name is StoreReplacementMode -> this.galaxyName else -> null } diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/models/GalaxyReplacementMode.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/models/GalaxyReplacementMode.kt index 97d4177935..e1b7c24769 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/models/GalaxyReplacementMode.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/models/GalaxyReplacementMode.kt @@ -1,7 +1,6 @@ package com.revenuecat.purchases.models import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI -import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.ReplacementMode import kotlinx.parcelize.Parcelize @@ -54,12 +53,5 @@ public enum class GalaxyReplacementMode : ReplacementMode { DEFERRED, ; - @ExperimentalPreviewRevenueCatPurchasesAPI - public companion object { - /** - * The default replacement mode for Galaxy Store subscription changes. - */ - @InternalRevenueCatAPI - public val default: GalaxyReplacementMode = INSTANT_NO_PRORATION - } + } diff --git a/purchases/src/test/java/com/revenuecat/purchases/ReplacementModeTest.kt b/purchases/src/test/java/com/revenuecat/purchases/ReplacementModeTest.kt index 8a18a811ca..8504e6bd17 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/ReplacementModeTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/ReplacementModeTest.kt @@ -1,7 +1,6 @@ package com.revenuecat.purchases import android.os.Parcel -import com.revenuecat.purchases.models.GalaxyReplacementMode import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.StoreReplacementMode import org.assertj.core.api.Assertions.assertThat @@ -26,23 +25,6 @@ class ReplacementModeTest { assertThat(expectations.size).isEqualTo(GoogleReplacementMode.values().size) } - @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) - @Test - fun `galaxy replacement modes use enum name for backend`() { - val expectations = mapOf( - GalaxyReplacementMode.INSTANT_PRORATED_DATE to "INSTANT_PRORATED_DATE", - GalaxyReplacementMode.INSTANT_PRORATED_CHARGE to "INSTANT_PRORATED_CHARGE", - GalaxyReplacementMode.INSTANT_NO_PRORATION to "INSTANT_NO_PRORATION", - GalaxyReplacementMode.DEFERRED to "DEFERRED", - ) - - GalaxyReplacementMode.values().forEach { mode -> - assertThat(expectations).containsKey(mode) - assertThat(mode.backendName).isEqualTo(expectations.getValue(mode)) - } - assertThat(expectations.size).isEqualTo(GalaxyReplacementMode.values().size) - } - @Test fun `backend name falls back to replacement mode name`() { val mode = TestReplacementMode("CUSTOM_MODE") @@ -118,32 +100,6 @@ class ReplacementModeTest { assertThat(galaxyExpectations.size).isEqualTo(GoogleReplacementMode.values().size - 1) } - @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) - @Test - fun `galaxy replacement modes use store specific backend names`() { - val playExpectations = mapOf( - GalaxyReplacementMode.INSTANT_NO_PRORATION to "IMMEDIATE_WITHOUT_PRORATION", - GalaxyReplacementMode.INSTANT_PRORATED_DATE to "IMMEDIATE_WITH_TIME_PRORATION", - GalaxyReplacementMode.INSTANT_PRORATED_CHARGE to "IMMEDIATE_AND_CHARGE_PRORATED_PRICE", - GalaxyReplacementMode.DEFERRED to "DEFERRED", - ) - val galaxyExpectations = mapOf( - GalaxyReplacementMode.INSTANT_PRORATED_DATE to "INSTANT_PRORATED_DATE", - GalaxyReplacementMode.INSTANT_PRORATED_CHARGE to "INSTANT_PRORATED_CHARGE", - GalaxyReplacementMode.INSTANT_NO_PRORATION to "INSTANT_NO_PRORATION", - GalaxyReplacementMode.DEFERRED to "DEFERRED", - ) - - GalaxyReplacementMode.values().forEach { mode -> - assertThat(playExpectations).containsKey(mode) - assertThat(mode.backendName(Store.PLAY_STORE)).isEqualTo(playExpectations.getValue(mode)) - assertThat(galaxyExpectations).containsKey(mode) - assertThat(mode.backendName(Store.GALAXY)).isEqualTo(galaxyExpectations.getValue(mode)) - } - assertThat(playExpectations.size).isEqualTo(GalaxyReplacementMode.values().size) - assertThat(galaxyExpectations.size).isEqualTo(GalaxyReplacementMode.values().size) - } - private class TestReplacementMode( override val name: String, ) : ReplacementMode { From 8fd6aab024fd53bb5f5217904aa26b7ce87081a0 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Tue, 17 Mar 2026 14:02:53 -0500 Subject: [PATCH 20/34] actually remove GalaxyReplacementMode --- purchases/api-defaults-bc7.txt | 7 --- purchases/api-defauts.txt | 7 --- purchases/api-entitlement.txt | 7 --- .../purchases/models/GalaxyReplacementMode.kt | 57 ------------------- 4 files changed, 78 deletions(-) delete mode 100644 purchases/src/main/kotlin/com/revenuecat/purchases/models/GalaxyReplacementMode.kt diff --git a/purchases/api-defaults-bc7.txt b/purchases/api-defaults-bc7.txt index 54e0cb2af4..834205be4e 100644 --- a/purchases/api-defaults-bc7.txt +++ b/purchases/api-defaults-bc7.txt @@ -1174,13 +1174,6 @@ package com.revenuecat.purchases.models { enum_constant public static final com.revenuecat.purchases.models.BillingFeature SUBSCRIPTIONS_UPDATE; } - @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI @kotlinx.parcelize.Parcelize public enum GalaxyReplacementMode implements com.revenuecat.purchases.ReplacementMode { - enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode DEFERRED; - enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_NO_PRORATION; - enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_PRORATED_CHARGE; - enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_PRORATED_DATE; - } - @dev.drewhamilton.poko.Poko public final class GoogleInstallmentsInfo implements com.revenuecat.purchases.models.InstallmentsInfo { ctor public GoogleInstallmentsInfo(int commitmentPaymentsCount, int renewalCommitmentPaymentsCount); method public int getCommitmentPaymentsCount(); diff --git a/purchases/api-defauts.txt b/purchases/api-defauts.txt index 54e0cb2af4..834205be4e 100644 --- a/purchases/api-defauts.txt +++ b/purchases/api-defauts.txt @@ -1174,13 +1174,6 @@ package com.revenuecat.purchases.models { enum_constant public static final com.revenuecat.purchases.models.BillingFeature SUBSCRIPTIONS_UPDATE; } - @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI @kotlinx.parcelize.Parcelize public enum GalaxyReplacementMode implements com.revenuecat.purchases.ReplacementMode { - enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode DEFERRED; - enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_NO_PRORATION; - enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_PRORATED_CHARGE; - enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_PRORATED_DATE; - } - @dev.drewhamilton.poko.Poko public final class GoogleInstallmentsInfo implements com.revenuecat.purchases.models.InstallmentsInfo { ctor public GoogleInstallmentsInfo(int commitmentPaymentsCount, int renewalCommitmentPaymentsCount); method public int getCommitmentPaymentsCount(); diff --git a/purchases/api-entitlement.txt b/purchases/api-entitlement.txt index 00a42686f8..65109e3406 100644 --- a/purchases/api-entitlement.txt +++ b/purchases/api-entitlement.txt @@ -1051,13 +1051,6 @@ package com.revenuecat.purchases.models { enum_constant public static final com.revenuecat.purchases.models.BillingFeature SUBSCRIPTIONS_UPDATE; } - @com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI @kotlinx.parcelize.Parcelize public enum GalaxyReplacementMode implements com.revenuecat.purchases.ReplacementMode { - enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode DEFERRED; - enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_NO_PRORATION; - enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_PRORATED_CHARGE; - enum_constant public static final com.revenuecat.purchases.models.GalaxyReplacementMode INSTANT_PRORATED_DATE; - } - @dev.drewhamilton.poko.Poko public final class GoogleInstallmentsInfo implements com.revenuecat.purchases.models.InstallmentsInfo { ctor public GoogleInstallmentsInfo(int commitmentPaymentsCount, int renewalCommitmentPaymentsCount); method public int getCommitmentPaymentsCount(); diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/models/GalaxyReplacementMode.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/models/GalaxyReplacementMode.kt deleted file mode 100644 index e1b7c24769..0000000000 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/models/GalaxyReplacementMode.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.revenuecat.purchases.models - -import com.revenuecat.purchases.ExperimentalPreviewRevenueCatPurchasesAPI -import com.revenuecat.purchases.ReplacementMode -import kotlinx.parcelize.Parcelize - -/** - * Enum of possible replacement modes to be passed to a Samsung Galaxy Store subscription change. - * Not used for Google Play and Amazon purchases. - * - * See https://developer.samsung.com/iap/subscription-guide/manage-subscription-plan/proration-modes.html - * for more details. - */ -@ExperimentalPreviewRevenueCatPurchasesAPI -@Parcelize -public enum class GalaxyReplacementMode : ReplacementMode { - /** - * The current subscription is instantly changed and the customer can start using the new subscription - * immediately. The remaining payment of the original subscription is prorated to the cost of the new - * subscription (based on the daily price). The payment (renewal) date and starting day of the subscription - * period are changed based on this calculation. - * - * This mode can be used for both upgrades and downgrades. - */ - INSTANT_PRORATED_DATE, - - /** - * For upgraded subscriptions only. The current subscription is instantly changed and the customer can - * start using the new subscription immediately. While the starting day of the subscription period and - * payment (renewal) date remain the same, the prorated cost of the upgraded subscription for the remainder - * of the subscription period (minus the remaining payment of the original subscription) is immediately - * charged to the customer. - */ - INSTANT_PRORATED_CHARGE, - - /** - * For upgraded subscriptions only. The current subscription is instantly changed and the customer can - * start using the new subscription immediately. The new subscription rate is not applied until the current - * subscription period ends. The payment (renewal) date remains the same. There are no extra charges to use - * the upgraded subscription during the current subscription period. - * - * This is the default behavior. - */ - INSTANT_NO_PRORATION, - - /** - * The current subscription continues and the features of the new subscription are not available until - * the current subscription period ends. The new subscription price is charged and the features of the - * new subscription are available when the subscription is renewed. When the customer changes their - * subscription, they cannot change the subscription again during the remaining time of the current - * subscription period. - */ - DEFERRED, - ; - - -} From 0d1859f7e2cb81e8b3c4e43f6e31708244a99c83 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Tue, 17 Mar 2026 14:11:31 -0500 Subject: [PATCH 21/34] deprecate GoogleReplacementMode --- purchases/api-defaults-bc7.txt | 34 +++++++++---------- purchases/api-defauts.txt | 34 +++++++++---------- purchases/api-entitlement.txt | 34 +++++++++---------- .../purchases/models/GoogleReplacementMode.kt | 1 + .../revenuecat/purchases/ParcelableTests.kt | 8 +++++ 5 files changed, 60 insertions(+), 51 deletions(-) diff --git a/purchases/api-defaults-bc7.txt b/purchases/api-defaults-bc7.txt index 834205be4e..9bdbb3349e 100644 --- a/purchases/api-defaults-bc7.txt +++ b/purchases/api-defaults-bc7.txt @@ -1208,23 +1208,23 @@ package com.revenuecat.purchases.models { property public final String token; } - public enum GoogleReplacementMode implements com.revenuecat.purchases.ReplacementMode { - method public int describeContents(); - method public int getPlayBillingClientMode(); - method public void writeToParcel(android.os.Parcel out, int flags); - property public final int playBillingClientMode; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode CHARGE_FULL_PRICE; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode CHARGE_PRORATED_PRICE; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode DEFERRED; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode WITHOUT_PRORATION; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode WITH_TIME_PRORATION; - field public static final com.revenuecat.purchases.models.GoogleReplacementMode.CREATOR CREATOR; - } - - public static final class GoogleReplacementMode.CREATOR implements android.os.Parcelable.Creator { - method public com.revenuecat.purchases.models.GoogleReplacementMode? createFromParcel(android.os.Parcel in); - method public com.revenuecat.purchases.models.GoogleReplacementMode? fromPlayBillingClientMode(@com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams.ReplacementMode Integer? playBillingClientMode); - method public com.revenuecat.purchases.models.GoogleReplacementMode?[] newArray(int size); + @Deprecated public enum GoogleReplacementMode implements com.revenuecat.purchases.ReplacementMode { + method @Deprecated public int describeContents(); + method @Deprecated public int getPlayBillingClientMode(); + method @Deprecated public void writeToParcel(android.os.Parcel out, int flags); + property @Deprecated public final int playBillingClientMode; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode CHARGE_FULL_PRICE; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode CHARGE_PRORATED_PRICE; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode DEFERRED; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode WITHOUT_PRORATION; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode WITH_TIME_PRORATION; + field @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode.CREATOR CREATOR; + } + + @Deprecated public static final class GoogleReplacementMode.CREATOR implements android.os.Parcelable.Creator { + method @Deprecated public com.revenuecat.purchases.models.GoogleReplacementMode? createFromParcel(android.os.Parcel in); + method @Deprecated public com.revenuecat.purchases.models.GoogleReplacementMode? fromPlayBillingClientMode(@com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams.ReplacementMode Integer? playBillingClientMode); + method @Deprecated public com.revenuecat.purchases.models.GoogleReplacementMode?[] newArray(int size); } @dev.drewhamilton.poko.Poko public final class GoogleStoreProduct implements com.revenuecat.purchases.models.StoreProduct { diff --git a/purchases/api-defauts.txt b/purchases/api-defauts.txt index 834205be4e..9bdbb3349e 100644 --- a/purchases/api-defauts.txt +++ b/purchases/api-defauts.txt @@ -1208,23 +1208,23 @@ package com.revenuecat.purchases.models { property public final String token; } - public enum GoogleReplacementMode implements com.revenuecat.purchases.ReplacementMode { - method public int describeContents(); - method public int getPlayBillingClientMode(); - method public void writeToParcel(android.os.Parcel out, int flags); - property public final int playBillingClientMode; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode CHARGE_FULL_PRICE; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode CHARGE_PRORATED_PRICE; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode DEFERRED; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode WITHOUT_PRORATION; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode WITH_TIME_PRORATION; - field public static final com.revenuecat.purchases.models.GoogleReplacementMode.CREATOR CREATOR; - } - - public static final class GoogleReplacementMode.CREATOR implements android.os.Parcelable.Creator { - method public com.revenuecat.purchases.models.GoogleReplacementMode? createFromParcel(android.os.Parcel in); - method public com.revenuecat.purchases.models.GoogleReplacementMode? fromPlayBillingClientMode(@com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams.ReplacementMode Integer? playBillingClientMode); - method public com.revenuecat.purchases.models.GoogleReplacementMode?[] newArray(int size); + @Deprecated public enum GoogleReplacementMode implements com.revenuecat.purchases.ReplacementMode { + method @Deprecated public int describeContents(); + method @Deprecated public int getPlayBillingClientMode(); + method @Deprecated public void writeToParcel(android.os.Parcel out, int flags); + property @Deprecated public final int playBillingClientMode; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode CHARGE_FULL_PRICE; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode CHARGE_PRORATED_PRICE; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode DEFERRED; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode WITHOUT_PRORATION; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode WITH_TIME_PRORATION; + field @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode.CREATOR CREATOR; + } + + @Deprecated public static final class GoogleReplacementMode.CREATOR implements android.os.Parcelable.Creator { + method @Deprecated public com.revenuecat.purchases.models.GoogleReplacementMode? createFromParcel(android.os.Parcel in); + method @Deprecated public com.revenuecat.purchases.models.GoogleReplacementMode? fromPlayBillingClientMode(@com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams.ReplacementMode Integer? playBillingClientMode); + method @Deprecated public com.revenuecat.purchases.models.GoogleReplacementMode?[] newArray(int size); } @dev.drewhamilton.poko.Poko public final class GoogleStoreProduct implements com.revenuecat.purchases.models.StoreProduct { diff --git a/purchases/api-entitlement.txt b/purchases/api-entitlement.txt index 65109e3406..524a6aae43 100644 --- a/purchases/api-entitlement.txt +++ b/purchases/api-entitlement.txt @@ -1085,23 +1085,23 @@ package com.revenuecat.purchases.models { property public final String token; } - public enum GoogleReplacementMode implements com.revenuecat.purchases.ReplacementMode { - method public int describeContents(); - method public int getPlayBillingClientMode(); - method public void writeToParcel(android.os.Parcel out, int flags); - property public final int playBillingClientMode; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode CHARGE_FULL_PRICE; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode CHARGE_PRORATED_PRICE; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode DEFERRED; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode WITHOUT_PRORATION; - enum_constant public static final com.revenuecat.purchases.models.GoogleReplacementMode WITH_TIME_PRORATION; - field public static final com.revenuecat.purchases.models.GoogleReplacementMode.CREATOR CREATOR; - } - - public static final class GoogleReplacementMode.CREATOR implements android.os.Parcelable.Creator { - method public com.revenuecat.purchases.models.GoogleReplacementMode? createFromParcel(android.os.Parcel in); - method public com.revenuecat.purchases.models.GoogleReplacementMode? fromPlayBillingClientMode(@com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams.ReplacementMode Integer? playBillingClientMode); - method public com.revenuecat.purchases.models.GoogleReplacementMode?[] newArray(int size); + @Deprecated public enum GoogleReplacementMode implements com.revenuecat.purchases.ReplacementMode { + method @Deprecated public int describeContents(); + method @Deprecated public int getPlayBillingClientMode(); + method @Deprecated public void writeToParcel(android.os.Parcel out, int flags); + property @Deprecated public final int playBillingClientMode; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode CHARGE_FULL_PRICE; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode CHARGE_PRORATED_PRICE; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode DEFERRED; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode WITHOUT_PRORATION; + enum_constant @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode WITH_TIME_PRORATION; + field @Deprecated public static final com.revenuecat.purchases.models.GoogleReplacementMode.CREATOR CREATOR; + } + + @Deprecated public static final class GoogleReplacementMode.CREATOR implements android.os.Parcelable.Creator { + method @Deprecated public com.revenuecat.purchases.models.GoogleReplacementMode? createFromParcel(android.os.Parcel in); + method @Deprecated public com.revenuecat.purchases.models.GoogleReplacementMode? fromPlayBillingClientMode(@com.android.billingclient.api.BillingFlowParams.SubscriptionUpdateParams.ReplacementMode Integer? playBillingClientMode); + method @Deprecated public com.revenuecat.purchases.models.GoogleReplacementMode?[] newArray(int size); } @dev.drewhamilton.poko.Poko public final class GoogleStoreProduct implements com.revenuecat.purchases.models.StoreProduct { diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/models/GoogleReplacementMode.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/models/GoogleReplacementMode.kt index a657fd61da..748ee8ecb6 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/models/GoogleReplacementMode.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/models/GoogleReplacementMode.kt @@ -11,6 +11,7 @@ import com.revenuecat.purchases.ReplacementMode * * See https://developer.android.com/google/play/billing/subscriptions#proration for examples */ +@Deprecated("Use StoreReplacementMode instead") public enum class GoogleReplacementMode( @BillingFlowParams.SubscriptionUpdateParams.ReplacementMode public val playBillingClientMode: Int, ) : ReplacementMode { diff --git a/purchases/src/test/java/com/revenuecat/purchases/ParcelableTests.kt b/purchases/src/test/java/com/revenuecat/purchases/ParcelableTests.kt index 88ac5dd207..181f4a8eab 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/ParcelableTests.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/ParcelableTests.kt @@ -6,6 +6,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.PurchaseState import com.revenuecat.purchases.models.PurchaseType +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.utils.JSONObjectParceler import com.revenuecat.purchases.utils.JSONObjectParceler.write @@ -95,6 +96,13 @@ class ParcelableTests { testParcelization(nullMode, true) } + @Test + fun `StoreReplacementMode is Parcelable`() { + StoreReplacementMode.values().forEach { testParcelization(it, true) } + val nullMode: StoreReplacementMode? = null + testParcelization(nullMode, true) + } + @Test fun `VirtualCurrency is Parcelable`() { testParcelization( From de85b714783b1d1ae3704b292a8daf811e9bd132 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Tue, 17 Mar 2026 14:21:54 -0500 Subject: [PATCH 22/34] migrate paywalls to use StoreReplacementMode --- .../components/common/ProductChangeConfig.kt | 5 +++-- .../ReplacementModeDeserializers.kt | 9 ++++---- .../common/ProductChangeConfigTest.kt | 21 ++++++++++--------- .../ui/revenuecatui/data/PaywallViewModel.kt | 2 +- .../data/ProductChangeCalculator.kt | 3 ++- 5 files changed, 22 insertions(+), 18 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/ProductChangeConfig.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/ProductChangeConfig.kt index 20e07c6cfd..7a4baa987b 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/ProductChangeConfig.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/ProductChangeConfig.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases.paywalls.components.common import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.models.GoogleReplacementMode +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.paywalls.components.common.serializers.DowngradeReplacementModeDeserializer import com.revenuecat.purchases.paywalls.components.common.serializers.UpgradeReplacementModeDeserializer import dev.drewhamilton.poko.Poko @@ -26,7 +27,7 @@ public class ProductChangeConfig( @get:JvmSynthetic @Serializable(with = UpgradeReplacementModeDeserializer::class) @SerialName("upgrade_replacement_mode") - public val upgradeReplacementMode: GoogleReplacementMode = GoogleReplacementMode.CHARGE_PRORATED_PRICE, + public val upgradeReplacementMode: StoreReplacementMode = StoreReplacementMode.CHARGE_PRORATED_PRICE, /** * Replacement mode to use for downgrades (moving to a lower price per unit time). @@ -35,5 +36,5 @@ public class ProductChangeConfig( @get:JvmSynthetic @Serializable(with = DowngradeReplacementModeDeserializer::class) @SerialName("downgrade_replacement_mode") - public val downgradeReplacementMode: GoogleReplacementMode = GoogleReplacementMode.DEFERRED, + public val downgradeReplacementMode: StoreReplacementMode = StoreReplacementMode.DEFERRED, ) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/serializers/ReplacementModeDeserializers.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/serializers/ReplacementModeDeserializers.kt index 088f62d678..499ee725ad 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/serializers/ReplacementModeDeserializers.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/serializers/ReplacementModeDeserializers.kt @@ -1,14 +1,15 @@ package com.revenuecat.purchases.paywalls.components.common.serializers import com.revenuecat.purchases.models.GoogleReplacementMode +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.utils.serializers.EnumDeserializerWithDefault -internal object UpgradeReplacementModeDeserializer : EnumDeserializerWithDefault( - defaultValue = GoogleReplacementMode.CHARGE_PRORATED_PRICE, +internal object UpgradeReplacementModeDeserializer : EnumDeserializerWithDefault( + defaultValue = StoreReplacementMode.CHARGE_PRORATED_PRICE, typeForValue = { value -> value.name.lowercase() }, ) -internal object DowngradeReplacementModeDeserializer : EnumDeserializerWithDefault( - defaultValue = GoogleReplacementMode.DEFERRED, +internal object DowngradeReplacementModeDeserializer : EnumDeserializerWithDefault( + defaultValue = StoreReplacementMode.DEFERRED, typeForValue = { value -> value.name.lowercase() }, ) diff --git a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/common/ProductChangeConfigTest.kt b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/common/ProductChangeConfigTest.kt index ced970bef6..91d82be621 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/common/ProductChangeConfigTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/paywalls/components/common/ProductChangeConfigTest.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases.paywalls.components.common import com.revenuecat.purchases.JsonTools import com.revenuecat.purchases.models.GoogleReplacementMode +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.utils.serializers.EmptyObjectToNullSerializer import org.intellij.lang.annotations.Language import org.junit.Test @@ -30,8 +31,8 @@ class ProductChangeConfigTest( Args( json = """{"upgrade_replacement_mode": "charge_prorated_price"}""", expected = ProductChangeConfig( - upgradeReplacementMode = GoogleReplacementMode.CHARGE_PRORATED_PRICE, - downgradeReplacementMode = GoogleReplacementMode.DEFERRED, + upgradeReplacementMode = StoreReplacementMode.CHARGE_PRORATED_PRICE, + downgradeReplacementMode = StoreReplacementMode.DEFERRED, ), ), ), @@ -45,8 +46,8 @@ class ProductChangeConfigTest( } """.trimIndent(), expected = ProductChangeConfig( - upgradeReplacementMode = GoogleReplacementMode.CHARGE_PRORATED_PRICE, - downgradeReplacementMode = GoogleReplacementMode.DEFERRED, + upgradeReplacementMode = StoreReplacementMode.CHARGE_PRORATED_PRICE, + downgradeReplacementMode = StoreReplacementMode.DEFERRED, ), ), ), @@ -60,8 +61,8 @@ class ProductChangeConfigTest( } """.trimIndent(), expected = ProductChangeConfig( - upgradeReplacementMode = GoogleReplacementMode.CHARGE_FULL_PRICE, - downgradeReplacementMode = GoogleReplacementMode.WITH_TIME_PRORATION, + upgradeReplacementMode = StoreReplacementMode.CHARGE_FULL_PRICE, + downgradeReplacementMode = StoreReplacementMode.WITH_TIME_PRORATION, ), ), ), @@ -75,8 +76,8 @@ class ProductChangeConfigTest( } """.trimIndent(), expected = ProductChangeConfig( - upgradeReplacementMode = GoogleReplacementMode.WITHOUT_PRORATION, - downgradeReplacementMode = GoogleReplacementMode.WITHOUT_PRORATION, + upgradeReplacementMode = StoreReplacementMode.WITHOUT_PRORATION, + downgradeReplacementMode = StoreReplacementMode.WITHOUT_PRORATION, ), ), ), @@ -102,8 +103,8 @@ class ProductChangeConfigTest( val json = """{"play_store_product_change_mode": {"upgrade_replacement_mode": "charge_full_price"}}""" val wrapper = JsonTools.json.decodeFromString(json) assert(wrapper.productChangeConfig != null) - assert(wrapper.productChangeConfig!!.upgradeReplacementMode == GoogleReplacementMode.CHARGE_FULL_PRICE) - assert(wrapper.productChangeConfig!!.downgradeReplacementMode == GoogleReplacementMode.DEFERRED) + assert(wrapper.productChangeConfig!!.upgradeReplacementMode == StoreReplacementMode.CHARGE_FULL_PRICE) + assert(wrapper.productChangeConfig!!.downgradeReplacementMode == StoreReplacementMode.DEFERRED) } @Test diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt index 68cd8f5db9..9c2fccfbae 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModel.kt @@ -536,7 +536,7 @@ internal class PaywallViewModelImpl( ) purchaseParamsBuilder .oldProductId(productChangeInfo.oldProductId) - .googleReplacementMode(productChangeInfo.replacementMode) + .replacementMode(productChangeInfo.replacementMode) } val purchaseResult = purchases.awaitPurchase(purchaseParamsBuilder) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/ProductChangeCalculator.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/ProductChangeCalculator.kt index 8ef3c85f46..c5cfbdc6b8 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/ProductChangeCalculator.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/ProductChangeCalculator.kt @@ -9,12 +9,13 @@ import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.GoogleStoreProduct import com.revenuecat.purchases.models.Period import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.paywalls.components.common.ProductChangeConfig import com.revenuecat.purchases.ui.revenuecatui.helpers.Logger internal data class ProductChangeInfo( val oldProductId: String, - val replacementMode: GoogleReplacementMode, + val replacementMode: StoreReplacementMode, ) /** From f0312d408baf7a255f4fac4d525a03678017354b Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Tue, 17 Mar 2026 14:35:23 -0500 Subject: [PATCH 23/34] send replacement mode to handleReceipt() in galaxy flow --- .../purchases/galaxy/GalaxyBillingWrapper.kt | 3 +- .../galaxy/GalaxyBillingWrapperTest.kt | 64 ++++++++++++++++++- .../components/common/ProductChangeConfig.kt | 1 - .../ReplacementModeDeserializers.kt | 1 - .../data/ProductChangeCalculator.kt | 1 - 5 files changed, 62 insertions(+), 8 deletions(-) diff --git a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt index 7c2b6fe4c8..7623b13a48 100644 --- a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt +++ b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt @@ -353,8 +353,7 @@ internal class GalaxyBillingWrapper( receipt = receipt, productId = productId, presentedOfferingContext = presentedOfferingContext, - // TODO: Send the replacementMode in here when handleReceipt() is updated - replacementMode = null, + replacementMode = replacementMode, ) finish() }, diff --git a/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/GalaxyBillingWrapperTest.kt b/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/GalaxyBillingWrapperTest.kt index 2a3c924e09..6146813d28 100644 --- a/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/GalaxyBillingWrapperTest.kt +++ b/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/GalaxyBillingWrapperTest.kt @@ -765,7 +765,7 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { verify(exactly = 1) { purchasesUpdatedListener.onPurchasesUpdated(capture(transactionsSlot)) } val transaction = transactionsSlot.captured.single() assertThat(transaction.productIds).containsExactly(storeProduct.id) - assertThat(transaction.replacementMode).isNull() + assertThat(transaction.replacementMode).isEqualTo(replacementMode) } @OptIn(GalaxySerialOperation::class) @@ -773,7 +773,8 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { fun `makePurchaseAsync defaults to CHARGE_PRORATED_PRICE when replacement mode is not a StoreReplacementMode`() { val changeSubscriptionPlanHandler = mockk(relaxed = true) val wrapper = createWrapper(changeSubscriptionPlanHandler = changeSubscriptionPlanHandler) - wrapper.purchasesUpdatedListener = mockk(relaxed = true) + val purchasesUpdatedListener = mockk(relaxed = true) + wrapper.purchasesUpdatedListener = purchasesUpdatedListener val storeProduct = createStoreProduct() val oldPurchase = storeTransaction( @@ -781,6 +782,18 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { type = ProductType.SUBS, productId = "old-product", ) + val onSuccessSlot = slot<(PurchaseVo) -> Unit>() + + every { + changeSubscriptionPlanHandler.changeSubscriptionPlan( + any(), + any(), + any(), + any(), + capture(onSuccessSlot), + any(), + ) + } answers { } wrapper.makePurchaseAsync( activity = mockk(), @@ -810,6 +823,22 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { verify(exactly = 0) { purchaseHandlerMock.purchase(any(), any(), any(), any()) } + + val purchaseVo = createPurchaseVo( + paymentId = "paymentId", + purchaseId = "purchaseId", + orderId = "orderId", + purchaseDate = "2024-01-15 13:45:20", + type = "subscription", + itemId = storeProduct.id, + ) + onSuccessSlot.captured(purchaseVo) + + val transactionsSlot = slot>() + verify(exactly = 1) { purchasesUpdatedListener.onPurchasesUpdated(capture(transactionsSlot)) } + val transaction = transactionsSlot.captured.single() + assertThat(transaction.productIds).containsExactly(storeProduct.id) + assertThat(transaction.replacementMode).isEqualTo(StoreReplacementMode.CHARGE_PRORATED_PRICE) } @OptIn(GalaxySerialOperation::class) @@ -817,7 +846,8 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { fun `makePurchaseAsync defaults to CHARGE_PRORATED_PRICE when replacement mode is null`() { val changeSubscriptionPlanHandler = mockk(relaxed = true) val wrapper = createWrapper(changeSubscriptionPlanHandler = changeSubscriptionPlanHandler) - wrapper.purchasesUpdatedListener = mockk(relaxed = true) + val purchasesUpdatedListener = mockk(relaxed = true) + wrapper.purchasesUpdatedListener = purchasesUpdatedListener val storeProduct = createStoreProduct() val oldPurchase = storeTransaction( @@ -825,6 +855,18 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { type = ProductType.SUBS, productId = "old-product", ) + val onSuccessSlot = slot<(PurchaseVo) -> Unit>() + + every { + changeSubscriptionPlanHandler.changeSubscriptionPlan( + any(), + any(), + any(), + any(), + capture(onSuccessSlot), + any(), + ) + } answers { } wrapper.makePurchaseAsync( activity = mockk(), @@ -854,6 +896,22 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { verify(exactly = 0) { purchaseHandlerMock.purchase(any(), any(), any(), any()) } + + val purchaseVo = createPurchaseVo( + paymentId = "paymentId", + purchaseId = "purchaseId", + orderId = "orderId", + purchaseDate = "2024-01-15 13:45:20", + type = "subscription", + itemId = storeProduct.id, + ) + onSuccessSlot.captured(purchaseVo) + + val transactionsSlot = slot>() + verify(exactly = 1) { purchasesUpdatedListener.onPurchasesUpdated(capture(transactionsSlot)) } + val transaction = transactionsSlot.captured.single() + assertThat(transaction.productIds).containsExactly(storeProduct.id) + assertThat(transaction.replacementMode).isEqualTo(StoreReplacementMode.CHARGE_PRORATED_PRICE) } @OptIn(GalaxySerialOperation::class) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/ProductChangeConfig.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/ProductChangeConfig.kt index 7a4baa987b..7fdb8a97e4 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/ProductChangeConfig.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/ProductChangeConfig.kt @@ -1,7 +1,6 @@ package com.revenuecat.purchases.paywalls.components.common import com.revenuecat.purchases.InternalRevenueCatAPI -import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.paywalls.components.common.serializers.DowngradeReplacementModeDeserializer import com.revenuecat.purchases.paywalls.components.common.serializers.UpgradeReplacementModeDeserializer diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/serializers/ReplacementModeDeserializers.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/serializers/ReplacementModeDeserializers.kt index 499ee725ad..346692fab1 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/serializers/ReplacementModeDeserializers.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/paywalls/components/common/serializers/ReplacementModeDeserializers.kt @@ -1,6 +1,5 @@ package com.revenuecat.purchases.paywalls.components.common.serializers -import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.utils.serializers.EnumDeserializerWithDefault diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/ProductChangeCalculator.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/ProductChangeCalculator.kt index c5cfbdc6b8..b9b36aea94 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/ProductChangeCalculator.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/ProductChangeCalculator.kt @@ -5,7 +5,6 @@ import com.revenuecat.purchases.ProductType import com.revenuecat.purchases.PurchasesException import com.revenuecat.purchases.Store import com.revenuecat.purchases.SubscriptionInfo -import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.GoogleStoreProduct import com.revenuecat.purchases.models.Period import com.revenuecat.purchases.models.StoreProduct From 28c8ac1c458a2ff2a5e6a882d03db355dbe14715 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Tue, 17 Mar 2026 15:04:44 -0500 Subject: [PATCH 24/34] update paywall tests --- .../revenuecatui/data/PaywallViewModelTest.kt | 14 ++++++------- .../data/ProductChangeCalculatorTest.kt | 20 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt index 1431601413..f76d81e0a9 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/PaywallViewModelTest.kt @@ -16,7 +16,7 @@ import com.revenuecat.purchases.PurchasesAreCompletedBy import com.revenuecat.purchases.PurchasesError import com.revenuecat.purchases.PurchasesErrorCode import com.revenuecat.purchases.PurchasesException -import com.revenuecat.purchases.models.GoogleReplacementMode +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.paywalls.PaywallData import com.revenuecat.purchases.paywalls.components.common.ProductChangeConfig @@ -1892,7 +1892,7 @@ class PaywallViewModelTest { productChangeCalculator.calculateProductChangeInfo(any(), any()) } returns ProductChangeInfo( oldProductId = "old_product", - replacementMode = GoogleReplacementMode.CHARGE_PRORATED_PRICE, + replacementMode = StoreReplacementMode.CHARGE_PRORATED_PRICE, ) val transaction = mockk() @@ -1932,7 +1932,7 @@ class PaywallViewModelTest { withArg { builder -> val params = builder.build() assertThat(params.oldProductId).isEqualTo("old_product") - assertThat(params.googleReplacementMode).isEqualTo(GoogleReplacementMode.CHARGE_PRORATED_PRICE) + assertThat(params.replacementMode).isEqualTo(StoreReplacementMode.CHARGE_PRORATED_PRICE) }, ) } @@ -2148,7 +2148,7 @@ class PaywallViewModelTest { productChangeCalculator.calculateProductChangeInfo(any(), any()) } returns ProductChangeInfo( oldProductId = "old_product", - replacementMode = GoogleReplacementMode.CHARGE_PRORATED_PRICE, + replacementMode = StoreReplacementMode.CHARGE_PRORATED_PRICE, ) val model = PaywallViewModelImpl( @@ -2176,7 +2176,7 @@ class PaywallViewModelTest { assertThat(receivedContext).isNotNull assertThat(receivedContext!!.oldProductId).isEqualTo("old_product") assertThat(receivedContext.replacementMode) - .isEqualTo(GoogleReplacementMode.CHARGE_PRORATED_PRICE) + .isEqualTo(StoreReplacementMode.CHARGE_PRORATED_PRICE) coVerify(exactly = 1) { purchases.awaitSyncPurchases() } assertThat(dismissInvoked).isTrue } @@ -2226,7 +2226,7 @@ class PaywallViewModelTest { productChangeCalculator.calculateProductChangeInfo(any(), any()) } returns ProductChangeInfo( oldProductId = "old_product", - replacementMode = GoogleReplacementMode.DEFERRED, + replacementMode = StoreReplacementMode.DEFERRED, ) val model = PaywallViewModelImpl( @@ -2254,7 +2254,7 @@ class PaywallViewModelTest { assertThat(receivedContext).isNotNull assertThat(receivedContext!!.oldProductId).isEqualTo("old_product") assertThat(receivedContext.replacementMode) - .isEqualTo(GoogleReplacementMode.DEFERRED) + .isEqualTo(StoreReplacementMode.DEFERRED) coVerify(exactly = 1) { purchases.awaitSyncPurchases() } assertThat(dismissInvoked).isTrue } diff --git a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/ProductChangeCalculatorTest.kt b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/ProductChangeCalculatorTest.kt index b05d10d61c..0335628381 100644 --- a/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/ProductChangeCalculatorTest.kt +++ b/ui/revenuecatui/src/test/kotlin/com/revenuecat/purchases/ui/revenuecatui/data/ProductChangeCalculatorTest.kt @@ -14,9 +14,9 @@ import com.revenuecat.purchases.PurchasesErrorCode import com.revenuecat.purchases.PurchasesException import com.revenuecat.purchases.Store import com.revenuecat.purchases.SubscriptionInfo -import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.Period import com.revenuecat.purchases.models.Price +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.TestStoreProduct import com.revenuecat.purchases.paywalls.components.common.ProductChangeConfig import io.mockk.coEvery @@ -37,8 +37,8 @@ class ProductChangeCalculatorTest { private lateinit var calculator: ProductChangeCalculator private val defaultProductChangeConfig = ProductChangeConfig( - upgradeReplacementMode = GoogleReplacementMode.CHARGE_PRORATED_PRICE, - downgradeReplacementMode = GoogleReplacementMode.DEFERRED, + upgradeReplacementMode = StoreReplacementMode.CHARGE_PRORATED_PRICE, + downgradeReplacementMode = StoreReplacementMode.DEFERRED, ) @Before @@ -170,7 +170,7 @@ class ProductChangeCalculatorTest { assertThat(result).isNotNull assertThat(result!!.oldProductId).isEqualTo("com.test.subscription.basic") - assertThat(result.replacementMode).isEqualTo(GoogleReplacementMode.CHARGE_PRORATED_PRICE) + assertThat(result.replacementMode).isEqualTo(StoreReplacementMode.CHARGE_PRORATED_PRICE) } @Test @@ -206,7 +206,7 @@ class ProductChangeCalculatorTest { assertThat(result).isNotNull assertThat(result!!.oldProductId).isEqualTo("com.test.subscription.premium") - assertThat(result.replacementMode).isEqualTo(GoogleReplacementMode.DEFERRED) + assertThat(result.replacementMode).isEqualTo(StoreReplacementMode.DEFERRED) } @Test @@ -242,7 +242,7 @@ class ProductChangeCalculatorTest { assertThat(result).isNotNull assertThat(result!!.oldProductId).isEqualTo("com.test.subscription.basic") - assertThat(result.replacementMode).isEqualTo(GoogleReplacementMode.CHARGE_PRORATED_PRICE) + assertThat(result.replacementMode).isEqualTo(StoreReplacementMode.CHARGE_PRORATED_PRICE) } @Test @@ -272,8 +272,8 @@ class ProductChangeCalculatorTest { ) val customConfig = ProductChangeConfig( - upgradeReplacementMode = GoogleReplacementMode.CHARGE_FULL_PRICE, - downgradeReplacementMode = GoogleReplacementMode.WITH_TIME_PRORATION, + upgradeReplacementMode = StoreReplacementMode.CHARGE_FULL_PRICE, + downgradeReplacementMode = StoreReplacementMode.WITH_TIME_PRORATION, ) val result = calculator.calculateProductChangeInfo( @@ -282,7 +282,7 @@ class ProductChangeCalculatorTest { ) assertThat(result).isNotNull - assertThat(result!!.replacementMode).isEqualTo(GoogleReplacementMode.CHARGE_FULL_PRICE) + assertThat(result!!.replacementMode).isEqualTo(StoreReplacementMode.CHARGE_FULL_PRICE) } @Test @@ -312,7 +312,7 @@ class ProductChangeCalculatorTest { ) assertThat(result).isNotNull - assertThat(result!!.replacementMode).isEqualTo(GoogleReplacementMode.DEFERRED) + assertThat(result!!.replacementMode).isEqualTo(StoreReplacementMode.DEFERRED) } @Test From 31a571fcd8eb3d935da25e66a55d271dc3d7f680 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Wed, 18 Mar 2026 09:14:00 -0500 Subject: [PATCH 25/34] docs updates --- .../src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt | 3 ++- .../com/revenuecat/purchases/ui/revenuecatui/PurchaseLogic.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt index a4e988a976..8beb16076b 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/PurchaseParams.kt @@ -135,7 +135,8 @@ public class PurchaseParams(public val builder: Builder) { * Note: When using [GoogleReplacementMode.DEFERRED], the product ID is used to match the purchase callback * with the transaction returned by Google Play. * - * Product changes are only available in the Play Store. Ignored for Amazon Appstore purchases. + * Product changes are only available in the Play Store and the Galaxy Store. + * Ignored for Amazon Appstore purchases. */ public fun oldProductId(oldProductId: String): Builder = apply { this.oldProductId = oldProductId diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PurchaseLogic.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PurchaseLogic.kt index c1b4f1a4c4..9e8c8e94a0 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PurchaseLogic.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PurchaseLogic.kt @@ -112,7 +112,7 @@ public class PaywallPurchaseLogicParams internal constructor( /** * The replacement mode to use for this product change, as configured in the paywall. - * For Google Play, this will be a [com.revenuecat.purchases.models.GoogleReplacementMode]. + * For Google Play, this will be a [com.revenuecat.purchases.models.StoreReplacementMode]. * Null if this is a new purchase or the store does not support replacement modes. */ public val replacementMode: ReplacementMode? get() = productChange?.replacementMode From 266a2ba650538fb9b32bd9a806378618041318b7 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Wed, 18 Mar 2026 09:14:20 -0500 Subject: [PATCH 26/34] default to WITHOUT_PRORATION in galaxy when it receives unsupported replacementMode --- .../purchases/galaxy/GalaxyBillingWrapper.kt | 2 +- .../purchases/galaxy/GalaxyBillingWrapperTest.kt | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt index 7623b13a48..547f29aed5 100644 --- a/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt +++ b/feature/galaxy/src/main/kotlin/com/revenuecat/purchases/galaxy/GalaxyBillingWrapper.kt @@ -339,7 +339,7 @@ internal class GalaxyBillingWrapper( replaceProductInfo?.let { replaceInfo -> val replacementMode = when (val mode = replaceInfo.replacementMode) { is StoreReplacementMode -> mode - else -> StoreReplacementMode.CHARGE_PRORATED_PRICE + else -> StoreReplacementMode.WITHOUT_PRORATION } serialRequestExecutor.executeSerially { finish -> diff --git a/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/GalaxyBillingWrapperTest.kt b/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/GalaxyBillingWrapperTest.kt index 6146813d28..18f7fe8be0 100644 --- a/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/GalaxyBillingWrapperTest.kt +++ b/feature/galaxy/src/test/java/com/revenuecat/purchases/galaxy/GalaxyBillingWrapperTest.kt @@ -770,7 +770,7 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { @OptIn(GalaxySerialOperation::class) @Test - fun `makePurchaseAsync defaults to CHARGE_PRORATED_PRICE when replacement mode is not a StoreReplacementMode`() { + fun `makePurchaseAsync defaults to WITHOUT_PRORATION when replacement mode is not a StoreReplacementMode`() { val changeSubscriptionPlanHandler = mockk(relaxed = true) val wrapper = createWrapper(changeSubscriptionPlanHandler = changeSubscriptionPlanHandler) val purchasesUpdatedListener = mockk(relaxed = true) @@ -815,7 +815,7 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { appUserID = "user", oldPurchase = oldPurchase, newProductId = storeProduct.id, - replacementMode = StoreReplacementMode.CHARGE_PRORATED_PRICE, + replacementMode = StoreReplacementMode.WITHOUT_PRORATION, onSuccess = any(), onError = any(), ) @@ -838,12 +838,12 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { verify(exactly = 1) { purchasesUpdatedListener.onPurchasesUpdated(capture(transactionsSlot)) } val transaction = transactionsSlot.captured.single() assertThat(transaction.productIds).containsExactly(storeProduct.id) - assertThat(transaction.replacementMode).isEqualTo(StoreReplacementMode.CHARGE_PRORATED_PRICE) + assertThat(transaction.replacementMode).isEqualTo(StoreReplacementMode.WITHOUT_PRORATION) } @OptIn(GalaxySerialOperation::class) @Test - fun `makePurchaseAsync defaults to CHARGE_PRORATED_PRICE when replacement mode is null`() { + fun `makePurchaseAsync defaults to WITHOUT_PRORATION when replacement mode is null`() { val changeSubscriptionPlanHandler = mockk(relaxed = true) val wrapper = createWrapper(changeSubscriptionPlanHandler = changeSubscriptionPlanHandler) val purchasesUpdatedListener = mockk(relaxed = true) @@ -888,7 +888,7 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { appUserID = "user", oldPurchase = oldPurchase, newProductId = storeProduct.id, - replacementMode = StoreReplacementMode.CHARGE_PRORATED_PRICE, + replacementMode = StoreReplacementMode.WITHOUT_PRORATION, onSuccess = any(), onError = any(), ) @@ -911,7 +911,7 @@ class GalaxyBillingWrapperTest : GalaxyStoreTest() { verify(exactly = 1) { purchasesUpdatedListener.onPurchasesUpdated(capture(transactionsSlot)) } val transaction = transactionsSlot.captured.single() assertThat(transaction.productIds).containsExactly(storeProduct.id) - assertThat(transaction.replacementMode).isEqualTo(StoreReplacementMode.CHARGE_PRORATED_PRICE) + assertThat(transaction.replacementMode).isEqualTo(StoreReplacementMode.WITHOUT_PRORATION) } @OptIn(GalaxySerialOperation::class) From 56356cd8635f473df141ddb4e6dd7b123c06001c Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Wed, 18 Mar 2026 09:19:35 -0500 Subject: [PATCH 27/34] update paywall docs --- .../com/revenuecat/purchases/ui/revenuecatui/PurchaseLogic.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PurchaseLogic.kt b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PurchaseLogic.kt index 9e8c8e94a0..73970c3674 100644 --- a/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PurchaseLogic.kt +++ b/ui/revenuecatui/src/main/kotlin/com/revenuecat/purchases/ui/revenuecatui/PurchaseLogic.kt @@ -85,7 +85,7 @@ public interface PurchaseLogic : PaywallPurchaseLogic { * ```kotlin * val params = PaywallPurchaseLogicParams.Builder(rcPackage) * .oldProductId("com.example.old_product") - * .replacementMode(GoogleReplacementMode.CHARGE_PRORATED_PRICE) + * .replacementMode(StoreReplacementMode.CHARGE_PRORATED_PRICE) * .subscriptionOption(subscriptionOption) * .build() * ``` @@ -137,7 +137,7 @@ public class PaywallPurchaseLogicParams internal constructor( /** * Sets the replacement mode for this product change. - * For Google Play, use [com.revenuecat.purchases.models.GoogleReplacementMode]. + * Use [com.revenuecat.purchases.models.StoreReplacementMode]. */ public fun replacementMode(replacementMode: ReplacementMode?): Builder = apply { this.replacementMode = replacementMode From 8fb4f131b97d980805c07b19a9d65150655f7198 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Wed, 18 Mar 2026 09:34:19 -0500 Subject: [PATCH 28/34] ReplacementMode serialization tweaks --- .../revenuecat/purchases/ReplacementMode.kt | 11 ++++--- .../purchases/common/ReceiptInfoTest.kt | 31 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt index 3d7e948398..66cd603d1c 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt @@ -100,14 +100,15 @@ internal object ReplacementModeSerializer : KSerializer { } override fun serialize(encoder: Encoder, value: ReplacementMode) { + // Always encode to StoreReplacementMode since GoogleReplacementMode is deprecated encoder.encodeStructure(descriptor) { - val type = when (value) { - is GoogleReplacementMode -> "GoogleReplacementMode" - is StoreReplacementMode -> "StoreReplacementMode" + val normalizedValue = when (value) { + is GoogleReplacementMode -> value.toStoreReplacementMode() + is StoreReplacementMode -> value else -> throw SerializationException("Unknown ReplacementMode type: ${value::class.simpleName}") } - encodeStringElement(descriptor, 0, type) - encodeStringElement(descriptor, 1, value.name) + encodeStringElement(descriptor, 0, "StoreReplacementMode") + encodeStringElement(descriptor, 1, normalizedValue.name) } } diff --git a/purchases/src/test/java/com/revenuecat/purchases/common/ReceiptInfoTest.kt b/purchases/src/test/java/com/revenuecat/purchases/common/ReceiptInfoTest.kt index 37265354b2..5cf7e07a6a 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/common/ReceiptInfoTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/common/ReceiptInfoTest.kt @@ -21,6 +21,7 @@ import com.revenuecat.purchases.models.PricingPhaseSerializer import com.revenuecat.purchases.models.PurchaseState import com.revenuecat.purchases.models.PurchaseType import com.revenuecat.purchases.models.RecurrenceMode +import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.utils.stubGooglePurchase @@ -386,6 +387,36 @@ class ReceiptInfoTest { assertThat(decoded.replacementMode).isEqualTo(StoreReplacementMode.CHARGE_PRORATED_PRICE) } + @Test + fun `ReceiptInfo with GoogleReplacementMode serializes as StoreReplacementMode and deserializes to StoreReplacementMode`() { + val original = ReceiptInfo( + productIDs = listOf(productIdentifier), + price = 0.99, + currency = "USD", + period = null, + pricingPhases = null, + replacementMode = GoogleReplacementMode.WITH_TIME_PRORATION, + ) + + val encoded = json.encodeToString(original) + val decoded = json.decodeFromString(encoded) + + val expectedJson = """ + { + "productIDs":["com.myproduct"], + "price":0.99, + "currency":"USD", + "replacementMode":{ + "type":"StoreReplacementMode", + "name":"WITH_TIME_PRORATION" + } + } + """.trimIndent().lines().joinToString("") { it.trim() } + + assertThat(encoded).isEqualTo(expectedJson) + assertThat(decoded.replacementMode).isEqualTo(StoreReplacementMode.WITH_TIME_PRORATION) + } + @Test fun `ReceiptInfo with legacy Google replacement mode JSON deserializes to StoreReplacementMode`() { // language=JSON From a98132337e6177a49a7b316ccf05665b63b7f7cf Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Wed, 18 Mar 2026 10:23:34 -0500 Subject: [PATCH 29/34] centralize ReplacementMode mapping --- .../revenuecat/purchases/ReplacementMode.kt | 59 ++----------- .../google/BillingFlowParamsExtensions.kt | 21 ++--- .../models/StoreReplacementModeConversions.kt | 88 +++++++++++++++---- .../StoreReplacementModeConversionsTest.kt | 37 +++++++- 4 files changed, 118 insertions(+), 87 deletions(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt index 66cd603d1c..0b3626ff44 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt @@ -3,7 +3,9 @@ package com.revenuecat.purchases import android.os.Parcelable import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.StoreReplacementMode -import com.revenuecat.purchases.models.toGoogleReplacementMode +import com.revenuecat.purchases.models.legacyPlayBackendName +import com.revenuecat.purchases.models.storeBackendName +import com.revenuecat.purchases.models.toStoreReplacementModeOrNull import com.revenuecat.purchases.models.toStoreReplacementMode import kotlinx.android.parcel.IgnoredOnParcel import kotlinx.serialization.KSerializer @@ -26,44 +28,6 @@ public interface ReplacementMode : Parcelable { public val name: String } -/** - * [GoogleReplacementMode] used to be `GoogleProrationMode`. The backend still expects these values, hence this enum. - */ -private enum class LegacyProrationMode { - IMMEDIATE_WITHOUT_PRORATION, - IMMEDIATE_WITH_TIME_PRORATION, - IMMEDIATE_AND_CHARGE_FULL_PRICE, - IMMEDIATE_AND_CHARGE_PRORATED_PRICE, - DEFERRED, -} - -private val GoogleReplacementMode.asLegacyProrationMode: LegacyProrationMode - get() = when (this) { - GoogleReplacementMode.WITHOUT_PRORATION -> LegacyProrationMode.IMMEDIATE_WITHOUT_PRORATION - GoogleReplacementMode.WITH_TIME_PRORATION -> LegacyProrationMode.IMMEDIATE_WITH_TIME_PRORATION - GoogleReplacementMode.CHARGE_FULL_PRICE -> LegacyProrationMode.IMMEDIATE_AND_CHARGE_FULL_PRICE - GoogleReplacementMode.CHARGE_PRORATED_PRICE -> LegacyProrationMode.IMMEDIATE_AND_CHARGE_PRORATED_PRICE - GoogleReplacementMode.DEFERRED -> LegacyProrationMode.DEFERRED - } - -private val StoreReplacementMode.galaxyName: String? - get() = when (this) { - StoreReplacementMode.WITHOUT_PRORATION -> "INSTANT_NO_PRORATION" - StoreReplacementMode.WITH_TIME_PRORATION -> "INSTANT_PRORATED_DATE" - StoreReplacementMode.CHARGE_FULL_PRICE -> null // Unsupported by Galaxy Store - StoreReplacementMode.CHARGE_PRORATED_PRICE -> "INSTANT_PRORATED_CHARGE" - StoreReplacementMode.DEFERRED -> "DEFERRED" - } - -private val GoogleReplacementMode.storeReplacementMode: StoreReplacementMode - get() = when (this) { - GoogleReplacementMode.WITHOUT_PRORATION -> StoreReplacementMode.WITHOUT_PRORATION - GoogleReplacementMode.WITH_TIME_PRORATION -> StoreReplacementMode.WITH_TIME_PRORATION - GoogleReplacementMode.CHARGE_FULL_PRICE -> StoreReplacementMode.CHARGE_FULL_PRICE - GoogleReplacementMode.CHARGE_PRORATED_PRICE -> StoreReplacementMode.CHARGE_PRORATED_PRICE - GoogleReplacementMode.DEFERRED -> StoreReplacementMode.DEFERRED - } - /** * Returns the backend name for this [ReplacementMode]. * For [GoogleReplacementMode], this returns the legacy proration mode name. @@ -72,25 +36,14 @@ private val GoogleReplacementMode.storeReplacementMode: StoreReplacementMode @Deprecated("Use ReplacementMode.backendName(store: Store) instead") internal val ReplacementMode.backendName: String get() = when (this) { - is GoogleReplacementMode -> this.asLegacyProrationMode.name + is GoogleReplacementMode -> this.toStoreReplacementMode().legacyPlayBackendName() + is StoreReplacementMode -> this.legacyPlayBackendName() else -> this.name } @OptIn(ExperimentalPreviewRevenueCatPurchasesAPI::class) internal fun ReplacementMode.backendName(store: Store): String? { - return when (store) { - Store.PLAY_STORE -> when (this) { - is GoogleReplacementMode -> this.asLegacyProrationMode.name - is StoreReplacementMode -> this.toGoogleReplacementMode().asLegacyProrationMode.name - else -> null - } - Store.GALAXY -> when (this) { - is GoogleReplacementMode -> this.storeReplacementMode.galaxyName - is StoreReplacementMode -> this.galaxyName - else -> null - } - else -> null - } + return this.toStoreReplacementModeOrNull()?.storeBackendName(store) } internal object ReplacementModeSerializer : KSerializer { diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingFlowParamsExtensions.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingFlowParamsExtensions.kt index 00479e98d3..4d436b37f5 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingFlowParamsExtensions.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/google/BillingFlowParamsExtensions.kt @@ -4,27 +4,18 @@ import com.android.billingclient.api.BillingFlowParams import com.revenuecat.purchases.InternalRevenueCatAPI import com.revenuecat.purchases.common.ReplaceProductInfo import com.revenuecat.purchases.common.errorLog -import com.revenuecat.purchases.models.GoogleReplacementMode -import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.toPlayBillingClientMode +import com.revenuecat.purchases.models.toStoreReplacementModeOrNull @OptIn(InternalRevenueCatAPI::class) internal fun BillingFlowParams.Builder.setUpgradeInfo(replaceProductInfo: ReplaceProductInfo) { val subscriptionUpdateParams = BillingFlowParams.SubscriptionUpdateParams.newBuilder().apply { setOldPurchaseToken(replaceProductInfo.oldPurchase.purchaseToken) - replaceProductInfo.replacementMode?.let { - when (it) { - is StoreReplacementMode -> { - setSubscriptionReplacementMode(it.toPlayBillingClientMode()) - } - // TO DO: Remove this when we remove GoogleReplacementMode - is GoogleReplacementMode -> { - setSubscriptionReplacementMode(it.playBillingClientMode) - } - else -> { - errorLog { "Got unidentified replacement mode" } - } - } + val replacementMode = replaceProductInfo.replacementMode.toStoreReplacementModeOrNull() + if (replacementMode != null) { + setSubscriptionReplacementMode(replacementMode.toPlayBillingClientMode()) + } else if (replaceProductInfo.replacementMode != null) { + errorLog { "Got unidentified replacement mode" } } } setSubscriptionUpdateParams(subscriptionUpdateParams.build()) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt index b29b3c0f90..10c8f50399 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/models/StoreReplacementModeConversions.kt @@ -1,30 +1,73 @@ package com.revenuecat.purchases.models import com.android.billingclient.api.BillingFlowParams +import com.revenuecat.purchases.ReplacementMode +import com.revenuecat.purchases.Store +private data class StoreReplacementModeMapping( + @get:BillingFlowParams.SubscriptionUpdateParams.ReplacementMode + val playBillingClientMode: Int, + val legacyPlayBackendName: String, + val galaxyBackendName: String?, + val googleReplacementMode: GoogleReplacementMode, +) + +// This mapping table doesn't include the Galaxy mappings from StoreReplacementMode -> HelperDefine.ProrationMode +// because the Galaxy SDK isn't available in the main purchases module. Those conversions are located in the galaxy +// module's StoreReplacementModeConversions.kt file. +private val StoreReplacementMode.mapping: StoreReplacementModeMapping + get() = when (this) { + StoreReplacementMode.WITHOUT_PRORATION -> StoreReplacementModeMapping( + playBillingClientMode = BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION, + legacyPlayBackendName = "IMMEDIATE_WITHOUT_PRORATION", + galaxyBackendName = "INSTANT_NO_PRORATION", + googleReplacementMode = GoogleReplacementMode.WITHOUT_PRORATION, + ) + StoreReplacementMode.WITH_TIME_PRORATION -> StoreReplacementModeMapping( + playBillingClientMode = BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION, + legacyPlayBackendName = "IMMEDIATE_WITH_TIME_PRORATION", + galaxyBackendName = "INSTANT_PRORATED_DATE", + googleReplacementMode = GoogleReplacementMode.WITH_TIME_PRORATION, + ) + StoreReplacementMode.CHARGE_FULL_PRICE -> StoreReplacementModeMapping( + playBillingClientMode = BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.CHARGE_FULL_PRICE, + legacyPlayBackendName = "IMMEDIATE_AND_CHARGE_FULL_PRICE", + galaxyBackendName = null, + googleReplacementMode = GoogleReplacementMode.CHARGE_FULL_PRICE, + ) + StoreReplacementMode.CHARGE_PRORATED_PRICE -> StoreReplacementModeMapping( + playBillingClientMode = BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.CHARGE_PRORATED_PRICE, + legacyPlayBackendName = "IMMEDIATE_AND_CHARGE_PRORATED_PRICE", + galaxyBackendName = "INSTANT_PRORATED_CHARGE", + googleReplacementMode = GoogleReplacementMode.CHARGE_PRORATED_PRICE, + ) + StoreReplacementMode.DEFERRED -> StoreReplacementModeMapping( + playBillingClientMode = BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.DEFERRED, + legacyPlayBackendName = "DEFERRED", + galaxyBackendName = "DEFERRED", + googleReplacementMode = GoogleReplacementMode.DEFERRED, + ) + } + +@BillingFlowParams.SubscriptionUpdateParams.ReplacementMode internal fun StoreReplacementMode.toPlayBillingClientMode(): Int { - return when (this) { - StoreReplacementMode.WITHOUT_PRORATION -> - BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITHOUT_PRORATION - StoreReplacementMode.WITH_TIME_PRORATION -> - BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.WITH_TIME_PRORATION - StoreReplacementMode.CHARGE_FULL_PRICE -> - BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.CHARGE_FULL_PRICE - StoreReplacementMode.CHARGE_PRORATED_PRICE -> - BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.CHARGE_PRORATED_PRICE - StoreReplacementMode.DEFERRED -> - BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.DEFERRED + return mapping.playBillingClientMode +} + +internal fun StoreReplacementMode.legacyPlayBackendName(): String { + return mapping.legacyPlayBackendName +} + +internal fun StoreReplacementMode.storeBackendName(store: Store): String? { + return when (store) { + Store.PLAY_STORE -> legacyPlayBackendName() + Store.GALAXY -> mapping.galaxyBackendName + else -> null } } internal fun StoreReplacementMode.toGoogleReplacementMode(): GoogleReplacementMode { - return when (this) { - StoreReplacementMode.WITHOUT_PRORATION -> GoogleReplacementMode.WITHOUT_PRORATION - StoreReplacementMode.WITH_TIME_PRORATION -> GoogleReplacementMode.WITH_TIME_PRORATION - StoreReplacementMode.CHARGE_FULL_PRICE -> GoogleReplacementMode.CHARGE_FULL_PRICE - StoreReplacementMode.CHARGE_PRORATED_PRICE -> GoogleReplacementMode.CHARGE_PRORATED_PRICE - StoreReplacementMode.DEFERRED -> GoogleReplacementMode.DEFERRED - } + return mapping.googleReplacementMode } internal fun GoogleReplacementMode.toStoreReplacementMode(): StoreReplacementMode { @@ -36,3 +79,12 @@ internal fun GoogleReplacementMode.toStoreReplacementMode(): StoreReplacementMod GoogleReplacementMode.DEFERRED -> StoreReplacementMode.DEFERRED } } + +internal fun ReplacementMode?.toStoreReplacementModeOrNull(): StoreReplacementMode? { + return when (this) { + null -> null + is StoreReplacementMode -> this + is GoogleReplacementMode -> this.toStoreReplacementMode() + else -> null + } +} diff --git a/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt b/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt index 0b36cbdbbe..3608a2eb39 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/models/StoreReplacementModeConversionsTest.kt @@ -2,6 +2,7 @@ package com.revenuecat.purchases.models import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.billingclient.api.BillingFlowParams +import com.revenuecat.purchases.Store import org.assertj.core.api.Assertions.assertThat import org.junit.Test import org.junit.runner.RunWith @@ -32,6 +33,33 @@ class StoreReplacementModeConversionsTest { assertThat(expectations.size).isEqualTo(StoreReplacementMode.values().size) } + @Test + fun `all store replacement modes map to store specific backend names`() { + val playExpectations = mapOf( + StoreReplacementMode.WITHOUT_PRORATION to "IMMEDIATE_WITHOUT_PRORATION", + StoreReplacementMode.WITH_TIME_PRORATION to "IMMEDIATE_WITH_TIME_PRORATION", + StoreReplacementMode.CHARGE_FULL_PRICE to "IMMEDIATE_AND_CHARGE_FULL_PRICE", + StoreReplacementMode.CHARGE_PRORATED_PRICE to "IMMEDIATE_AND_CHARGE_PRORATED_PRICE", + StoreReplacementMode.DEFERRED to "DEFERRED", + ) + val galaxyExpectations = mapOf( + StoreReplacementMode.WITHOUT_PRORATION to "INSTANT_NO_PRORATION", + StoreReplacementMode.WITH_TIME_PRORATION to "INSTANT_PRORATED_DATE", + StoreReplacementMode.CHARGE_PRORATED_PRICE to "INSTANT_PRORATED_CHARGE", + StoreReplacementMode.DEFERRED to "DEFERRED", + ) + + StoreReplacementMode.values().forEach { mode -> + assertThat(mode.storeBackendName(Store.PLAY_STORE)).isEqualTo(playExpectations.getValue(mode)) + + if (mode == StoreReplacementMode.CHARGE_FULL_PRICE) { + assertThat(mode.storeBackendName(Store.GALAXY)).isNull() + } else { + assertThat(mode.storeBackendName(Store.GALAXY)).isEqualTo(galaxyExpectations.getValue(mode)) + } + } + } + @Test fun `all store replacement modes map to deprecated Google replacement modes`() { val expectations = mapOf( @@ -39,7 +67,7 @@ class StoreReplacementModeConversionsTest { StoreReplacementMode.WITH_TIME_PRORATION to GoogleReplacementMode.WITH_TIME_PRORATION, StoreReplacementMode.CHARGE_FULL_PRICE to GoogleReplacementMode.CHARGE_FULL_PRICE, StoreReplacementMode.CHARGE_PRORATED_PRICE to GoogleReplacementMode.CHARGE_PRORATED_PRICE, - StoreReplacementMode.DEFERRED to GoogleReplacementMode.DEFERRED, + StoreReplacementMode.DEFERRED to GoogleReplacementMode.DEFERRED, ) StoreReplacementMode.values().forEach { mode -> @@ -67,4 +95,11 @@ class StoreReplacementModeConversionsTest { assertThat(expectations.size).isEqualTo(GoogleReplacementMode.values().size) } + + @Test + fun `ReplacementMode normalization returns canonical store replacement modes`() { + assertThat(StoreReplacementMode.DEFERRED.toStoreReplacementModeOrNull()).isEqualTo(StoreReplacementMode.DEFERRED) + assertThat(GoogleReplacementMode.DEFERRED.toStoreReplacementModeOrNull()).isEqualTo(StoreReplacementMode.DEFERRED) + assertThat((null as com.revenuecat.purchases.ReplacementMode?).toStoreReplacementModeOrNull()).isNull() + } } From 5948a18b43b7c066c40b9593910f2445393f52fb Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Wed, 18 Mar 2026 10:24:12 -0500 Subject: [PATCH 30/34] detekt --- .../src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt b/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt index 0b3626ff44..b9b12a11b1 100644 --- a/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt +++ b/purchases/src/main/kotlin/com/revenuecat/purchases/ReplacementMode.kt @@ -5,8 +5,8 @@ import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.legacyPlayBackendName import com.revenuecat.purchases.models.storeBackendName -import com.revenuecat.purchases.models.toStoreReplacementModeOrNull import com.revenuecat.purchases.models.toStoreReplacementMode +import com.revenuecat.purchases.models.toStoreReplacementModeOrNull import kotlinx.android.parcel.IgnoredOnParcel import kotlinx.serialization.KSerializer import kotlinx.serialization.SerializationException From a7e0ef99b12baaea60f8c53e1ee14d18e25d0cc0 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Thu, 19 Mar 2026 09:56:32 -0500 Subject: [PATCH 31/34] update PurchaseTester to use new replacementMode APIs --- .../revenuecat/purchasetester/OfferingFragment.kt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OfferingFragment.kt b/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OfferingFragment.kt index 32e31800c5..d73312230a 100644 --- a/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OfferingFragment.kt +++ b/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OfferingFragment.kt @@ -29,9 +29,9 @@ import com.revenuecat.purchases.getCustomerInfoWith import com.revenuecat.purchases.getOfferingsWith import com.revenuecat.purchases.interfaces.PurchaseCallback import com.revenuecat.purchases.models.GooglePurchasingData -import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.PurchasingData import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.models.SubscriptionOption import com.revenuecat.purchases_sample.R @@ -258,7 +258,7 @@ class OfferingFragment : Fragment(), PackageCardAdapter.PackageCardAdapterListen purchaseParamsBuilder.oldProductId(it) replacementMode?.let { - purchaseParamsBuilder.googleReplacementMode(replacementMode) + purchaseParamsBuilder.replacementMode(replacementMode) } val purchaseParams = purchaseParamsBuilder.build() @@ -311,7 +311,7 @@ class OfferingFragment : Fragment(), PackageCardAdapter.PackageCardAdapterListen purchaseParamsBuilder.oldProductId(it) replacementMode?.let { - purchaseParamsBuilder.googleReplacementMode(replacementMode) + purchaseParamsBuilder.replacementMode(replacementMode) } if (isPersonalizedPrice) { @@ -372,7 +372,7 @@ class OfferingFragment : Fragment(), PackageCardAdapter.PackageCardAdapterListen } } - private fun promptForProductChangeInfo(callback: (String?, GoogleReplacementMode?) -> Unit) { + private fun promptForProductChangeInfo(callback: (String?, StoreReplacementMode?) -> Unit) { showOldSubIdPicker { subId -> subId?.let { showReplacementModePicker { replacementMode, error -> @@ -439,9 +439,9 @@ class OfferingFragment : Fragment(), PackageCardAdapter.PackageCardAdapterListen .show() } - private fun showReplacementModePicker(callback: (GoogleReplacementMode?, Error?) -> Unit) { - val replacementModeOptions = GoogleReplacementMode.values() - var selectedReplacementMode: GoogleReplacementMode? = null + private fun showReplacementModePicker(callback: (StoreReplacementMode?, Error?) -> Unit) { + val replacementModeOptions = StoreReplacementMode.values() + var selectedReplacementMode: StoreReplacementMode? = null val replacementModeNames = replacementModeOptions.map { it.name }.toTypedArray() MaterialAlertDialogBuilder(requireContext()) From 987bc18a63630f56c4877eb1103965d24437b2c6 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Thu, 19 Mar 2026 09:59:36 -0500 Subject: [PATCH 32/34] Update PurchaseParamsValidatorTest.kt --- .../utils/PurchaseParamsValidatorTest.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/purchases/src/test/java/com/revenuecat/purchases/utils/PurchaseParamsValidatorTest.kt b/purchases/src/test/java/com/revenuecat/purchases/utils/PurchaseParamsValidatorTest.kt index 9a90441cae..b66228a167 100644 --- a/purchases/src/test/java/com/revenuecat/purchases/utils/PurchaseParamsValidatorTest.kt +++ b/purchases/src/test/java/com/revenuecat/purchases/utils/PurchaseParamsValidatorTest.kt @@ -9,6 +9,7 @@ import com.revenuecat.purchases.models.GooglePurchasingData import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.Period import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.models.StoreReplacementMode import io.mockk.every import io.mockk.mockk import org.assertj.core.api.Assertions.assertThat @@ -99,6 +100,35 @@ class PurchaseParamsValidatorTest { assertThat(validationResult).isInstanceOf(Result.Success::class.java) } + @Test + fun `purchaseParams with oldProductId, GoogleReplacementMode, and StoreReplacementMode passes validation`() { + val purchaseParams = PurchaseParams.Builder( + mockk(), + subscriptionOption = stubSubscriptionOption(id = "123", productId = "abc") + ) + .oldProductId("123") + .googleReplacementMode(GoogleReplacementMode.WITHOUT_PRORATION) + .replacementMode(StoreReplacementMode.WITHOUT_PRORATION) + .build() + + val validationResult = validator.validate(purchaseParams) + assertThat(validationResult).isInstanceOf(Result.Success::class.java) + } + + @Test + fun `purchaseParams with oldProductId and StoreReplacementMode passes validation`() { + val purchaseParams = PurchaseParams.Builder( + mockk(), + subscriptionOption = stubSubscriptionOption(id = "123", productId = "abc") + ) + .oldProductId("123") + .replacementMode(StoreReplacementMode.WITHOUT_PRORATION) + .build() + + val validationResult = validator.validate(purchaseParams) + assertThat(validationResult).isInstanceOf(Result.Success::class.java) + } + @Test fun `purchaseParams with personalized price passes validation`() { for (personalizedPrice in listOf(true, false)) { From ff9863c7cce2226e5826ef02f1f7deddf8ae6d92 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Thu, 19 Mar 2026 10:00:48 -0500 Subject: [PATCH 33/34] update api testers --- .../apitester/java/PurchasesCommonAPI.java | 13 +++++++++---- .../apitester/kotlin/PurchasesCommonAPI.kt | 13 +++++++++---- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/api-tester/src/main/java/com/revenuecat/apitester/java/PurchasesCommonAPI.java b/api-tester/src/main/java/com/revenuecat/apitester/java/PurchasesCommonAPI.java index 956ea4552d..92626eb6cc 100644 --- a/api-tester/src/main/java/com/revenuecat/apitester/java/PurchasesCommonAPI.java +++ b/api-tester/src/main/java/com/revenuecat/apitester/java/PurchasesCommonAPI.java @@ -30,6 +30,7 @@ import com.revenuecat.purchases.models.GoogleReplacementMode; import com.revenuecat.purchases.models.InAppMessageType; import com.revenuecat.purchases.models.StoreProduct; +import com.revenuecat.purchases.models.StoreReplacementMode; import com.revenuecat.purchases.models.StoreTransaction; import com.revenuecat.purchases.models.SubscriptionOption; @@ -108,13 +109,15 @@ public void onError(@NonNull PurchasesError error, boolean userCancelled) { } }; String oldProductId = "old"; - GoogleReplacementMode replacementMode = GoogleReplacementMode.WITH_TIME_PRORATION; + GoogleReplacementMode googleReplacementMode = GoogleReplacementMode.WITH_TIME_PRORATION; + StoreReplacementMode storeReplacementMode = StoreReplacementMode.WITH_TIME_PRORATION; Boolean isPersonalizedPrice = true; PurchaseParams.Builder purchaseProductBuilder = new PurchaseParams.Builder(activity, storeProduct); purchaseProductBuilder .oldProductId(oldProductId) - .googleReplacementMode(replacementMode) + .googleReplacementMode(googleReplacementMode) + .replacementMode(storeReplacementMode) .isPersonalizedPrice(isPersonalizedPrice); PurchaseParams purchaseProductParams = purchaseProductBuilder.build(); purchases.purchase(purchaseProductParams, purchaseCallback); @@ -122,7 +125,8 @@ public void onError(@NonNull PurchasesError error, boolean userCancelled) { PurchaseParams.Builder purchaseOptionBuilder = new PurchaseParams.Builder(activity, subscriptionOption); purchaseOptionBuilder .oldProductId(oldProductId) - .googleReplacementMode(replacementMode) + .googleReplacementMode(googleReplacementMode) + .replacementMode(storeReplacementMode) .isPersonalizedPrice(isPersonalizedPrice); PurchaseParams purchaseOptionParams = purchaseOptionBuilder.build(); purchases.purchase(purchaseOptionParams, purchaseCallback); @@ -130,7 +134,8 @@ public void onError(@NonNull PurchasesError error, boolean userCancelled) { PurchaseParams.Builder purchasePackageBuilder = new PurchaseParams.Builder(activity, packageToPurchase); purchasePackageBuilder .oldProductId(oldProductId) - .googleReplacementMode(replacementMode) + .googleReplacementMode(googleReplacementMode) + .replacementMode(storeReplacementMode) .isPersonalizedPrice(isPersonalizedPrice); PurchaseParams purchasePackageParams = purchasePackageBuilder.build(); purchases.purchase(purchasePackageParams, purchaseCallback); diff --git a/api-tester/src/main/java/com/revenuecat/apitester/kotlin/PurchasesCommonAPI.kt b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/PurchasesCommonAPI.kt index 40bb50ed18..fca2cd9eb2 100644 --- a/api-tester/src/main/java/com/revenuecat/apitester/kotlin/PurchasesCommonAPI.kt +++ b/api-tester/src/main/java/com/revenuecat/apitester/kotlin/PurchasesCommonAPI.kt @@ -38,6 +38,7 @@ import com.revenuecat.purchases.models.BillingFeature import com.revenuecat.purchases.models.GoogleReplacementMode import com.revenuecat.purchases.models.InAppMessageType import com.revenuecat.purchases.models.StoreProduct +import com.revenuecat.purchases.models.StoreReplacementMode import com.revenuecat.purchases.models.StoreTransaction import com.revenuecat.purchases.models.SubscriptionOption import com.revenuecat.purchases.purchaseWith @@ -109,13 +110,15 @@ private class PurchasesCommonAPI { } val oldProductId = "old" - val replacementMode = GoogleReplacementMode.WITH_TIME_PRORATION + val googleReplacementMode = GoogleReplacementMode.WITH_TIME_PRORATION + val storeReplacementMode = StoreReplacementMode.WITH_TIME_PRORATION val isPersonalizedPrice = true val purchasePackageBuilder: PurchaseParams.Builder = PurchaseParams.Builder(activity, packageToPurchase) purchasePackageBuilder .oldProductId(oldProductId) - .googleReplacementMode(replacementMode) + .googleReplacementMode(googleReplacementMode) + .replacementMode(storeReplacementMode) .isPersonalizedPrice(isPersonalizedPrice) val purchasePackageParams: PurchaseParams = purchasePackageBuilder.build() purchases.purchase(purchasePackageParams, purchaseCallback) @@ -123,7 +126,8 @@ private class PurchasesCommonAPI { val purchaseProductBuilder: PurchaseParams.Builder = PurchaseParams.Builder(activity, storeProduct) purchaseProductBuilder .oldProductId(oldProductId) - .googleReplacementMode(replacementMode) + .googleReplacementMode(googleReplacementMode) + .replacementMode(storeReplacementMode) .isPersonalizedPrice(isPersonalizedPrice) val purchaseProductParams: PurchaseParams = purchaseProductBuilder.build() purchases.purchase(purchaseProductParams, purchaseCallback) @@ -131,7 +135,8 @@ private class PurchasesCommonAPI { val purchaseOptionBuilder: PurchaseParams.Builder = PurchaseParams.Builder(activity, subscriptionOption) purchaseOptionBuilder .oldProductId(oldProductId) - .googleReplacementMode(replacementMode) + .googleReplacementMode(googleReplacementMode) + .replacementMode(storeReplacementMode) .isPersonalizedPrice(isPersonalizedPrice) val purchaseOptionsParams: PurchaseParams = purchaseOptionBuilder.build() purchases.purchase(purchaseOptionsParams, purchaseCallback) From 7985a6054eabd76e1c7f4e0067e5f895c68227c9 Mon Sep 17 00:00:00 2001 From: Will Taylor Date: Thu, 19 Mar 2026 10:17:27 -0500 Subject: [PATCH 34/34] update PurchaseTester to allow product changes on galaxy store --- .../revenuecat/purchasetester/OfferingFragment.kt | 6 +++--- .../revenuecat/purchasetester/PackageCardAdapter.kt | 13 +++++++++---- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OfferingFragment.kt b/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OfferingFragment.kt index d73312230a..b245c777cc 100644 --- a/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OfferingFragment.kt +++ b/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/OfferingFragment.kt @@ -51,7 +51,7 @@ class OfferingFragment : Fragment(), PackageCardAdapter.PackageCardAdapterListen private var activeSubscriptions: Set = setOf() private lateinit var dataStoreUtils: DataStoreUtils - private var isPlayStore: Boolean = true + private var store: Store = Store.GOOGLE private var packageCardAdapter: PackageCardAdapter? = null private var isAddOnPurchaseUpgrade: Boolean = false @@ -74,7 +74,7 @@ class OfferingFragment : Fragment(), PackageCardAdapter.PackageCardAdapterListen lifecycleScope.launch { dataStoreUtils.getSdkConfig().onEach { sdkConfiguration -> - isPlayStore = sdkConfiguration.store == Store.GOOGLE + store = sdkConfiguration.store }.collect() } @@ -141,7 +141,7 @@ class OfferingFragment : Fragment(), PackageCardAdapter.PackageCardAdapterListen offering.availablePackages, activeSubscriptions, this, - isPlayStore, + store, ) binding.offeringDetailsPackagesRecycler.adapter = packageCardAdapter diff --git a/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/PackageCardAdapter.kt b/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/PackageCardAdapter.kt index 49c4828331..fb8ab8c9ad 100644 --- a/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/PackageCardAdapter.kt +++ b/examples/purchase-tester/src/main/java/com/revenuecat/purchasetester/PackageCardAdapter.kt @@ -23,7 +23,7 @@ class PackageCardAdapter( private val packages: List, private val activeSubscriptions: Set, private val listener: PackageCardAdapterListener, - private val isPlayStore: Boolean, + private val store: Store, ) : RecyclerView.Adapter() { @@ -40,7 +40,7 @@ class PackageCardAdapter( override fun getItemCount(): Int = packages.size override fun onBindViewHolder(holder: PackageViewHolder, position: Int) { - holder.bind(packages[position], isPlayStore, isAddOnMode, selectedPackages.contains(packages[position])) + holder.bind(packages[position], store, isAddOnMode, selectedPackages.contains(packages[position])) } fun setAddOnMode(enabled: Boolean) { @@ -82,7 +82,12 @@ class PackageCardAdapter( private val nothingCheckedIndex = -1 @Suppress("CyclomaticComplexMethod", "LongMethod") - fun bind(currentPackage: Package, isPlayStore: Boolean, isAddOnMode: Boolean, isSelected: Boolean) { + fun bind(currentPackage: Package, store: Store, isAddOnMode: Boolean, isSelected: Boolean) { + val isPlayStore = store == Store.GOOGLE + val supportsProductChange = when (store) { + Store.GOOGLE, Store.GALAXY -> true + Store.AMAZON -> false + } val product = currentPackage.product val isSubscription = product.type == ProductType.SUBS val isActive = activeSubscriptions.contains(product.id) @@ -115,7 +120,7 @@ class PackageCardAdapter( binding.isUpgradeCheckbox.visibility = if (isAddOnMode) View.GONE else View.VISIBLE binding.isPersonalizedCheckbox.visibility = if (isAddOnMode) View.GONE else View.VISIBLE - binding.isUpgradeCheckbox.isEnabled = isPlayStore + binding.isUpgradeCheckbox.isEnabled = supportsProductChange binding.isPersonalizedCheckbox.isEnabled = isPlayStore binding.packageBuyButton.visibility = if (isAddOnMode) View.GONE else View.VISIBLE