Skip to content

Commit

Permalink
Refactors the creation of the subscription details in Customer Center (
Browse files Browse the repository at this point in the history
  • Loading branch information
vegaro authored Dec 11, 2024
1 parent ec025b6 commit 24d6660
Show file tree
Hide file tree
Showing 13 changed files with 951 additions and 784 deletions.
8 changes: 4 additions & 4 deletions RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@
3592E8902C2ED5C100D7F91D /* CustomerCenterConfigAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3592E88F2C2ED5C100D7F91D /* CustomerCenterConfigAPI.swift */; };
359E8E3F26DEBEEB00B869F9 /* TrialOrIntroPriceEligibilityChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 359E8E3E26DEBEEB00B869F9 /* TrialOrIntroPriceEligibilityChecker.swift */; };
35A99C832CCB95950074AB41 /* SubscriptionInformationFixtures.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A99C822CCB95950074AB41 /* SubscriptionInformationFixtures.swift */; };
35A99C842CCB95A70074AB41 /* SubscriptionInformationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A99C802CCB8D530074AB41 /* SubscriptionInformationTests.swift */; };
35A99C842CCB95A70074AB41 /* PurchaseInformationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35A99C802CCB8D530074AB41 /* PurchaseInformationTests.swift */; };
35AAEB452BBB14D000A12548 /* DiagnosticsFileHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35AAEB442BBB14D000A12548 /* DiagnosticsFileHandler.swift */; };
35AAEB492BBB17B500A12548 /* DiagnosticsEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35AAEB482BBB17B500A12548 /* DiagnosticsEvent.swift */; };
35AAEB4C2BBC39D100A12548 /* DiagnosticsFileHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35AAEB4A2BBC380600A12548 /* DiagnosticsFileHandlerTests.swift */; };
Expand Down Expand Up @@ -1566,7 +1566,7 @@
3597020F24BF6A710010506E /* TransactionsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionsFactory.swift; sourceTree = "<group>"; };
3597021124BF6AAC0010506E /* TransactionsFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionsFactoryTests.swift; sourceTree = "<group>"; };
359E8E3E26DEBEEB00B869F9 /* TrialOrIntroPriceEligibilityChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrialOrIntroPriceEligibilityChecker.swift; sourceTree = "<group>"; };
35A99C802CCB8D530074AB41 /* SubscriptionInformationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionInformationTests.swift; sourceTree = "<group>"; };
35A99C802CCB8D530074AB41 /* PurchaseInformationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseInformationTests.swift; sourceTree = "<group>"; };
35A99C822CCB95950074AB41 /* SubscriptionInformationFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionInformationFixtures.swift; sourceTree = "<group>"; };
35AAEB442BBB14D000A12548 /* DiagnosticsFileHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsFileHandler.swift; sourceTree = "<group>"; };
35AAEB482BBB17B500A12548 /* DiagnosticsEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticsEvent.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3681,7 +3681,7 @@
3544DA692C2C848E00704E9D /* CustomerCenterViewModelTests.swift */,
3544DA6A2C2C848E00704E9D /* ManageSubscriptionsViewModelTests.swift */,
1E2F911A2CC918ED00BDB016 /* ContactSupportUtilitiesTests.swift */,
35A99C802CCB8D530074AB41 /* SubscriptionInformationTests.swift */,
35A99C802CCB8D530074AB41 /* PurchaseInformationTests.swift */,
);
path = CustomerCenter;
sourceTree = "<group>";
Expand Down Expand Up @@ -6661,7 +6661,7 @@
35A99C832CCB95950074AB41 /* SubscriptionInformationFixtures.swift in Sources */,
887A633B2C1D177800E1A461 /* PaywallViewLocalizationTests.swift in Sources */,
887A633C2C1D177800E1A461 /* Template1ViewTests.swift in Sources */,
35A99C842CCB95A70074AB41 /* SubscriptionInformationTests.swift in Sources */,
35A99C842CCB95A70074AB41 /* PurchaseInformationTests.swift in Sources */,
777FB4882C661C0600CD4749 /* SemanticVersionTests.swift in Sources */,
88AD4C482C24E8EA00943C3E /* ExternalPurchaseAndRestoreTests.swift in Sources */,
887A633D2C1D177800E1A461 /* Template2ViewTests.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,7 @@ enum CustomerCenterConfigTestData {
price: .paid("$4.99"),
expirationOrRenewal: .init(label: .nextBillingDate,
date: .date("June 1st, 2024")),
willRenew: true,
productIdentifier: "product_id",
active: true,
store: .appStore
)

Expand All @@ -148,9 +146,7 @@ enum CustomerCenterConfigTestData {
price: .paid("$49.99"),
expirationOrRenewal: .init(label: .expires,
date: .date("June 1st, 2024")),
willRenew: false,
productIdentifier: "product_id",
active: true,
store: .appStore
)

Expand Down
111 changes: 64 additions & 47 deletions RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,7 @@ struct PurchaseInformation {
explanation: Explanation,
price: PriceDetails,
expirationOrRenewal: ExpirationOrRenewal?,
willRenew: Bool,
productIdentifier: String,
active: Bool,
store: Store
) {
self.title = title
Expand All @@ -46,60 +44,46 @@ struct PurchaseInformation {
self.store = store
}

init(explanation: Explanation,
price: PriceDetails,
expirationOrRenewal: ExpirationOrRenewal?,
willRenew: Bool,
productIdentifier: String,
active: Bool,
store: Store
) {
self.title = nil
self.durationTitle = nil
self.explanation = explanation
self.price = price
self.expirationOrRenewal = expirationOrRenewal
self.productIdentifier = productIdentifier
self.store = store
}

init(entitlement: EntitlementInfo,
init(entitlement: EntitlementInfo? = nil,
subscribedProduct: StoreProduct? = nil,
transaction: Transaction,
dateFormatter: DateFormatter = DateFormatter()) {
dateFormatter.dateStyle = .medium

// Title and duration from product if available
self.title = subscribedProduct?.localizedTitle
self.explanation = entitlement.explanation
self.durationTitle = subscribedProduct?.subscriptionPeriod?.durationTitle
self.price = entitlement.priceBestEffort(product: subscribedProduct)
self.expirationOrRenewal = entitlement.expirationOrRenewal(dateFormatter: dateFormatter)
self.productIdentifier = entitlement.productIdentifier
self.store = entitlement.store
}

init(product: StoreProduct,
expirationDate: Date?,
dateFormatter: DateFormatter = DateFormatter()) {
// We don't have enough information to determine if the subscription will renew or not because we
// are loading the information from the product without entitlement information.
// We also assume that the subscription is active.
// We also assume that the subscription will renew the earliest possible renewal date and this is the
// product with the earliest renewal date.
dateFormatter.dateStyle = .medium

self.title = product.localizedTitle
self.explanation = .earliestRenewal
self.durationTitle = product.subscriptionPeriod?.durationTitle
self.price = .paid(product.localizedPriceString)
if let dateString = expirationDate.map({ dateFormatter.string(from: $0) }) {
let date = PurchaseInformation.ExpirationOrRenewal.Date.date(dateString)
self.expirationOrRenewal = PurchaseInformation.ExpirationOrRenewal(label: .expires,
date: date)
// Use entitlement data if available, otherwise derive from transaction
if let entitlement = entitlement {
self.explanation = entitlement.explanation
self.expirationOrRenewal = entitlement.expirationOrRenewal(dateFormatter: dateFormatter)
self.productIdentifier = entitlement.productIdentifier
self.store = entitlement.store
self.price = entitlement.priceBestEffort(product: subscribedProduct)
} else {
self.expirationOrRenewal = nil
switch transaction.type {
case .subscription(let isActive, let willRenew, let expiresDate):
self.explanation = expiresDate != nil
? (isActive ? (willRenew ? .earliestRenewal : .earliestExpiration) : .expired)
: .lifetime
self.expirationOrRenewal = expiresDate.map { date in
let dateString = dateFormatter.string(from: date)
let label: ExpirationOrRenewal.Label = isActive
? (willRenew ? .nextBillingDate : .expires)
: .expired
return ExpirationOrRenewal(label: label, date: .date(dateString))
}
case .nonSubscription:
self.explanation = .lifetime
self.expirationOrRenewal = nil
}

self.productIdentifier = transaction.productIdentifier
self.store = transaction.store
self.price = transaction.store == .promotional ? .free
: (subscribedProduct.map { .paid($0.localizedPriceString) } ?? .unknown)
}
self.productIdentifier = product.productIdentifier
self.store = .appStore
}

struct ExpirationOrRenewal {
Expand Down Expand Up @@ -236,3 +220,36 @@ fileprivate extension String {
}

}

protocol Transaction {

var productIdentifier: String { get }
var store: Store { get }
var type: TransactionType { get }

}

enum TransactionType {

case subscription(isActive: Bool, willRenew: Bool, expiresDate: Date?)
case nonSubscription

}

extension SubscriptionInfo: Transaction {

var type: TransactionType {
.subscription(isActive: isActive,
willRenew: willRenew,
expiresDate: expiresDate)
}

}

extension NonSubscriptionTransaction: Transaction {

var type: TransactionType {
.nonSubscription
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,14 @@ import RevenueCat
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@MainActor class CustomerCenterViewModel: ObservableObject {
// We fail open.

private static let defaultAppIsLatestVersion = true

typealias CurrentVersionFetcher = () -> String?

private lazy var currentAppVersion: String? = currentVersionFetcher()

@Published
private(set) var hasActiveProducts: Bool = false
@Published
private(set) var hasAppleEntitlement: Bool = false
private(set) var purchaseInformation: PurchaseInformation?
@Published
private(set) var appIsLatestVersion: Bool = defaultAppIsLatestVersion
private(set) var purchasesProvider: CustomerCenterPurchasesType
Expand Down Expand Up @@ -61,7 +58,7 @@ import RevenueCat
}

self.appIsLatestVersion = currentVersion >= latestVersion
}
}
}

var isLoaded: Bool {
Expand Down Expand Up @@ -89,25 +86,41 @@ import RevenueCat
#if DEBUG

convenience init(
hasActiveProducts: Bool = false,
hasAppleEntitlement: Bool = false
purchaseInformation: PurchaseInformation,
configuration: CustomerCenterConfigData
) {
self.init(customerCenterActionHandler: nil)
self.hasActiveProducts = hasActiveProducts
self.hasAppleEntitlement = hasAppleEntitlement
self.purchaseInformation = purchaseInformation
self.configuration = configuration
self.state = .success
}

#endif

func loadHasActivePurchases() async {
func loadPurchaseInformation() async {
do {
let customerInfo = try await purchasesProvider.customerInfo()
self.hasActiveProducts = customerInfo.activeSubscriptions.count > 0 ||
customerInfo.nonSubscriptions.count > 0
self.hasAppleEntitlement = customerInfo.entitlements.active.contains { entitlement in
entitlement.value.store == .appStore
let hasActiveProducts =
!customerInfo.activeSubscriptions.isEmpty || !customerInfo.nonSubscriptions.isEmpty

if !hasActiveProducts {
self.purchaseInformation = nil
self.state = .success
return
}

guard let activeTransaction = findActiveTransaction(customerInfo: customerInfo) else {
Logger.warning(Strings.could_not_find_subscription_information)
self.purchaseInformation = nil
throw CustomerCenterError.couldNotFindSubscriptionInformation
}

let entitlement = customerInfo.entitlements.all.values
.first(where: { $0.productIdentifier == activeTransaction.productIdentifier })

self.purchaseInformation = try await createPurchaseInformation(for: activeTransaction,
entitlement: entitlement)

self.state = .success
} catch {
self.state = .error(error)
Expand Down Expand Up @@ -148,6 +161,60 @@ import RevenueCat

}

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
private extension CustomerCenterViewModel {

func findActiveTransaction(customerInfo: CustomerInfo) -> Transaction? {
let activeSubscriptions = customerInfo.subscriptionsByProductIdentifier.values
.filter(\.isActive)
.sorted(by: {
guard let date1 = $0.expiresDate, let date2 = $1.expiresDate else {
return $0.expiresDate != nil
}
return date1 < date2
})

let (activeAppleSubscriptions, otherActiveSubscriptions) = (
activeSubscriptions.filter { $0.store == .appStore },
activeSubscriptions.filter { $0.store != .appStore }
)

let (appleNonSubscriptions, otherNonSubscriptions) = (
customerInfo.nonSubscriptions.filter { $0.store == .appStore },
customerInfo.nonSubscriptions.filter { $0.store != .appStore }
)

return activeAppleSubscriptions.first ??
appleNonSubscriptions.first ??
otherActiveSubscriptions.first ??
otherNonSubscriptions.first
}

func createPurchaseInformation(for transaction: Transaction,
entitlement: EntitlementInfo?) async throws -> PurchaseInformation {
if transaction.store == .appStore {
guard let product = await purchasesProvider.products([transaction.productIdentifier]).first else {
Logger.warning(Strings.could_not_find_subscription_information)
throw CustomerCenterError.couldNotFindSubscriptionInformation
}
return PurchaseInformation(
entitlement: entitlement,
subscribedProduct: product,
transaction: transaction
)
} else {
return PurchaseInformation(
entitlement: entitlement,
transaction: transaction
)
}
}

}

fileprivate extension String {
/// Takes the first characters of this string, if they conform to Major.Minor.Patch. Returns nil otherwise.
/// Note that Minor and Patch are optional. So if this string starts with a single number, that number is returned.
Expand Down
Loading

0 comments on commit 24d6660

Please sign in to comment.