[PM-35101] feat: Complete Stripe premium checkout flow#2574
[PM-35101] feat: Complete Stripe premium checkout flow#2574andrebispo5 merged 28 commits intomainfrom
Conversation
…heckout streaming
…bounce and early-exit guard
…Combine subscription
…sor to VaultListProcessor
# Conflicts: # BitwardenShared/Core/Platform/Services/NotificationServiceTests.swift # BitwardenShared/UI/Billing/PremiumUpgrade/PremiumUpgradeProcessorTests.swift
|
Great job! No new security vulnerabilities introduced in this pull request |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #2574 +/- ##
==========================================
- Coverage 87.20% 86.15% -1.05%
==========================================
Files 1892 2126 +234
Lines 167474 183009 +15535
==========================================
+ Hits 146045 157675 +11630
- Misses 21429 25334 +3905 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Implements the end-to-end Stripe Premium checkout experience in the iOS app, wiring together deep-link/push triggers, billing-state publishing, UI reactions, and post-upgrade confirmation UI.
Changes:
- Added
PremiumCheckoutStatus+SubscriptionStatusenums and extendedBillingServiceto publish checkout status and react to deep link/push signals. - Routed premium upgrade results through
AppProcessordeep-link handling andNotificationService, with new billing-specific alerts and UI behaviors. - Updated Vault list and Premium upgrade flows to subscribe to checkout status, dismiss/refresh appropriately, and show an “Upgraded to Premium” action card with a route into plan details.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| BitwardenShared/UI/Vault/Vault/VaultRoute.swift | Adds viewPlanDetails navigation route. |
| BitwardenShared/UI/Vault/Vault/VaultList/VaultListView.swift | Adds “Upgraded to Premium” action card UI. |
| BitwardenShared/UI/Vault/Vault/VaultList/VaultListState.swift | Adds state flag for upgraded action card visibility. |
| BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift | Adds tests for checkout-status subscription behavior + new route/action. |
| BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift | Subscribes to billing checkout status; refreshes vault/updates premium UI state; adds plan details routing. |
| BitwardenShared/UI/Vault/Vault/VaultList/VaultListEffect.swift | Adds effect for dismissing the upgraded action card. |
| BitwardenShared/UI/Vault/Vault/VaultList/VaultListAction.swift | Adds action for “View plan details”. |
| BitwardenShared/UI/Vault/Vault/VaultCoordinatorTests.swift | Updates dismiss semantics and adds route test for plan details. |
| BitwardenShared/UI/Vault/Vault/VaultCoordinator.swift | Adds DismissCompletionContext; supports dismiss completion; routes plan details to Settings premium plan. |
| BitwardenShared/UI/Platform/Application/AppProcessorTests.swift | Adds deep-link tests for premium checkout result handling. |
| BitwardenShared/UI/Platform/Application/AppProcessor.swift | Handles bitwarden://premium-checkout-result deep link and delegates to BillingService. |
| BitwardenShared/UI/Billing/PremiumUpgrade/PremiumUpgradeProcessor.swift | Creates checkout session, shows overlays/alerts, subscribes to checkout status. |
| BitwardenShared/UI/Billing/PremiumPlan/PremiumPlanStatus.swift | Maps raw subscription status to UI plan status. |
| BitwardenShared/UI/Billing/Extensions/Alert+BillingTests.swift | Adds unit tests for new billing alerts. |
| BitwardenShared/UI/Billing/Extensions/Alert+Billing.swift | Adds billing-specific alert builders. |
| BitwardenShared/Core/Platform/Utilities/BitwardenDeepLinkConstants.swift | Adds constants for premium checkout result deep link + query param. |
| BitwardenShared/Core/Platform/Services/ServiceContainer.swift | Wires BillingService into container and into NotificationService. |
| BitwardenShared/Core/Platform/Services/NotificationServiceTests.swift | Updates expectations to route premium status change via billing service. |
| BitwardenShared/Core/Platform/Services/NotificationService.swift | Handles premiumStatusChanged push by calling billingService.premiumStatusChanged(). |
| BitwardenShared/Core/Billing/Services/BillingServiceTests.swift | Adds tests for status publishing/reset and sync behavior. |
| BitwardenShared/Core/Billing/Services/BillingService.swift | Extends BillingService API; implements status publisher + sync-triggered state publishing. |
| BitwardenShared/Core/Billing/Models/Response/BitwardenSubscriptionResponseModel.swift | Changes subscription status from String to SubscriptionStatus. |
| BitwardenShared/Core/Billing/Models/Enum/SubscriptionStatus.swift | Adds raw Stripe subscription status enum. |
| BitwardenShared/Core/Billing/Models/Enum/PremiumCheckoutStatus.swift | Adds premium checkout sync status enum. |
| BitwardenResources/Localizations/en.lproj/Localizable.strings | Adds new strings for checkout alerts, overlays, and confirmation card. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if result == BitwardenDeepLinkConstants.PremiumCheckoutResultQuery.successValue { | ||
| await services.billingService.premiumStatusChanged() | ||
| } else { | ||
| services.billingService.premiumCheckoutCanceled() | ||
| } | ||
| return true |
There was a problem hiding this comment.
handlePremiumCheckoutResult treats any premium-checkout-result URL whose result query item is missing or not equal to success as a cancellation. That means malformed/unknown values (e.g. missing result, typos, future values) will incorrectly trigger the “payment not received” flow. Consider explicitly handling only the known values (e.g. success and canceled) and returning false (or logging + returning false) for anything else so unrelated/invalid deep links don’t affect billing state.
| if result == BitwardenDeepLinkConstants.PremiumCheckoutResultQuery.successValue { | |
| await services.billingService.premiumStatusChanged() | |
| } else { | |
| services.billingService.premiumCheckoutCanceled() | |
| } | |
| return true | |
| if result == BitwardenDeepLinkConstants.PremiumCheckoutResultQuery.successValue { | |
| await services.billingService.premiumStatusChanged() | |
| return true | |
| } | |
| if result == BitwardenDeepLinkConstants.PremiumCheckoutResultQuery.canceledValue { | |
| services.billingService.premiumCheckoutCanceled() | |
| return true | |
| } | |
| return false |
| private func subscribeToPremiumCheckoutStatus() { | ||
| premiumStatusChangedCancellable = services.billingService | ||
| .premiumCheckoutStatusPublisher() | ||
| .receive(on: DispatchQueue.main) | ||
| .sink { [weak self] status in | ||
| guard let self else { return } | ||
| switch status { | ||
| case .canceled: | ||
| premiumStatusChangedCancellable = nil | ||
| coordinator.showAlert(.paymentNotReceivedYet { | ||
| self.state.checkoutURL = self.lastCheckoutURL | ||
| }) | ||
| case .confirmed, |
There was a problem hiding this comment.
In subscribeToPremiumCheckoutStatus, the .canceled branch sets premiumStatusChangedCancellable = nil and then shows the “Go back” alert which reopens lastCheckoutURL. If the user taps “Go back” and later completes checkout, there will no longer be an active subscription to receive .syncing/.confirmed/.pending, so the rest of the flow (dismissal/overlay/refresh) won’t run. Keep the subscription alive across cancel-and-retry (or re-subscribe in the “Go back” handler before reopening Stripe).
| guard let self else { return } | ||
| switch status { | ||
| case .canceled: | ||
| premiumStatusChangedCancellable = nil |
There was a problem hiding this comment.
subscribeToPremiumCheckoutStatus cancels the subscription on .canceled. If the user reopens Stripe from the “Go back” action (without re-entering the upgrade flow), subsequent .syncing/.confirmed/.pending events from a successful retry won’t be observed here—so the vault list won’t run the dismiss/refresh logic or show the “Upgraded to Premium” action card. Consider keeping the subscription alive across a cancel-and-retry, or resubscribing when the user chooses to retry checkout.
| premiumStatusChangedCancellable = nil | |
| break |
| case paused | ||
| case pastDue = "past_due" | ||
| case trialing | ||
| case unpaid |
There was a problem hiding this comment.
SubscriptionStatus is a closed String-backed Codable enum. If the API ever returns a new/unknown Stripe status value, decoding BitwardenSubscriptionResponseModel will fail and subscription fetching will break. Consider adding an unknown case and a custom init(from:) that defaults to .unknown (optionally logging the raw value) so the client remains forward-compatible.
| case unpaid | |
| case unpaid | |
| case unknown | |
| init(from decoder: Decoder) throws { | |
| let container = try decoder.singleValueContainer() | |
| let rawValue = try container.decode(String.self) | |
| self = Self(rawValue: rawValue) ?? .unknown | |
| } |
…ipe-payment # Conflicts: # BitwardenShared/Core/Billing/Models/Enum/SubscriptionStatus.swift # BitwardenShared/Core/Billing/Services/BillingService.swift # BitwardenShared/Core/Billing/Services/BillingServiceTests.swift # BitwardenShared/UI/Billing/PremiumPlan/PremiumPlanStatus.swift
| func premiumCheckoutCanceled() { | ||
| premiumCheckoutStatusSubject.send(.canceled) | ||
| premiumCheckoutStatusSubject.send(nil) | ||
| } |
There was a problem hiding this comment.
🤔 Why do you need to send nil after canceled?
There was a problem hiding this comment.
private let premiumCheckoutStatusSubject = CurrentValueSubject<PremiumCheckoutStatus?, Never>(nil) saves the last value passed for new subscribers. when we get to the .canceled state we want to remove it from being propagated again so we set it at nil. We can also send them in sequence because the .compactMap(\.self) will clear the nil before the debounce.
There was a problem hiding this comment.
And can't you use PassthroughSubject instead? which doesn't hold the last value and just propagates only to already subscribed subscribers.
| func premiumCheckoutStatusPublisher() -> AnyPublisher<PremiumCheckoutStatus, Never> { | ||
| premiumCheckoutStatusSubject | ||
| .compactMap(\.self) | ||
| .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) |
There was a problem hiding this comment.
🤔 Are you adding the debounce in case several status are sent in a row and you only want to publish the last one?
| premiumCheckoutStatusSubject.send(hasPremium ? .confirmed : .pending) | ||
| if hasPremium { | ||
| premiumCheckoutStatusSubject.send(nil) | ||
| } |
There was a problem hiding this comment.
🤔 Why is it needed to send nil if you have already sent confirmed?
There was a problem hiding this comment.
same explanation has above in this case .confirmed is an end state.
| premiumStatusChangedCancellable = services.billingService | ||
| .premiumCheckoutStatusPublisher() | ||
| .receive(on: DispatchQueue.main) | ||
| .sink { [weak self] status in |
There was a problem hiding this comment.
🤔 Why aren't you using for await here instead of this sink subscription? Additionally, if the user goes back (away from this screen) after it's subscribed, is the subscription canceled or remains in memory?
There was a problem hiding this comment.
Using this method is easier to cancel the subscription without having to explicitly handling tasks.
Regarding the cancellation:
It will be cancelled and released:
- premiumStatusChangedCancellable is a property on the processor
- When the processor deallocates, that property deallocates with it
- AnyCancellable automatically calls cancel() on deallocation → subscription is torn down
The [weak self] is the key safety piece. Without it:
processor → cancellable → sink closure → strong self (processor)
That's a retain cycle — the processor would keep itself alive and never deallocate.
With [weak self]:
processor → cancellable → sink closure → weak ref (processor)No cycle. Processor can deallocate freely, taking the cancellable with it.
| /// - Returns: `true` if the URL was a premium checkout result and was handled. | ||
| /// | ||
| private func handlePremiumCheckoutResult(url: URL) async -> Bool { | ||
| guard url.scheme == "bitwarden", url.host == "premium-checkout-result" else { |
There was a problem hiding this comment.
🎨 You can use isBitwardenAppScheme String extension for the scheme and I would move the host to the constants file.
| try await Task.sleep(nanoseconds: 100_000_000) | ||
| XCTAssertEqual(coordinator.routes.count, routeCountBeforeSend) |
| // MARK: - DismissCompletionContext | ||
|
|
||
| /// A context object used to pass a completion handler through `coordinator.navigate(to: .dismiss)`. | ||
| /// | ||
| class DismissCompletionContext: NSObject { | ||
| let completion: () -> Void | ||
|
|
||
| init(completion: @escaping () -> Void) { | ||
| self.completion = completion | ||
| } | ||
| } |
There was a problem hiding this comment.
🤔 Isn't this alike DismissAction? Or how is this different?
There was a problem hiding this comment.
of course we can... 🤦
…ltRoute and VaultCoordinator
fedemkr
left a comment
There was a problem hiding this comment.
Looks good, just some minor ⛏️
| XCTAssertEqual(coordinator.routes.last, .dismiss) | ||
| context.completion() | ||
| try await waitForAsync { | ||
| guard case .dismiss(let action) = self.coordinator.routes.last else { return false } |
There was a problem hiding this comment.
⛏️ We usually use guard case let instead of the let inside.
| XCTAssertEqual(coordinator.routes.last, .dismiss) | ||
| context.completion() | ||
| try await waitForAsync { | ||
| guard case .dismiss(let action) = self.coordinator.routes.last else { return false } |
There was a problem hiding this comment.
⛏️ We usually use guard case let instead of the let inside.
| XCTAssertEqual(coordinator.routes.last, .dismiss) | ||
| context.completion() | ||
| try await waitForAsync { | ||
| guard case .dismiss(let action) = self.coordinator.routes.last else { return false } |
There was a problem hiding this comment.
⛏️ We usually use guard case let instead of the let inside.

🎟️ Tracking
https://bitwarden.atlassian.net/browse/PM-35101
📔 Objective
Implements the complete Stripe premium checkout flow for iOS:
PremiumCheckoutStatusandSubscriptionStatusenumsBillingServicewith checkout status publishing,premiumCheckoutCanceled(), andpremiumStatusChanged()premiumStatusChangedpush notification throughBillingServiceinstead of a plain syncbitwarden://premium-checkout-resultdeep link inAppProcessorPremiumUpgradeProcessor(loading overlay, cancel/retry alert, dismiss on confirm)📸 Screenshots
ScreenRecording_04-27-2026.17-47-23_1.mov