From d1bc5c1c14fe956a44e8d8abc83a37b8698f4266 Mon Sep 17 00:00:00 2001 From: nalcalag Date: Wed, 5 Nov 2025 13:52:49 +0100 Subject: [PATCH 1/2] dynamic savings % implemented --- .../impl/SubscriptionsManager.kt | 30 +++++++++-- .../SwitchPlanBottomSheetDialog.kt | 10 +++- .../impl/ui/SubscriptionSettingsActivity.kt | 2 +- .../impl/ui/SubscriptionSettingsViewModel.kt | 11 +++- .../impl/RealSubscriptionsManagerTest.kt | 53 ++++++++++++++++--- 5 files changed, 93 insertions(+), 13 deletions(-) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt index ed2a52f628a8..4d3580256f66 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt @@ -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 @@ -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}" } @@ -1375,6 +1398,7 @@ data class SwitchPlanPricingInfo( val currentPrice: String, val targetPrice: String, val yearlyMonthlyEquivalent: String, + val savingsPercentage: Int, ) data class ValidatedTokenPair( diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/switch_plan/SwitchPlanBottomSheetDialog.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/switch_plan/SwitchPlanBottomSheetDialog.kt index 1959f54fe0f5..e918f92882f6 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/switch_plan/SwitchPlanBottomSheetDialog.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/switch_plan/SwitchPlanBottomSheetDialog.kt @@ -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 ?: "", @@ -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 ?: "", diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt index 93d8ec108a62..c5f89fc7e736 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt @@ -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) diff --git a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt index 75071c2cd4f5..92e3bf53978a 100644 --- a/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt +++ b/subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt @@ -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, @@ -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, ), ) } @@ -180,6 +188,7 @@ class SubscriptionSettingsViewModel @Inject constructor( val showFeedback: Boolean = false, val activeOffers: List, val switchPlanAvailable: Boolean, + val savingsPercentage: Int?, ) : ViewState() } } diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt index 9398c6d35262..e73d43629e79 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt @@ -2019,9 +2019,10 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { val result = subscriptionsManager.getSwitchPlanPricing(isUpgrade = true) assertNotNull(result) - assertEquals("$9.99", result!!.currentPrice) - assertEquals("$99.99", result.targetPrice) - assertEquals("$8.33", result.yearlyMonthlyEquivalent) + assertEquals("US$9.99", result!!.currentPrice) + assertEquals("US$99.99", result.targetPrice) + assertEquals("US$8.33", result.yearlyMonthlyEquivalent) + assertEquals(17, result.savingsPercentage) } @Test @@ -2038,9 +2039,10 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { val result = subscriptionsManager.getSwitchPlanPricing(isUpgrade = false) assertNotNull(result) - assertEquals("$99.99", result!!.currentPrice) - assertEquals("$9.99", result.targetPrice) - assertEquals("$8.33", result.yearlyMonthlyEquivalent) + assertEquals("US$99.99", result!!.currentPrice) + assertEquals("US$9.99", result.targetPrice) + assertEquals("US$8.33", result.yearlyMonthlyEquivalent) + assertEquals(17, result.savingsPercentage) } @Test @@ -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( From eee46d0ab51107b426cb6267fa054c1180d8e442 Mon Sep 17 00:00:00 2001 From: nalcalag Date: Thu, 6 Nov 2025 12:20:59 +0100 Subject: [PATCH 2/2] Revert adding US$ in tests --- .../impl/RealSubscriptionsManagerTest.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt index e73d43629e79..7db2776c76cd 100644 --- a/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt +++ b/subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt @@ -2019,9 +2019,9 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { val result = subscriptionsManager.getSwitchPlanPricing(isUpgrade = true) assertNotNull(result) - assertEquals("US$9.99", result!!.currentPrice) - assertEquals("US$99.99", result.targetPrice) - assertEquals("US$8.33", result.yearlyMonthlyEquivalent) + assertEquals("$9.99", result!!.currentPrice) + assertEquals("$99.99", result.targetPrice) + assertEquals("$8.33", result.yearlyMonthlyEquivalent) assertEquals(17, result.savingsPercentage) } @@ -2039,9 +2039,9 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) { val result = subscriptionsManager.getSwitchPlanPricing(isUpgrade = false) assertNotNull(result) - assertEquals("US$99.99", result!!.currentPrice) - assertEquals("US$9.99", result.targetPrice) - assertEquals("US$8.33", result.yearlyMonthlyEquivalent) + assertEquals("$99.99", result!!.currentPrice) + assertEquals("$9.99", result.targetPrice) + assertEquals("$8.33", result.yearlyMonthlyEquivalent) assertEquals(17, result.savingsPercentage) }