Skip to content

Commit

Permalink
Privacy Pro Subscriptions: Update some internal models and methods (d…
Browse files Browse the repository at this point in the history
…uckduckgo#5402)

Task/Issue URL: https://app.asana.com/0/1201807753394693/1209007672205184/f

### Description
Update some subscriptions models in order to support free trials on mobile:

- `SubscriptionOffer` model
- `OptionsJson` model
- `getSubscriptionOffer()` method

### Steps to test this PR
_Pre steps_
- [x] Apply patch to being able to test subscriptions in staging

_Privacy Pro Eligible US_
- [x] Make sure you are US eligible
- [x] Install form branch
- [x] Check browser works as expected
- [x] Go to Settings
- [x] Check you can see Privacy Pro option
- [x] Tap on that option
- [x] Check Subscription page looks as expected
- [x] Purchase any of the options and check it works well
- [x] Go to Subscription Settings
- [x] Check everything works as expected

_Privacy Pro Eligible ROW (Optional)_
- [x] Make sure you are ROW eligible
- [x] Install form branch
- [x] Check browser works as expected
- [x] Go to Settings
- [x] Check you can see Privacy Pro option
- [x] Tap on that option
- [x] Check Subscription page looks as expected
- [x] Purchase any of the options and check it works well
- [x] Go to Subscription Settings
- [x] Check everything works as expected

_Privacy Pro No Eligible_
- [x] Install form branch
- [x] Check browser works as expected
- [x] Go to Settings
- [x] Check you can't see Privacy Pro option

### No UI changes

Co-authored-by: Lukasz Macionczyk <lukasz.macionczyk@gmail.com>
  • Loading branch information
nalcalag and lmac012 authored Dec 21, 2024
1 parent f509798 commit 38fc930
Show file tree
Hide file tree
Showing 11 changed files with 258 additions and 152 deletions.
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.type,
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: String,
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

0 comments on commit 38fc930

Please sign in to comment.