diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt index 6768f2aa9..9d1084c0b 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt @@ -13,6 +13,7 @@ import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token import kotlinx.coroutines.CoroutineScope @@ -51,14 +52,17 @@ class TipPaymentDelegate @Inject constructor( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) /** - * The user's suggested tip amounts, derived from the server-provided per-region - * [com.flipcash.services.models.TipPresets] (via [UserFlagsCoordinator]) and the user's current - * preferred currency. Selects the presets whose region matches that currency (region is an ISO - * 4217 currency code), falls back to the USD presets, and expresses each tier — low / medium / - * high — as a [Fiat] in the matched region's currency. When the server provides no presets at - * all, falls back to the built-in [DEFAULT_USD_PRESETS], localized to the preferred currency. + * The server tip presets resolved for the user's current preferred currency: the [minimum] + * tippable amount and the suggested [tiers] (low / medium / high). Region-matched once and shared + * by [tipPresets] and [minTipAmount] so both stay consistent. Null until presets resolve. + * + * Derived from the server-provided per-region [com.flipcash.services.models.TipPresets] (via + * [UserFlagsCoordinator]) and the preferred currency: selects the presets whose region matches + * that currency (region is an ISO 4217 currency code), falls back to the USD presets, and — when + * the server provides no presets at all — to the built-in [DEFAULT_USD_PRESETS], localized to the + * preferred currency. */ - val tipPresets: StateFlow> = combine( + private val resolvedPresets: StateFlow = combine( userFlags.resolvedFlags, // Drive off the preferred rate (a StateFlow that always emits its current value) rather than // observePreferredCurrency() — which can be an empty flow, and combine()'d with the hot @@ -74,9 +78,20 @@ class TipPaymentDelegate @Inject constructor( ?.let { CurrencyCode.USD to it } ?: return@combine defaultPresets(preferred) - listOf(matched.low, matched.medium, matched.high) - .map { amount -> Fiat(fiat = amount, currencyCode = currency) } - }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList()) + ResolvedPresets( + minimum = Fiat(fiat = matched.minimum, currencyCode = currency), + tiers = listOf(matched.low, matched.medium, matched.high) + .map { amount -> Fiat(fiat = amount, currencyCode = currency) }, + ) + }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + + /** + * The user's suggested tip amounts — the low / medium / high tiers in the preferred currency, + * shown as preset chips. See [resolvedPresets]. + */ + val tipPresets: StateFlow> = + resolvedPresets.map { it?.tiers.orEmpty() } + .stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList()) /** * The largest tippable amount, in the user's preferred currency: the smaller of the @@ -98,12 +113,13 @@ class TipPaymentDelegate @Inject constructor( }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) /** - * The smallest tippable amount — the lowest preset tier in the user's preferred currency. The - * amount entry surfaces it as a "minimum tip" hint and blocks custom amounts below it. Null until - * presets resolve. + * The smallest tippable amount — the presets' [com.flipcash.services.models.TipPresets.minimum] + * in the user's preferred currency. This is the dedicated minimum, NOT the lowest tier (`low`), + * which typically sits above it. The amount entry surfaces it as a "minimum tip" hint and blocks + * custom amounts below it. Null until presets resolve. */ val minTipAmount: StateFlow = - tipPresets.map { presets -> presets.minOrNull() } + resolvedPresets.map { it?.minimum } .stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) /** @@ -153,20 +169,38 @@ class TipPaymentDelegate @Inject constructor( } /** - * The built-in fallback tip presets ([DEFAULT_USD_PRESETS], in USD), localized to [preferred] via - * the current exchange rate when the user isn't on USD. Leaves the amounts in USD if no rate is - * available for the preferred currency. + * The built-in fallback presets — the [DEFAULT_USD_MINIMUM] and [DEFAULT_USD_PRESETS] tiers (in + * USD), localized to [preferred] via the current exchange rate when the user isn't on USD. Leaves + * the amounts in USD if no rate is available for the preferred currency. */ - private fun defaultPresets(preferred: CurrencyCode): List { - val usd = DEFAULT_USD_PRESETS.map { Fiat(fiat = it, currencyCode = CurrencyCode.USD) } - if (preferred == CurrencyCode.USD) return usd + private fun defaultPresets(preferred: CurrencyCode): ResolvedPresets { + fun localize(amount: Double, rate: Rate?): Fiat { + val usd = Fiat(fiat = amount, currencyCode = CurrencyCode.USD) + return rate?.let { usd.convertingTo(it).rounded() } ?: usd + } - val rate = exchange.rateFor(preferred) ?: return usd - return usd.map { it.convertingTo(rate).rounded() } + val rate = if (preferred == CurrencyCode.USD) null else exchange.rateFor(preferred) + return ResolvedPresets( + minimum = localize(DEFAULT_USD_MINIMUM, rate), + tiers = DEFAULT_USD_PRESETS.map { localize(it, rate) }, + ) } + /** + * The region-matched tip presets in a single currency: the dedicated [minimum] tippable amount and + * the suggested [tiers] (low / medium / high) shown as preset chips. Resolved once so [tipPresets] + * and [minTipAmount] stay consistent. + */ + private data class ResolvedPresets( + val minimum: Fiat, + val tiers: List, + ) + companion object { /** Fallback tip amounts (USD) used when the server provides no presets. */ private val DEFAULT_USD_PRESETS = listOf(5.0, 10.0, 20.0) + + /** Fallback minimum tip (USD) used when the server provides no presets. */ + private const val DEFAULT_USD_MINIMUM = 1.0 } } diff --git a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt index 59ff325b0..6309224a9 100644 --- a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt +++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt @@ -1,8 +1,12 @@ package com.flipcash.shared.payments import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.userflags.FieldOverride +import com.flipcash.app.userflags.ResolvedFlag +import com.flipcash.app.userflags.ResolvedUserFlags import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.controllers.ResolverController +import com.flipcash.services.models.TipPresets import com.flipcash.shared.chat.ChatCoordinator import com.getcode.opencode.controllers.TransactionController import com.getcode.opencode.exchange.Exchange @@ -39,6 +43,11 @@ class TipPaymentDelegateTest { SendLimit(nextTransaction = 100.0, maxPerTransaction = 500.0, maxPerDay = 1000.0) } + private fun resolvedFlagsWith(presets: List): ResolvedUserFlags = + mockk { + every { tipPresets } returns ResolvedFlag(presets, FieldOverride.None) + } + private fun buildDelegate() = TipPaymentDelegate( resolverController, transactionController, @@ -82,4 +91,48 @@ class TipPaymentDelegateTest { // Send limit is $100, balance is $40 — the smaller wins. assertEquals(40.0, max!!.toDouble()) } + + @Test + fun `minTipAmount is the dedicated minimum preset, not the lowest tier`() = runTest { + every { userFlags.resolvedFlags } returns MutableStateFlow( + resolvedFlagsWith( + listOf(TipPresets(region = "usd", minimum = 1.0, low = 2.0, medium = 5.0, high = 10.0)), + ), + ) + every { exchange.observePreferredRate() } returns flowOf(Rate.oneToOne) + + val min = buildDelegate().minTipAmount.first { it != null } + + // The dedicated `minimum` (1.0) — not the lowest tier `low` (2.0). + assertEquals(1.0, min!!.toDouble()) + assertEquals(CurrencyCode.USD, min.currencyCode) + } + + @Test + fun `tipPresets are the low medium high tiers, excluding the minimum`() = runTest { + every { userFlags.resolvedFlags } returns MutableStateFlow( + resolvedFlagsWith( + listOf(TipPresets(region = "usd", minimum = 1.0, low = 2.0, medium = 5.0, high = 10.0)), + ), + ) + every { exchange.observePreferredRate() } returns flowOf(Rate.oneToOne) + + val presets = buildDelegate().tipPresets.first { it.isNotEmpty() } + + assertEquals(listOf(2.0, 5.0, 10.0), presets.map { it.toDouble() }) + } + + @Test + fun `falls back to the default minimum when the server provides no presets`() = runTest { + every { userFlags.resolvedFlags } returns MutableStateFlow(resolvedFlagsWith(emptyList())) + every { exchange.observePreferredRate() } returns flowOf(Rate.oneToOne) + + val delegate = buildDelegate() + val min = delegate.minTipAmount.first { it != null } + val presets = delegate.tipPresets.first { it.isNotEmpty() } + + // Default fallback: $1 minimum, $5/$10/$20 tiers. + assertEquals(1.0, min!!.toDouble()) + assertEquals(listOf(5.0, 10.0, 20.0), presets.map { it.toDouble() }) + } }