Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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<Fiat?> =
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
}

/**
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading