From db70743d968af866e41f0c9d960ed9205a9ff49f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 21 Aug 2026 16:57:01 -0400 Subject: [PATCH] fix(convert): auto-correct the entry to the affordable max instead of prompting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amount entry is capped at the raw balance, but the fee comes out of that same balance — charged on top when the funding side is Dollars (no launchpad sale to skim), grossed up out of the sale otherwise. Entering the maximum therefore always overruns the balance by exactly the fee, and never by more. Both flows handled that badly. A Get funded by a token the fee pushed over balance priced the typed number and then popped an "Insufficient Balance After Fees" modal asking the user to confirm a correction. Converting the whole Dollars balance hard-failed at submit with "Insufficient Funds". Trim the entry itself to what the balance can actually fund, before anything is priced. The correction band is only ever the fee percentage, so there is nothing to confirm. Writing through the delegate (rather than only the receipt, as the modal did) keeps the amount screen agreeing with what the flow priced when the user navigates back. The maximum is floored to the currency's smallest unit — rounding it up would put the debit straight back over the balance ($10.11 at 1% corrects to $10.00, not $10.01). Buy now derives its fee shape once, feeding both the correction and the receipt's fee, so the two can't disagree. --- .../core/src/main/res/values/strings.xml | 4 - .../shared/amountentry/AmountEntryDelegate.kt | 12 ++ .../amountentry/AmountEntryDelegateTest.kt | 12 ++ .../flipcash/app/tokens/FeeAffordableEntry.kt | 44 ++++++ .../flipcash/app/tokens/ui/SwapViewModel.kt | 146 ++++++++---------- .../app/tokens/FeeAffordableEntryTest.kt | 130 ++++++++++++++++ .../getcode/opencode/model/financial/Fiat.kt | 11 ++ .../model/financial/LaunchpadSellFee.kt | 20 +++ .../opencode/model/financial/FiatTest.kt | 21 +++ .../model/financial/LaunchpadSellFeeTest.kt | 36 +++++ 10 files changed, 350 insertions(+), 86 deletions(-) create mode 100644 apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/FeeAffordableEntry.kt create mode 100644 apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/FeeAffordableEntryTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 37a603b5d1..ece2644316 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -458,10 +458,6 @@ You Pay You Receive - Insufficient Balance After Fees - Switch to maximum amount, or go back and enter a smaller amount - Buy Maximum Amount - Give Send Transaction History diff --git a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt index cf2a5cd820..4e47c3bc23 100644 --- a/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt +++ b/apps/flipcash/shared/amount-entry/src/main/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegate.kt @@ -182,6 +182,18 @@ class AmountEntryDelegate( updateAnimatedModel(backspace = false) } + /** + * Replaces whatever is currently entered with [amount], as opposed to [prefill], which types + * on top of the existing entry. For corrections the flow makes on the user's behalf — dropping + * an entry to the maximum a balance can actually fund, say — so the amount screen keeps + * agreeing with what the rest of the flow priced. + */ + fun setAmount(amount: Double) { + numberInputHelper.fractionUnits = _state.value.currency.fractionUnits + reset() + prefill(amount) + } + fun reset() { numberInputHelper.reset() _state.update { it.copy(amountAnimatedModel = AmountAnimatedInputUiModel()) } diff --git a/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt b/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt index 807d5846ac..edb48e489c 100644 --- a/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt +++ b/apps/flipcash/shared/amount-entry/src/test/kotlin/com/flipcash/shared/amountentry/AmountEntryDelegateTest.kt @@ -120,6 +120,18 @@ class AmountEntryDelegateTest { assertEquals(0.0, delegate.state.value.enteredAmount) } + @Test + fun `setAmount replaces the current entry rather than appending to it`() = runTest { + val delegate = createDelegate() + delegate.onCurrencyChanged(usd) + delegate.onNumber(1) + delegate.onNumber(2) + + delegate.setAmount(9.90) + + assertEquals(9.90, delegate.state.value.enteredAmount) + } + @Test fun `reset clears amount`() = runTest { val delegate = createDelegate() diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/FeeAffordableEntry.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/FeeAffordableEntry.kt new file mode 100644 index 0000000000..ff7481e37a --- /dev/null +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/FeeAffordableEntry.kt @@ -0,0 +1,44 @@ +package com.flipcash.app.tokens + +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.grossingUpLaunchpadSellFee +import com.getcode.opencode.model.financial.launchpadSellFee +import com.getcode.opencode.model.financial.plus +import com.getcode.opencode.model.financial.spendableUnderGrossedUpSellFee +import com.getcode.opencode.model.financial.spendableUnderSellFeeOnTop + +/** + * Pure logic for trimming an entered amount down to what its funding [balance] can actually cover + * once the fee is applied. + * + * Amount entry is capped at the raw balance, so the only thing that can push the debit past it is + * the fee: charged on top, the debit is `entered × (1 + f)`; grossed up out of a launchpad sale, it + * is `entered / (1 - f)`. Entering the maximum therefore always overruns — by the fee, and never by + * more — which is why the correction is applied silently instead of being put to the user. + * + * Returns the corrected entry, floored to the currency's smallest unit so re-deriving the debit + * from it never lands back over the balance, or `null` when the entry already fits and should stay + * exactly as typed. [entered] is interpreted in [balance]'s currency. + */ +internal fun entryAffordableAfterFee( + entered: Double, + balance: Fiat, + feeBps: Int, + feeChargedOnTop: Boolean, +): Fiat? { + if (feeBps <= 0) return null + + val enteredFiat = Fiat(entered, balance.currencyCode) + val debit = if (feeChargedOnTop) { + enteredFiat + enteredFiat.launchpadSellFee(feeBps) + } else { + enteredFiat.grossingUpLaunchpadSellFee(feeBps) + } + if (debit <= balance) return null + + return if (feeChargedOnTop) { + balance.spendableUnderSellFeeOnTop(feeBps) + } else { + balance.spendableUnderGrossedUpSellFee(feeBps) + }.flooredToSmallestUnit() +} diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 7c3e2a5aa7..c4e2a933be 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -30,6 +30,7 @@ import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.funding.PurchaseMethodMetadata import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.tokens.UsdcDepositSweep +import com.flipcash.app.tokens.entryAffordableAfterFee import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.internal.model.thirdparty.OnRampProvider @@ -56,7 +57,6 @@ import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.TokenWithBalance import com.getcode.opencode.model.financial.TokenWithLocalizedBalance -import com.getcode.opencode.model.financial.div import com.getcode.opencode.model.financial.grossingUpLaunchpadSellFee import com.getcode.opencode.model.financial.launchpadSellFee import com.getcode.opencode.model.financial.max @@ -530,70 +530,43 @@ class SwapViewModel @Inject constructor( } /** - * Cross-currency buys pay the funding pool's sell fee on top of the entered amount, so - * "You Pay" = amount + fee. If that total exceeds the funding token's wallet balance, the user - * can't cover it — surface a modal (automatically, on landing the receipt) offering to drop to - * the maximum affordable amount rather than letting the buy fail. + * Drops the entry to the most the funding balance can cover once its fee is applied, in place. * - * No-op when the funding side charges nothing — v1's USDF buy — and sub-cent rounding is - * tolerated so applying the max doesn't immediately re-prompt. All comparisons are in USD - * ([Fiat.convertingToUsdIfNeeded]), the common denominator across native currencies. + * Entry is capped at the raw balance, so entering the maximum always overruns by exactly the + * fee — whether it rides on top of the amount (Dollars, which has no pool to skim) or is + * grossed up out of a launchpad sale. Rather than pricing a number the balance can't fund and + * asking the user to confirm a correction, the entry itself is set to the true maximum, so the + * amount screen and everything priced from it agree. Entries with room to spare are untouched. + * + * [balance] must be in the same currency the amount is entered in — the localized balance from + * [State.tokenWithBalance], or a USD wallet balance converted through the preferred rate. */ - private fun maybePromptInsufficientBalanceAfterFees( - fundingToken: Token, - payTotal: Fiat, - rate: Rate, + private fun correctEntryToAffordable( + balance: Fiat, + feeBps: Int, + feeChargedOnTop: Boolean, ) { - // A v2 Get from Dollars charges the house rate on top instead of skimming a pool, so it - // can overrun the balance just like a cross-currency buy. v1's USDF buy is still free. - val feeChargedOnTop = fundingToken.address == Mint.usdf && stateFlow.value.isGet - if (fundingToken.address == Mint.usdf && !feeChargedOnTop) return - - val balanceUsd = tokenCoordinator.balanceForToken(fundingToken).convertingToUsdIfNeeded(rate) - val payUsd = payTotal.convertingToUsdIfNeeded(rate) - if ((payUsd - balanceUsd) <= balanceUsd.smallestUnit) return - - BottomBarManager.showInfo( - title = resources.getString(R.string.title_insufficientBalanceAfterFees), - message = resources.getString(R.string.description_insufficientBalanceAfterFees), - actions = listOf( - BottomBarAction( - text = resources.getString(R.string.action_buyMaximumAmount), - style = BottomBarManager.BottomBarButtonStyle.Filled, - ) { - // The most we can pay is the full balance, so the most we can receive (net of - // the fee) is balance - fee(balance). This only corrects the displayed receipt - // (via OnAmountAccepted, a pure state update): the buy already caps the pay amount - // to the balance on-chain, and the entered amount is left untouched so returning - // to amount entry preserves what the user typed. selectedAmount is passed through - // unchanged. - val balanceNative = balanceUsd.convertingTo(rate) - val maxReceive = if (feeChargedOnTop) { - // Fee on top: pay = receive × (1 + f), so the balance affords receive = - // balance / (1 + f). - balanceNative / (1.0 + DEFAULT_CONVERT_FEE_BPS / 10_000.0) - } else { - // Fee grossed up: pay = receive / (1 - f), so the balance affords receive = - // balance × (1 - f). - balanceNative - - balanceNative.launchpadSellFee(fundingToken.launchpadMetadata?.sellFeeBps ?: 0) - } - val maxFee = balanceNative - maxReceive - dispatchEvent( - Event.OnAmountAccepted( - amount = stateFlow.value.amountEntryState.selectedAmount, - netTransferAmount = maxReceive, - enteredAmount = maxReceive, - feeAmount = maxFee, - ) - ) - }, - BottomBarAction( - text = resources.getString(R.string.action_dismiss), - style = BottomBarManager.BottomBarButtonStyle.Text, - ), - ), - ) + val corrected = entryAffordableAfterFee( + entered = amountDelegate.state.value.enteredAmount, + balance = balance, + feeBps = feeBps, + feeChargedOnTop = feeChargedOnTop, + ) ?: return + + amountDelegate.setAmount(corrected.decimalValue) + } + + /** + * The fee a buy funded by [fundingToken] charges, as bps and whether it rides on top of the + * entered amount. A v2 Get from Dollars pays the flat house rate on top (no pool to skim); + * every other currency has its pool's sell fee grossed up into the debit; v1's reserves buy is + * free. + */ + private fun buyFeeFor(fundingToken: Token): Pair = when { + fundingToken.address != Mint.usdf -> + (fundingToken.launchpadMetadata?.sellFeeBps ?: 0) to false + stateFlow.value.isGet -> DEFAULT_CONVERT_FEE_BPS to true + else -> 0 to false } private suspend fun transactionLimit(): Fiat { @@ -971,6 +944,17 @@ class SwapViewModel @Inject constructor( is SwapPurpose.Convert -> { val rate = exchange.preferredRate val sourceWithBalance = stateFlow.value.tokenWithBalance ?: return@onEach + // Converting out of Dollars charges the fee on top, so the whole balance + // can't be converted — trim the entry to the real maximum instead of + // pricing a debit the balance can't cover. Every other direction has its + // fee skimmed out of the entry, which can't overrun. + if (stateFlow.value.isConvertingFromDollars) { + correctEntryToAffordable( + balance = sourceWithBalance.balance, + feeBps = convertFeeBps, + feeChargedOnTop = true, + ) + } // The pin is taken against the total debited, which is the entered amount // plus the fee when converting out of Dollars (see [totalDebitAmount]). val amountFiat = verifiedFiatCalculator.compute( @@ -1238,8 +1222,19 @@ class SwapViewModel @Inject constructor( .map { it.mint } .mapNotNull { val token = tokenCoordinator.getTokenMetadata(it).getOrNull()?.token ?: return@mapNotNull null - val delegateState = amountDelegate.state.value val rate = exchange.preferredRate + + // The fee comes out of the same balance that funds the buy, so entering the whole + // balance can never cover both. Trim the entry to the real maximum before pricing + // anything, so the receipt and the amount screen show the same number. + val (feeBps, feeChargedOnTop) = buyFeeFor(token) + correctEntryToAffordable( + balance = tokenCoordinator.balanceForToken(token).convertingTo(rate), + feeBps = feeBps, + feeChargedOnTop = feeChargedOnTop, + ) + + val delegateState = amountDelegate.state.value val amountFiat = verifiedFiatCalculator.compute( amount = Fiat(delegateState.enteredAmount, rate.currency), token = token, @@ -1260,22 +1255,17 @@ class SwapViewModel @Inject constructor( balance = nativeAmount ) - val exchangeFee = if (token.address == Mint.usdf) { + val exchangeFee = if (feeChargedOnTop) { // Dollars has no launchpad sale to skim, so a v2 Get pays the flat house rate // *on top* of the entered amount — the same convention Convert-from-Dollars - // uses. The v1 reserves buy stays free. - if (stateFlow.value.isGet) { - nativeAmount.launchpadSellFee(DEFAULT_CONVERT_FEE_BPS) - } else { - 0.toFiat((rate.currency)) - } + // uses. + nativeAmount.launchpadSellFee(feeBps) } else { // The pool's sell fee is grossed up on top of the entered amount, so the // fee is (amount / (1 - fee)) - amount. Uses the funding pool's own bps, - // matching the gross-up applied at buy time in OnBuyConfirmed. - nativeAmount.grossingUpLaunchpadSellFee( - token.launchpadMetadata?.sellFeeBps ?: 0, - ) - nativeAmount + // matching the gross-up applied at buy time in OnBuyConfirmed. v1's reserves + // buy charges nothing, so its zero bps nets out to zero here. + nativeAmount.grossingUpLaunchpadSellFee(feeBps) - nativeAmount } dispatchEvent( @@ -1291,14 +1281,6 @@ class SwapViewModel @Inject constructor( ) ) dispatchEvent(Event.OnFundingTokenResolved(tokenWithBalance)) - - // Landing the receipt: if the fee pushes "You Pay" past the funding token's - // wallet balance, auto-offer to drop to the maximum affordable amount. - maybePromptInsufficientBalanceAfterFees( - fundingToken = token, - payTotal = nativeAmount + exchangeFee, - rate = rate, - ) }.onEach { }.launchIn(viewModelScope) diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/FeeAffordableEntryTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/FeeAffordableEntryTest.kt new file mode 100644 index 0000000000..7fb00401e6 --- /dev/null +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/FeeAffordableEntryTest.kt @@ -0,0 +1,130 @@ +package com.flipcash.app.tokens + +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.grossingUpLaunchpadSellFee +import com.getcode.opencode.model.financial.launchpadSellFee +import com.getcode.opencode.model.financial.plus +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FeeAffordableEntryTest { + + private fun usd(value: Double) = Fiat(value, CurrencyCode.USD) + + // --- Entries that already fit --- + + @Test + fun `an entry with room for its fee is left exactly as typed`() { + assertNull( + entryAffordableAfterFee( + entered = 5.0, + balance = usd(10.0), + feeBps = 100, + feeChargedOnTop = true, + ) + ) + } + + @Test + fun `a free conversion never corrects the entry`() { + assertNull( + entryAffordableAfterFee( + entered = 10.0, + balance = usd(10.0), + feeBps = 0, + feeChargedOnTop = true, + ) + ) + } + + // --- Entering the maximum --- + + @Test + fun `the whole balance drops to what a fee charged on top leaves`() { + val corrected = entryAffordableAfterFee( + entered = 10.0, + balance = usd(10.0), + feeBps = 100, + feeChargedOnTop = true, + ) + + assertEquals("$9.90", corrected?.formatted()) + assertTrue(corrected!! + corrected.launchpadSellFee(100) <= usd(10.0)) + } + + @Test + fun `the whole balance drops to what a grossed-up fee leaves`() { + val corrected = entryAffordableAfterFee( + entered = 10.0, + balance = usd(10.0), + feeBps = 100, + feeChargedOnTop = false, + ) + + assertEquals("$9.90", corrected?.formatted()) + assertTrue(corrected!!.grossingUpLaunchpadSellFee(100) <= usd(10.0)) + } + + @Test + fun `the correction is floored so the fee still fits when rounding would not`() { + // $10.11 / 1.01 = $10.0099, which rounds up to $10.01 — and $10.01 plus its own 1% fee is + // $10.1101, back over the balance. Flooring to $10.00 keeps the debit inside it. + val corrected = entryAffordableAfterFee( + entered = 10.11, + balance = usd(10.11), + feeBps = 100, + feeChargedOnTop = true, + ) + + assertEquals("$10.00", corrected?.formatted()) + assertTrue(corrected!! + corrected.launchpadSellFee(100) <= usd(10.11)) + } + + @Test + fun `re-entering the corrected amount does not correct it again`() { + val corrected = entryAffordableAfterFee( + entered = 10.0, + balance = usd(10.0), + feeBps = 100, + feeChargedOnTop = true, + ) + + assertNull( + entryAffordableAfterFee( + entered = corrected!!.decimalValue, + balance = usd(10.0), + feeBps = 100, + feeChargedOnTop = true, + ) + ) + } + + @Test + fun `an entry beyond the balance is corrected down to the maximum too`() { + val corrected = entryAffordableAfterFee( + entered = 50.0, + balance = usd(10.0), + feeBps = 100, + feeChargedOnTop = true, + ) + + assertEquals("$9.90", corrected?.formatted()) + } + + @Test + fun `the correction is denominated in the balance's currency`() { + val corrected = entryAffordableAfterFee( + entered = 1000.0, + balance = Fiat(1000.0, CurrencyCode.JPY), + feeBps = 100, + feeChargedOnTop = true, + ) + + // ¥ has no fractional unit, so the correction floors to whole yen. + assertEquals(CurrencyCode.JPY, corrected?.currencyCode) + assertEquals(Fiat(990.0, CurrencyCode.JPY).quarks, corrected?.quarks) + } +} 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 d4c58c9cb4..3127616f8e 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 @@ -162,6 +162,17 @@ data class Fiat( return Fiat(fiat = step, currencyCode = currencyCode) } + /** + * This value truncated down to its currency's smallest displayable unit (e.g. $9.90099 → + * $9.90). Used where rounding up would break the invariant that produced the value, such as a + * spend ceiling derived from a balance. + */ + fun flooredToSmallestUnit(): Fiat { + val step = smallestUnit.quarks + if (step <= 1L) return this + return Fiat(quarks = Math.floorDiv(quarks, step) * step, currencyCode = currencyCode) + } + /** Whether this value would format as non-zero in its currency. */ val hasDisplayableValue: Boolean get() = rounded(currencyCode.fractionDigits) >= smallestUnit diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt index fbc304cf47..83a1c77962 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFee.kt @@ -38,3 +38,23 @@ fun Fiat.grossingUpLaunchpadSellFee(bps: Int): Fiat { val feeFraction = cappedBps / MAX_FEE_BPS.toDouble() return this / (1.0 - feeFraction) } + +/** + * The most of this balance that can be spent when the pool's sell fee is *grossed up* out of the + * spend: `balance × (1 − bps/10_000)`, the inverse of [grossingUpLaunchpadSellFee]. + * + * Grossing the result back up lands exactly on this balance, which makes it the largest entry the + * balance can actually fund. + */ +fun Fiat.spendableUnderGrossedUpSellFee(bps: Int): Fiat = this - launchpadSellFee(bps) + +/** + * The most of this balance that can be spent when the fee is charged *on top* of the spend rather + * than skimmed out of it: `balance / (1 + bps/10_000)`. + * + * Adding that entry's own fee back lands exactly on this balance. + */ +fun Fiat.spendableUnderSellFeeOnTop(bps: Int): Fiat { + val cappedBps = bps.coerceIn(0, MAX_FEE_BPS) + return this / (1.0 + cappedBps / MAX_FEE_BPS.toDouble()) +} diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt index 8df1c3f6a1..56c21c9607 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/FiatTest.kt @@ -307,4 +307,25 @@ class FiatTest { assertEquals(0L, Fiat.Zero.quarks) assertEquals(CurrencyCode.USD, Fiat.Zero.currencyCode) } + + // --- Flooring to the smallest unit --- + + @Test + fun `flooring truncates sub-unit precision instead of rounding it up`() { + // Leaving 1% headroom on a $10.00 balance gives $9.90099 — rounding up would put the + // entry right back over budget. + assertEquals(Fiat(9.90).quarks, Fiat(9.90099).flooredToSmallestUnit().quarks) + } + + @Test + fun `flooring leaves a value already on the smallest unit untouched`() { + val exact = Fiat(9.90) + assertEquals(exact, exact.flooredToSmallestUnit()) + } + + @Test + fun `flooring a zero-decimal currency truncates to whole units`() { + val yen = Fiat(1234.9, CurrencyCode.JPY) + assertEquals(Fiat(1234.0, CurrencyCode.JPY).quarks, yen.flooredToSmallestUnit().quarks) + } } diff --git a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt index 6d12bf00f6..1efdb4dccd 100644 --- a/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt +++ b/services/opencode/src/test/kotlin/com/getcode/opencode/model/financial/LaunchpadSellFeeTest.kt @@ -74,4 +74,40 @@ class LaunchpadSellFeeTest { assertEquals(Fiat.Zero.quarks, net.launchpadSellFee(bps = 0).quarks) assertEquals(net, net.grossingUpLaunchpadSellFee(bps = 0)) } + + @Test + fun `spendable under a grossed-up fee grosses back up to the balance`() { + val balance = Fiat(20.0) + + val spendable = balance.spendableUnderGrossedUpSellFee(bps = 100) + + assertEquals("$19.80", spendable.formatted()) + assertEquals(balance.quarks, spendable.grossingUpLaunchpadSellFee(bps = 100).quarks) + } + + @Test + fun `spendable under a fee charged on top leaves exactly enough for the fee`() { + val balance = Fiat(10.10) + + val spendable = balance.spendableUnderSellFeeOnTop(bps = 100) + + assertEquals("$10.00", spendable.formatted()) + assertEquals(balance.quarks, (spendable + spendable.launchpadSellFee(bps = 100)).quarks) + } + + @Test + fun `spending the whole balance is only possible with a zero fee`() { + val balance = Fiat(20.0) + + assertEquals(balance, balance.spendableUnderSellFeeOnTop(bps = 0)) + assertEquals(balance, balance.spendableUnderGrossedUpSellFee(bps = 0)) + } + + @Test + fun `an on-top fee above 100 percent is clamped rather than over-shrinking the entry`() { + val balance = Fiat(20.0) + + // Clamped to 10_000 (100%): half the balance covers a fee equal to the entry. + assertEquals("$10.00", balance.spendableUnderSellFeeOnTop(bps = 12_000).formatted()) + } }