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

This commit is contained in:
aj-rosado authored and GitHub committed 2026-09-03 12:17:48 +00:00
1 parent 9b9f41ffe4
commit 7e14fcf2f7
2 files changed
+127 -84

No files matched your search

@@ -466,24 +466,13 @@ class PlanViewModel @Inject constructor(
action: PlanAction.Internal.SubscriptionStatusUpdateReceive,
) {
val status = (action.state as? SubscriptionStatusState.Available)?.status ?: return
if (!status.isPremiumViewEligible()) return
onFreeCloudContent { freeState ->
if (freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
mutableStateFlow.update {
it.copy(
viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
)
}
viewModelScope.launch {
sendAction(
PlanAction.Internal.SubscriptionResultReceive(
result = billingRepository.getSubscription(),
),
)
}
// 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()) {
return
}
// Only a confirmed-premium signal celebrates; a trouble status promotes silently.
promoteFreeCloudToPremiumView(isConfirmedPremium = isPremium)
}
// endregion Premium user handlers
@@ -494,17 +483,15 @@ class PlanViewModel @Inject constructor(
action: PlanAction.Internal.UserStateUpdateReceive,
) {
val isPremium = action.userState?.activeAccount?.isPremium == true
val wasShowingPremiumView = state.showsPremiumView
mutableStateFlow.update {
it.copy(
showsPremiumView = isPremium ||
premiumStateManager.subscriptionStatusStateFlow.value.isPremiumViewEligible(),
)
}
onFreeCloudContent { freeState ->
if (!freeState.isAwaitingPremiumStatus) return@onFreeCloudContent
if (isPremium) {
onPremiumUpgradeSuccess()
}
if (isPremium && !wasShowingPremiumView) {
promoteFreeCloudToPremiumView(isConfirmedPremium = true)
}
}
@@ -615,7 +602,19 @@ class PlanViewModel @Inject constructor(
}
}
private fun onPremiumUpgradeSuccess() {
// 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) {
onFreeCloudContent { freeState ->
if (isConfirmedPremium && freeState.isAwaitingPremiumStatus) {
onPremiumUpgradeSuccess()
} else {
promoteToPremiumView()
}
}
}
private fun promoteToPremiumView() {
onFreeCloudContent {
mutableStateFlow.update {
it.copy(
@@ -632,9 +631,10 @@ class PlanViewModel @Inject constructor(
)
}
}
// The Upgraded to Premium route uses `launchSingleTop = true` so a duplicate event is a
// no-op for the user. The event itself is harmless to re-emit; the state mutation above
// is what's guarded by `onFreeCloudContent`.
}
private fun onPremiumUpgradeSuccess() {
promoteToPremiumView()
sendEvent(PlanEvent.NavigateToUpgradedToPremium)
}
@@ -435,37 +435,6 @@ class PlanViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `premium status flip should show snackbar when in WaitingForPayment`() =
runTest {
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.WaitingForPayment,
),
pricingResult = null,
)
viewModel.eventFlow.test {
// Simulate premium status flip while WaitingForPayment.
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(
DEFAULT_ACCOUNT.copy(isPremium = true),
),
)
assertEquals(
PlanEvent.NavigateToUpgradedToPremium,
awaitItem(),
)
}
}
@Test
fun `premium flip via canceled special circumstance should navigate to UpgradedToPremium`() =
runTest {
@@ -610,24 +579,13 @@ class PlanViewModelTest : BaseViewModelTest() {
}
@Test
fun `UserStateUpdateReceive premium during Loading should navigate to UpgradedToPremium`() =
fun `premium status flip should promote off Free view when not awaiting checkout`() =
runTest {
val viewModel = createViewModel(
initialState = DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.Loading(
message = BitwardenString.confirming_your_upgrade.asText(),
),
),
pricingResult = null,
)
val viewModel = createViewModel(subscriptionResult = SUBSCRIPTION_SUCCESS_ACTIVE)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
assertEquals(DEFAULT_FREE_STATE, stateFlow.awaitItem())
viewModel.eventFlow.test {
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(
DEFAULT_ACCOUNT.copy(isPremium = true),
@@ -635,25 +593,73 @@ class PlanViewModelTest : BaseViewModelTest() {
)
assertEquals(
PlanEvent.NavigateToUpgradedToPremium,
awaitItem(),
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
showsPremiumView = true,
),
stateFlow.awaitItem(),
)
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, stateFlow.awaitItem())
eventFlow.expectNoEvents()
}
}
@Suppress("MaxLineLength")
@Test
fun `premium status flip should not emit event when not in WaitingForPayment`() =
fun `SubscriptionStatusUpdateReceive with trouble status while awaiting checkout should promote without celebration event`() =
runTest {
val viewModel = createViewModel()
viewModel.eventFlow.test {
// Flip premium while in Content state.
mutableUserStateFlow.value = DEFAULT_USER_STATE.copy(
accounts = listOf(
DEFAULT_ACCOUNT.copy(isPremium = true),
val initialState = DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Content.Free.Cloud(
rate = "$1.67",
checkoutUrl = null,
isAwaitingPremiumStatus = true,
isPremiumUpgradePending = false,
),
dialogState = PlanState.DialogState.WaitingForPayment,
)
val viewModel = createViewModel(
initialState = initialState,
pricingResult = null,
subscriptionResult = SubscriptionResult.Success(
subscription = SUBSCRIPTION_INFO_ACTIVE.copy(
status = PremiumSubscriptionStatus.CANCELED,
canceledDate = Instant.parse("2026-04-21T00:00:00Z"),
),
),
)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
assertEquals(initialState, stateFlow.awaitItem())
mutableSubscriptionStatusStateFlow.value = SubscriptionStatusState.Available(
status = PremiumSubscriptionStatus.CANCELED,
)
expectNoEvents()
assertEquals(
initialState.copy(
viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
),
stateFlow.awaitItem(),
)
assertEquals(
initialState.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.CANCELED,
canceledDateText = "April 21, 2026",
showCancelButton = false,
),
dialogState = null,
),
stateFlow.awaitItem(),
)
eventFlow.expectNoEvents()
}
}
@@ -976,6 +982,43 @@ class PlanViewModelTest : BaseViewModelTest() {
}
}
@Suppress("MaxLineLength")
@Test
fun `SubscriptionStatusUpdateReceive with ACTIVE status promotes premium account off Free view`() =
runTest {
markUserPremium()
val viewModel = createViewModel(subscriptionResult = SubscriptionResult.NotFound)
viewModel.stateEventFlow(backgroundScope) { stateFlow, eventFlow ->
// The NotFound fallback leaves the account premium while the view is still
// showing Free.Cloud content.
assertEquals(
DEFAULT_FREE_STATE.copy(showsPremiumView = true),
stateFlow.awaitItem(),
)
coEvery {
mockBillingRepository.getSubscription()
} returns SUBSCRIPTION_SUCCESS_ACTIVE
mutableSubscriptionStatusStateFlow.value = SubscriptionStatusState.Available(
status = PremiumSubscriptionStatus.ACTIVE,
)
assertEquals(
DEFAULT_FREE_STATE.copy(
viewState = PlanState.ViewState.Loading(
message = BitwardenString.loading_subscription.asText(),
),
showsPremiumView = true,
),
stateFlow.awaitItem(),
)
assertEquals(DEFAULT_PREMIUM_LOADED_STATE, stateFlow.awaitItem())
eventFlow.expectNoEvents()
}
}
@Test
fun `SubscriptionResultReceive NotFound falls back to Free view and fetches pricing`() =
runTest {