Skip to content

Commit

Permalink
feat: Don't show refund if free subscription (#4805)
Browse files Browse the repository at this point in the history
* Added refund_window

* missing tests

* feat: Support refund_window to hide refund path

* removed docs

* removed docs

* fixed previews

* fixed RC tests

* Update Tests/TestingApps/PaywallsTester/PaywallsTester/Config/ConfigItem.swift

* nits

* Use customercenter reuqested date

* feat: Don't show refund if free

* add comments

* requestDate from customerInfo
  • Loading branch information
facumenzella authored Feb 25, 2025
1 parent 7547196 commit c1e96e0
Show file tree
Hide file tree
Showing 6 changed files with 328 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,20 @@ enum CustomerCenterConfigTestData {
customerInfoRequestedDate: Date()
)

static let subscriptionInformationFree: PurchaseInformation = .init(
title: "Basic",
durationTitle: "Monthly",
explanation: .earliestRenewal,
price: .free,
expirationOrRenewal: .init(label: .nextBillingDate,
date: .date("June 1st, 2024")),
productIdentifier: "product_id",
store: .appStore,
isLifetime: false,
latestPurchaseDate: nil,
customerInfoRequestedDate: Date()
)

static let subscriptionInformationYearlyExpiring: PurchaseInformation = .init(
title: "Basic",
durationTitle: "Yearly",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
//
// 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
//
// CustomerCenterViewModel.swift
//
//
// Created by Cesar de la Vega on 27/5/24.
//

import Foundation
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)
@MainActor class CustomerCenterViewModel: ObservableObject {

private static let defaultAppIsLatestVersion = true

typealias CurrentVersionFetcher = () -> String?

private lazy var currentAppVersion: String? = currentVersionFetcher()

@Published
private(set) var purchaseInformation: PurchaseInformation?

@Published
private(set) var appIsLatestVersion: Bool = defaultAppIsLatestVersion

@Published
private(set) var onUpdateAppClick: (() -> Void)?

private(set) var purchasesProvider: CustomerCenterPurchasesType
private(set) var customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType

/// Whether or not the Customer Center should warn the customer that they're on an outdated version of the app.
var shouldShowAppUpdateWarnings: Bool {
return !appIsLatestVersion && (configuration?.support.shouldWarnCustomerToUpdate ?? true)
}

// @PublicForExternalTesting
@Published
var state: CustomerCenterViewState {
didSet {
if case let .error(stateError) = state {
self.error = stateError
}
}
}
@Published
var configuration: CustomerCenterConfigData? {
didSet {
guard
let currentVersionString = currentAppVersion?.versionString(),
let latestVersionString = configuration?.lastPublishedAppVersion?.versionString(),
let currentVersion = try? SemanticVersion(currentVersionString),
let latestVersion = try? SemanticVersion(latestVersionString)
else {
self.appIsLatestVersion = Self.defaultAppIsLatestVersion
return
}

self.appIsLatestVersion = currentVersion >= latestVersion
}
}

private let currentVersionFetcher: CurrentVersionFetcher
internal let customerCenterActionHandler: CustomerCenterActionHandler?

private var error: Error?
private var impressionData: CustomerCenterEvent.Data?

init(
customerCenterActionHandler: CustomerCenterActionHandler?,
currentVersionFetcher: @escaping CurrentVersionFetcher = {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
},
purchasesProvider: CustomerCenterPurchasesType = CustomerCenterPurchases(),
customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType = CustomerCenterStoreKitUtilities()
) {
self.state = .notLoaded
self.currentVersionFetcher = currentVersionFetcher
self.customerCenterActionHandler = customerCenterActionHandler
self.purchasesProvider = purchasesProvider
self.customerCenterStoreKitUtilities = customerCenterStoreKitUtilities
}

#if DEBUG

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

#endif

func loadScreen() async {
do {
try await self.loadPurchaseInformation()
try await self.loadCustomerCenterConfig()
self.state = .success
} catch {
self.state = .error(error)
}
}

func performRestore() async -> RestorePurchasesAlert.AlertType {
self.customerCenterActionHandler?(.restoreStarted)
do {
let customerInfo = try await purchasesProvider.restorePurchases()
self.customerCenterActionHandler?(.restoreCompleted(customerInfo))
let hasPurchases = !customerInfo.activeSubscriptions.isEmpty || !customerInfo.nonSubscriptions.isEmpty
return hasPurchases ? .purchasesRecovered : .purchasesNotFound
} catch {
self.customerCenterActionHandler?(.restoreFailed(error))
return .purchasesNotFound
}
}

func trackImpression(darkMode: Bool, displayMode: CustomerCenterPresentationMode) {
guard impressionData == nil else {
return
}

let eventData = CustomerCenterEvent.Data(locale: .current,
darkMode: darkMode,
isSandbox: purchasesProvider.isSandbox,
displayMode: displayMode)
defer { self.impressionData = eventData }

let event = CustomerCenterEvent.impression(CustomerCenterEventCreationData(), eventData)
purchasesProvider.track(customerCenterEvent: event)
}

}

@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 loadPurchaseInformation() async throws {
let customerInfo = try await purchasesProvider.customerInfo(fetchPolicy: .fetchCurrent)

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,
customerInfo: customerInfo)
}

func loadCustomerCenterConfig() async throws {
self.configuration = try await purchasesProvider.loadCustomerCenter()
if let productId = configuration?.productId {
self.onUpdateAppClick = {
// productId is a positive integer, so it should be safe to construct a URL from it.
let url = URL(string: "https://itunes.apple.com/app/id\(productId)")!
URLUtilities.openURLIfNotAppExtension(url)
}
}
}

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?,
customerInfo: CustomerInfo) async throws -> PurchaseInformation {
if transaction.store == .appStore {
if let product = await purchasesProvider.products([transaction.productIdentifier]).first {
return await PurchaseInformation.purchaseInformationUsingRenewalInfo(
entitlement: entitlement,
subscribedProduct: product,
transaction: transaction,
customerCenterStoreKitUtilities: customerCenterStoreKitUtilities,
customerInfoRequestedDate: customerInfo.requestDate
)
} else {
Logger.warning(
Strings.could_not_find_product_loading_without_product_information(transaction.productIdentifier)
)

return PurchaseInformation(
entitlement: entitlement,
transaction: transaction,
customerInfoRequestedDate: customerInfo.requestDate
)
}
}
Logger.warning(Strings.active_product_is_not_apple_loading_without_product_information(transaction.store))

return PurchaseInformation(
entitlement: entitlement,
transaction: transaction,
customerInfoRequestedDate: customerInfo.requestDate
)
}

}

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.
func versionString() -> String? {
do {
let pattern = #"^(\d+)(?:\.\d+)?(?:\.\d+)?"#
let regex = try NSRegularExpression(pattern: pattern)
let match = regex.firstMatch(in: self, range: NSRange(self.startIndex..., in: self))
return match.map { String(self[Range($0.range, in: self)!]) }
} catch {
return nil
}
}
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,16 @@ private extension Array<CustomerCenterConfigData.HelpPath> {
return self
}

return filter { !purchaseInformation.isLifetime || $0.type != .cancel }
.filter {
$0.refundWindowDuration.map { $0.isWithin(purchaseInformation) } ?? true
}
return filter {
// if it's cancel, it cannot be a lifetime subscription
($0.type != .cancel || !purchaseInformation.isLifetime) &&

// if it's refundRequest, it cannot be free
($0.type != .refundRequest || purchaseInformation.price != .free) &&

// if it has a refundDuration, check it's still valid
($0.refundWindowDuration?.isWithin(purchaseInformation) ?? true)
}
}
}

Expand Down
13 changes: 13 additions & 0 deletions RevenueCatUI/CustomerCenter/Views/ManageSubscriptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,19 @@ struct ManageSubscriptionsView_Previews: PreviewProvider {
}
.preferredColorScheme(colorScheme)
.previewDisplayName("Yearly expiring - \(colorScheme)")

CompatibilityNavigationStack {
let viewModelYearlyExpiring = ManageSubscriptionsViewModel(
screen: CustomerCenterConfigTestData.customerCenterData.screens[.management]!,
customerCenterActionHandler: nil,
purchaseInformation: CustomerCenterConfigTestData.subscriptionInformationFree)
ManageSubscriptionsView(viewModel: viewModelYearlyExpiring,
customerCenterActionHandler: nil)
.environment(\.localization, CustomerCenterConfigTestData.customerCenterData.localization)
.environment(\.appearance, CustomerCenterConfigTestData.customerCenterData.appearance)
}
.preferredColorScheme(colorScheme)
.previewDisplayName("Free subscription - \(colorScheme)")
}
}

Expand Down
2 changes: 1 addition & 1 deletion RevenueCatUI/CustomerCenter/Views/WrongPlatformView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ struct WrongPlatformView_Previews: PreviewProvider {
return PurchaseInformation(
entitlement: customerInfo.entitlements.active.first!.value,
transaction: customerInfo.subscriptionsByProductIdentifier.values.first!,
customerInfoRequestedDate: Date())
customerInfoRequestedDate: customerInfo.requestDate)
}

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,23 @@ class ManageSubscriptionsViewModelTests: TestCase {
expect(viewModel.relevantPathsForPurchase.contains(where: { $0.type == .refundRequest })).to(beFalse())
}

func testDoesNotShowRefundIfPurchaseIsFree() {
let latestPurchaseDate = Date()
let twoDays: TimeInterval = 2 * 24 * 60 * 60
let purchase = PurchaseInformation.mockNonLifetime(
price: .free,
latestPurchaseDate: latestPurchaseDate,
customerInfoRequestedDate: latestPurchaseDate.addingTimeInterval(twoDays))

let viewModel = ManageSubscriptionsViewModel(
screen: ManageSubscriptionsViewModelTests.managementScreen(refundWindowDuration: .forever),
customerCenterActionHandler: nil,
purchaseInformation: purchase)

expect(viewModel.relevantPathsForPurchase.count) == 3
expect(viewModel.relevantPathsForPurchase.contains(where: { $0.type == .refundRequest })).to(beFalse())
}

func testShowsRefundIfPurchaseOutsideRefundWindow() {
let latestPurchaseDate = Date()
let oneDay = ISODuration(
Expand Down Expand Up @@ -504,13 +521,14 @@ private extension PurchaseInformation {
}

static func mockNonLifetime(
price: PurchaseInformation.PriceDetails = .paid("5"),
latestPurchaseDate: Date = Date(),
customerInfoRequestedDate: Date = Date()) -> PurchaseInformation {
PurchaseInformation(
title: "",
durationTitle: "",
explanation: .earliestExpiration,
price: .paid(""),
price: price,
expirationOrRenewal: PurchaseInformation.ExpirationOrRenewal(
label: .expires,
date: .date("")
Expand Down

0 comments on commit c1e96e0

Please sign in to comment.