Skip to content

[PM-41836] fix: Promote Plan screen off Free view without requiring an in-flight checkout - #7337

Merged
aj-rosado merged 5 commits into
mainfrom
PM-41836/premium-user-displays-proper-plan-after-failure
Sep 3, 2026
Merged

[PM-41836] fix: Promote Plan screen off Free view without requiring an in-flight checkout#7337
aj-rosado merged 5 commits into
mainfrom
PM-41836/premium-user-displays-proper-plan-after-failure

Conversation

@aj-rosado

Copy link
Copy Markdown
Contributor

🎟️ Tracking

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

📔 Objective

The Plan screen only promoted a premium account off the Free-upgrade view while isAwaitingPremiumStatus was true (immediately after a Stripe checkout round-trip), and handleSubscriptionStatusUpdateReceive only reacted to trouble-status subscription updates, never a healthy ACTIVE one. A premium account reaching the screen outside that narrow window — via a push notification, a background sync, or simply reopening the screen later — stayed stuck on the Free-upgrade UI despite already being premium.

Both signal handlers (handleUserStateUpdateReceive, handleSubscriptionStatusUpdateReceive) now promote unconditionally whenever the account is premium. The upgrade-celebration event (NavigateToUpgradedToPremium) still only fires when a checkout was genuinely in flight (isAwaitingPremiumStatus), so a stale trouble-status update arriving mid-checkout can't fire a false celebration.

Out of scope, tracked separately: the isPremium vs. org-granted-premium (isPremiumFromSelf) distinction, and a client-side wiring gap in the Stripe checkout callback (a missing AndroidManifest.xml intent-filter affecting devices without Auth Tab support) — both real findings from this investigation, but independent of this fix and better reviewed on their own.

Review note: a local review caught that an early version of this change promoted unconditionally on every UserStateUpdateReceive, including for non-premium accounts — a regression, since fixed and verified against the full PlanViewModelTest suite.

@aj-rosado aj-rosado added the ai-review Request a Claude code review label Aug 31, 2026
@github-actions github-actions Bot added app:password-manager Bitwarden Password Manager app context t:bug Change Type - Bug labels Aug 31, 2026
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

🤖 Bitwarden Claude Code Review

Overall Assessment: APPROVE

Reviewed the two changed files at head (3773fd3c6): PlanViewModel.kt and PlanViewModelTest.kt. Traced the promotion paths through handleUserStateUpdateReceive, handleSubscriptionStatusUpdateReceive, handlePremiumCheckoutCircumstance, and handleSyncCompleteReceive, including the interaction with showsPremiumView, isAwaitingPremiumStatus, and the mutableStateFlow.first { viewState is Content } gate on the merged signal flow. The latest commit's extraction of onFreeCloudContent into promoteFreeCloudToPremiumView is behavior-preserving — the previous inline cast produced isAwaitingPremiumStatus = false for non-Free.Cloud view states, which already routed to promoteToPremiumView(), itself a no-op outside Free.Cloud. No security, correctness, or breaking-change findings above the reporting threshold.

Code Review Details

No findings.

Notes on prior review threads, for context (no action requested):

  • The wasShowingPremiumView transition gate in handleUserStateUpdateReceive resolves the repeated subscription/pricing round-trip raised earlier on this handler; org-granted premium accounts sitting on the Free.Cloud fallback no longer re-enter the load on every user-state emission.
  • Celebration coverage for the user-state-flip path is retained by premium flip via canceled special circumstance should navigate to UpgradedToPremium, so the two deleted tests do not leave a gap.
  • The Test app-ui-platform job was still pending for this commit at review time; worth confirming green before merge.

@codecov

codecov Bot commented Aug 31, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.81818% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.67%. Comparing base (59d0faa) to head (3773fd3).
⚠️ Report is 14 commits behind head on main.

Files with missing lines Patch % Lines
.../ui/platform/feature/premium/plan/PlanViewModel.kt 81.81% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7337      +/-   ##
==========================================
+ Coverage   85.27%   85.67%   +0.39%     
==========================================
  Files         987      947      -40     
  Lines       68237    67965     -272     
  Branches    10198    10186      -12     
==========================================
+ Hits        58192    58230      +38     
+ Misses       6461     6149     -312     
- Partials     3584     3586       +2     
Flag Coverage Δ
app-data 17.86% <0.00%> (-0.17%) ⬇️
app-ui-auth-tools 18.78% <0.00%> (-0.04%) ⬇️
app-ui-platform 16.31% <81.81%> (+0.09%) ⬆️
app-ui-vault 27.70% <0.00%> (+<0.01%) ⬆️
authenticator 6.00% <0.00%> (-0.01%) ⬇️
lib-core-network-bridge 4.04% <0.00%> (-0.02%) ⬇️
lib-data-ui 1.19% <0.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 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.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@aj-rosado
aj-rosado marked this pull request as ready for review September 1, 2026 17:17
@aj-rosado
aj-rosado requested review from a team and david-livefront as code owners September 1, 2026 17:17
@aj-rosado aj-rosado added t:bug Change Type - Bug and removed t:bug Change Type - Bug labels Sep 1, 2026
loadedState.viewState,
)
expectNoEvents()
assertEquals(null, loadedState.dialogState)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we use assertNull

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Actually, can we assert the complete state

Comment on lines 492 to 495
// Promote unconditionally, not only while `isAwaitingPremiumStatus`.
if (isPremium) {
promoteFreeCloudToPremiumView(isConfirmedPremium = true)
}

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: Org-granted premium accounts re-run the subscription + pricing load right after landing on the Free view.

Details and fix

Account.isPremium is hasPremiumPersonally || hasPremiumFromOrganization, and an org-granted-premium account has no GatewaySubscriptionId, so getSubscription() returns NotFound and handleSubscriptionResultReceive drops the screen back to Content.Free.Cloud (covered by the existing SubscriptionResultReceive NotFound falls back to Free view and fetches pricing test, which uses markUserPremium()).

That Free.Cloud state is also the first moment the init gate first { it.viewState is Content } lets the initial UserStateUpdateReceive through, so the new unconditional promotion fires immediately:

Free.Cloud → Loading("loading subscription") → getSubscription → NotFound → Loading → getPremiumPlanPricing → Free.Cloud

The user sees the Free view flash back into a full-screen loader on every open, with two extra API calls, and it repeats on every distinct userStateFlow emission while the screen is open. The old isAwaitingPremiumStatus guard suppressed this. It is invisible to the tests because UnconfinedTestDispatcher runs the whole cycle before collection starts.

One option — gate on the transition, which keeps the intended fix (a premium flip arriving from a push/sync while on the Free view) and drops the redundant reload:

val isPremium = action.userState?.activeAccount?.isPremium == true
val wasShowingPremiumView = state.showsPremiumView
mutableStateFlow.update {
    it.copy(
        showsPremiumView = isPremium ||
            premiumStateManager.subscriptionStatusStateFlow.value.isPremiumViewEligible(),
    )
}
if (isPremium && !wasShowingPremiumView) {
    promoteFreeCloudToPremiumView(isConfirmedPremium = true)
}

The free → premium flip tests start with showsPremiumView = false, so they stay green.

}
// ACTIVE status for a premium account must promote too, not just trouble statuses.
val isPremium = authRepository.userStateFlow.value?.activeAccount?.isPremium == true
if (!isPremium && !status.isPremiumViewEligible()) {

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.

🎨 SUGGESTED: The new isPremium branch — the headline behavior of this fix — has no test exercising it.

Details and fix

Every test that drives mutableSubscriptionStatusStateFlow into Available uses a trouble status with a free account (SubscriptionStatusUpdateReceive promotes Free view to Premium on trouble status, init opens Premium view when account is free but status is in a trouble state, and the new ...trouble status while awaiting checkout...). The tests that set isPremium = true (lines 169, 469, 590, 607, 889, 1753) never emit an Available(ACTIVE) status while the view state is Content.Free.Cloud.

So the case the comment on line 469 describes — a premium account whose status flow reports ACTIVE while sitting on the Free view — is never executed. Under the old if (!status.isPremiumViewEligible()) return, ACTIVE always returned early; the whole point of adding isPremium here is to let it through, and that path is exactly what Codecov reports as a partial.

A test would look like the sibling at line 965, with the account flipped premium first:

@Test
fun `SubscriptionStatusUpdateReceive with ACTIVE status promotes premium account off Free view`() =
    runTest {
        val viewModel = createViewModel(subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE)

        viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
            assertEquals(DEFAULT_FREE_STATE, stateFlow.awaitItem())

            markUserPremium()
            mutableSubscriptionStatusStateFlow.value = SubscriptionStatusState.Available(
                status = PremiumSubscriptionStatus.ACTIVE,
            )

            // ... assert Loading then DEFAULT_PREMIUM_LOADED_STATE
            eventFlow.expectNoEvents()
        }
    }

This also pins down the interaction with the !wasShowingPremiumView gate added to handleUserStateUpdateReceive, which is what makes this handler the fallback promoter rather than a redundant one.

david-livefront
david-livefront previously approved these changes Sep 2, 2026
// Fires the celebration event only when isConfirmedPremium and a checkout was actually
// in flight; otherwise this is a silent state recovery.
private fun promoteFreeCloudToPremiumView(isConfirmedPremium: Boolean) {
val isAwaitingPremiumStatus = (state.viewState as? PlanState.ViewState.Content.Free.Cloud)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we make this cleaner?

onFreeCloudContent { freeState ->
    if (isConfirmedPremium && freeState.isAwaitingPremiumStatus) {
        onPremiumUpgradeSuccess()
    } else {
        promoteToPremiumView()
    }
}

@aj-rosado
aj-rosado added this pull request to the merge queue Sep 3, 2026
Merged via the queue into main with commit 7e14fcf Sep 3, 2026
26 checks passed
@aj-rosado
aj-rosado deleted the PM-41836/premium-user-displays-proper-plan-after-failure branch September 3, 2026 12:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-review Request a Claude code review app:password-manager Bitwarden Password Manager app context t:bug Change Type - Bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants