diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerImpl.kt index 2448dafb52..18486a924c 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerImpl.kt @@ -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 = - 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, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt index 445305bdaa..32e6066fc4 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/settings/SettingsViewModel.kt @@ -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 diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerTest.kt index 8121fcb9e2..9d6e755719 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/manager/PremiumStateManagerTest.kt @@ -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() + 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 {