Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
96f9cca
PoC: allow overriding currencySymbol from a backend-provided ruleset
rickvdl Oct 15, 2025
1e37f0a
Cleaner way of getting the currency symbol override ruleset for the c…
rickvdl Oct 17, 2025
a877254
Code cleanup
rickvdl Oct 17, 2025
22e900d
Fixed a case where the wrong formatter might be used
rickvdl Oct 17, 2025
346d455
Implemented PriceFormatRuleset for web products as well
rickvdl Oct 17, 2025
13956ae
Added a bunch more unit tests
rickvdl Oct 17, 2025
8fa0af7
Moved the priceFormattingRuleSets from new Config struct to the exist…
rickvdl Oct 20, 2025
ea2cbca
Made PriceFormatterProvider internal
rickvdl Oct 20, 2025
d9bc6aa
Made UIConfig struct internal
rickvdl Oct 20, 2025
ffde29f
Fixed linting issues
rickvdl Oct 20, 2025
a8bdcc0
Moved priceFormattingRuleSet provider logic to ProductsManagerFactory…
rickvdl Oct 21, 2025
51dbd39
Add ProductsManagerFactoryTests
rickvdl Oct 21, 2025
bfb6a67
Fixed few linting issues
rickvdl Nov 14, 2025
7721327
Added an integration test between offerings API -> product manager ->…
rickvdl Nov 26, 2025
9307586
Implemented for SK1StoreProductDiscount as well
rickvdl Nov 17, 2025
23d6c29
Matching CurrencySymbolOverridingPriceFormatter rule matching logic t…
rickvdl Nov 17, 2025
e0cf349
Fixed swiftlint warnings
rickvdl Nov 17, 2025
2bab8e9
Updated approach to use the PriceFormattingRuleSetProvider as a refer…
rickvdl Nov 26, 2025
77b1503
Fixed test compilation
rickvdl Nov 26, 2025
3f1afcb
Moved priceFormattingRuleSets to new Config struct in OfferingsRespon…
rickvdl Nov 27, 2025
f98a3f6
Removed public modifier
rickvdl Nov 27, 2025
ef93bbc
Fix compile issue
rickvdl Nov 27, 2025
c53f1b4
Fixed compilation on tvOS
rickvdl Nov 27, 2025
a47741c
Implemented PR feedback
rickvdl Nov 27, 2025
1ffe2dc
Updated comment
rickvdl Nov 27, 2025
a2b6935
Implemented PR feedback
rickvdl Nov 28, 2025
277ba35
fixed compilation
rickvdl Dec 9, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,11 @@
1D20E1D62EBCF80E00ABE4CD /* HTTPRequestTimeoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D20E1D52EBCF80E00ABE4CD /* HTTPRequestTimeoutManager.swift */; };
1D20E1D82EBCF82900ABE4CD /* HTTPRequestTimeoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D20E1D72EBCF82900ABE4CD /* HTTPRequestTimeoutManager.swift */; };
1D58A16C2ED59AD90086809D /* ConnectionErrorReason.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D58A16B2ED59AD90086809D /* ConnectionErrorReason.swift */; };
1D58B0702ED75A6600BEAC0D /* PriceFormattingRuleSetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D58B06F2ED75A6600BEAC0D /* PriceFormattingRuleSetProvider.swift */; };
1D73D6612EBDD5CB00A9F0F3 /* MockHTTPRequestTimeoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D73D6602EBDD5CB00A9F0F3 /* MockHTTPRequestTimeoutManager.swift */; };
1D73D6622EBDD5CB00A9F0F3 /* MockHTTPRequestTimeoutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D73D6602EBDD5CB00A9F0F3 /* MockHTTPRequestTimeoutManager.swift */; };
1DB9B2A72E57373900252D58 /* OfferingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DB9B2A62E57373600252D58 /* OfferingTests.swift */; };
1DD9061A2ECB7205003E4340 /* PriceFormattingRuleSetIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DD906192ECB7205003E4340 /* PriceFormattingRuleSetIntegrationTests.swift */; };
1DEF758A2ECF0EFE00614CB2 /* CreateTicketView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DEF75892ECF0EFE00614CB2 /* CreateTicketView.swift */; };
1DEF75932ECF130500614CB2 /* EmailValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DEF75922ECF130500614CB2 /* EmailValidator.swift */; };
1DEF759A2ECF139F00614CB2 /* CreateTicketViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DEF75992ECF139F00614CB2 /* CreateTicketViewTests.swift */; };
Expand Down Expand Up @@ -1588,8 +1590,10 @@
1D20E1D52EBCF80E00ABE4CD /* HTTPRequestTimeoutManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPRequestTimeoutManager.swift; sourceTree = "<group>"; };
1D20E1D72EBCF82900ABE4CD /* HTTPRequestTimeoutManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPRequestTimeoutManager.swift; sourceTree = "<group>"; };
1D58A16B2ED59AD90086809D /* ConnectionErrorReason.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectionErrorReason.swift; sourceTree = "<group>"; };
1D58B06F2ED75A6600BEAC0D /* PriceFormattingRuleSetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceFormattingRuleSetProvider.swift; sourceTree = "<group>"; };
1D73D6602EBDD5CB00A9F0F3 /* MockHTTPRequestTimeoutManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockHTTPRequestTimeoutManager.swift; sourceTree = "<group>"; };
1DB9B2A62E57373600252D58 /* OfferingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfferingTests.swift; sourceTree = "<group>"; };
1DD906192ECB7205003E4340 /* PriceFormattingRuleSetIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceFormattingRuleSetIntegrationTests.swift; sourceTree = "<group>"; };
1DEF75892ECF0EFE00614CB2 /* CreateTicketView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateTicketView.swift; sourceTree = "<group>"; };
1DEF75922ECF130500614CB2 /* EmailValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailValidator.swift; sourceTree = "<group>"; };
1DEF75992ECF139F00614CB2 /* CreateTicketViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateTicketViewTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3415,6 +3419,7 @@
2D9C5EC926F2805C0057FC45 /* ProductsManagerTests.swift */,
2D90F8CB26FD2BA1009B9142 /* StoreKitConfigTestCase.swift */,
F55FFA622763F60700995146 /* TransactionsManagerTests.swift */,
1DD906192ECB7205003E4340 /* PriceFormattingRuleSetIntegrationTests.swift */,
5791A1C72767FC9400C972AA /* ManageSubscriptionsHelperTests.swift */,
5738F46D278CAC520096D623 /* StoreTransactionTests.swift */,
2D43017726EBFD7100BAB891 /* UnitTestsConfiguration.storekit */,
Expand Down Expand Up @@ -3870,6 +3875,7 @@
2DDA3E4524DB0B4500EDFE5B /* Misc */ = {
isa = PBXGroup;
children = (
1D58B06F2ED75A6600BEAC0D /* PriceFormattingRuleSetProvider.swift */,
35F38B492C32BC2800CD29FD /* Locale */,
57F3C0CA29B7A08F0004FD7E /* Codable */,
57F3C0CB29B7A0B10004FD7E /* Concurrency */,
Expand Down Expand Up @@ -6697,6 +6703,7 @@
1D73D6612EBDD5CB00A9F0F3 /* MockHTTPRequestTimeoutManager.swift in Sources */,
7571BE9F2D672B2C00A2C8B6 /* TrialOrIntroPriceEligibilityCheckerUIPreviewModeTests.swift in Sources */,
2D90F8C226FD20F7009B9142 /* MockETagManager.swift in Sources */,
1DD9061A2ECB7205003E4340 /* PriceFormattingRuleSetIntegrationTests.swift in Sources */,
2D90F8B526FD2093009B9142 /* MockSystemInfo.swift in Sources */,
2D90F8C126FD20F2009B9142 /* MockHTTPClient.swift in Sources */,
4F9BB6402A7AFB72001C120D /* MockPayment.swift in Sources */,
Expand Down Expand Up @@ -6930,6 +6937,7 @@
75BB98FE2E336C220001DD1A /* ProductsManagerFactory.swift in Sources */,
5766AB4728401B8400FA6091 /* PackageType.swift in Sources */,
35DE0DB62CEF9E8F00EB83E9 /* SubscriptionInfo.swift in Sources */,
1D58B0702ED75A6600BEAC0D /* PriceFormattingRuleSetProvider.swift in Sources */,
B3F3E8DA277158FE0047A5B9 /* DNSChecker.swift in Sources */,
1622D40E2E90189F00C20E3C /* URLWithValidation.swift in Sources */,
1EB697862CD0ED0B003000FC /* WebPurchaseRedemptionResult.swift in Sources */,
Expand Down
200 changes: 167 additions & 33 deletions Sources/Misc/PriceFormatterProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,61 @@ import Foundation
/// This provider caches the formatter to improve the performance.
final class PriceFormatterProvider: Sendable {

private let priceFormattingRuleSetProvider: PriceFormattingRuleSetProvider?

init(priceFormattingRuleSetProvider: PriceFormattingRuleSetProvider? = nil) {
self.priceFormattingRuleSetProvider = priceFormattingRuleSetProvider
}

private let cachedPriceFormatterForSK1: Atomic<NumberFormatter?> = nil

func priceFormatterForSK1(with locale: Locale) -> NumberFormatter {
func makePriceFormatterForSK1(with locale: Locale) -> NumberFormatter {
let formatter = NumberFormatter()
func makePriceFormatterForSK1(
with locale: Locale,
currencySymbolOverride: PriceFormattingRuleSet.CurrencySymbolOverride?
) -> NumberFormatter {
let formatter: NumberFormatter
if let currencySymbolOverride {
formatter = CurrencySymbolOverridingPriceFormatter(
currencySymbolOverride: currencySymbolOverride
)
} else {
formatter = NumberFormatter()
}
formatter.numberStyle = .currency
formatter.locale = locale
return formatter
}

return self.cachedPriceFormatterForSK1.modify { formatter in
guard let formatter = formatter, formatter.locale == locale else {
let newFormatter = makePriceFormatterForSK1(with: locale)
formatter = newFormatter
if let formatter = formatter as? CurrencySymbolOverridingPriceFormatter {
let override = priceFormattingRuleSetProvider?.currencySymbolOverride(for: formatter.currencyCode)
if formatter.locale == locale,
formatter.currencySymbolOverride == override {
return formatter
}
} else if let formatter = formatter, formatter.locale == locale {
return formatter
}

return newFormatter
var newFormatter = makePriceFormatterForSK1(
with: locale,
currencySymbolOverride: nil
)

// If there is a currency symbol override for the currencyCode of the new formatter, use that
if let currencySymbolOverride = priceFormattingRuleSetProvider?.currencySymbolOverride(
for: newFormatter.currencyCode
) {
newFormatter = makePriceFormatterForSK1(
with: locale,
currencySymbolOverride: currencySymbolOverride
)
}

return formatter
formatter = newFormatter

return newFormatter
}
}

Expand All @@ -45,23 +81,16 @@ final class PriceFormatterProvider: Sendable {
withCurrencyCode currencyCode: String,
locale: Locale = .autoupdatingCurrent
) -> NumberFormatter {
func makePriceFormatterForSK2(with currencyCode: String) -> NumberFormatter {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = locale
formatter.currencyCode = currencyCode
return formatter
}

return self.cachedPriceFormatterForSK2.modify { formatter in
guard let formatter = formatter, formatter.currencyCode == currencyCode, formatter.locale == locale else {
let newFormatter = makePriceFormatterForSK2(with: currencyCode)
let newFormatter = createPriceFormatterIfNeeded(
cachedPriceFormatter: formatter,
currencyCode: currencyCode,
locale: locale
)
if newFormatter != formatter {
formatter = newFormatter

return newFormatter
}

return formatter
return newFormatter
}
}

Expand All @@ -71,24 +100,129 @@ final class PriceFormatterProvider: Sendable {
withCurrencyCode currencyCode: String,
locale: Locale = .autoupdatingCurrent
) -> NumberFormatter {
func makePriceFormatterForWebProducts(with currencyCode: String) -> NumberFormatter {
let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.locale = locale
formatter.currencyCode = currencyCode
return formatter
}

return self.cachedPriceFormatterForWebProducts.modify { formatter in
guard let formatter = formatter, formatter.currencyCode == currencyCode, formatter.locale == locale else {
let newFormatter = makePriceFormatterForWebProducts(with: currencyCode)
let newFormatter = createPriceFormatterIfNeeded(
cachedPriceFormatter: formatter,
currencyCode: currencyCode,
locale: locale
)
if newFormatter != formatter {
formatter = newFormatter

return newFormatter
}
return newFormatter
}
}

private func createPriceFormatterIfNeeded(
cachedPriceFormatter: NumberFormatter?,
currencyCode: String,
locale: Locale
) -> NumberFormatter {
let currencySymbolOverride = priceFormattingRuleSetProvider?.currencySymbolOverride(
for: currencyCode
)

if let formatter = cachedPriceFormatter as? CurrencySymbolOverridingPriceFormatter {
if formatter.currencyCode == currencyCode,
formatter.locale == locale,
formatter.currencySymbolOverride == currencySymbolOverride {
return formatter
}
} else if let formatter = cachedPriceFormatter,
formatter.currencyCode == currencyCode,
formatter.locale == locale {
return formatter
}

return makePriceFormatter(
with: currencyCode,
locale: locale,
currencySymbolOverride: currencySymbolOverride
)
}

private func makePriceFormatter(
with currencyCode: String,
locale: Locale,
currencySymbolOverride: PriceFormattingRuleSet.CurrencySymbolOverride?
) -> NumberFormatter {
let formatter: NumberFormatter
if let currencySymbolOverride {
formatter = CurrencySymbolOverridingPriceFormatter(
currencySymbolOverride: currencySymbolOverride
)
} else {
formatter = NumberFormatter()
}
formatter.numberStyle = .currency
formatter.locale = locale
formatter.currencyCode = currencyCode
return formatter
}
}

class CurrencySymbolOverridingPriceFormatter: NumberFormatter, @unchecked Sendable {

let currencySymbolOverride: PriceFormattingRuleSet.CurrencySymbolOverride
private var numberFormatterCache = [PriceFormattingRuleSet.CurrencySymbolOverride.PluralRule: NumberFormatter]()

init(currencySymbolOverride: PriceFormattingRuleSet.CurrencySymbolOverride) {
self.currencySymbolOverride = currencySymbolOverride
super.init()
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

override func string(from number: NSNumber) -> String? {
formatter(for: rule(for: number)).string(from: number)
}

/// Cardinal plural selection per CLDR/ICU baseline:
/// - Non-integers → .other
/// - Integers: 0 → .zero, 1 → .one, 2 → .two, 3...4 → .few, 5...10 → .many, 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 numberValue = value.doubleValue

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

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

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

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

// Integer mapping matching VariableHandlerV2.swift logic:
// 0 → .zero, 1 → .one, 2 → .two, 3...4 → .few, 5...10 → .many, else → .other
switch intValue {
case 0: return .zero
case 1: return .one
case 2: return .two
case 3...4: return .few
case 5...10: return .many
default: return .other
}
}

private func formatter(for rule: PriceFormattingRuleSet.CurrencySymbolOverride.PluralRule) -> NumberFormatter {
if let formatter = numberFormatterCache[rule] {
return formatter
}

let formatter = NumberFormatter()
formatter.numberStyle = numberStyle
formatter.locale = locale
formatter.currencyCode = currencyCode
formatter.currencySymbol = currencySymbolOverride.value(for: rule)
numberFormatterCache[rule] = formatter
return formatter
}
}
34 changes: 34 additions & 0 deletions Sources/Misc/PriceFormattingRuleSetProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
// Licensed under the MIT License (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://opensource.org/licenses/MIT
//
// PriceFormattingRuleSetProvider.swift
//
// Created by Rick van der Linden on 26/11/2025.

// MARK: -
final class PriceFormattingRuleSetProvider: @unchecked Sendable {
private let priceFormattingRuleSet: Atomic<PriceFormattingRuleSet?>

init(priceFormattingRuleSet: PriceFormattingRuleSet?) {
self.priceFormattingRuleSet = Atomic(priceFormattingRuleSet)
}

/// Returns the currency symbol override for the given currency code, if available.
/// - Parameter currencyCode: The ISO currency code (e.g., "RON", "USD")
/// - Returns: The currency symbol override for the currency, or `nil` if not available
func currencySymbolOverride(for currencyCode: String) -> PriceFormattingRuleSet.CurrencySymbolOverride? {
return self.priceFormattingRuleSet.value?.currencySymbolOverride(currencyCode: currencyCode)
}

/// Updates the price formatting rule set.
/// - Parameter ruleSet: The new rule set to use, or `nil` to clear it
func updatePriceFormattingRuleSet(_ ruleSet: PriceFormattingRuleSet?) {
self.priceFormattingRuleSet.value = ruleSet
}
}
Loading