fix(amount): correct the entry to the fee-affordable maximum - #625
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
entered × (1 + f)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.canPerformActiongates on the raw balance whileConvertConfirmationViewModel.totalDebitedisamount + 1%, so converting the whole Dollars balance passed the gate and hard-failed at submit withinsufficientBalance. 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 ofgrossingUpLaunchpadSellFee(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; returnsnilwhen the entry already fits.AmountValidator.string(from:fractionDigits:)— the inverse ofvalidate(_:), so the corrected value is written back in the keypad's own separator convention instead of an ad-hoc format.Call sites
BuyAmountViewModel.primaryActioncorrects beforecomputePaymentAmount. It now takes the same injectablecollectsUSDFFeeflag as the confirmation, since fee-on-top applies only to the new-UI Dollars buy.ConvertAmountViewModel.showConfirmationcorrects when the source is Dollars.BuyConfirmationViewModel.buyMaximum, the appear-time auto-prompt, and the screen's 0.35s.task.paymentAmountis aletnow 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(notentered > spendable), so neither platform can drift at the boundary. The flooring matchesFiat.flooredToSmallestUniton 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 inLaunchpadSellFeeTests, flooring inFiatAmountTests, the validator round-trip inAmountValidatorTests, and correction coverage on both amount view models.