Skip to content

[PM-36969] feat: Surface subscription substate to premium gates#6931

Draft
SaintPatrck wants to merge 1 commit into
mainfrom
premium-upgrade/pm-36969-subscription-status-flow
Draft

[PM-36969] feat: Surface subscription substate to premium gates#6931
SaintPatrck wants to merge 1 commit into
mainfrom
premium-upgrade/pm-36969-subscription-status-flow

Conversation

@SaintPatrck
Copy link
Copy Markdown
Contributor

🎟️ Tracking

📔 Objective

The server keeps Profile.Premium=true during the grace window after a subscription enters a recovery or terminal Stripe state. Surfaces that gate purely on isPremium either suppress the Plan-screen badge that would explain the user's situation, or hide the upgrade CTA users need to recover their account.

Adds PremiumStateManager.subscriptionStatusStateFlow (Loading/NoSubscription/Available/Error) so callers can derive "effectively premium" from both the account flag and the Stripe substate. The upgrade banner now flips back on when the active subscription is in a trouble state (past_due, update_payment, canceled, paused) even while the server still reports premium. The Plan screen routes free-with-trouble-substate users to the Premium view so they see the right badge and Manage/Resubscribe affordances.

Renames OVERDUE_PAYMENT → UPDATE_PAYMENT so the badge label matches the Figma frame. A new SubscriptionResult.NotFound maps the 404 returned for users without a GatewaySubscriptionId to "free, show upgrade CTA" instead of an error dialog.

📸 Screenshots

The server keeps `Profile.Premium=true` during the grace window after a
subscription enters a recovery or terminal state, so callers that gate
purely on `isPremium` either route users away from the Plan-screen badge
that explains their situation (PM-36969, PM-36970, PM-37181) or suppress
the upgrade CTA users need to recover their account (PM-37093, PM-37177,
PM-37180). Expose the Stripe substate via a new
`PremiumStateManager.subscriptionStatusStateFlow` and use it to compute
"effectively premium" for banner eligibility and Plan-screen routing.

Renames the misleading `OVERDUE_PAYMENT` enum value to `UPDATE_PAYMENT` so
the badge label matches Figma. The subscription endpoint 404s for free
users (no `GatewaySubscriptionId`); a new `SubscriptionResult.NotFound`
maps that case to "free, show upgrade CTA" instead of an error dialog.
@SaintPatrck SaintPatrck added the ai-review-vnext Request a Claude code review using the vNext workflow label May 15, 2026
@github-actions github-actions Bot added app:password-manager Bitwarden Password Manager app context app:authenticator Bitwarden Authenticator app context t:feature Change Type - Feature Development labels May 15, 2026
@SaintPatrck SaintPatrck removed the app:authenticator Bitwarden Authenticator app context label May 15, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 15, 2026

🤖 Bitwarden Claude Code Review

Overall Assessment: REQUEST CHANGES

This PR introduces a new subscriptionStatusStateFlow on PremiumStateManager so callers can distinguish "effectively premium" from Account.isPremium, wires the banner and Plan screen to use that signal, and renames OVERDUE_PAYMENT → UPDATE_PAYMENT with a new SubscriptionResult.NotFound for 404s. Test coverage is strong for the new manager flow, the new VM action handler, and the new repository mapping. The main concern is a contract/implementation gap on the new flow that triggers a network call for every signed-in user (including free users) on every app launch and push, contrary to the interface KDoc; a secondary concern is that the NotFound → Free mapping can present an upgrade pitch to organization-premium users if PM-37465 has not landed first.

Code Review Details
  • ⚠️ : subscriptionStatusStateFlow fetches for every signed-in user (free included), contradicting the interface KDoc that says it fetches "lazily when the active account is premium"
    • app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerImpl.kt:62
  • ❓ : NotFound handler switches premium-from-organization users to the Free upgrade view — is this expected to be mitigated by PM-37465?
    • app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt:388
  • 🎨 : Use @BeforeEach/@AfterEach for mockkStatic/unmockkStatic in the new tests to avoid leaked static mocks on assertion failure
    • app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryTest.kt:220

Additional minor observations (not posted inline):

  • PremiumStateManager.isPremiumUpgradeBannerEligibleFlow KDoc lists expired as a trouble state, but PremiumSubscriptionStatus has no EXPIRED value (INCOMPLETE_EXPIRED collapses to CANCELED).
  • PlanScreenTest.kt:552 asserts "Expired" text does not exist; it was never rendered, so the assertion is dead.
  • BillingRepositoryTest.kt:24 has import com.bitwarden.network.model.toBitwardenError out of alphabetical order with the other com.bitwarden.network.model.* imports.
  • pushManager.premiumStatusChangedFlow triggers subscriptionRefreshTriggerFlow regardless of whether the push targets the active user; harmless but wasteful in multi-account scenarios.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 15, 2026

Codecov Report

❌ Patch coverage is 93.96552% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.34%. Comparing base (cc06636) to head (7a0a914).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
.../ui/platform/feature/premium/plan/PlanViewModel.kt 89.58% 1 Missing and 4 partials ⚠️
...en/data/billing/manager/PremiumStateManagerImpl.kt 96.66% 0 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6931      +/-   ##
==========================================
+ Coverage   86.29%   86.34%   +0.04%     
==========================================
  Files         856      867      +11     
  Lines       62034    62580     +546     
  Branches     9017     9067      +50     
==========================================
+ Hits        53532    54032     +500     
- Misses       5404     5433      +29     
- Partials     3098     3115      +17     
Flag Coverage Δ
app-data 17.23% <55.17%> (-0.04%) ⬇️
app-ui-auth-tools 19.37% <0.00%> (-0.15%) ⬇️
app-ui-platform 15.59% <40.17%> (+0.14%) ⬆️
app-ui-vault 28.07% <0.00%> (+0.17%) ⬆️
authenticator 6.32% <0.00%> (-0.06%) ⬇️
lib-core-network-bridge 4.11% <0.00%> (-0.01%) ⬇️
lib-data-ui 1.13% <0.00%> (-0.02%) ⬇️

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

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

Comment on lines +62 to +79
@OptIn(ExperimentalCoroutinesApi::class)
override val subscriptionStatusStateFlow: StateFlow<SubscriptionStatusState> =
authRepository
.userStateFlow
.map { it?.activeAccount?.userId }
.distinctUntilChanged()
.flatMapLatest { userId ->
if (userId == null) {
flowOf(SubscriptionStatusState.NoSubscription)
} else {
fetchSubscriptionStatusFlow()
}
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
initialValue = SubscriptionStatusState.Loading,
)
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: subscriptionStatusStateFlow fetches subscription for every signed-in user, including free users, even though the interface KDoc says fetches happen "lazily when the active account is premium."

Details and fix

The interface contract in PremiumStateManager.kt says:

"Fetches lazily when the active account is premium; emits SubscriptionStatusState.NoSubscription for non-premium accounts and for 404 responses..."

But the implementation only short-circuits on userId == null — any signed-in user (free or premium) triggers fetchSubscriptionStatusFlow(), which hits GET /accounts/subscription. For free users this is an unconditional 404 round-trip on every app launch and every push (since subscriptionRefreshTriggerFlow always re-emits regardless of premium state).

Suggested gate inside flatMapLatest:

.flatMapLatest { userId ->
    val activeAccount = authRepository.userStateFlow.value?.activeAccount
    when {
        userId == null -> flowOf(SubscriptionStatusState.NoSubscription)
        activeAccount?.isPremium != true -> flowOf(SubscriptionStatusState.NoSubscription)
        else -> fetchSubscriptionStatusFlow()
    }
}

You'd still want the push handler to nudge the flow re-evaluate when a data.isPremium == true push arrives for the active user (e.g., by re-keying on a combined (userId, isPremium) flow, or by triggering subscriptionRefreshTriggerFlow only after the user flips premium).

Either align the implementation to the documented "lazy on premium only" behavior, or update the KDoc to reflect that fetches happen for any signed-in user.

Comment on lines +388 to +406
SubscriptionResult.NotFound -> {
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Free(
rate = PLACEHOLDER_TEXT,
checkoutUrl = null,
isAwaitingPremiumStatus = false,
),
dialogState = null,
)
}
viewModelScope.launch {
sendAction(
PlanAction.Internal.PricingResultReceive(
result = billingRepository.getPremiumPlanPricing(),
),
)
}
}
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: When Account.isPremium=true is granted via organization membership (not personal Stripe), getSubscription() returns 404 → NotFound. This handler then switches the user from the initial Premium view to the Free upgrade view, prompting them to "Upgrade to Premium" even though they already have premium via their org. Is the assumption that PM-37465 (gating the Plan entry on personal premium) lands before this PR, preventing org-only premium users from ever reaching this state? If those PRs land out of order or the Plan screen is reached via a path that doesn't honor the personal-premium gate (e.g., modal from a feature gate), org-premium users will see the upgrade pitch.

Comment on lines +220 to +229
mockkStatic("com.bitwarden.network.model.BitwardenErrorKt")
every { throwable.toBitwardenError() } returns bitwardenHttpError
coEvery {
billingService.getSubscription()
} returns throwable.asFailure()

val result = repository.getSubscription()

assertEquals(SubscriptionResult.NotFound, result)
unmockkStatic("com.bitwarden.network.model.BitwardenErrorKt")
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: Move mockkStatic/unmockkStatic to @BeforeEach/@AfterEach to prevent leaked static mocks if an assertion throws.

Details and fix

If an assertion in the body throws before reaching the inline unmockkStatic(...) line, the static mock for BitwardenErrorKt stays installed and leaks into the next test in the suite, which can produce confusing failures. The codebase already follows the @AfterEach pattern for unmockkStatic (e.g., TotpIntentUtilsTest.kt).

@BeforeEach
fun setup() {
    mockkStatic("com.bitwarden.network.model.BitwardenErrorKt")
}

@AfterEach
fun tearDown() {
    unmockkStatic("com.bitwarden.network.model.BitwardenErrorKt")
}

Then drop the inline mockkStatic/unmockkStatic calls from the two new tests.

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 Change Type - Feature Development

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant