Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1078,6 +1078,7 @@
"Canceled" = "Canceled";
"PastDue" = "Past due";
"PendingCancellation" = "Pending cancellation";
"Unpaid" = "Unpaid";
"UpdatePayment" = "Update payment";
"BillingAmount" = "Billing amount";
"StorageCost" = "Storage cost";
Expand All @@ -1092,6 +1093,7 @@
/* Error message shown when the Premium Plan screen fails to load subscription data, with a prompt to retry. */
"WeCouldntLoadYourSubscriptionDetailsPleaseRetry" = "We couldn't load your subscription details. Please try again.";
"YourSubscriptionWasCanceledOnXDescriptionLong" = "Your subscription was canceled on **%1$@**. Resubscribe to continue using Premium features.";
"YourSubscriptionWasSuspendedOnXDescriptionLong" = "Your subscription was suspended on **%1$@**. To reactivate, please resolve your past due invoices.";
"YourSubscriptionExpiredOnXDescriptionLong" = "Your subscription expired on **%1$@**. Resubscribe to continue using Premium features.";
"YourSubscriptionIsScheduledToCancelOnXDescriptionLong" = "Your subscription is scheduled to cancel on **%1$@**. You can reinstate it anytime before then.";
"PerMonth" = "/ month";
Expand Down
19 changes: 15 additions & 4 deletions BitwardenShared/Core/Billing/Services/BillingService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ protocol BillingService: AnyObject { // sourcery: AutoMockable
/// Fetches the current subscription status and updates the visibility of the subscription
/// attention action card.
///
/// This runs for all non-self-hosted accounts regardless of their current premium status.
/// A user whose subscription has lapsed (e.g. unpaid after repeated payment failures) still
/// needs their payment-problem state surfaced even though the server reports them as
/// non-premium. Accounts with no personal subscription (free users) receive a
/// `GetSubscriptionRequestError` and are handled silently β€” the card is hidden for them.
///
/// - Parameters:
/// - subscription: A previously fetched subscription to use, or `nil` to fetch fresh.
///
Expand Down Expand Up @@ -231,8 +237,7 @@ class DefaultBillingService: BillingService {

func refreshSubscriptionAttentionCard(subscription: PremiumSubscription?) async {
guard await !isSelfHosted(),
await configService.getFeatureFlag(.premiumUpgradePath),
await stateService.doesActiveAccountHavePremiumPersonally()
await configService.getFeatureFlag(.premiumUpgradePath)
else {
do {
try await billingStateService.setSubscriptionAttentionCardVisible(false)
Expand All @@ -247,8 +252,14 @@ class DefaultBillingService: BillingService {
} else {
try await getSubscription()
}
let visible = sub.status == .pastDue || sub.status == .updatePayment
try await billingStateService.setSubscriptionAttentionCardVisible(visible)
try await billingStateService.setSubscriptionAttentionCardVisible(sub.status.isPaymentProblemState)
} catch is GetSubscriptionRequestError {
// No personal subscription β€” free user or subscription fully gone. Card not shown.
do {
try await billingStateService.setSubscriptionAttentionCardVisible(false)
} catch {
errorReporter.log(error: error)
}
} catch {
errorReporter.log(error: error)
}
Expand Down
30 changes: 17 additions & 13 deletions BitwardenShared/Core/Billing/Services/BillingServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ struct BillingServiceTests { // swiftlint:disable:this type_body_length
#expect(result.nextCharge != nil)
}

/// `getSubscription()` maps unpaid status to updatePayment.
/// `getSubscription()` maps unpaid status to its own `.unpaid` plan status.
@Test
func getSubscription_unpaid() async throws {
billingAPIService.getSubscriptionReturnValue = .fixture(
Expand All @@ -281,7 +281,7 @@ struct BillingServiceTests { // swiftlint:disable:this type_body_length

let result = try await subject.getSubscription()

#expect(result.status == .updatePayment)
#expect(result.status == .unpaid)
#expect(result.cancelAt != nil)
}

Expand Down Expand Up @@ -551,14 +551,13 @@ struct BillingServiceTests { // swiftlint:disable:this type_body_length
/// whether the subscription status requires payment attention.
@Test(arguments: [
(SubscriptionStatus.pastDue, true),
(SubscriptionStatus.unpaid, true), // maps to PremiumPlanStatus.updatePayment
(SubscriptionStatus.unpaid, true),
(SubscriptionStatus.active, false),
])
func refreshSubscriptionAttentionCard_statusVisibility(
status: SubscriptionStatus,
expectedVisible: Bool,
) async {
stateService.doesActiveAccountHavePremiumPersonallyResult = true
billingAPIService.getSubscriptionReturnValue = .fixture(status: status)

await subject.refreshSubscriptionAttentionCard(subscription: nil)
Expand All @@ -571,7 +570,6 @@ struct BillingServiceTests { // swiftlint:disable:this type_body_length
@Test
func refreshSubscriptionAttentionCard_selfHosted() async {
environmentService.region = .selfHosted
stateService.doesActiveAccountHavePremiumPersonallyResult = true

await subject.refreshSubscriptionAttentionCard(subscription: nil)

Expand All @@ -584,7 +582,6 @@ struct BillingServiceTests { // swiftlint:disable:this type_body_length
@Test
func refreshSubscriptionAttentionCard_featureFlagDisabled() async {
configService.featureFlagsBool[.premiumUpgradePath] = false
stateService.doesActiveAccountHavePremiumPersonallyResult = true

await subject.refreshSubscriptionAttentionCard(subscription: nil)

Expand All @@ -593,34 +590,41 @@ struct BillingServiceTests { // swiftlint:disable:this type_body_length
}

/// `refreshSubscriptionAttentionCard(subscription:)` sets the cached visibility to `false`
/// and skips the API call when the user does not have premium personally.
/// and does not log an error when the user has no personal subscription (free user).
@Test
func refreshSubscriptionAttentionCard_noPremiumPersonally() async {
stateService.doesActiveAccountHavePremiumPersonallyResult = false
func refreshSubscriptionAttentionCard_noSubscription() async {
billingAPIService.getSubscriptionThrowableError = GetSubscriptionRequestError.noSubscription

await subject.refreshSubscriptionAttentionCard(subscription: nil)

#expect(stateService.subscriptionAttentionCardVisibleResult == false)
#expect(!billingAPIService.getSubscriptionCalled)
#expect(errorReporter.errors.isEmpty)
}

/// `refreshSubscriptionAttentionCard(subscription:)` uses an already-fetched subscription
/// instead of making a new API call when one is provided.
@Test
func refreshSubscriptionAttentionCard_usesProvidedSubscription() async {
stateService.doesActiveAccountHavePremiumPersonallyResult = true

await subject.refreshSubscriptionAttentionCard(subscription: .fixture(status: .pastDue))

#expect(stateService.subscriptionAttentionCardVisibleResult == true)
#expect(!billingAPIService.getSubscriptionCalled)
}

/// `refreshSubscriptionAttentionCard(subscription:)` sets the cached visibility to `true`
/// when a subscription with `.unpaid` status is provided directly (Plan screen path).
@Test
func refreshSubscriptionAttentionCard_unpaid_providedSubscription() async {
await subject.refreshSubscriptionAttentionCard(subscription: .fixture(status: .unpaid))

#expect(stateService.subscriptionAttentionCardVisibleResult == true)
#expect(!billingAPIService.getSubscriptionCalled)
}

/// `refreshSubscriptionAttentionCard(subscription:)` logs the error and does not update
/// the cache when the API call fails.
@Test
func refreshSubscriptionAttentionCard_apiError() async {
stateService.doesActiveAccountHavePremiumPersonallyResult = true
billingAPIService.getSubscriptionThrowableError = URLError(.notConnectedToInternet)

await subject.refreshSubscriptionAttentionCard(subscription: nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ struct PremiumPlanState: Equatable {
)
case .unknown:
Localizations.yourSubscriptionStatusIsUnknownVisitTheWebAppDescriptionLong
case .unpaid:
Localizations.yourSubscriptionWasSuspendedOnXDescriptionLong(subscriptionEndDate)
case .updatePayment:
Localizations.weCouldNotProcessYourPaymentUpdateYourPaymentMethodDescriptionLong(
subscriptionEndDate,
Expand Down Expand Up @@ -139,6 +141,7 @@ struct PremiumPlanState: Equatable {
&& planStatus != .expired
&& planStatus != .pendingCancellation
&& planStatus != .unknown
&& planStatus != .unpaid
&& planStatus != .updatePayment
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ struct PremiumPlanStateTests { // swiftlint:disable:this type_body_length
}

/// `descriptionAccessibilityLabel` returns `descriptionText` with markdown stripped for non-active plan statuses.
@Test(arguments: [PremiumPlanStatus.canceled, .expired, .pastDue, .pendingCancellation, .unknown, .updatePayment])
@Test(arguments: [
PremiumPlanStatus.canceled, .expired, .pastDue, .pendingCancellation, .unknown, .unpaid, .updatePayment,
])
func descriptionAccessibilityLabel_nonActive(planStatus: PremiumPlanStatus) {
var state = PremiumPlanState()
state.planStatus = planStatus
Expand Down Expand Up @@ -129,6 +131,16 @@ struct PremiumPlanStateTests { // swiftlint:disable:this type_body_length
))
}

/// `descriptionText` returns the correct text for the unpaid plan status.
@Test
func descriptionText_unpaid() {
var state = PremiumPlanState()
state.planStatus = .unpaid
state.loadingState = .data(.fixture(status: .unpaid, suspension: testDate))
#expect(state.descriptionText == Localizations
.yourSubscriptionWasSuspendedOnXDescriptionLong(state.subscriptionEndDate))
}

/// `descriptionText` returns the correct text for the update payment plan status.
@Test
func descriptionText_updatePayment() {
Expand Down Expand Up @@ -214,6 +226,7 @@ struct PremiumPlanStateTests { // swiftlint:disable:this type_body_length
(PremiumPlanStatus.pastDue, true),
(PremiumPlanStatus.pendingCancellation, true),
(PremiumPlanStatus.unknown, false),
(PremiumPlanStatus.unpaid, true),
(PremiumPlanStatus.updatePayment, true),
])
func showBillingDetails(planStatus: PremiumPlanStatus, expected: Bool) {
Expand All @@ -232,6 +245,7 @@ struct PremiumPlanStateTests { // swiftlint:disable:this type_body_length
(PremiumPlanStatus.pastDue, true),
(PremiumPlanStatus.pendingCancellation, false),
(PremiumPlanStatus.unknown, false),
(PremiumPlanStatus.unpaid, false),
(PremiumPlanStatus.updatePayment, false),
])
func showCancelButton(planStatus: PremiumPlanStatus, expected: Bool) {
Expand Down
36 changes: 31 additions & 5 deletions BitwardenShared/UI/Billing/PremiumPlan/PremiumPlanStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ public enum PremiumPlanStatus: Equatable, Hashable {
/// The plan has an unknown status not yet supported by the app.
case unknown

/// The subscription is unpaid after repeated payment failures; premium access has lapsed.
case unpaid

/// The plan requires a payment method update.
case updatePayment

Expand All @@ -36,7 +39,8 @@ public enum PremiumPlanStatus: Equatable, Hashable {
case .active:
.success
case .canceled,
.expired:
.expired,
.unpaid:
.danger
case .pastDue,
.pendingCancellation,
Expand All @@ -46,16 +50,35 @@ public enum PremiumPlanStatus: Equatable, Hashable {
}
}

/// Whether the status represents a subscription in a troubled state (e.g. canceled, past due).
/// Whether the status represents a subscription in a troubled state that affects billing UI β€”
/// canceled, expired, past due, pending cancellation, unpaid, or update payment.
///
/// This is broader than `isPaymentProblemState`: it covers every non-normal state, including
/// those caused by user action (cancellation) or plan expiry, not only payment failures.
var isTroubleState: Bool {
switch self {
case .canceled, .expired, .pastDue, .pendingCancellation, .updatePayment:
case .canceled, .expired, .pastDue, .pendingCancellation, .unpaid, .updatePayment:
true
case .active, .unknown:
false
}
}

/// Whether the status represents a payment failure β€” past due, unpaid, or update payment.
///
/// This is the narrower subset of `isTroubleState` that drives the "subscription needs
/// attention" action card. It covers all three payment-problem states regardless of whether
/// premium access is currently active: past due and update payment retain premium during
/// the payment retry grace period, while unpaid has already lapsed.
var isPaymentProblemState: Bool {
switch self {
case .pastDue, .unpaid, .updatePayment:
true
case .active, .canceled, .expired, .pendingCancellation, .unknown:
false
}
}

/// The localized label for this status.
var label: String {
switch self {
Expand All @@ -71,6 +94,8 @@ public enum PremiumPlanStatus: Equatable, Hashable {
Localizations.pendingCancellation
case .unknown:
Localizations.unknownStatus
case .unpaid:
Localizations.unpaid
case .updatePayment:
Localizations.updatePayment
}
Expand All @@ -94,13 +119,14 @@ public enum PremiumPlanStatus: Equatable, Hashable {
self = .canceled
case .incompleteExpired:
self = .expired
case .incomplete,
.unpaid:
case .incomplete:
self = .updatePayment
case .pastDue:
self = .pastDue
case .unknown:
self = .unknown
case .unpaid:
self = .unpaid
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ struct PremiumPlanStatusTests {
(.pastDue, .warning),
(.pendingCancellation, .warning),
(.unknown, .warning),
(.unpaid, .danger),
(.updatePayment, .warning),
] as [(PremiumPlanStatus, PillBadgeStyle)])
func badgeStyle(_ status: PremiumPlanStatus, expected: PillBadgeStyle) {
Expand All @@ -31,7 +32,7 @@ struct PremiumPlanStatusTests {
(.incompleteExpired, .expired),
(.pastDue, .pastDue),
(.unknown, .unknown),
(.unpaid, .updatePayment),
(.unpaid, .unpaid),
] as [(SubscriptionStatus, PremiumPlanStatus)])
func init_mapsSubscriptionStatus(_ status: SubscriptionStatus, expected: PremiumPlanStatus) {
#expect(PremiumPlanStatus(subscriptionStatus: status) == expected)
Expand All @@ -52,12 +53,29 @@ struct PremiumPlanStatusTests {
(.pastDue, true),
(.pendingCancellation, true),
(.unknown, false),
(.unpaid, true),
(.updatePayment, true),
] as [(PremiumPlanStatus, Bool)])
func isTroubleState(_ status: PremiumPlanStatus, expected: Bool) {
#expect(status.isTroubleState == expected)
}

// MARK: Tests - isPaymentProblemState

@Test(arguments: [
(PremiumPlanStatus.active, false),
(.canceled, false),
(.expired, false),
(.pastDue, true),
(.pendingCancellation, false),
(.unknown, false),
(.unpaid, true),
(.updatePayment, true),
] as [(PremiumPlanStatus, Bool)])
func isPaymentProblemState(_ status: PremiumPlanStatus, expected: Bool) {
#expect(status.isPaymentProblemState == expected)
}

// MARK: Tests - label

@Test(arguments: [
Expand All @@ -67,6 +85,7 @@ struct PremiumPlanStatusTests {
(.pastDue, Localizations.pastDue),
(.pendingCancellation, Localizations.pendingCancellation),
(.unknown, Localizations.unknownStatus),
(.unpaid, Localizations.unpaid),
(.updatePayment, Localizations.updatePayment),
] as [(PremiumPlanStatus, String)])
func label(_ status: PremiumPlanStatus, expected: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ class PremiumPlanViewSnapshotTests: BitwardenTestCase {
assertSnapshots(of: subject, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
}

/// Check the snapshot for the unpaid state.
@MainActor
func disabletest_snapshot_unpaid() {
processor.state.planStatus = .unpaid
processor.state.loadingState = .data(.fixture(
discount: 2.10,
estimatedTax: 3.85,
status: .unpaid,
suspension: Date(timeIntervalSince1970: 1_748_822_400),
))
assertSnapshots(of: subject, as: [.defaultPortrait, .defaultPortraitDark, .defaultPortraitAX5])
}

/// Check the snapshot for the update payment state.
@MainActor
func disabletest_snapshot_updatePayment() {
Expand Down
Loading
Loading