Skip to content

[PM-35101] feat: Complete Stripe premium checkout flow#2574

Merged
andrebispo5 merged 28 commits intomainfrom
pm-35101/complete-stripe-payment
Apr 30, 2026
Merged

[PM-35101] feat: Complete Stripe premium checkout flow#2574
andrebispo5 merged 28 commits intomainfrom
pm-35101/complete-stripe-payment

Conversation

@andrebispo5
Copy link
Copy Markdown
Contributor

@andrebispo5 andrebispo5 commented Apr 24, 2026

🎟️ Tracking

https://bitwarden.atlassian.net/browse/PM-35101

📔 Objective

Implements the complete Stripe premium checkout flow for iOS:

  • Adds PremiumCheckoutStatus and SubscriptionStatus enums
  • Extends BillingService with checkout status publishing, premiumCheckoutCanceled(), and premiumStatusChanged()
  • Adds billing alerts (payment not received, checkout failed, upgrade pending)
  • Routes the premiumStatusChanged push notification through BillingService instead of a plain sync
  • Handles the bitwarden://premium-checkout-result deep link in AppProcessor
  • Subscribes to checkout status in PremiumUpgradeProcessor (loading overlay, cancel/retry alert, dismiss on confirm)
  • Adds an "Upgraded to Premium" action card to the vault list and streams checkout status to update premium state

📸 Screenshots

IMG_6329 Large IMG_6327 Large IMG_6328 Large
ScreenRecording_04-27-2026.17-47-23_1.mov

# Conflicts:
#	BitwardenShared/Core/Platform/Services/NotificationServiceTests.swift
#	BitwardenShared/UI/Billing/PremiumUpgrade/PremiumUpgradeProcessorTests.swift
@github-actions github-actions Bot added app:password-manager Bitwarden Password Manager app context app:authenticator Bitwarden Authenticator app context t:feature labels Apr 28, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 28, 2026

Logo
Checkmarx One – Scan Summary & Details1c8601c1-bc34-42f4-9775-6de0ec23c392

Great job! No new security vulnerabilities introduced in this pull request

@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 28, 2026

Codecov Report

❌ Patch coverage is 94.94949% with 40 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.15%. Comparing base (2ed2b51) to head (12b4155).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
...lling/PremiumUpgrade/PremiumUpgradeProcessor.swift 65.21% 16 Missing ⚠️
...hared/UI/Vault/Vault/VaultList/VaultListView.swift 21.05% 15 Missing ⚠️
...enShared/UI/Billing/Extensions/Alert+Billing.swift 89.47% 6 Missing ⚠️
.../UI/Vault/Vault/VaultList/VaultListProcessor.swift 94.23% 3 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 + SubscriptionStatus enums and extended BillingService to publish checkout status and react to deep link/push signals.
  • Routed premium upgrade results through AppProcessor deep-link handling and NotificationService, 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.

Comment on lines +451 to +456
if result == BitwardenDeepLinkConstants.PremiumCheckoutResultQuery.successValue {
await services.billingService.premiumStatusChanged()
} else {
services.billingService.premiumCheckoutCanceled()
}
return true
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +84 to +96
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,
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
guard let self else { return }
switch status {
case .canceled:
premiumStatusChangedCancellable = nil
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
premiumStatusChangedCancellable = nil
break

Copilot uses AI. Check for mistakes.
case paused
case pastDue = "past_due"
case trialing
case unpaid
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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
}

Copilot uses AI. Check for mistakes.
@andrebispo5 andrebispo5 marked this pull request as ready for review April 29, 2026 09:26
@andrebispo5 andrebispo5 requested review from a team and matt-livefront as code owners April 29, 2026 09:26
…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
Comment on lines +126 to +129
func premiumCheckoutCanceled() {
premiumCheckoutStatusSubject.send(.canceled)
premiumCheckoutStatusSubject.send(nil)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤔 Why do you need to send nil after canceled?

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.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤔 Are you adding the debounce in case several status are sent in a row and you only want to publish the last one?

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.

yup exactly.

Comment on lines +165 to +168
premiumCheckoutStatusSubject.send(hasPremium ? .confirmed : .pending)
if hasPremium {
premiumCheckoutStatusSubject.send(nil)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤔 Why is it needed to send nil if you have already sent confirmed?

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.

same explanation has above in this case .confirmed is an end state.

Comment on lines +85 to +88
premiumStatusChangedCancellable = services.billingService
.premiumCheckoutStatusPublisher()
.receive(on: DispatchQueue.main)
.sink { [weak self] status in
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤔 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?

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.

Using this method is easier to cancel the subscription without having to explicitly handling tasks.

Regarding the cancellation:

It will be cancelled and released:

  1. premiumStatusChangedCancellable is a property on the processor
  2. When the processor deallocates, that property deallocates with it
  3. 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 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🎨 You can use isBitwardenAppScheme String extension for the scheme and I would move the host to the constants file.

Comment on lines +699 to +700
try await Task.sleep(nanoseconds: 100_000_000)
XCTAssertEqual(coordinator.routes.count, routeCountBeforeSend)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

⛏️ Use waitForAsync instead.

Comment on lines +6 to +16
// 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
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤔 Isn't this alike DismissAction? Or how is this different?

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.

of course we can... 🤦

@andrebispo5 andrebispo5 requested a review from fedemkr April 29, 2026 22:19
Copy link
Copy Markdown
Member

@fedemkr fedemkr left a comment

Choose a reason for hiding this comment

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

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 }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

⛏️ 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 }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

⛏️ 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 }
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

⛏️ We usually use guard case let instead of the let inside.

@andrebispo5 andrebispo5 enabled auto-merge (squash) April 30, 2026 15:10
@andrebispo5 andrebispo5 merged commit 139286b into main Apr 30, 2026
19 checks passed
@andrebispo5 andrebispo5 deleted the pm-35101/complete-stripe-payment branch April 30, 2026 15:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app:authenticator Bitwarden Authenticator app context app:password-manager Bitwarden Password Manager app context t:feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants