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 9dee16494a..f49a40679c 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
@@ -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,
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PremiumSubscriptionStatus.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PremiumSubscriptionStatus.kt
index 4891ba56c0..98794a9a16 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PremiumSubscriptionStatus.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PremiumSubscriptionStatus.kt
@@ -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.
*/
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensions.kt
index 769c5f356a..05ec7952e5 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensions.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensions.kt
@@ -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
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt
index a0bdede28f..616c80f9d8 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreen.kt
@@ -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
}
}
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt
index 7c505027ce..e64ca73890 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModel.kt
@@ -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,
diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/PremiumSubscriptionStatusExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/PremiumSubscriptionStatusExtensions.kt
index 82731a71de..38f4ac5944 100644
--- a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/PremiumSubscriptionStatusExtensions.kt
+++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/PremiumSubscriptionStatusExtensions.kt
@@ -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,
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 271b88163e..d9f57b0ad4 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
@@ -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,
diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensionsTest.kt
index 7d25602a2a..2e0f38875e 100644
--- a/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensionsTest.kt
+++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensionsTest.kt
@@ -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
diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt
index 0f17108f45..22f2eb9dc3 100644
--- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt
+++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanScreenTest.kt
@@ -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
diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt
index 3fbc1ca54f..27187677d4 100644
--- a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt
+++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/PlanViewModelTest.kt
@@ -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 {
diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml
index 787c71699a..19f8d87b85 100644
--- a/ui/src/main/res/values/strings.xml
+++ b/ui/src/main/res/values/strings.xml
@@ -1303,19 +1303,21 @@ Do you want to switch to this account?
Cancel Premium
Active
Canceled
+ Expired
Past due
Paused
Update payment
Pending cancellation
Your subscription is scheduled to cancel on %1$s. You can reinstate it anytime before then.
Your next charge is for %1$s USD due on %2$s.
- Your subscription was canceled on %1$s. Resubscribe to continue using premium features.
+ Your subscription was canceled on %1$s. Resubscribe to continue using Premium features.
We couldn’t process your payment. Update your payment before your subscription ends on %1$s.
- You have a grace period of %1$d day from your subscription expiration date. Please resolve the past due amount by %2$s.
- You have a grace period of %1$d days from your subscription expiration date. Please resolve the past due amount by %2$s.
Your subscription is paused. Resume to continue using premium features.
+ Your subscription expired on %1$s. Resubscribe to continue using Premium features.
Loading subscription…
Loading portal…
Something went wrong