-
Notifications
You must be signed in to change notification settings - Fork 99
[PM-33849] feat: Add Premium Upgrade view #2523
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c1b5805
89ba31a
6ad2e9c
a124c37
f323ebf
8ce065b
447f1bd
e7252dc
04a0092
278ba64
9fc9aee
fb89588
688eaac
15a12c4
7fa8ff8
e100ee0
f5f0a5b
e52e970
d913840
7f43801
a0fcfba
408ecd6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } | ||
| } | ||
| } |
| 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,82 @@ | ||
| import TestHelpers | ||
| import XCTest | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
|---|---|---|
|
|
@@ -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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. βοΈ The ordering of this should be before |
||
|
|
||
| /// The service used to obtain device biometrics status & data. | ||
| let biometricsService: BiometricsService | ||
|
|
||
|
|
@@ -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, | ||
|
|
@@ -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 | ||
|
|
@@ -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(), | ||
|
|
||
| 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) | ||
| } | ||
| } |
There was a problem hiding this comment.
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?