Skip to content

Conversation

@rickvdl
Copy link
Contributor

@rickvdl rickvdl commented Oct 17, 2025

Based on this PoC PR.

We're having an issue related to currency formatting behavior in Romania (and possibly a few other currencies), internal discussion can be found here.

But some context: when using the Romanian Storefront, and a Romanian locale (ro_RO) prices are formatted as 1,23 RON, which is correct, but the more conventional way to write this in Romanian (according to reports) is 1,23 lei.

underlyingSK2Product.displayPrice uses the more common lei rather than RON, however the priceFormatter we create in SK2StoreProduct.swift through priceFormatterProvider.priceFormatterForSK2 based on the currency code and locale given to us by StoreKit creates strings using RON instead. This is causing discrepancies between localizedPriceString and the price strings we generate using the formatter (for example pricePerMonth) which looks confusing on the paywall especially.

After some research and discussions it looks like we can't get the NumberFormatter API to mimic the behavior of StoreKit, and thus we've been looking into an alternative approach.

We came up with a backend driven approach where we (given a ruleset) provide the option to override the currencySymbol for specific currency codes, given a storekit storefront (similar to what we already do for paywall localizations).

Checklist

  • If applicable, unit tests
  • If applicable, create follow-up issues for purchases-android and hybrids
  • Check if we need any web-billing specific implementations

Motivation

Description

let uiConfig: UIConfig?
let config: Config?

public struct Config {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a Config 'envelope' here in order to future-proof ourselves for more configuration values. But we can also just call it priceFormattingRuleSets and only have this single purpose field here, happy to go either way

Copy link
Member

Choose a reason for hiding this comment

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

I'm fine either way too, but I can see us having this Config wrapper with a single key forever. I also wouldn't mind putting it in UIConfig.

Copy link
Member

Choose a reason for hiding this comment

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

Yep. I think we may as well put it in UIConfig, as one could argue that this affects UI (how prices are presented in the UI).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good points, will move it to UIConfig

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Moved them to UIConfig, however on tvOS it's an empty structure. I think this should still be supported on tvOS so added it to that struct as well. LMK if you (dis)agree

@emerge-tools
Copy link

emerge-tools bot commented Oct 17, 2025

1 build increased size

Name Version Download Change Install Change Approval
RevenueCat
com.revenuecat.PaywallsTester
1.0 (1) 16.8 MB ⬆️ 38.8 kB (0.23%) 59.9 MB ⬆️ 144.6 kB (0.24%) N/A

RevenueCat 1.0 (1)
com.revenuecat.PaywallsTester

⚖️ Compare build
⏱️ Analyze build performance

Total install size change: ⬆️ 144.6 kB (0.24%)
Total download size change: ⬆️ 38.8 kB (0.23%)

Largest size changes

Item Install Size Change
DYLD.String Table ⬆️ 53.7 kB
📝 RevenueCat.CurrencySymbolOverridingPriceFormatter.CurrencySymbolO... ⬆️ 5.7 kB
DYLD.Exports ⬆️ 3.5 kB
Code Signature ⬆️ 3.4 kB
📝 RevenueCat.CurrencySymbolOverridingPriceFormatter.Objc Metadata ⬆️ 1.3 kB
View Treemap

Image of diff


🛸 Powered by Emerge Tools

Comment trigger: Size diff threshold of 100.00kB exceeded

return Set(storeKitProducts.map { SK2StoreProduct(sk2Product: $0) })
return Set(storeKitProducts.map { SK2StoreProduct(
sk2Product: $0,
priceFormatterProvider: .init(priceFormattingRuleSet: priceFormattingRuleSetProvider.priceFormattingRuleSet())
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This will hand the SKProduct the value type (PriceFormattingRuleSet) for the current storefront. However when the storefront changes the storekit cache is cleared and the storekit products are refetched, so I think this is fine

expect(priceFormatter.currencySymbol) == "RON"
XCTAssert(type(of: priceFormatter) == CurrencySymbolOverridingPriceFormatter.self)

XCTAssertEqual(priceFormatter.string(from: NSNumber(integerLiteral: 0)), "0,00 lei")
Copy link
Contributor Author

Choose a reason for hiding this comment

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

real world example

@rickvdl
Copy link
Contributor Author

rickvdl commented Oct 17, 2025

@RCGitBot please test

@emerge-tools
Copy link

emerge-tools bot commented Oct 17, 2025

📸 Snapshot Test

240 unchanged

Name Added Removed Modified Renamed Unchanged Errored Approval
RevenueCat
com.revenuecat.PaywallsTester
0 0 0 0 240 0 N/A

🛸 Powered by Emerge Tools

Copy link
Member

@JayShortway JayShortway left a comment

Choose a reason for hiding this comment

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

Some initial comments. Great job investigating this and adding support for this! 💪

Comment on lines 177 to 213
/// Cardinal plural selection per CLDR/ICU baseline:
/// - Non-integers → .other
/// - Integers: 0 → .zero, 1 → .one, 2 → .two, else → .other
/// This function is intentionally locale-agnostic; apply your locale-specific rules upstream.
/// Spec reference: Unicode TR35 (Plural Rules).
private func rule(for value: NSNumber) -> PriceFormattingRuleSet.CurrencySymbolOverride.PluralRule {
let n = value.doubleValue

// Guard weird numerics
if n.isNaN || n.isInfinite { return .other }

guard let intValue = Int64(exactly: n) else {
return .other
}

// Check if value has any fractional part
let isInteger = n == Double(intValue)

// Per CLDR/ICU, decimals are "other" unless a locale defines explicit fraction rules.
guard isInteger else { return .other }

// Integer-only mapping; locale-specific categories like "few"/"many" are handled elsewhere.
switch intValue {
case 0: return .zero
case 1: return .one
case 2: return .two
default: return .other
}
}
Copy link
Member

Choose a reason for hiding this comment

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

I think this should follow the same rules as we use for VariableLocalizationKeys.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should be matching now :)

let uiConfig: UIConfig?
let config: Config?

public struct Config {
Copy link
Member

Choose a reason for hiding this comment

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

I'm fine either way too, but I can see us having this Config wrapper with a single key forever. I also wouldn't mind putting it in UIConfig.

@JayShortway JayShortway requested a review from a team October 20, 2025 09:54
Copy link
Member

@ajpallares ajpallares left a comment

Choose a reason for hiding this comment

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

Thank for tackling this!

On a quick initial review, I'm seeing some APIs made public, but, if I'm not wrong, I don't think any of these APIs should be public, as it's server-driven. Please correct me if I'm wrong 🙏

/// A `NumberFormatter` provider class for prices.
/// This provider caches the formatter to improve the performance.
final class PriceFormatterProvider: Sendable {
public final class PriceFormatterProvider: Sendable {
Copy link
Member

Choose a reason for hiding this comment

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

I wonder, why should this API be public? Its init is not public.
It seems to me like it's only public to allow StoreProduct's new init be public

convenience init(
    sk2Product: SK2Product,
    priceFormatterProvider: PriceFormatterProvider
)

But I think that one shouldn't be public either?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@ajpallares indeed that's the reason, but this was already public. There also seems to be a public API tester for so I guess it is used by developers

Copy link
Member

Choose a reason for hiding this comment

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

I understand. It seems like that init was made public in #1341. But I don't see why the new init that receives a priceFormatterProvider needs to be public. Initially, I think we should not make it public unless we find a good reason. I would keep it internal so that PriceFormatterProvider can also be internal

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah you're right, my bad! I've made it internal again :) Thanks for clarifying

Copy link
Member

Choose a reason for hiding this comment

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

Also, I would make this PriceFormatterProvider internal (probably we need it to be @_spi(Internal) public). I see that it's needed in the UIConfig's init that is already public (but that init should actually be @_spi(Internal) public instead).

I would add the @_spi(Internal) marker to that init in UIConfig to allow making this @_spi as well.
There's precedent: in #5208 we added the @_spi(Internal) marker to some existing public subtypes of UIConfig, with no complain from anyone as these APIs are not for public use.

Copy link
Member

Choose a reason for hiding this comment

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

(unresolving to make sure this is addressed)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good point and agree, will make that change :)

let uiConfig: UIConfig?
let config: Config?

public struct Config {
Copy link
Member

Choose a reason for hiding this comment

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

Yep. I think we may as well put it in UIConfig, as one could argue that this affects UI (how prices are presented in the UI).

@rickvdl rickvdl force-pushed the rickvdl/price-formatting-ruleset-currency-symbol-override branch from b020ff5 to b4bdbf3 Compare October 20, 2025 14:42
/// A `NumberFormatter` provider class for prices.
/// This provider caches the formatter to improve the performance.
final class PriceFormatterProvider: Sendable {
public final class PriceFormatterProvider: Sendable {
Copy link
Member

Choose a reason for hiding this comment

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

Also, I would make this PriceFormatterProvider internal (probably we need it to be @_spi(Internal) public). I see that it's needed in the UIConfig's init that is already public (but that init should actually be @_spi(Internal) public instead).

I would add the @_spi(Internal) marker to that init in UIConfig to allow making this @_spi as well.
There's precedent: in #5208 we added the @_spi(Internal) marker to some existing public subtypes of UIConfig, with no complain from anyone as these APIs are not for public use.

@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
public convenience init(sk2Product: SK2Product) {
self.init(SK2StoreProduct(sk2Product: sk2Product))
public convenience init(
Copy link
Member

Choose a reason for hiding this comment

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

Just to be sure: this one should be internal

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Should be done :)

@rickvdl
Copy link
Contributor Author

rickvdl commented Oct 20, 2025

@RCGitBot please test

1 similar comment
@rickvdl
Copy link
Contributor Author

rickvdl commented Oct 21, 2025

@RCGitBot please test

@rickvdl rickvdl force-pushed the rickvdl/price-formatting-ruleset-currency-symbol-override branch from c52ff21 to c112d18 Compare October 21, 2025 08:53
@rickvdl
Copy link
Contributor Author

rickvdl commented Oct 21, 2025

@RCGitBot please test

@rickvdl rickvdl force-pushed the rickvdl/price-formatting-ruleset-currency-symbol-override branch 2 times, most recently from 5d82758 to b316ec3 Compare November 17, 2025 14:06
@rickvdl
Copy link
Contributor Author

rickvdl commented Nov 17, 2025

@RCGitBot please test

1 similar comment
@rickvdl
Copy link
Contributor Author

rickvdl commented Nov 17, 2025

@RCGitBot please test

@rickvdl rickvdl force-pushed the rickvdl/price-formatting-ruleset-currency-symbol-override branch 2 times, most recently from 805ab88 to 21dfe3c Compare November 26, 2025 16:10
@rickvdl
Copy link
Contributor Author

rickvdl commented Nov 26, 2025

@RCGitBot please test

2 similar comments
@rickvdl
Copy link
Contributor Author

rickvdl commented Nov 26, 2025

@RCGitBot please test

@rickvdl
Copy link
Contributor Author

rickvdl commented Nov 27, 2025

@RCGitBot please test

@rickvdl rickvdl force-pushed the rickvdl/price-formatting-ruleset-currency-symbol-override branch from 4eab3f3 to 6fddc7c Compare November 27, 2025 08:16
@rickvdl rickvdl force-pushed the rickvdl/price-formatting-ruleset-currency-symbol-override branch from bbdfc29 to 3308827 Compare December 8, 2025 14:55
… product price formatting using the priceFormattingRuleSets
…ence type and update it's rule set whenever the offerings are received
…se because it also needs to work without paywalls
@rickvdl rickvdl force-pushed the rickvdl/price-formatting-ruleset-currency-symbol-override branch from 3308827 to a2b6935 Compare December 9, 2025 12:37
@rickvdl
Copy link
Contributor Author

rickvdl commented Dec 9, 2025

@RCGitBot please test

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants