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 902dac083..568f09247 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 @@ -137,11 +137,15 @@ class TipPaymentDelegate @Inject constructor( * * The fee is stored in whatever currency the recipient set it in, so it is converted through USD * to the currency the sender is entering in. A missing rate for either side falls back to the - * preset rather than stating a floor in a currency the entry isn't using. + * preset rather than stating a floor in a currency the entry isn't using. A fee below the preset + * also falls back to it, since every tip carries the regional minimum and the server would deny + * the fee for it. */ fun startChattingFee(recipient: UserProfile?): Flow = combine(minTipAmount, exchange.observePreferredRate()) { preset, rate -> - recipient?.minDmChatInitFee?.inCurrency(rate.currency) ?: preset + val fee = recipient?.minDmChatInitFee + val stated = fee?.inCurrency(rate.currency) ?: return@combine preset + if (preset != null && fee.isBelow(preset, stated)) preset else stated } /** @@ -223,6 +227,18 @@ class TipPaymentDelegate @Inject constructor( return exchange.rateFor(target)?.let { usd.convertingTo(it).roundedUp() } } + /** + * Whether this fee, [stated] in the entry currency, falls short of the regional [minimum]. The + * minimum is in the entry currency or is the USD fallback row; a fee that can't be priced in USD + * keeps its own floor and the server remains the authority. + */ + private fun Fiat.isBelow(minimum: Fiat, stated: Fiat): Boolean = when (minimum.currencyCode) { + stated.currencyCode -> stated < minimum + currencyCode -> this < minimum + CurrencyCode.USD -> exchange.rateToUsd(currencyCode)?.let { convertingTo(it).rounded() < minimum } ?: false + else -> false + } + /** * 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 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 86d165c8e..5f369dff6 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 @@ -291,6 +291,39 @@ class TipPaymentDelegateTest { assertEquals(CurrencyCode.EUR, fee.currencyCode) } + @Test + fun `startChattingFee raises a fee below the regional minimum to the minimum`() = runTest { + every { userFlags.resolvedFlags } returns usdPresets() + every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) + + val recipient = recipient(Fiat(0.5, CurrencyCode.USD)) + val delegate = buildDelegate() + // The fee floor emits before the presets resolve; judge it once they have. + delegate.minTipAmount.first { it != null } + val min = delegate.startChattingFee(recipient).first { it != null } + + assertEquals(1.0, min!!.toDouble()) + assertEquals(CurrencyCode.USD, min.currencyCode) + } + + @Test + fun `startChattingFee judges a fee against the USD fallback minimum`() = runTest { + // Only a USD row exists, so an NGN sender's regional minimum is the $1 fallback. + every { userFlags.resolvedFlags } returns usdPresets() + every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1320.0, currency = CurrencyCode.NGN)) + // 1000 NGN at 1320 NGN/USD is about $0.76, under the $1 minimum. + every { exchange.rateToUsd(CurrencyCode.NGN) } returns Rate(fx = 1 / 1320.0, currency = CurrencyCode.USD) + + val recipient = recipient(Fiat(1000.0, CurrencyCode.NGN)) + val delegate = buildDelegate() + // The fee floor emits before the presets resolve; judge it once they have. + delegate.minTipAmount.first { it != null } + val min = delegate.startChattingFee(recipient).first { it != null } + + assertEquals(1.0, min!!.toDouble()) + assertEquals(CurrencyCode.USD, min.currencyCode) + } + @Test fun `minimumTipFor drops to the system minimum once a chat exists`() = runTest { every { userFlags.resolvedFlags } returns usdPresets()