Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -70,6 +71,11 @@ interface BitwardenServiceClient {
*/
val authRequestsService: AuthRequestsService

/**
* Provides access to the Billing service.
*/
val billingService: BillingService

/**
* Provides access to the Ciphers service.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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>
}
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
@@ -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>
}
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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",
)
Loading