Skip to content

Commit e7c6d56

Browse files
authored
Switch option: calculate yearly savings dynamically (#7060)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1211755920811451/task/1211842214579233?focus=true ### Description Calculate savings dynamically from product details fetched ### Steps to test this PR _Pre steps_ - [ ] Apply patch from https://app.asana.com/1/137249556945/project/1209991789468715/task/1210448620621729?focus=true _Upgrade_ - [ ] Install from branch - [ ] Purchase a test monthly subscription (Free Trial) - [ ] Cancel and wait until Free Trial expires **OR** wait until free trial is renewed to a paid monthly subscription - [ ] If expired after canceled, purchase a monthly subscription again - [ ] Go to Subscription Settings - [ ] Check Switch option is there with updated text "Switch to Yearly and Save 17%" - [ ] Select Switch option - [ ] Check copy for upgrade option is correct (17%) _Downgrade_ - [ ] Switch to yearly - [ ] Wait until screen refreshes - [ ] Select switch option - [ ] Check copy for downgrade option is correct (17%) ### No UI changes
1 parent 9773e3c commit e7c6d56

File tree

5 files changed

+87
-7
lines changed

5 files changed

+87
-7
lines changed

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/SubscriptionsManager.kt

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ import logcat.logcat
9999
import retrofit2.HttpException
100100
import java.io.IOException
101101
import java.math.BigDecimal
102+
import java.math.RoundingMode
102103
import java.text.NumberFormat
103104
import java.time.Duration
104105
import java.time.Instant
@@ -436,21 +437,43 @@ class RealSubscriptionsManager @Inject constructor(
436437
?.firstOrNull()
437438
?.formattedPrice ?: return@withContext null
438439

439-
// Calculate monthly equivalent for yearly plan
440-
val (yearlyPriceAmount, yearlyPriceCurrency) = basePlans
440+
// Get monthly and yearly price amounts for savings calculation
441+
val monthlyPriceAmount = basePlans
442+
.find { it.planId in listOf(MONTHLY_PLAN_US, MONTHLY_PLAN_ROW) }
443+
?.pricingPhases
444+
?.firstOrNull()
445+
?.priceAmount ?: return@withContext null
446+
447+
val yearlyPriceAmount = basePlans
448+
.find { it.planId in listOf(YEARLY_PLAN_US, YEARLY_PLAN_ROW) }
449+
?.pricingPhases
450+
?.firstOrNull()
451+
?.priceAmount ?: return@withContext null
452+
453+
val yearlyPriceCurrency = basePlans
441454
.find { it.planId in listOf(YEARLY_PLAN_US, YEARLY_PLAN_ROW) }
442455
?.pricingPhases
443456
?.firstOrNull()
444-
?.let { it.priceAmount to it.priceCurrency } ?: return@withContext null
457+
?.priceCurrency ?: return@withContext null
445458

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

464+
// Calculate savings percentage: ((monthly * 12 - yearly) / (monthly * 12)) * 100
465+
// This represents the percentage saved by choosing yearly over 12 monthly payments
466+
val totalMonthlyAnnual = monthlyPriceAmount * 12.toBigDecimal()
467+
val savingsAmount = totalMonthlyAnnual - yearlyPriceAmount
468+
val savingsPercentage = ((savingsAmount / totalMonthlyAnnual) * 100.toBigDecimal())
469+
.setScale(0, RoundingMode.HALF_UP)
470+
.toInt()
471+
450472
SwitchPlanPricingInfo(
451473
currentPrice = currentPrice,
452474
targetPrice = targetPrice,
453475
yearlyMonthlyEquivalent = yearlyMonthlyEquivalent,
476+
savingsPercentage = savingsPercentage,
454477
)
455478
} catch (e: Exception) {
456479
logcat { "Subs: Failed to get switch plan pricing: ${e.message}" }
@@ -1375,6 +1398,7 @@ data class SwitchPlanPricingInfo(
13751398
val currentPrice: String,
13761399
val targetPrice: String,
13771400
val yearlyMonthlyEquivalent: String,
1401+
val savingsPercentage: Int,
13781402
)
13791403

13801404
data class ValidatedTokenPair(

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/switch_plan/SwitchPlanBottomSheetDialog.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor(
107107
when (switchType) {
108108
SwitchPlanType.UPGRADE_TO_YEARLY -> {
109109
// Configure for upgrade (Monthly → Yearly)
110-
binding.switchBottomSheetDialogTitle.text = context.getString(R.string.switchBottomSheetTitleUpgrade)
110+
binding.switchBottomSheetDialogTitle.text = context.getString(
111+
R.string.switchBottomSheetDynamicTitleUpgrade,
112+
pricingInfo?.savingsPercentage.toString(),
113+
)
111114
binding.switchBottomSheetDialogSubTitle.text = context.getString(
112115
R.string.switchBottomSheetDescriptionUpgrade,
113116
pricingInfo?.yearlyMonthlyEquivalent ?: "",
@@ -130,7 +133,10 @@ class SwitchPlanBottomSheetDialog @AssistedInject constructor(
130133

131134
SwitchPlanType.DOWNGRADE_TO_MONTHLY -> {
132135
// Configure for downgrade (Yearly → Monthly)
133-
binding.switchBottomSheetDialogTitle.text = context.getString(R.string.switchBottomSheetTitleDowngrade)
136+
binding.switchBottomSheetDialogTitle.text = context.getString(
137+
R.string.switchBottomSheetDynamicTitleDowngrade,
138+
pricingInfo?.savingsPercentage.toString(),
139+
)
134140
binding.switchBottomSheetDialogSubTitle.text = context.getString(
135141
R.string.switchBottomSheetDescriptionDowngrade,
136142
pricingInfo?.yearlyMonthlyEquivalent ?: "",

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsActivity.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ class SubscriptionSettingsActivity : DuckDuckGoActivity() {
175175
if (viewState.switchPlanAvailable && viewState.platform.lowercase() == "google") {
176176
binding.switchPlan.show()
177177
val switchText = when (viewState.duration) {
178-
Monthly -> getString(string.subscriptionSettingSwitchUpgrade)
178+
Monthly -> getString(string.subscriptionSettingSwitchUpgradeDynamic, viewState.savingsPercentage.toString())
179179
Yearly -> getString(string.subscriptionSettingSwitchDowngrade)
180180
}
181181
binding.switchPlan.setPrimaryText(switchText)

subscriptions/subscriptions-impl/src/main/java/com/duckduckgo/subscriptions/impl/ui/SubscriptionSettingsViewModel.kt

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,13 @@ class SubscriptionSettingsViewModel @Inject constructor(
9292
else -> Yearly
9393
}
9494

95+
val switchPlanAvailable = subscriptionsManager.isSwitchPlanAvailable()
96+
val savingsPercentage = if (switchPlanAvailable && type == Monthly) {
97+
subscriptionsManager.getSwitchPlanPricing(isUpgrade = true)?.savingsPercentage
98+
} else {
99+
null
100+
}
101+
95102
_viewState.emit(
96103
Ready(
97104
date = date,
@@ -101,7 +108,8 @@ class SubscriptionSettingsViewModel @Inject constructor(
101108
email = account.email?.takeUnless { it.isBlank() },
102109
showFeedback = privacyProUnifiedFeedback.shouldUseUnifiedFeedback(source = SUBSCRIPTION_SETTINGS),
103110
activeOffers = subscription.activeOffers,
104-
switchPlanAvailable = subscriptionsManager.isSwitchPlanAvailable(),
111+
switchPlanAvailable = switchPlanAvailable,
112+
savingsPercentage = savingsPercentage,
105113
),
106114
)
107115
}
@@ -180,6 +188,7 @@ class SubscriptionSettingsViewModel @Inject constructor(
180188
val showFeedback: Boolean = false,
181189
val activeOffers: List<ActiveOfferType>,
182190
val switchPlanAvailable: Boolean,
191+
val savingsPercentage: Int?,
183192
) : ViewState()
184193
}
185194
}

subscriptions/subscriptions-impl/src/test/java/com/duckduckgo/subscriptions/impl/RealSubscriptionsManagerTest.kt

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2022,6 +2022,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) {
20222022
assertEquals("$9.99", result!!.currentPrice)
20232023
assertEquals("$99.99", result.targetPrice)
20242024
assertEquals("$8.33", result.yearlyMonthlyEquivalent)
2025+
assertEquals(17, result.savingsPercentage)
20252026
}
20262027

20272028
@Test
@@ -2041,6 +2042,7 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) {
20412042
assertEquals("$99.99", result!!.currentPrice)
20422043
assertEquals("$9.99", result.targetPrice)
20432044
assertEquals("$8.33", result.yearlyMonthlyEquivalent)
2045+
assertEquals(17, result.savingsPercentage)
20442046
}
20452047

20462048
@Test
@@ -2070,6 +2072,45 @@ class RealSubscriptionsManagerTest(private val authApiV2Enabled: Boolean) {
20702072

20712073
assertNotNull(result)
20722074
assertEquals("€7.50", result!!.yearlyMonthlyEquivalent)
2075+
// Savings: (8.99 * 12 - 89.99) / (8.99 * 12) * 100 = 16.58% ≈ 17%
2076+
assertEquals(17, result.savingsPercentage)
2077+
}
2078+
2079+
@Test
2080+
fun whenGetSwitchPlanPricingThenSavingsPercentageIsRoundedCorrectly() = runTest {
2081+
givenSwitchPlanSubscriptionExists(productId = MONTHLY_PLAN_US)
2082+
authRepository.setFeatures(MONTHLY_PLAN_US, setOf(NETP))
2083+
authRepository.setFeatures(YEARLY_PLAN_US, setOf(NETP))
2084+
// Monthly: $10, Yearly: $100 (exact 16.666...% savings)
2085+
givenPlanOffersExist(
2086+
monthlyAmount = "10.00".toBigDecimal(),
2087+
yearlyAmount = "100.00".toBigDecimal(),
2088+
currency = Currency.getInstance("USD"),
2089+
)
2090+
2091+
val result = subscriptionsManager.getSwitchPlanPricing(isUpgrade = true)
2092+
2093+
assertNotNull(result)
2094+
// Savings: (10 * 12 - 100) / (10 * 12) * 100 = 16.666...% rounds to 17%
2095+
assertEquals(17, result!!.savingsPercentage)
2096+
}
2097+
2098+
@Test
2099+
fun whenGetSwitchPlanPricingWith20PercentSavingsThenCalculateCorrectly() = runTest {
2100+
givenSwitchPlanSubscriptionExists(productId = MONTHLY_PLAN_US)
2101+
authRepository.setFeatures(MONTHLY_PLAN_US, setOf(NETP))
2102+
authRepository.setFeatures(YEARLY_PLAN_US, setOf(NETP))
2103+
// Monthly: $10, Yearly: $96 (20% savings: 12*10 - 96 = 24, 24/120 = 20%)
2104+
givenPlanOffersExist(
2105+
monthlyAmount = "10.00".toBigDecimal(),
2106+
yearlyAmount = "96.00".toBigDecimal(),
2107+
currency = Currency.getInstance("USD"),
2108+
)
2109+
2110+
val result = subscriptionsManager.getSwitchPlanPricing(isUpgrade = true)
2111+
2112+
assertNotNull(result)
2113+
assertEquals(20, result!!.savingsPercentage)
20732114
}
20742115

20752116
private suspend fun givenSwitchPlanSubscriptionExists(

0 commit comments

Comments
 (0)