Skip to content

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

Open
andrebispo5 wants to merge 9 commits intomainfrom
pm-36251/archive-premium-upgrade
Open

[PM-36251] feat: Navigate archive premium upsell through in-app upgrade flow#2607
andrebispo5 wants to merge 9 commits intomainfrom
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
let alert = coordinator.alertShown.last
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.98649% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.22%. Comparing base (9d93d87) to head (08615d3).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...enShared/Core/Platform/Services/StateService.swift 81.25% 3 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2607      +/-   ##
==========================================
+ Coverage   87.20%   87.22%   +0.02%     
==========================================
  Files        1895     1898       +3     
  Lines      167767   167986     +219     
==========================================
+ Hits       146304   146531     +227     
+ Misses      21463    21455       -8     

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

guard await !doesActiveAccountHavePremium() else { return false }

// Check account age >= 7 days
guard let account = try? await getActiveAccount(),
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 an error be logged if getActiveAccount throws or is it safe to ignore here?

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.

it will fail in doesActiveAccountHavePremium the call above and return false

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: REQUEST CHANGES

This PR refactors the premium upgrade banner eligibility logic, introduces a new BillingRepository to encapsulate the in-app upgrade availability check, and routes the archive upsell through the in-app upgrade flow. The new repository has solid unit coverage, the StateService split between isPremiumUpgradeEligible() and isPremiumUpgradeBannerDismissed() is a clean separation of concerns, and VaultListProcessor correctly bypasses the banner dismissal for the archive entry point. One issue with the migration of the more-options archive entry is worth addressing before merge.

Code Review Details
  • ⚠️ : Archive entry in VaultItemMoreOptionsHelper navigates to .premiumUpgrade without subscribing to premiumCheckoutStatusPublisher, so a successful checkout never dismisses the upgrade screen and the vault is not refreshed.
    • BitwardenShared/UI/Vault/Helpers/VaultItemMoreOptionsHelper.swift:131-143

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.

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:password-manager Bitwarden Password Manager app context t:feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants