Skip to content

Commit

Permalink
Support to round+truncate prices displayed on paywalls for backend-sp…
Browse files Browse the repository at this point in the history
…ecified countries (#4132)

## Rounds prices for certain locales.

The server now returns a list of storefront country codes where paywalls
should not display cents. This part of paywall data, in
`zero_decimal_place_countries`.

Prices are only truncated if the price ends in .00.
  • Loading branch information
jamesrb1 authored and nyeu committed Oct 1, 2024
1 parent c0e9944 commit d640f7d
Show file tree
Hide file tree
Showing 49 changed files with 596 additions and 107 deletions.
22 changes: 22 additions & 0 deletions RevenueCatUI/Data/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ enum Strings {
case executing_restore_logic
case executing_external_restore_logic

case no_price_format_price_formatter_unavailable
case no_price_format_price_string_incompatible
case no_price_round_price_formatter_nil
case no_price_round_price_string_incompatible
case no_price_round_formatter_failed

// Customer Center
case could_not_find_subscription_information
case could_not_offer_for_active_subscriptions
Expand Down Expand Up @@ -128,6 +134,21 @@ extension Strings: CustomStringConvertible {
"No StoreKit restore purchases logic will be performed by RevenueCat. " +
"You must have initialized your `PaywallView` appropriately."

case .no_price_format_price_formatter_unavailable:
return "Could not determine price format because price formatter is unavailable."

case .no_price_format_price_string_incompatible:
return "Could not determine price format because price string is incompatible."

case .no_price_round_price_formatter_nil:
return "Could not round price because price formatter is nil."

case .no_price_round_price_string_incompatible:
return "Could not round price because price string is incompatible."

case .no_price_round_formatter_failed:
return "Could not round price because formatter failed to round price."

case .could_not_find_subscription_information:
return "Could not find information for an active subscription"

Expand All @@ -139,6 +160,7 @@ extension Strings: CustomStringConvertible {

case .could_not_offer_for_active_subscriptions:
return "Could not find offer for any active subscription"

}
}

Expand Down
23 changes: 16 additions & 7 deletions RevenueCatUI/Data/TemplateViewConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ struct TemplateViewConfiguration {
let colorsByTier: [PaywallData.Tier: PaywallData.Configuration.Colors]
let fonts: PaywallFontProvider
let assetBaseURL: URL
let showZeroDecimalPlacePrices: Bool

}

Expand Down Expand Up @@ -162,7 +163,8 @@ extension TemplateViewConfiguration.PackageConfiguration {
localizationByTier: [String: PaywallData.LocalizedConfiguration]?,
tiers: [PaywallData.Tier],
setting: TemplatePackageSetting,
locale: Locale = .current
locale: Locale = .current,
showZeroDecimalPlacePrices: Bool = false
) throws -> Self {
let parameters: Parameters

Expand All @@ -189,7 +191,8 @@ extension TemplateViewConfiguration.PackageConfiguration {
with: packages,
activelySubscribedProductIdentifiers: activelySubscribedProductIdentifiers,
parameters: parameters,
locale: locale
locale: locale,
showZeroDecimalPlacePrices: showZeroDecimalPlacePrices
)
}

Expand All @@ -200,7 +203,8 @@ extension TemplateViewConfiguration.PackageConfiguration {
with packages: [RevenueCat.Package],
activelySubscribedProductIdentifiers: Set<String>,
parameters: Parameters,
locale: Locale
locale: Locale,
showZeroDecimalPlacePrices: Bool
) throws -> Self {
switch parameters {
case let .singleTier(filter, `default`, localization, multiPackage):
Expand All @@ -209,7 +213,8 @@ extension TemplateViewConfiguration.PackageConfiguration {
filter: filter,
activelySubscribedProductIdentifiers: activelySubscribedProductIdentifiers,
localization: localization,
locale: locale
locale: locale,
showZeroDecimalPlacePrices: showZeroDecimalPlacePrices
)

guard let firstPackage = filteredPackages.first else {
Expand Down Expand Up @@ -244,7 +249,8 @@ extension TemplateViewConfiguration.PackageConfiguration {
filter: tier.packages,
activelySubscribedProductIdentifiers: activelySubscribedProductIdentifiers,
localization: localization,
locale: locale
locale: locale,
showZeroDecimalPlacePrices: showZeroDecimalPlacePrices
)

guard let firstPackage = filteredPackages.first else {
Expand Down Expand Up @@ -282,12 +288,14 @@ extension TemplateViewConfiguration.PackageConfiguration {
}
}

// swiftlint:disable:next function_parameter_count
private static func processPackages(
from packages: [RevenueCat.Package],
filter: [String],
activelySubscribedProductIdentifiers: Set<String>,
localization: PaywallData.LocalizedConfiguration,
locale: Locale
locale: Locale,
showZeroDecimalPlacePrices: Bool
) -> [TemplateViewConfiguration.Package] {
let filtered = TemplateViewConfiguration.filter(packages: packages, with: filter)
let mostExpensivePricePerMonth = Self.mostExpensivePricePerMonth(in: filtered)
Expand All @@ -303,7 +311,8 @@ extension TemplateViewConfiguration.PackageConfiguration {
content: package,
localization: localization.processVariables(
with: package,
context: .init(discountRelativeToMostExpensivePerMonth: discount),
context: .init(discountRelativeToMostExpensivePerMonth: discount,
showZeroDecimalPlacePrices: showZeroDecimalPlacePrices),
locale: locale
),
currentlySubscribed: activelySubscribedProductIdentifiers.contains(
Expand Down
62 changes: 62 additions & 0 deletions RevenueCatUI/Data/TestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,18 @@ enum TestData {
introductoryDiscount: Self.intro(7, .day),
locale: Self.locale
)
static let threeMonthProductThailand = TestStoreProduct(
localizedTitle: "3 months",
price: 5.00,
localizedPriceString: "฿5.00",
productIdentifier: "com.revenuecat.product_5",
productType: .autoRenewableSubscription,
localizedDescription: "PRO monthly",
subscriptionGroupIdentifier: "group",
subscriptionPeriod: .init(value: 3, unit: .month),
introductoryDiscount: Self.intro(7, .day),
locale: Locale.thailand
)
static let sixMonthProduct = TestStoreProduct(
localizedTitle: "6 months",
price: 7.99,
Expand All @@ -103,6 +115,30 @@ enum TestData {
introductoryDiscount: Self.intro(14, .day, priceString: "$1.99"),
locale: Self.locale
)
static let annualProduct60 = TestStoreProduct(
localizedTitle: "Annual",
price: 60.00,
localizedPriceString: "$60.00",
productIdentifier: "com.revenuecat.product_3",
productType: .autoRenewableSubscription,
localizedDescription: "PRO annual",
subscriptionGroupIdentifier: "group",
subscriptionPeriod: .init(value: 1, unit: .year),
introductoryDiscount: Self.intro(14, .day, priceString: "$2.99"),
locale: Self.locale
)
static let annualProduct60Taiwan = TestStoreProduct(
localizedTitle: "Annual",
price: 60.00,
localizedPriceString: "$60.00",
productIdentifier: "com.revenuecat.product_3",
productType: .autoRenewableSubscription,
localizedDescription: "PRO annual",
subscriptionGroupIdentifier: "group",
subscriptionPeriod: .init(value: 1, unit: .year),
introductoryDiscount: Self.intro(14, .day, priceString: "$2.99"),
locale: Locale.taiwan
)
static let lifetimeProduct = TestStoreProduct(
localizedTitle: "Lifetime",
price: 119.49,
Expand Down Expand Up @@ -136,6 +172,13 @@ enum TestData {
offeringIdentifier: Self.offeringIdentifier
)
// @PublicForExternalTesting
static let threeMonthPackageThailand = Package(
identifier: PackageType.threeMonth.identifier,
packageType: .threeMonth,
storeProduct: Self.threeMonthProductThailand.toStoreProduct(),
offeringIdentifier: Self.offeringIdentifier
)
// @PublicForExternalTesting
static let sixMonthPackage = Package(
identifier: PackageType.sixMonth.identifier,
packageType: .sixMonth,
Expand All @@ -150,6 +193,20 @@ enum TestData {
offeringIdentifier: Self.offeringIdentifier
)
// @PublicForExternalTesting
static let annualPackage60 = Package(
identifier: PackageType.annual.identifier,
packageType: .annual,
storeProduct: Self.annualProduct60.toStoreProduct(),
offeringIdentifier: Self.offeringIdentifier
)
// @PublicForExternalTesting
static let annualPackage60Taiwan = Package(
identifier: PackageType.annual.identifier,
packageType: .annual,
storeProduct: Self.annualProduct60Taiwan.toStoreProduct(),
offeringIdentifier: Self.offeringIdentifier
)
// @PublicForExternalTesting
static let lifetimePackage = Package(
identifier: PackageType.lifetime.identifier,
packageType: .lifetime,
Expand Down Expand Up @@ -795,3 +852,8 @@ extension CustomerInfo {
}

}

extension Locale {
static let taiwan = Locale(identifier: "zh_TW")
static let thailand = Locale(identifier: "th_TH")
}
51 changes: 34 additions & 17 deletions RevenueCatUI/Data/Variables.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,13 @@ extension PaywallData.LocalizedConfiguration {
}

/// A type that can provide necessary information for `VariableHandler` to replace variable content in strings.
@available(iOS 15.0, *)
protocol VariableDataProvider {

var applicationName: String { get }

var packageIdentifier: String { get }

var localizedPrice: String { get }
var localizedPricePerWeek: String { get }
var localizedPricePerMonth: String { get }
var localizedIntroductoryOfferPrice: String? { get }
var productName: String { get }

func periodNameOrIdentifier(_ locale: Locale) -> String
Expand All @@ -46,10 +43,14 @@ protocol VariableDataProvider {
func normalizedSubscriptionDuration(_ locale: Locale) -> String?
func introductoryOfferDuration(_ locale: Locale) -> String?

func localizedPricePerPeriod(_ locale: Locale) -> String
func localizedPricePerPeriodFull(_ locale: Locale) -> String
func localizedPriceAndPerMonth(_ locale: Locale) -> String
func localizedPriceAndPerMonthFull(_ locale: Locale) -> String
func localizedIntroductoryOfferPrice(showZeroDecimalPlacePrices: Bool) -> String?
func localizedPricePerWeek(showZeroDecimalPlacePrices: Bool) -> String
func localizedPricePerMonth(showZeroDecimalPlacePrices: Bool) -> String
func localizedPrice(showZeroDecimalPlacePrices: Bool) -> String
func localizedPricePerPeriod(_ locale: Locale, showZeroDecimalPlacePrices: Bool) -> String
func localizedPricePerPeriodFull(_ locale: Locale, showZeroDecimalPlacePrices: Bool) -> String
func localizedPriceAndPerMonth(_ locale: Locale, showZeroDecimalPlacePrices: Bool) -> String
func localizedPriceAndPerMonthFull(_ locale: Locale, showZeroDecimalPlacePrices: Bool) -> String
func localizedRelativeDiscount(_ discount: Double?, _ locale: Locale) -> String?

}
Expand All @@ -62,6 +63,7 @@ enum VariableHandler {
struct Context {

var discountRelativeToMostExpensivePerMonth: Double?
var showZeroDecimalPlacePrices: Bool = false

}

Expand Down Expand Up @@ -100,25 +102,40 @@ enum VariableHandler {
fileprivate static func provider(for variableName: String) -> ValueProvider? {
switch variableName {
case "app_name": return { (provider, _, _) in provider.applicationName }
case "price": return { (provider, _, _) in provider.localizedPrice }
case "price_per_period": return { (provider, _, locale) in provider.localizedPricePerPeriod(locale) }
case "price_per_period_full": return { (provider, _, locale) in provider.localizedPricePerPeriodFull(locale) }
case "total_price_and_per_month": return { (provider, _, locale) in provider.localizedPriceAndPerMonth(locale) }
case "total_price_and_per_month_full": return { (provider, _, locale) in
provider.localizedPriceAndPerMonthFull(locale)
case "price": return { (provider, context, _) in
provider.localizedPrice(showZeroDecimalPlacePrices: context.showZeroDecimalPlacePrices)
}
case "price_per_period": return { (provider, context, locale) in
provider.localizedPricePerPeriod(locale, showZeroDecimalPlacePrices: context.showZeroDecimalPlacePrices)
}
case "price_per_period_full": return { (provider, context, locale) in
provider.localizedPricePerPeriodFull(locale, showZeroDecimalPlacePrices: context.showZeroDecimalPlacePrices)
}
case "total_price_and_per_month": return { (provider, context, locale) in
provider.localizedPriceAndPerMonth(locale, showZeroDecimalPlacePrices: context.showZeroDecimalPlacePrices)
}
case "total_price_and_per_month_full": return { (provider, context, locale) in
provider
.localizedPriceAndPerMonthFull(locale, showZeroDecimalPlacePrices: context.showZeroDecimalPlacePrices)
}
case "product_name": return { (provider, _, _) in provider.productName }
case "sub_period": return { (provider, _, locale) in provider.periodNameOrIdentifier(locale) }
case "sub_period_length": return { (provider, _, locale) in provider.periodLength(locale) }
case "sub_period_abbreviated": return { (provider, _, locale) in provider.periodNameAbbreviation(locale) }
case "sub_price_per_month": return { (provider, _, _) in provider.localizedPricePerMonth }
case "sub_price_per_week": return { (provider, _, _) in provider.localizedPricePerWeek }
case "sub_price_per_month": return { (provider, context, _) in
provider.localizedPricePerMonth(showZeroDecimalPlacePrices: context.showZeroDecimalPlacePrices)
}
case "sub_price_per_week": return { (provider, context, _) in
provider.localizedPricePerWeek(showZeroDecimalPlacePrices: context.showZeroDecimalPlacePrices)
}
case "sub_duration": return { (provider, _, locale) in provider.subscriptionDuration(locale) }
case "sub_duration_in_months": return { (provider, _, locale) in
provider.normalizedSubscriptionDuration(locale)
}
case "sub_offer_duration": return { (provider, _, locale) in provider.introductoryOfferDuration(locale) }
case "sub_offer_price": return { (provider, _, _) in provider.localizedIntroductoryOfferPrice }
case "sub_offer_price": return { (provider, context, _) in
provider.localizedIntroductoryOfferPrice(showZeroDecimalPlacePrices: context.showZeroDecimalPlacePrices)
}
case "sub_relative_discount": return { $0.localizedRelativeDiscount($1.discountRelativeToMostExpensivePerMonth,
$2) }

Expand Down
Loading

0 comments on commit d640f7d

Please sign in to comment.