mirror of
https://github.com/bitwarden/android.git
synced 2026-05-10 15:16:52 -05:00
[PM-35454] feat: Add subscription API, domain models, and status badge component (#6818)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.x8bit.bitwarden.data.billing.repository.model
|
||||
|
||||
/**
|
||||
* The billing cadence of a premium subscription.
|
||||
*/
|
||||
enum class PlanCadence {
|
||||
ANNUALLY,
|
||||
MONTHLY,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user