[PM-35454] feat: Add subscription API, domain models, and status badge component (#6818)

This commit is contained in:
Patrick Honkonen
2026-04-27 16:42:18 -04:00
committed by GitHub
parent bd6be6b851
commit 2b4ca430f1
22 changed files with 1455 additions and 0 deletions

View File

@@ -3,6 +3,7 @@ package com.x8bit.bitwarden.data.billing.repository
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.PremiumPlanPricingResult
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
import kotlinx.coroutines.flow.StateFlow
/**
@@ -29,4 +30,9 @@ interface BillingRepository {
* Retrieves the premium plan pricing information.
*/
suspend fun getPremiumPlanPricing(): PremiumPlanPricingResult
/**
* Fetches the current user's premium subscription details.
*/
suspend fun getSubscription(): SubscriptionResult
}

View File

@@ -5,6 +5,8 @@ import com.x8bit.bitwarden.data.billing.manager.PlayBillingManager
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.PremiumPlanPricingResult
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
import com.x8bit.bitwarden.data.billing.repository.util.toSubscriptionInfo
import kotlinx.coroutines.flow.StateFlow
/**
@@ -47,4 +49,16 @@ class BillingRepositoryImpl(
PremiumPlanPricingResult.Error(error = it)
},
)
override suspend fun getSubscription(): SubscriptionResult =
billingService
.getSubscription()
.fold(
onSuccess = {
SubscriptionResult.Success(
subscription = it.toSubscriptionInfo(),
)
},
onFailure = { SubscriptionResult.Error(error = it) },
)
}

View File

@@ -0,0 +1,9 @@
package com.x8bit.bitwarden.data.billing.repository.model
/**
* The billing cadence of a premium subscription.
*/
enum class PlanCadence {
ANNUALLY,
MONTHLY,
}

View File

@@ -0,0 +1,12 @@
package com.x8bit.bitwarden.data.billing.repository.model
/**
* Represents the UI-facing subscription status for premium users.
*/
enum class PremiumSubscriptionStatus {
ACTIVE,
CANCELED,
OVERDUE_PAYMENT,
PAST_DUE,
PAUSED,
}

View File

@@ -0,0 +1,36 @@
package com.x8bit.bitwarden.data.billing.repository.model
import java.math.BigDecimal
import java.time.Instant
/**
* Domain model containing a premium subscription's billing and lifecycle details.
*
* @property status The UI-facing subscription status.
* @property cadence The billing cadence (annual or monthly).
* @property seatsCost The cost of the seat line item for the current cadence.
* @property storageCost The cost of additional storage, or null if none.
* @property discountAmount The money value of any applied discount, or null if no discount is
* present. Percent-off discounts are resolved against the password manager subtotal at mapping
* time.
* @property estimatedTax The estimated tax charged on the next invoice.
* @property nextChargeTotal The total of the next invoice:
* `seatsCost + (storageCost ?: 0) - (discountAmount ?: 0) + estimatedTax`.
* @property nextCharge The date of the next charge, or null if not applicable.
* @property canceledDate The date the subscription was canceled, or null.
* @property suspensionDate The date the subscription will be suspended, or null.
* @property gracePeriodDays The grace period in days, or null.
*/
data class SubscriptionInfo(
val status: PremiumSubscriptionStatus,
val cadence: PlanCadence,
val seatsCost: BigDecimal,
val storageCost: BigDecimal?,
val discountAmount: BigDecimal?,
val estimatedTax: BigDecimal,
val nextChargeTotal: BigDecimal,
val nextCharge: Instant?,
val canceledDate: Instant?,
val suspensionDate: Instant?,
val gracePeriodDays: Int?,
)

View File

@@ -0,0 +1,20 @@
package com.x8bit.bitwarden.data.billing.repository.model
/**
* Models the result of fetching the user's premium subscription details.
*/
sealed class SubscriptionResult {
/**
* Subscription details were fetched successfully.
*/
data class Success(
val subscription: SubscriptionInfo,
) : SubscriptionResult()
/**
* An error occurred while fetching subscription details.
*/
data class Error(
val error: Throwable,
) : SubscriptionResult()
}

View File

@@ -0,0 +1,85 @@
package com.x8bit.bitwarden.data.billing.repository.util
import com.bitwarden.network.model.BitwardenDiscountJson
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CadenceTypeJson
import com.bitwarden.network.model.DiscountTypeJson
import com.bitwarden.network.model.SubscriptionStatusJson
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionInfo
import java.math.BigDecimal
import java.math.RoundingMode
private val PERCENT_DIVISOR: BigDecimal = BigDecimal("100")
private const val MONEY_SCALE: Int = 2
/**
* Maps a [BitwardenSubscriptionResponseJson] into a [SubscriptionInfo] domain
* model.
*
* `discountAmount` is resolved at mapping time: fixed-amount discounts pass
* through as-is; percent-off discounts apply to the password manager subtotal
* (`seatsCost + storageCost`). `nextChargeTotal` is computed client-side as
* `seatsCost + storageCost - discountAmount + estimatedTax` because the server
* does not expose a precomputed total.
*/
fun BitwardenSubscriptionResponseJson.toSubscriptionInfo(): SubscriptionInfo {
val seatsCost = cart.passwordManager.seats.cost
val storageCost = cart.passwordManager.additionalStorage?.cost
val discountAmount = cart.discount?.toMoneyAmount(
subtotal = seatsCost + (storageCost ?: BigDecimal.ZERO),
)
val estimatedTax = cart.estimatedTax
val nextChargeTotal = seatsCost +
(storageCost ?: BigDecimal.ZERO) -
(discountAmount ?: BigDecimal.ZERO) +
estimatedTax
return SubscriptionInfo(
status = status.toPremiumSubscriptionStatus(),
cadence = cart.cadence.toPlanCadence(),
seatsCost = seatsCost,
storageCost = storageCost,
discountAmount = discountAmount,
estimatedTax = estimatedTax,
nextChargeTotal = nextChargeTotal,
nextCharge = nextCharge,
canceledDate = canceled,
suspensionDate = suspension,
gracePeriodDays = gracePeriod,
)
}
private fun SubscriptionStatusJson.toPremiumSubscriptionStatus(): PremiumSubscriptionStatus =
when (this) {
SubscriptionStatusJson.ACTIVE,
SubscriptionStatusJson.TRIALING,
-> PremiumSubscriptionStatus.ACTIVE
SubscriptionStatusJson.CANCELED,
SubscriptionStatusJson.INCOMPLETE_EXPIRED,
-> PremiumSubscriptionStatus.CANCELED
SubscriptionStatusJson.INCOMPLETE,
SubscriptionStatusJson.UNPAID,
-> PremiumSubscriptionStatus.OVERDUE_PAYMENT
SubscriptionStatusJson.PAST_DUE -> PremiumSubscriptionStatus.PAST_DUE
SubscriptionStatusJson.PAUSED -> PremiumSubscriptionStatus.PAUSED
}
private fun CadenceTypeJson.toPlanCadence(): PlanCadence = when (this) {
CadenceTypeJson.ANNUALLY -> PlanCadence.ANNUALLY
CadenceTypeJson.MONTHLY -> PlanCadence.MONTHLY
}
private fun BitwardenDiscountJson.toMoneyAmount(subtotal: BigDecimal): BigDecimal =
when (type) {
DiscountTypeJson.AMOUNT_OFF -> value
DiscountTypeJson.PERCENT_OFF ->
subtotal
.multiply(value)
.divide(PERCENT_DIVISOR, MONEY_SCALE, RoundingMode.HALF_EVEN)
}

View File

@@ -2,14 +2,24 @@ package com.x8bit.bitwarden.data.billing.repository
import com.bitwarden.core.data.util.asFailure
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CadenceTypeJson
import com.bitwarden.network.model.CartItemJson
import com.bitwarden.network.model.CartJson
import com.bitwarden.network.model.CheckoutSessionResponseJson
import com.bitwarden.network.model.PasswordManagerCartItemsJson
import com.bitwarden.network.model.PortalUrlResponseJson
import com.bitwarden.network.model.PremiumPlanResponseJson
import com.bitwarden.network.model.SubscriptionStatusJson
import com.bitwarden.network.service.BillingService
import com.x8bit.bitwarden.data.billing.manager.PlayBillingManager
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
import com.x8bit.bitwarden.data.billing.repository.model.SubscriptionResult
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
@@ -19,6 +29,7 @@ import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class BillingRepositoryTest {
@@ -150,6 +161,76 @@ class BillingRepositoryTest {
result,
)
}
@Test
fun `getSubscription when service returns success should return Success`() =
runTest {
coEvery {
billingService.getSubscription()
} returns ACTIVE_SUBSCRIPTION_RESPONSE.asSuccess()
val result = repository.getSubscription()
assertEquals(
SubscriptionResult.Success(
subscription = SubscriptionInfo(
status = PremiumSubscriptionStatus.ACTIVE,
cadence = PlanCadence.ANNUALLY,
seatsCost = BigDecimal("19.80"),
storageCost = null,
discountAmount = null,
estimatedTax = BigDecimal.ZERO,
nextChargeTotal = BigDecimal("19.80"),
nextCharge = null,
canceledDate = null,
suspensionDate = null,
gracePeriodDays = null,
),
),
result,
)
}
@Test
fun `getSubscription when service returns failure should return Error`() =
runTest {
val exception = RuntimeException("Network error")
coEvery {
billingService.getSubscription()
} returns exception.asFailure()
val result = repository.getSubscription()
assertEquals(
SubscriptionResult.Error(error = exception),
result,
)
}
}
private const val ANNUAL_PRICE = 19.99
private val ACTIVE_SUBSCRIPTION_RESPONSE = BitwardenSubscriptionResponseJson(
status = SubscriptionStatusJson.ACTIVE,
cart = CartJson(
passwordManager = PasswordManagerCartItemsJson(
seats = CartItemJson(
translationKey = "premiumMembership",
quantity = 1,
cost = BigDecimal("19.80"),
discount = null,
),
additionalStorage = null,
),
secretsManager = null,
cadence = CadenceTypeJson.ANNUALLY,
discount = null,
estimatedTax = BigDecimal.ZERO,
),
storage = null,
cancelAt = null,
canceled = null,
nextCharge = null,
suspension = null,
gracePeriod = null,
)

View File

@@ -0,0 +1,219 @@
package com.x8bit.bitwarden.data.billing.repository.util
import com.bitwarden.network.model.BitwardenDiscountJson
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CadenceTypeJson
import com.bitwarden.network.model.CartItemJson
import com.bitwarden.network.model.CartJson
import com.bitwarden.network.model.DiscountTypeJson
import com.bitwarden.network.model.PasswordManagerCartItemsJson
import com.bitwarden.network.model.StorageJson
import com.bitwarden.network.model.SubscriptionStatusJson
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
import com.x8bit.bitwarden.data.billing.repository.model.PremiumSubscriptionStatus
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.time.Instant
class BitwardenSubscriptionResponseJsonExtensionsTest {
@Test
fun `toSubscriptionInfo maps ACTIVE and TRIALING to ACTIVE`() {
listOf(SubscriptionStatusJson.ACTIVE, SubscriptionStatusJson.TRIALING).forEach {
val info = buildResponse(status = it).toSubscriptionInfo()
assertEquals(PremiumSubscriptionStatus.ACTIVE, info.status)
}
}
@Test
fun `toSubscriptionInfo maps CANCELED and INCOMPLETE_EXPIRED to CANCELED`() {
listOf(
SubscriptionStatusJson.CANCELED,
SubscriptionStatusJson.INCOMPLETE_EXPIRED,
).forEach {
val info = buildResponse(status = it).toSubscriptionInfo()
assertEquals(PremiumSubscriptionStatus.CANCELED, info.status)
}
}
@Test
fun `toSubscriptionInfo maps INCOMPLETE and UNPAID to OVERDUE_PAYMENT`() {
listOf(SubscriptionStatusJson.INCOMPLETE, SubscriptionStatusJson.UNPAID).forEach {
val info = buildResponse(status = it).toSubscriptionInfo()
assertEquals(PremiumSubscriptionStatus.OVERDUE_PAYMENT, info.status)
}
}
@Test
fun `toSubscriptionInfo maps PAST_DUE to PAST_DUE`() {
val info = buildResponse(
status = SubscriptionStatusJson.PAST_DUE,
).toSubscriptionInfo()
assertEquals(PremiumSubscriptionStatus.PAST_DUE, info.status)
}
@Test
fun `toSubscriptionInfo maps PAUSED to PAUSED`() {
val info = buildResponse(
status = SubscriptionStatusJson.PAUSED,
).toSubscriptionInfo()
assertEquals(PremiumSubscriptionStatus.PAUSED, info.status)
}
@Test
fun `toSubscriptionInfo maps cadence to PlanCadence`() {
val annually = buildResponse(cadence = CadenceTypeJson.ANNUALLY).toSubscriptionInfo()
assertEquals(PlanCadence.ANNUALLY, annually.cadence)
val monthly = buildResponse(cadence = CadenceTypeJson.MONTHLY).toSubscriptionInfo()
assertEquals(PlanCadence.MONTHLY, monthly.cadence)
}
@Test
fun `toSubscriptionInfo maps seatsCost and null storageCost when not present`() {
val info = buildResponse(seatsCost = BigDecimal("19.80")).toSubscriptionInfo()
assertEquals(BigDecimal("19.80"), info.seatsCost)
assertNull(info.storageCost)
}
@Test
fun `toSubscriptionInfo maps storageCost from additionalStorage when present`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
storageCost = BigDecimal("24.00"),
).toSubscriptionInfo()
assertEquals(BigDecimal("24.00"), info.storageCost)
}
@Test
fun `toSubscriptionInfo discountAmount is null when no discount`() {
val info = buildResponse(discount = null).toSubscriptionInfo()
assertNull(info.discountAmount)
}
@Test
fun `toSubscriptionInfo discountAmount for AMOUNT_OFF passes value through`() {
val info = buildResponse(
discount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("2.10"),
),
).toSubscriptionInfo()
assertEquals(BigDecimal("2.10"), info.discountAmount)
}
@Test
fun `toSubscriptionInfo discountAmount for PERCENT_OFF applies to PM subtotal`() {
// seats 20 + storage 10 = 30 subtotal, 15% = 4.50
val info = buildResponse(
seatsCost = BigDecimal("20.00"),
storageCost = BigDecimal("10.00"),
discount = BitwardenDiscountJson(
type = DiscountTypeJson.PERCENT_OFF,
value = BigDecimal("15.00"),
),
).toSubscriptionInfo()
assertEquals(BigDecimal("4.50"), info.discountAmount)
}
@Test
fun `toSubscriptionInfo passes estimatedTax through`() {
val info = buildResponse(estimatedTax = BigDecimal("3.85")).toSubscriptionInfo()
assertEquals(BigDecimal("3.85"), info.estimatedTax)
}
@Test
fun `toSubscriptionInfo nextChargeTotal sums line items, subtracts discount, adds tax`() {
// Matches the design example: 19.80 + 24.00 - 2.10 + 3.85 = 45.55
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
storageCost = BigDecimal("24.00"),
discount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("2.10"),
),
estimatedTax = BigDecimal("3.85"),
).toSubscriptionInfo()
assertEquals(BigDecimal("45.55"), info.nextChargeTotal)
}
@Test
fun `toSubscriptionInfo nextChargeTotal with minimal cart equals seatsCost`() {
// User-provided JSON: 19.80 + 0 - 0 + 0 = 19.80
val info = buildResponse(seatsCost = BigDecimal("19.80")).toSubscriptionInfo()
assertEquals(BigDecimal("19.80"), info.nextChargeTotal)
}
@Test
fun `toSubscriptionInfo maps timestamps and gracePeriod`() {
val canceled = Instant.parse("2026-01-01T00:00:00Z")
val next = Instant.parse("2027-04-21T17:35:42Z")
val suspension = Instant.parse("2026-05-02T00:00:00Z")
val info = buildResponse(
canceled = canceled,
nextCharge = next,
suspension = suspension,
gracePeriod = 14,
).toSubscriptionInfo()
assertEquals(canceled, info.canceledDate)
assertEquals(next, info.nextCharge)
assertEquals(suspension, info.suspensionDate)
assertEquals(14, info.gracePeriodDays)
}
@Test
fun `toSubscriptionInfo has null timestamps and gracePeriod when not provided`() {
val info = buildResponse().toSubscriptionInfo()
assertNull(info.canceledDate)
assertNull(info.nextCharge)
assertNull(info.suspensionDate)
assertNull(info.gracePeriodDays)
}
@Suppress("LongParameterList")
private fun buildResponse(
status: SubscriptionStatusJson = SubscriptionStatusJson.ACTIVE,
cadence: CadenceTypeJson = CadenceTypeJson.ANNUALLY,
seatsCost: BigDecimal = BigDecimal("19.80"),
storageCost: BigDecimal? = null,
discount: BitwardenDiscountJson? = null,
estimatedTax: BigDecimal = BigDecimal.ZERO,
storage: StorageJson? = null,
canceled: Instant? = null,
nextCharge: Instant? = null,
suspension: Instant? = null,
gracePeriod: Int? = null,
): BitwardenSubscriptionResponseJson = BitwardenSubscriptionResponseJson(
status = status,
cart = CartJson(
passwordManager = PasswordManagerCartItemsJson(
seats = CartItemJson(
translationKey = "premiumMembership",
quantity = 1,
cost = seatsCost,
discount = null,
),
additionalStorage = storageCost?.let {
CartItemJson(
translationKey = "additionalStorage",
quantity = 1,
cost = it,
discount = null,
)
},
),
secretsManager = null,
cadence = cadence,
discount = discount,
estimatedTax = estimatedTax,
),
storage = storage,
cancelAt = null,
canceled = canceled,
nextCharge = nextCharge,
suspension = suspension,
gracePeriod = gracePeriod,
)
}

View File

@@ -0,0 +1,44 @@
package com.bitwarden.core.data.serializer
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerializationException
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonUnquotedLiteral
import java.math.BigDecimal
/**
* Used to serialize and deserialize [BigDecimal] as a JSON number literal without
* round-tripping through [Double]. Preserving the raw numeric string guarantees no
* precision loss for currency values.
*/
class BigDecimalSerializer : KSerializer<BigDecimal> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor(serialName = "BigDecimal", kind = PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): BigDecimal {
val jsonDecoder = decoder as? JsonDecoder
?: throw SerializationException(
"BigDecimalSerializer only supports JSON formats.",
)
val primitive = jsonDecoder.decodeJsonElement() as? JsonPrimitive
?: throw SerializationException(
"Expected a JSON number literal for BigDecimal.",
)
return primitive.content.toBigDecimal()
}
override fun serialize(encoder: Encoder, value: BigDecimal) {
val jsonEncoder = encoder as? JsonEncoder
?: throw SerializationException(
"BigDecimalSerializer only supports JSON formats.",
)
jsonEncoder.encodeJsonElement(JsonUnquotedLiteral(value.toPlainString()))
}
}

View File

@@ -1,6 +1,7 @@
package com.bitwarden.core.di
import com.bitwarden.core.data.manager.BuildInfoManager
import com.bitwarden.core.data.serializer.BigDecimalSerializer
import com.bitwarden.core.data.serializer.InstantSerializer
import dagger.Module
import dagger.Provides
@@ -34,6 +35,7 @@ object CoreModule {
explicitNulls = false
serializersModule = SerializersModule {
contextual(InstantSerializer())
contextual(BigDecimalSerializer())
}
// Respect model default property values.

View File

@@ -0,0 +1,93 @@
package com.bitwarden.core.data.serializer
import com.bitwarden.core.di.CoreModule
import io.mockk.mockk
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.encodeToJsonElement
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
import java.math.BigDecimal
class BigDecimalSerializerTest {
private val json = CoreModule.providesJson(buildInfoManager = mockk(relaxed = true))
@Test
fun `deserializes JSON number with decimals without precision loss`() {
assertEquals(
BigDecimalData(amount = BigDecimal("3.85")),
json.decodeFromString<BigDecimalData>(
"""
{
"amount": 3.85
}
""",
),
)
}
@Test
fun `deserializes JSON integer zero`() {
assertEquals(
BigDecimalData(amount = BigDecimal("0")),
json.decodeFromString<BigDecimalData>(
"""
{
"amount": 0
}
""",
),
)
}
@Test
fun `deserializes high-precision JSON number without coercing through Double`() {
assertEquals(
BigDecimalData(amount = BigDecimal("0.123456789012345")),
json.decodeFromString<BigDecimalData>(
"""
{
"amount": 0.123456789012345
}
""",
),
)
}
@Test
fun `deserializes negative JSON number`() {
assertEquals(
BigDecimalData(amount = BigDecimal("-4.20")),
json.decodeFromString<BigDecimalData>(
"""
{
"amount": -4.20
}
""",
),
)
}
@Test
fun `serializes BigDecimal as unquoted JSON number literal`() {
assertEquals(
json.parseToJsonElement(
"""
{
"amount": 19.80
}
""",
),
json.encodeToJsonElement(
BigDecimalData(amount = BigDecimal("19.80")),
),
)
}
}
@Serializable
private data class BigDecimalData(
@Serializable(BigDecimalSerializer::class)
@SerialName("amount")
val amount: BigDecimal,
)

View File

@@ -1,6 +1,7 @@
package com.bitwarden.network
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.core.data.serializer.BigDecimalSerializer
import com.bitwarden.core.data.serializer.InstantSerializer
import com.bitwarden.network.interceptor.AuthTokenManager
import com.bitwarden.network.interceptor.BaseUrlInterceptors
@@ -74,6 +75,7 @@ internal class BitwardenServiceClientImpl(
explicitNulls = false
serializersModule = SerializersModule {
contextual(InstantSerializer())
contextual(BigDecimalSerializer())
}
// Respect model default property values.

View File

@@ -1,5 +1,6 @@
package com.bitwarden.network.api
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CheckoutSessionRequestJson
import com.bitwarden.network.model.CheckoutSessionResponseJson
import com.bitwarden.network.model.NetworkResult
@@ -33,4 +34,10 @@ internal interface AuthenticatedBillingApi {
*/
@GET("/plans/premium")
suspend fun getPremiumPlan(): NetworkResult<PremiumPlanResponseJson>
/**
* Retrieves the user's premium subscription details.
*/
@GET("/account/billing/vnext/subscription")
suspend fun getSubscription(): NetworkResult<BitwardenSubscriptionResponseJson>
}

View File

@@ -0,0 +1,222 @@
package com.bitwarden.network.model
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.math.BigDecimal
import java.time.Instant
/**
* Response object returned when retrieving the user's premium subscription details.
*
* @property status The current status of the subscription.
* @property cart The cart details of the subscription.
* @property storage The storage usage details, if available.
* @property cancelAt The date the subscription is scheduled to cancel, if applicable.
* @property canceled The date the subscription was canceled, if applicable.
* @property nextCharge The date of the next charge, if applicable.
* @property suspension The date the subscription was suspended, if applicable.
* @property gracePeriod The grace period in days, if applicable.
*/
@Serializable
data class BitwardenSubscriptionResponseJson(
@SerialName("status")
val status: SubscriptionStatusJson,
@SerialName("cart")
val cart: CartJson,
@SerialName("storage")
val storage: StorageJson?,
@Contextual
@SerialName("cancelAt")
val cancelAt: Instant?,
@Contextual
@SerialName("canceled")
val canceled: Instant?,
@Contextual
@SerialName("nextCharge")
val nextCharge: Instant?,
@Contextual
@SerialName("suspension")
val suspension: Instant?,
@SerialName("gracePeriod")
val gracePeriod: Int?,
)
/**
* Represents the status of a subscription.
*/
@Serializable
enum class SubscriptionStatusJson {
@SerialName("active")
ACTIVE,
@SerialName("canceled")
CANCELED,
@SerialName("past_due")
PAST_DUE,
@SerialName("incomplete")
INCOMPLETE,
@SerialName("incomplete_expired")
INCOMPLETE_EXPIRED,
@SerialName("unpaid")
UNPAID,
@SerialName("trialing")
TRIALING,
@SerialName("paused")
PAUSED,
}
/**
* Represents the cart details of a subscription.
*
* @property passwordManager The password manager cart items.
* @property secretsManager The secrets manager cart items, if applicable.
* @property cadence The billing cadence of the subscription.
* @property discount The discount applied to the cart, if applicable.
* @property estimatedTax The estimated tax amount.
*/
@Serializable
data class CartJson(
@SerialName("passwordManager")
val passwordManager: PasswordManagerCartItemsJson,
@SerialName("secretsManager")
val secretsManager: SecretsManagerCartItemsJson?,
@SerialName("cadence")
val cadence: CadenceTypeJson,
@SerialName("discount")
val discount: BitwardenDiscountJson?,
@Contextual
@SerialName("estimatedTax")
val estimatedTax: BigDecimal,
)
/**
* Represents the password manager cart items within a subscription.
*
* @property seats The seat pricing details.
* @property additionalStorage The additional storage pricing details, if applicable.
*/
@Serializable
data class PasswordManagerCartItemsJson(
@SerialName("seats")
val seats: CartItemJson,
@SerialName("additionalStorage")
val additionalStorage: CartItemJson?,
)
/**
* Represents the secrets manager cart items within a subscription.
*
* @property seats The seat pricing details.
* @property additionalServiceAccounts The additional service accounts pricing details,
* if applicable.
*/
@Serializable
data class SecretsManagerCartItemsJson(
@SerialName("seats")
val seats: CartItemJson,
@SerialName("additionalServiceAccounts")
val additionalServiceAccounts: CartItemJson?,
)
/**
* Represents a single cart item within a subscription.
*
* @property translationKey The translation key for display purposes.
* @property quantity The quantity of this item.
* @property cost The cost of this item.
* @property discount The discount applied to this item, if applicable.
*/
@Serializable
data class CartItemJson(
@SerialName("translationKey")
val translationKey: String,
@SerialName("quantity")
val quantity: Long,
@Contextual
@SerialName("cost")
val cost: BigDecimal,
@SerialName("discount")
val discount: BitwardenDiscountJson?,
)
/**
* Represents a discount applied to a subscription or cart item.
*
* @property type The type of discount.
* @property value The discount value.
*/
@Serializable
data class BitwardenDiscountJson(
@SerialName("type")
val type: DiscountTypeJson,
@Contextual
@SerialName("value")
val value: BigDecimal,
)
/**
* Represents the type of discount applied to a subscription.
*/
@Serializable
enum class DiscountTypeJson {
@SerialName("amount-off")
AMOUNT_OFF,
@SerialName("percent-off")
PERCENT_OFF,
}
/**
* Represents the billing cadence of a subscription.
*/
@Serializable
enum class CadenceTypeJson {
@SerialName("annually")
ANNUALLY,
@SerialName("monthly")
MONTHLY,
}
/**
* Represents storage usage details for a subscription.
*
* @property available The available storage in bytes.
* @property used The used storage amount.
* @property readableUsed A human-readable representation of the used storage.
*/
@Serializable
data class StorageJson(
@SerialName("available")
val available: Int,
@SerialName("used")
val used: Double,
@SerialName("readableUsed")
val readableUsed: String,
)

View File

@@ -1,5 +1,6 @@
package com.bitwarden.network.service
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CheckoutSessionResponseJson
import com.bitwarden.network.model.PortalUrlResponseJson
import com.bitwarden.network.model.PremiumPlanResponseJson
@@ -23,4 +24,9 @@ interface BillingService {
* Retrieves the premium plan pricing information.
*/
suspend fun getPremiumPlan(): Result<PremiumPlanResponseJson>
/**
* Retrieves the user's premium subscription details.
*/
suspend fun getSubscription(): Result<BitwardenSubscriptionResponseJson>
}

View File

@@ -1,6 +1,7 @@
package com.bitwarden.network.service
import com.bitwarden.network.api.AuthenticatedBillingApi
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CheckoutSessionRequestJson
import com.bitwarden.network.model.CheckoutSessionResponseJson
import com.bitwarden.network.model.PortalUrlResponseJson
@@ -32,4 +33,9 @@ internal class BillingServiceImpl(
authenticatedBillingApi
.getPremiumPlan()
.toResult()
override suspend fun getSubscription(): Result<BitwardenSubscriptionResponseJson> =
authenticatedBillingApi
.getSubscription()
.toResult()
}

View File

@@ -3,15 +3,27 @@ package com.bitwarden.network.service
import com.bitwarden.core.data.util.asSuccess
import com.bitwarden.network.api.AuthenticatedBillingApi
import com.bitwarden.network.base.BaseServiceTest
import com.bitwarden.network.model.BitwardenDiscountJson
import com.bitwarden.network.model.BitwardenSubscriptionResponseJson
import com.bitwarden.network.model.CadenceTypeJson
import com.bitwarden.network.model.CartItemJson
import com.bitwarden.network.model.CartJson
import com.bitwarden.network.model.CheckoutSessionResponseJson
import com.bitwarden.network.model.DiscountTypeJson
import com.bitwarden.network.model.PasswordManagerCartItemsJson
import com.bitwarden.network.model.PortalUrlResponseJson
import com.bitwarden.network.model.PremiumPlanResponseJson
import com.bitwarden.network.model.StorageJson
import com.bitwarden.network.model.SubscriptionStatusJson
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.SerializationException
import okhttp3.mockwebserver.MockResponse
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import retrofit2.create
import java.math.BigDecimal
import java.time.Instant
class BillingServiceTest : BaseServiceTest() {
@@ -77,6 +89,105 @@ class BillingServiceTest : BaseServiceTest() {
val actual = service.getPremiumPlan()
assertEquals(PREMIUM_PLAN_RESPONSE.asSuccess(), actual)
}
@Test
fun `getSubscription when response is Failure should return Failure`() =
runTest {
val response = MockResponse().setResponseCode(400)
server.enqueue(response)
val actual = service.getSubscription()
assertTrue(actual.isFailure)
}
@Test
fun `getSubscription when response is Success should return Success`() =
runTest {
val response = MockResponse()
.setBody(SUBSCRIPTION_RESPONSE_JSON)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getSubscription()
assertEquals(SUBSCRIPTION_RESPONSE.asSuccess(), actual)
}
@Test
fun `getSubscription with monthly cadence should parse correctly`() =
runTest {
val response = MockResponse()
.setBody(SUBSCRIPTION_RESPONSE_MONTHLY_JSON)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getSubscription()
assertEquals(
CadenceTypeJson.MONTHLY,
actual.getOrNull()?.cart?.cadence,
)
}
@Test
fun `getSubscription should parse every SubscriptionStatusJson value`() =
runTest {
SubscriptionStatusJson.entries.forEach { status ->
val body = subscriptionResponseJsonForStatus(status)
val response = MockResponse()
.setBody(body)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getSubscription()
assertEquals(status, actual.getOrNull()?.status)
}
}
@Test
fun `getSubscription with null storage and discount should parse`() =
runTest {
val response = MockResponse()
.setBody(SUBSCRIPTION_RESPONSE_MINIMAL_JSON)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getSubscription()
assertTrue(actual.isSuccess)
}
@Test
fun `getSubscription with AMOUNT_OFF discount should parse`() =
runTest {
val response = MockResponse()
.setBody(SUBSCRIPTION_RESPONSE_AMOUNT_OFF_JSON)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getSubscription()
assertEquals(
DiscountTypeJson.AMOUNT_OFF,
actual.getOrNull()?.cart?.discount?.type,
)
}
@Test
fun `getSubscription with PERCENT_OFF discount should parse`() =
runTest {
val response = MockResponse()
.setBody(SUBSCRIPTION_RESPONSE_PERCENT_OFF_JSON)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getSubscription()
assertEquals(
DiscountTypeJson.PERCENT_OFF,
actual.getOrNull()?.cart?.discount?.type,
)
}
@Test
fun `getSubscription with unknown cadence should fail deserialization`() =
runTest {
val response = MockResponse()
.setBody(SUBSCRIPTION_RESPONSE_UNKNOWN_CADENCE_JSON)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getSubscription()
assertTrue(actual.isFailure)
assertTrue(actual.exceptionOrNull() is SerializationException)
}
}
private const val CHECKOUT_SESSION_RESPONSE_JSON = """
@@ -132,3 +243,261 @@ private val PREMIUM_PLAN_RESPONSE = PremiumPlanResponseJson(
provided = 5,
),
)
private const val SUBSCRIPTION_RESPONSE_JSON = """
{
"status": "active",
"cart": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
},
"additionalStorage": {
"translationKey": "additionalStorage",
"quantity": 24,
"cost": 24.00,
"discount": null
}
},
"secretsManager": null,
"cadence": "annually",
"discount": {
"type": "amount-off",
"value": 2.10
},
"estimatedTax": 3.85
},
"storage": {
"available": 5,
"used": 0,
"readableUsed": "0 Bytes"
},
"cancelAt": null,
"canceled": null,
"nextCharge": "2026-04-02T00:00:00Z",
"suspension": null,
"gracePeriod": null
}
"""
private val SUBSCRIPTION_RESPONSE = BitwardenSubscriptionResponseJson(
status = SubscriptionStatusJson.ACTIVE,
cart = CartJson(
passwordManager = PasswordManagerCartItemsJson(
seats = CartItemJson(
translationKey = "premiumMembership",
quantity = 1,
cost = BigDecimal("19.80"),
discount = null,
),
additionalStorage = CartItemJson(
translationKey = "additionalStorage",
quantity = 24,
cost = BigDecimal("24.00"),
discount = null,
),
),
secretsManager = null,
cadence = CadenceTypeJson.ANNUALLY,
discount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("2.10"),
),
estimatedTax = BigDecimal("3.85"),
),
storage = StorageJson(
available = 5,
used = 0.0,
readableUsed = "0 Bytes",
),
cancelAt = null,
canceled = null,
nextCharge = Instant.parse("2026-04-02T00:00:00Z"),
suspension = null,
gracePeriod = null,
)
private const val SUBSCRIPTION_RESPONSE_MONTHLY_JSON = """
{
"status": "active",
"cart": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 1.67,
"discount": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "monthly",
"discount": null,
"estimatedTax": 0
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
"""
private const val SUBSCRIPTION_RESPONSE_MINIMAL_JSON = """
{
"status": "active",
"cart": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "annually",
"discount": null,
"estimatedTax": 0
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
"""
private const val SUBSCRIPTION_RESPONSE_AMOUNT_OFF_JSON = """
{
"status": "active",
"cart": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "annually",
"discount": {
"type": "amount-off",
"value": 5.00
},
"estimatedTax": 0
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
"""
private const val SUBSCRIPTION_RESPONSE_PERCENT_OFF_JSON = """
{
"status": "active",
"cart": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "annually",
"discount": {
"type": "percent-off",
"value": 15.00
},
"estimatedTax": 0
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
"""
private const val SUBSCRIPTION_RESPONSE_UNKNOWN_CADENCE_JSON = """
{
"status": "active",
"cart": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "weekly",
"discount": null,
"estimatedTax": 0
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
"""
private fun subscriptionResponseJsonForStatus(
status: SubscriptionStatusJson,
): String {
val wireValue = when (status) {
SubscriptionStatusJson.ACTIVE -> "active"
SubscriptionStatusJson.CANCELED -> "canceled"
SubscriptionStatusJson.PAST_DUE -> "past_due"
SubscriptionStatusJson.INCOMPLETE -> "incomplete"
SubscriptionStatusJson.INCOMPLETE_EXPIRED -> "incomplete_expired"
SubscriptionStatusJson.UNPAID -> "unpaid"
SubscriptionStatusJson.TRIALING -> "trialing"
SubscriptionStatusJson.PAUSED -> "paused"
}
return """
{
"status": "$wireValue",
"cart": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "annually",
"discount": null,
"estimatedTax": 0
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
""".trimIndent()
}

View File

@@ -0,0 +1,99 @@
package com.bitwarden.ui.platform.components.badge
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.bitwarden.annotation.OmitFromCoverage
import com.bitwarden.ui.platform.feature.settings.appearance.model.AppTheme
import com.bitwarden.ui.platform.theme.BitwardenTheme
import com.bitwarden.ui.platform.theme.color.BitwardenColorScheme
/**
* A reusable status badge composable that displays a colored pill with a label.
*
* @param label The text to display in the badge.
* @param colors The border, background, and text colors for the badge.
* @param modifier The [Modifier] to apply to this badge.
*/
@Composable
fun BitwardenStatusBadge(
label: String,
colors: BitwardenColorScheme.StatusBadgeVariantColors,
modifier: Modifier = Modifier,
) {
val shape = RoundedCornerShape(size = 12.dp)
Surface(
shape = shape,
color = colors.background,
modifier = modifier
.height(24.dp)
.border(
width = 1.dp,
color = colors.border,
shape = shape,
),
) {
Text(
text = label,
style = BitwardenTheme.typography.labelSmall,
color = colors.text,
modifier = Modifier.padding(
horizontal = 8.dp,
vertical = 4.dp,
),
)
}
}
@OmitFromCoverage
@Preview
@Composable
private fun BitwardenStatusBadge_Preview() {
BitwardenTheme {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
BitwardenStatusBadge(
label = "Active",
colors = BitwardenTheme.colorScheme.statusBadge.success,
)
BitwardenStatusBadge(
label = "Canceled",
colors = BitwardenTheme.colorScheme.statusBadge.error,
)
BitwardenStatusBadge(
label = "Update payment",
colors = BitwardenTheme.colorScheme.statusBadge.warning,
)
}
}
}
@OmitFromCoverage
@Preview
@Composable
private fun BitwardenStatusBadge_PreviewDark() {
BitwardenTheme(theme = AppTheme.DARK) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
BitwardenStatusBadge(
label = "Active",
colors = BitwardenTheme.colorScheme.statusBadge.success,
)
BitwardenStatusBadge(
label = "Canceled",
colors = BitwardenTheme.colorScheme.statusBadge.error,
)
BitwardenStatusBadge(
label = "Update payment",
colors = BitwardenTheme.colorScheme.statusBadge.warning,
)
}
}
}

View File

@@ -18,6 +18,7 @@ data class BitwardenColorScheme(
val toggleButton: ToggleButtonColors,
val sliderButton: SliderButtonColors,
val status: StatusColors,
val statusBadge: StatusBadgeColors,
val illustration: IllustrationColors,
) {
/**
@@ -128,6 +129,26 @@ data class BitwardenColorScheme(
val error: Color,
)
/**
* Defines all the status badge colors for the app.
*/
@Immutable
data class StatusBadgeColors(
val success: StatusBadgeVariantColors,
val error: StatusBadgeVariantColors,
val warning: StatusBadgeVariantColors,
)
/**
* Defines the border, background, and text colors for a status badge variant.
*/
@Immutable
data class StatusBadgeVariantColors(
val border: Color,
val background: Color,
val text: Color,
)
/**
* Defines all the illustration colors for the app.
*/

View File

@@ -71,6 +71,23 @@ val darkBitwardenColorScheme: BitwardenColorScheme = BitwardenColorScheme(
weak2 = PrimitiveColors.yellow200,
error = PrimitiveColors.red200,
),
statusBadge = BitwardenColorScheme.StatusBadgeColors(
success = BitwardenColorScheme.StatusBadgeVariantColors(
border = PrimitiveColors.green800,
background = PrimitiveColors.green950,
text = PrimitiveColors.green150,
),
error = BitwardenColorScheme.StatusBadgeVariantColors(
border = PrimitiveColors.red800,
background = PrimitiveColors.red950,
text = PrimitiveColors.red150,
),
warning = BitwardenColorScheme.StatusBadgeVariantColors(
border = PrimitiveColors.orange800,
background = PrimitiveColors.orange950,
text = PrimitiveColors.orange200,
),
),
illustration = BitwardenColorScheme.IllustrationColors(
outline = PrimitiveColors.blue500,
backgroundPrimary = PrimitiveColors.blue200,
@@ -149,6 +166,23 @@ val lightBitwardenColorScheme: BitwardenColorScheme = BitwardenColorScheme(
weak2 = PrimitiveColors.yellow300,
error = PrimitiveColors.red300,
),
statusBadge = BitwardenColorScheme.StatusBadgeColors(
success = BitwardenColorScheme.StatusBadgeVariantColors(
border = PrimitiveColors.green150,
background = PrimitiveColors.green050,
text = PrimitiveColors.green400,
),
error = BitwardenColorScheme.StatusBadgeVariantColors(
border = PrimitiveColors.red150,
background = PrimitiveColors.red050,
text = PrimitiveColors.red400,
),
warning = BitwardenColorScheme.StatusBadgeVariantColors(
border = PrimitiveColors.orange200,
background = PrimitiveColors.orange050,
text = PrimitiveColors.orange700,
),
),
illustration = BitwardenColorScheme.IllustrationColors(
outline = PrimitiveColors.blue700,
backgroundPrimary = PrimitiveColors.blue100,
@@ -233,6 +267,11 @@ fun dynamicBitwardenColorScheme(
weak2 = defaultTheme.status.weak2,
error = defaultTheme.status.error,
),
statusBadge = BitwardenColorScheme.StatusBadgeColors(
success = defaultTheme.statusBadge.success,
error = defaultTheme.statusBadge.error,
warning = defaultTheme.statusBadge.warning,
),
illustration = BitwardenColorScheme.IllustrationColors(
outline = materialColorScheme.onSurface,
backgroundPrimary = if (isDarkTheme) {
@@ -290,20 +329,34 @@ private data object PrimitiveColors {
val blue600: Color = Color(color = 0xFF1A41AC)
val blue700: Color = Color(color = 0xFF020F66)
val green050: Color = Color(color = 0xFFF0FDF4)
val green100: Color = Color(color = 0xFFBFECC3)
val green150: Color = Color(color = 0xFFB9F8CF)
val green200: Color = Color(color = 0xFF6BF178)
val green300: Color = Color(color = 0xFF0C8018)
val green400: Color = Color(color = 0xFF08540F)
val green800: Color = Color(color = 0xFF016630)
val green950: Color = Color(color = 0xFF032E15)
val red050: Color = Color(color = 0xFFFEF2F2)
val red100: Color = Color(color = 0xFFFFECEF)
val red150: Color = Color(color = 0xFFFFC9C9)
val red200: Color = Color(color = 0xFFFF4E63)
val red300: Color = Color(color = 0xFFCB263A)
val red400: Color = Color(color = 0xFF951B2A)
val red800: Color = Color(color = 0xFF9F0712)
val red950: Color = Color(color = 0xFF460809)
val yellow100: Color = Color(color = 0xFFFFF8E4)
val yellow200: Color = Color(color = 0xFFFFBF00)
val yellow300: Color = Color(color = 0xFFAC5800)
val orange050: Color = Color(color = 0xFFFFF8F1)
val orange200: Color = Color(color = 0xFFFCD9BD)
val orange700: Color = Color(color = 0xFFB23300)
val orange800: Color = Color(color = 0xFF8A2203)
val orange950: Color = Color(color = 0xFF441600)
val pink100: Color = Color(color = 0xFFC01176)
val pink200: Color = Color(color = 0xFFFF8FD0)
}

View File

@@ -0,0 +1,49 @@
package com.bitwarden.ui.platform.components.badge
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithText
import com.bitwarden.ui.platform.base.BaseComposeTest
import com.bitwarden.ui.platform.theme.BitwardenTheme
import org.junit.Test
class BitwardenStatusBadgeTest : BaseComposeTest() {
@Test
fun `success variant renders with label`() {
setTestContent {
BitwardenTheme {
BitwardenStatusBadge(
label = "Active",
colors = BitwardenTheme.colorScheme.statusBadge.success,
)
}
}
composeTestRule.onNodeWithText("Active").assertIsDisplayed()
}
@Test
fun `error variant renders with label`() {
setTestContent {
BitwardenTheme {
BitwardenStatusBadge(
label = "Canceled",
colors = BitwardenTheme.colorScheme.statusBadge.error,
)
}
}
composeTestRule.onNodeWithText("Canceled").assertIsDisplayed()
}
@Test
fun `warning variant renders with label`() {
setTestContent {
BitwardenTheme {
BitwardenStatusBadge(
label = "Overdue payment",
colors = BitwardenTheme.colorScheme.statusBadge.warning,
)
}
}
composeTestRule.onNodeWithText("Overdue payment").assertIsDisplayed()
}
}