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 15 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
16 changes: 16 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,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 @@ -2308,6 +2310,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 +3577,7 @@
353756642C382C2800A1B8D6 /* CustomerCenter */ = {
isa = PBXGroup;
children = (
FDAD6AC52D132DC600FB047E /* Utilities */,
35653BD32C46803A009E8ADB /* Abstractions */,
353756562C382C2800A1B8D6 /* Data */,
3537565A2C382C2800A1B8D6 /* ViewModels */,
Expand Down Expand Up @@ -4994,6 +4999,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 +6526,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 +6543,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
113 changes: 110 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,100 @@ struct PurchaseInformation {
}
// swiftlint:enable nesting

extension PurchaseInformation {

/// Provides detailed information about a user's purchase, including renewal price.
fire-at-will marked this conversation as resolved.
Show resolved Hide resolved
///
/// 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
) async -> PurchaseInformation {
let renewalPriceDetails = await Self.extractPriceDetailsFromRenwalInfo(forProduct: subscribedProduct)
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 extractPriceDetailsFromRenwalInfo(
fire-at-will marked this conversation as resolved.
Show resolved Hide resolved
forProduct product: StoreProduct,
customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType = CustomerCenterStoreKitUtilities()
) async -> PriceDetails? {
guard let renewalInfo = await customerCenterStoreKitUtilities.renewalInfo(for: product) else {
return nil
}

#if compiler(>=6.0)
guard let renewalPrice = renewalInfo.renewalPrice as? NSNumber else { return nil }
guard let currencyCode = product.currencyCode else { return nil }

let formatter = NumberFormatter()
formatter.numberStyle = .currency
formatter.currencyCode = currencyCode

guard let formattedPrice = formatter.string(from: renewalPrice) else { return nil }

return .paid(formattedPrice)
#else
return nil
#endif
}

@available(iOS 15.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@available(watchOSApplicationExtension, unavailable)
private static func currencyCode(
fromRenewalInfo renewalInfo: Product.SubscriptionInfo.RenewalInfo,
locale: Locale = Locale.current
) -> String? {
// macOS 13.0 check is required for the compiler despite the function being marked
// as unavailable on macOS
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 #available(macOS 12.0, tvOS 15.0, watchOS 8.0, watchOSApplicationExtension 8.0, *) {
#if os(visionOS) || compiler(<6.0)
return nil
#else
return renewalInfo.currencyCode
#endif
} else {
return nil
}
}
}
}

fileprivate extension EntitlementInfo {

func priceBestEffort(product: StoreProduct?) -> PurchaseInformation.PriceDetails {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//
// 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 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 renewalInfo(for product: StoreProduct) async -> StoreKit.Product.SubscriptionInfo.RenewalInfo?
}
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ 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
Expand Down