feat: Introducing billing information for PurchaseInformation in CustomerCenter (#5066)

* feat: Update billing information based on subscription type

* chore: Make StoreProductDiscountType spi public

* missing spy

* add missing @_spi(Internal)  to TestStoreProductDiscount

* feat: Introduce copy changes for active subscription

* Added Self.management

* bring back preview

* bring back onchange

* use card

* hide chevron

* hide chevron

* ViewBuilder

* revert tuist

* fix tests

* remove testable

* remove testable

* default with productId

* remove trailing comma

* use localizations

* w

* check itout
This commit is contained in:
Facundo Menzella authored and GitHub committed 2025-05-08 17:28:39 -06:00
1 parent f3d2b18c2f
commit 4087a9ca39
19 files changed
+665 -451

No files matched your search

+17 -5
View File
@@ -582,6 +582,9 @@
5733D00928CFA7A4008638D8 /* MockPaymentQueueWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5733D00828CFA7A4008638D8 /* MockPaymentQueueWrapper.swift */; };
5733D01128D00354008638D8 /* PromotionalOfferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5733D01028D00354008638D8 /* PromotionalOfferTests.swift */; };
5736267B2D3E76C1003C9665 /* ProductPaidPrice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5736267A2D3E76C1003C9665 /* ProductPaidPrice.swift */; };
5738AF522DCBDD4700B6EDC5 /* PurchaseInformationCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5738AF512DCBDD4100B6EDC5 /* PurchaseInformationCardView.swift */; };
5738AF542DCBEF0D00B6EDC5 /* PurchaseInformation+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5738AF532DCBEF0700B6EDC5 /* PurchaseInformation+Mock.swift */; };
5738AF562DCBF0E000B6EDC5 /* StoreProductDiscountType+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5738AF552DCBF0DD00B6EDC5 /* StoreProductDiscountType+Mock.swift */; };
5738F46E278CAC520096D623 /* StoreTransactionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5738F46D278CAC520096D623 /* StoreTransactionTests.swift */; };
5738F489278CC2500096D623 /* MockTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = F591492726B9956C00D32E58 /* MockTransaction.swift */; };
573A10D52800A7C800F976E5 /* SKErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 573A10D42800A7C800F976E5 /* SKErrorTests.swift */; };
@@ -826,6 +829,7 @@
57F3C10529B7B22E0004FD7E /* CustomerInfo+ActiveDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F3C10429B7B22E0004FD7E /* CustomerInfo+ActiveDates.swift */; };
57F3C10C29B7EAE90004FD7E /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37E357D16038F07915D7825D /* MockUserDefaults.swift */; };
57F7DAAB2D9E906200855622 /* RuntimeUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F7DAAA2D9E906100855622 /* RuntimeUtils.swift */; };
57F9725A2DC3CA5400C6E540 /* Transaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F972592DC3CA5200C6E540 /* Transaction.swift */; };
57F9726B2DC3EF9500C6E540 /* MockStoreProductDiscount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57F9726A2DC3EF9500C6E540 /* MockStoreProductDiscount.swift */; };
57FD7B1528DA4037009CA4E4 /* PurchasesType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FD7B1428DA4037009CA4E4 /* PurchasesType.swift */; };
57FDAA962846BDE2009A48F1 /* PurchasesTransactionHandlingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FDAA952846BDE2009A48F1 /* PurchasesTransactionHandlingTests.swift */; };
@@ -1105,7 +1109,6 @@
B3F3E8DA277158FE0047A5B9 /* DNSChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F3E8D9277158FE0047A5B9 /* DNSChecker.swift */; };
B3F8418F26F3A93400E560FB /* ErrorCodeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3F8418E26F3A93400E560FB /* ErrorCodeTests.swift */; };
C3AD12BA2C6EA61F00A4F86F /* CompatibilityNavigationStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3AD12B92C6EA61F00A4F86F /* CompatibilityNavigationStack.swift */; };
C3AD12BC2C6EA69D00A4F86F /* SubscriptionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3AD12BB2C6EA69D00A4F86F /* SubscriptionDetailsView.swift */; };
F516BD29282434070083480B /* StoreKit2StorefrontListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = F516BD28282434070083480B /* StoreKit2StorefrontListener.swift */; };
F52CFAFC28290AE500E8ABC5 /* StoreKit2StorefrontListenerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F516BD322828FDD90083480B /* StoreKit2StorefrontListenerTests.swift */; };
F530E4FF275646EF001AF6BD /* MacDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = F530E4FE275646EF001AF6BD /* MacDevice.swift */; };
@@ -1965,6 +1968,9 @@
5733D00828CFA7A4008638D8 /* MockPaymentQueueWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPaymentQueueWrapper.swift; sourceTree = "<group>"; };
5733D01028D00354008638D8 /* PromotionalOfferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromotionalOfferTests.swift; sourceTree = "<group>"; };
5736267A2D3E76C1003C9665 /* ProductPaidPrice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPaidPrice.swift; sourceTree = "<group>"; };
5738AF512DCBDD4100B6EDC5 /* PurchaseInformationCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseInformationCardView.swift; sourceTree = "<group>"; };
5738AF532DCBEF0700B6EDC5 /* PurchaseInformation+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "PurchaseInformation+Mock.swift"; sourceTree = "<group>"; };
5738AF552DCBF0DD00B6EDC5 /* StoreProductDiscountType+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StoreProductDiscountType+Mock.swift"; sourceTree = "<group>"; };
5738F46D278CAC520096D623 /* StoreTransactionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreTransactionTests.swift; sourceTree = "<group>"; };
573A10D42800A7C800F976E5 /* SKErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKErrorTests.swift; sourceTree = "<group>"; };
573A10D82800ADCD00F976E5 /* StoreKitErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKitErrorTests.swift; sourceTree = "<group>"; };
@@ -2169,6 +2175,7 @@
57F2C60B29784C11009EE527 /* SwiftVersionCheck.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftVersionCheck.swift; sourceTree = "<group>"; };
57F3C10429B7B22E0004FD7E /* CustomerInfo+ActiveDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CustomerInfo+ActiveDates.swift"; sourceTree = "<group>"; };
57F7DAAA2D9E906100855622 /* RuntimeUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuntimeUtils.swift; sourceTree = "<group>"; };
57F972592DC3CA5200C6E540 /* Transaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transaction.swift; sourceTree = "<group>"; };
57F9726A2DC3EF9500C6E540 /* MockStoreProductDiscount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockStoreProductDiscount.swift; sourceTree = "<group>"; };
57FD7B1428DA4037009CA4E4 /* PurchasesType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesType.swift; sourceTree = "<group>"; };
57FDAA952846BDE2009A48F1 /* PurchasesTransactionHandlingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesTransactionHandlingTests.swift; sourceTree = "<group>"; };
@@ -2456,7 +2463,6 @@
B3F3E8D9277158FE0047A5B9 /* DNSChecker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DNSChecker.swift; sourceTree = "<group>"; };
B3F8418E26F3A93400E560FB /* ErrorCodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorCodeTests.swift; sourceTree = "<group>"; };
C3AD12B92C6EA61F00A4F86F /* CompatibilityNavigationStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompatibilityNavigationStack.swift; sourceTree = "<group>"; };
C3AD12BB2C6EA69D00A4F86F /* SubscriptionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionDetailsView.swift; sourceTree = "<group>"; };
EFB3CBAA73855779FE828CE2 /* ProductsFetcherSK1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductsFetcherSK1.swift; sourceTree = "<group>"; };
F516BD28282434070083480B /* StoreKit2StorefrontListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2StorefrontListener.swift; sourceTree = "<group>"; };
F516BD322828FDD90083480B /* StoreKit2StorefrontListenerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreKit2StorefrontListenerTests.swift; sourceTree = "<group>"; };
@@ -2838,6 +2844,7 @@
isa = PBXGroup;
children = (
2C8EC6AE2CCBD33E00D6CCF8 /* ButtonComponentTests.swift */,
57D963A62DC94244006B8845 /* Transaction.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
@@ -3817,7 +3824,9 @@
353756562C382C2800A1B8D6 /* Data */ = {
isa = PBXGroup;
children = (
57D963A62DC94244006B8845 /* Transaction.swift */,
5738AF552DCBF0DD00B6EDC5 /* StoreProductDiscountType+Mock.swift */,
5738AF532DCBEF0700B6EDC5 /* PurchaseInformation+Mock.swift */,
57F972592DC3CA5200C6E540 /* Transaction.swift */,
3563EB932DCAA37A0061E058 /* EntitlementInfo+Extensions.swift */,
357C43AE2D80510400B96769 /* CustomerCenterManagementOption.swift */,
355FDB5A2D783FC400D20E65 /* CustomerCenterActionWrapper.swift */,
@@ -3870,7 +3879,6 @@
3537565E2C382C2800A1B8D6 /* RestorePurchasesAlert.swift */,
3551E39C2C4A6A1400D27C25 /* TintedProgressView.swift */,
C3AD12B92C6EA61F00A4F86F /* CompatibilityNavigationStack.swift */,
C3AD12BB2C6EA69D00A4F86F /* SubscriptionDetailsView.swift */,
77372D982C6F8C7B008E59D3 /* AppUpdateWarningView.swift */,
352304892D0A0F2B003971A4 /* ErrorView.swift */,
);
@@ -4516,6 +4524,7 @@
574D02632DC65D3C0040FADC /* ManageSubscriptions */ = {
isa = PBXGroup;
children = (
5738AF512DCBDD4100B6EDC5 /* PurchaseInformationCardView.swift */,
3537565C2C382C2800A1B8D6 /* ManageSubscriptionsView.swift */,
3531DF872CFE138800D454BF /* ManageSubscriptionsButtonsView.swift */,
);
@@ -7039,6 +7048,7 @@
88B1BAEE2C813A3C001B7EE5 /* TextComponentView.swift in Sources */,
3546355F2C391F4D001D7E85 /* PromotionalOfferView.swift in Sources */,
2C7457882CEDF7C0004ACE52 /* BackgroundStyle.swift in Sources */,
5738AF542DCBEF0D00B6EDC5 /* PurchaseInformation+Mock.swift in Sources */,
2C8EC6DD2CCC7C5B00D6CCF8 /* PackageValidator.swift in Sources */,
574D025D2DC65C8B0040FADC /* ManageSubscriptionView.swift in Sources */,
4DEB9BC52D08CA1700D33E36 /* BadgeModifier.swift in Sources */,
@@ -7048,6 +7058,7 @@
353756722C382C2800A1B8D6 /* URLUtilities.swift in Sources */,
57D8D5532D3EAF11009CB6ED /* CompatibilityLabeledContent.swift in Sources */,
03C06FCA2D479C7400600693 /* CarouselComponentView.swift in Sources */,
5738AF562DCBF0E000B6EDC5 /* StoreProductDiscountType+Mock.swift in Sources */,
353FDC0F2CA446FA0055F328 /* StoreProductDiscount+Extensions.swift in Sources */,
03A98CF12D222F5F009BCA61 /* FallbackComponentPreview.swift in Sources */,
887A60862C1D037000E1A461 /* FooterHidingModifier.swift in Sources */,
@@ -7095,6 +7106,7 @@
2C4C36132C6FBA8B00AE959B /* CompatibilityTopBarTrailing.swift in Sources */,
FD9BCEBC2DC946C400408A5D /* BottomSheetView.swift in Sources */,
3546355C2C391F38001D7E85 /* FeedbackSurveyViewModel.swift in Sources */,
5738AF522DCBDD4700B6EDC5 /* PurchaseInformationCardView.swift in Sources */,
353756692C382C2800A1B8D6 /* CustomerCenterViewState.swift in Sources */,
7783607B2CCA88E4000785B8 /* StickyFooterComponentView.swift in Sources */,
887A608B2C1D037000E1A461 /* PurchaseHandler+TestData.swift in Sources */,
@@ -7103,8 +7115,8 @@
887A60682C1D037000E1A461 /* TemplateError.swift in Sources */,
887A60792C1D037000E1A461 /* UserInterfaceIdiom.swift in Sources */,
03C06FCC2D479C7C00600693 /* CarouselComponentViewModel.swift in Sources */,
57F9725A2DC3CA5400C6E540 /* Transaction.swift in Sources */,
35D41B432D403556000621C7 /* Store+Localization.swift in Sources */,
C3AD12BC2C6EA69D00A4F86F /* SubscriptionDetailsView.swift in Sources */,
887A60742C1D037000E1A461 /* Strings.swift in Sources */,
57233AA32D3F976500488B00 /* CustomerCenterLocalizationStrings.swift in Sources */,
887A60712C1D037000E1A461 /* PaywallViewConfiguration.swift in Sources */,
@@ -14,9 +14,8 @@
//
import Foundation
import RevenueCat
@_spi(Internal) import RevenueCat
// swiftlint:disable force_unwrapping
extension CustomerCenterConfigData {
@available(iOS 14.0, *)
@@ -138,75 +137,6 @@ extension CustomerCenterConfigData {
@available(iOS 14.0, *)
static let `default` = mock(lastPublishedAppVersion: "1.0.0")
static let subscriptionInformationMonthlyRenewing: PurchaseInformation = .init(
title: "Basic",
durationTitle: "Monthly",
explanation: .earliestRenewal,
price: .paid("$4.99"),
expirationOrRenewal: .init(label: .nextBillingDate,
date: .date("June 1st, 2024")),
productIdentifier: "product_id",
store: .appStore,
isLifetime: false,
isTrial: false,
isCancelled: false,
latestPurchaseDate: nil,
customerInfoRequestedDate: Date(),
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
)
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,
isTrial: false,
isCancelled: false,
latestPurchaseDate: nil,
customerInfoRequestedDate: Date(),
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
)
static func subscriptionInformationYearlyExpiring(store: Store = .appStore) -> PurchaseInformation {
PurchaseInformation(
title: "Basic",
durationTitle: "Yearly",
explanation: .earliestRenewal,
price: .paid("$49.99"),
expirationOrRenewal: .init(label: .expires,
date: .date("June 1st, 2024")),
productIdentifier: "product_id",
store: store,
isLifetime: false,
isTrial: false,
isCancelled: false,
latestPurchaseDate: nil,
customerInfoRequestedDate: Date(),
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
)
}
static let consumable: PurchaseInformation = .init(
title: "Basic",
durationTitle: nil,
explanation: .lifetime,
price: .paid("$49.99"),
expirationOrRenewal: nil,
productIdentifier: "product_id",
store: .appStore,
isLifetime: true,
isTrial: false,
isCancelled: false,
latestPurchaseDate: Date(),
customerInfoRequestedDate: Date(),
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
)
static let standardAppearance = CustomerCenterConfigData.Appearance(
accentColor: .init(light: "#007AFF", dark: "#007AFF"),
textColor: .init(light: "#000000", dark: "#ffffff"),
@@ -214,5 +144,4 @@ extension CustomerCenterConfigData {
buttonTextColor: .init(light: "#ffffff", dark: "#000000"),
buttonBackgroundColor: .init(light: "#287aff", dark: "#287aff")
)
}
@@ -0,0 +1,92 @@
//
// 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
//
// PurchaseInformation+Mock.swift
//
// Created by Facundo Menzella on 7/5/25.
import Foundation
@_spi(Internal) import RevenueCat
// swiftlint:disable force_unwrapping
extension PurchaseInformation {
static let monthlyRenewing = PurchaseInformation(
title: "Basic",
durationTitle: "Monthly",
explanation: .earliestRenewal,
price: .paid("$4.99"),
expirationOrRenewal: .init(label: .nextBillingDate, date: .date("June 1st, 2024")),
productIdentifier: "product_id5",
store: .appStore,
isLifetime: false,
isTrial: false,
latestPurchaseDate: nil,
customerInfoRequestedDate: Date(),
isCancelled: false,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
)
static let subscriptionInformationFree = PurchaseInformation(
title: "Basic",
durationTitle: "Monthly",
explanation: .earliestRenewal,
price: .free,
expirationOrRenewal: .init(label: .nextBillingDate,
date: .date("June 1st, 2024")),
productIdentifier: "product_id2",
store: .appStore,
isLifetime: false,
isTrial: false,
latestPurchaseDate: nil,
customerInfoRequestedDate: Date(),
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
)
static func yearlyExpiring(
title: String = "Product name",
productIdentifier: String = "productIdentifier3",
store: Store = .appStore,
isCancelled: Bool = false,
expirationDate: Date = Date(),
introductoryDiscount: StoreProductDiscountType? = nil
) -> PurchaseInformation {
PurchaseInformation(
title: title,
durationTitle: "Yearly",
explanation: .earliestRenewal,
price: .paid("$49.99"),
expirationOrRenewal: .init(label: .expires, date: .date("June 1st, 2024")),
productIdentifier: productIdentifier,
store: store,
isLifetime: false,
isTrial: false,
latestPurchaseDate: nil,
customerInfoRequestedDate: Date(),
isCancelled: isCancelled,
introductoryDiscount: introductoryDiscount,
expirationDate: expirationDate,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
)
}
static let consumable: PurchaseInformation = PurchaseInformation(
title: "Basic",
durationTitle: nil,
explanation: .lifetime,
price: .paid("$49.99"),
expirationOrRenewal: nil,
productIdentifier: "product_id",
store: .appStore,
isLifetime: true,
isTrial: false,
latestPurchaseDate: Date(),
customerInfoRequestedDate: Date(),
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
)
}
@@ -17,7 +17,7 @@ import Foundation
@_spi(Internal) import RevenueCat
import StoreKit
// swiftlint:disable nesting
// swiftlint:disable nesting file_length
/// Information about a purchase.
struct PurchaseInformation {
@@ -54,7 +54,7 @@ struct PurchaseInformation {
/// - `false` for purchases outside the trial period.
let isTrial: Bool
/// Indicates whether the purchased subscription is cancelled
/// Indicates wheter the purchased subscripcion is cancelled
/// - `true` if the subscription is user-cancelled
/// - `false` if the subscription is not user-cancelled
///
@@ -62,8 +62,18 @@ struct PurchaseInformation {
let isCancelled: Bool
let latestPurchaseDate: Date?
/// The fetch date of this CustomerInfo. (a.k.a. CustomerInfo.requestedDate)
let customerInfoRequestedDate: Date
let introductoryDiscount: StoreProductDiscountType?
let expirationDate: Date?
let renewalDate: Date?
private let dateFormatter: DateFormatter
let managePurchaseURL: URL?
init(title: String,
@@ -75,9 +85,12 @@ struct PurchaseInformation {
store: Store,
isLifetime: Bool,
isTrial: Bool,
isCancelled: Bool,
latestPurchaseDate: Date?,
customerInfoRequestedDate: Date,
isCancelled: Bool = false,
introductoryDiscount: StoreProductDiscountType? = nil,
expirationDate: Date? = nil,
renewalDate: Date? = nil,
managePurchaseURL: URL?
) {
self.title = title
@@ -92,23 +105,29 @@ struct PurchaseInformation {
self.isCancelled = isCancelled
self.latestPurchaseDate = latestPurchaseDate
self.customerInfoRequestedDate = customerInfoRequestedDate
self.introductoryDiscount = introductoryDiscount
self.expirationDate = expirationDate
self.renewalDate = renewalDate
self.dateFormatter = Self.defaultDateFormatter
self.managePurchaseURL = managePurchaseURL
}
// swiftlint:disable:next function_body_length
init(entitlement: EntitlementInfo? = nil,
subscribedProduct: StoreProduct? = nil,
storeProduct: StoreProduct? = nil,
transaction: Transaction,
renewalPrice: PriceDetails? = nil,
customerInfoRequestedDate: Date,
dateFormatter: DateFormatter = DateFormatter(),
managePurchaseURL: URL?) {
managePurchaseURL: URL?,
dateFormatter: DateFormatter = Self.defaultDateFormatter
) {
dateFormatter.dateStyle = .medium
// Title and duration from product if available
self.title = subscribedProduct?.localizedTitle
self.durationTitle = subscribedProduct?.subscriptionPeriod?.durationTitle
self.title = storeProduct?.localizedTitle
self.durationTitle = storeProduct?.subscriptionPeriod?.durationTitle
self.customerInfoRequestedDate = customerInfoRequestedDate
self.introductoryDiscount = storeProduct?.introductoryDiscount
self.managePurchaseURL = managePurchaseURL
// Use entitlement data if available, otherwise derive from transaction
@@ -120,12 +139,15 @@ struct PurchaseInformation {
if let renewalPrice {
self.price = renewalPrice
} else {
self.price = entitlement.priceBestEffort(product: subscribedProduct)
self.price = entitlement.priceBestEffort(product: storeProduct)
}
self.isLifetime = entitlement.expirationDate == nil
self.isTrial = entitlement.periodType == .trial
self.isCancelled = entitlement.isCancelled
self.latestPurchaseDate = entitlement.latestPurchaseDate
self.expirationDate = entitlement.expirationDate
self.renewalDate = entitlement.willRenew ? entitlement.expirationDate : nil
} else {
switch transaction.type {
case let .subscription(isActive, willRenew, expiresDate, isTrial):
@@ -141,12 +163,17 @@ struct PurchaseInformation {
}
self.isLifetime = false
self.isTrial = isTrial
self.expirationDate = expiresDate
self.renewalDate = willRenew ? expiresDate : nil
self.latestPurchaseDate = (transaction as? RevenueCat.SubscriptionInfo)?.purchaseDate
case .nonSubscription:
self.explanation = .lifetime
self.expirationOrRenewal = nil
self.isLifetime = true
self.renewalDate = nil
self.expirationDate = nil
self.isTrial = false
self.latestPurchaseDate = (transaction as? NonSubscriptionTransaction)?.purchaseDate
}
@@ -161,10 +188,12 @@ struct PurchaseInformation {
if let renewalPrice {
self.price = renewalPrice
} else {
self.price = subscribedProduct.map { .paid($0.localizedPriceString) } ?? .unknown
self.price = storeProduct.map { .paid($0.localizedPriceString) } ?? .unknown
}
}
}
self.dateFormatter = dateFormatter
}
struct ExpirationOrRenewal: Equatable {
@@ -202,6 +231,11 @@ struct PurchaseInformation {
case lifetime
}
private static let defaultDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
return dateFormatter
}()
}
// swiftlint:enable nesting
@@ -222,19 +256,19 @@ extension PurchaseInformation {
@available(iOS 15.0, macOS 12.0, tvOS 15.0, watchOS 8.0, visionOS 1.0, *)
static func purchaseInformationUsingRenewalInfo(
entitlement: EntitlementInfo? = nil,
subscribedProduct: StoreProduct,
storeProduct: StoreProduct,
transaction: Transaction,
customerCenterStoreKitUtilities: CustomerCenterStoreKitUtilitiesType,
customerInfoRequestedDate: Date,
managePurchaseURL: URL?
) async -> PurchaseInformation {
let renewalPriceDetails = await Self.extractPriceDetailsFromRenewalInfo(
forProduct: subscribedProduct,
forProduct: storeProduct,
customerCenterStoreKitUtilities: customerCenterStoreKitUtilities
)
return PurchaseInformation(
entitlement: entitlement,
subscribedProduct: subscribedProduct,
storeProduct: storeProduct,
transaction: transaction,
renewalPrice: renewalPriceDetails,
customerInfoRequestedDate: customerInfoRequestedDate,
@@ -273,8 +307,6 @@ extension PurchaseInformation: Identifiable {
}
}
extension PurchaseInformation: Equatable { }
private extension EntitlementInfo {
var isCancelled: Bool {
@@ -376,5 +408,67 @@ private extension String {
func isPromotionalLifetime(store: Store) -> Bool {
return self.hasSuffix("_lifetime") && store == .promotional
}
}
extension PurchaseInformation {
func billingInformation(localizations: CustomerCenterConfigData.Localization) -> String {
guard let expirationDate else {
// non subscription
return localizations[.pricePaid]
.replacingOccurrences(of: "{{ price }}", with: price.billingInformation(localizations: localizations))
}
if let introductoryDiscount {
if isCancelled {
var renewString = "\(introductoryDiscount.localizedPricePerPeriodByPaymentMode(.current))."
renewString += localizations[.expiresOnDateWithoutChanges]
.replacingOccurrences(of: "{{ date }}", with: dateFormatter.string(from: expirationDate))
return renewString
}
if introductoryDiscount.paymentMode == .freeTrial {
return localizations[.freeTrialUntilDate]
.replacingOccurrences(of: "{{ date }}", with: dateFormatter.string(from: expirationDate))
} else {
let introPrice = introductoryDiscount.localizedPricePerPeriodByPaymentMode(.current)
return "\(introPrice). \(priceAfterDiscount(localizations: localizations))"
}
} else if isCancelled {
return localizations[.expiresOnDateWithoutChanges]
.replacingOccurrences(of: "{{ date }}", with: dateFormatter.string(from: expirationDate))
} else {
switch price {
case let .paid(priceString):
return localizations[.renewsOnDateForPrice]
.replacingOccurrences(of: "{{ date }}", with: dateFormatter.string(from: expirationDate))
.replacingOccurrences(of: "{{ price }}", with: priceString)
case .free, .unknown:
return localizations[.renewsOnDate]
.replacingOccurrences(of: "{{ date }}", with: dateFormatter.string(from: expirationDate))
}
}
}
func priceAfterDiscount(localizations: CustomerCenterConfigData.Localization) -> String {
return localizations[.priceAfterwards]
.replacingOccurrences(of: "{{ price }}", with: price.billingInformation(localizations: localizations))
}
}
private extension PurchaseInformation.PriceDetails {
func billingInformation(localizations: CustomerCenterConfigData.Localization) -> String {
switch self {
case .free:
return localizations[.free]
case .paid(let priceString):
return priceString
case .unknown:
return "Unknown"
}
}
var isCancelled: Bool {
false
}
}
@@ -0,0 +1,68 @@
//
// 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
//
// StoreProductDiscountType+Mock.swift
//
// Created by Facundo Menzella on 7/5/25.
import Foundation
@_spi(Internal) import RevenueCat
@_spi(Internal) extension StoreProductDiscountType {
static func discount(
paymentMode: StoreProductDiscount.PaymentMode = .payAsYouGo,
price: Decimal = 0.99,
subscriptionPeriod: SubscriptionPeriod = .init(value: 1, unit: .month),
numberOfPeriods: Int = 3,
type: StoreProductDiscount.DiscountType = .introductory
) -> any StoreProductDiscountType {
MockStoreProductDiscount(
offerIdentifier: "offerIdentifier",
currencyCode: "USD",
price: price,
localizedPriceString: "\(price) USD",
paymentMode: paymentMode,
subscriptionPeriod: subscriptionPeriod,
numberOfPeriods: numberOfPeriods,
type: type
)
}
}
struct MockStoreProductDiscount: StoreProductDiscountType {
let offerIdentifier: String?
let currencyCode: String?
let price: Decimal
let localizedPriceString: String
let paymentMode: StoreProductDiscount.PaymentMode
let subscriptionPeriod: RevenueCat.SubscriptionPeriod
let numberOfPeriods: Int
let type: StoreProductDiscount.DiscountType
static func mock(
paymentMode: StoreProductDiscount.PaymentMode = .payAsYouGo,
price: Decimal = 0.99,
subscriptionPeriod: SubscriptionPeriod = .init(value: 1, unit: .month),
numberOfPeriods: Int = 3,
discountType: StoreProductDiscount.DiscountType
) -> MockStoreProductDiscount {
MockStoreProductDiscount(
offerIdentifier: nil,
currencyCode: nil,
price: 0.01,
localizedPriceString: "$0.01",
paymentMode: paymentMode,
subscriptionPeriod: SubscriptionPeriod(value: 1, unit: .month),
numberOfPeriods: 6,
type: discountType
)
}
}
@@ -28,7 +28,7 @@ extension CustomerInfo {
/// - Note: This is a **temporary** implementation and should eventually be replaced by
/// backend-side logic for consistency and accuracy.
///
func earliestExpiringTransaction() -> Transaction? {
func earliestExpiringTransaction() -> RevenueCatUI.Transaction? {
let activeSubscriptions = subscriptionsByProductIdentifier.values
.filter(\.isActive)
.sorted(by: {
@@ -235,14 +235,14 @@ private extension CustomerCenterViewModel {
}
}
func createPurchaseInformation(for transaction: Transaction,
func createPurchaseInformation(for transaction: RevenueCatUI.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,
storeProduct: product,
transaction: transaction,
customerCenterStoreKitUtilities: customerCenterStoreKitUtilities,
customerInfoRequestedDate: customerInfo.requestDate,
@@ -40,7 +40,8 @@ final class ManageSubscriptionsViewModel: BaseManageSubscriptionViewModel {
activePurchases: [PurchaseInformation] = [],
refundRequestStatus: RefundRequestStatus? = nil,
purchasesProvider: CustomerCenterPurchasesType,
loadPromotionalOfferUseCase: LoadPromotionalOfferUseCaseType? = nil) {
loadPromotionalOfferUseCase: LoadPromotionalOfferUseCaseType? = nil
) {
self.activePurchases = activePurchases
super.init(
screen: screen,
@@ -66,8 +66,8 @@ import SwiftUI
@available(watchOS, unavailable)
struct CompatibilityLabeledContent<Label: View, Content: View>: View {
let label: () -> Label
let content: () -> Content
@ViewBuilder let label: () -> Label
@ViewBuilder let content: () -> Content
var body: some View {
if #available(iOS 16.0, *) {
@@ -279,7 +279,7 @@ struct CustomerCenterView_Previews: PreviewProvider {
static var previews: some View {
let purchaseInformationApple =
CustomerCenterConfigData.subscriptionInformationMonthlyRenewing
PurchaseInformation.monthlyRenewing
let viewModelApple = CustomerCenterViewModel(
purchaseInformation: purchaseInformationApple,
configuration: CustomerCenterConfigData.default
@@ -137,9 +137,10 @@ struct ManageSubscriptionView: View {
var content: some View {
List {
if let purchaseInformation = self.viewModel.purchaseInformation {
SubscriptionDetailsView(
PurchaseInformationCardView(
purchaseInformation: purchaseInformation,
refundRequestStatus: self.viewModel.refundRequestStatus
showChevron: false,
localization: localization
)
Section {
@@ -234,43 +235,103 @@ struct ManageSubscriptionView: View {
viewModel: ManageSubscriptionViewModel(
screen: CustomerCenterConfigData.default.screens[.management]!,
showPurchaseHistory: false,
purchaseInformation: CustomerCenterConfigData.subscriptionInformationMonthlyRenewing
purchaseInformation: .monthlyRenewing
)
)
}
.preferredColorScheme(colorScheme)
.previewDisplayName("Monthly renewing - \(colorScheme)")
.previewDisplayName("Renewing subscription - No refund - No discount - \(colorScheme)")
CompatibilityNavigationStack {
ManageSubscriptionView(
viewModel: ManageSubscriptionViewModel(
screen: CustomerCenterConfigData.default.screens[.management]!,
showPurchaseHistory: false,
purchaseInformation: CustomerCenterConfigData.subscriptionInformationYearlyExpiring()
purchaseInformation: PurchaseInformation.yearlyExpiring()
)
)
}
.preferredColorScheme(colorScheme)
.previewDisplayName("Yearly expiring - \(colorScheme)")
.previewDisplayName("Renewing subscription - No refund - Pay as you go - \(colorScheme)")
CompatibilityNavigationStack {
ManageSubscriptionView(
viewModel: ManageSubscriptionViewModel(
screen: CustomerCenterConfigData.default.screens[.management]!,
showPurchaseHistory: false,
purchaseInformation: CustomerCenterConfigData.subscriptionInformationFree
purchaseInformation: .yearlyExpiring(
introductoryDiscount: MockStoreProductDiscount.discount(
paymentMode: .payUpFront,
price: 2.99,
subscriptionPeriod: .init(value: 3, unit: .month)
)
)
)
)
}
.preferredColorScheme(colorScheme)
.previewDisplayName("Free subscription - \(colorScheme)")
.previewDisplayName("Renewing subscription - No refund - Pay up front - \(colorScheme)")
CompatibilityNavigationStack {
ManageSubscriptionView(
viewModel: ManageSubscriptionViewModel(
screen: CustomerCenterConfigData.default.screens[.management]!,
showPurchaseHistory: false,
purchaseInformation: CustomerCenterConfigData.consumable
purchaseInformation: .yearlyExpiring(
introductoryDiscount: MockStoreProductDiscount.discount(
paymentMode: .freeTrial,
price: 2.99,
subscriptionPeriod: .init(value: 3, unit: .month)
)
)
)
)
}
.preferredColorScheme(colorScheme)
.previewDisplayName("Renewing subscription - No refund - Free trial - \(colorScheme)")
CompatibilityNavigationStack {
ManageSubscriptionView(
viewModel: ManageSubscriptionViewModel(
screen: CustomerCenterConfigData.default.screens[.management]!,
showPurchaseHistory: false,
purchaseInformation: .yearlyExpiring()
)
)
}
.preferredColorScheme(colorScheme)
.previewDisplayName("Renewing subscription - Requested refund - No discount - \(colorScheme)")
CompatibilityNavigationStack {
ManageSubscriptionView(
viewModel: ManageSubscriptionViewModel(
screen: CustomerCenterConfigData.default.screens[.management]!,
showPurchaseHistory: true,
purchaseInformation: .yearlyExpiring(isCancelled: true)
)
)
}
.preferredColorScheme(colorScheme)
.previewDisplayName("Cancelled subscription - No refund - No discount - \(colorScheme)")
CompatibilityNavigationStack {
ManageSubscriptionView(
viewModel: ManageSubscriptionViewModel(
screen: CustomerCenterConfigData.default.screens[.management]!,
showPurchaseHistory: false,
purchaseInformation: PurchaseInformation.subscriptionInformationFree
)
)
}
.preferredColorScheme(colorScheme)
.previewDisplayName("Free subscription - No refund - No discount - \(colorScheme)")
CompatibilityNavigationStack {
ManageSubscriptionView(
viewModel: ManageSubscriptionViewModel(
screen: CustomerCenterConfigData.default.screens[.management]!,
showPurchaseHistory: false,
purchaseInformation: PurchaseInformation.consumable
)
)
}
@@ -282,7 +343,7 @@ struct ManageSubscriptionView: View {
viewModel: ManageSubscriptionViewModel(
screen: CustomerCenterConfigData.default.screens[.management]!,
showPurchaseHistory: false,
purchaseInformation: CustomerCenterConfigData.subscriptionInformationYearlyExpiring(
purchaseInformation: PurchaseInformation.yearlyExpiring(
store: .playStore)
)
)
@@ -293,8 +354,7 @@ struct ManageSubscriptionView: View {
.environment(\.localization, CustomerCenterConfigData.default.localization)
.environment(\.appearance, CustomerCenterConfigData.default.appearance)
}
}
}
#endif
@@ -13,7 +13,7 @@
// Created by Andrés Boedo on 5/3/24.
//
import RevenueCat
@_spi(Internal) import RevenueCat
import SwiftUI
#if os(iOS)
@@ -48,7 +48,8 @@ struct ManageSubscriptionsView: View {
init(screen: CustomerCenterConfigData.Screen,
activePurchases: Binding<[PurchaseInformation]>,
purchasesProvider: CustomerCenterPurchasesType,
actionWrapper: CustomerCenterActionWrapper) {
actionWrapper: CustomerCenterActionWrapper
) {
let viewModel = ManageSubscriptionsViewModel(
screen: screen,
actionWrapper: actionWrapper,
@@ -98,90 +99,115 @@ struct ManageSubscriptionsView: View {
.environment(\.localization, localization)
.environment(\.navigationOptions, navigationOptions)
}
.onChangeOf(activePurchases) { _ in
.onChangeOf(activePurchases.first?.customerInfoRequestedDate) { _ in
viewModel.updatePurchases(activePurchases)
}
}
@ViewBuilder
var content: some View {
List {
if viewModel.purchasesMightBeDuplicated, support?.shouldWarnCustomersAboutMultipleSubscriptions == true {
CompatibilityContentUnavailableView(
localization[.youMayHaveDuplicatedSubscriptionsTitle],
systemImage: "exclamationmark.square",
description: Text(
localization[.youMayHaveDuplicatedSubscriptionsSubtitle]
)
)
.fixedSize(horizontal: false, vertical: true)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(
Color(colorScheme == .light
? UIColor.systemBackground
: UIColor.secondarySystemBackground)
ZStack {
Color(
colorScheme == .dark
? UIColor.secondarySystemBackground
: UIColor.systemGroupedBackground
)
.ignoresSafeArea()
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
if viewModel.purchasesMightBeDuplicated,
support?.shouldWarnCustomersAboutMultipleSubscriptions == true {
CompatibilityContentUnavailableView(
localization[.youMayHaveDuplicatedSubscriptionsTitle],
systemImage: "exclamationmark.square",
description: Text(
localization[.youMayHaveDuplicatedSubscriptionsSubtitle]
)
)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 24)
.padding(.top, 24)
)
}
.background(
RoundedRectangle(cornerRadius: 8)
.fill(
Color(colorScheme == .light
? UIColor.systemBackground
: UIColor.secondarySystemBackground)
)
)
}
if viewModel.activePurchases.isEmpty {
let fallbackDescription = localization[.tryCheckRestore]
if viewModel.activePurchases.isEmpty {
let fallbackDescription = localization[.tryCheckRestore]
Section {
CompatibilityContentUnavailableView(
self.viewModel.screen.title,
systemImage: "exclamationmark.triangle.fill",
description: Text(self.viewModel.screen.subtitle ?? fallbackDescription)
)
}
CompatibilityContentUnavailableView(
self.viewModel.screen.title,
systemImage: "exclamationmark.triangle.fill",
description: Text(self.viewModel.screen.subtitle ?? fallbackDescription)
)
.padding(.horizontal)
Section {
ManageSubscriptionsButtonsView(
viewModel: self.viewModel
)
}
ManageSubscriptionsButtonsView(
viewModel: self.viewModel
)
.padding(.horizontal)
} else {
Section(localization[.activeSubscriptions]) {
ForEach(viewModel.activePurchases) { purchase in
Button {
viewModel.purchaseInformation = purchase
} label: {
CompatibilityLabeledContent {
if purchase.title?.isEmpty == true {
Text(purchase.productIdentifier)
} else {
Text(purchase.title ?? "")
}
} content: {
Image(systemName: "chevron.forward")
} else {
Text(localization[.activeSubscriptions].uppercased())
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 32)
ForEach(viewModel.activePurchases) { purchase in
Button {
viewModel.purchaseInformation = purchase
} label: {
PurchaseInformationCardView(
purchaseInformation: purchase,
localization: localization
)
.padding(16)
.background(Color(UIColor.systemBackground))
.cornerRadius(10)
.padding(.horizontal)
}
.tint(.primary)
}
}
}
if support?.displayPurchaseHistoryLink == true {
Button {
viewModel.showAllPurchases = true
} label: {
CompatibilityLabeledContent(localization[.seeAllPurchases]) {
Image(systemName: "chevron.forward")
if support?.displayPurchaseHistoryLink == true {
Button {
viewModel.showAllPurchases = true
} label: {
CompatibilityLabeledContent(localization[.seeAllPurchases]) {
Image(systemName: "chevron.forward")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 12, height: 12)
.foregroundStyle(.secondary)
.font(Font.system(size: 12, weight: .bold))
}
.padding(16)
.background(Color(UIColor.systemBackground))
.cornerRadius(10)
.tint(.primary)
}
.padding(.horizontal)
}
}
}
.padding(.top)
}
}
.applyIf(self.viewModel.screen.type == .management, apply: {
$0.navigationTitle(self.viewModel.screen.title)
.navigationBarTitleDisplayMode(.inline)
})
})
}
}
#if DEBUG
@available(iOS 15.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@@ -190,10 +216,21 @@ struct ManageSubscriptionsView_Previews: PreviewProvider {
// swiftlint:disable force_unwrapping
static var previews: some View {
let purchases = [
CustomerCenterConfigData.subscriptionInformationYearlyExpiring(store: .amazon),
CustomerCenterConfigData.subscriptionInformationMonthlyRenewing,
CustomerCenterConfigData.subscriptionInformationFree
let purchases: [PurchaseInformation] = [
.yearlyExpiring(productIdentifier: "p1"),
.yearlyExpiring(productIdentifier: "p2", store: .amazon),
.yearlyExpiring(
productIdentifier: "p3",
introductoryDiscount: MockStoreProductDiscount.mock(
paymentMode: .payAsYouGo,
discountType: .introductory)),
.yearlyExpiring(
productIdentifier: "p4",
introductoryDiscount: MockStoreProductDiscount.mock(
paymentMode: .payUpFront,
discountType: .promotional)),
.monthlyRenewing,
.subscriptionInformationFree
]
let warningOffMock = CustomerCenterConfigData.mock(
@@ -233,7 +270,6 @@ struct ManageSubscriptionsView_Previews: PreviewProvider {
.environment(\.localization, CustomerCenterConfigData.default.localization)
.environment(\.appearance, CustomerCenterConfigData.default.appearance)
}
}
#endif
@@ -0,0 +1,133 @@
//
// 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
//
// PurchaseInformationCardView.swift
//
// Created by Facundo Menzella on 7/5/25.
import RevenueCat
import SwiftUI
#if os(iOS)
@available(iOS 15.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
struct PurchaseInformationCardView: View {
private let title: String
private let subtitle: String
private let storeTitle: String
private let showChevron: Bool
init(
purchaseInformation: PurchaseInformation,
showChevron: Bool = true,
localization: CustomerCenterConfigData.Localization
) {
if purchaseInformation.title?.isEmpty == true {
self.title = purchaseInformation.productIdentifier
} else {
self.title = purchaseInformation.title ?? purchaseInformation.productIdentifier
}
self.subtitle = purchaseInformation.billingInformation(localizations: localization)
self.storeTitle = localization[purchaseInformation.store.localizationKey]
self.showChevron = showChevron
}
init(
title: String,
subtitle: String,
storeTitle: String,
showChevron: Bool = true
) {
self.title = title
self.subtitle = subtitle
self.storeTitle = storeTitle
self.showChevron = showChevron
}
var body: some View {
CompatibilityLabeledContent {
VStack(alignment: .leading, spacing: 0) {
Text(title)
.font(.headline)
.foregroundStyle(.primary)
.padding(.bottom, 8)
.frame(alignment: .leading)
.multilineTextAlignment(.leading)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.padding(.bottom, 4)
.frame(alignment: .leading)
.multilineTextAlignment(.leading)
Text(storeTitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.frame(alignment: .leading)
.multilineTextAlignment(.leading)
}
} content: {
if showChevron {
Image(systemName: "chevron.forward")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 12, height: 12)
.foregroundStyle(.secondary)
.font(Font.system(size: 12, weight: .bold))
}
}
}
}
#if DEBUG
@available(iOS 15.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
struct PurchaseInformationCardView_Previews: PreviewProvider {
static var previews: some View {
ScrollView {
PurchaseInformationCardView(
title: "Product name",
subtitle: "Renews 24 May for $19.99",
storeTitle: Store.appStore.localizationKey.rawValue
)
.padding()
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 2)
.padding([.leading, .trailing])
PurchaseInformationCardView(
title: "Product name",
subtitle: "Renews 24 May for $19.99",
storeTitle: Store.playStore.localizationKey.rawValue
)
.padding()
.background(Color.white)
.cornerRadius(10)
.shadow(radius: 2)
.padding([.leading, .trailing])
}
.environment(\.localization, CustomerCenterConfigData.default.localization)
.environment(\.appearance, CustomerCenterConfigData.default.appearance)
}
}
#endif
#endif
@@ -299,7 +299,7 @@ private struct PreviewContainer: View {
var body: some View {
let purchaseInformationApple =
CustomerCenterConfigData.subscriptionInformationMonthlyRenewing
PurchaseInformation.monthlyRenewing
let viewModelApple = CustomerCenterViewModel(
purchaseInformation: purchaseInformationApple,
configuration: CustomerCenterConfigData.default
@@ -1,232 +0,0 @@
//
// 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
//
// SubscriptionDetailsView.swift
//
//
// Created by Cody Kerns on 8/12/24.
//
import RevenueCat
import SwiftUI
#if os(iOS)
@available(iOS 15.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
struct SubscriptionDetailsView: View {
let purchaseInformation: PurchaseInformation
let refundRequestStatus: RefundRequestStatus?
@Environment(\.localization)
private var localization: CustomerCenterConfigData.Localization
var body: some View {
VStack(alignment: .leading, spacing: 8) {
SubscriptionDetailsHeader(purchaseInformation: purchaseInformation, localization: localization)
.padding(.bottom, 10)
Divider()
.padding(.bottom)
VStack(alignment: .leading, spacing: 16.0) {
if let durationTitle = purchaseInformation.durationTitle {
IconLabelView(
iconName: "coloncurrencysign.arrow.circlepath",
label: localization[.billingCycle],
value: durationTitle
)
}
let priceValue: String? = {
switch purchaseInformation.price {
case .free:
return localization[.free]
case .paid(let localizedPrice):
return localizedPrice
case .unknown:
return nil
}
}()
if let price = priceValue {
IconLabelView(
iconName: "coloncurrencysign",
label: localization[.currentPrice],
value: price
)
}
if let expirationOrRenewal = purchaseInformation.expirationOrRenewal {
switch expirationOrRenewal.date {
case .never:
IconLabelView(
iconName: "calendar",
label: label(for: expirationOrRenewal),
value: localization[.never]
)
case .date(let value):
IconLabelView(
iconName: "calendar",
label: label(for: expirationOrRenewal),
value: value
)
}
}
if let refundRequestStatus = refundRequestStatus,
let refundStatusMessage = refundStatusMessage(for: refundRequestStatus) {
IconLabelView(
iconName: "arrowshape.turn.up.backward",
label: localization[.refundStatus],
value: refundStatusMessage
)
}
}
}
.padding(.vertical, 8.0)
}
private func refundStatusMessage(for status: RefundRequestStatus) -> String? {
switch status {
case .error:
return localization[.refundErrorGeneric]
case .success:
return localization[.refundGranted]
case .userCancelled:
return nil
@unknown default:
return nil
}
}
private func label(for expirationOrRenewal: PurchaseInformation.ExpirationOrRenewal) -> String {
switch expirationOrRenewal.label {
case .nextBillingDate:
return localization[.nextBillingDate]
case .expires:
return localization[.expires]
case .expired:
return localization[.expired]
}
}
}
@available(iOS 15.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
struct SubscriptionDetailsHeader: View {
let purchaseInformation: PurchaseInformation
let localization: CustomerCenterConfigData.Localization
var body: some View {
VStack(alignment: .leading) {
if let title = purchaseInformation.title {
Text(title)
.font(.headline)
}
if let explanation = getSubscriptionExplanation(from: purchaseInformation, localization: localization) {
Text(explanation)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.leading)
}
}
}
// swiftlint:disable:next cyclomatic_complexity
private func getSubscriptionExplanation(from purchaseInformation: PurchaseInformation,
localization: CustomerCenterConfigData.Localization) -> String? {
guard purchaseInformation.expirationOrRenewal != nil else {
return nil
}
switch purchaseInformation.explanation {
case .promotional:
return localization[.youHavePromo]
case .earliestRenewal:
return localization[.subEarliestRenewal]
case .earliestExpiration:
return localization[.subEarliestExpiration]
case .expired:
return localization[.subExpired]
case .lifetime:
return localization[.youHaveLifetime]
case .google:
return localization[.googleSubscriptionManage]
case .externalWeb:
return localization[.webSubscriptionManage]
case .otherStorePurchase:
return localization[.pleaseContactSupportToManage]
case .amazon:
return localization[.amazonSubscriptionManage]
case .rcWebBilling:
return localization[.webSubscriptionManage]
}
}
}
@available(iOS 15.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
struct IconLabelView: View {
let iconName: String
let label: String
let value: String
private let iconWidth = 22.0
var body: some View {
HStack(alignment: .center) {
Image(systemName: iconName)
.accessibilityHidden(true)
.frame(width: iconWidth)
VStack(alignment: .leading) {
Text(label)
.font(.caption2)
.foregroundColor(.secondary)
.textCase(.uppercase)
Text(value)
.font(.body)
}
}
}
}
#if DEBUG
@available(iOS 15.0, *)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
struct SubscriptionDetailsView_Previews: PreviewProvider {
static var previews: some View {
ForEach(ColorScheme.allCases, id: \.self) { colorScheme in
SubscriptionDetailsView(
purchaseInformation: CustomerCenterConfigData.subscriptionInformationMonthlyRenewing,
refundRequestStatus: .success
)
.preferredColorScheme(colorScheme)
.previewDisplayName("Subscription Details - Monthly - \(colorScheme)")
.padding()
}
}
}
#endif
#endif
@@ -144,6 +144,12 @@ public struct CustomerCenterConfigData: Equatable {
case debugHeaderTitle = "Debug"
case youMayHaveDuplicatedSubscriptionsTitle = "you_may_have_duplicated_subscriptions_title"
case youMayHaveDuplicatedSubscriptionsSubtitle = "you_may_have_duplicated_subscriptions_subtitle"
case pricePaid = "price_paid"
case expiresOnDateWithoutChanges = "expires_on_date_without_changes"
case renewsOnDateForPrice = "renews_on_date_for_price"
case renewsOnDate = "renews_on_date"
case priceAfterwards = "price_afterwards"
case freeTrialUntilDate = "free_trial_until_date"
var defaultValue: String {
switch self {
@@ -334,6 +340,18 @@ public struct CustomerCenterConfigData: Equatable {
case .youMayHaveDuplicatedSubscriptionsSubtitle:
return "You might be subscribed both on the web and through the App Store." +
"To avoid being charged twice, please cancel your iOS subscription in your device settings."
case .pricePaid:
return "Paid {{ price }}."
case .expiresOnDateWithoutChanges:
return "Expires on {{ date }} without further charges."
case .renewsOnDateForPrice:
return "Renews on {{ date }} for {{ price }}."
case .renewsOnDate:
return "Renews on {{ date }}."
case .priceAfterwards:
return "{{ price }} afterwards."
case .freeTrialUntilDate:
return "Free trial until {{ date }}."
}
}
}
@@ -98,7 +98,7 @@ final class CustomerCenterViewModelTests: TestCase {
await viewModel.loadScreen()
expect(viewModel.purchaseInformation) == viewModel.activePurchases.first
expect(viewModel.purchaseInformation?.productIdentifier) == viewModel.activePurchases.first?.productIdentifier
let purchaseInformation = try XCTUnwrap(viewModel.activePurchases.first)
expect(purchaseInformation.store) == .appStore
@@ -117,7 +117,7 @@ final class CustomerCenterViewModelTests: TestCase {
await viewModel.loadScreen()
expect(viewModel.purchaseInformation) == viewModel.activePurchases.first
expect(viewModel.purchaseInformation?.productIdentifier) == viewModel.activePurchases.first?.productIdentifier
let purchaseInformation = try XCTUnwrap(viewModel.activePurchases.first)
expect(purchaseInformation.store) == .playStore
@@ -203,7 +203,7 @@ final class CustomerCenterViewModelTests: TestCase {
// Assert
expect(viewModel.state) == .success
expect(viewModel.purchaseInformation) == viewModel.activePurchases.first
expect(viewModel.purchaseInformation?.productIdentifier) == viewModel.activePurchases.first?.productIdentifier
let purchaseInformation = try XCTUnwrap(viewModel.activePurchases.first)
expect(purchaseInformation.title) == "title"
expect(purchaseInformation.durationTitle) == "1 month"
@@ -443,7 +443,7 @@ final class ManageSubscriptionViewModelTests: TestCase {
signedData: signedData)
let screen = PurchaseInformationFixtures.screenWithPromo(offerID: offerIdentifierInJSON)
let purchaseInformation = CustomerCenterConfigData.subscriptionInformationYearlyExpiring()
let purchaseInformation = PurchaseInformation.yearlyExpiring()
let viewModel = ManageSubscriptionViewModel(screen: screen,
showPurchaseHistory: false,
actionWrapper: CustomerCenterActionWrapper(),
@@ -514,14 +514,14 @@ private extension PurchaseInformation {
durationTitle: "",
explanation: .lifetime,
price: .paid(""),
expirationOrRenewal: PurchaseInformation.ExpirationOrRenewal(label: .expires, date: .date("")),
expirationOrRenewal: ExpirationOrRenewal(label: .expires, date: .date("")),
productIdentifier: "",
store: .appStore,
isLifetime: true,
isTrial: false,
isCancelled: false,
latestPurchaseDate: nil,
customerInfoRequestedDate: customerInfoRequestedDate,
isCancelled: false,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
)
}
@@ -548,9 +548,9 @@ private extension PurchaseInformation {
store: store,
isLifetime: false,
isTrial: isTrial,
isCancelled: isCancelled,
latestPurchaseDate: latestPurchaseDate,
customerInfoRequestedDate: customerInfoRequestedDate,
isCancelled: isCancelled,
managePurchaseURL: managementURL
)
}
@@ -72,11 +72,11 @@ final class PurchaseInformationTests: TestCase {
let subscriptionInfo = try XCTUnwrap(PurchaseInformation(
entitlement: entitlement,
subscribedProduct: mockProduct.toStoreProduct(),
storeProduct: mockProduct.toStoreProduct(),
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
))
expect(subscriptionInfo.title) == "Monthly Product"
@@ -125,13 +125,14 @@ final class PurchaseInformationTests: TestCase {
let subscriptionInfo = try XCTUnwrap(
PurchaseInformation(
entitlement: entitlement,
subscribedProduct: mockProduct.toStoreProduct(),
storeProduct: mockProduct.toStoreProduct(),
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)
expect(subscriptionInfo.title) == "Monthly Product"
expect(subscriptionInfo.durationTitle) == "1 month"
expect(subscriptionInfo.explanation) == .lifetime
@@ -178,13 +179,14 @@ final class PurchaseInformationTests: TestCase {
let subscriptionInfo = try XCTUnwrap(
PurchaseInformation(
entitlement: entitlement,
subscribedProduct: mockProduct.toStoreProduct(),
storeProduct: mockProduct.toStoreProduct(),
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)
expect(subscriptionInfo.title) == "Monthly Product"
expect(subscriptionInfo.durationTitle) == "1 month"
expect(subscriptionInfo.explanation) == .earliestExpiration
@@ -231,13 +233,14 @@ final class PurchaseInformationTests: TestCase {
let subscriptionInfo = try XCTUnwrap(
PurchaseInformation(
entitlement: entitlement,
subscribedProduct: mockProduct.toStoreProduct(),
storeProduct: mockProduct.toStoreProduct(),
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)
expect(subscriptionInfo.title) == "Monthly Product"
expect(subscriptionInfo.durationTitle) == "1 month"
expect(subscriptionInfo.explanation) == .expired
@@ -273,8 +276,8 @@ final class PurchaseInformationTests: TestCase {
entitlement: entitlement,
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)
@@ -313,8 +316,8 @@ final class PurchaseInformationTests: TestCase {
entitlement: entitlement,
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)
@@ -353,8 +356,8 @@ final class PurchaseInformationTests: TestCase {
entitlement: entitlement,
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)
@@ -393,8 +396,8 @@ final class PurchaseInformationTests: TestCase {
entitlement: entitlement,
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)
@@ -433,8 +436,8 @@ final class PurchaseInformationTests: TestCase {
entitlement: entitlement,
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)
@@ -474,8 +477,8 @@ final class PurchaseInformationTests: TestCase {
entitlement: entitlement,
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)
@@ -514,8 +517,8 @@ final class PurchaseInformationTests: TestCase {
entitlement: entitlement,
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)
@@ -554,8 +557,8 @@ final class PurchaseInformationTests: TestCase {
entitlement: entitlement,
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)
@@ -594,8 +597,8 @@ final class PurchaseInformationTests: TestCase {
entitlement: entitlement,
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)
expect(subscriptionInfo.title).to(beNil())
@@ -633,8 +636,8 @@ final class PurchaseInformationTests: TestCase {
entitlement: entitlement,
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)
@@ -673,8 +676,8 @@ final class PurchaseInformationTests: TestCase {
entitlement: entitlement,
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)
@@ -708,11 +711,11 @@ final class PurchaseInformationTests: TestCase {
let subscriptionInfo = try XCTUnwrap(
PurchaseInformation(
entitlement: nil,
subscribedProduct: nil,
storeProduct: nil,
transaction: mockTransaction,
customerInfoRequestedDate: Date(),
dateFormatter: Self.mockDateFormatter,
managePurchaseURL: URL(string: "https://www.revenuecat.com")!
managePurchaseURL: URL(string: "https://www.revenuecat.com")!,
dateFormatter: Self.mockDateFormatter
)
)