Skip to content

Commit

Permalink
Paywalls: added support for custom and lifetime products (#2941)
Browse files Browse the repository at this point in the history
The main change is that `VariableHandler` no longer cashes when trying
to determine price per month for non-subscriptions. I added a test to
cover this behavior.
  • Loading branch information
NachoSoto committed Sep 6, 2023
1 parent 0961ef5 commit 7c6ab8d
Show file tree
Hide file tree
Showing 12 changed files with 112 additions and 16 deletions.
3 changes: 2 additions & 1 deletion RevenueCatUI/Data/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,9 @@ private extension PackageType {
case .twoMonth: return "\(keyPrefix)twoMonth"
case .monthly: return "\(keyPrefix)monthly"
case .weekly: return "\(keyPrefix)weekly"
case .lifetime: return "\(keyPrefix)lifetime"

case .unknown, .custom, .lifetime:
case .unknown, .custom:
return nil
}
}
Expand Down
5 changes: 5 additions & 0 deletions RevenueCatUI/Data/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import RevenueCat

enum Strings {

case package_not_subscription(Package)
case found_multiple_packages_of_same_type(PackageType)
case could_not_find_content_for_variable(variableName: String)

Expand All @@ -25,6 +26,10 @@ extension Strings: CustomStringConvertible {

var description: String {
switch self {
case let .package_not_subscription(package):
return "Expected package '\(package.identifier)' to be a subscription. " +
"Type: \(package.packageType.debugDescription)"

case let .found_multiple_packages_of_same_type(type):
return "Found multiple \(type) packages. Will use the first one."

Expand Down
4 changes: 1 addition & 3 deletions RevenueCatUI/Data/TemplateViewConfiguration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,9 +173,7 @@ extension TemplateViewConfiguration {

/// Filters `packages`, extracting only the values corresponding to `list`.
static func filter(packages: [RevenueCat.Package], with list: [PackageType]) -> [RevenueCat.Package] {
// Only subscriptions are supported at the moment
let subscriptions = packages.filter { $0.storeProduct.productCategory == .subscription }
let map = Dictionary(grouping: subscriptions) { $0.packageType }
let map = Dictionary(grouping: packages) { $0.packageType }

return list.compactMap { type in
if let packages = map[type] {
Expand Down
16 changes: 16 additions & 0 deletions RevenueCatUI/Data/TestData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,16 @@ internal enum TestData {
subscriptionPeriod: .init(value: 1, unit: .year),
introductoryDiscount: Self.intro(14, .day)
)
static let lifetimeProduct = TestStoreProduct(
localizedTitle: "Lifetime",
price: 119.49,
localizedPriceString: "$119.49",
productIdentifier: "com.revenuecat.product_lifetime",
productType: .consumable,
localizedDescription: "Lifetime purchase",
subscriptionGroupIdentifier: "group",
subscriptionPeriod: nil
)
static let productWithIntroOffer = TestStoreProduct(
localizedTitle: "PRO monthly",
price: 3.99,
Expand Down Expand Up @@ -110,6 +120,12 @@ internal enum TestData {
storeProduct: productWithNoIntroOffer.toStoreProduct(),
offeringIdentifier: Self.offeringIdentifier
)
static let lifetimePackage = Package(
identifier: "lifetime",
packageType: .lifetime,
storeProduct: Self.lifetimeProduct.toStoreProduct(),
offeringIdentifier: Self.offeringIdentifier
)

static let packages = [
Self.packageWithIntroOffer,
Expand Down
3 changes: 2 additions & 1 deletion RevenueCatUI/Data/Variables.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ protocol VariableDataProvider {

var applicationName: String { get }

var isSubscription: Bool { get }
var isMonthly: Bool { get }

var localizedPrice: String { get }
Expand Down Expand Up @@ -92,7 +93,7 @@ private extension VariableDataProvider {
case "price": return self.localizedPrice
case "price_per_month": return self.localizedPricePerMonth
case "total_price_and_per_month":
if self.isMonthly {
if !self.isSubscription || self.isMonthly {
return self.localizedPrice
} else {
let unit = Localization.abbreviatedUnitLocalizedString(for: .month, locale: locale)
Expand Down
7 changes: 6 additions & 1 deletion RevenueCatUI/Helpers/Package+VariableDataProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ extension Package: VariableDataProvider {
return Bundle.main.applicationDisplayName
}

var isSubscription: Bool {
return self.storeProduct.productCategory == .subscription
}

var isMonthly: Bool {
return self.packageType == .monthly
}
Expand Down Expand Up @@ -46,7 +50,8 @@ private extension Package {

var pricePerMonth: NSDecimalNumber {
guard let price = self.storeProduct.pricePerMonth else {
fatalError("Unexpectedly found a package which is not a subscription: \(self)")
Logger.warning(Strings.package_not_subscription(self))
return self.storeProduct.priceDecimalNumber
}

return price
Expand Down
1 change: 1 addition & 0 deletions RevenueCatUI/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@
"PackageType.twoMonth" = "2 month";
"PackageType.monthly" = "Monthly";
"PackageType.weekly" = "Weekly";
"PackageType.lifetime" = "Lifetime";

"%d%% off" = "%d%% off";
1 change: 1 addition & 0 deletions RevenueCatUI/Resources/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@
"PackageType.twoMonth" = "2 meses";
"PackageType.monthly" = "Mensual";
"PackageType.weekly" = "Semanal";
"PackageType.lifetime" = "Vitalicio";

"%d%% off" = "Ahorra %d%%";
35 changes: 30 additions & 5 deletions Tests/RevenueCatUITests/Data/TemplateViewConfigurationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,31 @@ class TemplateViewConfigurationCreationTests: BaseTemplateViewConfigurationTests
}
}

func testCreateOnlyLifetime() throws {
let result = try Config.create(
with: [TestData.lifetimePackage],
filter: [.lifetime],
default: nil,
localization: Self.localization,
setting: .single
)

switch result {
case let .single(package):
expect(package.content) === TestData.lifetimePackage
Self.verifyLocalizationWasProcessed(package.localization, for: TestData.lifetimePackage)
case .multiple:
fail("Invalid result: \(result)")
}
}

func testCreateMultiplePackage() throws {
let result = try Config.create(
with: [TestData.monthlyPackage, TestData.annualPackage, TestData.weeklyPackage],
filter: [.annual, .monthly],
with: [TestData.monthlyPackage,
TestData.annualPackage,
TestData.weeklyPackage,
TestData.lifetimePackage],
filter: [.annual, .monthly, .lifetime],
default: .monthly,
localization: Self.localization,
setting: .multiple
Expand All @@ -77,7 +98,7 @@ class TemplateViewConfigurationCreationTests: BaseTemplateViewConfigurationTests
expect(first.content) === TestData.annualPackage
expect(defaultPackage.content) === TestData.monthlyPackage

expect(packages).to(haveCount(2))
expect(packages).to(haveCount(3))

let annual = packages[0]
expect(annual.content) === TestData.annualPackage
Expand All @@ -89,6 +110,10 @@ class TemplateViewConfigurationCreationTests: BaseTemplateViewConfigurationTests
expect(monthly.content) === TestData.monthlyPackage
expect(monthly.discountRelativeToMostExpensivePerMonth).to(beNil())
Self.verifyLocalizationWasProcessed(monthly.localization, for: TestData.monthlyPackage)

let lifetime = packages[2]
expect(lifetime.content) === TestData.lifetimePackage
Self.verifyLocalizationWasProcessed(lifetime.localization, for: TestData.lifetimePackage)
}
}

Expand Down Expand Up @@ -119,8 +144,8 @@ class TemplateViewConfigurationFilteringTests: BaseTemplateViewConfigurationTest
expect(TemplateViewConfiguration.filter(packages: [TestData.monthlyPackage], with: [.annual])) == []
}

func testFilterOutNonSubscriptions() {
expect(TemplateViewConfiguration.filter(packages: [Self.consumable], with: [.custom])) == []
func testConsumablesAreIncluded() {
expect(TemplateViewConfiguration.filter(packages: [Self.consumable], with: [.custom])) == [Self.consumable]
}

func testFilterByPackageType() {
Expand Down
26 changes: 26 additions & 0 deletions Tests/RevenueCatUITests/Data/VariablesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@ class VariablesTests: TestCase {
expect(self.process("{{ total_price_and_per_month }}")) == "$49.99 ($4.16/mo)"
}

func testTotalPriceAndPerMonthForNonSubscriptions() {
self.provider.isSubscription = false
self.provider.isMonthly = false
self.provider.localizedPrice = "$49.99"
expect(self.process("{{ total_price_and_per_month }}")) == "$49.99"
}

func testTotalPriceAndPerMonthWithDifferentPricesSpanish() {
self.provider.localizedPrice = "49,99€"
self.provider.localizedPricePerMonth = "4,16€"
Expand Down Expand Up @@ -137,6 +144,24 @@ class VariablesTests: TestCase {
]
}

// Note: this isn't perfect, but a warning is logged
// and it's better than crashing.
func testPricePerMonthForLifetimeProductsReturnsPrice() {
let result = VariableHandler.processVariables(
in: "{{ price_per_month }}",
with: TestData.lifetimePackage
)
expect(result) == "$119.49"
}

func testTotalPriceAndPerMonthForLifetimeProductsReturnsPrice() {
let result = VariableHandler.processVariables(
in: "{{ total_price_and_per_month }}",
with: TestData.lifetimePackage
)
expect(result) == "$119.49"
}

}

// MARK: - Private
Expand All @@ -153,6 +178,7 @@ private extension VariablesTests {
private struct MockVariableProvider: VariableDataProvider {

var applicationName: String = ""
var isSubscription: Bool = true
var isMonthly: Bool = false
var localizedPrice: String = ""
var localizedPricePerMonth: String = ""
Expand Down
6 changes: 3 additions & 3 deletions Tests/RevenueCatUITests/LocalizationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,11 @@ class PackageTypeEnglishLocalizationTests: BaseLocalizationTests {
verify(.twoMonth, "2 month")
verify(.monthly, "Monthly")
verify(.weekly, "Weekly")
verify(.lifetime, "Lifetime")
}

func testOtherValues() {
verify(.custom, "")
verify(.lifetime, "")
verify(.unknown, "")
}

Expand All @@ -150,11 +150,11 @@ class PackageTypeSpanishLocalizationTests: BaseLocalizationTests {
verify(.twoMonth, "2 meses")
verify(.monthly, "Mensual")
verify(.weekly, "Semanal")
verify(.lifetime, "Vitalicio")
}

func testOtherValues() {
verify(.custom, "")
verify(.lifetime, "")
verify(.unknown, "")
}
}
Expand All @@ -171,11 +171,11 @@ class PackageTypeOtherLanguageLocalizationTests: BaseLocalizationTests {
verify(.twoMonth, "2 month")
verify(.monthly, "Monthly")
verify(.weekly, "Weekly")
verify(.lifetime, "Lifetime")
}

func testOtherValues() {
verify(.custom, "")
verify(.lifetime, "")
verify(.unknown, "")
}
}
Expand Down
21 changes: 19 additions & 2 deletions Tests/TestingApps/SimpleApp/SimpleApp/SamplePaywalls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ final class SamplePaywallLoader {
self.packages = [
Self.weeklyPackage,
Self.monthlyPackage,
Self.annualPackage
Self.annualPackage,
Self.lifetimePackage
]
}

Expand Down Expand Up @@ -75,6 +76,12 @@ private extension SamplePaywallLoader {
storeProduct: annualProduct.toStoreProduct(),
offeringIdentifier: offeringIdentifier
)
static let lifetimePackage = Package(
identifier: "lifetime",
packageType: .lifetime,
storeProduct: lifetimeProduct.toStoreProduct(),
offeringIdentifier: offeringIdentifier
)

static let weeklyProduct = TestStoreProduct(
localizedTitle: "Weekly",
Expand Down Expand Up @@ -124,6 +131,16 @@ private extension SamplePaywallLoader {
type: .introductory
)
)
static let lifetimeProduct = TestStoreProduct(
localizedTitle: "Lifetime",
price: 119.49,
localizedPriceString: "$119.49",
productIdentifier: "com.revenuecat.product_lifetime",
productType: .consumable,
localizedDescription: "Lifetime purchase",
subscriptionGroupIdentifier: "group",
subscriptionPeriod: nil
)

}

Expand Down Expand Up @@ -171,7 +188,7 @@ private extension SamplePaywallLoader {
return .init(
template: .multiPackageBold,
config: .init(
packages: [.weekly, .monthly, .annual],
packages: [.weekly, .monthly, .annual, .lifetime],
images: Self.images,
colors: .init(
light: .init(
Expand Down

0 comments on commit 7c6ab8d

Please sign in to comment.