Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ec660d1
Making sure we do not show the loader if the purchase fails right away
ThomasSession Oct 27, 2025
32b457f
UI Update to match designs
ThomasSession Oct 28, 2025
e7a2c1b
More state and UI handling to match designs
ThomasSession Oct 28, 2025
0f5a606
Merge branch 'dev' into feature/more-pro-states
ThomasSession Oct 28, 2025
9fc05e7
Merge branch 'dev' into feature/more-pro-states
ThomasSession Oct 29, 2025
be0d710
giving subscription state to CTA to display dynamic content
ThomasSession Oct 29, 2025
9a594d6
Merge branch 'dev' into feature/more-pro-states
ThomasSession Oct 30, 2025
054e01e
Rely on subscription state instead of simple boolean for Pro status
ThomasSession Oct 30, 2025
fa92657
Renaming subscription methods in preparation for price calculation
ThomasSession Oct 30, 2025
150ca71
Added State management to the choose plan and cancel data
ThomasSession Oct 30, 2025
760a693
New debug toggle for quick refunds
ThomasSession Oct 30, 2025
63c3d0c
Making sure refund also handle its data within a State
ThomasSession Oct 31, 2025
e6134d7
Adding price calculation and formatting
ThomasSession Oct 31, 2025
556cbd1
Formatting total to match
ThomasSession Oct 31, 2025
8d9e34f
Do not apply debug setting if we are not forcing the user as pro
ThomasSession Oct 31, 2025
85ada7d
Fixing old component to use crossfade for better transition
ThomasSession Oct 31, 2025
0e3a987
Removing suffix for QA + PR feedback
ThomasSession Nov 3, 2025
2e77128
PR feedback
ThomasSession Nov 3, 2025
61c05e5
Merge branch 'dev' into feature/more-pro-states
ThomasSession Nov 3, 2025
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
Prev Previous commit
Next Next commit
Adding price calculation and formatting
  • Loading branch information
ThomasSession committed Oct 31, 2025
commit e6134d72303e6c3ecce3a317238f521cf186f879
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ import org.thoughtcrime.securesms.pro.subscription.SubscriptionManager
import org.thoughtcrime.securesms.pro.subscription.expiryFromNow
import org.thoughtcrime.securesms.ui.SimpleDialogData
import org.thoughtcrime.securesms.ui.UINavigator
import org.thoughtcrime.securesms.util.CurrencyFormatter
import org.thoughtcrime.securesms.util.DateUtils
import org.thoughtcrime.securesms.util.State
import java.math.BigDecimal


@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
Expand Down Expand Up @@ -558,7 +560,16 @@ class ProSettingsViewModel @AssistedInject constructor(
// there is no point in calculating it if the user is pro but without a valid sub
// (meaning they got pro from a different google account than the one they are on now
val plans = if(subType is SubscriptionType.Active && !hasValidSub) emptyList()
else getSubscriptionPlans(subType)
else {
// attempt to get the prices from the subscription provider
// return early in case of error
try {
getSubscriptionPlans(subType)
} catch (e: Exception){
_choosePlanState.update { State.Error(e) }
return@launch
}
}

_choosePlanState.update {
State.Success(
Expand All @@ -585,81 +596,96 @@ class ProSettingsViewModel @AssistedInject constructor(
val currentPlan3Months = isActive && subType.duration == ProSubscriptionDuration.THREE_MONTHS
val currentPlan1Month = isActive && subType.duration == ProSubscriptionDuration.ONE_MONTH

return listOf(
// get prices from the subscription provider
val prices = subscriptionCoordinator.getCurrentManager().getSubscriptionPrices()

val data1Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.ONE_MONTH })
val data3Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.THREE_MONTHS })
val data12Month = calculatePricesFor(prices.firstOrNull{ it.subscriptionDuration == ProSubscriptionDuration.TWELVE_MONTHS })

val baseline = data1Month?.perMonthUnits ?: BigDecimal.ZERO

val plan12Months = data12Month?.let {
ProPlan(
title = Phrase.from(context.getText(R.string.proPriceTwelveMonths))
.put(MONTHLY_PRICE_KEY, "$3.99") //todo PRO calculate properly
.put(MONTHLY_PRICE_KEY, it.perMonthText)
.format().toString(),
subtitle = Phrase.from(context.getText(R.string.proBilledAnnually))
.put(PRICE_KEY, "$47.99") //todo PRO calculate properly
.put(PRICE_KEY, it.totalText)
.format().toString(),
selected = currentPlan12Months || subType !is SubscriptionType.Active, // selected if our active sub is 12 month, or as a default for non pro or renew
currentPlan = currentPlan12Months,
durationType = ProSubscriptionDuration.TWELVE_MONTHS,
badges = buildList {
if(currentPlan12Months){
add(
ProPlanBadge(context.getString(R.string.currentBilling))
)
}
if (currentPlan12Months) add(ProPlanBadge(context.getString(R.string.currentBilling)))
discountBadge(baseline = baseline, it.perMonthUnits, showTooltip = currentPlan12Months)?.let(this::add)
}
)
}

add(
ProPlanBadge(
"33% Off", //todo PRO calculate properly
if(currentPlan12Months) Phrase.from(context.getText(R.string.proDiscountTooltip))
.put(PRO_KEY, NonTranslatableStringConstants.PRO)
.put(PERCENT_KEY, "33") //todo PRO calculate properly
.put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO)
.format().toString()
else null
)
)
},
),
val plan3Months = data3Month?.let {
ProPlan(
title = Phrase.from(context.getText(R.string.proPriceThreeMonths))
.put(MONTHLY_PRICE_KEY, "$4.99") //todo PRO calculate properly
.put(MONTHLY_PRICE_KEY, it.perMonthText)
.format().toString(),
subtitle = Phrase.from(context.getText(R.string.proBilledQuarterly))
.put(PRICE_KEY, "$14.99") //todo PRO calculate properly
.put(PRICE_KEY, it.totalText)
.format().toString(),
selected = currentPlan3Months,
currentPlan = currentPlan3Months,
durationType = ProSubscriptionDuration.THREE_MONTHS,
badges = buildList {
if(currentPlan3Months){
add(
ProPlanBadge(context.getString(R.string.currentBilling))
)
}
if (currentPlan3Months) add(ProPlanBadge(context.getString(R.string.currentBilling)))
discountBadge(baseline = baseline, it.perMonthUnits, showTooltip = currentPlan3Months)?.let(this::add)
}
)
}

add(
ProPlanBadge(
"16% Off", //todo PRO calculate properly
if(currentPlan3Months) Phrase.from(context.getText(R.string.proDiscountTooltip))
.put(PRO_KEY, NonTranslatableStringConstants.PRO)
.put(PERCENT_KEY, "16") //todo PRO calculate properly
.put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO)
.format().toString()
else null
)
)
},
),
val plan1Month = data1Month?.let {
ProPlan(
title = Phrase.from(context.getText(R.string.proPriceOneMonth))
.put(MONTHLY_PRICE_KEY, "$5.99") //todo PRO calculate properly
.put(MONTHLY_PRICE_KEY, it.perMonthText)
.format().toString(),
subtitle = Phrase.from(context.getText(R.string.proBilledMonthly))
.put(PRICE_KEY, "$5") //todo PRO calculate properly
.put(PRICE_KEY, it.totalText)
.format().toString(),
selected = currentPlan1Month,
currentPlan = currentPlan1Month,
durationType = ProSubscriptionDuration.ONE_MONTH,
badges = if(currentPlan1Month) listOf(
ProPlanBadge(context.getString(R.string.currentBilling))
) else emptyList(),
),
badges = if (currentPlan1Month) listOf(ProPlanBadge(context.getString(R.string.currentBilling))) else emptyList()
// no discount on the baseline 1 month...
)
}

return listOfNotNull(plan12Months, plan3Months, plan1Month)
}

private data class PriceDisplayData(val perMonthUnits: BigDecimal, val perMonthText: String, val totalText: String)

private fun calculatePricesFor(pricing: SubscriptionManager.SubscriptionPricing?): PriceDisplayData? {
if(pricing == null) return null

val months = CurrencyFormatter.monthsFromIso(pricing.billingPeriodIso)
val perMonthUnits = CurrencyFormatter.perMonthUnitsFloor(pricing.priceAmountMicros, months, pricing.priceCurrencyCode)
val perMonthText = CurrencyFormatter.formatUnits(perMonthUnits, pricing.priceCurrencyCode)
return PriceDisplayData(perMonthUnits, perMonthText, pricing.formattedTotal)
}

private fun discountBadge(baseline: BigDecimal ,perMonthUnits: BigDecimal, showTooltip: Boolean): ProPlanBadge? {
val pct = CurrencyFormatter.percentOffFloor(baseline, perMonthUnits)
if (pct <= 0) return null
val tooltip = if (showTooltip)
Phrase.from(context.getText(R.string.proDiscountTooltip))
.put(PRO_KEY, NonTranslatableStringConstants.PRO)
.put(PERCENT_KEY, pct.toString())
.put(APP_PRO_KEY, NonTranslatableStringConstants.APP_PRO)
.format().toString()
else null
return ProPlanBadge(
title = Phrase.from(context.getText(R.string.proPercentOff))
.put(PERCENT_KEY, pct.toString())
.format().toString(),
tooltip = tooltip
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,8 @@ class NoOpSubscriptionManager @Inject constructor() : SubscriptionManager {
override suspend fun isWithinQuickRefundWindow(): Boolean {
return false
}

override suspend fun getSubscriptionPrices(): List<SubscriptionManager.SubscriptionPricing> {
return emptyList()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,19 @@ interface SubscriptionManager: OnAppStartupComponent {
*/
suspend fun hasValidSubscription(): Boolean

/**
* Gets a list of pricing for the subscriptions
* @throws Exception in case of errors fetching prices
*/
@Throws(Exception::class)
suspend fun getSubscriptionPrices(): List<SubscriptionPricing>

data class SubscriptionPricing(
val subscriptionDuration: ProSubscriptionDuration,
val price: String,
val priceAmountMicros: Long,
val priceCurrencyCode: String,
val billingPeriodIso: String,
val formattedTotal: String,
)
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package org.thoughtcrime.securesms.util

import java.math.BigDecimal
import java.math.RoundingMode
import java.text.NumberFormat
import java.util.Currency
import java.util.Locale

/**
* Utility for converting and formatting prices
* to correctly localized strings.
*
* - Only supports months/years for ISO 8601 billing periods (PXM, PXY, P1Y6M) - We can add more if needed in the future
*/
object CurrencyFormatter {

/** Parse only Years/Months: P1M, P3M, P1Y, P1Y6M. (Weeks/Days intentionally ignored.) */
fun monthsFromIso(iso: String): Int {
val y = Regex("""(\d+)Y""").find(iso)?.groupValues?.get(1)?.toInt() ?: 0
val m = Regex("""(\d+)M""").find(iso)?.groupValues?.get(1)?.toInt() ?: 0
return (y * 12 + m).coerceAtLeast(1)
}

/** Currency fraction digits with sane default. */
private fun fractionDigits(code: String): Int =
Currency.getInstance(code).defaultFractionDigits.let { if (it >= 0) it else 2 }

/** PRD rule: (total/months) then **ROUND DOWN** to the currency’s smallest unit. */
fun perMonthUnitsFloor(totalMicros: Long, months: Int, currencyCode: String): BigDecimal {
val units = BigDecimal(totalMicros).divide(BigDecimal(1_000_000)) // raw units
val perMonth = units.divide(BigDecimal(months), 10, RoundingMode.DOWN)
return perMonth.setScale(fractionDigits(currencyCode), RoundingMode.DOWN)
}

/** Locale-correct currency formatting (no extra rounding — use the scale already on amount). */
fun formatUnits(amountUnits: BigDecimal, currencyCode: String, locale: Locale = Locale.getDefault()): String {
val nf = NumberFormat.getCurrencyInstance(locale)
nf.currency = Currency.getInstance(currencyCode)
return nf.format(amountUnits)
}

/**
* Used to calculate discounts:
* floor(((baseline - plan)/baseline) * 100). Assumes both inputs already floored to fraction.
**/
fun percentOffFloor(baselinePerMonthUnits: BigDecimal, planPerMonthUnits: BigDecimal): Int {
if (baselinePerMonthUnits <= BigDecimal.ZERO || planPerMonthUnits >= baselinePerMonthUnits) return 0
val pct = baselinePerMonthUnits.subtract(planPerMonthUnits)
.divide(baselinePerMonthUnits, 6, RoundingMode.DOWN)
.multiply(BigDecimal(100))
.setScale(0, RoundingMode.DOWN)
return pct.toInt()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package org.thoughtcrime.securesms.pro.subscription

import android.app.Application
import android.widget.Toast
import androidx.compose.ui.res.stringResource
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
import com.android.billingclient.api.BillingFlowParams
Expand All @@ -18,7 +17,6 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
Expand All @@ -44,7 +42,6 @@ import java.time.Instant
import java.time.temporal.ChronoUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.measureTime

/**
* The Google Play Store implementation of our subscription manager
Expand Down Expand Up @@ -272,6 +269,47 @@ class PlayStoreSubscriptionManager @Inject constructor(
return now.isBefore(refundDeadline)
}

@Throws(Exception::class)
override suspend fun getSubscriptionPrices(): List<SubscriptionManager.SubscriptionPricing> {
val result = getProductDetails()
check(result.billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
"Failed to query product details. Reason: ${result.billingResult}"
}

val productDetails = result.productDetailsList?.firstOrNull()
?: run {
Log.w(TAG, "No ProductDetails returned for product id session_pro")
return emptyList()
}

val offersByBasePlan = productDetails.subscriptionOfferDetails
?.associateBy { it.basePlanId }
.orEmpty()

// For each duration we support, find the matching offer by basePlanId
return availablePlans.mapNotNull { duration ->
val offer = offersByBasePlan[duration.id]
if (offer == null) {
Log.w(TAG, "No offer found for basePlanId=${duration.id}")
return@mapNotNull null
}

val phases = offer.pricingPhases.pricingPhaseList

val pricing = phases.firstOrNull {
it.recurrenceMode == com.android.billingclient.api.ProductDetails.RecurrenceMode.INFINITE_RECURRING
} ?:return@mapNotNull null // skip if not found

SubscriptionManager.SubscriptionPricing(
subscriptionDuration = duration,
priceAmountMicros = pricing.priceAmountMicros,
priceCurrencyCode = pricing.priceCurrencyCode,
billingPeriodIso = pricing.billingPeriod, // e.g., P1M, P3M, P1Y
formattedTotal = pricing.formattedPrice // Play-formatted localized total
)
}
}

companion object {
private const val TAG = "PlayStoreSubscriptionManager"
}
Expand Down