From dea4f164ed14afc1c32f9126e79147040b665d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Sikora?= Date: Wed, 5 Feb 2025 13:45:13 +0100 Subject: [PATCH] Handle claiming winback offer --- .../profile/winback/WinbackFragment.kt | 39 ++++- .../profile/winback/WinbackViewModel.kt | 80 +++++++++- .../profile/winback/WinbackViewModelTest.kt | 147 ++++++++++++++++-- .../src/main/res/values/strings.xml | 1 + .../subscription/SubscriptionManager.kt | 7 + .../subscription/SubscriptionManagerImpl.kt | 24 +++ .../repositories/winback/WinbackManager.kt | 8 + .../winback/WinbackManagerImpl.kt | 51 +++++- 8 files changed, 332 insertions(+), 25 deletions(-) diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/winback/WinbackFragment.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/winback/WinbackFragment.kt index e271fa4ee4f..6d1cb8d35c1 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/winback/WinbackFragment.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/winback/WinbackFragment.kt @@ -7,7 +7,6 @@ import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater import android.view.ViewGroup -import androidx.appcompat.app.AppCompatActivity import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.Box @@ -35,6 +34,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.core.os.BundleCompat @@ -48,6 +48,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import au.com.shiftyjelly.pocketcasts.compose.AppThemeWithBackground +import au.com.shiftyjelly.pocketcasts.compose.components.ProgressDialog import au.com.shiftyjelly.pocketcasts.compose.components.TextH50 import au.com.shiftyjelly.pocketcasts.compose.theme import au.com.shiftyjelly.pocketcasts.models.type.BillingPeriod @@ -120,12 +121,7 @@ class WinbackFragment : BaseDialogFragment() { WinbackOfferPage( offer = state.winbackOfferState?.offer, onClaimOffer = { offer -> - viewModel.trackClaimOfferTapped() - navController.navigate(WinbackNavRoutes.offerClaimedDestination(offer.details.billingPeriod)) { - popUpTo(WinbackNavRoutes.WinbackOffer) { - inclusive = true - } - } + viewModel.claimOffer(offer, requireActivity()) }, onSeeAvailablePlans = { viewModel.trackAvailablePlansTapped() @@ -164,7 +160,7 @@ class WinbackFragment : BaseDialogFragment() { composable(WinbackNavRoutes.AvailablePlans) { AvailablePlansPage( plansState = state.subscriptionPlansState, - onSelectPlan = { plan -> viewModel.changePlan(requireActivity() as AppCompatActivity, plan) }, + onSelectPlan = { plan -> viewModel.changePlan(plan, requireActivity()) }, onGoToSubscriptions = { if (!goToPlayStoreSubscriptions()) { scope.launch { @@ -223,6 +219,33 @@ class WinbackFragment : BaseDialogFragment() { } } + val offerState = state.winbackOfferState + if (offerState?.isClaimingOffer == true) { + ProgressDialog( + text = stringResource(LR.string.winback_claiming_offer), + onDismiss = {}, + ) + } + + if (offerState?.isOfferClaimed == true) { + LaunchedEffect(Unit) { + viewModel.consumeClaimedOffer() + val billingPeriod = offerState.offer.details.billingPeriod + navController.navigate(WinbackNavRoutes.offerClaimedDestination(billingPeriod)) { + popUpTo(WinbackNavRoutes.WinbackOffer) { + inclusive = true + } + } + } + } + + val hasClaimOfferFailed = offerState?.hasOfferClaimFailed == true + if (hasClaimOfferFailed) { + LaunchedEffect(Unit) { + snackbarHostState.showSnackbar(getString(LR.string.error_generic_message)) + } + } + val hasPlanChangeFailed = (state.subscriptionPlansState as? SubscriptionPlansState.Loaded)?.hasPlanChangeFailed == true if (hasPlanChangeFailed) { LaunchedEffect(Unit) { diff --git a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/winback/WinbackViewModel.kt b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/winback/WinbackViewModel.kt index 0cf7b06aa64..3757b3f3cf0 100644 --- a/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/winback/WinbackViewModel.kt +++ b/modules/features/profile/src/main/java/au/com/shiftyjelly/pocketcasts/profile/winback/WinbackViewModel.kt @@ -1,6 +1,6 @@ package au.com.shiftyjelly.pocketcasts.profile.winback -import androidx.appcompat.app.AppCompatActivity +import android.app.Activity import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import au.com.shiftyjelly.pocketcasts.analytics.AnalyticsEvent @@ -93,8 +93,8 @@ class WinbackViewModel @Inject constructor( private var changePlanJob: Job? = null internal fun changePlan( - activity: AppCompatActivity, newPlan: SubscriptionPlan, + activity: Activity, ) { trackPlanSelected(newPlan.productId) if (changePlanJob?.isActive == true) { @@ -166,6 +166,71 @@ class WinbackViewModel @Inject constructor( } } + private var claimOfferjob: Job? = null + + internal fun claimOffer( + offer: WinbackOffer, + activity: Activity, + ) { + trackClaimOfferTapped() + if (claimOfferjob?.isActive == true) { + return + } + + val loadedState = (_uiState.value.subscriptionPlansState as? SubscriptionPlansState.Loaded) ?: run { + logWarning("Failed to start winback offer flow. Subscriptions are not loaded.") + return + } + val currentPurchase = _uiState.value.purchases.find { it.orderId == loadedState.activePurchase.orderId } ?: run { + logWarning("Failed to start winback offer flow. No matching current purchase.") + return + } + val winbackProduct = _uiState.value.productsDetails.find { product -> + product.subscriptionOfferDetails.orEmpty().any { playOffer -> playOffer.offerToken == offer.offerToken } + } ?: run { + logWarning("Failed to start winback offer flow. No matching product for a winback offer.") + return + } + claimOfferjob = viewModelScope.launch { + _uiState.value = _uiState.value.withOfferState { state -> + state.copy(isClaimingOffer = true) + } + val purchaseEvent = winbackManager.claimWinbackOffer( + currentPurchase = currentPurchase, + winbackProduct = winbackProduct, + winbackOfferToken = offer.offerToken, + winbackClaimCode = offer.redeemCode, + activity = activity, + ) + when (purchaseEvent) { + is PurchaseEvent.Cancelled -> { + _uiState.value = _uiState.value.withOfferState { state -> + state.copy(isClaimingOffer = false) + } + } + + is PurchaseEvent.Failure -> { + logWarning("Winback offer failure: ${purchaseEvent.responseCode}, ${purchaseEvent.errorMessage}") + _uiState.value = _uiState.value.withOfferState { state -> + state.copy(isClaimingOffer = false, hasOfferClaimFailed = true) + } + } + + is PurchaseEvent.Success -> { + _uiState.value = _uiState.value.withOfferState { state -> + state.copy(isClaimingOffer = false, isOfferClaimed = true) + } + } + } + } + } + + internal fun consumeClaimedOffer() { + _uiState.value = _uiState.value.withOfferState { state -> + state.copy(isOfferClaimed = false) + } + } + private suspend fun loadPlans() = when (val state = winbackManager.loadProducts()) { is ProductDetailsState.Loaded -> state.productDetails to state.productDetails.toSubscriptionPlans() is ProductDetailsState.Failure -> emptyList() to null @@ -243,6 +308,14 @@ class WinbackViewModel @Inject constructor( } } + private fun UiState.withOfferState(block: (WinbackOfferState) -> WinbackOfferState): UiState { + return if (winbackOfferState != null) { + copy(winbackOfferState = block(winbackOfferState)) + } else { + this + } + } + private fun logWarning(message: String) { Timber.tag(LogBuffer.TAG_SUBSCRIPTIONS).w(message) LogBuffer.w(LogBuffer.TAG_SUBSCRIPTIONS, message) @@ -268,7 +341,7 @@ class WinbackViewModel @Inject constructor( ) } - internal fun trackClaimOfferTapped() { + private fun trackClaimOfferTapped() { val activePurchase = (uiState.value.subscriptionPlansState as? SubscriptionPlansState.Loaded)?.activePurchase tracker.track( event = AnalyticsEvent.WINBACK_MAIN_SCREEN_ROW_TAP, @@ -374,6 +447,7 @@ class WinbackViewModel @Inject constructor( internal data class WinbackOfferState( val offer: WinbackOffer, val isClaimingOffer: Boolean = false, + val isOfferClaimed: Boolean = false, val hasOfferClaimFailed: Boolean = false, ) diff --git a/modules/features/profile/src/test/kotlin/au/com/shiftyjelly/pocketcasts/profile/winback/WinbackViewModelTest.kt b/modules/features/profile/src/test/kotlin/au/com/shiftyjelly/pocketcasts/profile/winback/WinbackViewModelTest.kt index 743ad49afd7..8db69b54fbd 100644 --- a/modules/features/profile/src/test/kotlin/au/com/shiftyjelly/pocketcasts/profile/winback/WinbackViewModelTest.kt +++ b/modules/features/profile/src/test/kotlin/au/com/shiftyjelly/pocketcasts/profile/winback/WinbackViewModelTest.kt @@ -60,6 +60,18 @@ class WinbackViewModelTest { billingPeriod = BillingPeriod.Yearly, ) + private val winbackResponse = winbackResponse { + offer = WinbackOfferDetails.PlusMonthly.offerId + code = "ABC" + } + + private val winbackOffer = WinbackOffer( + details = WinbackOfferDetails.PlusMonthly, + offerToken = "offer-token-${WinbackOfferDetails.PlusMonthly.productId}", + redeemCode = "ABC", + formattedPrice = "formated-price", + ) + private lateinit var viewModel: WinbackViewModel @Before @@ -227,7 +239,7 @@ class WinbackViewModelTest { viewModel.uiState.test { assertFalse(awaitLoadedState().isChangingPlan) - viewModel.changePlan(mock(), knownPlan) + viewModel.changePlan(knownPlan, mock()) assertTrue(awaitLoadedState().isChangingPlan) val newPurchase = createPurchase(orderId = "new-purchase") @@ -249,7 +261,7 @@ class WinbackViewModelTest { viewModel.uiState.test { skipItems(1) - viewModel.changePlan(mock(), knownPlan) + viewModel.changePlan(knownPlan, mock()) expectNoEvents() } } @@ -263,7 +275,7 @@ class WinbackViewModelTest { viewModel.uiState.test { skipItems(1) - viewModel.changePlan(mock(), knownPlan.copy(productId = "unknown")) + viewModel.changePlan(knownPlan.copy(productId = "unknown"), mock()) expectNoEvents() } } @@ -277,7 +289,7 @@ class WinbackViewModelTest { viewModel.uiState.test { assertFalse(awaitLoadedState().isChangingPlan) - viewModel.changePlan(mock(), knownPlan) + viewModel.changePlan(knownPlan, mock()) assertTrue(awaitLoadedState().isChangingPlan) winbackManager.addPurchaseEvent(PurchaseEvent.Cancelled(0)) @@ -297,7 +309,7 @@ class WinbackViewModelTest { viewModel.uiState.test { assertFalse(awaitLoadedState().isChangingPlan) - viewModel.changePlan(mock(), knownPlan) + viewModel.changePlan(knownPlan, mock()) assertTrue(awaitLoadedState().isChangingPlan) winbackManager.addPurchaseEvent(PurchaseEvent.Failure("", 0)) @@ -317,7 +329,7 @@ class WinbackViewModelTest { viewModel.uiState.test { assertFalse(awaitLoadedState().isChangingPlan) - viewModel.changePlan(mock(), knownPlan) + viewModel.changePlan(knownPlan, mock()) assertTrue(awaitLoadedState().isChangingPlan) winbackManager.addPurchases(emptyList()) @@ -569,6 +581,103 @@ class WinbackViewModelTest { } } + @Test + fun `claim winback offer successfully`() = runTest { + winbackManager.addProductDetails(products) + winbackManager.addPurchase(purchase) + winbackManager.addWinbackResponse(winbackResponse) + + viewModel.uiState.test { + val initialState = awaitOfferState() + assertFalse(initialState.isClaimingOffer) + assertFalse(initialState.isOfferClaimed) + assertFalse(initialState.hasOfferClaimFailed) + + viewModel.claimOffer(winbackOffer, mock()) + val claimingState = awaitOfferState() + assertTrue(claimingState.isClaimingOffer) + assertFalse(claimingState.isOfferClaimed) + assertFalse(claimingState.hasOfferClaimFailed) + + winbackManager.addPurchaseEvent(PurchaseEvent.Success) + val claimedState = awaitOfferState() + assertFalse(claimedState.isClaimingOffer) + assertTrue(claimedState.isOfferClaimed) + assertFalse(claimedState.hasOfferClaimFailed) + + viewModel.consumeClaimedOffer() + assertFalse(awaitOfferState().isOfferClaimed) + } + } + + @Test + fun `claim winback offer when current state is not loaded`() = runTest { + winbackManager.addProductDetails(products) + winbackManager.addPurchases(emptyList()) + winbackManager.addWinbackResponse(winbackResponse) + + viewModel.uiState.test { + skipItems(1) + + viewModel.claimOffer(winbackOffer, mock()) + expectNoEvents() + } + } + + @Test + fun `claim winback offer when there is no matching product`() = runTest { + winbackManager.addProductDetails(products) + winbackManager.addPurchase(purchase) + winbackManager.addWinbackResponse(winbackResponse) + + viewModel.uiState.test { + skipItems(1) + + viewModel.claimOffer(winbackOffer.copy(offerToken = "unknown"), mock()) + expectNoEvents() + } + } + + @Test + fun `claim winback offer when it is cancelled`() = runTest { + winbackManager.addProductDetails(products) + winbackManager.addPurchase(purchase) + winbackManager.addWinbackResponse(winbackResponse) + + viewModel.uiState.test { + assertFalse(awaitOfferState().isClaimingOffer) + + viewModel.claimOffer(winbackOffer, mock()) + assertTrue(awaitOfferState().isClaimingOffer) + + winbackManager.addPurchaseEvent(PurchaseEvent.Cancelled(responseCode = 1)) + val claimedState = awaitOfferState() + assertFalse(claimedState.isClaimingOffer) + assertFalse(claimedState.isOfferClaimed) + assertFalse(claimedState.hasOfferClaimFailed) + } + } + + @Test + fun `claim winback offer when purchase fails`() = runTest { + winbackManager.addProductDetails(products) + winbackManager.addPurchase(purchase) + winbackManager.addWinbackResponse(winbackResponse) + + viewModel.uiState.test { + skipItems(1) + + viewModel.claimOffer(winbackOffer, mock()) + skipItems(1) + + winbackManager.addPurchaseEvent(PurchaseEvent.Failure("error", responseCode = 1)) + val claimedState = awaitOfferState() + assertFalse(claimedState.isClaimingOffer) + assertFalse(claimedState.isOfferClaimed) + assertTrue(claimedState.hasOfferClaimFailed) + } + } + @Test fun `track screen shown`() = runTest { winbackManager.addProductDetails(products) @@ -626,7 +735,8 @@ class WinbackViewModelTest { winbackManager.addPurchase(createPurchase(productIds = listOf(Subscription.PLUS_MONTHLY_PRODUCT_ID))) winbackManager.addWinbackResponse(null) - viewModel.trackClaimOfferTapped() + viewModel.claimOffer(winbackOffer, mock()) + winbackManager.addPurchaseEvent(PurchaseEvent.Success) val event = tracker.events.single() assertEquals( @@ -648,7 +758,8 @@ class WinbackViewModelTest { winbackManager.addPurchase(createPurchase(productIds = listOf(Subscription.PLUS_YEARLY_PRODUCT_ID))) winbackManager.addWinbackResponse(null) - viewModel.trackClaimOfferTapped() + viewModel.claimOffer(winbackOffer, mock()) + winbackManager.addPurchaseEvent(PurchaseEvent.Success) val event = tracker.events.single() assertEquals( @@ -670,7 +781,8 @@ class WinbackViewModelTest { winbackManager.addPurchase(createPurchase(productIds = listOf(Subscription.PATRON_MONTHLY_PRODUCT_ID))) winbackManager.addWinbackResponse(null) - viewModel.trackClaimOfferTapped() + viewModel.claimOffer(winbackOffer, mock()) + winbackManager.addPurchaseEvent(PurchaseEvent.Success) val event = tracker.events.single() assertEquals( @@ -692,7 +804,8 @@ class WinbackViewModelTest { winbackManager.addPurchase(createPurchase(productIds = listOf(Subscription.PATRON_YEARLY_PRODUCT_ID))) winbackManager.addWinbackResponse(null) - viewModel.trackClaimOfferTapped() + viewModel.claimOffer(winbackOffer, mock()) + winbackManager.addPurchaseEvent(PurchaseEvent.Success) val event = tracker.events.single() assertEquals( @@ -780,7 +893,7 @@ class WinbackViewModelTest { winbackManager.addPurchase(purchase) winbackManager.addWinbackResponse(null) - viewModel.changePlan(mock(), knownPlan) + viewModel.changePlan(knownPlan, mock()) winbackManager.addPurchase(createPurchase(productIds = listOf(Subscription.PLUS_MONTHLY_PRODUCT_ID))) winbackManager.addPurchaseEvent(PurchaseEvent.Success) @@ -840,6 +953,10 @@ private suspend fun TurbineTestContext.awaitLoadedStat return awaitItem().subscriptionPlansState as SubscriptionPlansState.Loaded } +private suspend fun TurbineTestContext.awaitOfferState(): WinbackOfferState { + return awaitItem().winbackOfferState!! +} + private operator fun SubscriptionPlansState.Loaded.get(productId: String) = plans.singleOrNull { it.productId == productId } @@ -956,6 +1073,14 @@ class FakeWinbackManager : WinbackManager { ) = purchaseEventTurbine.awaitItem() override suspend fun getWinbackOffer() = winbackResponseTurbine.awaitItem() + + override suspend fun claimWinbackOffer( + currentPurchase: Purchase, + winbackProduct: ProductDetails, + winbackOfferToken: String, + winbackClaimCode: String, + activity: Activity, + ) = purchaseEventTurbine.awaitItem() } class FakeTracker : Tracker { diff --git a/modules/services/localization/src/main/res/values/strings.xml b/modules/services/localization/src/main/res/values/strings.xml index 1f125fe1aa5..113b0ba9148 100644 --- a/modules/services/localization/src/main/res/values/strings.xml +++ b/modules/services/localization/src/main/res/values/strings.xml @@ -2307,6 +2307,7 @@ Manage subscriptions in Play Store Sorry, but something went wrong fetching your plans. Changing plan + Claiming offer Changing plan This will change your plan to a free account. Your current subscription will remain active until %1$s. diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/subscription/SubscriptionManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/subscription/SubscriptionManager.kt index c6285762506..0748f69d378 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/subscription/SubscriptionManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/subscription/SubscriptionManager.kt @@ -43,6 +43,13 @@ interface SubscriptionManager { activity: Activity, ): BillingResult + suspend fun claimWinbackOffer( + currentPurchase: Purchase, + winbackProduct: ProductDetails, + winbackOfferToken: String, + activity: Activity, + ): BillingResult + fun signOut() fun observeProductDetails(): Flowable diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/subscription/SubscriptionManagerImpl.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/subscription/SubscriptionManagerImpl.kt index fa09a736b84..94113d6aced 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/subscription/SubscriptionManagerImpl.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/subscription/SubscriptionManagerImpl.kt @@ -324,6 +324,30 @@ class SubscriptionManagerImpl @Inject constructor( return billingClient.launchBillingFlow(activity, billingFlowParams) } + override suspend fun claimWinbackOffer( + currentPurchase: Purchase, + winbackProduct: ProductDetails, + winbackOfferToken: String, + activity: Activity, + ): BillingResult { + val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(winbackProduct) + .setOfferToken(winbackOfferToken) + .build() + + val updateParams = BillingFlowParams.SubscriptionUpdateParams.newBuilder() + .setOldPurchaseToken(currentPurchase.purchaseToken) + .setSubscriptionReplacementMode(BillingFlowParams.SubscriptionUpdateParams.ReplacementMode.CHARGE_FULL_PRICE) + .build() + + val billingFlowParams = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(listOf(productDetailsParams)) + .setSubscriptionUpdateParams(updateParams) + .build() + + return billingClient.launchBillingFlow(activity, billingFlowParams) + } + private suspend fun loadSubscriptionUpdateParamsMode( productDetails: ProductDetails, ): Pair { diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/winback/WinbackManager.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/winback/WinbackManager.kt index 6953ede1a63..b22c2b9dd1e 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/winback/WinbackManager.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/winback/WinbackManager.kt @@ -22,4 +22,12 @@ interface WinbackManager { ): PurchaseEvent suspend fun getWinbackOffer(): WinbackResponse? + + suspend fun claimWinbackOffer( + currentPurchase: Purchase, + winbackProduct: ProductDetails, + winbackOfferToken: String, + winbackClaimCode: String, + activity: Activity, + ): PurchaseEvent } diff --git a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/winback/WinbackManagerImpl.kt b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/winback/WinbackManagerImpl.kt index cfd22d3b45f..e48c799c455 100644 --- a/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/winback/WinbackManagerImpl.kt +++ b/modules/services/repositories/src/main/java/au/com/shiftyjelly/pocketcasts/repositories/winback/WinbackManagerImpl.kt @@ -2,6 +2,7 @@ package au.com.shiftyjelly.pocketcasts.repositories.winback import android.app.Activity import au.com.shiftyjelly.pocketcasts.repositories.referrals.ReferralManager +import au.com.shiftyjelly.pocketcasts.repositories.referrals.ReferralManager.ReferralResult import au.com.shiftyjelly.pocketcasts.repositories.subscription.PurchaseEvent import au.com.shiftyjelly.pocketcasts.repositories.subscription.SubscriptionManager import au.com.shiftyjelly.pocketcasts.repositories.subscription.isOk @@ -44,8 +45,52 @@ class WinbackManagerImpl @Inject constructor( } override suspend fun getWinbackOffer() = when (val result = referralManager.getWinbackResponse()) { - is ReferralManager.ReferralResult.SuccessResult -> result.body - is ReferralManager.ReferralResult.EmptyResult -> null - is ReferralManager.ReferralResult.ErrorResult -> null + is ReferralResult.SuccessResult -> result.body + is ReferralResult.EmptyResult -> null + is ReferralResult.ErrorResult -> null + } + + override suspend fun claimWinbackOffer( + currentPurchase: Purchase, + winbackProduct: ProductDetails, + winbackOfferToken: String, + winbackClaimCode: String, + activity: Activity, + ): PurchaseEvent { + val startResult = subscriptionManager.claimWinbackOffer( + currentPurchase = currentPurchase, + winbackProduct = winbackProduct, + winbackOfferToken = winbackOfferToken, + activity = activity, + ) + return if (startResult.isOk()) { + val purchaseEvent = subscriptionManager.observePurchaseEvents().asFlow().first() + when (purchaseEvent) { + is PurchaseEvent.Success -> { + /** + * Successfully redeeming a referral code means that a user is no longer eligible for the Winback offer. + * Failure to redeem the code means that the Winback offer remains available to the user. + * + * However, whether the code was redeemed or not does not affect the purchase itself. + * It only matters for displaying the Winback offer to the user. + * + * If we forwarded the error, the user would see an error in a snackbar or some other form. + * But their purchase would still be processed and added to their subscription. + * They would still see the "Claim offer" button, allowing them to claim the offer indefinitely, + * as long as the redeem call fails. + * + * For this reason, we ignore the result of redeeming the referral code. + */ + referralManager.redeemReferralCode(winbackClaimCode) + purchaseEvent + } + is PurchaseEvent.Cancelled, is PurchaseEvent.Failure -> purchaseEvent + } + } else { + PurchaseEvent.Failure( + "Failed to start change product flow. ${startResult.debugMessage}", + startResult.responseCode, + ) + } } }