diff --git a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt index 67c105f099f..b5bfab1dd69 100644 --- a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt +++ b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt @@ -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. */ diff --git a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt index b7027ce6409..229cfcd6e8d 100644 --- a/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt +++ b/network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt @@ -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(), diff --git a/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedBillingApi.kt b/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedBillingApi.kt new file mode 100644 index 00000000000..6ffb0342f0f --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/api/AuthenticatedBillingApi.kt @@ -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 + + /** + * Creates a Stripe customer portal session for managing the premium subscription. + */ + @POST("/account/billing/vnext/portal-session") + suspend fun getPortalUrl(): NetworkResult +} diff --git a/network/src/main/kotlin/com/bitwarden/network/model/CheckoutSessionRequestJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/CheckoutSessionRequestJson.kt new file mode 100644 index 00000000000..41a4ff9d7e0 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/CheckoutSessionRequestJson.kt @@ -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, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/model/CheckoutSessionResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/CheckoutSessionResponseJson.kt new file mode 100644 index 00000000000..201c0b14996 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/CheckoutSessionResponseJson.kt @@ -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, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/model/PortalUrlResponseJson.kt b/network/src/main/kotlin/com/bitwarden/network/model/PortalUrlResponseJson.kt new file mode 100644 index 00000000000..831ac4b26d9 --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/model/PortalUrlResponseJson.kt @@ -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, +) diff --git a/network/src/main/kotlin/com/bitwarden/network/service/BillingService.kt b/network/src/main/kotlin/com/bitwarden/network/service/BillingService.kt new file mode 100644 index 00000000000..1a30e19cdce --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/service/BillingService.kt @@ -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 + + /** + * Creates a Stripe customer portal session for managing the premium subscription. + */ + suspend fun getPortalUrl(): 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 new file mode 100644 index 00000000000..0b52ba3ca8e --- /dev/null +++ b/network/src/main/kotlin/com/bitwarden/network/service/BillingServiceImpl.kt @@ -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 = + authenticatedBillingApi + .createCheckoutSession( + body = CheckoutSessionRequestJson(platform = PLATFORM), + ) + .toResult() + + override suspend fun getPortalUrl(): Result = + authenticatedBillingApi + .getPortalUrl() + .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 new file mode 100644 index 00000000000..10c515ac058 --- /dev/null +++ b/network/src/test/kotlin/com/bitwarden/network/service/BillingServiceTest.kt @@ -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", +)