-
Notifications
You must be signed in to change notification settings - Fork 328
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
Changes from 20 commits
1f608ee
4d0342f
1fc7513
b8aaec4
c6e7d9f
78d03c5
3aeb44f
b15755d
d26883d
31501b9
9c452eb
0725da8
a3a26a7
524bd07
52479a1
4154ddf
a2dcd7a
5635d08
38264d5
61c699d
73ee192
716f6a4
2445eb1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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. | ||
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, | ||
customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType | ||
) async -> PurchaseInformation { | ||
let renewalPriceDetails = await Self.extractPriceDetailsFromRenwalInfo( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we also need to extract the date from the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we have to since we're already calculating it accurately from the transaction today, so I think we can keep doing that to keep the size of the change down. Even in the scenario where you switch product durations (say you switch from a monthly product to a yearly), the new product won't be active until the the renewal at the end of the current subscription period. Do you know of any edge cases where the current renewal date calculation is inaccurate? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh right I was thinking of cases where the product change would happen before the end of the period, but in that case, it would be immediate and it would be showing the new product. So I think this makes sense! |
||
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 extractPriceDetailsFromRenwalInfo( | ||
fire-at-will marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 { | ||
|
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm honestly not sure, but |
||
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)? | ||
} |
There was a problem hiding this comment.
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