From 2b4ca430f15cafa5046f60968a5790b87ffe901e Mon Sep 17 00:00:00 2001 From: Patrick Honkonen <1883101+SaintPatrck@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:42:18 -0400 Subject: [PATCH] [PM-35454] feat: Add subscription API, domain models, and status badge component (#6818) --- .../billing/repository/BillingRepository.kt | 6 + .../repository/BillingRepositoryImpl.kt | 14 + .../billing/repository/model/PlanCadence.kt | 9 + .../model/PremiumSubscriptionStatus.kt | 12 + .../repository/model/SubscriptionInfo.kt | 36 ++ .../repository/model/SubscriptionResult.kt | 20 + ...ardenSubscriptionResponseJsonExtensions.kt | 85 ++++ .../repository/BillingRepositoryTest.kt | 81 ++++ ...nSubscriptionResponseJsonExtensionsTest.kt | 219 +++++++++++ .../data/serializer/BigDecimalSerializer.kt | 44 +++ .../com/bitwarden/core/di/CoreModule.kt | 2 + .../serializer/BigDecimalSerializerTest.kt | 93 +++++ .../network/BitwardenServiceClientImpl.kt | 2 + .../network/api/AuthenticatedBillingApi.kt | 7 + .../BitwardenSubscriptionResponseJson.kt | 222 +++++++++++ .../network/service/BillingService.kt | 6 + .../network/service/BillingServiceImpl.kt | 6 + .../network/service/BillingServiceTest.kt | 369 ++++++++++++++++++ .../components/badge/BitwardenStatusBadge.kt | 99 +++++ .../theme/color/BitwardenColorScheme.kt | 21 + .../ui/platform/theme/color/ColorScheme.kt | 53 +++ .../badge/BitwardenStatusBadgeTest.kt | 49 +++ 22 files changed, 1455 insertions(+) create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PlanCadence.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PremiumSubscriptionStatus.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionInfo.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionResult.kt create mode 100644 app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensions.kt create mode 100644 app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensionsTest.kt create mode 100644 core/src/main/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializer.kt create mode 100644 core/src/test/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializerTest.kt create mode 100644 network/src/main/kotlin/com/bitwarden/network/model/BitwardenSubscriptionResponseJson.kt create mode 100644 ui/src/main/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadge.kt create mode 100644 ui/src/test/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadgeTest.kt diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepository.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepository.kt index 398d787ff4..e1f422b549 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepository.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepository.kt @@ -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 } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryImpl.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryImpl.kt index a13efbde8e..87bf2a4f06 100644 --- a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryImpl.kt +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryImpl.kt @@ -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) }, + ) } diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PlanCadence.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PlanCadence.kt new file mode 100644 index 0000000000..eb862d293e --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PlanCadence.kt @@ -0,0 +1,9 @@ +package com.x8bit.bitwarden.data.billing.repository.model + +/** + * The billing cadence of a premium subscription. + */ +enum class PlanCadence { + ANNUALLY, + MONTHLY, +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PremiumSubscriptionStatus.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PremiumSubscriptionStatus.kt new file mode 100644 index 0000000000..3f288a4b85 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/PremiumSubscriptionStatus.kt @@ -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, +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionInfo.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionInfo.kt new file mode 100644 index 0000000000..2fbac43010 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionInfo.kt @@ -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?, +) diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionResult.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionResult.kt new file mode 100644 index 0000000000..2fee5fddc1 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/model/SubscriptionResult.kt @@ -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() +} diff --git a/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensions.kt b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensions.kt new file mode 100644 index 0000000000..db26b5aec4 --- /dev/null +++ b/app/src/main/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensions.kt @@ -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) + } diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryTest.kt index f480891e43..6b8a81e51e 100644 --- a/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryTest.kt +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/BillingRepositoryTest.kt @@ -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, +) diff --git a/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensionsTest.kt b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensionsTest.kt new file mode 100644 index 0000000000..ca70ae5db5 --- /dev/null +++ b/app/src/test/kotlin/com/x8bit/bitwarden/data/billing/repository/util/BitwardenSubscriptionResponseJsonExtensionsTest.kt @@ -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, + ) +} diff --git a/core/src/main/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializer.kt b/core/src/main/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializer.kt new file mode 100644 index 0000000000..5c122c06dc --- /dev/null +++ b/core/src/main/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializer.kt @@ -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 { + 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())) + } +} diff --git a/core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt b/core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt index 1d641360b0..3351f7c8fe 100644 --- a/core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt +++ b/core/src/main/kotlin/com/bitwarden/core/di/CoreModule.kt @@ -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. diff --git a/core/src/test/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializerTest.kt b/core/src/test/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializerTest.kt new file mode 100644 index 0000000000..0f45adc395 --- /dev/null +++ b/core/src/test/kotlin/com/bitwarden/core/data/serializer/BigDecimalSerializerTest.kt @@ -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( + """ + { + "amount": 3.85 + } + """, + ), + ) + } + + @Test + fun `deserializes JSON integer zero`() { + assertEquals( + BigDecimalData(amount = BigDecimal("0")), + json.decodeFromString( + """ + { + "amount": 0 + } + """, + ), + ) + } + + @Test + fun `deserializes high-precision JSON number without coercing through Double`() { + assertEquals( + BigDecimalData(amount = BigDecimal("0.123456789012345")), + json.decodeFromString( + """ + { + "amount": 0.123456789012345 + } + """, + ), + ) + } + + @Test + fun `deserializes negative JSON number`() { + assertEquals( + BigDecimalData(amount = BigDecimal("-4.20")), + json.decodeFromString( + """ + { + "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, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt index 229cfcd6e8..342726cb1f 100644 --- a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt @@ -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. diff --git a/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedBillingApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedBillingApi.kt index f236b95c76..e1220c68d5 100644 --- a/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedBillingApi.kt +++ b/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedBillingApi.kt @@ -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 + + /** + * Retrieves the user's premium subscription details. + */ + @GET("/account/billing/vnext/subscription") + suspend fun getSubscription(): NetworkResult } diff --git a/network/src/main/kotlin/com/bitwarden/network/model/BitwardenSubscriptionResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/BitwardenSubscriptionResponseJson.kt new file mode 100644 index 0000000000..7288df28e0 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/BitwardenSubscriptionResponseJson.kt @@ -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, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/service/BillingService.kt b/network/src/main/kotlin/com/bitwarden/network/service/BillingService.kt index 5fd224699f..5daf228cad 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/BillingService.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/BillingService.kt @@ -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 + + /** + * Retrieves the user's premium subscription details. + */ + suspend fun getSubscription(): Result } diff --git a/network/src/main/kotlin/com/bitwarden/network/service/BillingServiceImpl.kt b/network/src/main/kotlin/com/bitwarden/network/service/BillingServiceImpl.kt index 9a79c98da0..13536c3268 100644 --- a/network/src/main/kotlin/com/bitwarden/network/service/BillingServiceImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/service/BillingServiceImpl.kt @@ -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 = + authenticatedBillingApi + .getSubscription() + .toResult() } diff --git a/network/src/test/kotlin/com/bitwarden/network/service/BillingServiceTest.kt b/network/src/test/kotlin/com/bitwarden/network/service/BillingServiceTest.kt index cf9a043913..aa1213a45b 100644 --- a/network/src/test/kotlin/com/bitwarden/network/service/BillingServiceTest.kt +++ b/network/src/test/kotlin/com/bitwarden/network/service/BillingServiceTest.kt @@ -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() +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadge.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadge.kt new file mode 100644 index 0000000000..e54a4a28a2 --- /dev/null +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadge.kt @@ -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, + ) + } + } +} diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/BitwardenColorScheme.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/BitwardenColorScheme.kt index 7cf0cd342d..6b5f1e0c2c 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/BitwardenColorScheme.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/BitwardenColorScheme.kt @@ -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. */ diff --git a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/ColorScheme.kt b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/ColorScheme.kt index 1a5b713936..a5deea3e6f 100644 --- a/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/ColorScheme.kt +++ b/ui/src/main/kotlin/com/bitwarden/ui/platform/theme/color/ColorScheme.kt @@ -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) } diff --git a/ui/src/test/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadgeTest.kt b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadgeTest.kt new file mode 100644 index 0000000000..78b4519081 --- /dev/null +++ b/ui/src/test/kotlin/com/bitwarden/ui/platform/components/badge/BitwardenStatusBadgeTest.kt @@ -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() + } +}