Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
c1b5805
[PM-33849] feat: Add PremiumUpgrade localization strings
andrebispo5 Apr 6, 2026
89ba31a
[PM-33849] chore: Add BillingAPIService mock support
andrebispo5 Apr 6, 2026
6ad2e9c
[PM-33849] feat: Add PremiumUpgrade view
andrebispo5 Apr 6, 2026
a124c37
[PM-33849] test: Add PremiumUpgrade tests
andrebispo5 Apr 6, 2026
f323ebf
[PM-33849] feat: Integrate PremiumUpgrade into Vault
andrebispo5 Apr 6, 2026
8ce065b
[PM-33849] feat: Add BillingService with checkout session validation
andrebispo5 Apr 6, 2026
447f1bd
[PM-33849] feat: Integrate BillingService into ServiceContainer
andrebispo5 Apr 6, 2026
e7252dc
[PM-33849] feat: Update PremiumUpgrade to use BillingService
andrebispo5 Apr 6, 2026
04a0092
Merge branch 'main' into pm-33849/premium-upgrade-view
andrebispo5 Apr 6, 2026
278ba64
Merge branch 'main' into pm-33849/premium-upgrade-view
andrebispo5 Apr 8, 2026
9fc9aee
Merge branch 'main' into pm-33849/premium-upgrade-view
andrebispo5 Apr 8, 2026
fb89588
[PM-33849] fix: Remove trailing whitespace
andrebispo5 Apr 9, 2026
688eaac
[PM-33849] fix: Correct TODO comment format in BillingError
andrebispo5 Apr 9, 2026
15a12c4
[PM-33849] refactor: Inline return in ServiceContainer+Mocks
andrebispo5 Apr 9, 2026
7fa8ff8
[PM-33849] fix: Remove unused import in PremiumUpgradeCoordinatorTests
andrebispo5 Apr 9, 2026
e100ee0
[PM-33849] fix: Move featureRow to Private Methods section in Premium…
andrebispo5 Apr 9, 2026
f5f0a5b
[PM-33849] refactor: Extract handleAppReviewPromptShown to fix functi…
andrebispo5 Apr 9, 2026
e52e970
[PM-33849] fix: Update localization strings for premium upgrade
andrebispo5 Apr 9, 2026
d913840
Merge branch 'main' into pm-33849/premium-upgrade-view
andrebispo5 Apr 9, 2026
7f43801
Merge branch 'pm-33849/premium-upgrade-view' of https://github.com/bi…
andrebispo5 Apr 9, 2026
a0fcfba
[PM-33849] test: Add ViewInspector tests for PremiumUpgradeView
andrebispo5 Apr 9, 2026
408ecd6
[PM-33849] test: Add snapshot tests for PremiumUpgradeView
andrebispo5 Apr 9, 2026
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 @@ -9,8 +9,8 @@ typealias Services = HasConfigService
& HasErrorReporter
& HasFlightRecorder
& HasLanguageStateService
& HasTimeProvider
& HasServerCommunicationConfigClientSingleton
& HasTimeProvider

/// A service container used for testing processors within `BitwardenKitTests`.
///
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1331,3 +1331,11 @@
"TurnOnNow" = "Turn on now";
"AcceptTransfer" = "Accept transfer";
"YourPasswordAndHintCannotBeTheSamePleaseChooseADifferentHint" = "Your password and hint cannot be the same. Please choose a different hint.";
"PerMonth" = " / month";
"UnlockMoreAdvancedFeaturesWithPremiumPlan" = "Unlock more advanced features with a Premium plan.";
"BuiltInAuthenticator" = "Built-in authenticator";
"EmergencyAccess" = "Emergency access";
"SecureFileStorage" = "Secure file storage";
"BreachMonitoring" = "Breach monitoring";
"UpgradeNow" = "Upgrade now";
"YouWillGoToStripeSecureCheckoutToCompleteYourPurchase" = "You'll go to Stripe's secure checkout to complete your purchase.";
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// The service used by the application to make API requests.
let apiService: APIService

/// An optional override for the billing API service, used for testing.
var billingAPIServiceOverride: BillingAPIService?

Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

billingAPIServiceOverride introduces a mutable test-only escape hatch on the production ServiceContainer. To keep the override from being modified outside test helpers, consider tightening access (e.g., private(set) / private) and injecting the override via an initializer or a dedicated test-only API (possibly behind #if DEBUG).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks forgot to delete this.

/// Helper used to know app context.
let appContextHelper: AppContextHelper

Expand Down Expand Up @@ -1172,7 +1175,7 @@ extension ServiceContainer {
}

var billingAPIService: BillingAPIService {
apiService
billingAPIServiceOverride ?? apiService
}

var configAPIService: ConfigAPIService {
Expand Down
2 changes: 1 addition & 1 deletion BitwardenShared/Core/Platform/Services/Services.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ typealias Services = HasAPIService
& HasApplication
& HasAuthAPIService
& HasAuthRepository
& HasBillingAPIService
& HasAuthService
& HasAutofillCredentialService
& HasBillingAPIService
& HasBiometricsRepository
& HasCameraService
& HasChangeKdfService
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ extension ServiceContainer {
authService: AuthService = MockAuthService(),
authenticatorSyncService: AuthenticatorSyncService = MockAuthenticatorSyncService(),
autofillCredentialService: AutofillCredentialService = MockAutofillCredentialService(),
billingAPIService: BillingAPIService? = nil,
biometricsRepository: BiometricsRepository = MockBiometricsRepository(),
biometricsService: BiometricsService = MockBiometricsService(),
cameraService: CameraService = MockCameraService(),
Expand Down Expand Up @@ -90,7 +91,7 @@ extension ServiceContainer {
actualSearchProcessorMediatorFactory = factoryMock
}

return ServiceContainer(
let container = ServiceContainer(
apiService: APIService(
client: httpClient,
environmentService: environmentService,
Expand Down Expand Up @@ -159,5 +160,7 @@ extension ServiceContainer {
vaultTimeoutService: vaultTimeoutService,
watchService: watchService,
)
container.billingAPIServiceOverride = billingAPIService
return container
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// MARK: - PremiumUpgradeAction

/// Actions handled by the `PremiumUpgradeProcessor`.
///
enum PremiumUpgradeAction: Equatable {
/// The cancel button was tapped.
case cancelTapped

/// Clear the checkout URL after it has been opened.
case clearURL
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import BitwardenKit
import SwiftUI

// MARK: - PremiumUpgradeCoordinator

/// A coordinator that manages navigation for the premium upgrade view.
///
class PremiumUpgradeCoordinator: Coordinator, HasStackNavigator {
// MARK: Types

typealias Services = HasBillingAPIService
& HasErrorAlertServices.ErrorAlertServices
& HasErrorReporter

// MARK: Properties

/// The services used by this coordinator.
let services: Services

/// The stack navigator that is managed by this coordinator.
private(set) weak var stackNavigator: StackNavigator?

// MARK: Initialization

/// Creates a new `PremiumUpgradeCoordinator`.
///
/// - Parameters:
/// - services: The services used by this coordinator.
/// - stackNavigator: The stack navigator that is managed by this coordinator.
///
init(
services: Services,
stackNavigator: StackNavigator,
) {
self.services = services
self.stackNavigator = stackNavigator
}

// MARK: Methods

func navigate(to route: PremiumUpgradeRoute, context: AnyObject?) {
switch route {
case .dismiss:
stackNavigator?.dismiss()
}
}

func start() {
showPremiumUpgrade()
}

// MARK: Private Methods

/// Shows the premium upgrade screen.
///
private func showPremiumUpgrade() {
let processor = PremiumUpgradeProcessor(
coordinator: asAnyCoordinator(),
services: services,
state: PremiumUpgradeState(),
)
let view = PremiumUpgradeView(store: Store(processor: processor))
stackNavigator?.replace(view)
}
}

// MARK: - HasErrorAlertServices

extension PremiumUpgradeCoordinator: HasErrorAlertServices {
var errorAlertServices: ErrorAlertServices { services }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import BitwardenKitMocks
import SwiftUI
import XCTest

@testable import BitwardenShared
@testable import BitwardenSharedMocks

class PremiumUpgradeCoordinatorTests: BitwardenTestCase {
// MARK: Properties

var stackNavigator: MockStackNavigator!
var subject: PremiumUpgradeCoordinator!

// MARK: Setup & Teardown

override func setUp() {
super.setUp()

stackNavigator = MockStackNavigator()

subject = PremiumUpgradeCoordinator(
services: ServiceContainer.withMocks(),
stackNavigator: stackNavigator,
)
}

override func tearDown() {
super.tearDown()

stackNavigator = nil
subject = nil
}

// MARK: Tests

/// `start()` replaces the stack navigator's stack with the premium upgrade view.
@MainActor
func test_start() throws {
subject.start()

XCTAssertEqual(stackNavigator.actions.count, 1)
let action = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(action.type, .replaced)
XCTAssertTrue(action.view is PremiumUpgradeView)
}

/// `navigate(to:)` with `.dismiss` dismisses the view.
@MainActor
func test_navigate_dismiss() throws {
subject.navigate(to: .dismiss)

let action = try XCTUnwrap(stackNavigator.actions.last)
XCTAssertEqual(action.type, .dismissed)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// MARK: - PremiumUpgradeEffect

/// Effects that can be processed by a `PremiumUpgradeProcessor`.
///
enum PremiumUpgradeEffect {
/// The upgrade now button was tapped.
case upgradeNowTapped
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import BitwardenKit

// MARK: - PremiumUpgradeModule

/// An object that builds coordinators for the premium upgrade view.
///
@MainActor
protocol PremiumUpgradeModule {
/// Initializes a coordinator for navigating between `PremiumUpgradeRoute`s.
///
/// - Parameter stackNavigator: The stack navigator that will be used to navigate between routes.
/// - Returns: A coordinator that can navigate to `PremiumUpgradeRoute`s.
///
func makePremiumUpgradeCoordinator(
stackNavigator: StackNavigator,
) -> AnyCoordinator<PremiumUpgradeRoute, Void>
}

extension DefaultAppModule: PremiumUpgradeModule {
func makePremiumUpgradeCoordinator(
stackNavigator: StackNavigator,
) -> AnyCoordinator<PremiumUpgradeRoute, Void> {
PremiumUpgradeCoordinator(
services: services,
stackNavigator: stackNavigator,
).asAnyCoordinator()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import BitwardenKit

// MARK: - PremiumUpgradeProcessor

/// The processor used to manage state and handle actions for the `PremiumUpgradeView`.
///
final class PremiumUpgradeProcessor: StateProcessor<
PremiumUpgradeState,
PremiumUpgradeAction,
PremiumUpgradeEffect,
> {
// MARK: Types

typealias Services = HasBillingAPIService
& HasErrorReporter

// MARK: Properties

/// The coordinator used to manage navigation.
private let coordinator: AnyCoordinator<PremiumUpgradeRoute, Void>

/// The services used by this processor.
private let services: Services

// MARK: Initialization

/// Initializes a `PremiumUpgradeProcessor`.
///
/// - Parameters:
/// - coordinator: The coordinator used for navigation.
/// - services: The services used by this processor.
/// - state: The initial state of the processor.
///
init(
coordinator: AnyCoordinator<PremiumUpgradeRoute, Void>,
services: Services,
state: PremiumUpgradeState,
) {
self.coordinator = coordinator
self.services = services
super.init(state: state)
}

// MARK: Methods

override func perform(_ effect: PremiumUpgradeEffect) async {
switch effect {
case .upgradeNowTapped:
await createCheckoutSession()
}
}

override func receive(_ action: PremiumUpgradeAction) {
switch action {
case .cancelTapped:
coordinator.navigate(to: .dismiss)
case .clearURL:
state.checkoutURL = nil
}
}

// MARK: Private Methods

/// Creates a checkout session by calling the billing API.
///
private func createCheckoutSession() async {
defer { state.isLoading = false }
do {
state.isLoading = true
let response = try await services.billingAPIService.createCheckoutSession()
state.checkoutURL = response.checkoutSessionUrl
} catch {
services.errorReporter.log(error: error)
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

state.isLoading is cleared via defer, but because the catch path awaits coordinator.showErrorAlert, the loading state will remain true until after the alert flow completes. This can leave the UI disabled/spinning while the error alert is onscreen. Consider setting isLoading = false before awaiting the error alert (or avoid defer and explicitly reset loading in both success and failure paths).

Suggested change
defer { state.isLoading = false }
do {
state.isLoading = true
let response = try await services.billingAPIService.createCheckoutSession()
state.checkoutURL = response.checkoutSessionUrl
} catch {
services.errorReporter.log(error: error)
do {
state.isLoading = true
let response = try await services.billingAPIService.createCheckoutSession()
state.isLoading = false
state.checkoutURL = response.checkoutSessionUrl
} catch {
services.errorReporter.log(error: error)
state.isLoading = false

Copilot uses AI. Check for mistakes.
await coordinator.showErrorAlert(error: error)
}
}
}
Loading
Loading