diff --git a/RevenueCat.xcodeproj/project.pbxproj b/RevenueCat.xcodeproj/project.pbxproj index c88060a944..f7dc0ac191 100644 --- a/RevenueCat.xcodeproj/project.pbxproj +++ b/RevenueCat.xcodeproj/project.pbxproj @@ -1049,6 +1049,7 @@ FD33CD4D2D034CBD000D13A4 /* CustomerCenterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD33CD4C2D034CBD000D13A4 /* CustomerCenterViewController.swift */; }; FD43D2FC2C41864000077235 /* TimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD43D2FA2C4185B700077235 /* TimeInterval+Extensions.swift */; }; FD43D2FE2C41867600077235 /* TimeInterval+ExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD43D2FD2C41867600077235 /* TimeInterval+ExtensionsTests.swift */; }; + FD6186542D1393FA007843DA /* MockCustomerCenterStoreKitUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD6186532D1393FA007843DA /* MockCustomerCenterStoreKitUtilities.swift */; }; FD9F982D2BE28A7F0091A5BF /* MockNotificationCenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 351B515526D44B2300BD2BD7 /* MockNotificationCenter.swift */; }; FDAADFCB2BE2A5BF00BD1659 /* MockAllTransactionsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAADFCA2BE2A5BF00BD1659 /* MockAllTransactionsProvider.swift */; }; FDAADFCC2BE2A5BF00BD1659 /* MockAllTransactionsProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAADFCA2BE2A5BF00BD1659 /* MockAllTransactionsProvider.swift */; }; @@ -1061,6 +1062,8 @@ FDAC7B572CD3FD8500DFC0D9 /* MockWinBackOfferEligibilityCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAC7B562CD3FD8500DFC0D9 /* MockWinBackOfferEligibilityCalculator.swift */; }; FDAC7B582CD3FD8500DFC0D9 /* MockWinBackOfferEligibilityCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAC7B562CD3FD8500DFC0D9 /* MockWinBackOfferEligibilityCalculator.swift */; }; FDAC7B5B2CD4085800DFC0D9 /* PurchasesWinBackOfferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAC7B5A2CD4085800DFC0D9 /* PurchasesWinBackOfferTests.swift */; }; + FDAD6AC72D132DD900FB047E /* CustomerCenterStoreKitUtilitiesType.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAD6AC62D132DD900FB047E /* CustomerCenterStoreKitUtilitiesType.swift */; }; + FDAD6AC92D132E6500FB047E /* CustomerCenterStoreKitUtilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDAD6AC82D132E6500FB047E /* CustomerCenterStoreKitUtilities.swift */; }; FDC892D12CCAD0EE000AEB9F /* MockStoreKit2PurchaseIntentListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC892D02CCAD0EE000AEB9F /* MockStoreKit2PurchaseIntentListener.swift */; }; FDC892D22CCAD0EE000AEB9F /* MockStoreKit2PurchaseIntentListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC892D02CCAD0EE000AEB9F /* MockStoreKit2PurchaseIntentListener.swift */; }; FDC892FE2CD157F1000AEB9F /* WinBackOffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC892FD2CD157F1000AEB9F /* WinBackOffer.swift */; }; @@ -2300,6 +2303,7 @@ FD33CD5F2D03500C000D13A4 /* CustomerCenterUIKitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterUIKitView.swift; sourceTree = ""; }; FD43D2FA2C4185B700077235 /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = ""; }; FD43D2FD2C41867600077235 /* TimeInterval+ExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+ExtensionsTests.swift"; sourceTree = ""; }; + FD6186532D1393FA007843DA /* MockCustomerCenterStoreKitUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCustomerCenterStoreKitUtilities.swift; sourceTree = ""; }; FDAADFCA2BE2A5BF00BD1659 /* MockAllTransactionsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAllTransactionsProvider.swift; sourceTree = ""; }; FDAADFCE2BE2B84500BD1659 /* StoreKit2ObserverModePurchaseDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2ObserverModePurchaseDetector.swift; sourceTree = ""; }; FDAADFD22BE2B99900BD1659 /* MockStoreKit2ObserverModePurchaseDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreKit2ObserverModePurchaseDetector.swift; sourceTree = ""; }; @@ -2308,6 +2312,8 @@ FDAC7B542CD3D7A200DFC0D9 /* WinBackOfferEligibilityCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WinBackOfferEligibilityCalculator.swift; sourceTree = ""; }; FDAC7B562CD3FD8500DFC0D9 /* MockWinBackOfferEligibilityCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockWinBackOfferEligibilityCalculator.swift; sourceTree = ""; }; FDAC7B5A2CD4085800DFC0D9 /* PurchasesWinBackOfferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesWinBackOfferTests.swift; sourceTree = ""; }; + FDAD6AC62D132DD900FB047E /* CustomerCenterStoreKitUtilitiesType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterStoreKitUtilitiesType.swift; sourceTree = ""; }; + FDAD6AC82D132E6500FB047E /* CustomerCenterStoreKitUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterStoreKitUtilities.swift; sourceTree = ""; }; FDC892D02CCAD0EE000AEB9F /* MockStoreKit2PurchaseIntentListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreKit2PurchaseIntentListener.swift; sourceTree = ""; }; FDC892FD2CD157F1000AEB9F /* WinBackOffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WinBackOffer.swift; sourceTree = ""; }; FECF627761D375C8431EB866 /* StoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProduct.swift; sourceTree = ""; }; @@ -3573,6 +3579,7 @@ 353756642C382C2800A1B8D6 /* CustomerCenter */ = { isa = PBXGroup; children = ( + FDAD6AC52D132DC600FB047E /* Utilities */, 35653BD32C46803A009E8ADB /* Abstractions */, 353756562C382C2800A1B8D6 /* Data */, 3537565A2C382C2800A1B8D6 /* ViewModels */, @@ -3679,7 +3686,7 @@ 3544DA6B2C2C848E00704E9D /* CustomerCenter */ = { isa = PBXGroup; children = ( - 356523A92CF885CE00B6E3EA /* MockCustomerCenterPurchases.swift */, + FD6186522D1393E2007843DA /* Mocks */, 35A99C822CCB95950074AB41 /* SubscriptionInformationFixtures.swift */, 3544DA692C2C848E00704E9D /* CustomerCenterViewModelTests.swift */, 3544DA6A2C2C848E00704E9D /* ManageSubscriptionsViewModelTests.swift */, @@ -4977,6 +4984,15 @@ path = "UIKit Compatibility"; sourceTree = ""; }; + FD6186522D1393E2007843DA /* Mocks */ = { + isa = PBXGroup; + children = ( + 356523A92CF885CE00B6E3EA /* MockCustomerCenterPurchases.swift */, + FD6186532D1393FA007843DA /* MockCustomerCenterStoreKitUtilities.swift */, + ); + path = Mocks; + sourceTree = ""; + }; FDAADFCD2BE2B82D00BD1659 /* Observer Mode */ = { isa = PBXGroup; children = ( @@ -4994,6 +5010,15 @@ path = "Win-Back Offers"; sourceTree = ""; }; + FDAD6AC52D132DC600FB047E /* Utilities */ = { + isa = PBXGroup; + children = ( + FDAD6AC62D132DD900FB047E /* CustomerCenterStoreKitUtilitiesType.swift */, + FDAD6AC82D132E6500FB047E /* CustomerCenterStoreKitUtilities.swift */, + ); + path = Utilities; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -6512,6 +6537,7 @@ 887A60BE2C1D037000E1A461 /* PaywallFooterViewController.swift in Sources */, 887A608A2C1D037000E1A461 /* PurchaseHandler.swift in Sources */, 2D2AFE8D2C6A834D00D1B0B4 /* TestData.swift in Sources */, + FDAD6AC92D132E6500FB047E /* CustomerCenterStoreKitUtilities.swift in Sources */, 887A60C92C1D037000E1A461 /* PurchaseButton.swift in Sources */, 88B1BAF02C813A3C001B7EE5 /* TextComponentViewModel.swift in Sources */, 2D2AFE912C6A9EF500D1B0B4 /* Binding+Extensions.swift in Sources */, @@ -6528,6 +6554,7 @@ 353756722C382C2800A1B8D6 /* URLUtilities.swift in Sources */, 353FDC0F2CA446FA0055F328 /* StoreProductDiscount+Extensions.swift in Sources */, 887A60862C1D037000E1A461 /* FooterHidingModifier.swift in Sources */, + FDAD6AC72D132DD900FB047E /* CustomerCenterStoreKitUtilitiesType.swift in Sources */, 887A60C02C1D037000E1A461 /* AsyncButton.swift in Sources */, 887A60892C1D037000E1A461 /* PaywallPurchasesType.swift in Sources */, 2C8EC71B2CCDD43900D6CCF8 /* ComponentViewState.swift in Sources */, @@ -6675,6 +6702,7 @@ 887A63412C1D177800E1A461 /* BaseSnapshotTest.swift in Sources */, 887A63422C1D177800E1A461 /* ImageLoaderTests.swift in Sources */, 887A63432C1D177800E1A461 /* LocalizationTests.swift in Sources */, + FD6186542D1393FA007843DA /* MockCustomerCenterStoreKitUtilities.swift in Sources */, 887A63442C1D177800E1A461 /* PaywallFooterTests.swift in Sources */, 887A63452C1D177800E1A461 /* PaywallViewEventsTests.swift in Sources */, 887A63472C1D177800E1A461 /* PurchaseCompletedHandlerTests.swift in Sources */, diff --git a/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift b/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift index fc8144216c..482c6e20c5 100644 --- a/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift +++ b/RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift @@ -15,6 +15,7 @@ import Foundation import RevenueCat +import StoreKit // swiftlint:disable nesting struct PurchaseInformation { @@ -47,6 +48,7 @@ struct PurchaseInformation { init(entitlement: EntitlementInfo? = nil, subscribedProduct: StoreProduct? = nil, transaction: Transaction, + renewalPrice: PriceDetails? = nil, dateFormatter: DateFormatter = DateFormatter()) { dateFormatter.dateStyle = .medium @@ -60,7 +62,11 @@ struct PurchaseInformation { self.expirationOrRenewal = entitlement.expirationOrRenewal(dateFormatter: dateFormatter) self.productIdentifier = entitlement.productIdentifier self.store = entitlement.store - self.price = entitlement.priceBestEffort(product: subscribedProduct) + if let renewalPrice { + self.price = renewalPrice + } else { + self.price = entitlement.priceBestEffort(product: subscribedProduct) + } } else { switch transaction.type { case .subscription(let isActive, let willRenew, let expiresDate): @@ -81,8 +87,15 @@ struct PurchaseInformation { self.productIdentifier = transaction.productIdentifier self.store = transaction.store - self.price = transaction.store == .promotional ? .free - : (subscribedProduct.map { .paid($0.localizedPriceString) } ?? .unknown) + if transaction.store == .promotional { + self.price = .free + } else { + if let renewalPrice { + self.price = renewalPrice + } else { + self.price = subscribedProduct.map { .paid($0.localizedPriceString) } ?? .unknown + } + } } } @@ -123,6 +136,60 @@ struct PurchaseInformation { } // swiftlint:enable nesting +extension PurchaseInformation { + + /// Provides detailed information about a user's purchase, including renewal price. + /// + /// This function fetches the renewal price details for the given product asynchronously from + /// StoreKit 2 and constructs a `PurchaseInformation` object with the provided + /// transaction, entitlement, and subscribed product details. + /// + /// - Parameters: + /// - entitlement: Optional entitlement information associated with the purchase. + /// - subscribedProduct: The product the user has subscribed to, represented as a `StoreProduct`. + /// - transaction: The transaction information for the purchase. + /// - Returns: A `PurchaseInformation` object containing the purchase details, including the renewal price. + /// + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + static func purchaseInformationUsingRenewalInfo( + entitlement: EntitlementInfo? = nil, + subscribedProduct: StoreProduct, + transaction: Transaction, + customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType + ) async -> PurchaseInformation { + let renewalPriceDetails = await Self.extractPriceDetailsFromRenewalInfo( + forProduct: subscribedProduct, + customerCenterStoreKitUtilities: customerCenterStoreKitUtilities + ) + return PurchaseInformation( + entitlement: entitlement, + subscribedProduct: subscribedProduct, + transaction: transaction, + renewalPrice: renewalPriceDetails + ) + } + + @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) + private static func extractPriceDetailsFromRenewalInfo( + forProduct product: StoreProduct, + customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType + ) async -> PriceDetails? { + guard let renewalPriceDetails = await customerCenterStoreKitUtilities.renewalPriceFromRenewalInfo( + for: product + ) else { + return nil + } + + let formatter = NumberFormatter() + formatter.numberStyle = .currency + formatter.currencyCode = renewalPriceDetails.currencyCode + + guard let formattedPrice = formatter.string(from: renewalPriceDetails.price as NSNumber) else { return nil } + + return .paid(formattedPrice) + } +} + fileprivate extension EntitlementInfo { func priceBestEffort(product: StoreProduct?) -> PurchaseInformation.PriceDetails { diff --git a/RevenueCatUI/CustomerCenter/Utilities/CustomerCenterStoreKitUtilities.swift b/RevenueCatUI/CustomerCenter/Utilities/CustomerCenterStoreKitUtilities.swift new file mode 100644 index 0000000000..74cee7dde9 --- /dev/null +++ b/RevenueCatUI/CustomerCenter/Utilities/CustomerCenterStoreKitUtilities.swift @@ -0,0 +1,89 @@ +// +// 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 +// +// CustomerCenterStoreKitUtilities.swift +// +// Created by Will Taylor on 12/18/24. + +import Foundation +import StoreKit + +import RevenueCat + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +class CustomerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType { + + func renewalPriceFromRenewalInfo(for product: StoreProduct) async -> (price: Decimal, currencyCode: String)? { + + #if compiler(>=6.0) + guard let renewalInfo = await renewalInfo(for: product) else { return nil } + guard let renewalPrice = renewalInfo.renewalPrice else { return nil } + guard let currencyCode = currencyCode(fromRenewalInfo: renewalInfo) else { return nil } + + return (renewalPrice, currencyCode) + #else + return nil + #endif + } + + private func currencyCode( + fromRenewalInfo renewalInfo: Product.SubscriptionInfo.RenewalInfo, + locale: Locale = Locale.current + ) -> String? { + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, watchOSApplicationExtension 9.0, *) { + + // renewalInfo.currency was introduced in iOS 18.0 and backdeployed through iOS 16.0 + // However, Xcode versions <15.0 don't have the symbols, so we need to check the compiler version + // to make sure that this is being built with an Xcode version >=15.0. + #if compiler(>=6.0) + guard let currency = renewalInfo.currency else { return nil } + if currency.isISOCurrency { + return currency.identifier + } else { + return nil + } + #else + return nil + #endif + } else { + #if os(visionOS) || compiler(<6.0) + return nil + #else + return renewalInfo.currencyCode + #endif + } + } + + private func renewalInfo( + for product: RevenueCat.StoreProduct + ) async -> Product.SubscriptionInfo.RenewalInfo? { + guard let statuses = try? await product.sk2Product?.subscription?.status, !statuses.isEmpty else { + // If StoreKit.Product.subscription is nil, then the product isn't a subscription + // If statuses is empty, the subscriber was never subscribed to a product in the subscription group. + return nil + } + + guard let purchaseSubscriptionStatus = statuses.first(where: { + do { + return try $0.transaction.payloadValue.ownershipType == .purchased + } catch { + return false + } + }) else { + return nil + } + + switch purchaseSubscriptionStatus.renewalInfo { + case .unverified: + return nil + case .verified(let renewalInfo): + return renewalInfo + } + } +} diff --git a/RevenueCatUI/CustomerCenter/Utilities/CustomerCenterStoreKitUtilitiesType.swift b/RevenueCatUI/CustomerCenter/Utilities/CustomerCenterStoreKitUtilitiesType.swift new file mode 100644 index 0000000000..e623de26b2 --- /dev/null +++ b/RevenueCatUI/CustomerCenter/Utilities/CustomerCenterStoreKitUtilitiesType.swift @@ -0,0 +1,23 @@ +// +// 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 +// +// CustomerCenterStoreKitUtilitiesType.swift +// +// Created by Will Taylor on 12/18/24. + +import Foundation +import StoreKit + +import RevenueCat + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +protocol CustomerCenterStoreKitUtilitiesType { + + func renewalPriceFromRenewalInfo(for product: StoreProduct) async -> (price: Decimal, currencyCode: String)? +} diff --git a/RevenueCatUI/CustomerCenter/ViewModels/CustomerCenterViewModel.swift b/RevenueCatUI/CustomerCenter/ViewModels/CustomerCenterViewModel.swift index 10e8684fab..069cc09e73 100644 --- a/RevenueCatUI/CustomerCenter/ViewModels/CustomerCenterViewModel.swift +++ b/RevenueCatUI/CustomerCenter/ViewModels/CustomerCenterViewModel.swift @@ -34,6 +34,7 @@ import RevenueCat @Published private(set) var appIsLatestVersion: Bool = defaultAppIsLatestVersion private(set) var purchasesProvider: CustomerCenterPurchasesType + private(set) var customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType @Published private(set) var onUpdateAppClick: (() -> Void)? @@ -79,12 +80,14 @@ import RevenueCat currentVersionFetcher: @escaping CurrentVersionFetcher = { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String }, - purchasesProvider: CustomerCenterPurchasesType = CustomerCenterPurchases() + purchasesProvider: CustomerCenterPurchasesType = CustomerCenterPurchases(), + customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType = CustomerCenterStoreKitUtilities() ) { self.state = .notLoaded self.currentVersionFetcher = currentVersionFetcher self.customerCenterActionHandler = customerCenterActionHandler self.purchasesProvider = purchasesProvider + self.customerCenterStoreKitUtilities = customerCenterStoreKitUtilities } #if DEBUG @@ -209,10 +212,11 @@ private extension CustomerCenterViewModel { entitlement: EntitlementInfo?) async throws -> PurchaseInformation { if transaction.store == .appStore { if let product = await purchasesProvider.products([transaction.productIdentifier]).first { - return PurchaseInformation( + return await PurchaseInformation.purchaseInformationUsingRenewalInfo( entitlement: entitlement, subscribedProduct: product, - transaction: transaction + transaction: transaction, + customerCenterStoreKitUtilities: customerCenterStoreKitUtilities ) } else { Logger.warning( diff --git a/Sources/CustomerCenter/CustomerCenterConfigData.swift b/Sources/CustomerCenter/CustomerCenterConfigData.swift index bb1afc3b9d..a2cc6a69ad 100644 --- a/Sources/CustomerCenter/CustomerCenterConfigData.swift +++ b/Sources/CustomerCenter/CustomerCenterConfigData.swift @@ -122,7 +122,7 @@ public struct CustomerCenterConfigData { case .billingCycle: return "Billing cycle" case .currentPrice: - return "Current price" + return "Price" case .expired: return "Expired" case .expires: diff --git a/Tests/RevenueCatUITests/CustomerCenter/CustomerCenterViewModelTests.swift b/Tests/RevenueCatUITests/CustomerCenter/CustomerCenterViewModelTests.swift index 02e2c679ed..ad5ef7ed16 100644 --- a/Tests/RevenueCatUITests/CustomerCenter/CustomerCenterViewModelTests.swift +++ b/Tests/RevenueCatUITests/CustomerCenter/CustomerCenterViewModelTests.swift @@ -837,6 +837,44 @@ class CustomerCenterViewModelTests: TestCase { expect(viewModel.shouldShowAppUpdateWarnings).to(beFalse()) } + func testPurchaseInformationIsStillLoadedIfRenewalInfoCantBeFetched() async { + let mockPurchases = MockCustomerCenterPurchases() + let mockStoreKitUtilities = MockCustomerCenterStoreKitUtilities() + + let viewModel = CustomerCenterViewModel( + customerCenterActionHandler: nil, + currentVersionFetcher: { return "3.0.0" }, + purchasesProvider: mockPurchases, + customerCenterStoreKitUtilities: mockStoreKitUtilities as CustomerCenterStoreKitUtilitiesType + ) + + expect(mockStoreKitUtilities.returnRenewalPriceFromRenewalInfo).to(beNil()) + + await viewModel.loadScreen() + + expect(viewModel.purchaseInformation).toNot(beNil()) + expect(mockStoreKitUtilities.renewalPriceFromRenewalInfoCallCount).to(equal(1)) + } + + func testPurchaseInformationUsesInfoFromRenewalInfoWhenAvailable() async { + let mockPurchases = MockCustomerCenterPurchases() + let mockStoreKitUtilities = MockCustomerCenterStoreKitUtilities() + mockStoreKitUtilities.returnRenewalPriceFromRenewalInfo = (5, "USD") + + let viewModel = CustomerCenterViewModel( + customerCenterActionHandler: nil, + currentVersionFetcher: { return "3.0.0" }, + purchasesProvider: mockPurchases, + customerCenterStoreKitUtilities: mockStoreKitUtilities as CustomerCenterStoreKitUtilitiesType + ) + + expect(mockStoreKitUtilities.returnRenewalPriceFromRenewalInfo).to(equal((5, "USD"))) + + await viewModel.loadScreen() + + expect(viewModel.purchaseInformation?.price).to(equal(.paid("$5.00"))) + expect(mockStoreKitUtilities.renewalPriceFromRenewalInfoCallCount).to(equal(1)) + } } @available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *) diff --git a/Tests/RevenueCatUITests/CustomerCenter/MockCustomerCenterPurchases.swift b/Tests/RevenueCatUITests/CustomerCenter/Mocks/MockCustomerCenterPurchases.swift similarity index 100% rename from Tests/RevenueCatUITests/CustomerCenter/MockCustomerCenterPurchases.swift rename to Tests/RevenueCatUITests/CustomerCenter/Mocks/MockCustomerCenterPurchases.swift diff --git a/Tests/RevenueCatUITests/CustomerCenter/Mocks/MockCustomerCenterStoreKitUtilities.swift b/Tests/RevenueCatUITests/CustomerCenter/Mocks/MockCustomerCenterStoreKitUtilities.swift new file mode 100644 index 0000000000..08b3336176 --- /dev/null +++ b/Tests/RevenueCatUITests/CustomerCenter/Mocks/MockCustomerCenterStoreKitUtilities.swift @@ -0,0 +1,29 @@ +// +// 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 +// +// MockCustomerCenterStoreKitUtilities.swift +// +// Created by Will Taylor on 12/18/24. + +import Foundation +import StoreKit + +import RevenueCat +@testable import RevenueCatUI + +@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *) +class MockCustomerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType { + + var returnRenewalPriceFromRenewalInfo: (price: Decimal, currencyCode: String)? + var renewalPriceFromRenewalInfoCallCount = 0 + func renewalPriceFromRenewalInfo(for product: StoreProduct) async -> (price: Decimal, currencyCode: String)? { + renewalPriceFromRenewalInfoCallCount += 1 + return returnRenewalPriceFromRenewalInfo + } +}