mirror of
https://github.com/bitwarden/android.git
synced 2026-11-09 10:34:18 -06:00
PM-39367: bug: When the billing API 404s, the 'Plan' row should be hidden from the UI (#7546)
This commit is contained in:
+28
-12
@@ -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,
|
||||
|
||||
+5
-6
@@ -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
|
||||
|
||||
|
||||
+195
-8
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user