Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
33 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
aacf169
[PM-33849] Add security comment explaining HTTPS check in BillingService
andrebispo5 Apr 10, 2026
79e1d97
[PM-33849] Convert BillingServiceTests to Swift Testing framework
andrebispo5 Apr 10, 2026
53b8bb4
[PM-33849] Fix alphabetization in ServiceContainer
andrebispo5 Apr 10, 2026
051deb2
[PM-33849] Group upgradeNowTapped tests together in PremiumUpgradePro…
andrebispo5 Apr 10, 2026
7700c60
[PM-33849] Add scroll view and use ContentBlock in PremiumUpgradeView
andrebispo5 Apr 10, 2026
ceb731e
[PM-33849] Fix alphabetization in VaultListAction and VaultRoute
andrebispo5 Apr 10, 2026
843be90
[PM-33849] Fix conflicts
andrebispo5 Apr 10, 2026
1655d9d
Merge branch 'main' into pm-33849/premium-upgrade-view
andrebispo5 Apr 10, 2026
e77d233
[PM-33849] Fix localization after merge
andrebispo5 Apr 10, 2026
f56b88d
[PM-34897] Fix BillingServiceTests build errors
andrebispo5 Apr 10, 2026
3fb2d32
[PM-33849] Fix margin bot in main content block
andrebispo5 Apr 10, 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";
"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
}
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

@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

/// 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