Skip to content

Commit 04a8dfd

Browse files
committed
[PM-33508] feat: Add AuthenticatedBillingApi and BillingService network layer
Add billing network infrastructure for premium upgrade and subscription management flows: - AuthenticatedBillingApi with checkout session and portal session endpoints - CheckoutSessionRequestJson/ResponseJson for premium checkout - PortalUrlResponseJson for Stripe customer portal - BillingService interface and implementation - Wire BillingService into BitwardenServiceClient
1 parent 453fc22 commit 04a8dfd

9 files changed

Lines changed: 221 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 checkoutUrl The Stripe checkout URL for premium upgrade.
10+
*/
11+
@Serializable
12+
data class CheckoutSessionResponseJson(
13+
@SerialName("checkoutUrl")
14+
val checkoutUrl: 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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
* @param platform The platform identifier (e.g., "android" or "ios").
15+
*/
16+
suspend fun createCheckoutSession(
17+
platform: String,
18+
): Result<CheckoutSessionResponseJson>
19+
20+
/**
21+
* Creates a Stripe customer portal session for managing the premium subscription.
22+
*/
23+
suspend fun getPortalUrl(): Result<PortalUrlResponseJson>
24+
}
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+
/**
10+
* The default implementation of the [BillingService].
11+
*/
12+
internal class BillingServiceImpl(
13+
private val authenticatedBillingApi: AuthenticatedBillingApi,
14+
) : BillingService {
15+
16+
override suspend fun createCheckoutSession(
17+
platform: String,
18+
): 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: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
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(platform = PLATFORM)
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(platform = PLATFORM)
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 PLATFORM = "android"
62+
63+
private const val CHECKOUT_SESSION_RESPONSE_JSON = """
64+
{
65+
"checkoutUrl": "https://checkout.stripe.com/c/pay/test_session_123"
66+
}
67+
"""
68+
69+
private val CHECKOUT_SESSION_RESPONSE = CheckoutSessionResponseJson(
70+
checkoutUrl = "https://checkout.stripe.com/c/pay/test_session_123",
71+
)
72+
73+
private const val PORTAL_URL_RESPONSE_JSON = """
74+
{
75+
"url": "https://billing.stripe.com/p/session/test_portal_456"
76+
}
77+
"""
78+
79+
private val PORTAL_URL_RESPONSE = PortalUrlResponseJson(
80+
url = "https://billing.stripe.com/p/session/test_portal_456",
81+
)

0 commit comments

Comments
 (0)