Skip to content

Commit e362425

Browse files
authored
[PM-35100] feat: Load subscription data into premium plan screen (#2555)
1 parent 2373dce commit e362425

27 files changed

Lines changed: 1175 additions & 142 deletions

BitwardenResources/Localizations/en.lproj/Localizable.strings

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1043,20 +1043,28 @@
10431043
"BillingAmount" = "Billing amount";
10441044
"StorageCost" = "Storage cost";
10451045
"Discount" = "Discount";
1046+
"EstimatedTax" = "Estimated tax";
10461047
"ManagePlan" = "Manage plan";
10471048
"CancelPremium" = "Cancel Premium";
1049+
"CancelNow" = "Cancel now";
10481050
"YourNextChargeIsForXDueOnY" = "Your next charge is for **%1$@**, due on **%2$@**.";
10491051
"WeCouldNotProcessYourPaymentUpdateYourPaymentMethodDescriptionLong" = "We could not process your payment. Update your payment method before your subscription ends on **%1$@**.";
1050-
"YouHaveAGracePeriodOfXFromYourSubscriptionDescriptionLong" = "You have a grace period of **%1$@** from your subscription expiration date. Please resolve the past due invoices by **%2$@**.";
10511052
"YourSubscriptionWasCanceledOnXResubscribeToContinueUsingDescriptionLong" = "Your subscription was canceled on **%1$@**. Resubscribe to continue using premium features.";
10521053
"PerMonth" = "/ month";
1054+
"PerYear" = "/ year";
1055+
"XAmountPerCadence" = "%1$@ %2$@";
1056+
"NegativeX" = "-%1$@";
1057+
"UnknownStatus" = "Unknown";
1058+
"YourSubscriptionStatusIsUnknownVisitTheWebAppDescriptionLong" = "Your subscription status could not be determined. Please visit the web app for more details or update to the latest version.";
1059+
"AtTheMomentPremiumPlanIsNotAvailableDescriptionLong" = "At the moment premium plan is not available, please try again later.";
10531060
"UnlockMoreAdvancedFeaturesWithPremiumPlan" = "Unlock more advanced features with a Premium plan.";
10541061
"BuiltInAuthenticator" = "Built-in authenticator";
10551062
"EmergencyAccess" = "Emergency access";
10561063
"SecureFileStorage" = "Secure file storage";
10571064
"BreachMonitoring" = "Breach monitoring";
10581065
"UpgradeNow" = "Upgrade now";
10591066
"YoullGoToStripeSecureCheckoutToCompleteYourPurchase" = "You´ll go to Stripe´s secure checkout to complete your purchase.";
1067+
"YoullContinueToHavePremiumAccessUntilX" = "You'll continue to have Premium access until %1$@.";
10601068
"UserIDCopiedToTheClipboard" = "User ID copied to the clipboard";
10611069
"UserID" = "User ID";
10621070
"CopyUserID" = "Copy user ID";

BitwardenResources/Localizations/en.lproj/Localizable.stringsdict

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,23 @@
189189
<string>%d weeks</string>
190190
</dict>
191191
</dict>
192+
<!-- A message indicating the grace period for a past due subscription, with pluralized days and the date. -->
193+
<key>YouHaveAGracePeriodOfXDaysFromYourSubscriptionDescriptionLong</key>
194+
<dict>
195+
<key>NSStringLocalizedFormatKey</key>
196+
<string>**%1$#@YouHaveAGracePeriodOfXDaysFromYourSubscriptionExpirationDate@** Please resolve the past due invoices by **%2$@**.</string>
197+
<key>YouHaveAGracePeriodOfXDaysFromYourSubscriptionExpirationDate</key>
198+
<dict>
199+
<key>NSStringFormatSpecTypeKey</key>
200+
<string>NSStringPluralRuleType</string>
201+
<key>NSStringFormatValueTypeKey</key>
202+
<string>d</string>
203+
<key>one</key>
204+
<string>You have a grace period of %1$d day from your subscription expiration date.</string>
205+
<key>other</key>
206+
<string>You have a grace period of %1$d days from your subscription expiration date.</string>
207+
</dict>
208+
</dict>
192209
<!-- A message indicating the minimum length of a PIN in characters. -->
193210
<key>YourPINMustBeAtLeastXCharactersDescriptionLong</key>
194211
<dict>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Foundation
2+
3+
extension NumberFormatter {
4+
/// A formatter for US dollar currency strings using the currency symbol (e.g. "$19.80").
5+
static let usdCurrency: NumberFormatter = {
6+
let formatter = NumberFormatter()
7+
formatter.numberStyle = .currency
8+
formatter.currencyCode = "USD"
9+
return formatter
10+
}()
11+
12+
/// A formatter for US dollar currency strings using the ISO currency code (e.g. "19.80 USD").
13+
static let usdCurrencyCode: NumberFormatter = {
14+
let formatter = NumberFormatter()
15+
formatter.numberStyle = .currencyISOCode
16+
formatter.currencyCode = "USD"
17+
return formatter
18+
}()
19+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import Foundation
2+
3+
@testable import BitwardenShared
4+
5+
extension PremiumSubscription {
6+
static func fixture(
7+
cadence: PlanCadenceType = .annually,
8+
cancelAt: Date? = nil,
9+
canceled: Date? = nil,
10+
discount: Decimal = 0,
11+
estimatedTax: Decimal = 0,
12+
gracePeriod: Int? = nil,
13+
nextCharge: Date? = nil,
14+
seatsCost: Decimal = 19.8,
15+
status: PremiumPlanStatus = .active,
16+
storageCost: Decimal = 0,
17+
suspension: Date? = nil,
18+
) -> PremiumSubscription {
19+
PremiumSubscription(
20+
cadence: cadence,
21+
cancelAt: cancelAt,
22+
canceled: canceled,
23+
discount: discount,
24+
estimatedTax: estimatedTax,
25+
gracePeriod: gracePeriod,
26+
nextCharge: nextCharge,
27+
seatsCost: seatsCost,
28+
status: status,
29+
storageCost: storageCost,
30+
suspension: suspension,
31+
)
32+
}
33+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import Foundation
2+
3+
// MARK: - PremiumSubscription
4+
5+
/// A domain model representing the user's premium subscription details.
6+
///
7+
struct PremiumSubscription: Equatable {
8+
// MARK: Properties
9+
10+
/// The billing cadence (e.g. annually, monthly).
11+
let cadence: PlanCadenceType
12+
13+
/// The date at which the subscription will be canceled, if pending cancellation.
14+
let cancelAt: Date?
15+
16+
/// The date the subscription was canceled.
17+
let canceled: Date?
18+
19+
/// The total discount amount applied to the subscription.
20+
let discount: Decimal
21+
22+
/// The estimated tax amount.
23+
let estimatedTax: Decimal
24+
25+
/// The number of days in the grace period after the subscription goes past due.
26+
let gracePeriod: Int?
27+
28+
/// The date of the next charge for the subscription.
29+
let nextCharge: Date?
30+
31+
/// The total cost for seats (cost × quantity).
32+
let seatsCost: Decimal
33+
34+
/// The status of the subscription.
35+
let status: PremiumPlanStatus
36+
37+
/// The total cost for additional storage (cost × quantity), or zero if none.
38+
let storageCost: Decimal
39+
40+
/// The date the subscription will be suspended due to lack of payment.
41+
let suspension: Date?
42+
43+
// MARK: Computed Properties
44+
45+
/// The total amount for the next charge (seats + storage + tax - discount), floored at zero.
46+
var totalAmount: Decimal {
47+
max(0, seatsCost + storageCost + estimatedTax - discount)
48+
}
49+
50+
// MARK: Initialization
51+
52+
/// Creates a `PremiumSubscription` from a subscription API response.
53+
///
54+
/// - Parameter response: The API response model.
55+
///
56+
init(response: BitwardenSubscriptionResponseModel) {
57+
let seats = response.cart.passwordManager?.seats
58+
let storage = response.cart.passwordManager?.additionalStorage
59+
60+
let seatsCost = (seats?.cost ?? 0) * Decimal(seats?.quantity ?? 0)
61+
let storageCost = (storage?.cost ?? 0) * Decimal(storage?.quantity ?? 0)
62+
63+
let itemTotal = seatsCost + storageCost
64+
let seatDiscount = Self.discountAmount(seats?.discount, on: seatsCost)
65+
let storageDiscount = Self.discountAmount(storage?.discount, on: storageCost)
66+
let cartDiscount = Self.discountAmount(response.cart.discount, on: itemTotal)
67+
68+
cadence = response.cart.cadence
69+
cancelAt = response.cancelAt
70+
canceled = response.canceled
71+
discount = seatDiscount + storageDiscount + cartDiscount
72+
estimatedTax = response.cart.estimatedTax
73+
gracePeriod = response.gracePeriod
74+
nextCharge = response.nextCharge
75+
self.seatsCost = seatsCost
76+
self.status = PremiumPlanStatus(response.status)
77+
self.storageCost = storageCost
78+
suspension = response.suspension
79+
}
80+
81+
/// Creates a `PremiumSubscription` with explicit values.
82+
///
83+
init(
84+
cadence: PlanCadenceType,
85+
cancelAt: Date?,
86+
canceled: Date?,
87+
discount: Decimal,
88+
estimatedTax: Decimal,
89+
gracePeriod: Int?,
90+
nextCharge: Date?,
91+
seatsCost: Decimal,
92+
status: PremiumPlanStatus,
93+
storageCost: Decimal,
94+
suspension: Date?,
95+
) {
96+
self.cadence = cadence
97+
self.cancelAt = cancelAt
98+
self.canceled = canceled
99+
self.discount = discount
100+
self.estimatedTax = estimatedTax
101+
self.gracePeriod = gracePeriod
102+
self.nextCharge = nextCharge
103+
self.seatsCost = seatsCost
104+
self.status = status
105+
self.storageCost = storageCost
106+
self.suspension = suspension
107+
}
108+
109+
// MARK: Private Methods
110+
111+
/// Computes the discount amount for a cart item.
112+
///
113+
/// - Parameters:
114+
/// - discount: The discount model, if any.
115+
/// - cost: The item's total cost before discount.
116+
/// - Returns: The computed discount amount.
117+
///
118+
private static func discountAmount(_ discount: BitwardenDiscountResponseModel?, on cost: Decimal) -> Decimal {
119+
guard let discount else { return 0 }
120+
switch discount.type {
121+
case .amountOff:
122+
return discount.value
123+
case .percentOff:
124+
return cost * discount.value / 100
125+
}
126+
}
127+
}

BitwardenShared/Core/Billing/Models/Enum/BillingError.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,16 @@ enum BillingError: LocalizedError {
88
/// The checkout URL is invalid (e.g., not HTTPS).
99
case invalidCheckoutUrl
1010

11+
/// The portal URL is invalid (e.g., not HTTPS).
12+
case invalidPortalUrl
13+
1114
/// Unable to open the checkout URL in the browser.
1215
case unableToOpenCheckout
1316

1417
var errorDescription: String? {
1518
switch self {
1619
case .invalidCheckoutUrl,
20+
.invalidPortalUrl,
1721
.unableToOpenCheckout:
1822
// TODO: PM-33856 Handle payment errors
1923
nil

BitwardenShared/Core/Billing/Models/Enum/PlanCadenceType.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import BitwardenResources
2+
13
// MARK: - PlanCadenceType
24

35
/// The billing cadence for a subscription plan.
@@ -8,4 +10,14 @@ enum PlanCadenceType: String, Codable, Equatable, Sendable {
810

911
/// A monthly billing cadence.
1012
case monthly
13+
14+
/// The localized label for this cadence (e.g. "/ month", "/ year").
15+
var label: String {
16+
switch self {
17+
case .annually:
18+
Localizations.perYear
19+
case .monthly:
20+
Localizations.perMonth
21+
}
22+
}
1123
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import Foundation
2+
3+
// MARK: - SubscriptionStatus
4+
5+
/// The status of a subscription as returned by the API.
6+
///
7+
enum SubscriptionStatus: String, Codable, Equatable, Sendable {
8+
/// The subscription is active.
9+
case active
10+
11+
/// The subscription has been canceled.
12+
case canceled
13+
14+
/// The subscription is past due.
15+
case pastDue = "past_due"
16+
17+
/// An unknown status not yet supported by the app.
18+
case unknown
19+
20+
/// The subscription is unpaid.
21+
case unpaid
22+
23+
/// Decodes a `SubscriptionStatus` from the API, defaulting to `.unknown` for unrecognized values.
24+
init(from decoder: Decoder) throws {
25+
let container = try decoder.singleValueContainer()
26+
let rawValue = try container.decode(String.self)
27+
self = SubscriptionStatus(rawValue: rawValue) ?? .unknown
28+
}
29+
}

BitwardenShared/Core/Billing/Models/Response/BitwardenSubscriptionResponseModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ struct BitwardenSubscriptionResponseModel: JSONResponse, Equatable, Sendable {
2626
let nextCharge: Date?
2727

2828
/// The status of the subscription.
29-
let status: String
29+
let status: SubscriptionStatus
3030

3131
/// The amount of storage available and used for the subscription.
3232
let storage: SubscriptionStorageResponseModel?
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import Foundation
2+
3+
@testable import BitwardenShared
4+
5+
extension BitwardenSubscriptionResponseModel {
6+
static func fixture(
7+
cancelAt: Date? = nil,
8+
canceled: Date? = nil,
9+
cart: SubscriptionCartResponseModel = .fixture(),
10+
gracePeriod: Int? = nil,
11+
nextCharge: Date? = nil,
12+
status: SubscriptionStatus = .active,
13+
storage: SubscriptionStorageResponseModel? = nil,
14+
suspension: Date? = nil,
15+
) -> BitwardenSubscriptionResponseModel {
16+
BitwardenSubscriptionResponseModel(
17+
cancelAt: cancelAt,
18+
canceled: canceled,
19+
cart: cart,
20+
gracePeriod: gracePeriod,
21+
nextCharge: nextCharge,
22+
status: status,
23+
storage: storage,
24+
suspension: suspension,
25+
)
26+
}
27+
}
28+
29+
extension SubscriptionCartResponseModel {
30+
static func fixture(
31+
cadence: PlanCadenceType = .annually,
32+
discount: BitwardenDiscountResponseModel? = nil,
33+
estimatedTax: Decimal = 0,
34+
passwordManager: PasswordManagerCartItemsResponseModel? = nil,
35+
) -> SubscriptionCartResponseModel {
36+
SubscriptionCartResponseModel(
37+
cadence: cadence,
38+
discount: discount,
39+
estimatedTax: estimatedTax,
40+
passwordManager: passwordManager,
41+
)
42+
}
43+
}

0 commit comments

Comments
 (0)