Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Privacy Pro Subscriptions: Update some internal models and methods #5402

Merged
merged 14 commits into from
Dec 21, 2024
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ class RealSubscriptions @Inject constructor(
override suspend fun isEligible(): Boolean {
val supportsEncryption = subscriptionsManager.canSupportEncryption()
val isActive = subscriptionsManager.subscriptionStatus().isActiveOrWaiting()
val isEligible = subscriptionsManager.getSubscriptionOffer() != null
val isEligible = subscriptionsManager.getSubscriptionOffer().isNotEmpty()
return isActive || (isEligible && supportsEncryption)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ object SubscriptionsConstants {
const val YEARLY_PLAN_ROW = "ddg-privacy-pro-yearly-renews-row"
const val MONTHLY_PLAN_ROW = "ddg-privacy-pro-monthly-renews-row"

// List of offers
const val MONTHLY_FREE_TRIAL_OFFER_US = "ddg-privacy-pro-sandbox-freetrial-monthly-renews-us"
const val YEARLY_FREE_TRIAL_OFFER_US = "ddg-privacy-pro-sandbox-freetrial-yearly-renews-us"

// List of features
const val LEGACY_FE_NETP = "vpn"
const val LEGACY_FE_ITR = "identity-theft-restoration"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ interface SubscriptionsManager {
/**
* Returns available purchase options retrieved from Play Store
*/
suspend fun getSubscriptionOffer(): SubscriptionOffer?
suspend fun getSubscriptionOffer(): List<SubscriptionOffer>

/**
* Launches the purchase flow for a given plan id
Expand Down Expand Up @@ -309,6 +309,7 @@ class RealSubscriptionsManager @Inject constructor(
removeExpiredSubscriptionOnCancelledPurchase = false
}
}

else -> {
// NOOP
}
Expand Down Expand Up @@ -612,6 +613,7 @@ class RealSubscriptionsManager @Inject constructor(
RecoverSubscriptionResult.Failure(SUBSCRIPTION_NOT_FOUND_ERROR)
}
}

is StoreLoginResult.Failure -> {
RecoverSubscriptionResult.Failure("")
}
Expand Down Expand Up @@ -653,43 +655,48 @@ class RealSubscriptionsManager @Inject constructor(
data class Failure(val message: String) : RecoverSubscriptionResult()
}

override suspend fun getSubscriptionOffer(): SubscriptionOffer? =
private suspend fun activePlanIds(): List<String> =
if (isLaunchedRow()) {
listOf(YEARLY_PLAN_US, MONTHLY_PLAN_US, YEARLY_PLAN_ROW, MONTHLY_PLAN_ROW)
} else {
listOf(YEARLY_PLAN_US, MONTHLY_PLAN_US)
}

override suspend fun getSubscriptionOffer(): List<SubscriptionOffer> =
playBillingManager.products
.find { it.productId == BASIC_SUBSCRIPTION }
?.subscriptionOfferDetails
.orEmpty()
.associateBy { it.basePlanId }
.filter { activePlanIds().contains(it.basePlanId) }
.let { availablePlans ->
when {
availablePlans.keys.containsAll(listOf(MONTHLY_PLAN_US, YEARLY_PLAN_US)) -> {
availablePlans.getValue(MONTHLY_PLAN_US) to availablePlans.getValue(YEARLY_PLAN_US)
}
availablePlans.keys.containsAll(listOf(MONTHLY_PLAN_ROW, YEARLY_PLAN_ROW)) && isLaunchedRow() -> {
availablePlans.getValue(MONTHLY_PLAN_ROW) to availablePlans.getValue(YEARLY_PLAN_ROW)
availablePlans.map { offer ->
val pricingPhases = offer.pricingPhases.pricingPhaseList.map { phase ->
PricingPhase(
formattedPrice = phase.formattedPrice,
billingPeriod = phase.billingPeriod,

)
}
else -> null
}
}
?.let { (monthlyOffer, yearlyOffer) ->
val features = if (privacyProFeature.get().featuresApi().isEnabled()) {
authRepository.getFeatures(monthlyOffer.basePlanId)
} else {
when (monthlyOffer.basePlanId) {
MONTHLY_PLAN_US -> setOf(LEGACY_FE_NETP, LEGACY_FE_PIR, LEGACY_FE_ITR)
MONTHLY_PLAN_ROW -> setOf(NETP, ROW_ITR)
else -> throw IllegalStateException()

val features = if (privacyProFeature.get().featuresApi().isEnabled()) {
authRepository.getFeatures(offer.basePlanId)
} else {
when (offer.basePlanId) {
MONTHLY_PLAN_US, YEARLY_PLAN_US -> setOf(LEGACY_FE_NETP, LEGACY_FE_PIR, LEGACY_FE_ITR)
MONTHLY_PLAN_ROW, YEARLY_PLAN_ROW -> setOf(NETP, ROW_ITR)
else -> throw IllegalStateException()
}
}
}

if (features.isEmpty()) return@let null
if (features.isEmpty()) return@let emptyList()

SubscriptionOffer(
monthlyPlanId = monthlyOffer.basePlanId,
monthlyFormattedPrice = monthlyOffer.pricingPhases.pricingPhaseList.first().formattedPrice,
yearlyPlanId = yearlyOffer.basePlanId,
yearlyFormattedPrice = yearlyOffer.pricingPhases.pricingPhaseList.first().formattedPrice,
features = features,
)
SubscriptionOffer(
planId = offer.basePlanId,
pricingPhases = pricingPhases,
offerId = offer.offerId,
features = features,
)
}
}

override suspend fun purchase(
Expand Down Expand Up @@ -945,13 +952,26 @@ sealed class CurrentPurchase {
}

data class SubscriptionOffer(
val monthlyPlanId: String,
val monthlyFormattedPrice: String,
val yearlyPlanId: String,
val yearlyFormattedPrice: String,
val planId: String,
val offerId: String?,
val pricingPhases: List<PricingPhase>,
val features: Set<String>,
)

data class PricingPhase(
val formattedPrice: String,
val billingPeriod: String,

) {
internal fun getBillingPeriodInDays(): Int? =
when (billingPeriod) {
"P1W" -> 7
"P1M" -> 30
"P1Y" -> 365
else -> null
}
}

data class ValidatedTokenPair(
val accessToken: String,
val accessTokenClaims: AccessTokenClaims,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus
import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_ROW
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_ROW
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US
import com.duckduckgo.subscriptions.impl.SubscriptionsManager
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
import com.duckduckgo.subscriptions.impl.settings.views.LegacyProSettingViewModel.Command.OpenBuyScreen
Expand Down Expand Up @@ -88,9 +90,10 @@ class LegacyProSettingViewModel @Inject constructor(
subscriptionsManager.subscriptionStatus
.distinctUntilChanged()
.onEach { subscriptionStatus ->
val region = when (subscriptionsManager.getSubscriptionOffer()?.monthlyPlanId) {
MONTHLY_PLAN_ROW -> SubscriptionRegion.ROW
MONTHLY_PLAN_US -> SubscriptionRegion.US
val offer = subscriptionsManager.getSubscriptionOffer().firstOrNull()
val region = when (offer?.planId) {
MONTHLY_PLAN_ROW, YEARLY_PLAN_ROW -> SubscriptionRegion.ROW
MONTHLY_PLAN_US, YEARLY_PLAN_US -> SubscriptionRegion.US
else -> null
}
_viewState.emit(viewState.value.copy(status = subscriptionStatus, region = region))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus
import com.duckduckgo.subscriptions.api.SubscriptionStatus.UNKNOWN
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_ROW
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_ROW
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US
import com.duckduckgo.subscriptions.impl.SubscriptionsManager
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
import com.duckduckgo.subscriptions.impl.settings.views.ProSettingViewModel.Command.OpenBuyScreen
Expand Down Expand Up @@ -88,9 +90,10 @@ class ProSettingViewModel @Inject constructor(
subscriptionsManager.subscriptionStatus
.distinctUntilChanged()
.onEach { subscriptionStatus ->
val region = when (subscriptionsManager.getSubscriptionOffer()?.monthlyPlanId) {
MONTHLY_PLAN_ROW -> SubscriptionRegion.ROW
MONTHLY_PLAN_US -> SubscriptionRegion.US
val offer = subscriptionsManager.getSubscriptionOffer().firstOrNull()
val region = when (offer?.planId) {
MONTHLY_PLAN_ROW, YEARLY_PLAN_ROW -> SubscriptionRegion.ROW
MONTHLY_PLAN_US, YEARLY_PLAN_US -> SubscriptionRegion.US
else -> null
}
_viewState.emit(viewState.value.copy(status = subscriptionStatus, region = region))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,24 @@ import com.duckduckgo.subscriptions.api.SubscriptionStatus
import com.duckduckgo.subscriptions.impl.CurrentPurchase
import com.duckduckgo.subscriptions.impl.JSONObjectAdapter
import com.duckduckgo.subscriptions.impl.PrivacyProFeature
import com.duckduckgo.subscriptions.impl.SubscriptionOffer
import com.duckduckgo.subscriptions.impl.SubscriptionsChecker
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.ITR
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LEGACY_FE_ITR
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LEGACY_FE_NETP
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.LEGACY_FE_PIR
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_FREE_TRIAL_OFFER_US
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_ROW
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.MONTHLY_PLAN_US
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.NETP
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.PIR
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.PLATFORM
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.ROW_ITR
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_FREE_TRIAL_OFFER_US
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_ROW
import com.duckduckgo.subscriptions.impl.SubscriptionsConstants.YEARLY_PLAN_US
import com.duckduckgo.subscriptions.impl.SubscriptionsManager
import com.duckduckgo.subscriptions.impl.pixels.SubscriptionPixelSender
import com.duckduckgo.subscriptions.impl.repository.isActive
Expand Down Expand Up @@ -229,38 +236,87 @@ class SubscriptionWebViewViewModel @Inject constructor(
}

private fun getSubscriptionOptions(featureName: String, method: String, id: String) {
suspend fun sendOptionJson(optionsJson: SubscriptionOptionsJson) {
val response = JsCallbackData(
featureName = featureName,
method = method,
id = id,
params = JSONObject(jsonAdapter.toJson(optionsJson)),
)
command.send(SendResponseToJs(response))
}

viewModelScope.launch(dispatcherProvider.io()) {
var subscriptionOptions = SubscriptionOptionsJson(
val defaultOptions = SubscriptionOptionsJson(
options = emptyList(),
features = emptyList(),
)

if (privacyProFeature.allowPurchase().isEnabled()) {
subscriptionsManager.getSubscriptionOffer()?.let { offer ->
val yearlyJson = OptionsJson(
id = offer.yearlyPlanId,
cost = CostJson(displayPrice = offer.yearlyFormattedPrice, recurrence = YEARLY),
)
val subscriptionOptions = if (privacyProFeature.allowPurchase().isEnabled()) {
val subscriptionOffers = subscriptionsManager.getSubscriptionOffer().associateBy { it.offerId ?: it.planId }
when {
subscriptionOffers.keys.containsAll(listOf(MONTHLY_PLAN_US, YEARLY_PLAN_US)) -> {
createSubscriptionOptions(
monthlyOffer = subscriptionOffers.getValue(MONTHLY_PLAN_US),
yearlyOffer = subscriptionOffers.getValue(YEARLY_PLAN_US),
)
}

subscriptionOffers.keys.containsAll(listOf(MONTHLY_PLAN_ROW, YEARLY_PLAN_ROW)) -> {
createSubscriptionOptions(
monthlyOffer = subscriptionOffers.getValue(MONTHLY_PLAN_ROW),
yearlyOffer = subscriptionOffers.getValue(YEARLY_PLAN_ROW),
)
}

else -> defaultOptions
}
} else {
defaultOptions
}

val monthlyJson = OptionsJson(
id = offer.monthlyPlanId,
cost = CostJson(displayPrice = offer.monthlyFormattedPrice, recurrence = MONTHLY),
)
sendOptionJson(subscriptionOptions)
}
}

subscriptionOptions = SubscriptionOptionsJson(
options = listOf(yearlyJson, monthlyJson),
features = offer.features.map(::FeatureJson),
)
}
private fun createSubscriptionOptions(
monthlyOffer: SubscriptionOffer,
yearlyOffer: SubscriptionOffer,
): SubscriptionOptionsJson {
return SubscriptionOptionsJson(
options = listOf(
createOptionsJson(yearlyOffer, YEARLY),
createOptionsJson(monthlyOffer, MONTHLY),
),
features = monthlyOffer.features.map(::FeatureJson),
)
}

private fun createOptionsJson(offer: SubscriptionOffer, recurrence: String): OptionsJson {
val offerDisplayPrice: String = offer.offerId?.let {
offer.pricingPhases.getOrNull(1)?.formattedPrice ?: offer.pricingPhases.first().formattedPrice
} ?: offer.pricingPhases.first().formattedPrice

return OptionsJson(
id = offer.planId,
cost = CostJson(displayPrice = offerDisplayPrice, recurrence = recurrence),
offer = getOfferJson(offer),
)
}

private fun getOfferJson(offer: SubscriptionOffer): OfferJson? {
return offer.offerId?.let {
val offerType = when (offer.offerId) {
MONTHLY_FREE_TRIAL_OFFER_US, YEARLY_FREE_TRIAL_OFFER_US -> OfferType.FREE_TRIAL
else -> OfferType.UNKNOWN
}

val response = JsCallbackData(
featureName = featureName,
method = method,
id = id,
params = JSONObject(jsonAdapter.toJson(subscriptionOptions)),
OfferJson(
type = offerType,
id = it,
durationInDays = offer.pricingPhases.first().getBillingPeriodInDays(),
isUserEligible = true, // TODO Noelia: Need to check if they already had a free trial before to return false
)
command.send(SendResponseToJs(response))
}
}

Expand Down Expand Up @@ -293,11 +349,28 @@ class SubscriptionWebViewViewModel @Inject constructor(
data class OptionsJson(
val id: String,
val cost: CostJson,
val offer: OfferJson?,
)

data class CostJson(
val displayPrice: String,
val recurrence: String,
)

data class OfferJson(
val type: OfferType,
val id: String,
val durationInDays: Int?,
val isUserEligible: Boolean,
)

data class CostJson(val displayPrice: String, val recurrence: String)
data class FeatureJson(val name: String)

enum class OfferType(val type: String) {
FREE_TRIAL("freeTrial"),
UNKNOWN("unknown"),
}

sealed class PurchaseStateView {
data object Inactive : PurchaseStateView()
data object InProgress : PurchaseStateView()
Expand Down
Loading
Loading