PM-46261: feat: Update to use the new premium subscription API

This commit is contained in:
David Perez
2026-11-04 13:52:46 -06:00
parent 512650c477
commit c852aafc23
6 changed files with 309 additions and 351 deletions
@@ -1,18 +1,13 @@
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.DiscountTypeJson
import com.bitwarden.network.model.InvoicePreviewItemJson
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 const val MONEY_SCALE: Int = 2
/**
* Maps a [BitwardenSubscriptionResponseJson] into a [SubscriptionInfo] domain
@@ -30,29 +25,22 @@ private const val MONEY_SCALE: Int = 2
* does not expose a precomputed total.
*/
fun BitwardenSubscriptionResponseJson.toSubscriptionInfo(): SubscriptionInfo {
val seatsCost = cart.passwordManager.seats.lineTotal()
val storageCost = cart.passwordManager.additionalStorage?.lineTotal()
val subtotal = seatsCost + (storageCost ?: BigDecimal.ZERO)
val cartDiscount = cart.discount?.toDiscountAmount(baseAmount = subtotal)
val seatsDiscount = cart.passwordManager.seats.discount
?.toDiscountAmount(baseAmount = seatsCost)
val discountAmount = listOfNotNull(cartDiscount, seatsDiscount)
.takeIf { it.isNotEmpty() }
?.reduce(BigDecimal::add)
val estimatedTax = cart.estimatedTax
val nextChargeTotal = subtotal -
(discountAmount ?: BigDecimal.ZERO) +
estimatedTax
val seatsCost = invoicePreview.passwordManager.seats?.lineTotal() ?: BigDecimal.ZERO
val storageCost = invoicePreview.passwordManager.additionalStorage?.lineTotal()
val discountAmount = invoicePreview
.discounts
?.map { it.amount }
?.reduce(operation = BigDecimal::add)
return SubscriptionInfo(
status = toPremiumSubscriptionStatus(),
cadence = cart.cadence.toPlanCadence(),
cadence = invoicePreview.cadence.toPlanCadence(),
seatsCost = seatsCost,
storageCost = storageCost,
discountAmount = discountAmount,
estimatedTax = estimatedTax,
nextChargeTotal = nextChargeTotal,
nextCharge = nextCharge,
estimatedTax = invoicePreview.estimatedTax,
nextChargeTotal = invoicePreview.total,
nextCharge = invoicePreview.nextPaymentAttempt,
cancelAt = cancelAt,
canceledDate = canceled,
suspensionDate = suspension,
@@ -77,21 +65,11 @@ private fun BitwardenSubscriptionResponseJson.toPremiumSubscriptionStatus():
SubscriptionStatusJson.INCOMPLETE -> PremiumSubscriptionStatus.UPDATE_PAYMENT
SubscriptionStatusJson.UNPAID -> PremiumSubscriptionStatus.UNPAID
SubscriptionStatusJson.PAST_DUE -> PremiumSubscriptionStatus.PAST_DUE
SubscriptionStatusJson.PAUSED -> PremiumSubscriptionStatus.PAUSED
}
private fun CartItemJson.lineTotal(): BigDecimal = cost.multiply(quantity.toBigDecimal())
private fun InvoicePreviewItemJson.lineTotal(): BigDecimal = cost.multiply(quantity.toBigDecimal())
private fun CadenceTypeJson.toPlanCadence(): PlanCadence = when (this) {
CadenceTypeJson.ANNUALLY -> PlanCadence.ANNUALLY
CadenceTypeJson.MONTHLY -> PlanCadence.MONTHLY
}
private fun BitwardenDiscountJson.toDiscountAmount(baseAmount: BigDecimal): BigDecimal =
when (type) {
DiscountTypeJson.AMOUNT_OFF -> value
DiscountTypeJson.PERCENT_OFF -> {
val percentage = if (value < BigDecimal.ONE) value else value.movePointLeft(2)
baseAmount.multiply(percentage).setScale(MONEY_SCALE, RoundingMode.HALF_UP)
}
}
@@ -5,11 +5,11 @@ 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.GetSubscriptionResponse
import com.bitwarden.network.model.PasswordManagerCartItemsJson
import com.bitwarden.network.model.InvoicePreviewItemJson
import com.bitwarden.network.model.InvoicePreviewJson
import com.bitwarden.network.model.PasswordManagerInvoicePreviewJson
import com.bitwarden.network.model.PortalUrlResponseJson
import com.bitwarden.network.model.PremiumPlanResponseJson
import com.bitwarden.network.model.SubscriptionStatusJson
@@ -308,25 +308,26 @@ private const val ANNUAL_PRICE = 19.99
private val ACTIVE_SUBSCRIPTION_RESPONSE = BitwardenSubscriptionResponseJson(
status = SubscriptionStatusJson.ACTIVE,
cart = CartJson(
passwordManager = PasswordManagerCartItemsJson(
seats = CartItemJson(
translationKey = "premiumMembership",
invoicePreview = InvoicePreviewJson(
passwordManager = PasswordManagerInvoicePreviewJson(
seats = InvoicePreviewItemJson(
quantity = 1,
cost = BigDecimal("19.80"),
discount = null,
discounts = null,
),
additionalStorage = null,
),
secretsManager = null,
cadence = CadenceTypeJson.ANNUALLY,
discount = null,
discounts = null,
startingBalance = null,
estimatedTax = BigDecimal.ZERO,
total = BigDecimal("19.80"),
amountDue = BigDecimal("19.80"),
nextPaymentAttempt = null,
),
storage = null,
cancelAt = null,
canceled = null,
nextCharge = null,
suspension = null,
gracePeriod = null,
)
@@ -1,12 +1,12 @@
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.InvoicePreviewDiscountJson
import com.bitwarden.network.model.InvoicePreviewItemJson
import com.bitwarden.network.model.InvoicePreviewJson
import com.bitwarden.network.model.PasswordManagerInvoicePreviewJson
import com.bitwarden.network.model.StorageJson
import com.bitwarden.network.model.SubscriptionStatusJson
import com.x8bit.bitwarden.data.billing.repository.model.PlanCadence
@@ -61,14 +61,6 @@ class BitwardenSubscriptionResponseJsonExtensionsTest {
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 ACTIVE with cancelAt to PENDING_CANCELLATION`() {
val info = buildResponse(
@@ -119,6 +111,12 @@ class BitwardenSubscriptionResponseJsonExtensionsTest {
assertNull(info.storageCost)
}
@Test
fun `toSubscriptionInfo seatsCost is zero when seats are not present`() {
val info = buildResponse(seatsCost = null).toSubscriptionInfo()
assertEquals(BigDecimal.ZERO, info.seatsCost)
}
@Test
fun `toSubscriptionInfo maps storageCost from additionalStorage when present`() {
val info = buildResponse(
@@ -147,105 +145,57 @@ class BitwardenSubscriptionResponseJsonExtensionsTest {
}
@Test
fun `toSubscriptionInfo discountAmount is null when no discount`() {
val info = buildResponse(discount = null).toSubscriptionInfo()
fun `toSubscriptionInfo discountAmount is null when no discounts`() {
val info = buildResponse(discounts = null).toSubscriptionInfo()
assertNull(info.discountAmount)
}
@Test
fun `toSubscriptionInfo discountAmount for AMOUNT_OFF passes value through`() {
fun `toSubscriptionInfo discountAmount uses the applied amount rather than the value`() {
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"),
discounts = listOf(
buildDiscount(
type = DiscountTypeJson.PERCENT_OFF,
value = BigDecimal("15"),
amount = BigDecimal("4.50"),
),
),
).toSubscriptionInfo()
assertEquals(BigDecimal("4.50"), info.discountAmount)
}
@Test
fun `toSubscriptionInfo PERCENT_OFF discount treats value below one as a decimal fraction`() {
fun `toSubscriptionInfo discountAmount sums the amounts of all invoice discounts`() {
val info = buildResponse(
seatsCost = BigDecimal("20.00"),
discount = BitwardenDiscountJson(
type = DiscountTypeJson.PERCENT_OFF,
value = BigDecimal("0.5"),
discounts = listOf(
buildDiscount(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("2.00"),
amount = BigDecimal("2.00"),
),
buildDiscount(
type = DiscountTypeJson.PERCENT_OFF,
value = BigDecimal("10"),
amount = BigDecimal("1.98"),
),
),
).toSubscriptionInfo()
assertEquals(BigDecimal("10.00"), info.discountAmount)
assertEquals(BigDecimal("3.98"), info.discountAmount)
}
@Test
fun `toSubscriptionInfo applies PM seats item-level discount`() {
fun `toSubscriptionInfo discountAmount ignores item-level discounts`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
seatsDiscount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("5.00"),
),
).toSubscriptionInfo()
assertEquals(BigDecimal("5.00"), info.discountAmount)
assertEquals(BigDecimal("14.80"), info.nextChargeTotal)
}
@Test
fun `toSubscriptionInfo PERCENT_OFF seats item discount applies to seats line total`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
seatsQuantity = 2,
seatsDiscount = BitwardenDiscountJson(
type = DiscountTypeJson.PERCENT_OFF,
value = BigDecimal("10.00"),
),
).toSubscriptionInfo()
assertEquals(BigDecimal("3.96"), info.discountAmount)
}
@Test
fun `toSubscriptionInfo combines cart-level and PM seats item discounts`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
seatsDiscount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("3.00"),
),
discount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("2.00"),
),
estimatedTax = BigDecimal("1.00"),
).toSubscriptionInfo()
assertEquals(BigDecimal("5.00"), info.discountAmount)
assertEquals(BigDecimal("15.80"), info.nextChargeTotal)
}
@Test
fun `toSubscriptionInfo ignores additionalStorage item-level discount`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
storageCost = BigDecimal("4"),
storageQuantity = 3,
storageDiscount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("5.00"),
seatsDiscounts = listOf(
buildDiscount(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("5.00"),
amount = BigDecimal("5.00"),
),
),
discounts = null,
).toSubscriptionInfo()
assertNull(info.discountAmount)
assertEquals(BigDecimal("31.80"), info.nextChargeTotal)
}
@Test
@@ -255,52 +205,23 @@ class BitwardenSubscriptionResponseJsonExtensionsTest {
}
@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
fun `toSubscriptionInfo nextChargeTotal passes the invoice total through`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
storageCost = BigDecimal("24.00"),
discount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("2.10"),
discounts = listOf(
buildDiscount(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("2.10"),
amount = BigDecimal("2.10"),
),
),
estimatedTax = BigDecimal("3.85"),
total = BigDecimal("45.55"),
).toSubscriptionInfo()
assertEquals(BigDecimal("45.55"), info.nextChargeTotal)
}
@Test
fun `toSubscriptionInfo nextChargeTotal multiplies storage line by quantity`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
storageCost = BigDecimal("4"),
storageQuantity = 3,
estimatedTax = BigDecimal("2.04"),
).toSubscriptionInfo()
assertEquals(BigDecimal("33.84"), info.nextChargeTotal)
}
@Test
fun `toSubscriptionInfo PERCENT_OFF discount applies to quantity-multiplied subtotal`() {
val info = buildResponse(
seatsCost = BigDecimal("19.80"),
storageCost = BigDecimal("4"),
storageQuantity = 3,
discount = BitwardenDiscountJson(
type = DiscountTypeJson.PERCENT_OFF,
value = BigDecimal("10.00"),
),
).toSubscriptionInfo()
assertEquals(BigDecimal("3.18"), info.discountAmount)
}
@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")
@@ -308,7 +229,7 @@ class BitwardenSubscriptionResponseJsonExtensionsTest {
val suspension = Instant.parse("2026-05-02T00:00:00Z")
val info = buildResponse(
canceled = canceled,
nextCharge = next,
nextPaymentAttempt = next,
suspension = suspension,
gracePeriod = 14,
).toSubscriptionInfo()
@@ -328,52 +249,66 @@ class BitwardenSubscriptionResponseJsonExtensionsTest {
assertNull(info.gracePeriodDays)
}
private fun buildDiscount(
type: DiscountTypeJson,
value: BigDecimal,
amount: BigDecimal,
): InvoicePreviewDiscountJson = InvoicePreviewDiscountJson(
type = type,
value = value,
amount = amount,
label = null,
durationInMonths = null,
)
@Suppress("LongParameterList")
private fun buildResponse(
status: SubscriptionStatusJson = SubscriptionStatusJson.ACTIVE,
cadence: CadenceTypeJson = CadenceTypeJson.ANNUALLY,
seatsCost: BigDecimal = BigDecimal("19.80"),
seatsCost: BigDecimal? = BigDecimal("19.80"),
seatsQuantity: Long = 1,
seatsDiscount: BitwardenDiscountJson? = null,
seatsDiscounts: List<InvoicePreviewDiscountJson>? = null,
storageCost: BigDecimal? = null,
storageQuantity: Long = 1,
storageDiscount: BitwardenDiscountJson? = null,
discount: BitwardenDiscountJson? = null,
discounts: List<InvoicePreviewDiscountJson>? = null,
estimatedTax: BigDecimal = BigDecimal.ZERO,
total: BigDecimal = BigDecimal("19.80"),
storage: StorageJson? = null,
cancelAt: Instant? = null,
canceled: Instant? = null,
nextCharge: Instant? = null,
nextPaymentAttempt: Instant? = null,
suspension: Instant? = null,
gracePeriod: Int? = null,
): BitwardenSubscriptionResponseJson = BitwardenSubscriptionResponseJson(
status = status,
cart = CartJson(
passwordManager = PasswordManagerCartItemsJson(
seats = CartItemJson(
translationKey = "premiumMembership",
quantity = seatsQuantity,
cost = seatsCost,
discount = seatsDiscount,
),
invoicePreview = InvoicePreviewJson(
passwordManager = PasswordManagerInvoicePreviewJson(
seats = seatsCost?.let {
InvoicePreviewItemJson(
quantity = seatsQuantity,
cost = it,
discounts = seatsDiscounts,
)
},
additionalStorage = storageCost?.let {
CartItemJson(
translationKey = "additionalStorage",
InvoicePreviewItemJson(
quantity = storageQuantity,
cost = it,
discount = storageDiscount,
discounts = null,
)
},
),
secretsManager = null,
cadence = cadence,
discount = discount,
discounts = discounts,
startingBalance = null,
estimatedTax = estimatedTax,
total = total,
amountDue = total,
nextPaymentAttempt = nextPaymentAttempt,
),
storage = storage,
cancelAt = cancelAt,
canceled = canceled,
nextCharge = nextCharge,
suspension = suspension,
gracePeriod = gracePeriod,
)
@@ -38,6 +38,6 @@ internal interface AuthenticatedBillingApi {
/**
* Retrieves the user's premium subscription details.
*/
@GET("/account/billing/vnext/subscription")
@GET("/account/billing/subscription/preview")
suspend fun getSubscription(): NetworkResult<BitwardenSubscriptionResponseJson>
}
@@ -10,11 +10,10 @@ 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 invoicePreview The invoice preview 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.
*/
@@ -23,8 +22,8 @@ data class BitwardenSubscriptionResponseJson(
@SerialName("status")
val status: SubscriptionStatusJson,
@SerialName("cart")
val cart: CartJson,
@SerialName("invoicePreview")
val invoicePreview: InvoicePreviewJson,
@SerialName("storage")
val storage: StorageJson?,
@@ -37,10 +36,6 @@ data class BitwardenSubscriptionResponseJson(
@SerialName("canceled")
val canceled: Instant?,
@Contextual
@SerialName("nextCharge")
val nextCharge: Instant?,
@Contextual
@SerialName("suspension")
val suspension: Instant?,
@@ -74,83 +69,76 @@ enum class SubscriptionStatusJson {
@SerialName("trialing")
TRIALING,
@SerialName("paused")
PAUSED,
}
/**
* Represents the cart details of a subscription.
* Represents the invoice preview details of a subscription.
*
* @property passwordManager The password manager cart items.
* @property secretsManager The secrets manager cart items, if applicable.
* @property passwordManager The password manager invoice preview items.
* @property cadence The billing cadence of the subscription.
* @property discount The discount applied to the cart, if applicable.
* @property discounts The discounts applied to the invoice, if applicable.
* @property startingBalance The starting balance of the invoice, if applicable.
* @property estimatedTax The estimated tax amount.
* @property total The total amount of the invoice.
* @property amountDue The amount due on the invoice.
* @property nextPaymentAttempt The billing-period boundary of the subscription, if applicable.
*/
@Serializable
data class CartJson(
data class InvoicePreviewJson(
@SerialName("passwordManager")
val passwordManager: PasswordManagerCartItemsJson,
@SerialName("secretsManager")
val secretsManager: SecretsManagerCartItemsJson?,
val passwordManager: PasswordManagerInvoicePreviewJson,
@SerialName("cadence")
val cadence: CadenceTypeJson,
@SerialName("discount")
val discount: BitwardenDiscountJson?,
@SerialName("discounts")
val discounts: List<InvoicePreviewDiscountJson>?,
@Contextual
@SerialName("startingBalance")
val startingBalance: BigDecimal?,
@Contextual
@SerialName("estimatedTax")
val estimatedTax: BigDecimal,
@Contextual
@SerialName("total")
val total: BigDecimal,
@Contextual
@SerialName("amountDue")
val amountDue: BigDecimal,
@Contextual
@SerialName("nextPaymentAttempt")
val nextPaymentAttempt: Instant?,
)
/**
* Represents the password manager cart items within a subscription.
* Represents the password manager invoice preview items within a subscription.
*
* @property seats The seat pricing details.
* @property seats The seat pricing details, if applicable.
* @property additionalStorage The additional storage pricing details, if applicable.
*/
@Serializable
data class PasswordManagerCartItemsJson(
data class PasswordManagerInvoicePreviewJson(
@SerialName("seats")
val seats: CartItemJson,
val seats: InvoicePreviewItemJson?,
@SerialName("additionalStorage")
val additionalStorage: CartItemJson?,
val additionalStorage: InvoicePreviewItemJson?,
)
/**
* Represents the secrets manager cart items within a subscription.
* Represents a single invoice preview item 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.
* @property discounts The discounts applied to this item, if applicable.
*/
@Serializable
data class CartItemJson(
@SerialName("translationKey")
val translationKey: String,
data class InvoicePreviewItemJson(
@SerialName("quantity")
val quantity: Long,
@@ -158,24 +146,37 @@ data class CartItemJson(
@SerialName("cost")
val cost: BigDecimal,
@SerialName("discount")
val discount: BitwardenDiscountJson?,
@SerialName("discounts")
val discounts: List<InvoicePreviewDiscountJson>?,
)
/**
* Represents a discount applied to a subscription or cart item.
* Represents a discount applied to an invoice preview or invoice preview item.
*
* @property type The type of discount.
* @property value The discount value.
* @property value The discount value, either a whole-number percentage or dollars off.
* @property amount The applied discount amount in dollars.
* @property label The coupon name, if applicable.
* @property durationInMonths The number of months the coupon repeats for, if applicable.
*/
@Serializable
data class BitwardenDiscountJson(
data class InvoicePreviewDiscountJson(
@SerialName("type")
val type: DiscountTypeJson,
@Contextual
@SerialName("value")
val value: BigDecimal,
@Contextual
@SerialName("amount")
val amount: BigDecimal,
@SerialName("label")
val label: String?,
@SerialName("durationInMonths")
val durationInMonths: Int?,
)
/**
@@ -3,15 +3,15 @@ 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.GetSubscriptionResponse
import com.bitwarden.network.model.PasswordManagerCartItemsJson
import com.bitwarden.network.model.InvoicePreviewDiscountJson
import com.bitwarden.network.model.InvoicePreviewItemJson
import com.bitwarden.network.model.InvoicePreviewJson
import com.bitwarden.network.model.PasswordManagerInvoicePreviewJson
import com.bitwarden.network.model.PortalUrlResponseJson
import com.bitwarden.network.model.PremiumPlanResponseJson
import com.bitwarden.network.model.StorageJson
@@ -159,7 +159,7 @@ class BillingServiceTest : BaseServiceTest() {
}
@Test
fun `getSubscription with null storage and discount should parse`() =
fun `getSubscription with null storage and discounts should parse`() =
runTest {
val response = MockResponse()
.setBody(SUBSCRIPTION_RESPONSE_MINIMAL_JSON)
@@ -276,28 +276,34 @@ private val PREMIUM_PLAN_RESPONSE = PremiumPlanResponseJson(
private const val SUBSCRIPTION_RESPONSE_JSON = """
{
"status": "active",
"cart": {
"invoicePreview": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
"discounts": null
},
"additionalStorage": {
"translationKey": "additionalStorage",
"quantity": 24,
"cost": 24.00,
"discount": null
"discounts": null
}
},
"secretsManager": null,
"cadence": "annually",
"discount": {
"type": "amount-off",
"value": 2.10
},
"estimatedTax": 3.85
"discounts": [
{
"type": "amount-off",
"value": 2.10,
"amount": 2.10,
"label": "Welcome Coupon",
"durationInMonths": 12
}
],
"startingBalance": 0,
"estimatedTax": 3.85,
"total": 45.55,
"amountDue": 45.55,
"nextPaymentAttempt": "2026-04-02T00:00:00Z"
},
"storage": {
"available": 5,
@@ -306,7 +312,6 @@ private const val SUBSCRIPTION_RESPONSE_JSON = """
},
"cancelAt": null,
"canceled": null,
"nextCharge": "2026-04-02T00:00:00Z",
"suspension": null,
"gracePeriod": null
}
@@ -314,28 +319,34 @@ private const val SUBSCRIPTION_RESPONSE_JSON = """
private val SUBSCRIPTION_RESPONSE = BitwardenSubscriptionResponseJson(
status = SubscriptionStatusJson.ACTIVE,
cart = CartJson(
passwordManager = PasswordManagerCartItemsJson(
seats = CartItemJson(
translationKey = "premiumMembership",
invoicePreview = InvoicePreviewJson(
passwordManager = PasswordManagerInvoicePreviewJson(
seats = InvoicePreviewItemJson(
quantity = 1,
cost = BigDecimal("19.80"),
discount = null,
discounts = null,
),
additionalStorage = CartItemJson(
translationKey = "additionalStorage",
additionalStorage = InvoicePreviewItemJson(
quantity = 24,
cost = BigDecimal("24.00"),
discount = null,
discounts = null,
),
),
secretsManager = null,
cadence = CadenceTypeJson.ANNUALLY,
discount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("2.10"),
discounts = listOf(
InvoicePreviewDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("2.10"),
amount = BigDecimal("2.10"),
label = "Welcome Coupon",
durationInMonths = 12,
),
),
startingBalance = BigDecimal("0"),
estimatedTax = BigDecimal("3.85"),
total = BigDecimal("45.55"),
amountDue = BigDecimal("45.55"),
nextPaymentAttempt = Instant.parse("2026-04-02T00:00:00Z"),
),
storage = StorageJson(
available = 5,
@@ -344,7 +355,6 @@ private val SUBSCRIPTION_RESPONSE = BitwardenSubscriptionResponseJson(
),
cancelAt = null,
canceled = null,
nextCharge = Instant.parse("2026-04-02T00:00:00Z"),
suspension = null,
gracePeriod = null,
)
@@ -352,25 +362,26 @@ private val SUBSCRIPTION_RESPONSE = BitwardenSubscriptionResponseJson(
private const val SUBSCRIPTION_RESPONSE_MONTHLY_JSON = """
{
"status": "active",
"cart": {
"invoicePreview": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 1.67,
"discount": null
"discounts": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "monthly",
"discount": null,
"estimatedTax": 0
"discounts": null,
"startingBalance": null,
"estimatedTax": 0,
"total": 1.67,
"amountDue": 1.67,
"nextPaymentAttempt": null
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
@@ -378,25 +389,26 @@ private const val SUBSCRIPTION_RESPONSE_MONTHLY_JSON = """
private val SUBSCRIPTION_RESPONSE_MONTHLY = BitwardenSubscriptionResponseJson(
status = SubscriptionStatusJson.ACTIVE,
cart = CartJson(
passwordManager = PasswordManagerCartItemsJson(
seats = CartItemJson(
translationKey = "premiumMembership",
invoicePreview = InvoicePreviewJson(
passwordManager = PasswordManagerInvoicePreviewJson(
seats = InvoicePreviewItemJson(
quantity = 1,
cost = BigDecimal("1.67"),
discount = null,
discounts = null,
),
additionalStorage = null,
),
secretsManager = null,
cadence = CadenceTypeJson.MONTHLY,
discount = null,
discounts = null,
startingBalance = null,
estimatedTax = BigDecimal("0"),
total = BigDecimal("1.67"),
amountDue = BigDecimal("1.67"),
nextPaymentAttempt = null,
),
storage = null,
cancelAt = null,
canceled = null,
nextCharge = null,
suspension = null,
gracePeriod = null,
)
@@ -404,25 +416,26 @@ private val SUBSCRIPTION_RESPONSE_MONTHLY = BitwardenSubscriptionResponseJson(
private const val SUBSCRIPTION_RESPONSE_MINIMAL_JSON = """
{
"status": "active",
"cart": {
"invoicePreview": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
"discounts": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "annually",
"discount": null,
"estimatedTax": 0
"discounts": null,
"startingBalance": null,
"estimatedTax": 0,
"total": 19.80,
"amountDue": 19.80,
"nextPaymentAttempt": null
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
@@ -430,25 +443,26 @@ private const val SUBSCRIPTION_RESPONSE_MINIMAL_JSON = """
private val SUBSCRIPTION_RESPONSE_MINIMAL = BitwardenSubscriptionResponseJson(
status = SubscriptionStatusJson.ACTIVE,
cart = CartJson(
passwordManager = PasswordManagerCartItemsJson(
seats = CartItemJson(
translationKey = "premiumMembership",
invoicePreview = InvoicePreviewJson(
passwordManager = PasswordManagerInvoicePreviewJson(
seats = InvoicePreviewItemJson(
quantity = 1,
cost = BigDecimal("19.80"),
discount = null,
discounts = null,
),
additionalStorage = null,
),
secretsManager = null,
cadence = CadenceTypeJson.ANNUALLY,
discount = null,
discounts = null,
startingBalance = null,
estimatedTax = BigDecimal("0"),
total = BigDecimal("19.80"),
amountDue = BigDecimal("19.80"),
nextPaymentAttempt = null,
),
storage = null,
cancelAt = null,
canceled = null,
nextCharge = null,
suspension = null,
gracePeriod = null,
)
@@ -456,38 +470,49 @@ private val SUBSCRIPTION_RESPONSE_MINIMAL = BitwardenSubscriptionResponseJson(
private const val SUBSCRIPTION_RESPONSE_AMOUNT_OFF_JSON = """
{
"status": "active",
"cart": {
"invoicePreview": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
"discounts": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "annually",
"discount": {
"type": "amount-off",
"value": 5.00
},
"estimatedTax": 0
"discounts": [
{
"type": "amount-off",
"value": 5.00,
"amount": 5.00,
"label": null,
"durationInMonths": null
}
],
"startingBalance": null,
"estimatedTax": 0,
"total": 19.80,
"amountDue": 19.80,
"nextPaymentAttempt": null
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
"""
private val SUBSCRIPTION_RESPONSE_AMOUNT_OFF = SUBSCRIPTION_RESPONSE_MINIMAL.copy(
cart = SUBSCRIPTION_RESPONSE_MINIMAL.cart.copy(
discount = BitwardenDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("5.00"),
invoicePreview = SUBSCRIPTION_RESPONSE_MINIMAL.invoicePreview.copy(
discounts = listOf(
InvoicePreviewDiscountJson(
type = DiscountTypeJson.AMOUNT_OFF,
value = BigDecimal("5.00"),
amount = BigDecimal("5.00"),
label = null,
durationInMonths = null,
),
),
),
)
@@ -495,38 +520,55 @@ private val SUBSCRIPTION_RESPONSE_AMOUNT_OFF = SUBSCRIPTION_RESPONSE_MINIMAL.cop
private const val SUBSCRIPTION_RESPONSE_PERCENT_OFF_JSON = """
{
"status": "active",
"cart": {
"invoicePreview": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
"discounts": [
{
"type": "percent-off",
"value": 15,
"amount": 2.97,
"label": null,
"durationInMonths": null
}
]
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "annually",
"discount": {
"type": "percent-off",
"value": 15.00
},
"estimatedTax": 0
"discounts": null,
"startingBalance": null,
"estimatedTax": 0,
"total": 19.80,
"amountDue": 19.80,
"nextPaymentAttempt": null
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
"""
private val SUBSCRIPTION_RESPONSE_PERCENT_OFF = SUBSCRIPTION_RESPONSE_MINIMAL.copy(
cart = SUBSCRIPTION_RESPONSE_MINIMAL.cart.copy(
discount = BitwardenDiscountJson(
type = DiscountTypeJson.PERCENT_OFF,
value = BigDecimal("15.00"),
invoicePreview = SUBSCRIPTION_RESPONSE_MINIMAL.invoicePreview.copy(
passwordManager = SUBSCRIPTION_RESPONSE_MINIMAL.invoicePreview.passwordManager.copy(
seats = InvoicePreviewItemJson(
quantity = 1,
cost = BigDecimal("19.80"),
discounts = listOf(
InvoicePreviewDiscountJson(
type = DiscountTypeJson.PERCENT_OFF,
value = BigDecimal("15"),
amount = BigDecimal("2.97"),
label = null,
durationInMonths = null,
),
),
),
),
),
)
@@ -534,25 +576,26 @@ private val SUBSCRIPTION_RESPONSE_PERCENT_OFF = SUBSCRIPTION_RESPONSE_MINIMAL.co
private const val SUBSCRIPTION_RESPONSE_UNKNOWN_CADENCE_JSON = """
{
"status": "active",
"cart": {
"invoicePreview": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
"discounts": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "weekly",
"discount": null,
"estimatedTax": 0
"discounts": null,
"startingBalance": null,
"estimatedTax": 0,
"total": 19.80,
"amountDue": 19.80,
"nextPaymentAttempt": null
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}
@@ -569,30 +612,30 @@ private fun subscriptionResponseJsonForStatus(
SubscriptionStatusJson.INCOMPLETE_EXPIRED -> "incomplete_expired"
SubscriptionStatusJson.UNPAID -> "unpaid"
SubscriptionStatusJson.TRIALING -> "trialing"
SubscriptionStatusJson.PAUSED -> "paused"
}
return """
{
"status": "$wireValue",
"cart": {
"invoicePreview": {
"passwordManager": {
"seats": {
"translationKey": "premiumMembership",
"quantity": 1,
"cost": 19.80,
"discount": null
"discounts": null
},
"additionalStorage": null
},
"secretsManager": null,
"cadence": "annually",
"discount": null,
"estimatedTax": 0
"discounts": null,
"startingBalance": null,
"estimatedTax": 0,
"total": 19.80,
"amountDue": 19.80,
"nextPaymentAttempt": null
},
"storage": null,
"cancelAt": null,
"canceled": null,
"nextCharge": null,
"suspension": null,
"gracePeriod": null
}