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 @@ -99,6 +99,7 @@ import logcat.logcat
import retrofit2.HttpException
import java.io.IOException
import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.time.Duration
import java.time.Instant
Expand Down Expand Up @@ -436,21 +437,43 @@ class RealSubscriptionsManager @Inject constructor(
?.firstOrNull()
?.formattedPrice ?: return@withContext null

// Calculate monthly equivalent for yearly plan
val (yearlyPriceAmount, yearlyPriceCurrency) = basePlans
// Get monthly and yearly price amounts for savings calculation
val monthlyPriceAmount = basePlans
.find { it.planId in listOf(MONTHLY_PLAN_US, MONTHLY_PLAN_ROW) }
?.pricingPhases
?.firstOrNull()
?.priceAmount ?: return@withContext null

val yearlyPriceAmount = basePlans
.find { it.planId in listOf(YEARLY_PLAN_US, YEARLY_PLAN_ROW) }
?.pricingPhases
?.firstOrNull()
?.priceAmount ?: return@withContext null

val yearlyPriceCurrency = basePlans
.find { it.planId in listOf(YEARLY_PLAN_US, YEARLY_PLAN_ROW) }
?.pricingPhases
?.firstOrNull()
?.let { it.priceAmount to it.priceCurrency } ?: return@withContext null
?.priceCurrency ?: return@withContext null

// Calculate monthly equivalent for yearly plan
val yearlyMonthlyEquivalent = NumberFormat.getCurrencyInstance()
.apply { currency = yearlyPriceCurrency }
.format(yearlyPriceAmount / 12.toBigDecimal())

// Calculate savings percentage: ((monthly * 12 - yearly) / (monthly * 12)) * 100
// This represents the percentage saved by choosing yearly over 12 monthly payments
val totalMonthlyAnnual = monthlyPriceAmount * 12.toBigDecimal()
val savingsAmount = totalMonthlyAnnual - yearlyPriceAmount
val savingsPercentage = ((savingsAmount / totalMonthlyAnnual) * 100.toBigDecimal())
.setScale(0, RoundingMode.HALF_UP)
.toInt()

SwitchPlanPricingInfo(
currentPrice = currentPrice,
targetPrice = targetPrice,
yearlyMonthlyEquivalent = yearlyMonthlyEquivalent,
savingsPercentage = savingsPercentage,
)
} catch (e: Exception) {
logcat { "Subs: Failed to get switch plan pricing: ${e.message}" }
Expand Down Expand Up @@ -1375,6 +1398,7 @@ data class SwitchPlanPricingInfo(
val currentPrice: String,
val targetPrice: String,
val yearlyMonthlyEquivalent: String,
val savingsPercentage: Int,
)

data class ValidatedTokenPair(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor(
when (switchType) {
SwitchPlanType.UPGRADE_TO_YEARLY -> {
// Configure for upgrade (Monthly → Yearly)
binding.switchBottomSheetDialogTitle.text = context.getString(R.string.switchBottomSheetTitleUpgrade)
binding.switchBottomSheetDialogTitle.text = context.getString(
R.string.switchBottomSheetDynamicTitleUpgrade,
pricingInfo?.savingsPercentage.toString(),
)
binding.switchBottomSheetDialogSubTitle.text = context.getString(
R.string.switchBottomSheetDescriptionUpgrade,
pricingInfo?.yearlyMonthlyEquivalent ?: "",
Expand All @@ -130,7 +133,10 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor(

SwitchPlanType.DOWNGRADE_TO_MONTHLY -> {
// Configure for downgrade (Yearly → Monthly)
binding.switchBottomSheetDialogTitle.text = context.getString(R.string.switchBottomSheetTitleDowngrade)
binding.switchBottomSheetDialogTitle.text = context.getString(
R.string.switchBottomSheetDynamicTitleDowngrade,
pricingInfo?.savingsPercentage.toString(),
)
binding.switchBottomSheetDialogSubTitle.text = context.getString(
R.string.switchBottomSheetDescriptionDowngrade,
pricingInfo?.yearlyMonthlyEquivalent ?: "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() {
if (viewState.switchPlanAvailable && viewState.platform.lowercase() == "google") {
binding.switchPlan.show()
val switchText = when (viewState.duration) {
Monthly -> getString(string.subscriptionSettingSwitchUpgrade)
Monthly -> getString(string.subscriptionSettingSwitchUpgradeDynamic, viewState.savingsPercentage.toString())
Yearly -> getString(string.subscriptionSettingSwitchDowngrade)
}
binding.switchPlan.setPrimaryText(switchText)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,13 @@ class SubscriptionSettingsViewModel @Inject constructor(
else -> Yearly
}

val switchPlanAvailable = subscriptionsManager.isSwitchPlanAvailable()
val savingsPercentage = if (switchPlanAvailable && type == Monthly) {
subscriptionsManager.getSwitchPlanPricing(isUpgrade = true)?.savingsPercentage
} else {
null
}

_viewState.emit(
Ready(
date = date,
Expand All @@ -101,7 +108,8 @@ class SubscriptionSettingsViewModel @Inject constructor(
email = account.email?.takeUnless { it.isBlank() },
showFeedback = privacyProUnifiedFeedback.shouldUseUnifiedFeedback(source = SUBSCRIPTION_SETTINGS),
activeOffers = subscription.activeOffers,
switchPlanAvailable = subscriptionsManager.isSwitchPlanAvailable(),
switchPlanAvailable = switchPlanAvailable,
savingsPercentage = savingsPercentage,
),
)
}
Expand Down Expand Up @@ -180,6 +188,7 @@ class SubscriptionSettingsViewModel @Inject constructor(
val showFeedback: Boolean = false,
val activeOffers: List<ActiveOfferType>,
val switchPlanAvailable: Boolean,
val savingsPercentage: Int?,
) : ViewState()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2022,6 +2022,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) {
assertEquals("$9.99", result!!.currentPrice)
assertEquals("$99.99", result.targetPrice)
assertEquals("$8.33", result.yearlyMonthlyEquivalent)
assertEquals(17, result.savingsPercentage)
}

@Test
Expand All @@ -2041,6 +2042,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) {
assertEquals("$99.99", result!!.currentPrice)
assertEquals("$9.99", result.targetPrice)
assertEquals("$8.33", result.yearlyMonthlyEquivalent)
assertEquals(17, result.savingsPercentage)
}

@Test
Expand Down Expand Up @@ -2070,6 +2072,45 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) {

assertNotNull(result)
assertEquals("€7.50", result!!.yearlyMonthlyEquivalent)
// Savings: (8.99 * 12 - 89.99) / (8.99 * 12) * 100 = 16.58% ≈ 17%
assertEquals(17, result.savingsPercentage)
}

@Test
fun whenGetSwitchPlanPricingThenSavingsPercentageIsRoundedCorrectly() = runTest {
givenSwitchPlanSubscriptionExists(productId = MONTHLY_PLAN_US)
authRepository.setFeatures(MONTHLY_PLAN_US, setOf(NETP))
authRepository.setFeatures(YEARLY_PLAN_US, setOf(NETP))
// Monthly: $10, Yearly: $100 (exact 16.666...% savings)
givenPlanOffersExist(
monthlyAmount = "10.00".toBigDecimal(),
yearlyAmount = "100.00".toBigDecimal(),
currency = Currency.getInstance("USD"),
)

val result = subscriptionsManager.getSwitchPlanPricing(isUpgrade = true)

assertNotNull(result)
// Savings: (10 * 12 - 100) / (10 * 12) * 100 = 16.666...% rounds to 17%
assertEquals(17, result!!.savingsPercentage)
}

@Test
fun whenGetSwitchPlanPricingWith20PercentSavingsThenCalculateCorrectly() = runTest {
givenSwitchPlanSubscriptionExists(productId = MONTHLY_PLAN_US)
authRepository.setFeatures(MONTHLY_PLAN_US, setOf(NETP))
authRepository.setFeatures(YEARLY_PLAN_US, setOf(NETP))
// Monthly: $10, Yearly: $96 (20% savings: 12*10 - 96 = 24, 24/120 = 20%)
givenPlanOffersExist(
monthlyAmount = "10.00".toBigDecimal(),
yearlyAmount = "96.00".toBigDecimal(),
currency = Currency.getInstance("USD"),
)

val result = subscriptionsManager.getSwitchPlanPricing(isUpgrade = true)

assertNotNull(result)
assertEquals(20, result!!.savingsPercentage)
}

private suspend fun givenSwitchPlanSubscriptionExists(
Expand Down
Loading