Skip to content
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

feat: Introduce refund window to control if a refund is offered for a purchase #4784

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
10 changes: 9 additions & 1 deletion RevenueCat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -3754,10 +3754,10 @@
3537565A2C382C2800A1B8D6 /* ViewModels */ = {
isa = PBXGroup;
children = (
5767D0462D5B505A003D423C /* ManageSubscriptions */,
574BA2672D3E762E009B8EA6 /* PurchaseHistory */,
353756572C382C2800A1B8D6 /* CustomerCenterViewModel.swift */,
353756582C382C2800A1B8D6 /* CustomerCenterViewState.swift */,
353756592C382C2800A1B8D6 /* ManageSubscriptionsViewModel.swift */,
3546355A2C391F38001D7E85 /* FeedbackSurveyViewModel.swift */,
3546355B2C391F38001D7E85 /* PromotionalOfferViewModel.swift */,
);
Expand Down Expand Up @@ -4452,6 +4452,14 @@
path = Purchases;
sourceTree = "<group>";
};
5767D0462D5B505A003D423C /* ManageSubscriptions */ = {
isa = PBXGroup;
children = (
353756592C382C2800A1B8D6 /* ManageSubscriptionsViewModel.swift */,
);
path = ManageSubscriptions;
sourceTree = "<group>";
};
5774F9BF2805EA1200997128 /* Responses */ = {
isa = PBXGroup;
children = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ enum CustomerCenterConfigTestData {
static func customerCenterData(
lastPublishedAppVersion: String?,
shouldWarnCustomerToUpdate: Bool = false,
displayPurchaseHistoryLink: Bool = false
displayPurchaseHistoryLink: Bool = false,
refundWindowDuration: CustomerCenterConfigData.HelpPath.RefundWindowDuration = .forever
) -> CustomerCenterConfigData {
CustomerCenterConfigData(
screens: [.management:
Expand All @@ -38,7 +39,8 @@ enum CustomerCenterConfigTestData {
url: nil,
openMethod: nil,
type: .missingPurchase,
detail: nil
detail: nil,
refundWindowDuration: nil
),
.init(
id: "2",
Expand All @@ -52,15 +54,17 @@ enum CustomerCenterConfigTestData {
title: "title",
subtitle: "subtitle",
productMapping: ["monthly": "offer_id"]
))
)),
refundWindowDuration: refundWindowDuration
),
.init(
id: "3",
title: "Change plans",
url: nil,
openMethod: nil,
type: .changePlans,
detail: nil
detail: nil,
refundWindowDuration: nil
),
.init(
id: "4",
Expand All @@ -87,7 +91,8 @@ enum CustomerCenterConfigTestData {
promotionalOffer: nil
)
]
))
)),
refundWindowDuration: nil
)
]
),
Expand All @@ -102,7 +107,8 @@ enum CustomerCenterConfigTestData {
url: nil,
openMethod: nil,
type: .missingPurchase,
detail: nil
detail: nil,
refundWindowDuration: nil
)
]
)
Expand Down Expand Up @@ -145,7 +151,9 @@ enum CustomerCenterConfigTestData {
date: .date("June 1st, 2024")),
productIdentifier: "product_id",
store: .appStore,
isLifetime: false
isLifetime: false,
latestPurchaseDate: nil,
customerInfoRequestedDate: Date()
)

static let subscriptionInformationYearlyExpiring: PurchaseInformation = .init(
Expand All @@ -157,7 +165,9 @@ enum CustomerCenterConfigTestData {
date: .date("June 1st, 2024")),
productIdentifier: "product_id",
store: .appStore,
isLifetime: false
isLifetime: false,
latestPurchaseDate: nil,
customerInfoRequestedDate: Date()
)

}
21 changes: 17 additions & 4 deletions RevenueCatUI/CustomerCenter/Data/PurchaseInformation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,19 @@ struct PurchaseInformation {
/// - `false` for subscriptions, even if the expiration date is set far in the future.
let isLifetime: Bool

let latestPurchaseDate: Date?
let customerInfoRequestedDate: Date

init(title: String,
durationTitle: String,
explanation: Explanation,
price: PriceDetails,
expirationOrRenewal: ExpirationOrRenewal?,
productIdentifier: String,
store: Store,
isLifetime: Bool
isLifetime: Bool,
latestPurchaseDate: Date?,
customerInfoRequestedDate: Date
) {
self.title = title
self.durationTitle = durationTitle
Expand All @@ -59,18 +64,22 @@ struct PurchaseInformation {
self.productIdentifier = productIdentifier
self.store = store
self.isLifetime = isLifetime
self.latestPurchaseDate = latestPurchaseDate
self.customerInfoRequestedDate = customerInfoRequestedDate
}

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

// Title and duration from product if available
self.title = subscribedProduct?.localizedTitle
self.durationTitle = subscribedProduct?.subscriptionPeriod?.durationTitle
self.customerInfoRequestedDate = customerInfoRequestedDate

// Use entitlement data if available, otherwise derive from transaction
if let entitlement = entitlement {
Expand All @@ -84,7 +93,7 @@ struct PurchaseInformation {
self.price = entitlement.priceBestEffort(product: subscribedProduct)
}
self.isLifetime = entitlement.expirationDate == nil

self.latestPurchaseDate = entitlement.latestPurchaseDate
} else {
switch transaction.type {
case .subscription(let isActive, let willRenew, let expiresDate):
Expand All @@ -99,11 +108,13 @@ struct PurchaseInformation {
return ExpirationOrRenewal(label: label, date: .date(dateString))
}
self.isLifetime = false
self.latestPurchaseDate = (transaction as? SubscriptionInfo)?.purchaseDate

case .nonSubscription:
self.explanation = .lifetime
self.expirationOrRenewal = nil
self.isLifetime = true
self.latestPurchaseDate = (transaction as? NonSubscriptionTransaction)?.purchaseDate
}

self.productIdentifier = transaction.productIdentifier
Expand Down Expand Up @@ -176,7 +187,8 @@ extension PurchaseInformation {
entitlement: EntitlementInfo? = nil,
subscribedProduct: StoreProduct,
transaction: Transaction,
customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType
customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType,
customerInfoRequestedDate: Date
) async -> PurchaseInformation {
let renewalPriceDetails = await Self.extractPriceDetailsFromRenewalInfo(
forProduct: subscribedProduct,
Expand All @@ -186,7 +198,8 @@ extension PurchaseInformation {
entitlement: entitlement,
subscribedProduct: subscribedProduct,
transaction: transaction,
renewalPrice: renewalPriceDetails
renewalPrice: renewalPriceDetails,
customerInfoRequestedDate: customerInfoRequestedDate
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,10 @@ private extension CustomerCenterViewModel {
let entitlement = customerInfo.entitlements.all.values
.first(where: { $0.productIdentifier == activeTransaction.productIdentifier })

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

func loadCustomerCenterConfig() async throws {
Expand Down Expand Up @@ -217,14 +219,16 @@ private extension CustomerCenterViewModel {
}

func createPurchaseInformation(for transaction: Transaction,
entitlement: EntitlementInfo?) async throws -> PurchaseInformation {
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
customerCenterStoreKitUtilities: customerCenterStoreKitUtilities,
customerInfoRequestedDate: customerInfo.requestDate
)
} else {
Logger.warning(
Expand All @@ -233,15 +237,17 @@ private extension CustomerCenterViewModel {

return PurchaseInformation(
entitlement: entitlement,
transaction: transaction
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
transaction: transaction,
customerInfoRequestedDate: customerInfo.requestDate
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
//

import Foundation
import RevenueCat
@_spi(Internal) import RevenueCat
import SwiftUI

#if os(iOS)
Expand All @@ -29,11 +29,7 @@ final class ManageSubscriptionsViewModel: ObservableObject {
let screen: CustomerCenterConfigData.Screen

var relevantPathsForPurchase: [CustomerCenterConfigData.HelpPath] {
if purchaseInformation?.isLifetime == true {
return paths.filter { $0.type != .cancel }
} else {
return paths
}
paths.relevantPathsForPurchase(purchaseInformation)
}

@Published
Expand Down Expand Up @@ -75,21 +71,22 @@ final class ManageSubscriptionsViewModel: ObservableObject {
private let paths: [CustomerCenterConfigData.HelpPath]
private var purchasesProvider: ManageSubscriptionsPurchaseType

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
}
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
}

#if os(iOS) || targetEnvironment(macCatalyst)
func determineFlow(for path: CustomerCenterConfigData.HelpPath) async {
Expand Down Expand Up @@ -258,4 +255,59 @@ private extension CustomerCenterConfigData.Screen {

}

private extension Array<CustomerCenterConfigData.HelpPath> {
func relevantPathsForPurchase(
_ purchaseInformation: PurchaseInformation?
) -> [CustomerCenterConfigData.HelpPath] {
guard let purchaseInformation else {
return self
}

return filter { !purchaseInformation.isLifetime || $0.type != .cancel }
.filter {
$0.refundWindowDuration.map { $0.isWithin(purchaseInformation) } ?? true
}
}
}

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
}
}

#endif
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ struct ManageSubscriptionsView_Previews: PreviewProvider {
customerCenterActionHandler: nil)
.environment(\.localization, CustomerCenterConfigTestData.customerCenterData.localization)
.environment(\.appearance, CustomerCenterConfigTestData.customerCenterData.appearance)
}.preferredColorScheme(colorScheme)
}
.preferredColorScheme(colorScheme)
.previewDisplayName("Monthly renewing - \(colorScheme)")

CompatibilityNavigationStack {
Expand All @@ -192,7 +193,8 @@ struct ManageSubscriptionsView_Previews: PreviewProvider {
customerCenterActionHandler: nil)
.environment(\.localization, CustomerCenterConfigTestData.customerCenterData.localization)
.environment(\.appearance, CustomerCenterConfigTestData.customerCenterData.appearance)
}.preferredColorScheme(colorScheme)
}
.preferredColorScheme(colorScheme)
.previewDisplayName("Yearly expiring - \(colorScheme)")
}
}
Expand Down
Loading