From adf76fee4077ba3ad1dbaf58a0c282659f439e8c Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Mon, 3 Feb 2025 12:32:40 +0100 Subject: [PATCH 1/7] feat: Dont show cancel path if lifetime subscription --- .../ViewModels/CustomerCenterViewModel.swift | 41 ++++++++++--------- .../ManageSubscriptionsViewModel.swift | 20 +++++++-- .../ManageSubscriptionsButtonsView.swift | 16 ++------ .../Views/ManageSubscriptionsView.swift | 4 +- .../ManageSubscriptionsViewModelTests.swift | 24 +++++++++++ 5 files changed, 69 insertions(+), 36 deletions(-) diff --git a/RevenueCatUI/CustomerCenter/ViewModels/CustomerCenterViewModel.swift b/RevenueCatUI/CustomerCenter/ViewModels/CustomerCenterViewModel.swift index 069cc09e73..854477fb7a 100644 --- a/RevenueCatUI/CustomerCenter/ViewModels/CustomerCenterViewModel.swift +++ b/RevenueCatUI/CustomerCenter/ViewModels/CustomerCenterViewModel.swift @@ -29,16 +29,19 @@ import RevenueCat typealias CurrentVersionFetcher = () -> String? private lazy var currentAppVersion: String? = currentVersionFetcher() + @Published private(set) var purchaseInformation: PurchaseInformation? + @Published private(set) var appIsLatestVersion: Bool = defaultAppIsLatestVersion - private(set) var purchasesProvider: CustomerCenterPurchasesType - private(set) var customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType @Published private(set) var onUpdateAppClick: (() -> Void)? + private(set) var purchasesProvider: CustomerCenterPurchasesType + private(set) var customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType + /// Whether or not the Customer Center should warn the customer that they're on an outdated version of the app. var shouldShowAppUpdateWarnings: Bool { return !appIsLatestVersion && (configuration?.support.shouldWarnCustomerToUpdate ?? true) @@ -147,28 +150,28 @@ import RevenueCat private extension CustomerCenterViewModel { func loadPurchaseInformation() async throws { - let customerInfo = try await purchasesProvider.customerInfo(fetchPolicy: .fetchCurrent) + let customerInfo = try await purchasesProvider.customerInfo(fetchPolicy: .fetchCurrent) - let hasActiveProducts = - !customerInfo.activeSubscriptions.isEmpty || !customerInfo.nonSubscriptions.isEmpty + let hasActiveProducts = !customerInfo.activeSubscriptions.isEmpty || + !customerInfo.nonSubscriptions.isEmpty - if !hasActiveProducts { - self.purchaseInformation = nil - self.state = .success - return - } + if !hasActiveProducts { + self.purchaseInformation = nil + self.state = .success + return + } - guard let activeTransaction = findActiveTransaction(customerInfo: customerInfo) else { - Logger.warning(Strings.could_not_find_subscription_information) - self.purchaseInformation = nil - throw CustomerCenterError.couldNotFindSubscriptionInformation - } + guard let activeTransaction = findActiveTransaction(customerInfo: customerInfo) else { + Logger.warning(Strings.could_not_find_subscription_information) + self.purchaseInformation = nil + throw CustomerCenterError.couldNotFindSubscriptionInformation + } - let entitlement = customerInfo.entitlements.all.values - .first(where: { $0.productIdentifier == activeTransaction.productIdentifier }) + let entitlement = customerInfo.entitlements.all.values + .first(where: { $0.productIdentifier == activeTransaction.productIdentifier }) - self.purchaseInformation = try await createPurchaseInformation(for: activeTransaction, - entitlement: entitlement) + self.purchaseInformation = try await createPurchaseInformation(for: activeTransaction, + entitlement: entitlement) } func loadCustomerCenterConfig() async throws { diff --git a/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift b/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift index 31fd36f5d7..8b74e77976 100644 --- a/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift +++ b/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift @@ -27,21 +27,33 @@ import SwiftUI class ManageSubscriptionsViewModel: ObservableObject { let screen: CustomerCenterConfigData.Screen - let paths: [CustomerCenterConfigData.HelpPath] + + var relevantPathsForPurchase: [CustomerCenterConfigData.HelpPath] { + if purchaseInformation?.explanation == .lifetime { + return paths.filter { $0.type != .cancel } + } else { + return paths + } + } @Published var showRestoreAlert: Bool = false + @Published var showPurchases: Bool = false @Published var feedbackSurveyData: FeedbackSurveyData? + @Published var loadingPath: CustomerCenterConfigData.HelpPath? + @Published var promotionalOfferData: PromotionalOfferData? + @Published var inAppBrowserURL: IdentifiableURL? + @Published var state: CustomerCenterViewState { didSet { @@ -53,13 +65,15 @@ class ManageSubscriptionsViewModel: ObservableObject { @Published private(set) var purchaseInformation: PurchaseInformation? + @Published private(set) var refundRequestStatus: RefundRequestStatus? - private var purchasesProvider: ManageSubscriptionsPurchaseType - private let loadPromotionalOfferUseCase: LoadPromotionalOfferUseCaseType private let customerCenterActionHandler: CustomerCenterActionHandler? private var error: Error? + private let loadPromotionalOfferUseCase: LoadPromotionalOfferUseCaseType + private let paths: [CustomerCenterConfigData.HelpPath] + private var purchasesProvider: ManageSubscriptionsPurchaseType init(screen: CustomerCenterConfigData.Screen, customerCenterActionHandler: CustomerCenterActionHandler?, diff --git a/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsButtonsView.swift b/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsButtonsView.swift index 8367085740..d710803f45 100644 --- a/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsButtonsView.swift +++ b/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsButtonsView.swift @@ -25,16 +25,11 @@ struct ManageSubscriptionsButtonsView: View { @ObservedObject var viewModel: ManageSubscriptionsViewModel - @Binding - var loadingPath: CustomerCenterConfigData.HelpPath? - @Environment(\.openURL) - var openURL - @Environment(\.localization) - private var localization: CustomerCenterConfigData.Localization + var loadingPath: CustomerCenterConfigData.HelpPath? var body: some View { - ForEach(self.viewModel.paths, id: \.id) { path in + ForEach(self.viewModel.relevantPathsForPurchase, id: \.id) { path in ManageSubscriptionButton(path: path, viewModel: self.viewModel) } } @@ -45,13 +40,10 @@ struct ManageSubscriptionsButtonsView: View { @available(macOS, unavailable) @available(tvOS, unavailable) @available(watchOS, unavailable) -struct ManageSubscriptionButton: View { +private struct ManageSubscriptionButton: View { let path: CustomerCenterConfigData.HelpPath - @ObservedObject var viewModel: ManageSubscriptionsViewModel - - @Environment(\.appearance) - private var appearance: CustomerCenterConfigData.Appearance + let viewModel: ManageSubscriptionsViewModel var body: some View { AsyncButton(action: { diff --git a/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift b/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift index 4ba5021f28..d0859bdf02 100644 --- a/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift +++ b/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift @@ -128,7 +128,7 @@ struct ManageSubscriptionsView: View { Section { ManageSubscriptionsButtonsView( viewModel: self.viewModel, - loadingPath: self.$viewModel.loadingPath + loadingPath: self.viewModel.loadingPath ) } header: { if let subtitle = self.viewModel.screen.subtitle { @@ -149,7 +149,7 @@ struct ManageSubscriptionsView: View { Section { ManageSubscriptionsButtonsView(viewModel: self.viewModel, - loadingPath: self.$viewModel.loadingPath) + loadingPath: self.viewModel.loadingPath) } } } diff --git a/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift b/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift index f7c62c9950..0bf353df44 100644 --- a/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift +++ b/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift @@ -50,6 +50,16 @@ class ManageSubscriptionsViewModelTests: TestCase { expect(viewModel.showRestoreAlert) == false } + func testLifetimeSubscriptionDoesNotShowCancel() { + let viewModel = ManageSubscriptionsViewModel( + screen: ManageSubscriptionsViewModelTests.screen, + customerCenterActionHandler: nil, + purchaseInformation: PurchaseInformation.mockLifetime + ) + + expect(viewModel.relevantPathsForPurchase.contains(where: { $0.type == .cancel })).to(beFalse()) + } + func testStateChangeToError() { let viewModel = ManageSubscriptionsViewModel(screen: ManageSubscriptionsViewModelTests.screen, customerCenterActionHandler: nil) @@ -434,4 +444,18 @@ private class MockLoadPromotionalOfferUseCase: LoadPromotionalOfferUseCaseType { } +private extension PurchaseInformation { + static var mockLifetime: PurchaseInformation { + PurchaseInformation( + title: "", + durationTitle: "", + explanation: .lifetime, + price: .paid(""), + expirationOrRenewal: PurchaseInformation.ExpirationOrRenewal(label: .expires, date: .date("")), + productIdentifier: "", + store: .appStore + ) + } +} + #endif From 569ea2430a454c6bdac3c6f8ad4e5ffa5ec1466a Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Tue, 4 Feb 2025 16:21:24 +0100 Subject: [PATCH 2/7] added variable + tests --- .../Data/CustomerCenterConfigTestData.swift | 6 +- .../Data/PurchaseInformation.swift | 10 +++- RevenueCatUI/Data/CustomerInfoFixtures.swift | 14 ++++- .../ManageSubscriptionsViewModelTests.swift | 3 +- .../PurchaseInformationTests.swift | 59 ++++++++++++++++++- 5 files changed, 84 insertions(+), 8 deletions(-) diff --git a/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift b/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift index d4d0b667c9..db9cff0c7d 100644 --- a/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift +++ b/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift @@ -144,7 +144,8 @@ enum CustomerCenterConfigTestData { expirationOrRenewal: .init(label: .nextBillingDate, date: .date("June 1st, 2024")), productIdentifier: "product_id", - store: .appStore + store: .appStore, + isLifetimeSubscription: false ) static let subscriptionInformationYearlyExpiring: PurchaseInformation = .init( @@ -155,7 +156,8 @@ enum CustomerCenterConfigTestData { expirationOrRenewal: .init(label: .expires, date: .date("June 1st, 2024")), productIdentifier: "product_id", - store: .appStore + store: .appStore, + isLifetimeSubscription: false ) } diff --git a/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift b/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift index 482c6e20c5..f6b4dc6b7c 100644 --- a/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift +++ b/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift @@ -27,6 +27,7 @@ struct PurchaseInformation { let expirationOrRenewal: ExpirationOrRenewal? let productIdentifier: String let store: Store + let isLifetimeSubscription: Bool init(title: String, durationTitle: String, @@ -34,7 +35,8 @@ struct PurchaseInformation { price: PriceDetails, expirationOrRenewal: ExpirationOrRenewal?, productIdentifier: String, - store: Store + store: Store, + isLifetimeSubscription: Bool ) { self.title = title self.durationTitle = durationTitle @@ -43,6 +45,7 @@ struct PurchaseInformation { self.expirationOrRenewal = expirationOrRenewal self.productIdentifier = productIdentifier self.store = store + self.isLifetimeSubscription = isLifetimeSubscription } init(entitlement: EntitlementInfo? = nil, @@ -67,6 +70,8 @@ struct PurchaseInformation { } else { self.price = entitlement.priceBestEffort(product: subscribedProduct) } + self.isLifetimeSubscription = entitlement.expirationDate == nil + } else { switch transaction.type { case .subscription(let isActive, let willRenew, let expiresDate): @@ -80,9 +85,12 @@ struct PurchaseInformation { : .expired return ExpirationOrRenewal(label: label, date: .date(dateString)) } + self.isLifetimeSubscription = expiresDate == nil + case .nonSubscription: self.explanation = .lifetime self.expirationOrRenewal = nil + self.isLifetimeSubscription = false } self.productIdentifier = transaction.productIdentifier diff --git a/RevenueCatUI/Data/CustomerInfoFixtures.swift b/RevenueCatUI/Data/CustomerInfoFixtures.swift index adc3c362fc..d9b51b8d1b 100644 --- a/RevenueCatUI/Data/CustomerInfoFixtures.swift +++ b/RevenueCatUI/Data/CustomerInfoFixtures.swift @@ -25,14 +25,14 @@ class CustomerInfoFixtures { init(id: String, store: String, purchaseDate: String, - expirationDate: String, + expirationDate: String?, unsubscribeDetectedAt: String? = nil) { self.id = id self.json = """ { "auto_resume_date": null, "billing_issues_detected_at": null, - "expires_date": "\(expirationDate)", + "expires_date": \(expirationDate != nil ? "\"\(expirationDate!)\"" : "null"), "grace_period_expires_date": null, "is_sandbox": true, "original_purchase_date": "\(purchaseDate)", @@ -149,7 +149,7 @@ class CustomerInfoFixtures { store: String, productId: String = "com.revenuecat.product", purchaseDate: String = "2022-04-12T00:03:28Z", - expirationDate: String = "2062-04-12T00:03:35Z", + expirationDate: String? = "2062-04-12T00:03:35Z", unsubscribeDetectedAt: String? = nil ) -> CustomerInfo { return customerInfo( @@ -185,6 +185,14 @@ class CustomerInfoFixtures { ) }() + static let customerInfoWithLifetimeAppSubscrition: CustomerInfo = { + makeCustomerInfo( + store: "app_store", + purchaseDate: "1999-04-12T00:03:28Z", + expirationDate: nil + ) + }() + static let customerInfoWithNonRenewingAppleSubscriptions: CustomerInfo = { makeCustomerInfo( store: "app_store", diff --git a/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift b/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift index 0bf353df44..e09b2b211f 100644 --- a/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift +++ b/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift @@ -453,7 +453,8 @@ private extension PurchaseInformation { price: .paid(""), expirationOrRenewal: PurchaseInformation.ExpirationOrRenewal(label: .expires, date: .date("")), productIdentifier: "", - store: .appStore + store: .appStore, + isLifetimeSubscription: true ) } } diff --git a/Tests/RevenueCatUITests/CustomerCenter/PurchaseInformationTests.swift b/Tests/RevenueCatUITests/CustomerCenter/PurchaseInformationTests.swift index 0266c62c5a..8c4869d8cb 100644 --- a/Tests/RevenueCatUITests/CustomerCenter/PurchaseInformationTests.swift +++ b/Tests/RevenueCatUITests/CustomerCenter/PurchaseInformationTests.swift @@ -74,6 +74,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle) == "1 month" expect(subscriptionInfo.explanation) == .earliestRenewal expect(subscriptionInfo.price) == .paid("$6.99") + expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .nextBillingDate @@ -83,6 +84,51 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.store) == .appStore } + func testAppleEntitlementAndLifetimeProduct() throws { + let customerInfo = CustomerInfoFixtures.customerInfoWithLifetimeAppSubscrition + let entitlement = try XCTUnwrap(customerInfo.entitlements.all.first?.value) + + let mockProduct = TestStoreProduct( + localizedTitle: "Monthly Product", + price: 6.99, + localizedPriceString: "$6.99", + productIdentifier: entitlement.productIdentifier, + productType: .autoRenewableSubscription, + localizedDescription: "PRO monthly", + subscriptionGroupIdentifier: "group", + subscriptionPeriod: .init(value: 1, unit: .month), + introductoryDiscount: nil, + locale: Self.locale + ) + + let mockTransaction = MockTransaction( + productIdentifier: entitlement.productIdentifier, + store: .appStore, + type: .subscription( + isActive: true, + willRenew: true, + expiresDate: nil + ) + ) + + let subscriptionInfo = try XCTUnwrap(PurchaseInformation(entitlement: entitlement, + subscribedProduct: mockProduct.toStoreProduct(), + transaction: mockTransaction, + dateFormatter: Self.mockDateFormatter)) + expect(subscriptionInfo.title) == "Monthly Product" + expect(subscriptionInfo.durationTitle) == "1 month" + expect(subscriptionInfo.explanation) == .lifetime + expect(subscriptionInfo.price) == .paid("$6.99") + expect(subscriptionInfo.isLifetimeSubscription).to(beTrue()) + + let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) + expect(expirationOrRenewal.label) == .expires + expect(expirationOrRenewal.date) == .never + + expect(subscriptionInfo.productIdentifier) == entitlement.productIdentifier + expect(subscriptionInfo.store) == .appStore + } + func testAppleEntitlementAndNonRenewingSubscribedProduct() throws { let customerInfo = CustomerInfoFixtures.customerInfoWithNonRenewingAppleSubscriptions let entitlement = try XCTUnwrap(customerInfo.entitlements.all.first?.value) @@ -118,6 +164,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle) == "1 month" expect(subscriptionInfo.explanation) == .earliestExpiration expect(subscriptionInfo.price) == .paid("$6.99") + expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expires @@ -162,6 +209,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle) == "1 month" expect(subscriptionInfo.explanation) == .expired expect(subscriptionInfo.price) == .paid("$6.99") + expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expired @@ -193,6 +241,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .google expect(subscriptionInfo.price) == .unknown + expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .nextBillingDate @@ -224,6 +273,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .google expect(subscriptionInfo.price) == .unknown + expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expires @@ -255,6 +305,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .google expect(subscriptionInfo.price) == .unknown + expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expired @@ -286,6 +337,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .promotional expect(subscriptionInfo.price) == .free + expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expires @@ -317,6 +369,8 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .promotional expect(subscriptionInfo.price) == .free + // false - no way to know if its lifetime + expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expires @@ -348,6 +402,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .web expect(subscriptionInfo.price) == .unknown + expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .nextBillingDate @@ -379,6 +434,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .web expect(subscriptionInfo.price) == .unknown + expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expires @@ -410,6 +466,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .web expect(subscriptionInfo.price) == .unknown + expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expired @@ -438,6 +495,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.explanation) == .expired expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.price) == .unknown + expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expired @@ -446,5 +504,4 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.productIdentifier) == "product_id" expect(subscriptionInfo.store) == .stripe } - } From a709a7b5ef9e30c4af2b263ca4e33ff1c6b8d450 Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Tue, 4 Feb 2025 16:22:10 +0100 Subject: [PATCH 3/7] add lifetime sub check --- .../ViewModels/ManageSubscriptionsViewModel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift b/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift index 8b74e77976..b21c723014 100644 --- a/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift +++ b/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift @@ -29,7 +29,7 @@ class ManageSubscriptionsViewModel: ObservableObject { let screen: CustomerCenterConfigData.Screen var relevantPathsForPurchase: [CustomerCenterConfigData.HelpPath] { - if purchaseInformation?.explanation == .lifetime { + if purchaseInformation?.isLifetimeSubscription == true { return paths.filter { $0.type != .cancel } } else { return paths From e328c02f2e88d9c305d8a7ddf130b3928b0e1c9b Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Tue, 4 Feb 2025 16:27:25 +0100 Subject: [PATCH 4/7] nits --- .../Views/ManageSubscriptionsButtonsView.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsButtonsView.swift b/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsButtonsView.swift index d710803f45..943b5c56b0 100644 --- a/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsButtonsView.swift +++ b/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsButtonsView.swift @@ -26,11 +26,12 @@ struct ManageSubscriptionsButtonsView: View { @ObservedObject var viewModel: ManageSubscriptionsViewModel - var loadingPath: CustomerCenterConfigData.HelpPath? - var body: some View { ForEach(self.viewModel.relevantPathsForPurchase, id: \.id) { path in - ManageSubscriptionButton(path: path, viewModel: self.viewModel) + ManageSubscriptionButton( + path: path, + viewModel: self.viewModel + ) } } @@ -43,7 +44,9 @@ struct ManageSubscriptionsButtonsView: View { private struct ManageSubscriptionButton: View { let path: CustomerCenterConfigData.HelpPath - let viewModel: ManageSubscriptionsViewModel + + @ObservedObject + var viewModel: ManageSubscriptionsViewModel var body: some View { AsyncButton(action: { From b74eab4defff3be7003ac4478932b1a37c2a07ff Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Tue, 4 Feb 2025 17:54:00 +0100 Subject: [PATCH 5/7] remove extra loding path --- .../CustomerCenter/Views/ManageSubscriptionsView.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift b/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift index d0859bdf02..adcd3a0c0a 100644 --- a/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift +++ b/RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift @@ -127,8 +127,7 @@ struct ManageSubscriptionsView: View { Section { ManageSubscriptionsButtonsView( - viewModel: self.viewModel, - loadingPath: self.viewModel.loadingPath + viewModel: self.viewModel ) } header: { if let subtitle = self.viewModel.screen.subtitle { @@ -148,8 +147,7 @@ struct ManageSubscriptionsView: View { } Section { - ManageSubscriptionsButtonsView(viewModel: self.viewModel, - loadingPath: self.viewModel.loadingPath) + ManageSubscriptionsButtonsView(viewModel: self.viewModel) } } } From 9c0e4d061bd8e2a532a3e7122750f7f182e54c76 Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Thu, 6 Feb 2025 12:30:49 +0100 Subject: [PATCH 6/7] Renamed isLifetime --- .../Data/CustomerCenterConfigTestData.swift | 4 +-- .../Data/PurchaseInformation.swift | 12 ++++---- .../ManageSubscriptionsViewModel.swift | 2 +- .../ManageSubscriptionsViewModelTests.swift | 2 +- .../PurchaseInformationTests.swift | 29 ++++++++++--------- 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift b/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift index db9cff0c7d..24267e889d 100644 --- a/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift +++ b/RevenueCatUI/CustomerCenter/Data/CustomerCenterConfigTestData.swift @@ -145,7 +145,7 @@ enum CustomerCenterConfigTestData { date: .date("June 1st, 2024")), productIdentifier: "product_id", store: .appStore, - isLifetimeSubscription: false + isLifetime: false ) static let subscriptionInformationYearlyExpiring: PurchaseInformation = .init( @@ -157,7 +157,7 @@ enum CustomerCenterConfigTestData { date: .date("June 1st, 2024")), productIdentifier: "product_id", store: .appStore, - isLifetimeSubscription: false + isLifetime: false ) } diff --git a/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift b/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift index f6b4dc6b7c..7603feb466 100644 --- a/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift +++ b/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift @@ -27,7 +27,7 @@ struct PurchaseInformation { let expirationOrRenewal: ExpirationOrRenewal? let productIdentifier: String let store: Store - let isLifetimeSubscription: Bool + let isLifetime: Bool init(title: String, durationTitle: String, @@ -36,7 +36,7 @@ struct PurchaseInformation { expirationOrRenewal: ExpirationOrRenewal?, productIdentifier: String, store: Store, - isLifetimeSubscription: Bool + isLifetime: Bool ) { self.title = title self.durationTitle = durationTitle @@ -45,7 +45,7 @@ struct PurchaseInformation { self.expirationOrRenewal = expirationOrRenewal self.productIdentifier = productIdentifier self.store = store - self.isLifetimeSubscription = isLifetimeSubscription + self.isLifetime = isLifetime } init(entitlement: EntitlementInfo? = nil, @@ -70,7 +70,7 @@ struct PurchaseInformation { } else { self.price = entitlement.priceBestEffort(product: subscribedProduct) } - self.isLifetimeSubscription = entitlement.expirationDate == nil + self.isLifetime = entitlement.expirationDate == nil } else { switch transaction.type { @@ -85,12 +85,12 @@ struct PurchaseInformation { : .expired return ExpirationOrRenewal(label: label, date: .date(dateString)) } - self.isLifetimeSubscription = expiresDate == nil + self.isLifetime = false case .nonSubscription: self.explanation = .lifetime self.expirationOrRenewal = nil - self.isLifetimeSubscription = false + self.isLifetime = true } self.productIdentifier = transaction.productIdentifier diff --git a/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift b/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift index b21c723014..1f421389c4 100644 --- a/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift +++ b/RevenueCatUI/CustomerCenter/ViewModels/ManageSubscriptionsViewModel.swift @@ -29,7 +29,7 @@ class ManageSubscriptionsViewModel: ObservableObject { let screen: CustomerCenterConfigData.Screen var relevantPathsForPurchase: [CustomerCenterConfigData.HelpPath] { - if purchaseInformation?.isLifetimeSubscription == true { + if purchaseInformation?.isLifetime == true { return paths.filter { $0.type != .cancel } } else { return paths diff --git a/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift b/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift index e09b2b211f..1e97a6a713 100644 --- a/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift +++ b/Tests/RevenueCatUITests/CustomerCenter/ManageSubscriptionsViewModelTests.swift @@ -454,7 +454,7 @@ private extension PurchaseInformation { expirationOrRenewal: PurchaseInformation.ExpirationOrRenewal(label: .expires, date: .date("")), productIdentifier: "", store: .appStore, - isLifetimeSubscription: true + isLifetime: true ) } } diff --git a/Tests/RevenueCatUITests/CustomerCenter/PurchaseInformationTests.swift b/Tests/RevenueCatUITests/CustomerCenter/PurchaseInformationTests.swift index 8c4869d8cb..7f88bd6e86 100644 --- a/Tests/RevenueCatUITests/CustomerCenter/PurchaseInformationTests.swift +++ b/Tests/RevenueCatUITests/CustomerCenter/PurchaseInformationTests.swift @@ -22,7 +22,7 @@ import RevenueCat @available(macOS, unavailable) @available(tvOS, unavailable) @available(watchOS, unavailable) -class PurchaseInformationTests: TestCase { +final class PurchaseInformationTests: TestCase { static let locale: Locale = .current static let mockDateFormatter: DateFormatter = { @@ -74,7 +74,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle) == "1 month" expect(subscriptionInfo.explanation) == .earliestRenewal expect(subscriptionInfo.price) == .paid("$6.99") - expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) + expect(subscriptionInfo.isLifetime).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .nextBillingDate @@ -119,7 +119,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle) == "1 month" expect(subscriptionInfo.explanation) == .lifetime expect(subscriptionInfo.price) == .paid("$6.99") - expect(subscriptionInfo.isLifetimeSubscription).to(beTrue()) + expect(subscriptionInfo.isLifetime).to(beTrue()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expires @@ -164,7 +164,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle) == "1 month" expect(subscriptionInfo.explanation) == .earliestExpiration expect(subscriptionInfo.price) == .paid("$6.99") - expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) + expect(subscriptionInfo.isLifetime).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expires @@ -209,7 +209,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle) == "1 month" expect(subscriptionInfo.explanation) == .expired expect(subscriptionInfo.price) == .paid("$6.99") - expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) + expect(subscriptionInfo.isLifetime).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expired @@ -241,7 +241,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .google expect(subscriptionInfo.price) == .unknown - expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) + expect(subscriptionInfo.isLifetime).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .nextBillingDate @@ -273,7 +273,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .google expect(subscriptionInfo.price) == .unknown - expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) + expect(subscriptionInfo.isLifetime).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expires @@ -305,7 +305,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .google expect(subscriptionInfo.price) == .unknown - expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) + expect(subscriptionInfo.isLifetime).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expired @@ -337,7 +337,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .promotional expect(subscriptionInfo.price) == .free - expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) + expect(subscriptionInfo.isLifetime).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expires @@ -370,7 +370,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.explanation) == .promotional expect(subscriptionInfo.price) == .free // false - no way to know if its lifetime - expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) + expect(subscriptionInfo.isLifetime).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expires @@ -402,7 +402,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .web expect(subscriptionInfo.price) == .unknown - expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) + expect(subscriptionInfo.isLifetime).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .nextBillingDate @@ -434,7 +434,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .web expect(subscriptionInfo.price) == .unknown - expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) + expect(subscriptionInfo.isLifetime).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expires @@ -466,7 +466,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.explanation) == .web expect(subscriptionInfo.price) == .unknown - expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) + expect(subscriptionInfo.isLifetime).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expired @@ -495,7 +495,7 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.explanation) == .expired expect(subscriptionInfo.durationTitle).to(beNil()) expect(subscriptionInfo.price) == .unknown - expect(subscriptionInfo.isLifetimeSubscription).to(beFalse()) + expect(subscriptionInfo.isLifetime).to(beFalse()) let expirationOrRenewal = try XCTUnwrap(subscriptionInfo.expirationOrRenewal) expect(expirationOrRenewal.label) == .expired @@ -504,4 +504,5 @@ class PurchaseInformationTests: TestCase { expect(subscriptionInfo.productIdentifier) == "product_id" expect(subscriptionInfo.store) == .stripe } + } From 4663db132777268b085bb143b5d548b9185771d6 Mon Sep 17 00:00:00 2001 From: Facundo Menzella Date: Thu, 6 Feb 2025 12:37:42 +0100 Subject: [PATCH 7/7] Added docs --- .../CustomerCenter/Data/PurchaseInformation.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift b/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift index 7603feb466..6c844bd6b3 100644 --- a/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift +++ b/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift @@ -18,15 +18,28 @@ import RevenueCat import StoreKit // swiftlint:disable nesting + +/// Information about a purchase. struct PurchaseInformation { + /// The title of the storekit product, if applicable. + /// - Note: See `StoreProduct.localizedTitle` for more details. let title: String? + /// The duration of the product, if applicable. + /// - Note: See `StoreProduct.localizedDetails` for more details. let durationTitle: String? let explanation: Explanation + /// Pricing details of the purchase. let price: PriceDetails + /// Subscription expiration or renewal details, if applicable. let expirationOrRenewal: ExpirationOrRenewal? + /// The unique product identifier for the purchase. let productIdentifier: String + /// The store from which the purchase was made (e.g., App Store, Play Store). let store: Store + /// Indicates whether the purchase grants lifetime access. + /// - `true` for non-subscription purchases. + /// - `false` for subscriptions, even if the expiration date is set far in the future. let isLifetime: Bool init(title: String,