diff --git a/Flipcash/Core/Controllers/Database/Models/StoredBalance.swift b/Flipcash/Core/Controllers/Database/Models/StoredBalance.swift index 3563b658d..5123769bd 100644 --- a/Flipcash/Core/Controllers/Database/Models/StoredBalance.swift +++ b/Flipcash/Core/Controllers/Database/Models/StoredBalance.swift @@ -112,10 +112,7 @@ nonisolated extension StoredBalance { /// The USD figure the wallet's token card renders for this balance: the /// stored value rounded to the cents a user actually sees. var displayedUSDF: FiatAmount { - FiatAmount( - value: usdf.value.rounded(to: CurrencyCode.usd.maximumFractionDigits), - currency: .usd - ) + usdf.roundedToSmallestUnit() } /// Orders the wallet's token card stack: largest displayed value first, diff --git a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift index c88d2e5a2..62cb88c57 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift @@ -23,7 +23,7 @@ func canPayLaunchCost(_ balance: StoredBalance, launchCost: TokenAmount) -> Bool if balance.mint == .usdf { return balance.usdf.value >= launchCost.decimalValue } - return balance.usdf.value.rounded(to: CurrencyCode.usd.maximumFractionDigits) >= launchCost.decimalValue + return balance.usdf.roundedToSmallestUnit().value >= launchCost.decimalValue } /// True when no single balance can pay `launchCost` (purchase + fee), so the diff --git a/Flipcash/Core/Screens/Main/AddMoney/CoinbaseDepositOperation.swift b/Flipcash/Core/Screens/Main/AddMoney/CoinbaseDepositOperation.swift index 7d502690b..1c94bc64a 100644 --- a/Flipcash/Core/Screens/Main/AddMoney/CoinbaseDepositOperation.swift +++ b/Flipcash/Core/Screens/Main/AddMoney/CoinbaseDepositOperation.swift @@ -98,13 +98,9 @@ final class CoinbaseDepositOperation { // Round the converted floor to the displayed denomination first — the // check must accept exactly the number the dialog shows; comparing raw // USD rejects the displayed minimum itself. - let minimum = FiatAmount( - value: FiatAmount.usd(Self.minimumPurchaseUSD) - .converting(to: amount.currencyRate) - .value - .rounded(to: amount.currencyRate.currency.maximumFractionDigits), - currency: amount.currencyRate.currency - ) + let minimum = FiatAmount.usd(Self.minimumPurchaseUSD) + .converting(to: amount.currencyRate) + .roundedToSmallestUnit() guard amount.nativeAmount.value >= minimum.value else { logger.info("Coinbase deposit below minimum", metadata: [ "amount": "\(amount.nativeAmount.formatted())", diff --git a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift index 0253e9f98..11711f357 100644 --- a/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift +++ b/Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift @@ -201,9 +201,8 @@ final class BuyAmountViewModel { // quarks past the spendable reserves; past it it stays uncapped so // the summary shows what was actually entered and the confirmation's // gate can surface the shortfall. - let displayedBalance = balance.usdf.converting(to: pin.rate).value - .rounded(to: entered.currency.maximumFractionDigits) - let isWithinDisplayedBalance = entered.value <= displayedBalance + let displayedBalance = balance.usdf.converting(to: pin.rate).roundedToSmallestUnit() + let isWithinDisplayedBalance = entered <= displayedBalance return ExchangedFiat.compute( fromEntered: entered, rate: pin.rate, diff --git a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawViewModel.swift b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawViewModel.swift index 98569bab9..66e963d0e 100644 --- a/Flipcash/Core/Screens/Settings/Withdraw/WithdrawViewModel.swift +++ b/Flipcash/Core/Screens/Settings/Withdraw/WithdrawViewModel.swift @@ -43,9 +43,7 @@ class WithdrawViewModel { /// summary verbatim — no rounding drift between the two screens. var displayFee: FiatAmount? { guard let enteredFiat, let fee = resolvedFee else { return nil } - let feeInEntry = fee.usd.converting(to: enteredFiat.currencyRate) - let rounded = feeInEntry.value.rounded(to: feeInEntry.currency.maximumFractionDigits) - return FiatAmount(value: rounded, currency: feeInEntry.currency) + return fee.usd.converting(to: enteredFiat.currencyRate).roundedToSmallestUnit() } /// Net in the user's currency, derived as `entered − displayFee` so the diff --git a/Flipcash/UI/EnterAmountCalculator.swift b/Flipcash/UI/EnterAmountCalculator.swift index 6107ce32b..8a4125727 100644 --- a/Flipcash/UI/EnterAmountCalculator.swift +++ b/Flipcash/UI/EnterAmountCalculator.swift @@ -66,8 +66,7 @@ nonisolated struct EnterAmountCalculator { // does, in Decimal. Formatting the max and parsing the string back went // through a double — "$8.54" returned 8.539999999999999, rejecting an // entry of exactly the displayed balance. - let displayMax = max.value.rounded(to: max.currency.maximumFractionDigits) - return amount <= displayMax + return amount <= max.roundedToSmallestUnit().value } // MARK: - Methods diff --git a/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift b/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift index 074189aa8..7387092ba 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift @@ -88,11 +88,10 @@ extension FiatAmount { guard let ownRate = rates[self.currency] else { return nil } let usd = convertingToUSD(rate: ownRate) if currency == .usd { - return FiatAmount(value: usd.value.rounded(to: currency.maximumFractionDigits), currency: .usd) + return usd.roundedToSmallestUnit() } guard let targetRate = rates[currency] else { return nil } - let converted = usd.converting(to: targetRate) - return FiatAmount(value: converted.value.rounded(to: currency.maximumFractionDigits), currency: currency) + return usd.converting(to: targetRate).roundedToSmallestUnit() } } @@ -126,6 +125,19 @@ extension FiatAmount { /// Non-zero but too small to display (would format as the currency's zero). public var isApproximatelyZero: Bool { value > 0 && !hasDisplayableValue } + /// This value rounded to its currency's smallest displayable unit (e.g. + /// $9.90099 → $9.90, $9.906 → $9.91) — the figure a user is shown, and so + /// the figure any comparison against a displayed bound must use. Rounds + /// half-up, matching `NumberFormatter.fiat`. Use + /// ``flooredToSmallestUnit()`` instead for values that rounding up would + /// break, such as a spend ceiling derived from a balance. + public func roundedToSmallestUnit() -> FiatAmount { + FiatAmount( + value: value.rounded(to: currency.maximumFractionDigits), + currency: currency + ) + } + /// This value truncated down to its currency's smallest displayable unit /// (e.g. $9.90099 → $9.90). For values whose defining invariant rounding up /// would break, such as a spend ceiling derived from a balance. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift b/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift index 550f204c7..c06ae6708 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift @@ -42,8 +42,7 @@ public enum TipFloor: Equatable, Sendable { // mismatch here means no rate reached it. Comparing across // currencies would trap; the server remains the authority instead. guard entered.nativeAmount.currency == fee.currency else { return true } - let value = entered.nativeAmount.value.rounded(to: fee.currency.maximumFractionDigits) - return value >= fee.value + return entered.nativeAmount.roundedToSmallestUnit() >= fee case .preset(let presets): return presets.meetsMinimum(entered) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/UserFlags.swift b/FlipcashCore/Sources/FlipcashCore/Models/UserFlags.swift index 195db6560..a0f2db378 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/UserFlags.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/UserFlags.swift @@ -110,11 +110,9 @@ extension UserFlags { /// mirroring the server's floor for currencies without presets. public func meetsMinimum(_ amount: ExchangedFiat) -> Bool { if amount.nativeAmount.currency == currency { - let entered = amount.nativeAmount.value.rounded(to: currency.maximumFractionDigits) - return entered >= minimum + return amount.nativeAmount.roundedToSmallestUnit().value >= minimum } - let usd = amount.usdfValue.value.rounded(to: CurrencyCode.usd.maximumFractionDigits) - return usd >= minimum + return amount.usdfValue.roundedToSmallestUnit().value >= minimum } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/FiatAmountTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/FiatAmountTests.swift index fa83b59f0..5de773260 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/FiatAmountTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/FiatAmountTests.swift @@ -34,4 +34,44 @@ struct FiatAmountTests { #expect(yen.flooredToSmallestUnit().value == 1234) } + + // MARK: - Rounding to the smallest unit + + @Test("Rounding takes a value up once it passes the half-unit") + func rounding_roundsHalfUp() { + let amount = FiatAmount.usd(Decimal(string: "9.906")!) + + #expect(amount.roundedToSmallestUnit().value == Decimal(string: "9.91")!) + } + + @Test("Rounding drops sub-unit precision below the half-unit") + func rounding_dropsSubUnitPrecision() { + let amount = FiatAmount.usd(Decimal(string: "9.90099")!) + + #expect(amount.roundedToSmallestUnit().value == Decimal(string: "9.90")!) + } + + @Test("Rounding leaves a value already on the smallest unit untouched") + func rounding_exactValue_unchanged() { + let exact = FiatAmount.usd(Decimal(string: "9.90")!) + + #expect(exact.roundedToSmallestUnit() == exact) + } + + @Test("Rounding a zero-decimal currency rounds to whole units") + func rounding_zeroDecimalCurrency_roundsToWholeUnits() { + let yen = FiatAmount(value: Decimal(string: "1234.9")!, currency: .jpy) + + #expect(yen.roundedToSmallestUnit().value == 1235) + } + + @Test("Two values that display alike round to the same figure") + func rounding_valuesSharingADisplayedFigure_collapse() { + // The wallet card stack compares at this precision so a sixth-decimal + // move can't reorder two cards that both read $1.00. + let under = FiatAmount.usd(Decimal(string: "0.9996")!) + let over = FiatAmount.usd(Decimal(string: "1.0004")!) + + #expect(under.roundedToSmallestUnit() == over.roundedToSmallestUnit()) + } }