[PM-37181] feat: Surface Expired subscription substate (#6982)

This commit is contained in:
Patrick Honkonen authored and GitHub committed 2026-05-28 20:37:50 +00:00
1 parent 8661dfaf2f
commit 230c8f769d
11 files changed
+255 -74

No files matched your search

@@ -404,6 +404,7 @@ private fun SubscriptionStatusState.isInTroubleState(): Boolean =
this is SubscriptionStatusState.Available &&
when (this.status) {
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
@@ -7,6 +7,13 @@ enum class PremiumSubscriptionStatus {
ACTIVE,
CANCELED,
/**
* The subscription's initial payment never succeeded and Stripe voided the invoice, so
* the subscription never became active. Distinct from [CANCELED], which describes a
* subscription that was previously active.
*/
EXPIRED,
/**
* The subscription is scheduled to cancel at a future date but is still active until then.
*/
@@ -64,10 +64,9 @@ private fun BitwardenSubscriptionResponseJson.toPremiumSubscriptionStatus():
}
}
SubscriptionStatusJson.CANCELED,
SubscriptionStatusJson.INCOMPLETE_EXPIRED,
-> PremiumSubscriptionStatus.CANCELED
SubscriptionStatusJson.CANCELED -> PremiumSubscriptionStatus.CANCELED
SubscriptionStatusJson.INCOMPLETE_EXPIRED -> PremiumSubscriptionStatus.EXPIRED
SubscriptionStatusJson.INCOMPLETE,
SubscriptionStatusJson.UNPAID,
-> PremiumSubscriptionStatus.UPDATE_PAYMENT
@@ -4,6 +4,7 @@ package com.x8bit.bitwarden.ui.platform.feature.premium.plan
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -72,6 +73,7 @@ import com.x8bit.bitwarden.ui.platform.composition.LocalAuthTabLaunchers
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.handlers.PlanHandlers
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.badgeColors
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.labelRes
import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.showsFeatureList
import com.x8bit.bitwarden.ui.platform.model.AuthTabLaunchers
private const val PLACEHOLDER_TEXT: String = "--"
@@ -393,25 +395,30 @@ private fun PremiumFeaturesCard(
.standardHorizontalMargin(),
)
BitwardenHorizontalDivider()
PremiumFeatureRows()
}
}
val features = listOf(
BitwardenString.built_in_authenticator,
BitwardenString.emergency_access,
BitwardenString.secure_file_storage,
BitwardenString.breach_monitoring,
@Composable
private fun ColumnScope.PremiumFeatureRows() {
BitwardenHorizontalDivider()
val features = listOf(
BitwardenString.built_in_authenticator,
BitwardenString.emergency_access,
BitwardenString.secure_file_storage,
BitwardenString.breach_monitoring,
)
features.forEachIndexed { index, featureStringRes ->
BitwardenContentBlock(
data = ContentBlockData(
headerText = stringResource(id = featureStringRes),
iconVectorResource = BitwardenDrawable.ic_check_mark,
),
headerTextStyle = BitwardenTheme.typography.titleMedium,
showDivider = index != features.lastIndex,
modifier = Modifier.padding(vertical = 8.dp),
)
features.forEachIndexed { index, featureStringRes ->
BitwardenContentBlock(
data = ContentBlockData(
headerText = stringResource(id = featureStringRes),
iconVectorResource = BitwardenDrawable.ic_check_mark,
),
headerTextStyle = BitwardenTheme.typography.titleMedium,
showDivider = index != features.lastIndex,
modifier = Modifier.padding(vertical = 8.dp),
)
}
}
}
@@ -562,21 +569,18 @@ private fun PremiumContent(
}
}
@Suppress("LongMethod")
@Composable
private fun SubscriptionCard(
viewState: PlanState.ViewState.Premium,
modifier: Modifier = Modifier,
) {
val rowModifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
Column(
modifier = modifier
.fillMaxWidth()
.cardStyle(
cardStyle = CardStyle.Full,
// Override bottom padding; the final row owns its own spacing.
// Override bottom padding; the final row (line item or feature) owns its
// own spacing.
paddingBottom = 0.dp,
),
) {
@@ -593,55 +597,70 @@ private fun SubscriptionCard(
.standardHorizontalMargin(),
)
BitwardenHorizontalDivider()
SubscriptionLineItem(
label = stringResource(id = BitwardenString.billing_amount),
value = viewState.billingAmountText(),
testTag = "BillingAmountRow",
modifier = rowModifier,
)
viewState.storageCostText?.let { storageCostText ->
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.storage_cost),
value = storageCostText,
testTag = "StorageCostRow",
modifier = rowModifier,
)
if (viewState.status?.showsFeatureList() == true) {
PremiumFeatureRows()
} else {
SubscriptionLineItems(viewState = viewState)
}
}
}
viewState.discountAmountText?.let { discountAmountText ->
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.discount),
value = discountAmountText,
testTag = "DiscountRow",
modifier = rowModifier,
valueColor = BitwardenTheme.colorScheme.statusBadge.success.text,
)
}
@Composable
private fun SubscriptionLineItems(
viewState: PlanState.ViewState.Premium,
) {
val rowModifier = Modifier
.fillMaxWidth()
.standardHorizontalMargin()
BitwardenHorizontalDivider()
SubscriptionLineItem(
label = stringResource(id = BitwardenString.billing_amount),
value = viewState.billingAmountText(),
testTag = "BillingAmountRow",
modifier = rowModifier,
)
viewState.storageCostText?.let { storageCostText ->
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.estimated_tax),
value = viewState.estimatedTaxText,
testTag = "EstimatedTaxRow",
label = stringResource(id = BitwardenString.storage_cost),
value = storageCostText,
testTag = "StorageCostRow",
modifier = rowModifier,
)
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.total),
value = viewState.totalText(),
testTag = "TotalRow",
modifier = rowModifier,
labelStyle = BitwardenTheme.typography.bodyLargeEmphasis,
)
}
viewState.discountAmountText?.let { discountAmountText ->
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.discount),
value = discountAmountText,
testTag = "DiscountRow",
modifier = rowModifier,
valueColor = BitwardenTheme.colorScheme.statusBadge.success.text,
)
}
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.estimated_tax),
value = viewState.estimatedTaxText,
testTag = "EstimatedTaxRow",
modifier = rowModifier,
)
BitwardenHorizontalDivider(modifier = Modifier.padding(start = 16.dp))
SubscriptionLineItem(
label = stringResource(id = BitwardenString.total),
value = viewState.totalText(),
testTag = "TotalRow",
modifier = rowModifier,
labelStyle = BitwardenTheme.typography.bodyLargeEmphasis,
)
}
@Composable
@@ -692,7 +711,7 @@ private fun SubscriptionHeader(
}
}
@Suppress("CyclomaticComplexMethod")
@Suppress("CyclomaticComplexMethod", "LongMethod")
@Composable
private fun subscriptionDescriptionText(
status: PremiumSubscriptionStatus?,
@@ -759,6 +778,13 @@ private fun subscriptionDescriptionText(
stringResource(id = BitwardenString.subscription_paused_description),
)
PremiumSubscriptionStatus.EXPIRED -> annotatedStringResource(
id = BitwardenString.subscription_expired_description,
args = arrayOf(suspensionDateText ?: PLACEHOLDER_TEXT),
style = baseStyle,
emphasisHighlightStyle = emphasisStyle,
)
null -> null
}
}
@@ -1175,6 +1175,7 @@ sealed class PlanAction {
*/
private fun PremiumSubscriptionStatus.canBeCanceled(): Boolean = when (this) {
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
-> false
@@ -1193,6 +1194,7 @@ private fun PremiumSubscriptionStatus.canBeCanceled(): Boolean = when (this) {
*/
private fun PremiumSubscriptionStatus.isPremiumViewEligible(): Boolean = when (this) {
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
@@ -14,6 +14,7 @@ import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStat
fun PremiumSubscriptionStatus.labelRes(): Int = when (this) {
PremiumSubscriptionStatus.ACTIVE -> BitwardenString.subscription_status_active
PremiumSubscriptionStatus.CANCELED -> BitwardenString.subscription_status_canceled
PremiumSubscriptionStatus.EXPIRED -> BitwardenString.subscription_status_expired
PremiumSubscriptionStatus.PENDING_CANCELLATION -> {
BitwardenString.subscription_status_pending_cancellation
}
@@ -23,6 +24,24 @@ fun PremiumSubscriptionStatus.labelRes(): Int = when (this) {
PremiumSubscriptionStatus.UPDATE_PAYMENT -> BitwardenString.subscription_status_update_payment
}
/**
* Returns `true` when the Premium plan card should replace its billing line items with the
* premium feature list. Reserved for terminal states where line items carry no actionable
* information and the user's path forward is to resubscribe.
*/
fun PremiumSubscriptionStatus.showsFeatureList(): Boolean = when (this) {
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
-> true
PremiumSubscriptionStatus.ACTIVE,
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
-> false
}
/**
* Returns the [BitwardenColorScheme.StatusBadgeVariantColors] used to render the badge for a
* [PremiumSubscriptionStatus].
@@ -31,7 +50,10 @@ fun PremiumSubscriptionStatus.labelRes(): Int = when (this) {
fun PremiumSubscriptionStatus.badgeColors(): BitwardenColorScheme.StatusBadgeVariantColors =
when (this) {
PremiumSubscriptionStatus.ACTIVE -> BitwardenTheme.colorScheme.statusBadge.success
PremiumSubscriptionStatus.CANCELED -> BitwardenTheme.colorScheme.statusBadge.error
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
-> BitwardenTheme.colorScheme.statusBadge.error
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.PENDING_CANCELLATION,
@@ -1035,6 +1035,7 @@ class PremiumStateManagerTest {
fun `banner eligible when account is premium but status is in a trouble state`() = runTest {
listOf(
PremiumSubscriptionStatus.CANCELED,
PremiumSubscriptionStatus.EXPIRED,
PremiumSubscriptionStatus.PAST_DUE,
PremiumSubscriptionStatus.PAUSED,
PremiumSubscriptionStatus.UPDATE_PAYMENT,
@@ -29,16 +29,18 @@ class BitwardenSubscriptionResponseJsonExtensionsTest {
@Test
fun `toSubscriptionInfo maps CANCELED to CANCELED`() {
val info = buildResponse(status = SubscriptionStatusJson.CANCELED).toSubscriptionInfo()
val info = buildResponse(
status = SubscriptionStatusJson.CANCELED,
).toSubscriptionInfo()
assertEquals(PremiumSubscriptionStatus.CANCELED, info.status)
}
@Test
fun `toSubscriptionInfo maps INCOMPLETE_EXPIRED to CANCELED`() {
fun `toSubscriptionInfo maps INCOMPLETE_EXPIRED to EXPIRED`() {
val info = buildResponse(
status = SubscriptionStatusJson.INCOMPLETE_EXPIRED,
).toSubscriptionInfo()
assertEquals(PremiumSubscriptionStatus.CANCELED, info.status)
assertEquals(PremiumSubscriptionStatus.EXPIRED, info.status)
}
@Test
@@ -503,6 +503,20 @@ class PlanScreenTest : BitwardenComposeTest() {
.assertIsDisplayed()
}
@Test
fun `status badge should render with Expired label for EXPIRED status`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.EXPIRED,
),
)
}
composeTestRule
.onNodeWithText("Expired")
.assertIsDisplayed()
}
@Test
fun `status badge should render with Update payment label for UPDATE_PAYMENT status`() {
mutableStateFlow.update {
@@ -568,6 +582,7 @@ class PlanScreenTest : BitwardenComposeTest() {
}
composeTestRule.onNodeWithText("Active").assertDoesNotExist()
composeTestRule.onNodeWithText("Canceled").assertDoesNotExist()
composeTestRule.onNodeWithText("Expired").assertDoesNotExist()
composeTestRule.onNodeWithText("Past due").assertDoesNotExist()
composeTestRule.onNodeWithText("Paused").assertDoesNotExist()
composeTestRule.onNodeWithText("Pending cancellation").assertDoesNotExist()
@@ -614,7 +629,7 @@ class PlanScreenTest : BitwardenComposeTest() {
composeTestRule
.onNodeWithText(
"Your subscription was canceled on April 21, 2026. " +
"Resubscribe to continue using premium features.",
"Resubscribe to continue using Premium features.",
)
.assertTextRangeHasBoldSpan(boldSubstring = "April 21, 2026")
}
@@ -633,7 +648,7 @@ class PlanScreenTest : BitwardenComposeTest() {
composeTestRule
.onNodeWithText(
"Your subscription was canceled on May 15, 2026. " +
"Resubscribe to continue using premium features.",
"Resubscribe to continue using Premium features.",
)
.assertTextRangeHasBoldSpan(boldSubstring = "May 15, 2026")
}
@@ -709,6 +724,24 @@ class PlanScreenTest : BitwardenComposeTest() {
.assertTextRangeHasBoldSpan(boldSubstring = "May 1, 2026")
}
@Test
fun `EXPIRED description should bold the suspension date`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.EXPIRED,
suspensionDateText = "April 21, 2026",
),
)
}
composeTestRule
.onNodeWithText(
"Your subscription expired on April 21, 2026. " +
"Resubscribe to continue using Premium features.",
)
.assertTextRangeHasBoldSpan(boldSubstring = "April 21, 2026")
}
// endregion Premium content rendering
// region Line items
@@ -842,6 +875,66 @@ class PlanScreenTest : BitwardenComposeTest() {
// endregion Line items
// region Terminal-state feature list
@Test
fun `CANCELED status should render feature list instead of line items`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.CANCELED,
),
)
}
composeTestRule.onNodeWithText("Built-in authenticator").assertIsDisplayed()
composeTestRule.onNodeWithText("Emergency access").assertIsDisplayed()
composeTestRule.onNodeWithText("Secure file storage").assertIsDisplayed()
composeTestRule.onNodeWithText("Breach monitoring").assertIsDisplayed()
composeTestRule.onNodeWithTag("BillingAmountRow").assertDoesNotExist()
composeTestRule.onNodeWithTag("StorageCostRow").assertDoesNotExist()
composeTestRule.onNodeWithTag("DiscountRow").assertDoesNotExist()
composeTestRule.onNodeWithTag("EstimatedTaxRow").assertDoesNotExist()
composeTestRule.onNodeWithTag("TotalRow").assertDoesNotExist()
}
@Test
fun `EXPIRED status should render feature list instead of line items`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.EXPIRED,
),
)
}
composeTestRule.onNodeWithText("Built-in authenticator").assertIsDisplayed()
composeTestRule.onNodeWithText("Emergency access").assertIsDisplayed()
composeTestRule.onNodeWithText("Secure file storage").assertIsDisplayed()
composeTestRule.onNodeWithText("Breach monitoring").assertIsDisplayed()
composeTestRule.onNodeWithTag("BillingAmountRow").assertDoesNotExist()
composeTestRule.onNodeWithTag("StorageCostRow").assertDoesNotExist()
composeTestRule.onNodeWithTag("DiscountRow").assertDoesNotExist()
composeTestRule.onNodeWithTag("EstimatedTaxRow").assertDoesNotExist()
composeTestRule.onNodeWithTag("TotalRow").assertDoesNotExist()
}
@Test
fun `UPDATE_PAYMENT status should keep line items and not render feature list`() {
mutableStateFlow.update {
it.copy(
viewState = DEFAULT_PREMIUM_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.UPDATE_PAYMENT,
),
)
}
composeTestRule.onNodeWithTag("BillingAmountRow").assertExists()
composeTestRule.onNodeWithTag("EstimatedTaxRow").assertExists()
composeTestRule.onNodeWithTag("TotalRow").assertExists()
composeTestRule.onNodeWithText("Built-in authenticator").assertDoesNotExist()
composeTestRule.onNodeWithText("Breach monitoring").assertDoesNotExist()
}
// endregion Terminal-state feature list
// region Action buttons
@Test
@@ -1221,6 +1221,32 @@ class PlanViewModelTest : BaseViewModelTest() {
}
}
@Test
fun `SubscriptionResultReceive Success with Expired status should hide cancel button`() =
runTest {
markUserPremium()
val viewModel = createViewModel(
subscriptionResult = SubscriptionResult.Success(
subscription = SUBSCRIPTION_INFO_ACTIVE.copy(
status = PremiumSubscriptionStatus.EXPIRED,
),
),
)
viewModel.stateFlow.test {
assertEquals(
DEFAULT_PREMIUM_LOADED_STATE.copy(
viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy(
status = PremiumSubscriptionStatus.EXPIRED,
showCancelButton = false,
),
),
awaitItem(),
)
}
}
@Test
fun `SubscriptionResultReceive Success with Monthly cadence formats per-month rate`() =
runTest {
+3 -1
View File
@@ -1303,19 +1303,21 @@ Do you want to switch to this account?</string>
<string name="cancel_premium">Cancel Premium</string>
<string name="subscription_status_active">Active</string>
<string name="subscription_status_canceled">Canceled</string>
<string name="subscription_status_expired">Expired</string>
<string name="subscription_status_past_due">Past due</string>
<string name="subscription_status_paused">Paused</string>
<string name="subscription_status_update_payment">Update payment</string>
<string name="subscription_status_pending_cancellation">Pending cancellation</string>
<string name="subscription_pending_cancellation_description">Your subscription is scheduled to cancel on <annotation emphasis="bold"><annotation arg="0">%1$s</annotation></annotation>. You can reinstate it anytime before then.</string>
<string name="premium_next_charge_summary">Your next charge is for <annotation emphasis="bold"><annotation arg="0">%1$s</annotation></annotation> USD due on <annotation emphasis="bold"><annotation arg="1">%2$s</annotation></annotation>.</string>
<string name="subscription_canceled_description">Your subscription was canceled on <annotation emphasis="bold"><annotation arg="0">%1$s</annotation></annotation>. Resubscribe to continue using premium features.</string>
<string name="subscription_canceled_description">Your subscription was canceled on <annotation emphasis="bold"><annotation arg="0">%1$s</annotation></annotation>. Resubscribe to continue using Premium features.</string>
<string name="subscription_update_payment_description">We couldn’t process your payment. Update your payment before your subscription ends on <annotation emphasis="bold"><annotation arg="0">%1$s</annotation></annotation>.</string>
<plurals name="subscription_past_due_description">
<item quantity="one">You have a grace period of <annotation arg="0">%1$d</annotation> day from your subscription expiration date. Please resolve the past due amount by <annotation emphasis="bold"><annotation arg="1">%2$s</annotation></annotation>.</item>
<item quantity="other">You have a grace period of <annotation arg="0">%1$d</annotation> days from your subscription expiration date. Please resolve the past due amount by <annotation emphasis="bold"><annotation arg="1">%2$s</annotation></annotation>.</item>
</plurals>
<string name="subscription_paused_description">Your subscription is paused. Resume to continue using premium features.</string>
<string name="subscription_expired_description">Your subscription expired on <annotation emphasis="bold"><annotation arg="0">%1$s</annotation></annotation>. Resubscribe to continue using Premium features.</string>
<string name="loading_subscription">Loading subscription…</string>
<string name="loading_portal">Loading portal…</string>
<string name="portal_error">Something went wrong</string>