Skip to content

Commit

Permalink
Reload Winback offer after subscription plan changes (#3565)
Browse files Browse the repository at this point in the history
  • Loading branch information
MiSikora authored Feb 7, 2025
1 parent d921898 commit cefa571
Show file tree
Hide file tree
Showing 2 changed files with 59 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ import com.pocketcasts.service.api.WinbackResponse
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.Date
import javax.inject.Inject
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -54,7 +56,7 @@ class WinbackViewModel @Inject constructor(
viewModelScope.launch {
val plansDeferred = async { loadPlans() }
val activePurchaseDeferred = async { loadActivePurchase() }
val winbackOfferResponseDeferred = async { winbackManager.getWinbackOffer() }
val winbackOfferResponseDeferred = async { loadWinbackOffer() }

val plansResult = plansDeferred.await()
val activePurchaseResult = activePurchaseDeferred.await()
Expand Down Expand Up @@ -82,11 +84,7 @@ class WinbackViewModel @Inject constructor(
)

val winbackResponse = winbackOfferResponseDeferred.await()
_uiState.value = _uiState.value.copy(
winbackOfferState = winbackResponse
?.toWinbackOffer(_uiState.value.productsDetails)
?.let(::WinbackOfferState),
)
_uiState.value = _uiState.value.applyWinbackResponse(winbackResponse)
}
}

Expand Down Expand Up @@ -145,22 +143,28 @@ class WinbackViewModel @Inject constructor(
currentProductId = currentProductId,
newProductId = newProduct.productId,
)
val activePurchaseDeferred = async { loadActivePurchase() }
val winbackOfferResponseDeferred = async { loadWinbackOffer() }

val (newPurchases, newPurchase) = activePurchaseDeferred.await()

val (newPurchases, newPurchase) = loadActivePurchase()
when (newPurchase) {
_uiState.value = when (newPurchase) {
is ActivePurchaseResult.Found -> {
_uiState.value = _uiState.value
.copy(purchases = newPurchases)
_uiState.value
.copy(purchases = newPurchases, winbackOfferState = null)
.withLoadedSubscriptionPlans { plans ->
plans.copy(isChangingPlan = false, activePurchase = newPurchase.purchase)
}
}

is ActivePurchaseResult.NotFound -> {
val failure = SubscriptionPlansState.Failure(FailureReason.Default)
_uiState.value = _uiState.value.copy(subscriptionPlansState = failure)
uiState.value.copy(subscriptionPlansState = failure, winbackOfferState = null)
}
}

val winbackResponse = winbackOfferResponseDeferred.await()
_uiState.value = _uiState.value.applyWinbackResponse(winbackResponse)
}
}
}
Expand Down Expand Up @@ -241,6 +245,22 @@ class WinbackViewModel @Inject constructor(
is PurchasesState.Failure -> emptyList<Purchase>() to ActivePurchaseResult.NotFound(FailureReason.Default)
}

private var winbackOfferJob: Deferred<WinbackResponse?>? = null

// The winback offer is loaded this way to avoid plan change race conditions.
// Changing a plan means the user may have a different winback offer available, which needs to be loaded.
//
// However, the offer from the initial call may not have loaded yet.
//
// If we start loading a new one without canceling the previous call, we might end up
// displaying an incorrect winback offer to the user, as the old call could complete after the new one.
private suspend fun loadWinbackOffer(): WinbackResponse? {
winbackOfferJob?.cancelAndJoin()
return viewModelScope.async { winbackManager.getWinbackOffer() }
.also { winbackOfferJob = it }
.await()
}

private fun List<ProductDetails>.toSubscriptionPlans() = map(subscriptionMapper::mapFromProductDetails)
.filterIsInstance<Subscription.Simple>()
.map(Subscription.Simple::toPlan)
Expand Down Expand Up @@ -316,6 +336,16 @@ class WinbackViewModel @Inject constructor(
}
}

private fun UiState.applyWinbackResponse(response: WinbackResponse?): UiState {
val activePurchase = (subscriptionPlansState as? SubscriptionPlansState.Loaded)?.activePurchase
return copy(
winbackOfferState = response
?.takeIf { activePurchase != null }
?.toWinbackOffer(productsDetails)
?.let(::WinbackOfferState),
)
}

private fun logWarning(message: String) {
Timber.tag(LogBuffer.TAG_SUBSCRIPTIONS).w(message)
LogBuffer.w(LogBuffer.TAG_SUBSCRIPTIONS, message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -245,10 +245,18 @@ class WinbackViewModelTest {
val newPurchase = createPurchase(orderId = "new-purchase")
winbackManager.addPurchases(listOf(newPurchase))
winbackManager.addPurchaseEvent(PurchaseEvent.Success)
val state = awaitLoadedState()

assertFalse(state.isChangingPlan)
assertEquals(state.activePurchase, ActivePurchase(newPurchase.orderId!!, newPurchase.products[0]))
val changedPlanState = awaitItem()
val plansState = changedPlanState.subscriptionPlansState as SubscriptionPlansState.Loaded
assertFalse(plansState.isChangingPlan)
assertEquals(plansState.activePurchase, ActivePurchase(newPurchase.orderId!!, newPurchase.products[0]))
assertNull(changedPlanState.winbackOfferState)

winbackManager.addWinbackResponse(winbackResponse)
assertEquals(
"offer-token-${Subscription.PLUS_MONTHLY_PRODUCT_ID}",
awaitOfferState().offer.offerToken,
)
}
}

Expand Down Expand Up @@ -335,8 +343,12 @@ class WinbackViewModelTest {
winbackManager.addPurchases(emptyList())
winbackManager.addPurchaseEvent(PurchaseEvent.Success)

val state = awaitItem().subscriptionPlansState
assertTrue(state is SubscriptionPlansState.Failure)
val changedPlanState = awaitItem()
assertTrue(changedPlanState.subscriptionPlansState is SubscriptionPlansState.Failure)
assertNull(changedPlanState.winbackOfferState)

winbackManager.addWinbackResponse(winbackResponse)
expectNoEvents()
}
}

Expand Down Expand Up @@ -896,6 +908,7 @@ class WinbackViewModelTest {
viewModel.changePlan(knownPlan, mock())

winbackManager.addPurchase(createPurchase(productIds = listOf(Subscription.PLUS_MONTHLY_PRODUCT_ID)))
winbackManager.addWinbackResponse(null)
winbackManager.addPurchaseEvent(PurchaseEvent.Success)

assertEquals(
Expand Down

0 comments on commit cefa571

Please sign in to comment.