Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use SK2 RenewalInfo to get renewal prices & currency #4608

Merged
merged 23 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -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 */; };
Expand Down Expand Up @@ -2300,6 +2303,7 @@
FD33CD5F2D03500C000D13A4 /* CustomerCenterUIKitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterUIKitView.swift; sourceTree = "<group>"; };
FD43D2FA2C4185B700077235 /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = "<group>"; };
FD43D2FD2C41867600077235 /* TimeInterval+ExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+ExtensionsTests.swift"; sourceTree = "<group>"; };
FD6186532D1393FA007843DA /* MockCustomerCenterStoreKitUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockCustomerCenterStoreKitUtilities.swift; sourceTree = "<group>"; };
FDAADFCA2BE2A5BF00BD1659 /* MockAllTransactionsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockAllTransactionsProvider.swift; sourceTree = "<group>"; };
FDAADFCE2BE2B84500BD1659 /* StoreKit2ObserverModePurchaseDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2ObserverModePurchaseDetector.swift; sourceTree = "<group>"; };
FDAADFD22BE2B99900BD1659 /* MockStoreKit2ObserverModePurchaseDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreKit2ObserverModePurchaseDetector.swift; sourceTree = "<group>"; };
Expand All @@ -2308,6 +2312,8 @@
FDAC7B542CD3D7A200DFC0D9 /* WinBackOfferEligibilityCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WinBackOfferEligibilityCalculator.swift; sourceTree = "<group>"; };
FDAC7B562CD3FD8500DFC0D9 /* MockWinBackOfferEligibilityCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockWinBackOfferEligibilityCalculator.swift; sourceTree = "<group>"; };
FDAC7B5A2CD4085800DFC0D9 /* PurchasesWinBackOfferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesWinBackOfferTests.swift; sourceTree = "<group>"; };
FDAD6AC62D132DD900FB047E /* CustomerCenterStoreKitUtilitiesType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterStoreKitUtilitiesType.swift; sourceTree = "<group>"; };
FDAD6AC82D132E6500FB047E /* CustomerCenterStoreKitUtilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerCenterStoreKitUtilities.swift; sourceTree = "<group>"; };
FDC892D02CCAD0EE000AEB9F /* MockStoreKit2PurchaseIntentListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreKit2PurchaseIntentListener.swift; sourceTree = "<group>"; };
FDC892FD2CD157F1000AEB9F /* WinBackOffer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WinBackOffer.swift; sourceTree = "<group>"; };
FECF627761D375C8431EB866 /* StoreProduct.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreProduct.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3573,6 +3579,7 @@
353756642C382C2800A1B8D6 /* CustomerCenter */ = {
isa = PBXGroup;
children = (
FDAD6AC52D132DC600FB047E /* Utilities */,
35653BD32C46803A009E8ADB /* Abstractions */,
353756562C382C2800A1B8D6 /* Data */,
3537565A2C382C2800A1B8D6 /* ViewModels */,
Expand Down Expand Up @@ -3679,7 +3686,7 @@
3544DA6B2C2C848E00704E9D /* CustomerCenter */ = {
isa = PBXGroup;
children = (
356523A92CF885CE00B6E3EA /* MockCustomerCenterPurchases.swift */,
FD6186522D1393E2007843DA /* Mocks */,
35A99C822CCB95950074AB41 /* SubscriptionInformationFixtures.swift */,
3544DA692C2C848E00704E9D /* CustomerCenterViewModelTests.swift */,
3544DA6A2C2C848E00704E9D /* ManageSubscriptionsViewModelTests.swift */,
Expand Down Expand Up @@ -4977,6 +4984,15 @@
path = "UIKit Compatibility";
sourceTree = "<group>";
};
FD6186522D1393E2007843DA /* Mocks */ = {
isa = PBXGroup;
children = (
356523A92CF885CE00B6E3EA /* MockCustomerCenterPurchases.swift */,
FD6186532D1393FA007843DA /* MockCustomerCenterStoreKitUtilities.swift */,
);
path = Mocks;
sourceTree = "<group>";
};
FDAADFCD2BE2B82D00BD1659 /* Observer Mode */ = {
isa = PBXGroup;
children = (
Expand All @@ -4994,6 +5010,15 @@
path = "Win-Back Offers";
sourceTree = "<group>";
};
FDAD6AC52D132DC600FB047E /* Utilities */ = {
isa = PBXGroup;
children = (
FDAD6AC62D132DD900FB047E /* CustomerCenterStoreKitUtilitiesType.swift */,
FDAD6AC82D132E6500FB047E /* CustomerCenterStoreKitUtilities.swift */,
);
path = Utilities;
sourceTree = "<group>";
};
/* End PBXGroup section */

/* Begin PBXHeadersBuildPhase section */
Expand Down Expand Up @@ -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 */,
Expand All @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
73 changes: 70 additions & 3 deletions RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

import Foundation
import RevenueCat
import StoreKit

// swiftlint:disable nesting
struct PurchaseInformation {
Expand Down Expand Up @@ -47,6 +48,7 @@ struct PurchaseInformation {
init(entitlement: EntitlementInfo? = nil,
subscribedProduct: StoreProduct? = nil,
transaction: Transaction,
renewalPrice: PriceDetails? = nil,
dateFormatter: DateFormatter = DateFormatter()) {
dateFormatter.dateStyle = .medium

Expand All @@ -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)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fall back to our old calculation method if the RenewalInfo method failed for some reason

}
} else {
switch transaction.type {
case .subscription(let isActive, let willRenew, let expiresDate):
Expand All @@ -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
}
}
}
}

Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

interesting, what's the other option if it's not ISO? just curious

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm honestly not sure, but NumberFormatter.currencyCode depends on the currency code being a ISO 4217 currency code, so I figured it was worth a check here

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
}
}
}
Original file line number Diff line number Diff line change
@@ -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)?
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)?
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion Sources/CustomerCenter/CustomerCenterConfigData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading