Skip to content

fix(amount): correct the entry to the fee-affordable maximum - #625

Merged
bmc08gt merged 1 commit into
mainfrom
fix/conversion-max-fee-handling
Aug 22, 2026
Merged

fix(amount): correct the entry to the fee-affordable maximum#625
bmc08gt merged 1 commit into
mainfrom
fix/conversion-max-fee-handling

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

iOS mirror of the Android change in code-payments/code-android-app#1302.

Why it always happens at the max

Amount entry is capped at the raw balance, but the fee comes out of that same balance. Two conventions apply:

Funding side Convention Debit
Dollars (no launchpad sale to skim) fee on top entered × (1 + f)
every other currency fee grossed up out of the sale entered / (1 − f)

So entering the maximum always overruns by exactly the fee, and never by more. That band is narrow and bounded, which is what makes correcting it silently safe rather than surprising.

Buy surfaced the overrun as a "Buy Maximum Amount" modal. Convert-from-Dollars didn't surface it at all: ConvertAmountViewModel.canPerformAction gates on the raw balance while ConvertConfirmationViewModel.totalDebited is amount + 1%, so converting the whole Dollars balance passed the gate and hard-failed at submit with insufficientBalance. Converting from a token takes its fee out of the entry, so it can't overrun and is left alone.

What changed

Trim the entry to what the balance can actually fund, before anything is priced — so the amount screen and the receipt agree, and navigating back shows the corrected figure rather than a stale one.

FlipcashCore

  • FiatAmount.spendableUnderGrossedUpSellFee(bps:) / spendableUnderSellFeeOnTop(bps:) — inverses of grossingUpLaunchpadSellFee(bps:), sitting beside it and unrounded like it.
  • FiatAmount.flooredToSmallestUnit() — the derived max must floor, never round. $10.11 at 1% corrects to $10.00: $10.01 plus its own fee is $10.1101, straight back over the balance. The floor is an entry-precision concern, so it lives here rather than at the fiat→quark boundary.
  • entryAffordableAfterFee(entered:balance:feeBps:feeChargedOnTop:) — pure and unit-tested; returns nil when the entry already fits.
  • AmountValidator.string(from:fractionDigits:) — the inverse of validate(_:), so the corrected value is written back in the keypad's own separator convention instead of an ad-hoc format.

Call sites

  • BuyAmountViewModel.primaryAction corrects before computePaymentAmount. It now takes the same injectable collectsUSDFFee flag as the confirmation, since fee-on-top applies only to the new-UI Dollars buy.
  • ConvertAmountViewModel.showConfirmation corrects when the source is Dollars.
  • Deleted with the modal: BuyConfirmationViewModel.buyMaximum, the appear-time auto-prompt, and the screen's 0.35s .task. paymentAmount is a let now that nothing mutates it in place. The submit-time gate stays as a plain "Insufficient Balance" error — reachable only when the balance moves under a quote.

Parity

Fee math is a cross-platform hotspot, so the helper deliberately mirrors Android's structure rather than an algebraically-equivalent rearrangement: it builds the debit and compares debit > balance (not entered > spendable), so neither platform can drift at the boundary. The flooring matches Fiat.flooredToSmallestUnit on Android.

Tests

29 new tests, TDD throughout — Android's cases mirrored 1:1 in FeeAffordableEntryTests (entries with room, zero fee, the whole balance under each convention, $10.11 → $10.00 flooring, idempotence, over-balance entries, and a JPY case proving the correction is denominated in the balance's currency and floors to whole yen), plus inverse round-trips in LaunchpadSellFeeTests, flooring in FiatAmountTests, the validator round-trip in AmountValidatorTests, and correction coverage on both amount view models.

Amount entry is capped at the raw balance, but the fee comes out of that
same balance. Two conventions apply: charged on top (funding side is
Dollars — there's no launchpad sale to skim), where the debit is
entered × (1 + f); and grossed up (every other currency), where the pool
sell fee comes out of the sale and the debit is entered / (1 − f). Either
way, entering the maximum always overruns by exactly the fee and never by
more.

Buy surfaced that bounded overrun as a "Buy Maximum Amount" modal, and
Convert-from-Dollars didn't surface it at all — its gate reads the raw
balance while the confirmation debits amount + 1%, so converting the whole
Dollars balance passed the gate and hard-failed at submit.

Trim the entry instead, before anything is priced, so the amount screen and
the receipt agree and going back shows the corrected figure:

- FiatAmount.spendableUnderGrossedUpSellFee / spendableUnderSellFeeOnTop —
  inverses of grossingUpLaunchpadSellFee, unrounded like it.
- FiatAmount.flooredToSmallestUnit — the derived max must floor, never
  round: $10.11 at 1% corrects to $10.00, since $10.01 + its own fee is
  $10.1101, back over the balance.
- entryAffordableAfterFee — pure, unit-tested, mirrors the Android helper
  exactly (fee math is a cross-platform parity hotspot).
- AmountValidator.string(from:fractionDigits:) — the inverse of validate,
  so the correction is written back in the keypad's own separator.

The Buy confirmation's Buy Maximum action, its appear-time auto-prompt and
the in-place paymentAmount mutation go with the modal; the submit-time gate
stays as a plain error, now reachable only when the balance moves under a
quote.
@bmc08gt
bmc08gt merged commit 9fd56f2 into main Aug 22, 2026
@bmc08gt
bmc08gt deleted the fix/conversion-max-fee-handling branch August 27, 2026 15:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant