Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -1350,3 +1350,11 @@
"TurnOnNow" = "Turn on now";
"AcceptTransfer" = "Accept transfer";
"YourPasswordAndHintCannotBeTheSamePleaseChooseADifferentHint" = "Your password and hint cannot be the same. Please choose a different hint.";
"PerMonth" = "/ month";
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🎨 Would it be better to localize the full string with a placeholder if the order needs to change on translation?

Suggested change
"PerMonth" = "/ month";
"XPerMonth" = "%1$@ / 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";
"YoullGoToStripeSecureCheckoutToCompleteYourPurchase" = "YouΒ΄ll go to StripeΒ΄s secure checkout to complete your purchase.";
22 changes: 22 additions & 0 deletions BitwardenShared/Core/Billing/Models/Enum/BillingError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

// MARK: - BillingError

/// Errors that can occur during billing operations.
///
enum BillingError: LocalizedError {
/// The checkout URL is invalid (e.g., not HTTPS).
case invalidCheckoutUrl

/// Unable to open the checkout URL in the browser.
case unableToOpenCheckout

var errorDescription: String? {
switch self {
case .invalidCheckoutUrl,
.unableToOpenCheckout:
// TODO: PM-33856 Handle payment errors
nil
}
}
}
46 changes: 46 additions & 0 deletions BitwardenShared/Core/Billing/Services/BillingService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import Foundation

// MARK: - BillingService

/// A protocol for a service used to manage billing operations.
///
protocol BillingService: AnyObject { // sourcery: AutoMockable
/// Creates a checkout session for premium upgrade and returns the checkout URL.
///
/// - Returns: A validated HTTPS URL for the checkout session.
/// - Throws: `BillingError.invalidCheckoutUrl` if the URL is invalid or not HTTPS.
///
func createCheckoutSession() async throws -> URL
}

// MARK: - DefaultBillingService

/// The default implementation of `BillingService`.
///
class DefaultBillingService: BillingService {
// MARK: Properties

/// The API service used for billing requests.
private let billingAPIService: BillingAPIService

// MARK: Initialization

/// Creates a new `DefaultBillingService`.
///
/// - Parameter billingAPIService: The API service used for billing requests.
///
init(billingAPIService: BillingAPIService) {
self.billingAPIService = billingAPIService
}

// MARK: Methods

func createCheckoutSession() async throws -> URL {
let response = try await billingAPIService.createCheckoutSession()
let url = response.checkoutSessionUrl
guard url.scheme == "https" else {
throw BillingError.invalidCheckoutUrl
Comment on lines +40 to +42
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

❓ Just curious why this check is needed? Is this more of a safeguard or is there a meaningful reason behind this? Maybe worth a comment for future context.

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.

Just a safeguard since we are opening a url coming from a service call.

}
return url
}
}
82 changes: 82 additions & 0 deletions BitwardenShared/Core/Billing/Services/BillingServiceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import TestHelpers
import XCTest
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🎨 We've been trying to use Swift Testing for new tests because they are significantly faster. Do you want to try and convert the tests in this PR? I'm not sure about the view tests, but the service, processor and coordinators should be easy to convert.


@testable import BitwardenShared
@testable import BitwardenSharedMocks

class BillingServiceTests: BitwardenTestCase {
// MARK: Properties

var billingAPIService: MockBillingAPIService!
var subject: DefaultBillingService!

// MARK: Setup and Teardown

override func setUp() {
super.setUp()

billingAPIService = MockBillingAPIService()
subject = DefaultBillingService(billingAPIService: billingAPIService)
}

override func tearDown() {
super.tearDown()

billingAPIService = nil
subject = nil
}

// MARK: Tests

/// `createCheckoutSession()` returns the URL when it uses HTTPS scheme.
func test_createCheckoutSession_success() async throws {
let expectedURL = URL(string: "https://checkout.stripe.com/session")!
billingAPIService.createCheckoutSessionReturnValue = CheckoutSessionResponseModel(
checkoutSessionUrl: expectedURL,
)

let result = try await subject.createCheckoutSession()

XCTAssertEqual(billingAPIService.createCheckoutSessionCallsCount, 1)
XCTAssertEqual(result, expectedURL)
}

/// `createCheckoutSession()` throws `invalidCheckoutUrl` when the URL uses HTTP scheme.
func test_createCheckoutSession_invalidUrl_http() async throws {
let httpURL = URL(string: "http://checkout.stripe.com/session")!
billingAPIService.createCheckoutSessionReturnValue = CheckoutSessionResponseModel(
checkoutSessionUrl: httpURL,
)

await assertAsyncThrows(error: BillingError.invalidCheckoutUrl) {
_ = try await subject.createCheckoutSession()
}

XCTAssertEqual(billingAPIService.createCheckoutSessionCallsCount, 1)
}

/// `createCheckoutSession()` throws `invalidCheckoutUrl` when the URL has no scheme.
func test_createCheckoutSession_invalidUrl_noScheme() async throws {
let noSchemeURL = URL(string: "checkout.stripe.com/session")!
billingAPIService.createCheckoutSessionReturnValue = CheckoutSessionResponseModel(
checkoutSessionUrl: noSchemeURL,
)

await assertAsyncThrows(error: BillingError.invalidCheckoutUrl) {
_ = try await subject.createCheckoutSession()
}

XCTAssertEqual(billingAPIService.createCheckoutSessionCallsCount, 1)
}

/// `createCheckoutSession()` propagates errors from the API service.
func test_createCheckoutSession_apiError() async throws {
billingAPIService.createCheckoutSessionThrowableError = URLError(.notConnectedToInternet)

await assertAsyncThrows {
_ = try await subject.createCheckoutSession()
}

XCTAssertEqual(billingAPIService.createCheckoutSessionCallsCount, 1)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// The repository to manage biometric unlock policies and access controls the user.
let biometricsRepository: BiometricsRepository

/// The service used by the application to manage billing operations.
let billingService: BillingService
Comment on lines +63 to +64
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

⛏️ The ordering of this should be before biometricsRepository.


/// The service used to obtain device biometrics status & data.
let biometricsService: BiometricsService

Expand Down Expand Up @@ -312,6 +315,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
authService: AuthService,
authenticatorSyncService: AuthenticatorSyncService,
autofillCredentialService: AutofillCredentialService,
billingService: BillingService,
biometricsRepository: BiometricsRepository,
biometricsService: BiometricsService,
cameraService: CameraService,
Expand Down Expand Up @@ -378,6 +382,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
self.authService = authService
self.authenticatorSyncService = authenticatorSyncService
self.autofillCredentialService = autofillCredentialService
self.billingService = billingService
self.biometricsRepository = biometricsRepository
self.biometricsService = biometricsService
self.cameraService = cameraService
Expand Down Expand Up @@ -1089,6 +1094,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
authService: authService,
authenticatorSyncService: authenticatorSyncService,
autofillCredentialService: autofillCredentialService,
billingService: DefaultBillingService(billingAPIService: apiService),
biometricsRepository: biometricsRepository,
biometricsService: biometricsService,
cameraService: DefaultCameraService(),
Expand Down
8 changes: 8 additions & 0 deletions BitwardenShared/Core/Platform/Services/Services.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ typealias Services = HasAPIService
& HasAuthService
& HasAutofillCredentialService
& HasBillingAPIService
& HasBillingService
& HasBiometricsRepository
& HasCameraService
& HasChangeKdfService
Expand Down Expand Up @@ -153,6 +154,13 @@ protocol HasBillingAPIService {
var billingAPIService: BillingAPIService { get }
}

/// Protocol for an object that provides a `BillingService`.
///
protocol HasBillingService {
/// The service used by the application to manage billing operations.
var billingService: BillingService { get }
}

/// Protocol for obtaining the device's biometric authentication type.
///
protocol HasBiometricsRepository {
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(),
billingService: BillingService = MockBillingService(),
biometricsRepository: BiometricsRepository = MockBiometricsRepository(),
biometricsService: BiometricsService = MockBiometricsService(),
cameraService: CameraService = MockCameraService(),
Expand Down Expand Up @@ -106,6 +107,7 @@ extension ServiceContainer {
authService: authService,
authenticatorSyncService: authenticatorSyncService,
autofillCredentialService: autofillCredentialService,
billingService: billingService,
biometricsRepository: biometricsRepository,
biometricsService: biometricsService,
cameraService: cameraService,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class VaultListPreparedDataBuilderAddItemTests: BitwardenTestCase {
var subject: DefaultVaultListPreparedDataBuilder!
var timeProvider: MockTimeProvider!
var totpService: MockTOTPService!

// MARK: Setup & Teardown

override func setUp() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ class DefaultVaultRepository {

/// The service used to get the present time.
private let timeProvider: TimeProvider

/// The service used to retrieve TOTPs.
private let totpService: TOTPService

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// 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

/// The checkout URL failed to open in the browser.
case urlOpenFailed
}
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 = HasBillingService
& 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,54 @@
import BitwardenKitMocks
import SwiftUI
import XCTest

@testable import BitwardenShared

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