diff --git a/RevenueCatUI/Templates/V2/Variables/VariableHandlerV2.swift b/RevenueCatUI/Templates/V2/Variables/VariableHandlerV2.swift index 9edb23b3e4..17b12c0562 100644 --- a/RevenueCatUI/Templates/V2/Variables/VariableHandlerV2.swift +++ b/RevenueCatUI/Templates/V2/Variables/VariableHandlerV2.swift @@ -742,11 +742,15 @@ extension VariablesV2 { return localizations[VariableLocalizationKey.freePrice.rawValue] ?? "" } + // Format the discount's displayed price string rather than reformatting the + // decimal price through `NumberFormatter`. The formatter can emit a different + // currency token than the displayed price (e.g. "6,99 USD" vs "$6.99") when its + // locale metadata doesn't match the product's currency, which would make the + // offer price inconsistent with `product.price` and `product.currency_symbol`. return formatDiscountPrice( - discount.price, + discount.localizedPriceString, package: package, - showZeroDecimalPlacePrices: offerContext.showZeroDecimalPlacePrices, - fallback: discount.localizedPriceString + showZeroDecimalPlacePrices: offerContext.showZeroDecimalPlacePrices ) } @@ -1090,6 +1094,23 @@ private extension VariablesV2 { ) ?? fallback } + /// Formats an already-displayed discount price string, preserving its currency token. + /// Mirrors the base price path (`Package.localizedPrice`) so the currency token stays + /// consistent across the paywall instead of being re-derived from `NumberFormatter`. + func formatDiscountPrice( + _ priceString: String, + package: Package, + showZeroDecimalPlacePrices: Bool + ) -> String { + guard let formatter = package.storeProduct.priceFormatter else { + return priceString + } + return formatter.formattedPriceStrippingTrailingZerosIfNeeded( + from: priceString, + showZeroDecimalPlacePrices: showZeroDecimalPlacePrices + ) + } + } private extension String { diff --git a/Tests/RevenueCatUITests/PaywallsV2/VariableHandlerV2Tests.swift b/Tests/RevenueCatUITests/PaywallsV2/VariableHandlerV2Tests.swift index c8436f0516..a640aaf795 100644 --- a/Tests/RevenueCatUITests/PaywallsV2/VariableHandlerV2Tests.swift +++ b/Tests/RevenueCatUITests/PaywallsV2/VariableHandlerV2Tests.swift @@ -558,6 +558,50 @@ class VariableHandlerV2Test: TestCase { expect(result).to(equal("$1.99")) } + func testOfferPriceUsesDisplayedPriceCurrencyTokenWhenFormatterLocaleDoesNotMatch() { + // The displayed price uses "$", but the formatter locale (ro_RO) would render + // the currency differently. The offer price should match the displayed price + // token so it stays consistent with product.price and product.currency_symbol + // across the paywall (same divergence fixed for currency_symbol in #6572). + let package = Package( + identifier: PackageType.monthly.identifier, + packageType: .monthly, + storeProduct: TestStoreProduct( + localizedTitle: "Monthly", + price: 6.99, + currencyCode: "USD", + localizedPriceString: "$6.99", + productIdentifier: "com.revenuecat.product.offer_price_regression", + productType: .autoRenewableSubscription, + localizedDescription: "PRO monthly", + subscriptionGroupIdentifier: "group", + subscriptionPeriod: .init(value: 1, unit: .month), + introductoryDiscount: .init( + identifier: "intro", + price: 6.99, + localizedPriceString: "$6.99", + paymentMode: .payUpFront, + subscriptionPeriod: .init(value: 1, unit: .week), + numberOfPeriods: 1, + type: .introductory + ), + locale: Locale(identifier: "ro_RO") + ).toStoreProduct(), + offeringIdentifier: "offering", + webCheckoutUrl: nil + ) + + let result = variableHandler.processVariables( + in: "{{ product.offer_price }}", + with: package, + locale: locale, + localizations: localizations["en_US"]!, + isEligibleForIntroOffer: true + ) + + expect(result).to(equal("$6.99")) + } + func testProductPayUpFrontOfferPricePerDay() { let result = variableHandler.processVariables( in: "{{ product.offer_price_per_day }}",