Skip to content

Fixes price formatting discrepancies on Paywalls for {{ product.price_per_[day|week|month|year] }}#2604

Merged
JayShortway merged 8 commits into
mainfrom
currency-locale
Aug 20, 2025
Merged

Fixes price formatting discrepancies on Paywalls for {{ product.price_per_[day|week|month|year] }}#2604
JayShortway merged 8 commits into
mainfrom
currency-locale

Conversation

@JayShortway

Copy link
Copy Markdown
Member

Bug

We've had reports of formatting discrepancies between calculated prices (e.g. monthly price calculated from a yearly subscription) and the price that comes directly from the store (the yearly price of a yearly subscription). This store price is shown on the paywall ({{ product.price }}) and on the Play Billing purchase sheet.

Cause

This is caused by the fact that calculated prices use the device locale to format, and the store uses the storefront country.

Fix

The fix implemented in this PR is to use the storefront country to format all prices. The difficulty is in the fact that the storefront country is only a country, not a complete locale. It's missing a language. To solve that, we check all available device locales for the storefront country, and pick the best one.

@JayShortway JayShortway self-assigned this Aug 15, 2025
@JayShortway JayShortway added the pr:fix A bug fix label Aug 15, 2025
@JayShortway JayShortway changed the title Fixes price formatting discrepancies on Paywalls for {{ product.price_per_[day|week|month|year }} Fixes price formatting discrepancies on Paywalls for {{ product.price_per_[day|week|month|year] }} Aug 15, 2025
Comment on lines +162 to +182
val currencyLocale by derivedStateOf {
if (storefrontCountryCode.isNullOrBlank()) {
locale
} else {
val deviceLanguageCode = locale.language.lowercase()

// We find all available device locales with the same country as the storefront country.
val availableStorefrontCountryLocalesByLanguage: Map<String, Locale> =
availableDeviceLocalesByCountry[storefrontCountryCode.lowercase()]
?.associateBy { it.language.lowercase() }
?: emptyMap()

// We pick the one with the same language as the device if available. If not, we just pick the
// first. If the list is empty, we build our own Locale.
val javaLocale = availableStorefrontCountryLocalesByLanguage[deviceLanguageCode]
?: availableStorefrontCountryLocalesByLanguage.values.firstOrNull()
?: Locale.forLanguageTag("$deviceLanguageCode-${storefrontCountryCode.uppercase()}")

javaLocale.toComposeLocale()
}
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is the fix.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I remember from a past project that there are some exceptions where the locale is not just language-country, but may also contain a -variant.

I'm not sure if it's super relevant since the fallback might be good enough, but for example zh-Hans (Chinese in Simplified script) would be one of them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

You're right! I was thinking this is good enough for a fallback, but we can do better. We're now using the entire device locale as a last resort, but only adjusting the country to the storefront one: feb641e.

@emerge-tools

emerge-tools Bot commented Aug 15, 2025

Copy link
Copy Markdown

📸 Snapshot Test

233 modified, 427 unchanged

Name Added Removed Modified Renamed Unchanged Errored Approval
TestPurchasesUIAndroidCompatibility
com.revenuecat.testpurchasesuiandroidcompatibility
0 0 115 0 289 0 ✅ Approved
TestPurchasesUIAndroidCompatibility Paparazzi
com.revenuecat.testpurchasesuiandroidcompatibility.paparazzi
0 0 118 0 138 0 ✅ Approved

🛸 Powered by Emerge Tools

@codecov

codecov Bot commented Aug 15, 2025

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.41%. Comparing base (f3065b0) to head (470762b).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...evenuecat/purchases/utils/PreviewOfferingParser.kt 0.00% 9 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2604   +/-   ##
=======================================
  Coverage   78.41%   78.41%           
=======================================
  Files         301      301           
  Lines       11241    11241           
  Branches     1561     1561           
=======================================
  Hits         8815     8815           
  Misses       1740     1740           
  Partials      686      686           

☔ 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.

@JayShortway JayShortway requested a review from a team August 15, 2025 12:30
@JayShortway JayShortway marked this pull request as ready for review August 15, 2025 12:31
@rickvdl

rickvdl commented Aug 15, 2025

Copy link
Copy Markdown
Member

I'll let someone with a bit more context on this approve the PR :)

@tonidero tonidero left a comment

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.

Just a question but I think it makes sense! Many thanks for adding those tests and the fix!! Exciting to get this fixed!

// first. If the list is empty, we build our own Locale.
val javaLocale = availableStorefrontCountryLocalesByLanguage[deviceLanguageCode]
?: availableStorefrontCountryLocalesByLanguage.values.firstOrNull()
?: Locale.forLanguageTag("$deviceLanguageCode-${storefrontCountryCode.uppercase()}")

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.

From my tests on a kotlin playground, if the locale is wrongly formatted here it would still create the locale... what would happen in that case? Just wondering if we should add extra validation here to make sure we build a valid locale or fallback to the device locale instead...

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good point! I changed it so we're using the device locale and only adjust the country to the storefront one: feb641e.

@JayShortway

Copy link
Copy Markdown
Member Author

Asked Emerge for logs on the failures.

@JayShortway JayShortway added this pull request to the merge queue Aug 20, 2025
Merged via the queue into main with commit b1052b8 Aug 20, 2025
20 checks passed
@JayShortway JayShortway deleted the currency-locale branch August 20, 2025 15:16
This was referenced Aug 20, 2025
tonidero pushed a commit that referenced this pull request Aug 21, 2025
**This is an automatic release.**

## RevenueCat SDK
### 🐞 Bugfixes
* Use `Block store` to backup anonymous user ids across installations
(#2595) via Toni Rico (@tonidero)

## RevenueCatUI SDK
### Paywallv2
#### 🐞 Bugfixes
* Fixes price formatting discrepancies on Paywalls for `{{
product.price_per_[day|week|month|year] }}` (#2604) via JayShortway
(@JayShortway)

### 🔄 Other Changes
* Revert dokka 2 and gradle 9 update (#2618) via Toni Rico (@tonidero)
* Introduce runtime annotations library and add stability annotations
for increasing UI performances (#2608) via Jaewoong Eum (@skydoves)
* Override presented offering context paywalls without offering (#2612)
via Toni Rico (@tonidero)
* Add APIs for hybrid SDKs to set presentedOfferingContext (#2610) via
Toni Rico (@tonidero)
* Bump Baseline Profiles to 1.4.0 and update profiles (#2611) via
Jaewoong Eum (@skydoves)
* Migrate deprecated kotlinOptions to compilerOptions (#2607) via
Jaewoong Eum (@skydoves)
* [AUTOMATIC][Paywalls V2] Updates paywall-preview-resources submodule
(#2613) via RevenueCat Git Bot (@RCGitBot)
* Migrate amazon & debugview modules to KTS (#2327) via Jaewoong Eum
(@skydoves)
* Update to Dokka 2.0.0 (#2609) via Toni Rico (@tonidero)
* Add log when restoring purchases finds no purchases with some
troubleshooting (#2599) via Toni Rico (@tonidero)

Co-authored-by: revenuecat-ops <ops@revenuecat.com>
tonidero pushed a commit that referenced this pull request Aug 25, 2025
…e_per_[day|week|month|year] }}` (#2604)

## Bug
We've had reports of formatting discrepancies between calculated prices
(e.g. monthly price calculated from a yearly subscription) and the price
that comes directly from the store (the yearly price of a yearly
subscription). This store price is shown on the paywall (`{{
product.price }}`) and on the Play Billing purchase sheet.

## Cause
This is caused by the fact that calculated prices use the device locale
to format, and the store uses the storefront country.

## Fix
The fix implemented in this PR is to use the storefront country to
format all prices. The difficulty is in the fact that the storefront
country is only a country, not a complete locale. It's missing a
language. To solve that, we check all available device locales for the
storefront country, and pick the best one.
@tonidero tonidero mentioned this pull request Aug 25, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants