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())
+ }
}