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 @@ -384,6 +384,8 @@ class RealSubscriptionsManager @Inject constructor(
checkPurchase(it.packageName, it.purchaseToken)
}
is PurchaseState.Canceled -> {
subscriptionPurchaseWideEvent.onPurchaseCancelledByUser()
subscriptionSwitchWideEvent.onUserCancelled()
_currentPurchaseState.emit(CurrentPurchase.Canceled)
if (removeExpiredSubscriptionOnCancelledPurchase) {
if (subscriptionStatus().isExpired()) {
Expand All @@ -393,6 +395,12 @@ class RealSubscriptionsManager @Inject constructor(
}
}

is PurchaseState.Failure -> {
subscriptionPurchaseWideEvent.onBillingFlowPurchaseFailure(it.errorType)
subscriptionSwitchWideEvent.onSwitchFailed(it.errorType)
_currentPurchaseState.emit(CurrentPurchase.Failure(it.errorType))
}

else -> {
// NOOP
}
Expand Down Expand Up @@ -534,20 +542,14 @@ class RealSubscriptionsManager @Inject constructor(
val currentPurchaseToken = playBillingManager.getLatestPurchaseToken()

if (currentPurchaseToken == null) {
val errorMessage = "No current purchase token found for switch"
logcat { "Subs: Cannot switch plan - $errorMessage" }
subscriptionSwitchWideEvent.onSwitchFailed(errorMessage)
_currentPurchaseState.emit(CurrentPurchase.Failure(errorMessage))
_currentPurchaseState.emit(CurrentPurchase.Failure("No current purchase token found for switch"))
return@withContext
}

// Get account details for external ID
val account = authRepository.getAccount()
if (account == null) {
val errorMessage = "No account found for switch"
logcat { "Subs: Cannot switch plan - $errorMessage" }
subscriptionSwitchWideEvent.onSwitchFailed(errorMessage)
_currentPurchaseState.emit(CurrentPurchase.Failure(errorMessage))
_currentPurchaseState.emit(CurrentPurchase.Failure("No account found for switch"))
return@withContext
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,8 +224,9 @@ class RealPlayBillingManager @Inject constructor(
?.offerToken

if (productDetails == null || offerToken == null) {
subscriptionPurchaseWideEvent.onBillingFlowInitFailure(error = "Missing product details")
_purchaseState.emit(Canceled)
val error = "Missing product details"
subscriptionPurchaseWideEvent.onBillingFlowInitFailure(error = error)
_purchaseState.emit(PurchaseState.Failure(error))
return@withContext
}

Expand All @@ -246,7 +247,7 @@ class RealPlayBillingManager @Inject constructor(
is LaunchBillingFlowResult.Failure -> {
val error = "Billing error: ${launchBillingFlowResult.error.name}"
subscriptionPurchaseWideEvent.onBillingFlowInitFailure(error)
_purchaseState.emit(Canceled)
_purchaseState.emit(PurchaseState.Failure(error))
}
}
}
Expand All @@ -270,7 +271,7 @@ class RealPlayBillingManager @Inject constructor(
val errorMessage = "empty old purchase token"
logcat { "Billing: $errorMessage" }
subscriptionSwitchWideEvent.onBillingFlowInitFailure(errorMessage)
_purchaseState.emit(Canceled)
_purchaseState.emit(PurchaseState.Failure(errorMessage))
return@withContext
}

Expand All @@ -285,7 +286,7 @@ class RealPlayBillingManager @Inject constructor(
val errorMessage = "Missing product details"
logcat { "Billing: $errorMessage" }
subscriptionSwitchWideEvent.onBillingFlowInitFailure(errorMessage)
_purchaseState.emit(Canceled)
_purchaseState.emit(PurchaseState.Failure(errorMessage))
return@withContext
}

Expand All @@ -307,7 +308,7 @@ class RealPlayBillingManager @Inject constructor(

is LaunchBillingFlowResult.Failure -> {
subscriptionSwitchWideEvent.onBillingFlowInitFailure(launchBillingFlowResult.error.name)
_purchaseState.emit(Canceled)
_purchaseState.emit(PurchaseState.Failure(launchBillingFlowResult.error.name))
}
}
}
Expand All @@ -326,17 +327,13 @@ class RealPlayBillingManager @Inject constructor(

PurchaseAbsent -> {}
UserCancelled -> {
subscriptionPurchaseWideEvent.onPurchaseCancelledByUser()
subscriptionSwitchWideEvent.onUserCancelled()
_purchaseState.emit(Canceled)
// Handle an error caused by a user cancelling the purchase flow.
}

is PurchasesUpdateResult.Failure -> {
subscriptionPurchaseWideEvent.onBillingFlowPurchaseFailure(result.errorType)
subscriptionSwitchWideEvent.onSwitchFailed(result.errorType)
pixelSender.reportPurchaseFailureStore(result.errorType)
_purchaseState.emit(Canceled)
_purchaseState.emit(PurchaseState.Failure(result.errorType))
}
}
}
Expand Down Expand Up @@ -413,4 +410,5 @@ sealed class PurchaseState {
) : PurchaseState()

data object Canceled : PurchaseState()
data class Failure(val errorType: String) : PurchaseState()
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import com.duckduckgo.subscriptions.impl.auth2.TokenPair
import com.duckduckgo.subscriptions.impl.billing.PlayBillingManager
import com.duckduckgo.subscriptions.impl.billing.PurchaseState
import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Canceled
import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Failure
import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Purchased
import com.duckduckgo.subscriptions.impl.billing.SubscriptionReplacementMode
import com.duckduckgo.subscriptions.impl.model.Entitlement
Expand Down Expand Up @@ -788,6 +789,43 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) {
}
}

@Test
fun whenPurchaseFailedThenEmitFailure() = runTest {
val flowTest: MutableSharedFlow<PurchaseState> = MutableSharedFlow()
whenever(playBillingManager.purchaseState).thenReturn(flowTest)

val manager = RealSubscriptionsManager(
authService,
subscriptionsService,
authRepository,
playBillingManager,
emailManager,
context,
TestScope(),
coroutineRule.testDispatcherProvider,
pixelSender,
{ privacyProFeature },
authClient,
authJwtValidator,
pkceGenerator,
timeProvider,
backgroundTokenRefresh,
subscriptionPurchaseWideEvent,
tokenRefreshWideEvent,
subscriptionSwitchWideEvent,
freeTrialConversionWideEvent,
subscriptionRestoreWideEvent,
)

manager.currentPurchaseState.test {
flowTest.emit(Failure("BILLING_UNAVAILABLE"))
val result = awaitItem()
assertTrue(result is CurrentPurchase.Failure)
assertEquals("BILLING_UNAVAILABLE", (result as CurrentPurchase.Failure).message)
cancelAndConsumeRemainingEvents()
}
}

@Test
fun whenGetAccessTokenIfUserIsSignedInThenReturnSuccess() = runTest {
givenUserIsSignedIn()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMe
import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.LaunchBillingFlow
import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.LaunchSubscriptionUpdate
import com.duckduckgo.subscriptions.impl.billing.FakeBillingClientAdapter.FakeMethodInvocation.QueryPurchases
import com.duckduckgo.subscriptions.impl.billing.PurchaseState.Canceled
import com.duckduckgo.subscriptions.impl.billing.PurchaseState.InProgress
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceTimeBy
Expand Down Expand Up @@ -121,7 +120,7 @@ class RealPlayBillingManagerTest {
}

@Test
fun `when can't connect to service then launching billing flow is cancelled`() = runTest {
fun `when can't connect to service then launching billing flow fails`() = runTest {
billingClientAdapter.billingInitResult = BillingInitResult.Failure(BILLING_UNAVAILABLE)
processLifecycleOwner.currentState = RESUMED
billingClientAdapter.launchBillingFlowResult = LaunchBillingFlowResult.Failure(error = SERVICE_UNAVAILABLE)
Expand All @@ -134,7 +133,7 @@ class RealPlayBillingManagerTest {

subject.launchBillingFlow(activity = mock(), planId = MONTHLY_PLAN_US, externalId, null)

assertEquals(Canceled, awaitItem())
assertEquals(PurchaseState.Failure("Missing product details"), awaitItem())
}

billingClientAdapter.verifyConnectInvoked()
Expand Down Expand Up @@ -255,7 +254,7 @@ class RealPlayBillingManagerTest {
}

@Test
fun `when launchSubscriptionUpdate called with invalid plan then emits canceled state`() = runTest {
fun `when launchSubscriptionUpdate called with invalid plan then emits failure state`() = runTest {
// Set up purchase history so getCurrentPurchaseToken() returns a valid token
val mockPurchase: PurchaseHistoryRecord = mock {
whenever(it.products).thenReturn(listOf(BASIC_SUBSCRIPTION))
Expand All @@ -282,14 +281,14 @@ class RealPlayBillingManagerTest {
replacementMode = SubscriptionReplacementMode.DEFERRED,
)

assertEquals(Canceled, awaitItem())
assertEquals(PurchaseState.Failure("Missing product details"), awaitItem())
}

billingClientAdapter.verifyLaunchSubscriptionUpdateNotInvoked()
}

@Test
fun `when launchSubscriptionUpdate fails then emits canceled state`() = runTest {
fun `when launchSubscriptionUpdate fails then emits failure state`() = runTest {
// Set up purchase history so getCurrentPurchaseToken() returns a valid token
val mockPurchase: PurchaseHistoryRecord = mock {
whenever(it.products).thenReturn(listOf(BASIC_SUBSCRIPTION))
Expand Down Expand Up @@ -321,7 +320,7 @@ class RealPlayBillingManagerTest {
replacementMode = replacementMode,
)

assertEquals(Canceled, awaitItem())
assertEquals(PurchaseState.Failure("SERVICE_UNAVAILABLE"), awaitItem())
}

billingClientAdapter.verifyLaunchSubscriptionUpdateInvoked(
Expand All @@ -334,7 +333,7 @@ class RealPlayBillingManagerTest {
}

@Test
fun `when launchSubscriptionUpdate called with empty purchase token then emits canceled state`() = runTest {
fun `when launchSubscriptionUpdate called with empty purchase token then emits failure state`() = runTest {
// Test with empty purchase token to simulate no valid token scenario
billingClientAdapter.subscriptionsPurchaseHistory = emptyList()

Expand All @@ -356,11 +355,24 @@ class RealPlayBillingManagerTest {
replacementMode = SubscriptionReplacementMode.DEFERRED,
)

assertEquals(Canceled, awaitItem())
assertEquals(PurchaseState.Failure("empty old purchase token"), awaitItem())
}

billingClientAdapter.verifyLaunchSubscriptionUpdateNotInvoked()
}

@Test
fun `when purchase update fails then emits failure state`() = runTest {
processLifecycleOwner.currentState = RESUMED

subject.purchaseState.test {
expectNoEvents()

billingClientAdapter.purchasesListener?.invoke(PurchasesUpdateResult.Failure("BILLING_UNAVAILABLE"))

assertEquals(PurchaseState.Failure("BILLING_UNAVAILABLE"), awaitItem())
}
}
}

class FakeBillingClientAdapter : BillingClientAdapter {
Expand Down
Loading