Skip to content

[PM-36251] feat: Navigate archive premium upsell through in-app upgrade flow#2607

Merged
andrebispo5 merged 36 commits into
mainfrom
pm-36251/archive-premium-upgrade
May 11, 2026
Merged

[PM-36251] feat: Navigate archive premium upsell through in-app upgrade flow#2607
andrebispo5 merged 36 commits into
mainfrom
pm-36251/archive-premium-upgrade

Conversation

@andrebispo5
Copy link
Copy Markdown
Contributor

🎟️ Tracking

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

📔 Objective

Wires the archive unavailable upsell CTA to the same in-app premium upgrade flow used by the vault list banner, instead of always opening the web vault URL.

  • Renames shouldShowPremiumUpgradeBannerisPremiumUpgradeEligible to better reflect that it checks user eligibility, not banner visibility.
  • Extracts isPremiumUpgradeBannerDismissed as a separate StateService method so banner dismissal is a distinct concern.
  • Adds isInAppUpgradeAvailable() and navigateToPremiumUpgrade() to VaultListProcessor, encapsulating the routing decision (in-app upgrade flow vs. web vault fallback) based on the feature flag and US storefront.
  • The banner respects dismissal; the archive CTA bypasses it so a dismissed banner does not block the user from upgrading via the archive entry point.

@andrebispo5 andrebispo5 marked this pull request as ready for review May 1, 2026 09:51
Copilot AI review requested due to automatic review settings May 1, 2026 09:52
@andrebispo5 andrebispo5 requested review from a team and matt-livefront as code owners May 1, 2026 09:52
@github-actions github-actions Bot added app:password-manager Bitwarden Password Manager app context t:feature labels May 1, 2026
@andrebispo5 andrebispo5 added the ai-review-vnext Request a Claude code review using the vNext workflow label May 1, 2026
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

Routes the “Archive unavailable” upsell CTA through the same premium in-app upgrade flow as the vault list banner (with a web vault URL fallback when in-app upgrade isn’t available), and refactors premium-upgrade banner eligibility vs. dismissal into distinct concerns.

Changes:

  • Split premium upgrade logic into eligibility (isPremiumUpgradeEligible) vs. banner dismissal (isPremiumUpgradeBannerDismissed).
  • Centralized in-app upgrade availability checks and navigation fallback logic inside VaultListProcessor.
  • Updated/added tests to cover banner dismissal behavior and archive CTA routing (in-app vs web fallback).

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift Refactors premium upgrade banner visibility logic and routes archive upsell to in-app upgrade when available, otherwise web URL fallback.
BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessorTests.swift Updates banner tests for new eligibility/dismissal split; adds archive CTA routing tests.
BitwardenShared/Core/Platform/Services/StateService.swift Updates StateService API by replacing shouldShowPremiumUpgradeBanner() with eligibility + dismissal methods and implements them in DefaultStateService.
BitwardenShared/Core/Platform/Services/StateServiceTests.swift Renames/adjusts tests for the new StateService API and adds dismissal-specific tests.
BitwardenShared/Core/Platform/Services/TestHelpers/MockStateService.swift Updates the mock to match the new StateService API used by tests/processors.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +2191 to +2193
let alert = coordinator.alertShown.last
try? await alert?.tapAction(title: Localizations.upgradeToPremium)
try await waitForAsync { self.coordinator.routes.last == .premiumUpgrade }
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

Avoid try? + optional chaining here; if the alert/action isn't present, the test will silently continue and could pass incorrectly. Unwrap the alert and use try await so failures are surfaced.

Copilot uses AI. Check for mistakes.
Comment on lines 2210 to 2214
let alert = coordinator.alertShown.last
XCTAssertEqual(alert?.title, Localizations.archiveUnavailable)
XCTAssertEqual(alert?.message, Localizations.archivingItemsIsAPremiumFeatureDescriptionLong)

try? await alert?.tapAction(title: Localizations.upgradeToPremium)
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

Avoid try? + optional chaining here; if the alert/action isn't present, the test will silently continue and could pass incorrectly. Unwrap the alert and use try await so failures are surfaced.

Suggested change
let alert = coordinator.alertShown.last
XCTAssertEqual(alert?.title, Localizations.archiveUnavailable)
XCTAssertEqual(alert?.message, Localizations.archivingItemsIsAPremiumFeatureDescriptionLong)
try? await alert?.tapAction(title: Localizations.upgradeToPremium)
let alert = try XCTUnwrap(coordinator.alertShown.last)
XCTAssertEqual(alert.title, Localizations.archiveUnavailable)
XCTAssertEqual(alert.message, Localizations.archivingItemsIsAPremiumFeatureDescriptionLong)
try await alert.tapAction(title: Localizations.upgradeToPremium)

Copilot uses AI. Check for mistakes.
Comment on lines 2663 to 2676
@@ -2672,12 +2672,12 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
)))
appSettingsStore.premiumUpgradeBannerDismissedByUserId["1"] = false

let shouldShow = await subject.shouldShowPremiumUpgradeBanner()
let shouldShow = await subject.isPremiumUpgradeEligible()
XCTAssertTrue(shouldShow)
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

The doc comment for test_isPremiumUpgradeEligible_true still says eligibility requires the banner to not be dismissed, but isPremiumUpgradeEligible() no longer checks dismissal (that’s covered by isPremiumUpgradeBannerDismissed()). Please update the comment (and consider renaming shouldShow to isEligible for clarity).

Copilot uses AI. Check for mistakes.
Comment on lines +2175 to +2184
/// `receive(_:)` with `.itemPressed` navigates to the premium upgrade screen even when the
/// banner has been dismissed, since the archive entry point bypasses the dismissal check.
@MainActor
func test_receive_itemPressed_archiveGroup_noPremium_noItems_actionTapped_bannerDismissed() async throws {
configService.featureFlagsBool[.premiumUpgradePath] = true
stateService.isPremiumUpgradeEligibleResult = true
vaultRepository.hasMinimumCipherCountResult = .success(true)
storefrontService.isUSStorefrontReturnValue = true
let statusSubject = PassthroughSubject<PremiumCheckoutStatus, Never>()
billingService.premiumCheckoutStatusPublisherReturnValue = statusSubject.eraseToAnyPublisher()
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

This test claims the archive upgrade flow should work even when the banner is dismissed, but it never sets stateService.isPremiumUpgradeBannerDismissedResult = true. As written it doesn't actually verify the dismissal-bypass behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +2163 to +2168
@@ -2142,13 +2164,61 @@ class VaultListProcessorTests: BitwardenTestCase { // swiftlint:disable:this typ
XCTAssertEqual(alert?.title, Localizations.archiveUnavailable)
XCTAssertEqual(alert?.message, Localizations.archivingItemsIsAPremiumFeatureDescriptionLong)

XCTAssertTrue(vaultRepository.archiveCipher.isEmpty)
try? await alert?.tapAction(title: Localizations.upgradeToPremium)
try await waitForAsync { self.coordinator.routes.last == .premiumUpgrade }
Copy link

Copilot AI May 1, 2026

Choose a reason for hiding this comment

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

Avoid try? + optional chaining here; if the alert/action isn't present, the test will silently continue and could pass incorrectly. Unwrap the alert and use try await so failures are surfaced.

Copilot uses AI. Check for mistakes.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 1, 2026

Codecov Report

❌ Patch coverage is 98.37838% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.27%. Comparing base (4568a40) to head (38d45a0).

Files with missing lines Patch % Lines
...d/Core/Platform/Services/BillingStateService.swift 81.25% 3 Missing ⚠️
...I/Vault/Vault/VaultGroup/VaultGroupProcessor.swift 94.11% 3 Missing ⚠️
...ultItemSelection/VaultItemSelectionProcessor.swift 94.00% 3 Missing ⚠️
.../UI/Vault/Vault/VaultList/VaultListProcessor.swift 96.29% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2607      +/-   ##
==========================================
- Coverage   87.28%   86.27%   -1.01%     
==========================================
  Files        1908     2137     +229     
  Lines      169827   184896   +15069     
==========================================
+ Hits       148226   159512   +11286     
- Misses      21601    25384    +3783     

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

func isPremiumUpgradeBannerDismissed() async -> Bool

/// Returns whether the user meets the eligibility criteria for the premium upgrade
/// (free account and 7+ days old).
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.

⛏️ I wouldn't say which are the requirements here in the protocol as if they change you need to remember updating this comment as well. Also the caller doesn't need to know which are the requirements just what the function does and returns.
So for easier maintainability I'd just remove this line

Suggested change
/// (free account and 7+ days old).

}

func isPremiumUpgradeBannerDismissed() async -> Bool {
await ((try? getPremiumUpgradeBannerDismissed()) ?? 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.

⛏️ I think you can drop the outer parenthesis here:

Suggested change
await ((try? getPremiumUpgradeBannerDismissed()) ?? false)
await (try? getPremiumUpgradeBannerDismissed()) ?? false

🤔 Also, I think you should log the error if getPremiumUpgradeBannerDismissed throws and then return false instead of of using try? here. Or is it fine to ignore the errors on this function?

Comment thread BitwardenShared/Core/Platform/Services/StateService.swift Outdated
Comment on lines +1849 to +1853
guard timeProvider.timeSince(creationDate) >= Constants.premiumUpgradeBannerAccountAge else {
return false
}

return true
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.

🎨 Can't you just return the operation result here?

Suggested change
guard timeProvider.timeSince(creationDate) >= Constants.premiumUpgradeBannerAccountAge else {
return false
}
return true
return timeProvider.timeSince(creationDate) >= Constants.premiumUpgradeBannerAccountAge

Comment on lines +568 to +569
guard (try? await services.vaultRepository
.hasMinimumCipherCount(Constants.minimumPremiumUpgradeBannerCipherCount)) ?? 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.

🤔 Shouldn't this be logging the error when hasMinimumCipherCount throws instead of ignoring it?

guard (try? await services.vaultRepository
.hasMinimumCipherCount(Constants.minimumPremiumUpgradeBannerCipherCount)) ?? false
else { return false }
guard await services.storefrontService.isUSStorefront() 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.

🎨 IMO this services.storefrontService.isUSStorefront should be checked before hasMinimumCipherCount or even services.stateService.isPremiumUpgradeEligible as it should be faster and consume less resources than the others, as it's just a cached value.

Comment on lines +579 to +584
if await isInAppUpgradeAvailable() {
subscribeToPremiumCheckoutStatus()
coordinator.navigate(to: .premiumUpgrade)
} else {
state.url = services.environmentService.upgradeToPremiumURL
}
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.

⛏️ I'd use a guard here to avoid the nesting blocks and be more "Swifty":

Suggested change
if await isInAppUpgradeAvailable() {
subscribeToPremiumCheckoutStatus()
coordinator.navigate(to: .premiumUpgrade)
} else {
state.url = services.environmentService.upgradeToPremiumURL
}
guard await isInAppUpgradeAvailable() else {
state.url = services.environmentService.upgradeToPremiumURL
return
}
subscribeToPremiumCheckoutStatus()
coordinator.navigate(to: .premiumUpgrade)

Comment on lines +264 to +266
let isBannerDismissed = await services.stateService.isPremiumUpgradeBannerDismissed()
let isUpgradeAvailable = await isInAppUpgradeAvailable()
state.shouldShowPremiumUpgradeActionCard = !isBannerDismissed && isUpgradeAvailable
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.

🎨 If the banner has already been dismissed then it's wasteful to check for isInAppUpgradeAvailable as that value doesn't really matter later. So I would rewrite this with short-circuit as:

        let isBannerDismissed = await services.stateService.isPremiumUpgradeBannerDismissed()
        guard !isBannerDismissed else {
            state.shouldShowPremiumUpgradeActionCard = false
            return
        }
        state.shouldShowPremiumUpgradeActionCard = await isInAppUpgradeAvailable()

or like this:

        let isBannerDismissed = await services.stateService.isPremiumUpgradeBannerDismissed()
        state.shouldShowPremiumUpgradeActionCard = !isBannerDismissed && (await isInAppUpgradeAvailable())

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 1, 2026

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

This PR wires the archive unavailable upsell CTA through the in-app premium upgrade flow used by the vault list banner, introduces a BillingRepository that centralizes the isInAppUpgradeAvailable decision, and extracts BillingStateService as a segregated billing-state protocol. The refactor also breaks up VaultListProcessor.perform into smaller methods and propagates the new handleNavigateToPremiumUpgrade callback through VaultItemMoreOptionsHelper, VaultGroupProcessor, and VaultItemSelectionProcessor. Test coverage is solid across the new repository, the renamed state methods, and the three processors’ premium checkout status branches.

Code Review Details

No new findings beyond those already captured in existing open review threads on this PR. The substantive concerns raised earlier — including the archive-upsell subscription path, the duplication of subscribeToPremiumCheckoutStatus across three processors, the BillingStateService interface segregation, and lock/dismiss handling in BillingCoordinator — are tracked in prior comments and remain the right place to continue that discussion.

Minor observations not worth their own inline:

  • VaultListProcessor.swift line 360-363: the outer [weak self] on the Alert.archiveUnavailable action closure is shadowed by the inner [weak self] on the Task and can be dropped for clarity.
  • VaultGroupProcessor.subscribeToPremiumCheckoutStatus on .confirmed calls refreshVaultGroup() (which only triggers a sync) but does not refresh state.hasPremium; not a defect in the current call paths since the archive helper reads premium live, but worth keeping in mind if local state.hasPremium is later used to gate UI on this screen.

Comment on lines 363 to +367
coordinator.showAlert(
Alert.archiveUnavailable(action: { [weak self] in
guard let self else { return }
state.url = services.environmentService.upgradeToPremiumURL
Task { [weak self] in
await self?.navigateToPremiumUpgrade()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

QUESTION: The same Alert.archiveUnavailable is also shown from VaultItemMoreOptionsHelper.archive() (when a non-premium user taps Archive on an individual cipher), but that path still goes straight to services.environmentService.upgradeToPremiumURL and bypasses the new in-app upgrade flow.

Details

BitwardenShared/UI/Vault/Helpers/VaultItemMoreOptionsHelper.swift:131-134:

Alert.archiveUnavailable(action: { [weak self] in
    guard let self else { return }
    handleOpenURL(services.environmentService.upgradeToPremiumURL)
}),

Was the more-options entry point intentionally left out of scope, or should it be migrated to the same navigateToPremiumUpgrade() path so that the identical alert routes consistently from both entry points?

Comment on lines 131 to 143
coordinator.showAlert(
Alert.archiveUnavailable(action: { [weak self] in
guard let self else { return }
handleOpenURL(services.environmentService.upgradeToPremiumURL)
Task { [weak self] in
guard let self else { return }
if await self.services.billingRepository.isInAppUpgradeAvailable() {
self.coordinator.navigate(to: .premiumUpgrade)
} else {
handleOpenURL(self.services.environmentService.upgradeToPremiumURL)
}
}
}),
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ IMPORTANT: Archive entry navigates to .premiumUpgrade without subscribing to premiumCheckoutStatusPublisher, so a successful checkout never dismisses the upgrade screen.

Details and fix

PremiumUpgradeProcessor.subscribeToPremiumCheckoutStatus only handles .canceled itself; for .confirmed, .pending, and .syncing it relies on the parent owning the dismiss (see the explicit comment in PremiumUpgradeProcessor.swift:120-122: "VaultListProcessor owns the dismiss and all post-dismiss actions via DismissAction for each of these states.").

VaultListProcessor.navigateToPremiumUpgrade() calls subscribeToPremiumCheckoutStatus() before navigating, but the new code path in this helper does not. Concrete consequences when in-app upgrade is available and the user completes checkout from the archive more-options entry:

  • The premium upgrade modal does not dismiss after .confirmed (no DismissAction).
  • The vault is not refreshed and state.hasPremium is not updated, so the archive group still appears unavailable.
  • The "upgrade pending" alert is not shown for .pending.
  • The confirming-loading overlay is not shown for .syncing.

VaultItemMoreOptionsHelper is a @MainActor helper, not a StateProcessor, so it cannot reasonably own the dismiss/refresh logic. The cleanest fix is to keep the upsell routing centralized in VaultListProcessor — either by surfacing this through the existing .upgradeToPremium action, or by exposing a coordinator/delegate hook so the processor can subscribe before navigation.

Reference: BitwardenShared/UI/Billing/PremiumUpgrade/PremiumUpgradeProcessor.swift:117-124, BitwardenShared/UI/Vault/Vault/VaultList/VaultListProcessor.swift:562-569.

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.

@andrebispo5 have you checked this?

@github-actions github-actions Bot added the app:authenticator Bitwarden Authenticator app context label May 4, 2026
@andrebispo5 andrebispo5 requested a review from fedemkr May 4, 2026 14:27
@@ -0,0 +1,11 @@
@testable import BitwardenShared

class MockBillingRepository: BillingRepository {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤔 BillingRepository is already marked as AutoMockable; we shouldn't need a manual mock, right?

/// A protocol for a `BillingRepository` which manages access to billing-related data
/// needed by the UI layer.
///
protocol BillingRepository: AnyObject { // sourcery: AutoMockable
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤔 Why is it necessary for this to conform to AnyObject?

@testable import BitwardenShared
@testable import BitwardenSharedMocks

class BillingRepositoryTests: BitwardenTestCase {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎨 Ideally new tests are Swift Testing, instead of XCTest. The skill in the repo should be able to convert it for you fairly easily

///
/// - Returns: `true` if the user has dismissed the banner.
///
func isPremiumUpgradeBannerDismissed() async -> Bool
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤔 I have to wonder if it would make sense to actually put these in a BillingStateService for some sort of interface segregation, like we have with the ConfigStateService, BiometricsStateService, UserSessionStateService, etc.

Comment on lines +12 to +17
/// Checks (in order):
/// 1. The `.premiumUpgradePath` feature flag is enabled.
/// 2. The App Store storefront is in the United States.
/// 3. The user is eligible for a premium upgrade (not already premium and account is 7+ days old).
/// 4. The vault contains at least the minimum cipher count.
///
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.

⛏️ Remove what this checks inside. Unless it's strictly necessary this shouldn't be added to protocol docs as the caller should only care in this case if the in-app premium upgrade path is available and not how billing internally checks that.
👍 The callout for the banner dismissal is correct as it's something that callers may need to know for their logic.

Suggested change
/// Checks (in order):
/// 1. The `.premiumUpgradePath` feature flag is enabled.
/// 2. The App Store storefront is in the United States.
/// 3. The user is eligible for a premium upgrade (not already premium and account is 7+ days old).
/// 4. The vault contains at least the minimum cipher count.
///

Comment on lines +75 to +77
guard await configService.getFeatureFlag(.premiumUpgradePath) else { return false }
guard await storefrontService.isUSStorefront() else { return false }
guard await stateService.isPremiumUpgradeEligible() 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.

⛏️ You can use comma separated conditions here with one guard:

Suggested change
guard await configService.getFeatureFlag(.premiumUpgradePath) else { return false }
guard await storefrontService.isUSStorefront() else { return false }
guard await stateService.isPremiumUpgradeEligible() else { return false }
guard await configService.getFeatureFlag(.premiumUpgradePath),
storefrontService.isUSStorefront(),
stateService.isPremiumUpgradeEligible()
else {
return false
}

/// A default implementation of `StateService`.
///
actor DefaultStateService: StateService, ActiveAccountStateProvider, ConfigStateService, FlightRecorderStateService, LanguageStateService { // swiftlint:disable:this type_body_length line_length
actor DefaultStateService: StateService, ActiveAccountStateProvider, BillingStateService, ConfigStateService, FlightRecorderStateService, LanguageStateService { // swiftlint:disable:this type_body_length line_length
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.

🎨 This file is gigantic, could you move this to BillingStateService as an extension so the implementation is there? You can check ServerCommunicationConfigStateService as an example.
Also move its tests.

Comment on lines 131 to 143
coordinator.showAlert(
Alert.archiveUnavailable(action: { [weak self] in
guard let self else { return }
handleOpenURL(services.environmentService.upgradeToPremiumURL)
Task { [weak self] in
guard let self else { return }
if await self.services.billingRepository.isInAppUpgradeAvailable() {
self.coordinator.navigate(to: .premiumUpgrade)
} else {
handleOpenURL(self.services.environmentService.upgradeToPremiumURL)
}
}
}),
)
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.

@andrebispo5 have you checked this?

Comment thread BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessor.swift
Comment thread BitwardenShared/UI/Vault/Vault/VaultGroup/VaultGroupProcessor.swift
Comment on lines +236 to +275
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:
break
case .confirmed:
premiumStatusChangedCancellable = nil
coordinator.navigate(
to: .dismiss(DismissAction { [weak self] in
guard let self else { return }
coordinator.hideLoadingOverlay()
Task { await self.refreshVaultGroup() }
}),
)
case .pending:
coordinator.navigate(
to: .dismiss(DismissAction { [weak self] in
guard let self else { return }
coordinator.hideLoadingOverlay()
coordinator.showAlert(.upgradePending {
await self.services.billingService.premiumStatusChanged()
})
}),
)
case .syncing:
coordinator.navigate(
to: .dismiss(DismissAction { [weak self] in
guard let self else { return }
coordinator.showLoadingOverlay(
LoadingOverlayState(title: Localizations.confirmingYourUpgrade),
)
}),
)
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ DEBT: New subscribeToPremiumCheckoutStatus() lacks coverage for the .confirmed, .pending, and .syncing branches.

Details

The existing VaultGroupProcessorTests.test_perform_morePressed_navigateToPremiumUpgrade_inAppUpgradeAvailable only asserts that premiumCheckoutStatusPublisher() was called. None of the dismiss / loading-overlay / pending-alert / refreshVaultGroup paths inside this sink are exercised.

VaultListProcessorTests already provides this coverage (test_subscribeToPremiumCheckoutStatus_confirmed/pending/syncing/canceled). Mirroring that pattern here would prevent regressions in the new in-app upgrade flow for vault groups.

The same gap exists in VaultItemSelectionProcessor.swift:166-204 — its tests also only assert the publisher was called, not that .confirmed/.pending/.syncing produce the expected dismiss/alert/overlay behavior.

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.

This navigation will change in a future PR, the amount of work to refactor it is not worth it for now.

Comment on lines +236 to +275
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:
break
case .confirmed:
premiumStatusChangedCancellable = nil
coordinator.navigate(
to: .dismiss(DismissAction { [weak self] in
guard let self else { return }
coordinator.hideLoadingOverlay()
Task { await self.refreshVaultGroup() }
}),
)
case .pending:
coordinator.navigate(
to: .dismiss(DismissAction { [weak self] in
guard let self else { return }
coordinator.hideLoadingOverlay()
coordinator.showAlert(.upgradePending {
await self.services.billingService.premiumStatusChanged()
})
}),
)
case .syncing:
coordinator.navigate(
to: .dismiss(DismissAction { [weak self] in
guard let self else { return }
coordinator.showLoadingOverlay(
LoadingOverlayState(title: Localizations.confirmingYourUpgrade),
)
}),
)
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

♻️ DEBT: subscribeToPremiumCheckoutStatus() is duplicated across three processors with only the .confirmed branch differing.

Details and fix

This PR introduces near-identical copies of navigateToPremiumUpgrade() + subscribeToPremiumCheckoutStatus() in VaultListProcessor, VaultGroupProcessor, and VaultItemSelectionProcessor. The .canceled, .pending, and .syncing branches are byte-for-byte the same; only the post-.confirmed action varies (refresh vault list vs. refresh vault group vs. nothing).

This couples three processors to identical orchestration logic. A bug fix or behavior change to the pending/syncing branch must be applied in three places, and the duplication is likely to grow as more entry points are added.

Suggested approaches:

  • Extract a PremiumCheckoutFlowHandler (or similar) helper holding the AnyCancellable and accepting an onConfirmed: () async -> Void closure for the variant behavior.
  • Or expose the orchestration on the existing BillingService/BillingRepository so processors only inject a confirmation callback.

Worth doing as a follow-up since the pattern is now anchored in three places.

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.

This navigation will change in a future PR, the amount of work to refactor it is not worth it for now.

@andrebispo5 andrebispo5 requested a review from fedemkr May 11, 2026 08:38
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 🎉

@andrebispo5 andrebispo5 merged commit b8b85dc into main May 11, 2026
30 checks passed
@andrebispo5 andrebispo5 deleted the pm-36251/archive-premium-upgrade branch May 11, 2026 14:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review-vnext Request a Claude code review using the vNext workflow 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.

4 participants