2024-08-09 15:26:55 +02:00
|
|
|
//
|
|
|
|
|
// 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
|
|
|
|
|
//
|
|
|
|
|
// ManageSubscriptionsViewModel.swift
|
|
|
|
|
//
|
|
|
|
|
//
|
|
|
|
|
// Created by Cesar de la Vega on 27/5/24.
|
|
|
|
|
//
|
|
|
|
|
|
|
|
|
|
import Foundation
|
2025-02-25 13:13:49 +01:00
|
|
|
@_spi(Internal) import RevenueCat
|
2024-09-25 16:16:26 +02:00
|
|
|
import SwiftUI
|
2024-08-09 15:26:55 +02:00
|
|
|
|
|
|
|
|
#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)
|
|
|
|
|
@MainActor
|
2025-02-07 11:13:13 +01:00
|
|
|
final class ManageSubscriptionsViewModel: ObservableObject {
|
2024-08-09 15:26:55 +02:00
|
|
|
|
|
|
|
|
let screen: CustomerCenterConfigData.Screen
|
2025-02-07 11:14:37 +01:00
|
|
|
|
|
|
|
|
var relevantPathsForPurchase: [CustomerCenterConfigData.HelpPath] {
|
2025-02-25 13:13:49 +01:00
|
|
|
paths.relevantPathsForPurchase(purchaseInformation)
|
2025-02-07 11:14:37 +01:00
|
|
|
}
|
2024-08-09 15:26:55 +02:00
|
|
|
|
|
|
|
|
@Published
|
|
|
|
|
var showRestoreAlert: Bool = false
|
2025-02-07 11:13:13 +01:00
|
|
|
|
2025-01-21 10:06:06 +01:00
|
|
|
@Published
|
|
|
|
|
var showPurchases: Bool = false
|
|
|
|
|
|
2024-08-09 15:26:55 +02:00
|
|
|
@Published
|
|
|
|
|
var feedbackSurveyData: FeedbackSurveyData?
|
2025-02-07 11:13:13 +01:00
|
|
|
|
2024-08-09 15:26:55 +02:00
|
|
|
@Published
|
|
|
|
|
var loadingPath: CustomerCenterConfigData.HelpPath?
|
2025-02-07 11:13:13 +01:00
|
|
|
|
2024-08-09 15:26:55 +02:00
|
|
|
@Published
|
|
|
|
|
var promotionalOfferData: PromotionalOfferData?
|
2025-02-07 11:13:13 +01:00
|
|
|
|
2024-08-09 15:26:55 +02:00
|
|
|
@Published
|
2024-10-21 15:47:16 +02:00
|
|
|
var inAppBrowserURL: IdentifiableURL?
|
2025-02-07 11:13:13 +01:00
|
|
|
|
2024-10-21 15:47:16 +02:00
|
|
|
@Published
|
2024-08-09 15:26:55 +02:00
|
|
|
var state: CustomerCenterViewState {
|
|
|
|
|
didSet {
|
|
|
|
|
if case let .error(stateError) = state {
|
|
|
|
|
self.error = stateError
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Published
|
2024-11-22 14:42:48 +01:00
|
|
|
private(set) var purchaseInformation: PurchaseInformation?
|
2025-02-07 11:14:37 +01:00
|
|
|
|
2024-08-09 15:26:55 +02:00
|
|
|
@Published
|
2024-10-02 15:38:11 +02:00
|
|
|
private(set) var refundRequestStatus: RefundRequestStatus?
|
2024-08-09 15:26:55 +02:00
|
|
|
|
|
|
|
|
private let customerCenterActionHandler: CustomerCenterActionHandler?
|
|
|
|
|
private var error: Error?
|
2025-02-07 11:14:37 +01:00
|
|
|
private let loadPromotionalOfferUseCase: LoadPromotionalOfferUseCaseType
|
|
|
|
|
private let paths: [CustomerCenterConfigData.HelpPath]
|
|
|
|
|
private var purchasesProvider: ManageSubscriptionsPurchaseType
|
2024-08-09 15:26:55 +02:00
|
|
|
|
2025-02-25 13:13:49 +01:00
|
|
|
init(
|
|
|
|
|
screen: CustomerCenterConfigData.Screen,
|
|
|
|
|
customerCenterActionHandler: CustomerCenterActionHandler?,
|
|
|
|
|
purchaseInformation: PurchaseInformation? = nil,
|
|
|
|
|
refundRequestStatus: RefundRequestStatus? = nil,
|
|
|
|
|
purchasesProvider: ManageSubscriptionsPurchaseType = ManageSubscriptionPurchases(),
|
|
|
|
|
loadPromotionalOfferUseCase: LoadPromotionalOfferUseCaseType? = nil) {
|
|
|
|
|
self.screen = screen
|
|
|
|
|
self.paths = screen.filteredPaths
|
|
|
|
|
self.purchaseInformation = purchaseInformation
|
|
|
|
|
self.purchasesProvider = ManageSubscriptionPurchases()
|
|
|
|
|
self.refundRequestStatus = refundRequestStatus
|
|
|
|
|
self.customerCenterActionHandler = customerCenterActionHandler
|
|
|
|
|
self.loadPromotionalOfferUseCase = loadPromotionalOfferUseCase ?? LoadPromotionalOfferUseCase()
|
|
|
|
|
self.state = .success
|
|
|
|
|
}
|
2024-08-09 15:26:55 +02:00
|
|
|
|
|
|
|
|
#if os(iOS) || targetEnvironment(macCatalyst)
|
|
|
|
|
func determineFlow(for path: CustomerCenterConfigData.HelpPath) async {
|
|
|
|
|
switch path.detail {
|
|
|
|
|
case let .feedbackSurvey(feedbackSurvey):
|
2024-12-10 14:31:57 +01:00
|
|
|
self.feedbackSurveyData = FeedbackSurveyData(configuration: feedbackSurvey,
|
|
|
|
|
path: path) { [weak self] in
|
2024-08-09 15:26:55 +02:00
|
|
|
Task {
|
|
|
|
|
await self?.onPathSelected(path: path)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
case let .promotionalOffer(promotionalOffer):
|
|
|
|
|
if promotionalOffer.eligible {
|
|
|
|
|
self.loadingPath = path
|
|
|
|
|
let result = await loadPromotionalOfferUseCase.execute(promoOfferDetails: promotionalOffer)
|
|
|
|
|
switch result {
|
|
|
|
|
case .success(let promotionalOfferData):
|
|
|
|
|
self.promotionalOfferData = promotionalOfferData
|
|
|
|
|
case .failure:
|
|
|
|
|
await self.onPathSelected(path: path)
|
|
|
|
|
self.loadingPath = nil
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
await self.onPathSelected(path: path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
await self.onPathSelected(path: path)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func handleSheetDismiss() async {
|
|
|
|
|
if let loadingPath = loadingPath {
|
|
|
|
|
await self.onPathSelected(path: loadingPath)
|
|
|
|
|
self.loadingPath = nil
|
|
|
|
|
}
|
|
|
|
|
}
|
2024-10-21 15:47:16 +02:00
|
|
|
|
|
|
|
|
func onDismissInAppBrowser() {
|
|
|
|
|
self.inAppBrowserURL = nil
|
|
|
|
|
}
|
2024-08-09 15:26:55 +02:00
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2024-10-21 15:47:16 +02:00
|
|
|
struct IdentifiableURL: Identifiable {
|
|
|
|
|
|
|
|
|
|
var id: String {
|
|
|
|
|
return url.absoluteString
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let url: URL
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2024-11-14 14:17:21 -06:00
|
|
|
// MARK: - Promotional Offer Sheet Dismissal Handling
|
|
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
|
|
|
|
|
@available(macOS, unavailable)
|
|
|
|
|
@available(tvOS, unavailable)
|
|
|
|
|
@available(watchOS, unavailable)
|
|
|
|
|
extension ManageSubscriptionsViewModel {
|
|
|
|
|
|
|
|
|
|
/// Function responsible for handling the user's action on the PromotionalOfferView
|
|
|
|
|
func handleDismissPromotionalOfferView(_ userAction: PromotionalOfferViewAction) async {
|
|
|
|
|
// Clear the promotional offer data to dismiss the sheet
|
|
|
|
|
self.promotionalOfferData = nil
|
|
|
|
|
|
|
|
|
|
if userAction.shouldTerminateCurrentPathFlow {
|
|
|
|
|
self.loadingPath = nil
|
|
|
|
|
} else {
|
|
|
|
|
if let loadingPath = loadingPath {
|
|
|
|
|
await self.onPathSelected(path: loadingPath)
|
|
|
|
|
self.loadingPath = nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-09 15:26:55 +02:00
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
|
|
|
|
|
@available(macOS, unavailable)
|
|
|
|
|
@available(tvOS, unavailable)
|
|
|
|
|
@available(watchOS, unavailable)
|
|
|
|
|
private extension ManageSubscriptionsViewModel {
|
|
|
|
|
|
|
|
|
|
#if os(iOS) || targetEnvironment(macCatalyst)
|
2024-10-21 15:47:16 +02:00
|
|
|
// swiftlint:disable:next cyclomatic_complexity
|
2024-08-09 15:26:55 +02:00
|
|
|
private func onPathSelected(path: CustomerCenterConfigData.HelpPath) async {
|
|
|
|
|
switch path.type {
|
|
|
|
|
case .missingPurchase:
|
|
|
|
|
self.showRestoreAlert = true
|
|
|
|
|
case .refundRequest:
|
|
|
|
|
do {
|
2024-11-22 14:42:48 +01:00
|
|
|
guard let purchaseInformation = self.purchaseInformation else { return }
|
|
|
|
|
let productId = purchaseInformation.productIdentifier
|
2024-08-09 15:26:55 +02:00
|
|
|
self.customerCenterActionHandler?(.refundRequestStarted(productId))
|
|
|
|
|
let status = try await self.purchasesProvider.beginRefundRequest(forProduct: productId)
|
2024-10-02 15:38:11 +02:00
|
|
|
self.refundRequestStatus = status
|
2024-08-09 15:26:55 +02:00
|
|
|
self.customerCenterActionHandler?(.refundRequestCompleted(status))
|
|
|
|
|
} catch {
|
2024-10-02 15:38:11 +02:00
|
|
|
self.refundRequestStatus = .error
|
2024-08-09 15:26:55 +02:00
|
|
|
self.customerCenterActionHandler?(.refundRequestCompleted(.error))
|
|
|
|
|
}
|
|
|
|
|
case .changePlans, .cancel:
|
|
|
|
|
do {
|
|
|
|
|
self.customerCenterActionHandler?(.showingManageSubscriptions)
|
|
|
|
|
try await purchasesProvider.showManageSubscriptions()
|
|
|
|
|
} catch {
|
|
|
|
|
self.state = .error(error)
|
|
|
|
|
}
|
2024-10-21 15:47:16 +02:00
|
|
|
case .customUrl:
|
|
|
|
|
guard let url = path.url,
|
2024-11-22 14:42:48 +01:00
|
|
|
let openMethod = path.openMethod else {
|
2024-10-21 15:47:16 +02:00
|
|
|
Logger.warning("Found a custom URL path without a URL or open method. Ignoring tap.")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
switch openMethod {
|
2024-12-04 18:20:55 +01:00
|
|
|
case .external,
|
2024-12-04 19:32:46 +01:00
|
|
|
_ where !url.isWebLink:
|
2024-12-04 14:59:06 +01:00
|
|
|
URLUtilities.openURLIfNotAppExtension(url)
|
2024-10-21 15:47:16 +02:00
|
|
|
case .inApp:
|
|
|
|
|
self.inAppBrowserURL = .init(url: url)
|
2024-12-04 18:20:55 +01:00
|
|
|
@unknown default:
|
|
|
|
|
Logger.warning(Strings.could_not_determine_type_of_custom_url)
|
|
|
|
|
URLUtilities.openURLIfNotAppExtension(url)
|
2024-10-21 15:47:16 +02:00
|
|
|
}
|
2024-08-09 15:26:55 +02:00
|
|
|
default:
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
#endif
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, *)
|
|
|
|
|
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, *)
|
|
|
|
|
@available(macOS, unavailable)
|
|
|
|
|
@available(tvOS, unavailable)
|
|
|
|
|
@available(watchOS, unavailable)
|
|
|
|
|
private final class ManageSubscriptionPurchases: ManageSubscriptionsPurchaseType {
|
|
|
|
|
|
|
|
|
|
func beginRefundRequest(forProduct productID: String) async throws -> RevenueCat.RefundRequestStatus {
|
|
|
|
|
try await Purchases.shared.beginRefundRequest(forProduct: productID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func showManageSubscriptions() async throws {
|
|
|
|
|
try await Purchases.shared.showManageSubscriptions()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func customerInfo() async throws -> RevenueCat.CustomerInfo {
|
|
|
|
|
try await Purchases.shared.customerInfo()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func products(_ productIdentifiers: [String]) async -> [StoreProduct] {
|
|
|
|
|
await Purchases.shared.products(productIdentifiers)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2024-12-03 12:39:46 +01:00
|
|
|
private extension CustomerCenterConfigData.Screen {
|
|
|
|
|
|
|
|
|
|
var filteredPaths: [CustomerCenterConfigData.HelpPath] {
|
|
|
|
|
return self.paths.filter { path in
|
2025-02-07 11:13:13 +01:00
|
|
|
return path.type != .unknown
|
2024-12-03 12:39:46 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-25 13:13:49 +01:00
|
|
|
private extension Array<CustomerCenterConfigData.HelpPath> {
|
|
|
|
|
func relevantPathsForPurchase(
|
|
|
|
|
_ purchaseInformation: PurchaseInformation?
|
|
|
|
|
) -> [CustomerCenterConfigData.HelpPath] {
|
|
|
|
|
guard let purchaseInformation else {
|
|
|
|
|
return self
|
|
|
|
|
}
|
|
|
|
|
|
2025-02-25 13:31:58 +01:00
|
|
|
return filter {
|
|
|
|
|
// if it's cancel, it cannot be a lifetime subscription
|
|
|
|
|
($0.type != .cancel || !purchaseInformation.isLifetime) &&
|
|
|
|
|
|
2025-02-26 12:04:21 +01:00
|
|
|
// if it's refundRequest, it cannot be free nor within trial period
|
|
|
|
|
($0.type != .refundRequest || (purchaseInformation.price != .free && !purchaseInformation.isTrial)) &&
|
2025-02-25 13:31:58 +01:00
|
|
|
|
|
|
|
|
// if it has a refundDuration, check it's still valid
|
|
|
|
|
($0.refundWindowDuration?.isWithin(purchaseInformation) ?? true)
|
|
|
|
|
}
|
2025-02-25 13:13:49 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extension CustomerCenterConfigData.HelpPath.RefundWindowDuration {
|
|
|
|
|
func isWithin(_ purchaseInformation: PurchaseInformation) -> Bool {
|
|
|
|
|
switch self {
|
|
|
|
|
case .forever:
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
|
|
case let .duration(duration):
|
|
|
|
|
return duration.isWithin(
|
|
|
|
|
from: purchaseInformation.latestPurchaseDate,
|
|
|
|
|
now: purchaseInformation.customerInfoRequestedDate
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@unknown default:
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private extension ISODuration {
|
|
|
|
|
func isWithin(from startDate: Date?, now: Date) -> Bool {
|
|
|
|
|
guard let startDate else {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var dateComponents = DateComponents()
|
|
|
|
|
dateComponents.year = self.years
|
|
|
|
|
dateComponents.month = self.months
|
|
|
|
|
dateComponents.weekOfYear = self.weeks
|
|
|
|
|
dateComponents.day = self.days
|
|
|
|
|
dateComponents.hour = self.hours
|
|
|
|
|
dateComponents.minute = self.minutes
|
|
|
|
|
dateComponents.second = self.seconds
|
|
|
|
|
|
|
|
|
|
let calendar = Calendar.current
|
|
|
|
|
let endDate = calendar.date(byAdding: dateComponents, to: startDate) ?? startDate
|
|
|
|
|
|
|
|
|
|
return startDate < endDate && now <= endDate
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2024-08-09 15:26:55 +02:00
|
|
|
#endif
|