From 640d40b3c1c908c040b0c8700397acec7797a4eb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 13:22:41 -0400 Subject: [PATCH] fix(tip): round a converted DM fee floor up minimumToOpenDmWith rounded the recipient's converted fee to the nearest cent. A 600 INR fee at 95.95 INR/USD is $6.2533, stated as $6.25, which is 599.69 INR, so the client accepted a tip the server then denied. iOS sees the same denial as Bugsnag 6a67781. inCurrency now rounds up through a new Fiat.roundedUp(). It has no other caller, so display conversions keep rounding half-up. --- .../shared/payments/TipPaymentDelegate.kt | 7 +++- .../shared/payments/TipPaymentDelegateTest.kt | 42 +++++++++++++++++++ .../getcode/opencode/model/financial/Fiat.kt | 13 ++++++ 3 files changed, 60 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 a7bb4b5ab2..3e2e87f94d 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 @@ -212,12 +212,15 @@ class TipPaymentDelegate @Inject constructor( /** * This amount in [target], routed through USD — the only rate every currency has. Null when * either leg has no rate, which is the caller's cue to fall back rather than mix currencies. + * + * Rounds up because the result is a fee floor: half-up can state one below the fee, which the + * client then accepts and the server denies. */ private fun Fiat.inCurrency(target: CurrencyCode): Fiat? { if (currencyCode == target) return this val usd = exchange.rateToUsd(currencyCode)?.let { convertingTo(it) } ?: return null - if (target == CurrencyCode.USD) return usd.rounded() - return exchange.rateFor(target)?.let { usd.convertingTo(it).rounded() } + if (target == CurrencyCode.USD) return usd.roundedUp() + return exchange.rateFor(target)?.let { usd.convertingTo(it).roundedUp() } } /** 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 4747b53ba4..1d46276720 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 @@ -222,6 +222,48 @@ class TipPaymentDelegateTest { assertEquals(5.0, min!!.toDouble()) } + @Test + fun `minimumToOpenDmWith rounds the converted fee up so it can't be undercut`() = runTest { + every { userFlags.resolvedFlags } returns usdPresets() + every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) + // 600 INR at 95.95 INR/USD is 6.2533... USD — half-up rounding states it as $6.25, which is + // short of the fee (6.25 * 95.95 = 599.69 < 600). Rounding up to $6.26 keeps the stated floor + // from ever understating the fee. + every { exchange.rateToUsd(CurrencyCode.INR) } returns Rate(fx = 1 / 95.95, currency = CurrencyCode.USD) + + val recipient = recipient(Fiat(600.0, CurrencyCode.INR)) + val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null } + + assertEquals(6.26, min!!.toDouble()) + assertTrue(min.toDouble() * 95.95 >= 600.0) + } + + @Test + fun `minimumToOpenDmWith rounds another currency's converted fee up too`() = runTest { + every { userFlags.resolvedFlags } returns usdPresets() + every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) + // 1500 NGN at 1326 NGN/USD is 1.1312... USD, which rounds up to $1.14 (not the half-up $1.13). + every { exchange.rateToUsd(CurrencyCode.NGN) } returns Rate(fx = 1 / 1326.0, currency = CurrencyCode.USD) + + val recipient = recipient(Fiat(1500.0, CurrencyCode.NGN)) + val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null } + + assertEquals(1.14, min!!.toDouble()) + } + + @Test + fun `minimumToOpenDmWith does not bump a fee that converts exactly`() = runTest { + every { userFlags.resolvedFlags } returns usdPresets() + every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) + // 600 INR at an even 100 INR/USD is exactly $6.00 — rounding up must not bump an exact value. + every { exchange.rateToUsd(CurrencyCode.INR) } returns Rate(fx = 1 / 100.0, currency = CurrencyCode.USD) + + val recipient = recipient(Fiat(600.0, CurrencyCode.INR)) + val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null } + + assertEquals(6.00, min!!.toDouble()) + } + @Test fun `minimumTipFor drops to the system minimum once a chat exists`() = runTest { every { userFlags.resolvedFlags } returns usdPresets() diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt index 3127616f8e..1c5f917293 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/Fiat.kt @@ -71,6 +71,19 @@ data class Fiat( ) } + /** + * This value rounded UP (ceiling) to [decimalPlaces], instead of [rounded]'s half-up. Use this + * only where understating the value is unsafe — e.g. a converted fee floor, which must never + * round down below the fee it stands in for. A value that already lands exactly on + * [decimalPlaces] is left unchanged. + */ + fun roundedUp(decimalPlaces: Int = 2): Fiat { + return Fiat( + fiat = decimalValue.roundTo(decimalPlaces, RoundingMode.UP), + currencyCode = currencyCode + ) + } + // Formatting fun formatted( rule: FormattingRule = FormattingRule.None,