Skip to content

Commit

Permalink
feat: [CustomerCenter] Introduce PurchaseHistory (#4686)
Browse files Browse the repository at this point in the history
  • Loading branch information
facumenzella authored Jan 21, 2025
1 parent 3c5e07f commit 475a72b
Show file tree
Hide file tree
Showing 20 changed files with 1,146 additions and 45 deletions.
56 changes: 56 additions & 0 deletions RevenueCat.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ enum CustomerCenterConfigTestData {
// swiftlint:disable:next function_body_length
static func customerCenterData(
lastPublishedAppVersion: String?,
shouldWarnCustomerToUpdate: Bool = false
shouldWarnCustomerToUpdate: Bool = false,
displayPurchaseHistoryLink: Bool = false
) -> CustomerCenterConfigData {
CustomerCenterConfigData(
screens: [.management:
Expand Down Expand Up @@ -116,7 +117,8 @@ enum CustomerCenterConfigTestData {
),
support: .init(
email: "[email protected]",
shouldWarnCustomerToUpdate: shouldWarnCustomerToUpdate
shouldWarnCustomerToUpdate: shouldWarnCustomerToUpdate,
displayPurchaseHistoryLink: displayPurchaseHistoryLink
),
lastPublishedAppVersion: lastPublishedAppVersion,
productId: 1
Expand Down
32 changes: 32 additions & 0 deletions RevenueCatUI/CustomerCenter/Extensions/Store+Localization.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
//
// 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
//
// Store+Localized.swift
//
//
// Created by Facundo Menzella on 14/1/25.
//
import RevenueCat

extension Store {

var localizationKey: CCLocalizedString {
switch self {
case .appStore: return .storeAppStore
case .macAppStore: return .storeMacAppStore
case .playStore: return .storePlayStore
case .stripe: return .storeStripe
case .promotional: return .storePromotional
case .amazon: return .storeAmazon
case .rcBilling: return .storeRCBilling
case .external: return .storeExternal
case .unknownStore: return .storeUnknownStore
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// 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
//
// CustomerCenterLocalizationStrings.swift
//
// Created by Facundo Menzella on 21/1/25.

import RevenueCat

/// **typealias** for **CustomerCenterConfigData.Localization.CommonLocalizedString**
typealias CCLocalizedString = CustomerCenterConfigData.Localization.CommonLocalizedString
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ class ManageSubscriptionsViewModel: ObservableObject {

@Published
var showRestoreAlert: Bool = false
@Published
var showPurchases: Bool = false

@Published
var feedbackSurveyData: FeedbackSurveyData?
@Published
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// 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
//
// PurchaseDetailItem.swift
//
//
// Created by Facundo Menzella on 14/1/25.
//

import RevenueCat

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
enum PurchaseDetailItem: Identifiable {
case productName(String)
case paidPrice(String?)
case purchaseDate(String)
case status(CCLocalizedString)

case nextRenewalDate(String)
case expiresDate(String)
case unsubscribeDetectedAt(String)
case billingIssuesDetectedAt(String)
case gracePeriodExpiresDate(String)
case periodType(CCLocalizedString)
case refundedAtDate(String)

// DEBUG only
case store(CCLocalizedString)
case productID(String)
case sandbox(Bool)
case transactionID(String)

var label: CCLocalizedString {
switch self {
case .productName: return .productName
case .paidPrice: return .paidPrice
case .purchaseDate: return .originalDownloadDate
case .status: return .status
case .nextRenewalDate: return .nextRenewalDate
case .expiresDate: return .expires
case .unsubscribeDetectedAt: return .unsubscribedAt
case .billingIssuesDetectedAt: return .billingIssueDetectedAt
case .gracePeriodExpiresDate: return .gracePeriodExpiresAt
case .periodType: return .periodType
case .refundedAtDate: return .refundedAt
case .store: return .store
case .productID: return .productID
case .sandbox: return .sandbox
case .transactionID: return .transactionID
}
}

var isDebugOnly: Bool {
switch self {
case .store, .productID, .sandbox, .transactionID:
return true
case .productName,
.paidPrice,
.purchaseDate,
.status,
.nextRenewalDate,
.expiresDate,
.unsubscribeDetectedAt,
.billingIssuesDetectedAt,
.gracePeriodExpiresDate,
.periodType,
.refundedAtDate:
return false
}
}

var id: String {
label.rawValue
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// 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
//
// PurchaseHistoryViewModel.swift
//
//
// Created by Facundo Menzella on 14/1/25.
//

import Foundation
import SwiftUI

import RevenueCat

#if os(iOS)

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
final class PurchaseDetailViewModel: ObservableObject {

@Environment(\.localization)
private var localization: CustomerCenterConfigData.Localization

@Published var items: [PurchaseDetailItem] = []

var localizedOwnership: String? {
switch purchaseInfo {
case .subscription(let subscriptionInfo):
subscriptionInfo.ownershipType == .familyShared
? localization.commonLocalizedString(for: .sharedThroughFamilyMember)
: nil
case .nonSubscription:
nil
}
}

init(purchaseInfo: PurchaseInfo) {
self.purchaseInfo = purchaseInfo
}

func didAppear() async {
await fetchProduct()
}

// MARK: - Private

private let purchaseInfo: PurchaseInfo
}

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

func fetchProduct() async {
guard
let product = await Purchases.shared.products([purchaseInfo.productIdentifier]).first
else {
return
}

await MainActor.run {
var items: [PurchaseDetailItem] = [
.productName(product.localizedTitle)
]

items.append(contentsOf: purchaseInfo.purchaseDetailItems)

self.items = items
}
}
}

#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//
// 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
//
// PurchaseHistoryViewModel.swift
//
//
// Created by Facundo Menzella on 13/1/25.
//

import Foundation
import SwiftUI

import RevenueCat

#if os(iOS)

@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
final class PurchaseHistoryViewModel: ObservableObject {

@Published var selectedPurchase: PurchaseInfo?

@Published var customerInfo: CustomerInfo? {
didSet {
isLoading = false
updateActiveAndNonActiveSubscriptions()
}
}
@Published var errorMessage: String?
@Published var isLoading: Bool = true

var activeSubscriptions: [PurchaseInfo] = []
var inactiveSubscriptions: [PurchaseInfo] = []
var nonSubscriptions: [PurchaseInfo] = []

init(
selectedPurchase: PurchaseInfo? = nil,
customerInfo: CustomerInfo? = nil,
errorMessage: String? = nil,
isLoading: Bool = true,
activeSubscriptions: [PurchaseInfo] = [],
inactiveSubscriptions: [PurchaseInfo] = [],
nonSubscriptions: [PurchaseInfo] = []
) {
self.selectedPurchase = selectedPurchase
self.customerInfo = customerInfo
self.errorMessage = errorMessage
self.isLoading = isLoading
self.activeSubscriptions = activeSubscriptions
self.inactiveSubscriptions = inactiveSubscriptions
self.nonSubscriptions = nonSubscriptions
}

func didAppear() async {
await fetchCustomerInfo()
}
}

@available(iOS 15.0, *)
private extension PurchaseHistoryViewModel {
func fetchCustomerInfo() async {
do {
let customerInfo = try await Purchases.shared.customerInfo()
await MainActor.run {
self.customerInfo = customerInfo
}
} catch {
await MainActor.run {
self.errorMessage = error.localizedDescription
}
}
}

func updateActiveAndNonActiveSubscriptions() {
activeSubscriptions = customerInfo.map {
$0.subscriptionsByProductIdentifier
.filter { $0.value.isActive }
.values
.sorted(by: { sub1, sub2 in
sub1.purchaseDate < sub2.purchaseDate
})
.map {
PurchaseInfo.subscription($0)
}
} ?? []

inactiveSubscriptions = customerInfo.map {
$0.subscriptionsByProductIdentifier
.filter { !$0.value.isActive }
.values
.sorted(by: { sub1, sub2 in
sub1.purchaseDate < sub2.purchaseDate
})
.map {
PurchaseInfo.subscription($0)
}
} ?? []

nonSubscriptions = customerInfo?.nonSubscriptions.map {
.nonSubscription($0)
} ?? []
}
}

#endif
Loading

0 comments on commit 475a72b

Please sign in to comment.