Skip to content

Commit b7c0a52

Browse files
authored
Subscription switching: Billing integration (#6787)
Task/Issue URL: https://app.asana.com/1/137249556945/project/72649045549333/task/1211320822502364?focus=true ### Description This PR implements the foundational billing infrastructure required for subscription plan switching functionality. It adds the core Google Play Billing integration without exposing any UI changes to users. ### Steps to test this PR _Optional_ - [ ] Smoke test subscriptions but no functionality has been added to the flow - [ ] CI checks are green ✅ ### No UI changes
1 parent 0102a96 commit b7c0a52

File tree

4 files changed

+323
-0
lines changed

4 files changed

+323
-0
lines changed

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/BillingClientAdapter.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ interface BillingClientAdapter {
3838
offerToken: String,
3939
externalId: String,
4040
): LaunchBillingFlowResult
41+
42+
suspend fun launchSubscriptionUpdate(
43+
activity: Activity,
44+
productDetails: ProductDetails,
45+
offerToken: String,
46+
externalId: String,
47+
oldPurchaseToken: String,
48+
replacementMode: SubscriptionReplacementMode = SubscriptionReplacementMode.DEFERRED,
49+
): LaunchBillingFlowResult
4150
}
4251

4352
sealed class BillingInitResult {
@@ -92,3 +101,19 @@ enum class BillingError {
92101
BILLING_CRASH_ERROR, // This is our own error
93102
;
94103
}
104+
105+
/**
106+
* Defines supported replacement modes for Google Play Billing subscription updates.
107+
*
108+
* Currently, we only use the [DEFERRED] mode in our implementation.
109+
*
110+
* For a complete list of available values, refer to the official documentation:
111+
* https://developer.android.com/reference/com/android/billingclient/api/BillingFlowParams.SubscriptionUpdateParams.ReplacementMode
112+
*/
113+
enum class SubscriptionReplacementMode(val value: Int) {
114+
/**
115+
* New subscription starts after current subscription expires.
116+
* Best for: When you want to avoid billing complications or user requested delayed switch.
117+
*/
118+
DEFERRED(6),
119+
}

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/PlayBillingManager.kt

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ interface PlayBillingManager {
7777
externalId: String,
7878
offerId: String?,
7979
)
80+
81+
/**
82+
* Launches the subscription update flow
83+
*
84+
* It is safe to call this method without specifying dispatcher as it's handled internally
85+
*/
86+
suspend fun launchSubscriptionUpdate(
87+
activity: Activity,
88+
newPlanId: String,
89+
externalId: String,
90+
newOfferId: String?,
91+
replacementMode: SubscriptionReplacementMode = SubscriptionReplacementMode.DEFERRED,
92+
)
8093
}
8194

8295
@SingleInstanceIn(AppScope::class)
@@ -212,6 +225,63 @@ class RealPlayBillingManager @Inject constructor(
212225
}
213226
}
214227

228+
override suspend fun launchSubscriptionUpdate(
229+
activity: Activity,
230+
newPlanId: String,
231+
externalId: String,
232+
newOfferId: String?,
233+
replacementMode: SubscriptionReplacementMode,
234+
) = withContext(dispatcherProvider.io()) {
235+
if (!billingClient.ready) {
236+
logcat { "Service not ready" }
237+
connect()
238+
}
239+
240+
val oldPurchaseToken: String? = getCurrentPurchaseToken()
241+
242+
val productDetails = products.find { it.productId == BASIC_SUBSCRIPTION }
243+
244+
val offerToken = productDetails
245+
?.subscriptionOfferDetails
246+
?.find { it.basePlanId == newPlanId && it.offerId == newOfferId }
247+
?.offerToken
248+
249+
if (productDetails == null || offerToken == null || oldPurchaseToken == null) {
250+
_purchaseState.emit(Canceled)
251+
return@withContext
252+
}
253+
254+
val launchBillingFlowResult = billingClient.launchSubscriptionUpdate(
255+
activity = activity,
256+
productDetails = productDetails,
257+
offerToken = offerToken,
258+
externalId = externalId,
259+
oldPurchaseToken = oldPurchaseToken,
260+
replacementMode = replacementMode,
261+
)
262+
263+
when (launchBillingFlowResult) {
264+
LaunchBillingFlowResult.Success -> {
265+
_purchaseState.emit(InProgress)
266+
billingFlowInProgress = true
267+
}
268+
269+
LaunchBillingFlowResult.Failure -> {
270+
_purchaseState.emit(Canceled)
271+
}
272+
}
273+
}
274+
275+
/**
276+
* Gets the current purchase token for the active subscription
277+
*/
278+
private suspend fun getCurrentPurchaseToken(): String? = withContext(dispatcherProvider.io()) {
279+
return@withContext purchaseHistory
280+
.filter { it.products.contains(BASIC_SUBSCRIPTION) }
281+
.maxByOrNull { it.purchaseTime }
282+
?.purchaseToken
283+
}
284+
215285
private fun onPurchasesUpdated(result: PurchasesUpdateResult) {
216286
coroutineScope.launch {
217287
when (result) {

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/billing/RealBillingClientAdapter.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,46 @@ class RealBillingClientAdapter @Inject constructor(
178178
}
179179
}
180180

181+
override suspend fun launchSubscriptionUpdate(
182+
activity: Activity,
183+
productDetails: ProductDetails,
184+
offerToken: String,
185+
externalId: String,
186+
oldPurchaseToken: String,
187+
replacementMode: SubscriptionReplacementMode,
188+
): LaunchBillingFlowResult {
189+
val client = billingClient
190+
if (client == null || !client.isReady) return LaunchBillingFlowResult.Failure
191+
192+
val subscriptionUpdateParams = BillingFlowParams.SubscriptionUpdateParams.newBuilder()
193+
.setOldPurchaseToken(oldPurchaseToken)
194+
.setSubscriptionReplacementMode(replacementMode.value)
195+
.build()
196+
197+
val billingFlowParams = BillingFlowParams.newBuilder()
198+
.setProductDetailsParamsList(
199+
listOf(
200+
BillingFlowParams.ProductDetailsParams.newBuilder()
201+
.setProductDetails(productDetails)
202+
.setOfferToken(offerToken)
203+
.build(),
204+
),
205+
)
206+
.setObfuscatedAccountId(externalId)
207+
.setObfuscatedProfileId(externalId)
208+
.setSubscriptionUpdateParams(subscriptionUpdateParams)
209+
.build()
210+
211+
val result = withContext(coroutineDispatchers.main()) {
212+
client.launchBillingFlow(activity, billingFlowParams)
213+
}
214+
215+
return when (result.responseCode) {
216+
BillingResponseCode.OK -> LaunchBillingFlowResult.Success
217+
else -> LaunchBillingFlowResult.Failure
218+
}
219+
}
220+
181221
private fun reset() {
182222
billingClient?.endConnection()
183223
billingClient = null

subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/billing/RealPlayBillingManagerTest.kt

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMe
1919
import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.GetSubscriptions
2020
import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.GetSubscriptionsPurchaseHistory
2121
import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.LaunchBillingFlow
22+
import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.LaunchSubscriptionUpdate
2223
import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Canceled
2324
import com.duckduckgo.subscriptions.impl.billing.PurchaseState.InProgress
2425
import kotlin.time.Duration.Companion.minutes
@@ -202,6 +203,149 @@ class RealPlayBillingManagerTest {
202203

203204
billingClientAdapter.verifyLaunchBillingFlowInvoked(productDetails, offerToken = offerDetails.offerToken, externalId)
204205
}
206+
207+
@Test
208+
fun `when launchSubscriptionUpdate called with valid parameters then launches subscription update flow`() = runTest {
209+
// Set up purchase history so getCurrentPurchaseToken() returns a valid token
210+
val mockPurchase: PurchaseHistoryRecord = mock {
211+
whenever(it.products).thenReturn(listOf(BASIC_SUBSCRIPTION))
212+
whenever(it.purchaseTime).thenReturn(1000L)
213+
whenever(it.purchaseToken).thenReturn("old_purchase_token")
214+
}
215+
billingClientAdapter.subscriptionsPurchaseHistory = listOf(mockPurchase)
216+
217+
processLifecycleOwner.currentState = RESUMED
218+
runCurrent() // Ensure purchase history is loaded
219+
220+
billingClientAdapter.launchBillingFlowResult = LaunchBillingFlowResult.Success
221+
222+
val productDetails = billingClientAdapter.subscriptions.first()
223+
val offerDetails = productDetails.subscriptionOfferDetails!!.first()
224+
val externalId = "test_external_id"
225+
val oldPurchaseToken = "old_purchase_token" // This is what getCurrentPurchaseToken() will return
226+
val replacementMode = SubscriptionReplacementMode.DEFERRED
227+
228+
subject.purchaseState.test {
229+
expectNoEvents()
230+
231+
subject.launchSubscriptionUpdate(
232+
activity = mock(),
233+
newPlanId = MONTHLY_PLAN_US,
234+
externalId = externalId,
235+
newOfferId = null,
236+
replacementMode = replacementMode,
237+
)
238+
239+
assertEquals(InProgress, awaitItem())
240+
}
241+
242+
billingClientAdapter.verifyLaunchSubscriptionUpdateInvoked(
243+
productDetails = productDetails,
244+
offerToken = offerDetails.offerToken,
245+
externalId = externalId,
246+
oldPurchaseToken = oldPurchaseToken,
247+
replacementMode = replacementMode,
248+
)
249+
}
250+
251+
@Test
252+
fun `when launchSubscriptionUpdate called with invalid plan then emits canceled state`() = runTest {
253+
// Set up purchase history so getCurrentPurchaseToken() returns a valid token
254+
val mockPurchase: PurchaseHistoryRecord = mock {
255+
whenever(it.products).thenReturn(listOf(BASIC_SUBSCRIPTION))
256+
whenever(it.purchaseTime).thenReturn(1000L)
257+
whenever(it.purchaseToken).thenReturn("old_purchase_token")
258+
}
259+
billingClientAdapter.subscriptionsPurchaseHistory = listOf(mockPurchase)
260+
261+
processLifecycleOwner.currentState = RESUMED
262+
runCurrent() // Ensure purchase history is loaded
263+
264+
val externalId = "test_external_id"
265+
266+
subject.purchaseState.test {
267+
expectNoEvents()
268+
269+
subject.launchSubscriptionUpdate(
270+
activity = mock(),
271+
newPlanId = "invalid_plan_id",
272+
externalId = externalId,
273+
newOfferId = null,
274+
)
275+
276+
assertEquals(Canceled, awaitItem())
277+
}
278+
279+
billingClientAdapter.verifyLaunchSubscriptionUpdateNotInvoked()
280+
}
281+
282+
@Test
283+
fun `when launchSubscriptionUpdate fails then emits canceled state`() = runTest {
284+
// Set up purchase history so getCurrentPurchaseToken() returns a valid token
285+
val mockPurchase: PurchaseHistoryRecord = mock {
286+
whenever(it.products).thenReturn(listOf(BASIC_SUBSCRIPTION))
287+
whenever(it.purchaseTime).thenReturn(1000L)
288+
whenever(it.purchaseToken).thenReturn("old_purchase_token")
289+
}
290+
billingClientAdapter.subscriptionsPurchaseHistory = listOf(mockPurchase)
291+
292+
processLifecycleOwner.currentState = RESUMED
293+
runCurrent() // Ensure purchase history is loaded
294+
295+
billingClientAdapter.launchBillingFlowResult = LaunchBillingFlowResult.Failure
296+
297+
val productDetails = billingClientAdapter.subscriptions.first()
298+
val offerDetails = productDetails.subscriptionOfferDetails!!.first()
299+
val externalId = "test_external_id"
300+
val oldPurchaseToken = "old_purchase_token" // This is what getCurrentPurchaseToken() will return
301+
302+
subject.purchaseState.test {
303+
expectNoEvents()
304+
305+
subject.launchSubscriptionUpdate(
306+
activity = mock(),
307+
newPlanId = MONTHLY_PLAN_US,
308+
externalId = externalId,
309+
newOfferId = null,
310+
)
311+
312+
assertEquals(Canceled, awaitItem())
313+
}
314+
315+
billingClientAdapter.verifyLaunchSubscriptionUpdateInvoked(
316+
productDetails = productDetails,
317+
offerToken = offerDetails.offerToken,
318+
externalId = externalId,
319+
oldPurchaseToken = oldPurchaseToken,
320+
replacementMode = SubscriptionReplacementMode.DEFERRED, // default value
321+
)
322+
}
323+
324+
@Test
325+
fun `when launchSubscriptionUpdate called with no purchase history then emits canceled state`() = runTest {
326+
// No purchase history set up, so getCurrentPurchaseToken() will return null
327+
billingClientAdapter.subscriptionsPurchaseHistory = emptyList()
328+
329+
processLifecycleOwner.currentState = RESUMED
330+
runCurrent() // Ensure purchase history is loaded
331+
332+
val externalId = "test_external_id"
333+
334+
subject.purchaseState.test {
335+
expectNoEvents()
336+
337+
subject.launchSubscriptionUpdate(
338+
activity = mock(),
339+
newPlanId = MONTHLY_PLAN_US,
340+
externalId = externalId,
341+
newOfferId = null,
342+
)
343+
344+
assertEquals(Canceled, awaitItem())
345+
}
346+
347+
billingClientAdapter.verifyLaunchSubscriptionUpdateNotInvoked()
348+
}
205349
}
206350

207351
class FakeBillingClientAdapter : BillingClientAdapter {
@@ -287,6 +431,18 @@ class FakeBillingClientAdapter : BillingClientAdapter {
287431
return launchBillingFlowResult
288432
}
289433

434+
override suspend fun launchSubscriptionUpdate(
435+
activity: Activity,
436+
productDetails: ProductDetails,
437+
offerToken: String,
438+
externalId: String,
439+
oldPurchaseToken: String,
440+
replacementMode: SubscriptionReplacementMode,
441+
): LaunchBillingFlowResult {
442+
methodInvocations.add(LaunchSubscriptionUpdate(productDetails, offerToken, externalId, oldPurchaseToken, replacementMode))
443+
return launchBillingFlowResult
444+
}
445+
290446
fun verifyConnectInvoked(times: Int = 1) {
291447
val invocations = methodInvocations.filterIsInstance<Connect>()
292448
assertEquals(times, invocations.count())
@@ -324,6 +480,30 @@ class FakeBillingClientAdapter : BillingClientAdapter {
324480
assertTrue(methodInvocations.filterIsInstance<LaunchBillingFlow>().isEmpty())
325481
}
326482

483+
fun verifyLaunchSubscriptionUpdateInvoked(
484+
productDetails: ProductDetails,
485+
offerToken: String,
486+
externalId: String,
487+
oldPurchaseToken: String,
488+
replacementMode: SubscriptionReplacementMode,
489+
times: Int = 1,
490+
) {
491+
val invocations = methodInvocations
492+
.filterIsInstance<LaunchSubscriptionUpdate>()
493+
.filter { invocation ->
494+
invocation.productDetails == productDetails &&
495+
invocation.offerToken == offerToken &&
496+
invocation.externalId == externalId &&
497+
invocation.oldPurchaseToken == oldPurchaseToken &&
498+
invocation.replacementMode == replacementMode
499+
}
500+
assertEquals(times, invocations.count())
501+
}
502+
503+
fun verifyLaunchSubscriptionUpdateNotInvoked() {
504+
assertTrue(methodInvocations.filterIsInstance<LaunchSubscriptionUpdate>().isEmpty())
505+
}
506+
327507
sealed class FakeMethodInvocation {
328508
data object Connect : FakeMethodInvocation()
329509
data class GetSubscriptions(val productIds: List<String>) : FakeMethodInvocation()
@@ -334,5 +514,13 @@ class FakeBillingClientAdapter : BillingClientAdapter {
334514
val offerToken: String,
335515
val externalId: String,
336516
) : FakeMethodInvocation()
517+
518+
data class LaunchSubscriptionUpdate(
519+
val productDetails: ProductDetails,
520+
val offerToken: String,
521+
val externalId: String,
522+
val oldPurchaseToken: String,
523+
val replacementMode: SubscriptionReplacementMode,
524+
) : FakeMethodInvocation()
337525
}
338526
}

0 commit comments

Comments
 (0)