Skip to content

Commit 6b36efc

Browse files
committed
fix(tip): raise a DM fee floor to the regional minimum
Re-lands #1599, which merged into fix/tip-floor-rounding after #1597 had already merged that branch into code/cash, so it never shipped. startChattingFee returns the recipient's converted fee even when it is under minTipAmount. Every tip carries the regional minimum, so a fee like 1000 NGN (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 function was renamed from minimumToOpenDmWith since #1599; the change is otherwise the same.
1 parent 6bed84a commit 6b36efc

2 files changed

Lines changed: 51 additions & 2 deletions

File tree

‎apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,15 @@ class TipPaymentDelegate @Inject constructor(
137137
*
138138
* The fee is stored in whatever currency the recipient set it in, so it is converted through USD
139139
* to the currency the sender is entering in. A missing rate for either side falls back to the
140-
* preset rather than stating a floor in a currency the entry isn't using.
140+
* preset rather than stating a floor in a currency the entry isn't using. A fee below the preset
141+
* also falls back to it, since every tip carries the regional minimum and the server would deny
142+
* the fee for it.
141143
*/
142144
fun startChattingFee(recipient: UserProfile?): Flow<Fiat?> =
143145
combine(minTipAmount, exchange.observePreferredRate()) { preset, rate ->
144-
recipient?.minDmChatInitFee?.inCurrency(rate.currency) ?: preset
146+
val fee = recipient?.minDmChatInitFee
147+
val stated = fee?.inCurrency(rate.currency) ?: return@combine preset
148+
if (preset != null && fee.isBelow(preset, stated)) preset else stated
145149
}
146150

147151
/**
@@ -223,6 +227,18 @@ class TipPaymentDelegate @Inject constructor(
223227
return exchange.rateFor(target)?.let { usd.convertingTo(it).roundedUp() }
224228
}
225229

230+
/**
231+
* Whether this fee, [stated] in the entry currency, falls short of the regional [minimum]. The
232+
* minimum is in the entry currency or is the USD fallback row; a fee that can't be priced in USD
233+
* keeps its own floor and the server remains the authority.
234+
*/
235+
private fun Fiat.isBelow(minimum: Fiat, stated: Fiat): Boolean = when (minimum.currencyCode) {
236+
stated.currencyCode -> stated < minimum
237+
currencyCode -> this < minimum
238+
CurrencyCode.USD -> exchange.rateToUsd(currencyCode)?.let { convertingTo(it).rounded() < minimum } ?: false
239+
else -> false
240+
}
241+
226242
/**
227243
* The built-in fallback presets — the [DEFAULT_USD_MINIMUM] and [DEFAULT_USD_PRESETS] tiers (in
228244
* USD), localized to [preferred] via the current exchange rate when the user isn't on USD. Leaves

‎apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,39 @@ class TipPaymentDelegateTest {
291291
assertEquals(CurrencyCode.EUR, fee.currencyCode)
292292
}
293293

294+
@Test
295+
fun `startChattingFee raises a fee below the regional minimum to the minimum`() = runTest {
296+
every { userFlags.resolvedFlags } returns usdPresets()
297+
every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD))
298+
299+
val recipient = recipient(Fiat(0.5, CurrencyCode.USD))
300+
val delegate = buildDelegate()
301+
// The fee floor emits before the presets resolve; judge it once they have.
302+
delegate.minTipAmount.first { it != null }
303+
val min = delegate.startChattingFee(recipient).first { it != null }
304+
305+
assertEquals(1.0, min!!.toDouble())
306+
assertEquals(CurrencyCode.USD, min.currencyCode)
307+
}
308+
309+
@Test
310+
fun `startChattingFee judges a fee against the USD fallback minimum`() = runTest {
311+
// Only a USD row exists, so an NGN sender's regional minimum is the $1 fallback.
312+
every { userFlags.resolvedFlags } returns usdPresets()
313+
every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1320.0, currency = CurrencyCode.NGN))
314+
// 1000 NGN at 1320 NGN/USD is about $0.76, under the $1 minimum.
315+
every { exchange.rateToUsd(CurrencyCode.NGN) } returns Rate(fx = 1 / 1320.0, currency = CurrencyCode.USD)
316+
317+
val recipient = recipient(Fiat(1000.0, CurrencyCode.NGN))
318+
val delegate = buildDelegate()
319+
// The fee floor emits before the presets resolve; judge it once they have.
320+
delegate.minTipAmount.first { it != null }
321+
val min = delegate.startChattingFee(recipient).first { it != null }
322+
323+
assertEquals(1.0, min!!.toDouble())
324+
assertEquals(CurrencyCode.USD, min.currencyCode)
325+
}
326+
294327
@Test
295328
fun `minimumTipFor drops to the system minimum once a chat exists`() = runTest {
296329
every { userFlags.resolvedFlags } returns usdPresets()

0 commit comments

Comments
 (0)