Skip to content

Unified StoreReplacementMode API#3234

Open
fire-at-will wants to merge 40 commits intomainfrom
shared-replacement-mode
Open

Unified StoreReplacementMode API#3234
fire-at-will wants to merge 40 commits intomainfrom
shared-replacement-mode

Conversation

@fire-at-will
Copy link
Copy Markdown
Contributor

@fire-at-will fire-at-will commented Mar 13, 2026

Motivation

With the introduction of the Galaxy Store, we needed to find a way to gracefully model our replacement mode APIs for product changes, now that we have two stores in the Android SDK that support product changes with replacement modes.

Our current replacement mode APIs are store-specific. This allows you to tailor the replacement modes to each store, but puts the onus on the developer to maintain different logic for each store to call the appropriate PurchaseParams.Builder function to set the replacement mode for that store.

Description

This PR introduces a new unified StoreReplacementMode enum that can be used across both the Play Store and the Galaxy Store. It replaces the existing GoogleReplacementMode and GalaxyReplacementMode enums. It has the same fields as the existing GoogleReplacementMode:

public enum class StoreReplacementMode : ReplacementMode {
    WITHOUT_PRORATION,
    WITH_TIME_PRORATION,
    CHARGE_FULL_PRICE,
    CHARGE_PRORATED_PRICE,
    DEFERRED,
}

The following table documents how each StoreReplacementMode maps to each store:

StoreReplacementMode Play Store Replacement Mode Galaxy Replacement Mode
WITHOUT_PRORATION WITHOUT_PRORATION INSTANT_NO_PRORATION
WITH_TIME_PRORATION WITH_TIME_PRORATION INSTANT_PRORATED_DATE
CHARGE_FULL_PRICE CHARGE_FULL_PRICE N/A - error returned if provided to purchase()
CHARGE_PRORATED_PRICE CHARGE_PRORATED_PRICE INSTANT_PRORATED_CHARGE
DEFERRED DEFERRED DEFERRED

Additionally, this PR deprecates the existing GoogleReplacementMode enum and associated public APIs and hard-removes the experimental GalaxyReplacementMode enum and its APIs.

Under the hood, it also updates the product change flows for both the Galaxy and Play Stores to use the StoreReplacementMode enum instead of the store-specific enums.

API Changes

Additions

  • StoreReplacementMode enum
  • PurchaseParams.replacementMode: StoreReplacementMode (defaults to WITHOUT_PRORATION)
  • PurchaseParams.Builder.replacementMode(replacementMode: StoreReplacementMode)

Deprecations

  • GoogleReplacementMode - use StoreReplacementMode instead
  • PurchaseParams.googleReplacementMode - use PurchaseParams.replacementMode instead
  • PurchaseParams.Builder.googleReplacementMode - use PurchaseParams.Builder.replacementMode instead
  • PurchaseParams.Builder.googleReplacementMode(googleReplacementMode: GoogleReplacementMode) - use PurchaseParams.Builder.replacementMode(replacementMode: StoreReplacementMode) instead

Hard Removals

These are all currently marked as @ExperimentalPreviewRevenueCatPurchasesAPI:

  • GalaxyReplacementMode - use StoreReplacementMode instead
  • PurchaseParams.galaxyReplacementMode - use PurchaseParams.replacementMode instead
  • PurchaseParams.Builder.galaxyReplacementMode - use PurchaseParams.Builder.replacementMode instead
  • PurchaseParams.Builder.galaxyReplacementMode(galaxyReplacementMode: GalaxyReplacementMode) - use PurchaseParams.Builder.replacementMode(replacementMode: StoreReplacementMode) instead

Paywalls Changes

This PR includes minor changes to the paywalls product to account for this change, primarily in how product changes and custom purchase logic are handled.

Testing

  • Lots of unit tests
  • Manually tested:
    • Product changes on the Galaxy Store using the new StoreReplacementAPI work
    • Confirmed that making a product change on the Galaxy Store with StoreReplacementAPI.CHARGE_FULL_PRICE returns an unsupported error
    • Product changes on the Play Store using the deprecated GoogleReplacementAPI still work
    • Product changes on the Play Store using the new StoreReplacementAPI work

Note

Medium Risk
Introduces a new unified replacement-mode API and rewires Play/Galaxy product-change flows and backend payload mapping, which could affect upgrade/downgrade behavior and receipt posting across stores. Risk is mitigated by extensive test updates but touches core purchase paths.

Overview
Unifies subscription product-change replacement modes across stores by introducing StoreReplacementMode and a new PurchaseParams.Builder.replacementMode(...) / PurchaseParams.replacementMode field, while hard-removing experimental GalaxyReplacementMode APIs and deprecating GoogleReplacementMode and related PurchaseParams accessors.

Updates Play Store and Galaxy Store purchase/product-change implementations to consume StoreReplacementMode, including store-specific conversions (Play Billing mode, Galaxy proration mode) and store-aware backend serialization for proration_mode.

Adjusts sample/test harnesses and paywall product-change config/serializers to use StoreReplacementMode, and refreshes unit tests to cover new defaults, mapping behavior, deferred product-change handling, and Galaxy unsupported-mode errors (notably CHARGE_FULL_PRICE).

Written by Cursor Bugbot for commit 065832a. This will update automatically on new commits. Configure here.

@fire-at-will fire-at-will added the pr:feat A new feature label Mar 13, 2026
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 13, 2026

Codecov Report

❌ Patch coverage is 91.66667% with 8 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.43%. Comparing base (b4a62c7) to head (7985a60).
⚠️ Report is 25 commits behind head on main.

Files with missing lines Patch % Lines
...urchases/models/StoreReplacementModeConversions.kt 94.00% 2 Missing and 1 partial ⚠️
...kotlin/com/revenuecat/purchases/ReplacementMode.kt 85.71% 0 Missing and 2 partials ⚠️
...at/purchases/google/BillingFlowParamsExtensions.kt 60.00% 1 Missing and 1 partial ⚠️
.../com/revenuecat/purchases/PurchasesOrchestrator.kt 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3234      +/-   ##
==========================================
+ Coverage   79.39%   79.43%   +0.04%     
==========================================
  Files         356      357       +1     
  Lines       14344    14387      +43     
  Branches     1959     1966       +7     
==========================================
+ Hits        11389    11429      +40     
- Misses       2151     2152       +1     
- Partials      804      806       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@emerge-tools
Copy link
Copy Markdown

emerge-tools bot commented Mar 16, 2026

📸 Snapshot Test

582 unchanged

Name Added Removed Modified Renamed Unchanged Errored Approval
TestPurchasesUIAndroidCompatibility Paparazzi
com.revenuecat.testpurchasesuiandroidcompatibility.paparazzi
0 0 0 0 257 0 N/A
TestPurchasesUIAndroidCompatibility
com.revenuecat.testpurchasesuiandroidcompatibility
0 0 0 0 325 0 N/A

🛸 Powered by Emerge Tools

}
}

internal object ReplacementModeSerializer : KSerializer<ReplacementMode> {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR updates ReplacementModeSerializer to be asymmetric to account for the migration from GoogleReplacementMode -> StoreReplacementMode.

For serialization, it always serializes to StoreReplacementMode, even if the object is a GoogleReplacementMode.

For deserialization, it supports both GoogleReplacementMode and StoreReplacementMode as inputs to maintain backwards compatibility, but always deserializes to a StoreReplacementMode.

@fire-at-will fire-at-will marked this pull request as ready for review March 18, 2026 15:40
@fire-at-will fire-at-will requested review from a team as code owners March 18, 2026 15:40
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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If StoreReplacementMode.CHARGE_FULL_PRICE is provided to the Galaxy store for a product change, we return an error, since there is no equivalent of this mode for the Galaxy Store.

We could gracefully degrade to a default here, but I thought it might be best to fail since the request can't be executed as specified. What do y'all think?

@fire-at-will fire-at-will changed the title [WIP]: Unified StoreReplacementMode API Unified StoreReplacementMode API Mar 18, 2026
@fire-at-will fire-at-will requested a review from tonidero March 18, 2026 15:48
* For Google Play, use [com.revenuecat.purchases.models.GoogleReplacementMode].
* Use [com.revenuecat.purchases.models.StoreReplacementMode].
*/
public fun replacementMode(replacementMode: ReplacementMode?): Builder = apply {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe this is technically a breaking change since .replacementMode() uses ReplacementMode

@fire-at-will fire-at-will requested a review from vegaro March 18, 2026 16:20
Copy link
Copy Markdown
Contributor

@tonidero tonidero left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work! My only comment is whether we should try to avoid using enums for the new type now that we have the chance. Lmk what you think!

return mapping.legacyPlayBackendName
}

internal fun StoreReplacementMode.storeBackendName(store: Store): String? {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could have been nice to try to consolidate these in the backend... But that should be a different PR.

* Enum of possible replacement modes to be used when performing a subscription product change.
*/
@Parcelize
public enum class StoreReplacementMode : ReplacementMode {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking... should we take the chance to make this not an enum? Since we want to avoid enums/sealed classes in public APIs when possible.... The approach we've been folllowing can be seen for example with the CustomVariableValue type. It would mean we can't have exhaustive when-else statements... but I think that's exactly the point, for public APIs 😅 . Wdyt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is the direction we're trying to take our APIs, I'm more than happy to go with the pattern! Will update the PR to use an abstract class like CustomVariableValue does :)

Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

val productId =
if (replaceProductInfo?.replacementMode == GoogleReplacementMode.DEFERRED) {
if (replaceProductInfo?.replacementMode == StoreReplacementMode.DEFERRED ||
replaceProductInfo?.replacementMode == GoogleReplacementMode.DEFERRED
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inconsistent replacement mode handling in BillingWrapper DEFERRED check

Low Severity

The DEFERRED mode check on lines 300–301 defensively handles both StoreReplacementMode.DEFERRED and GoogleReplacementMode.DEFERRED, but the PurchaseContext assignment on line 326 uses as? StoreReplacementMode?, which silently drops GoogleReplacementMode to null. If a GoogleReplacementMode.DEFERRED were to reach this code, the callback would be correctly routed to the old product ID, but the replacement mode stored on the resulting StoreTransaction (and sent to the backend) would be null. The two locations are inconsistent in their handling of the deprecated type.

Additional Locations (1)
Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr:feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants