PM-39367: bug: When the billing API 404s, the 'Plan' row should be hidden from the UI (#7546)

This commit is contained in:
David Perez
2026-11-03 18:46:55 +00:00
committed by GitHub
parent fdbf92f8dc
commit dde0e88436
3 changed files with 228 additions and 26 deletions
@@ -193,21 +193,37 @@ class PremiumStateManagerImpl(
)
/**
* Eligibility is keyed on the user holding personal Premium (or being eligible to purchase
* it). Organization-granted Premium does not surface the Plan row, since the user has no
* personal subscription to manage.
* * Free users always see the Plan row so they can purchase Premium, regardless of the
* subscription state.
* * Personal Premium users only see the row once their subscription is available; it is
* hidden while the subscription is loading, when the fetch fails, and when the server
* reports no subscription (404).
* * Organization-only Premium never surfaces the Plan row, since the user has no personal
* subscription to manage.
* * The row is also hidden when there is no active user.
*/
override val isPlanRowEligibleFlow: StateFlow<Boolean> =
authDiskSource
.userStateFlow
.map { userState ->
val profile = userState?.activeAccount?.profile ?: return@map false
val hasPremium = profile.hasPremiumPersonally == true ||
profile.hasPremiumFromOrganization == true
val isPremiumFromSelf = profile.hasPremiumPersonally == true
val isOrgOnlyPremium = hasPremium && !isPremiumFromSelf
!isOrgOnlyPremium
combine(
authDiskSource.userStateFlow,
subscriptionStatusStateFlow,
) { userState, subscriptionStatus ->
val profile = userState?.activeAccount?.profile ?: return@combine false
val isPremiumFromSelf = profile.hasPremiumPersonally == true
val isPremiumFromOrg = profile.hasPremiumFromOrganization == true
val hasPremium = isPremiumFromSelf || isPremiumFromOrg
val isOrgOnlyPremium = isPremiumFromOrg && !isPremiumFromSelf
val hasSubscription = when (subscriptionStatus) {
is SubscriptionStatusState.Available -> true
is SubscriptionStatusState.Error,
SubscriptionStatusState.Loading,
SubscriptionStatusState.NoSubscription,
-> false
}
val hasIneligiblePremium = hasPremium && !hasSubscription
// The Plan Row is hidden if the user has premium but does not have
// a subscription or the premium status was granted by an organization.
!(hasIneligiblePremium || isOrgOnlyPremium)
}
.stateIn(
scope = unconfinedScope,
started = SharingStarted.Eagerly,
@@ -1,7 +1,6 @@
package com.x8bit.bitwarden.ui.platform.feature.settings
import androidx.annotation.DrawableRes
import androidx.compose.material3.Text
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import com.bitwarden.ui.platform.base.BaseViewModel
@@ -216,11 +215,11 @@ data class SettingsState(
val shouldShowUpgradedToPremiumCard: Boolean = !isPreAuth && isUpgradedToPremiumCardEligible
/**
* Whether the plan row should be shown. The row is visible post-authentication when the user
* is eligible per [PremiumStateManager.isPlanRowEligibleFlow] — currently, when the in-app
* upgrade feature is enabled and the user is not relying solely on organization-granted
* Premium — and the account is on a cloud-hosted environment. Self-hosted users manage their
* subscription on the web vault.
* Whether the plan row should be shown.
* The row is visible when all are true:
* * In a post-authentication state.
* * The user is eligible per [PremiumStateManager.isPlanRowEligibleFlow].
* * Not a Self-hosted users as they manage their subscription on the web vault.
*/
private val shouldShowPlanRow: Boolean = !isPreAuth && isPlanRowEligible && !isSelfHosted
@@ -30,6 +30,7 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
@@ -443,19 +444,51 @@ class PremiumStateManagerTest {
}
@Test
fun `isPlanRowEligibleFlow emits true when user is a free user`() = runTest {
fakeAuthDiskSource.userState = userStateJsonWith(account = createAccountJson())
fun `isPlanRowEligibleFlow emits false while the subscription is loading`() = runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
val subscriptionResult = CompletableDeferred<SubscriptionResult>()
coEvery { billingRepository.getSubscription() } coAnswers { subscriptionResult.await() }
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertFalse(awaitItem())
subscriptionResult.complete(
SubscriptionResult.Success(
subscription = createSubscriptionInfo(
status = PremiumSubscriptionStatus.ACTIVE,
),
),
)
assertTrue(awaitItem())
}
}
@Test
fun `isPlanRowEligibleFlow emits true when user is a free user with a subscription`() =
runTest {
fakeAuthDiskSource.userState = userStateJsonWith(account = createAccountJson())
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Success(
subscription = createSubscriptionInfo(status = PremiumSubscriptionStatus.EXPIRED),
)
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertTrue(awaitItem())
}
}
@Test
fun `isPlanRowEligibleFlow emits true for a personal Premium user`() = runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Success(
subscription = createSubscriptionInfo(status = PremiumSubscriptionStatus.ACTIVE),
)
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertTrue(awaitItem())
@@ -463,11 +496,110 @@ class PremiumStateManagerTest {
}
@Test
fun `isPlanRowEligibleFlow emits false for a user with only org-granted Premium`() =
fun `isPlanRowEligibleFlow emits false for a Premium user when the fetch fails`() =
runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Error(error = IllegalStateException("Fail"))
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `isPlanRowEligibleFlow emits true for a free user when the fetch fails`() =
runTest {
fakeAuthDiskSource.userState = userStateJsonWith(account = createAccountJson())
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Error(error = IllegalStateException("Fail"))
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertTrue(awaitItem())
}
}
@Test
fun `isPlanRowEligibleFlow emits false for a Premium user with no subscription`() =
runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
coEvery { billingRepository.getSubscription() } returns SubscriptionResult.NotFound
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `isPlanRowEligibleFlow emits true for a free user with no subscription`() =
runTest {
fakeAuthDiskSource.userState = userStateJsonWith(account = createAccountJson())
coEvery { billingRepository.getSubscription() } returns SubscriptionResult.NotFound
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertTrue(awaitItem())
}
}
@Test
fun `isPlanRowEligibleFlow does not trigger an additional subscription fetch`() = runTest {
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertTrue(awaitItem())
}
coVerify(exactly = 1) { billingRepository.getSubscription() }
}
@Test
fun `isPlanRowEligibleFlow emits false for org-only Premium with a subscription`() =
runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumFromOrganization = true),
)
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Success(
subscription = createSubscriptionInfo(status = PremiumSubscriptionStatus.ACTIVE),
)
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertFalse(awaitItem())
}
}
@Test
fun `isPlanRowEligibleFlow emits true for personal and org Premium with a subscription`() =
runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(
hasPremiumPersonally = true,
hasPremiumFromOrganization = true,
),
)
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Success(
subscription = createSubscriptionInfo(status = PremiumSubscriptionStatus.ACTIVE),
)
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertTrue(awaitItem())
}
}
@Test
fun `isPlanRowEligibleFlow emits false for org-granted Premium with no subscription`() =
runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumFromOrganization = true),
)
coEvery { billingRepository.getSubscription() } returns SubscriptionResult.NotFound
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertFalse(awaitItem())
@@ -477,6 +609,11 @@ class PremiumStateManagerTest {
@Test
fun `isPlanRowEligibleFlow emits false when userState is null`() = runTest {
fakeAuthDiskSource.userState = null
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Success(
subscription = createSubscriptionInfo(status = PremiumSubscriptionStatus.ACTIVE),
)
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertFalse(awaitItem())
@@ -484,20 +621,70 @@ class PremiumStateManagerTest {
}
@Test
fun `isPlanRowEligibleFlow updates as the user gains and loses org-only Premium`() = runTest {
fakeAuthDiskSource.userState = userStateJsonWith(account = createAccountJson())
fun `isPlanRowEligibleFlow updates as the subscription result changes`() = runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Success(
subscription = createSubscriptionInfo(status = PremiumSubscriptionStatus.ACTIVE),
)
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertTrue(awaitItem())
// A getSubscription() call from elsewhere re-emits through the shared flow.
mutableSubscriptionResultFlow.emit(SubscriptionResult.NotFound)
assertFalse(awaitItem())
mutableSubscriptionResultFlow.emit(
SubscriptionResult.Success(
subscription = createSubscriptionInfo(
status = PremiumSubscriptionStatus.ACTIVE,
),
),
)
assertTrue(awaitItem())
}
}
@Test
fun `isPlanRowEligibleFlow updates when the active user changes`() = runTest {
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumPersonally = true),
)
coEvery {
billingRepository.getSubscription()
} returns SubscriptionResult.Success(
subscription = createSubscriptionInfo(status = PremiumSubscriptionStatus.ACTIVE),
) andThen SubscriptionResult.NotFound
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertTrue(awaitItem())
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumFromOrganization = true),
account = createAccountJson(userId = "otherUserId", hasPremiumPersonally = true),
)
assertFalse(awaitItem())
fakeAuthDiskSource.userState = userStateJsonWith(account = createAccountJson())
assertTrue(awaitItem())
}
coVerify(exactly = 2) { billingRepository.getSubscription() }
}
@Test
fun `isPlanRowEligibleFlow updates as a user with no subscription gains and loses Premium`() =
runTest {
fakeAuthDiskSource.userState = userStateJsonWith(account = createAccountJson())
coEvery { billingRepository.getSubscription() } returns SubscriptionResult.NotFound
val manager = createManager()
manager.isPlanRowEligibleFlow.test {
assertTrue(awaitItem())
fakeAuthDiskSource.userState = userStateJsonWith(
account = createAccountJson(hasPremiumFromOrganization = true),
)
assertFalse(awaitItem())
fakeAuthDiskSource.userState = userStateJsonWith(account = createAccountJson())
assertTrue(awaitItem())
}
}
@Test
fun `isUpgradedToPremiumCardEligibleFlow emits false when nothing has been observed`() =
runTest {