Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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)}" }
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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",
),
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading