From 1c6db1174ff258e6a4f678dfb4d9350c604a1109 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 14:01:09 -0400 Subject: [PATCH] fix(tip): raise a DM fee floor to the regional minimum minimumToOpenDmWith returned the recipient's converted fee even below minTipAmount. Every tip carries the regional minimum, so a 1000 NGN fee (about $0.76) states a floor the server always denies with "tip amount is below the minimum of 1 USD". A fee under the minimum now falls back to it. The minimum is in the entry currency or is the USD fallback row, and the fee is compared in whichever it is; a fee with no USD rate keeps its own floor. --- .../shared/payments/TipPaymentDelegate.kt | 20 +++++++++-- .../shared/payments/TipPaymentDelegateTest.kt | 33 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) 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 3e2e87f94d..262539cb87 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 minimumToOpenDmWith(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 1d46276720..dd16f79e26 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 @@ -264,6 +264,39 @@ class TipPaymentDelegateTest { assertEquals(6.00, min!!.toDouble()) } + @Test + fun `minimumToOpenDmWith 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.minimumToOpenDmWith(recipient).first { it != null } + + assertEquals(1.0, min!!.toDouble()) + assertEquals(CurrencyCode.USD, min.currencyCode) + } + + @Test + fun `minimumToOpenDmWith 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.minimumToOpenDmWith(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()