From c852aafc23888bf7b2d29b254aec52f1161d95ae Mon Sep 17 00:00:00 2001 From: David Perez Date: Wed, 4 Nov 2026 13:52:46 -0600 Subject: [PATCH] PM-46261: feat: Update to use the new premium subscription API --- ...ardenSubscriptionResponseJsonExtensions.kt | 46 +--- .../repository/BillingRepositoryTest.kt | 23 +- ...nSubscriptionResponseJsonExtensionsTest.kt | 229 ++++++---------- .../network/api/AuthenticatedBillingApi.kt | 2 +- .../BitwardenSubscriptionResponseJson.kt | 111 ++++---- .../network/service/BillingServiceTest.kt | 249 ++++++++++-------- 6 files changed, 309 insertions(+), 351 deletions(-) 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 index 684c614474..7d12e0c5c3 100644 --- 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 @@ -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) - } - } 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 d26ff272a9..96c1c54680 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 @@ -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, ) 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 index 783a69f787..cd77a58fd6 100644 --- 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 @@ -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? = null, storageCost: BigDecimal? = null, storageQuantity: Long = 1, - storageDiscount: BitwardenDiscountJson? = null, - discount: BitwardenDiscountJson? = null, + discounts: List? = 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, ) 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 e1220c68d5..3f7c2fe8dd 100644 --- a/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedBillingApi.kt +++ b/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedBillingApi.kt @@ -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 } diff --git a/network/src/main/kotlin/com/bitwarden/network/model/BitwardenSubscriptionResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/BitwardenSubscriptionResponseJson.kt index 7288df28e0..b9f39f7782 100644 --- a/network/src/main/kotlin/com/bitwarden/network/model/BitwardenSubscriptionResponseJson.kt +++ b/network/src/main/kotlin/com/bitwarden/network/model/BitwardenSubscriptionResponseJson.kt @@ -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?, + + @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?, ) /** - * 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?, ) /** 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 f294f04ae1..576315b6b2 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,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 }