Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Screens/Main/AddMoney/AddMoneyGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())",
Expand Down
5 changes: 2 additions & 3 deletions Flipcash/Core/Screens/Main/Buy/BuyAmountViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions Flipcash/UI/EnterAmountCalculator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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.
Expand Down
3 changes: 1 addition & 2 deletions FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
6 changes: 2 additions & 4 deletions FlipcashCore/Sources/FlipcashCore/Models/UserFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
40 changes: 40 additions & 0 deletions FlipcashCore/Tests/FlipcashCoreTests/FiatAmountTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}