[PM-33508] feat: Add AuthenticatedBillingApi and BillingService network layer (#6668)

This commit is contained in:
Patrick Honkonen
2026-03-18 16:23:09 +00:00
committed by GitHub
parent 04c3147a56
commit 6d04c04929
9 changed files with 215 additions and 0 deletions
@@ -9,6 +9,7 @@ import com.bitwarden.network.provider.RefreshTokenProvider
import com.bitwarden.network.provider.TokenProvider
import com.bitwarden.network.service.AccountsService
import com.bitwarden.network.service.AuthRequestsService
import com.bitwarden.network.service.BillingService
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.ConfigService
import com.bitwarden.network.service.DevicesService
@@ -70,6 +71,11 @@ interface BitwardenServiceClient {
*/
val authRequestsService: AuthRequestsService
/**
* Provides access to the Billing service.
*/
val billingService: BillingService
/**
* Provides access to the Ciphers service.
*/
@@ -15,6 +15,8 @@ import com.bitwarden.network.retrofit.RetrofitsImpl
import com.bitwarden.network.service.AccountsServiceImpl
import com.bitwarden.network.service.AuthRequestsService
import com.bitwarden.network.service.AuthRequestsServiceImpl
import com.bitwarden.network.service.BillingService
import com.bitwarden.network.service.BillingServiceImpl
import com.bitwarden.network.service.CiphersService
import com.bitwarden.network.service.CiphersServiceImpl
import com.bitwarden.network.service.ConfigService
@@ -115,6 +117,12 @@ internal class BitwardenServiceClientImpl(
)
}
override val billingService: BillingService by lazy {
BillingServiceImpl(
authenticatedBillingApi = retrofits.authenticatedApiRetrofit.create(),
)
}
override val ciphersService: CiphersService by lazy {
CiphersServiceImpl(
azureApi = retrofits.createStaticRetrofit().create(),
@@ -0,0 +1,28 @@
package com.bitwarden.network.api
import com.bitwarden.network.model.CheckoutSessionRequestJson
import com.bitwarden.network.model.CheckoutSessionResponseJson
import com.bitwarden.network.model.NetworkResult
import com.bitwarden.network.model.PortalUrlResponseJson
import retrofit2.http.Body
import retrofit2.http.POST
/**
* Defines raw calls under the /account/billing API with authentication applied.
*/
internal interface AuthenticatedBillingApi {
/**
* Creates a Stripe checkout session for premium upgrade.
*/
@POST("/account/billing/vnext/premium/checkout")
suspend fun createCheckoutSession(
@Body body: CheckoutSessionRequestJson,
): NetworkResult<CheckoutSessionResponseJson>
/**
* Creates a Stripe customer portal session for managing the premium subscription.
*/
@POST("/account/billing/vnext/portal-session")
suspend fun getPortalUrl(): NetworkResult<PortalUrlResponseJson>
}
@@ -0,0 +1,15 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request object for creating a Stripe checkout session for premium upgrade.
*
* @property platform The platform identifier (e.g., "android" or "ios").
*/
@Serializable
data class CheckoutSessionRequestJson(
@SerialName("platform")
val platform: String,
)
@@ -0,0 +1,15 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Response object returned when creating a premium checkout session.
*
* @property checkoutSessionUrl The Stripe checkout URL for premium upgrade.
*/
@Serializable
data class CheckoutSessionResponseJson(
@SerialName("checkoutSessionUrl")
val checkoutSessionUrl: String,
)
@@ -0,0 +1,15 @@
package com.bitwarden.network.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Response object returned when requesting a Stripe customer portal session.
*
* @property url The Stripe customer portal URL.
*/
@Serializable
data class PortalUrlResponseJson(
@SerialName("url")
val url: String,
)
@@ -0,0 +1,20 @@
package com.bitwarden.network.service
import com.bitwarden.network.model.CheckoutSessionResponseJson
import com.bitwarden.network.model.PortalUrlResponseJson
/**
* Provides an API for interacting with the billing endpoints.
*/
interface BillingService {
/**
* Creates a Stripe checkout session for premium upgrade.
*/
suspend fun createCheckoutSession(): Result<CheckoutSessionResponseJson>
/**
* Creates a Stripe customer portal session for managing the premium subscription.
*/
suspend fun getPortalUrl(): Result<PortalUrlResponseJson>
}
@@ -0,0 +1,29 @@
package com.bitwarden.network.service
import com.bitwarden.network.api.AuthenticatedBillingApi
import com.bitwarden.network.model.CheckoutSessionRequestJson
import com.bitwarden.network.model.CheckoutSessionResponseJson
import com.bitwarden.network.model.PortalUrlResponseJson
import com.bitwarden.network.util.toResult
private const val PLATFORM = "android"
/**
* The default implementation of the [BillingService].
*/
internal class BillingServiceImpl(
private val authenticatedBillingApi: AuthenticatedBillingApi,
) : BillingService {
override suspend fun createCheckoutSession(): Result<CheckoutSessionResponseJson> =
authenticatedBillingApi
.createCheckoutSession(
body = CheckoutSessionRequestJson(platform = PLATFORM),
)
.toResult()
override suspend fun getPortalUrl(): Result<PortalUrlResponseJson> =
authenticatedBillingApi
.getPortalUrl()
.toResult()
}
@@ -0,0 +1,79 @@
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.CheckoutSessionResponseJson
import com.bitwarden.network.model.PortalUrlResponseJson
import kotlinx.coroutines.test.runTest
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
class BillingServiceTest : BaseServiceTest() {
private val billingApi: AuthenticatedBillingApi = retrofit.create()
private val service = BillingServiceImpl(
authenticatedBillingApi = billingApi,
)
@Test
fun `createCheckoutSession when response is Failure should return Failure`() =
runTest {
val response = MockResponse().setResponseCode(400)
server.enqueue(response)
val actual = service.createCheckoutSession()
assertTrue(actual.isFailure)
}
@Test
fun `createCheckoutSession when response is Success should return Success`() =
runTest {
val response = MockResponse()
.setBody(CHECKOUT_SESSION_RESPONSE_JSON)
.setResponseCode(200)
server.enqueue(response)
val actual = service.createCheckoutSession()
assertEquals(CHECKOUT_SESSION_RESPONSE.asSuccess(), actual)
}
@Test
fun `getPortalUrl when response is Failure should return Failure`() = runTest {
val response = MockResponse().setResponseCode(400)
server.enqueue(response)
val actual = service.getPortalUrl()
assertTrue(actual.isFailure)
}
@Test
fun `getPortalUrl when response is Success should return Success`() = runTest {
val response = MockResponse()
.setBody(PORTAL_URL_RESPONSE_JSON)
.setResponseCode(200)
server.enqueue(response)
val actual = service.getPortalUrl()
assertEquals(PORTAL_URL_RESPONSE.asSuccess(), actual)
}
}
private const val CHECKOUT_SESSION_RESPONSE_JSON = """
{
"checkoutSessionUrl": "https://checkout.stripe.com/c/pay/test_session_123"
}
"""
private val CHECKOUT_SESSION_RESPONSE = CheckoutSessionResponseJson(
checkoutSessionUrl = "https://checkout.stripe.com/c/pay/test_session_123",
)
private const val PORTAL_URL_RESPONSE_JSON = """
{
"url": "https://billing.stripe.com/p/session/test_portal_456"
}
"""
private val PORTAL_URL_RESPONSE = PortalUrlResponseJson(
url = "https://billing.stripe.com/p/session/test_portal_456",
)