Skip to content

Commit 6d04c04

Browse files
authored
[PM-33508] feat: Add AuthenticatedBillingApi and BillingService network layer (#6668)
1 parent 04c3147 commit 6d04c04

9 files changed

Lines changed: 215 additions & 0 deletions

File tree

network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClient.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.bitwarden.network.provider.RefreshTokenProvider
99
import com.bitwarden.network.provider.TokenProvider
1010
import com.bitwarden.network.service.AccountsService
1111
import com.bitwarden.network.service.AuthRequestsService
12+
import com.bitwarden.network.service.BillingService
1213
import com.bitwarden.network.service.CiphersService
1314
import com.bitwarden.network.service.ConfigService
1415
import com.bitwarden.network.service.DevicesService
@@ -70,6 +71,11 @@ interface BitwardenServiceClient {
7071
*/
7172
val authRequestsService: AuthRequestsService
7273

74+
/**
75+
* Provides access to the Billing service.
76+
*/
77+
val billingService: BillingService
78+
7379
/**
7480
* Provides access to the Ciphers service.
7581
*/

network/src/main/kotlin/com/bitwarden/network/BitwardenServiceClientImpl.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import com.bitwarden.network.retrofit.RetrofitsImpl
1515
import com.bitwarden.network.service.AccountsServiceImpl
1616
import com.bitwarden.network.service.AuthRequestsService
1717
import com.bitwarden.network.service.AuthRequestsServiceImpl
18+
import com.bitwarden.network.service.BillingService
19+
import com.bitwarden.network.service.BillingServiceImpl
1820
import com.bitwarden.network.service.CiphersService
1921
import com.bitwarden.network.service.CiphersServiceImpl
2022
import com.bitwarden.network.service.ConfigService
@@ -115,6 +117,12 @@ internal class BitwardenServiceClientImpl(
115117
)
116118
}
117119

120+
override val billingService: BillingService by lazy {
121+
BillingServiceImpl(
122+
authenticatedBillingApi = retrofits.authenticatedApiRetrofit.create(),
123+
)
124+
}
125+
118126
override val ciphersService: CiphersService by lazy {
119127
CiphersServiceImpl(
120128
azureApi = retrofits.createStaticRetrofit().create(),
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.bitwarden.network.api
2+
3+
import com.bitwarden.network.model.CheckoutSessionRequestJson
4+
import com.bitwarden.network.model.CheckoutSessionResponseJson
5+
import com.bitwarden.network.model.NetworkResult
6+
import com.bitwarden.network.model.PortalUrlResponseJson
7+
import retrofit2.http.Body
8+
import retrofit2.http.POST
9+
10+
/**
11+
* Defines raw calls under the /account/billing API with authentication applied.
12+
*/
13+
internal interface AuthenticatedBillingApi {
14+
15+
/**
16+
* Creates a Stripe checkout session for premium upgrade.
17+
*/
18+
@POST("/account/billing/vnext/premium/checkout")
19+
suspend fun createCheckoutSession(
20+
@Body body: CheckoutSessionRequestJson,
21+
): NetworkResult<CheckoutSessionResponseJson>
22+
23+
/**
24+
* Creates a Stripe customer portal session for managing the premium subscription.
25+
*/
26+
@POST("/account/billing/vnext/portal-session")
27+
suspend fun getPortalUrl(): NetworkResult<PortalUrlResponseJson>
28+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.bitwarden.network.model
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
/**
7+
* Request object for creating a Stripe checkout session for premium upgrade.
8+
*
9+
* @property platform The platform identifier (e.g., "android" or "ios").
10+
*/
11+
@Serializable
12+
data class CheckoutSessionRequestJson(
13+
@SerialName("platform")
14+
val platform: String,
15+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.bitwarden.network.model
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
/**
7+
* Response object returned when creating a premium checkout session.
8+
*
9+
* @property checkoutSessionUrl The Stripe checkout URL for premium upgrade.
10+
*/
11+
@Serializable
12+
data class CheckoutSessionResponseJson(
13+
@SerialName("checkoutSessionUrl")
14+
val checkoutSessionUrl: String,
15+
)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.bitwarden.network.model
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
/**
7+
* Response object returned when requesting a Stripe customer portal session.
8+
*
9+
* @property url The Stripe customer portal URL.
10+
*/
11+
@Serializable
12+
data class PortalUrlResponseJson(
13+
@SerialName("url")
14+
val url: String,
15+
)
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.bitwarden.network.service
2+
3+
import com.bitwarden.network.model.CheckoutSessionResponseJson
4+
import com.bitwarden.network.model.PortalUrlResponseJson
5+
6+
/**
7+
* Provides an API for interacting with the billing endpoints.
8+
*/
9+
interface BillingService {
10+
11+
/**
12+
* Creates a Stripe checkout session for premium upgrade.
13+
*/
14+
suspend fun createCheckoutSession(): Result<CheckoutSessionResponseJson>
15+
16+
/**
17+
* Creates a Stripe customer portal session for managing the premium subscription.
18+
*/
19+
suspend fun getPortalUrl(): Result<PortalUrlResponseJson>
20+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.bitwarden.network.service
2+
3+
import com.bitwarden.network.api.AuthenticatedBillingApi
4+
import com.bitwarden.network.model.CheckoutSessionRequestJson
5+
import com.bitwarden.network.model.CheckoutSessionResponseJson
6+
import com.bitwarden.network.model.PortalUrlResponseJson
7+
import com.bitwarden.network.util.toResult
8+
9+
private const val PLATFORM = "android"
10+
11+
/**
12+
* The default implementation of the [BillingService].
13+
*/
14+
internal class BillingServiceImpl(
15+
private val authenticatedBillingApi: AuthenticatedBillingApi,
16+
) : BillingService {
17+
18+
override suspend fun createCheckoutSession(): Result<CheckoutSessionResponseJson> =
19+
authenticatedBillingApi
20+
.createCheckoutSession(
21+
body = CheckoutSessionRequestJson(platform = PLATFORM),
22+
)
23+
.toResult()
24+
25+
override suspend fun getPortalUrl(): Result<PortalUrlResponseJson> =
26+
authenticatedBillingApi
27+
.getPortalUrl()
28+
.toResult()
29+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package com.bitwarden.network.service
2+
3+
import com.bitwarden.core.data.util.asSuccess
4+
import com.bitwarden.network.api.AuthenticatedBillingApi
5+
import com.bitwarden.network.base.BaseServiceTest
6+
import com.bitwarden.network.model.CheckoutSessionResponseJson
7+
import com.bitwarden.network.model.PortalUrlResponseJson
8+
import kotlinx.coroutines.test.runTest
9+
import okhttp3.mockwebserver.MockResponse
10+
import org.junit.jupiter.api.Assertions.assertEquals
11+
import org.junit.jupiter.api.Assertions.assertTrue
12+
import org.junit.jupiter.api.Test
13+
import retrofit2.create
14+
15+
class BillingServiceTest : BaseServiceTest() {
16+
17+
private val billingApi: AuthenticatedBillingApi = retrofit.create()
18+
private val service = BillingServiceImpl(
19+
authenticatedBillingApi = billingApi,
20+
)
21+
22+
@Test
23+
fun `createCheckoutSession when response is Failure should return Failure`() =
24+
runTest {
25+
val response = MockResponse().setResponseCode(400)
26+
server.enqueue(response)
27+
val actual = service.createCheckoutSession()
28+
assertTrue(actual.isFailure)
29+
}
30+
31+
@Test
32+
fun `createCheckoutSession when response is Success should return Success`() =
33+
runTest {
34+
val response = MockResponse()
35+
.setBody(CHECKOUT_SESSION_RESPONSE_JSON)
36+
.setResponseCode(200)
37+
server.enqueue(response)
38+
val actual = service.createCheckoutSession()
39+
assertEquals(CHECKOUT_SESSION_RESPONSE.asSuccess(), actual)
40+
}
41+
42+
@Test
43+
fun `getPortalUrl when response is Failure should return Failure`() = runTest {
44+
val response = MockResponse().setResponseCode(400)
45+
server.enqueue(response)
46+
val actual = service.getPortalUrl()
47+
assertTrue(actual.isFailure)
48+
}
49+
50+
@Test
51+
fun `getPortalUrl when response is Success should return Success`() = runTest {
52+
val response = MockResponse()
53+
.setBody(PORTAL_URL_RESPONSE_JSON)
54+
.setResponseCode(200)
55+
server.enqueue(response)
56+
val actual = service.getPortalUrl()
57+
assertEquals(PORTAL_URL_RESPONSE.asSuccess(), actual)
58+
}
59+
}
60+
61+
private const val CHECKOUT_SESSION_RESPONSE_JSON = """
62+
{
63+
"checkoutSessionUrl": "https://checkout.stripe.com/c/pay/test_session_123"
64+
}
65+
"""
66+
67+
private val CHECKOUT_SESSION_RESPONSE = CheckoutSessionResponseJson(
68+
checkoutSessionUrl = "https://checkout.stripe.com/c/pay/test_session_123",
69+
)
70+
71+
private const val PORTAL_URL_RESPONSE_JSON = """
72+
{
73+
"url": "https://billing.stripe.com/p/session/test_portal_456"
74+
}
75+
"""
76+
77+
private val PORTAL_URL_RESPONSE = PortalUrlResponseJson(
78+
url = "https://billing.stripe.com/p/session/test_portal_456",
79+
)

0 commit comments

Comments
 (0)