From a5afdae205c18609fec10ddefe2bb6ac61c0b128 Mon Sep 17 00:00:00 2001 From: Josh Holtz Date: Sun, 5 Jan 2025 23:34:29 -0600 Subject: [PATCH] This mostly works --- RevenueCat.xcodeproj/project.pbxproj | 24 + .../Package+VariableDataProvider.swift | 14 + .../V2/Variables/VariableHandlerV2.swift | 518 ++++++++++++++++++ .../StoreKitAbstractions/StoreProduct.swift | 17 + .../SubscriptionPeriod.swift | 31 ++ .../Test Data/TestStoreProduct.swift | 1 + .../PaywallsV2/VariableHandlerV2Tests.swift | 409 ++++++++++++++ 7 files changed, 1014 insertions(+) create mode 100644 RevenueCatUI/Templates/V2/Variables/VariableHandlerV2.swift create mode 100644 Tests/RevenueCatUITests/PaywallsV2/VariableHandlerV2Tests.swift diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index e5b2c48072..ccfc6be51d 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 030890812D2B764D0069677B /* VariableHandlerV2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030890802D2B76450069677B /* VariableHandlerV2.swift */; }; + 030890842D2B77E70069677B /* VariableHandlerV2Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 030890832D2B77E20069677B /* VariableHandlerV2Tests.swift */; }; 0313FD41268A506400168386 /* DateProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0313FD40268A506400168386 /* DateProvider.swift */; }; 03A98CEF2D1EE048009BCA61 /* FallbackComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A98CEE2D1EE040009BCA61 /* FallbackComponentTests.swift */; }; 03A98CF12D222F5F009BCA61 /* FallbackComponentPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A98CF02D222F53009BCA61 /* FallbackComponentPreview.swift */; }; @@ -1236,6 +1238,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 030890802D2B76450069677B /* VariableHandlerV2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariableHandlerV2.swift; sourceTree = ""; }; + 030890832D2B77E20069677B /* VariableHandlerV2Tests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VariableHandlerV2Tests.swift; sourceTree = ""; }; 0313FD40268A506400168386 /* DateProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateProvider.swift; sourceTree = ""; }; 03A98CEE2D1EE040009BCA61 /* FallbackComponentTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackComponentTests.swift; sourceTree = ""; }; 03A98CF02D222F53009BCA61 /* FallbackComponentPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FallbackComponentPreview.swift; sourceTree = ""; }; @@ -2440,6 +2444,22 @@ path = StoreKit2; sourceTree = ""; }; + 0308907F2D2B76340069677B /* Variables */ = { + isa = PBXGroup; + children = ( + 030890802D2B76450069677B /* VariableHandlerV2.swift */, + ); + path = Variables; + sourceTree = ""; + }; + 030890822D2B77DD0069677B /* PaywallsV2 */ = { + isa = PBXGroup; + children = ( + 030890832D2B77E20069677B /* VariableHandlerV2Tests.swift */, + ); + path = PaywallsV2; + sourceTree = ""; + }; 03A98D2E2D240C2D009BCA61 /* RevenueCatUI */ = { isa = PBXGroup; children = ( @@ -4711,6 +4731,7 @@ 887A62242C1D168B00E1A461 /* RevenueCatUITests */ = { isa = PBXGroup; children = ( + 030890822D2B77DD0069677B /* PaywallsV2 */, 3544DA6B2C2C848E00704E9D /* CustomerCenter */, 887A612D2C1D168B00E1A461 /* Data */, 887A61362C1D168B00E1A461 /* Helpers */, @@ -4752,6 +4773,7 @@ children = ( 88B1BAE32C813A3C001B7EE5 /* PaywallsV2View.swift */, 2C7457492CEA6B06004ACE52 /* EnvironmentObjects */, + 0308907F2D2B76340069677B /* Variables */, 2C7457442CEA652B004ACE52 /* ViewHelpers */, 2C7457452CEA653A004ACE52 /* ViewModelHelpers */, 2C7457432CEA6470004ACE52 /* Components */, @@ -6647,6 +6669,7 @@ 887A60842C1D037000E1A461 /* ConsistentPackageContentView.swift in Sources */, 887A60C42C1D037000E1A461 /* IconView.swift in Sources */, 887A60732C1D037000E1A461 /* ProcessedLocalizedConfiguration.swift in Sources */, + 030890812D2B764D0069677B /* VariableHandlerV2.swift in Sources */, 887A606F2C1D037000E1A461 /* PaywallData+Validation.swift in Sources */, 88A543DF2C37A45B0039C6A5 /* TemplatePackageSetting.swift in Sources */, 3546355D2C391F38001D7E85 /* PromotionalOfferViewModel.swift in Sources */, @@ -6694,6 +6717,7 @@ 887A63412C1D177800E1A461 /* BaseSnapshotTest.swift in Sources */, 887A63422C1D177800E1A461 /* ImageLoaderTests.swift in Sources */, 887A63432C1D177800E1A461 /* LocalizationTests.swift in Sources */, + 030890842D2B77E70069677B /* VariableHandlerV2Tests.swift in Sources */, FD6186542D1393FA007843DA /* MockCustomerCenterStoreKitUtilities.swift in Sources */, 887A63442C1D177800E1A461 /* PaywallFooterTests.swift in Sources */, 887A63452C1D177800E1A461 /* PaywallViewEventsTests.swift in Sources */, diff --git a/RevenueCatUI/Helpers/Package+VariableDataProvider.swift b/RevenueCatUI/Helpers/Package+VariableDataProvider.swift index 52613348bb..675cd4d4e4 100644 --- a/RevenueCatUI/Helpers/Package+VariableDataProvider.swift +++ b/RevenueCatUI/Helpers/Package+VariableDataProvider.swift @@ -30,6 +30,20 @@ extension Package: VariableDataProvider { return self.storeProduct.localizedPriceString } } + + func localizedPricePerDay(showZeroDecimalPlacePrices: Bool = false) -> String { + guard let price = self.storeProduct.localizedPricePerDay else { + Logger.warning(Strings.package_not_subscription(self)) + return self.storeProduct.localizedPriceString + } + + if showZeroDecimalPlacePrices && isPriceEndingIn00Cents(price) { + return formatAsZeroDecimalPlaces(price) + } else { + return price + } + + } func localizedPricePerWeek(showZeroDecimalPlacePrices: Bool = false) -> String { guard let price = self.storeProduct.localizedPricePerWeek else { diff --git a/RevenueCatUI/Templates/V2/Variables/VariableHandlerV2.swift b/RevenueCatUI/Templates/V2/Variables/VariableHandlerV2.swift new file mode 100644 index 0000000000..0d5132b428 --- /dev/null +++ b/RevenueCatUI/Templates/V2/Variables/VariableHandlerV2.swift @@ -0,0 +1,518 @@ +// +// 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 +// +// VariableHandlerV2.swift +// +// Created by Josh Holtz on 1/5/25. + +import Foundation +import RevenueCat + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +struct VariableHandlerV2 { + + let discountRelativeToMostExpensivePerMonth: Double? + let showZeroDecimalPlacePrices: Bool + + func processVariables( + in text: String, + with package: Package, + locale: Locale, + localizations: [String: String] + ) -> String { + let whisker = Whisker(template: text) { variableRaw, functionRaw in + let variable = VariablesV2(rawValue: variableRaw) + let function = functionRaw.flatMap { FunctionsV2(rawValue: $0) } + + let processedVariable = variable?.process(package: package, locale: locale, localizations: localizations, discountRelativeToMostExpensivePerMonth: self.discountRelativeToMostExpensivePerMonth) + + return processedVariable.flatMap { + function?.process($0) ?? $0 + } + } + + return whisker.render() + } + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum VariablesV2: String { + + case productCurrencyCode = "product.currency_code" + case productCurrencySymbol = "product.currency_symbol" + case productPeriodly = "product.periodly" + case productPrice = "product.price" + case productPricePerPeriod = "product.price_per_period" + case productPricePerPeriodAbbreviated = "product.price_per_period_abbreviated" + case productPricePerDay = "product.price_per_day" + case productPricePerWeek = "product.price_per_week" + case productPricePerMonth = "product.price_per_month" + case productPricePerYear = "product.price_per_year" + case productPeriod = "product.period" + case productPeriodAbbreviated = "product.period_abbreviated" + case productPeriodInDays = "product.period_in_days" + case productPeriodInWeeks = "product.period_in_weeks" + case productPeriodInMonths = "product.period_in_months" + case productPeriodInYears = "product.period_in_years" + case productPeriodWithUnit = "product.period_with_unit" + case productOfferPrice = "product.offer_price" + case productOfferPricePerDay = "product.offer_price_per_day" + case productOfferPricePerWeek = "product.offer_price_per_week" + case productOfferPricePerMonth = "product.offer_price_per_month" + case productOfferPricePerYear = "product.offer_price_per_year" + case productOfferPeriod = "product.offer_period" + case productOfferPeriodAbbreviated = "product.offer_period_abbreviated" + case productOfferPeriodInDays = "product.offer_period_in_days" + case productOfferPeriodInWeeks = "product.offer_period_in_weeks" + case productOfferPeriodInMonths = "product.offer_period_in_months" + case productOfferPeriodInYears = "product.offer_period_in_years" + case productOfferPeriodWithUnit = "product.offer_period_with_unit" + case productOfferEndDate = "product.offer_end_date" + case productSecondaryOfferPrice = "product.secondary_offer_price" + case productSecondaryOfferPeriod = "product.secondary_offer_period" + case productSecondaryOfferPeriodAbbreviated = "product.secondary_offer_period_abbreviated" + case productRelativeDiscount = "product.relative_discount" + +} + + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +enum FunctionsV2: String { + + case lowercase + case uppercase + case capitalize + +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension VariablesV2 { + + func process(package: Package, locale: Locale, localizations: [String: String], discountRelativeToMostExpensivePerMonth: Double?) -> String { + switch self { + case .productCurrencyCode: + return self.productCurrencyCode(package: package) + case .productCurrencySymbol: + return self.productCurrencySymbol(locale: locale) + case .productPeriodly: + return self.productPeriodly(package: package, localizations: localizations) + case .productPrice: + return self.productPrice(package: package) + case .productPricePerPeriod: + return self.productPricePerPeriod(package: package, localizations: localizations) + case .productPricePerPeriodAbbreviated: + return self.productPricePerPeriodAbbreviated(package: package, localizations: localizations) + case .productPricePerDay: + return self.productPricePerDay(package: package) + case .productPricePerWeek: + return self.productPricePerWeek(package: package) + case .productPricePerMonth: + return self.productPricePerMonth(package: package) + case .productPricePerYear: + return self.productPricePerYear(package: package) + case .productPeriod: + return self.productPeriod(package: package, localizations: localizations) + case .productPeriodAbbreviated: + return self.productPeriodAbbreviated(package: package, localizations: localizations) + case .productPeriodInDays: + return self.productPeriodInDays(package: package) + case .productPeriodInWeeks: + return self.productPeriodInWeeks(package: package) + case .productPeriodInMonths: + return self.productPeriodInMonths(package: package) + case .productPeriodInYears: + return self.productPeriodInYears(package: package) + case .productPeriodWithUnit: + return self.productPeriodWithUnit(package: package, localizations: localizations) + case .productOfferPrice: + return self.productOfferPrice(package: package) + case .productOfferPricePerDay: + return self.productOfferPricePerDay(package: package) + case .productOfferPricePerWeek: + return self.productOfferPricePerWeek(package: package) + case .productOfferPricePerMonth: + return self.productOfferPricePerMonth(package: package) + case .productOfferPricePerYear: + return self.productOfferPricePerYear(package: package) + case .productOfferPeriod: + return self.productOfferPeriod(package: package, localizations: localizations) + case .productOfferPeriodAbbreviated: + return self.productOfferPeriodAbbreviated(package: package, localizations: localizations) + case .productOfferPeriodInDays: + return self.productOfferPeriodInDays(package: package) + case .productOfferPeriodInWeeks: + return self.productOfferPeriodInWeeks(package: package) + case .productOfferPeriodInMonths: + return self.productOfferPeriodInMonths(package: package) + case .productOfferPeriodInYears: + return self.productOfferPeriodInYears(package: package) + case .productOfferPeriodWithUnit: + return self.productOfferPeriodWithUnit(package: package, localizations: localizations) + case .productOfferEndDate: + return self.productOfferEndDate(package: package) + case .productSecondaryOfferPrice: + return self.productSecondaryOfferPrice(package: package) + case .productSecondaryOfferPeriod: + return self.productSecondaryOfferPeriod(package: package) + case .productSecondaryOfferPeriodAbbreviated: + return self.productSecondaryOfferPeriodAbbreviated(package: package) + case .productRelativeDiscount: + return self.productRelativeDiscount(discountRelativeToMostExpensivePerMonth: discountRelativeToMostExpensivePerMonth, localizations: localizations) + } + } + + func productCurrencyCode(package: Package) -> String { + return package.storeProduct.currencyCode ?? "" + } + + func productCurrencySymbol(locale: Locale) -> String { + return locale.currencySymbol ?? "" + } + + func productPrice(package: Package) -> String { + return package.storeProduct.localizedPriceString + } + + func productPricePerPeriod(package: Package, localizations: [String: String]) -> String { + let price = package.storeProduct.localizedPriceString + let period = self.productPeriod(package: package, localizations: localizations) + + return "\(price)/\(period)" + } + + func productPricePerPeriodAbbreviated(package: Package, localizations: [String: String]) -> String { + let price = package.storeProduct.localizedPriceString + let periodAbbreviated = self.productPeriodAbbreviated(package: package, localizations: localizations) + + return "\(price)/\(periodAbbreviated)" + } + + func productPeriodly(package: Package, localizations: [String: String]) -> String { + guard let period = package.storeProduct.subscriptionPeriod else { + return "" + } + + let value: String + switch period.unit { + case .day: + value = "daily" + case .week: + value = "weekly" + case .month: + value = "monthly" + case .year: + value = "yearly" + } + + return localizations[value] ?? "" + } + + func productPricePerDay(package: Package) -> String { + return package.storeProduct.localizedPricePerDay ?? "" + } + + func productPricePerWeek(package: Package) -> String { + return package.storeProduct.localizedPricePerWeek ?? "" + } + + func productPricePerMonth(package: Package) -> String { + return package.storeProduct.localizedPricePerMonth ?? "" + } + + func productPricePerYear(package: Package) -> String { + return package.storeProduct.localizedPricePerYear ?? "" + } + + func productPeriod(package: Package, localizations: [String: String]) -> String { + guard let period = package.storeProduct.subscriptionPeriod else { + return "" + } + + return localizations[period.periodLocalizationKey] ?? "" + } + + func productPeriodAbbreviated(package: Package, localizations: [String: String]) -> String { + guard let period = package.storeProduct.subscriptionPeriod else { + return "" + } + + return localizations[period.periodAbbreviatedLocalizationKey] ?? "" + } + + func productPeriodInDays(package: Package) -> String { + guard let period = package.storeProduct.subscriptionPeriod else { + return "" + } + + return "\(period.periodInUnit(unit: .day))" + } + + func productPeriodInWeeks(package: Package) -> String { + guard let period = package.storeProduct.subscriptionPeriod else { + return "" + } + + let thing = period.numberOfUnitsPer(unit: .week) + print("thing: \(thing)") + + return "\(period.periodInUnit(unit: .week))" + } + + func productPeriodInMonths(package: Package) -> String { + guard let period = package.storeProduct.subscriptionPeriod else { + return "" + } + + return "\(period.periodInUnit(unit: .month))" + } + + func productPeriodInYears(package: Package) -> String { + guard let period = package.storeProduct.subscriptionPeriod else { + return "" + } + + return "\(period.periodInUnit(unit: .year))" + } + + func productPeriodWithUnit(package: Package, localizations: [String: String]) -> String { + guard let period = package.storeProduct.subscriptionPeriod else { + return "" + } + + guard let localizedFormat = localizations[period.unitPeriodLocalizationKey] else { + return "" + } + + return String(format: localizedFormat, period.value) + } + + func productOfferPrice(package: Package) -> String { + return package.storeProduct.introductoryDiscount?.localizedPriceString ?? "" + } + + func productOfferPricePerDay(package: Package) -> String { + return "" + } + + func productOfferPricePerWeek(package: Package) -> String { + return "" + } + + func productOfferPricePerMonth(package: Package) -> String { + return "" + } + + func productOfferPricePerYear(package: Package) -> String { + return "" + } + + func productOfferPeriod(package: Package, localizations: [String: String]) -> String { + guard let period = package.storeProduct.introductoryDiscount?.subscriptionPeriod else { + return "" + } + + return localizations[period.periodLocalizationKey] ?? "" + } + + func productOfferPeriodAbbreviated(package: Package, localizations: [String: String]) -> String { + guard let period = package.storeProduct.introductoryDiscount?.subscriptionPeriod else { + return "" + } + + return localizations[period.periodAbbreviatedLocalizationKey] ?? "" + } + + func productOfferPeriodInDays(package: Package) -> String { + guard let period = package.storeProduct.introductoryDiscount?.subscriptionPeriod else { + return "" + } + + return "\(period.periodInUnit(unit: .day))" + } + + func productOfferPeriodInWeeks(package: Package) -> String { + guard let period = package.storeProduct.introductoryDiscount?.subscriptionPeriod else { + return "" + } + + return "\(period.periodInUnit(unit: .week))" + } + + func productOfferPeriodInMonths(package: Package) -> String { + guard let period = package.storeProduct.introductoryDiscount?.subscriptionPeriod else { + return "" + } + + return "\(period.periodInUnit(unit: .month))" + } + + func productOfferPeriodInYears(package: Package) -> String { + guard let period = package.storeProduct.introductoryDiscount?.subscriptionPeriod else { + return "" + } + + return "\(period.periodInUnit(unit: .year))" + } + + func productOfferPeriodWithUnit(package: Package, localizations: [String: String]) -> String { + guard let period = package.storeProduct.introductoryDiscount?.subscriptionPeriod else { + return "" + } + + guard let localizedFormat = localizations[period.unitPeriodLocalizationKey] else { + return "" + } + + return String(format: localizedFormat, period.value) + } + + func productOfferEndDate(package: Package) -> String { + return "" + } + + func productSecondaryOfferPrice(package: Package) -> String { + // Not implemented on this platform + return "" + } + + func productSecondaryOfferPeriod(package: Package) -> String { + // Not implemented on this platform + return "" + } + + func productSecondaryOfferPeriodAbbreviated(package: Package) -> String { + // Not implemented on this platform + return "" + } + + func productRelativeDiscount(discountRelativeToMostExpensivePerMonth: Double?, localizations: [String: String]) -> String { + guard let discountRelativeToMostExpensivePerMonth else { + return "" + } + + guard let localizedFormat = localizations["%d%%"] else { + return "" + } + + let percent = Int(discountRelativeToMostExpensivePerMonth * 100) + return String(format: localizedFormat, percent) + } + +} + +extension SubscriptionPeriod { + + var periodLocalizationKey: String { + switch self.unit { + case .day: + return "day" + case .week: + return "week" + case .month: + return "month" + case .year: + return "year" + } + } + + var periodAbbreviatedLocalizationKey: String { + switch self.unit { + case .day: + return "d" + case .week: + return "wk" + case .month: + return "mo" + case .year: + return "yr" + } + } + + var unitPeriodLocalizationKey: String { + if self.value == 1 { + return "%d \(periodLocalizationKey)" + } else { + return "%d \(periodLocalizationKey)s" + } + } + + func periodInUnit(unit: SubscriptionPeriod.Unit) -> Int { + return NSDecimalNumber( + decimal: self.numberOfUnitsPer(unit: unit) + ).rounding(accordingToBehavior: nil).intValue + } + +} + +extension Locale { + func currencySymbol(forCurrencyCode currencyCode: String) -> String? { + let localeIdentifier = Locale.identifier(fromComponents: [NSLocale.Key.currencyCode.rawValue: currencyCode]) + return Locale(identifier: localeIdentifier).currencySymbol + } +} + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +extension FunctionsV2 { + + func process(_ text: String) -> String { + switch self { + case .lowercase: + return lowercase(text) + case .uppercase: + return uppercase(text) + case .capitalize: + return capitalize(text) + } + } + + func lowercase(_ text: String) -> String { + return text.lowercased() + } + + func uppercase(_ text: String) -> String { + return text.uppercased() + } + + func capitalize(_ text: String) -> String { + return text.capitalized + } + +} + +struct Whisker { + let template: String + let resolve: (String, String?) -> String? + + func render() -> String { + let regex = try! NSRegularExpression(pattern: "\\{\\{\\s*(.*?)\\s*\\}\\}") + var result = template + let matches = regex.matches(in: template, range: NSRange(template.startIndex..., in: template)) + + for match in matches.reversed() { + guard let range = Range(match.range(at: 1), in: template) else { continue } + let expression = String(template[range]) + + // Split the expression into variable and filter parts + let parts = expression.split(separator: "|").map { $0.trimmingCharacters(in: .whitespaces) } + let variable = parts[0] + let filter = parts.count > 1 ? parts[1] : nil + + // Use the single callback to resolve the variable and apply the filter + if let resolvedValue = resolve(variable, filter) { + // Replace the full match range in the result + if let fullRange = Range(match.range, in: result) { + result.replaceSubrange(fullRange, with: "\(resolvedValue)") + } + } + } + + return result + } +} diff --git a/Sources/Purchasing/StoreKitAbstractions/StoreProduct.swift b/Sources/Purchasing/StoreKitAbstractions/StoreProduct.swift index bd04c91efd..ed1e3f3ae6 100644 --- a/Sources/Purchasing/StoreKitAbstractions/StoreProduct.swift +++ b/Sources/Purchasing/StoreKitAbstractions/StoreProduct.swift @@ -206,6 +206,13 @@ public extension StoreProduct { @objc(price) var priceDecimalNumber: NSDecimalNumber { return self.price as NSDecimalNumber } + + /// Calculates the price of this subscription product per day. + /// - Returns: `nil` if the product is not a subscription. + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var pricePerDay: NSDecimalNumber? { + return self.subscriptionPeriod?.pricePerDay(withTotalPrice: self.price) as NSDecimalNumber? + } /// Calculates the price of this subscription product per week. /// - Returns: `nil` if the product is not a subscription. @@ -235,6 +242,16 @@ public extension StoreProduct { return self.formattedString(for: self.introductoryDiscount?.priceDecimalNumber) } + /// The formatted price per week using ``StoreProduct/priceFormatter``. + /// ### Related Symbols + /// - ``pricePerWeek`` + /// - ``localizedPricePerMonth`` + /// - ``localizedPricePerYear`` + @available(iOS 11.2, macOS 10.13.2, tvOS 11.2, watchOS 6.2, *) + @objc var localizedPricePerDay: String? { + return self.formattedString(for: self.pricePerDay) + } + /// The formatted price per week using ``StoreProduct/priceFormatter``. /// ### Related Symbols /// - ``pricePerWeek`` diff --git a/Sources/Purchasing/StoreKitAbstractions/SubscriptionPeriod.swift b/Sources/Purchasing/StoreKitAbstractions/SubscriptionPeriod.swift index f810258b66..8897786c55 100644 --- a/Sources/Purchasing/StoreKitAbstractions/SubscriptionPeriod.swift +++ b/Sources/Purchasing/StoreKitAbstractions/SubscriptionPeriod.swift @@ -101,8 +101,30 @@ public extension SubscriptionPeriod { extension SubscriptionPeriod.Unit: Sendable {} extension SubscriptionPeriod: Sendable {} +public extension SubscriptionPeriod { + + func numberOfUnitsPer(unit: Unit) -> Decimal { + + switch unit { + case .day: + return Decimal(self.value) * self.unitsPerDay + case .week: + return Decimal(self.value) * self.unitsPerWeek + case .month: + return Decimal(self.value) * self.unitsPerMonth + case .year: + return Decimal(self.value) * self.unitsPerYear + } + } + +} + extension SubscriptionPeriod { + func pricePerDay(withTotalPrice price: Decimal) -> Decimal { + return self.pricePerPeriod(for: self.unitsPerDay, totalPrice: price) + } + func pricePerWeek(withTotalPrice price: Decimal) -> Decimal { return self.pricePerPeriod(for: self.unitsPerWeek, totalPrice: price) } @@ -114,6 +136,15 @@ extension SubscriptionPeriod { func pricePerYear(withTotalPrice price: Decimal) -> Decimal { return self.pricePerPeriod(for: self.unitsPerYear, totalPrice: price) } + + private var unitsPerDay: Decimal { + switch self.unit { + case .day: return 1 + case .week: return Constants.daysPerWeek + case .month: return Constants.daysPerMonth + case .year: return Constants.daysPerYear + } + } private var unitsPerWeek: Decimal { switch self.unit { diff --git a/Sources/Purchasing/StoreKitAbstractions/Test Data/TestStoreProduct.swift b/Sources/Purchasing/StoreKitAbstractions/Test Data/TestStoreProduct.swift index e28d411262..50b22a9812 100644 --- a/Sources/Purchasing/StoreKitAbstractions/Test Data/TestStoreProduct.swift +++ b/Sources/Purchasing/StoreKitAbstractions/Test Data/TestStoreProduct.swift @@ -51,6 +51,7 @@ public struct TestStoreProduct { public var localizedTitle: String public var price: Decimal public var localizedPriceString: String + public var localizedPricePerDay: String? public var localizedPricePerWeek: String? public var localizedPricePerMonth: String? public var localizedPricePerYear: String? diff --git a/Tests/RevenueCatUITests/PaywallsV2/VariableHandlerV2Tests.swift b/Tests/RevenueCatUITests/PaywallsV2/VariableHandlerV2Tests.swift new file mode 100644 index 0000000000..7f93e4e133 --- /dev/null +++ b/Tests/RevenueCatUITests/PaywallsV2/VariableHandlerV2Tests.swift @@ -0,0 +1,409 @@ +// +// 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 +// +// VariableHandlerV2Tests.swift +// +// Created by Josh Holtz on 1/5/25. + +import Nimble +import RevenueCat +@testable import RevenueCatUI +import XCTest + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) +class VariableHandlerV2Test: TestCase { + + let localizations = [ + "en_US": [ + "weekly": "weekly", + "week": "week", + "wk": "wk", + "monthly": "monthly", + "month": "month", + "mo": "mo", + "%d day": "%d day", + "%d days": "%d days", + "%d week": "%d week", + "%d weeks": "%d weeks", + "%d month": "%d month", + "%d months": "%d months", + "%d year": "%d year", + "%d years": "%d years", + "%d%%": "%d%%", + ], + "es_ES": [ + "month": "month" + ] + ] + + let locale = Locale(identifier: "en_US") + + let variableHandler = VariableHandlerV2( + discountRelativeToMostExpensivePerMonth: nil, + showZeroDecimalPlacePrices: false + ) + + + func testProductCurrencyCode() { + let result = variableHandler.processVariables( + in: "{{ product.currency_code }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + + expect(result).to(equal("USD")) + } + + func testProductCurrencySymbol() { + let result = variableHandler.processVariables( + in: "{{ product.currency_symbol }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("$")) + } + + func testProductPeriodly() { + let result = variableHandler.processVariables( + in: "{{ product.periodly }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("monthly")) + } + + func testProductPrice() { + let result = variableHandler.processVariables( + in: "{{ product.price }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("$6.99")) + } + + func testProductPricePerPeriod() { + let result = variableHandler.processVariables( + in: "{{ product.price_per_period }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("$6.99/month")) + } + + func testProductPricePerPeriodAbbreviated() { + let result = variableHandler.processVariables( + in: "{{ product.price_per_period_abbreviated }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("$6.99/mo")) + } + + func testProductPricePerDay() { + let result = variableHandler.processVariables( + in: "{{ product.price_per_day }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("$0.23")) + } + + func testProductPricePerWeek() { + let result = variableHandler.processVariables( + in: "{{ product.price_per_week }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("$1.61")) + } + + func testProductPricePerMonth() { + let result = variableHandler.processVariables( + in: "{{ product.price_per_month }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("$6.99")) + } + + func testProductPricePerYear() { + let result = variableHandler.processVariables( + in: "{{ product.price_per_year }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("$83.88")) + } + + func testProductPeriod() { + let result = variableHandler.processVariables( + in: "{{ product.period }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("month")) + } + + func testProductPeriodAbbreviated() { + let result = variableHandler.processVariables( + in: "{{ product.period_abbreviated }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("mo")) + } + + func testProductPeriodInDays() { + let result = variableHandler.processVariables( + in: "{{ product.period_in_days }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("30")) + } + + func testProductPeriodInWeeks() { + let result = variableHandler.processVariables( + in: "{{ product.period_in_weeks }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("4")) + } + + func testProductPeriodInMonths() { + let result = variableHandler.processVariables( + in: "{{ product.period_in_months }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("1")) + } + + func testProductPeriodInYears() { + let result = variableHandler.processVariables( + in: "{{ product.period_in_years }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("0")) + } + + func testProductPeriodWithUnit1Month() { + let result = variableHandler.processVariables( + in: "{{ product.period_with_unit }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("1 month")) + } + + func testProductPeriodWithUnit3Months() { + let result = variableHandler.processVariables( + in: "{{ product.period_with_unit }}", + with: TestData.threeMonthPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("3 months")) + } + +// func testProductOfferPrice() { +// let result = variableHandler.processVariables( +// in: "{{ product.offer_price }}", +// with: TestData.monthlyPackage, +// locale: locale, +// localizations: localizations["en_US"]! +// ) +// expect(result).to(equal("4.99")) +// } +// +// func testProductOfferPricePerDay() { +// let result = variableHandler.processVariables( +// in: "{{ product.offer_price_per_day }}", +// with: TestData.monthlyPackage, +// locale: locale, +// localizations: localizations["en_US"]! +// ) +// expect(result).to(equal("0.17")) +// } +// +// func testProductOfferPricePerWeek() { +// let result = variableHandler.processVariables( +// in: "{{ product.offer_price_per_week }}", +// with: TestData.monthlyPackage, +// locale: locale, +// localizations: localizations["en_US"]! +// ) +// expect(result).to(equal("1.24")) +// } +// +// func testProductOfferPricePerMonth() { +// let result = variableHandler.processVariables( +// in: "{{ product.offer_price_per_month }}", +// with: TestData.monthlyPackage, +// locale: locale, +// localizations: localizations["en_US"]! +// ) +// expect(result).to(equal("4.99")) +// } +// +// func testProductOfferPricePerYear() { +// let result = variableHandler.processVariables( +// in: "{{ product.offer_price_per_year }}", +// with: TestData.monthlyPackage, +// locale: locale, +// localizations: localizations["en_US"]! +// ) +// expect(result).to(equal("59.88")) +// } + + func testProductOfferPeriod() { + let result = variableHandler.processVariables( + in: "{{ product.offer_period }}", + with: TestData.packageWithIntroOffer, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("week")) + } + + func testProductOfferPeriodAbbreviated() { + let result = variableHandler.processVariables( + in: "{{ product.offer_period_abbreviated }}", + with: TestData.packageWithIntroOffer, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("wk")) + } + + func testProductOfferPeriodInDays() { + let result = variableHandler.processVariables( + in: "{{ product.offer_period_in_days }}", + with: TestData.packageWithIntroOffer, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("7")) + } + + func testProductOfferPeriodInWeeks() { + let result = variableHandler.processVariables( + in: "{{ product.offer_period_in_weeks }}", + with: TestData.packageWithIntroOffer, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("1")) + } + + func testProductOfferPeriodInMonths() { + let result = variableHandler.processVariables( + in: "{{ product.offer_period_in_months }}", + with: TestData.packageWithIntroOffer, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("0")) + } + + func testProductOfferPeriodInYears() { + let result = variableHandler.processVariables( + in: "{{ product.offer_period_in_years }}", + with: TestData.packageWithIntroOffer, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("0")) + } + + func testProductOfferPeriodWithUnit() { + let result = variableHandler.processVariables( + in: "{{ product.offer_period_with_unit }}", + with: TestData.packageWithIntroOffer, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("1 week")) + } + +// func testProductOfferEndDate() { +// let result = variableHandler.processVariables( +// in: "{{ product.offer_end_date }}", +// with: TestData.packageWithIntroOffer, +// locale: locale, +// localizations: localizations["en_US"]! +// ) +// expect(result).to(equal("2025-01-31")) +// } + + func testProductSecondaryOfferPrice() { + let result = variableHandler.processVariables( + in: "{{ product.secondary_offer_price }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("")) + } + + func testProductSecondaryOfferPeriod() { + let result = variableHandler.processVariables( + in: "{{ product.secondary_offer_period }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("")) + } + + func testProductSecondaryOfferPeriodAbbreviated() { + let result = variableHandler.processVariables( + in: "{{ product.secondary_offer_period_abbreviated }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("")) + } + + func testProductRelativeDiscount() { + let variableHandler = VariableHandlerV2( + discountRelativeToMostExpensivePerMonth: 0.3, + showZeroDecimalPlacePrices: false + ) + + let result = variableHandler.processVariables( + in: "{{ product.relative_discount }}", + with: TestData.monthlyPackage, + locale: locale, + localizations: localizations["en_US"]! + ) + expect(result).to(equal("30%")) + } + +}