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 a01c49e3e46..33588aaeef5 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 @@ -19,7 +19,6 @@ import com.x8bit.bitwarden.data.billing.manager.PremiumStateManager import com.x8bit.bitwarden.data.billing.repository.BillingRepository import com.x8bit.bitwarden.data.billing.repository.model.CheckoutSessionResult import com.x8bit.bitwarden.data.billing.repository.model.CustomerPortalResult -import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence import com.x8bit.bitwarden.data.billing.repository.model.PremiumPlanPricingResult import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo @@ -32,6 +31,10 @@ import com.x8bit.bitwarden.data.platform.manager.model.SpecialCircumstance import com.x8bit.bitwarden.data.platform.repository.EnvironmentRepository import com.x8bit.bitwarden.data.vault.manager.model.SyncVaultDataResult import com.x8bit.bitwarden.data.vault.repository.VaultRepository +import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toBillingAmountText +import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toDiscountMoneyText +import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toPresentMoneyText +import com.x8bit.bitwarden.ui.platform.feature.premium.plan.util.toRequiredMoneyText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -40,7 +43,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable -import java.math.BigDecimal import java.text.NumberFormat import java.time.Clock import java.time.Instant @@ -713,11 +715,11 @@ class PlanViewModel @Inject constructor( return PlanState.ViewState.Premium( status = status, - billingAmountText = seatsCost.toBillingAmountText(cadence), - storageCostText = storageCost.toOptionalMoneyText(), - discountAmountText = discountAmount.toOptionalMoneyText(negative = true), - estimatedTaxText = estimatedTax.toRequiredMoneyText(), - totalText = nextChargeTotal.toBillingAmountText(cadence), + billingAmountText = seatsCost.toBillingAmountText(cadence, currencyFormatter), + storageCostText = storageCost.toPresentMoneyText(currencyFormatter), + discountAmountText = discountAmount.toDiscountMoneyText(currencyFormatter), + estimatedTaxText = estimatedTax.toRequiredMoneyText(currencyFormatter), + totalText = nextChargeTotal.toBillingAmountText(cadence, currencyFormatter), nextChargeTotalText = formattedTotal, nextChargeDateText = formattedDate, cancelAtDateText = formattedCancelAt, @@ -728,36 +730,6 @@ class PlanViewModel @Inject constructor( ) } - private fun BigDecimal.toBillingAmountText(cadence: PlanCadence): Text { - val formatted = currencyFormatter.format(this) - val cadenceRes = when (cadence) { - PlanCadence.ANNUALLY -> BitwardenString.billing_rate_per_year - PlanCadence.MONTHLY -> BitwardenString.billing_rate_per_month - } - return cadenceRes.asText(formatted) - } - - /** - * Formats this amount for an always-rendered line item. Null is coerced to zero so the row - * still shows the locale-formatted `$0.00`, matching the Web convention of always rendering - * the Estimated Tax and Total rows. - */ - private fun BigDecimal?.toRequiredMoneyText(): String = - currencyFormatter.format(this ?: BigDecimal.ZERO) - - /** - * Formats this amount for a hide-when-absent line item. Returns `null` when the amount is - * `null` or non-positive so the caller can omit the row entirely (Discount, Storage). - * When [negative] is true, the formatted value is prefixed with `-` to match the canonical - * Web discount styling. - */ - private fun BigDecimal?.toOptionalMoneyText(negative: Boolean = false): String? = - when { - this == null || this.signum() <= 0 -> null - negative -> "-${currencyFormatter.format(this)}" - else -> currencyFormatter.format(this) - } - private fun Instant.toLocalizedDate(): String = toFormattedDateStyle( dateStyle = FormatStyle.LONG, diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/BigDecimalExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/BigDecimalExtensions.kt new file mode 100644 index 00000000000..06670c75a8e --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/BigDecimalExtensions.kt @@ -0,0 +1,48 @@ +package com.x8bit.bitwarden.ui.platform.feature.premium.plan.util + +import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.util.Text +import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence +import java.math.BigDecimal +import java.text.NumberFormat + +/** + * Formats this amount as a cadence-qualified billing rate (e.g. "$10.00 per year"), using + * [currencyFormatter] for the locale-aware currency value. + */ +fun BigDecimal.toBillingAmountText( + cadence: PlanCadence, + currencyFormatter: NumberFormat, +): Text { + val formatted = currencyFormatter.format(this) + val cadenceRes = when (cadence) { + PlanCadence.ANNUALLY -> BitwardenString.billing_rate_per_year + PlanCadence.MONTHLY -> BitwardenString.billing_rate_per_month + } + return cadenceRes.asText(formatted) +} + +/** + * Formats this amount for an always-rendered line item. Null is coerced to zero so the row still + * shows the locale-formatted `$0.00`, as the Estimated Tax and Total rows always render. + */ +fun BigDecimal?.toRequiredMoneyText(currencyFormatter: NumberFormat): String = + currencyFormatter.format(this ?: BigDecimal.ZERO) + +/** + * Formats this amount for a render-when-present line item (Storage), rendering `$0.00` for a + * free line and returning `null` only when the amount is `null`. + */ +fun BigDecimal?.toPresentMoneyText(currencyFormatter: NumberFormat): String? = + this?.let { currencyFormatter.format(it) } + +/** + * Formats this amount as a negative money string for the Discount line item (e.g. "-$5.00"), + * returning `null` when the amount is `null` or non-positive so the row is omitted when there is + * no discount. + */ +fun BigDecimal?.toDiscountMoneyText(currencyFormatter: NumberFormat): String? = + this + ?.takeIf { it.signum() > 0 } + ?.let { "\u2212${currencyFormatter.format(it)}" } 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 f41499cc8f4..779d83685c3 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 @@ -1333,7 +1333,7 @@ class PlanViewModelTest : BaseViewModelTest() { } @Test - fun `SubscriptionResultReceive Success with zero line items hides discount and storage rows`() = + fun `SubscriptionResultReceive Success renders zero storage row but hides zero discount`() = runTest { markUserPremium() @@ -1351,7 +1351,7 @@ class PlanViewModelTest : BaseViewModelTest() { assertEquals( DEFAULT_PREMIUM_LOADED_STATE.copy( viewState = DEFAULT_PREMIUM_ACTIVE_VIEW_STATE.copy( - storageCostText = null, + storageCostText = "$0.00", discountAmountText = null, estimatedTaxText = "$0.00", ), @@ -1904,7 +1904,7 @@ private val DEFAULT_PREMIUM_ACTIVE_VIEW_STATE = PlanState.ViewState.Premium( status = PremiumSubscriptionStatus.ACTIVE, billingAmountText = BitwardenString.billing_rate_per_year.asText("$19.80"), storageCostText = "$24.00", - discountAmountText = "-$2.10", + discountAmountText = "\u2212$2.10", estimatedTaxText = "$3.85", totalText = BitwardenString.billing_rate_per_year.asText("$45.55"), nextChargeTotalText = "$45.55", diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/BigDecimalExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/BigDecimalExtensionsTest.kt new file mode 100644 index 00000000000..b7b2c97f073 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/ui/platform/feature/premium/plan/util/BigDecimalExtensionsTest.kt @@ -0,0 +1,65 @@ +package com.x8bit.bitwarden.ui.platform.feature.premium.plan.util + +import com.bitwarden.ui.platform.resource.BitwardenString +import com.bitwarden.ui.util.asText +import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.math.BigDecimal +import java.text.NumberFormat +import java.util.Locale + +class BigDecimalExtensionsTest { + private val currencyFormatter: NumberFormat = NumberFormat.getCurrencyInstance(Locale.US) + + @Test + fun `toBillingAmountText returns the per-year rate for an annual cadence`() { + assertEquals( + BitwardenString.billing_rate_per_year.asText("$10.00"), + BigDecimal("10").toBillingAmountText(PlanCadence.ANNUALLY, currencyFormatter), + ) + } + + @Test + fun `toBillingAmountText returns the per-month rate for a monthly cadence`() { + assertEquals( + BitwardenString.billing_rate_per_month.asText("$10.00"), + BigDecimal("10").toBillingAmountText(PlanCadence.MONTHLY, currencyFormatter), + ) + } + + @Test + fun `toRequiredMoneyText coerces null to a formatted zero`() { + assertEquals("$0.00", null.toRequiredMoneyText(currencyFormatter)) + } + + @Test + fun `toRequiredMoneyText formats zero and positive amounts`() { + assertEquals("$0.00", BigDecimal.ZERO.toRequiredMoneyText(currencyFormatter)) + assertEquals("$10.00", BigDecimal("10").toRequiredMoneyText(currencyFormatter)) + } + + @Test + fun `toPresentMoneyText returns null only when the amount is null`() { + assertNull(null.toPresentMoneyText(currencyFormatter)) + } + + @Test + fun `toPresentMoneyText renders zero and positive amounts`() { + assertEquals("$0.00", BigDecimal.ZERO.toPresentMoneyText(currencyFormatter)) + assertEquals("$10.00", BigDecimal("10").toPresentMoneyText(currencyFormatter)) + } + + @Test + fun `toDiscountMoneyText returns null when the amount is null or non-positive`() { + assertNull(null.toDiscountMoneyText(currencyFormatter)) + assertNull(BigDecimal.ZERO.toDiscountMoneyText(currencyFormatter)) + assertNull(BigDecimal("-5").toDiscountMoneyText(currencyFormatter)) + } + + @Test + fun `toDiscountMoneyText formats a positive amount as a negative money string`() { + assertEquals("\u2212$5.00", BigDecimal("5").toDiscountMoneyText(currencyFormatter)) + } +}