Skip to content

Commit

Permalink
use SK2 RenewalInfo to get renewal prices & currency
Browse files Browse the repository at this point in the history
  • Loading branch information
fire-at-will authored Dec 20, 2024
1 parent 10392a0 commit 6c4256a
Show file tree
Hide file tree
Showing 9 changed files with 286 additions and 8 deletions.
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)
}
} 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 {
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

0 comments on commit 6c4256a

Please sign in to comment.