diff --git a/FlipcashCore/Sources/FlipcashCore/Extensions/Decimal+Operations.swift b/FlipcashCore/Sources/FlipcashCore/Extensions/Decimal+Operations.swift index 494b7997a..9d973dfd0 100644 --- a/FlipcashCore/Sources/FlipcashCore/Extensions/Decimal+Operations.swift +++ b/FlipcashCore/Sources/FlipcashCore/Extensions/Decimal+Operations.swift @@ -22,6 +22,13 @@ extension Decimal { return rounded } + public func roundedUp(to decimalPlaces: Int) -> Decimal { + var current = self + var rounded = Decimal() + NSDecimalRound(&rounded, ¤t, decimalPlaces, .up) + return rounded + } + private func pow10(_ n: Int) -> Decimal { var result: Decimal = 1 for _ in 0.. FiatAmount? { + /// table is keyed, and rounded to the target's display precision — half-up + /// by default, or up when `roundingUp` so the result never falls short of + /// this amount. `nil` when either leg of the conversion has no rate. + public func converted( + to currency: CurrencyCode, + rates: [CurrencyCode: Rate], + roundingUp: Bool = false + ) -> FiatAmount? { if self.currency == currency { return self } guard let ownRate = rates[self.currency] else { return nil } let usd = convertingToUSD(rate: ownRate) + let unrounded: FiatAmount if currency == .usd { - return usd.roundedToSmallestUnit() + unrounded = usd + } else { + guard let targetRate = rates[currency] else { return nil } + unrounded = usd.converting(to: targetRate) } - guard let targetRate = rates[currency] else { return nil } - return usd.converting(to: targetRate).roundedToSmallestUnit() + return roundingUp ? unrounded.ceiledToSmallestUnit() : unrounded.roundedToSmallestUnit() } } @@ -227,6 +235,16 @@ extension FiatAmount { currency: currency ) } + + /// This value raised to its currency's smallest displayable unit (e.g. + /// $6.2533 → $6.26). For values whose defining invariant rounding down + /// would break, such as a floor that has to cover a fee. + public func ceiledToSmallestUnit() -> FiatAmount { + FiatAmount( + value: value.roundedUp(to: currency.maximumFractionDigits), + currency: currency + ) + } } // MARK: - Description - diff --git a/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift b/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift index c06ae6708..d741556be 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift @@ -64,7 +64,9 @@ extension TipFloor { in currency: CurrencyCode, rates: [CurrencyCode: Rate] ) -> TipFloor? { - if let fee = recipientFee?.converted(to: currency, rates: rates), fee.isPositive { + // Rounded up: a half-up floor can land under the fee, pass `isMet`, + // and be denied by the server. + if let fee = recipientFee?.converted(to: currency, rates: rates, roundingUp: true), fee.isPositive { return .recipientFee(fee) } return systemMinimum(presets: presets) diff --git a/FlipcashTests/Regressions/Regression_6a677815fdca5cb0d6a9ef7e.swift b/FlipcashTests/Regressions/Regression_6a677815fdca5cb0d6a9ef7e.swift new file mode 100644 index 000000000..121589dab --- /dev/null +++ b/FlipcashTests/Regressions/Regression_6a677815fdca5cb0d6a9ef7e.swift @@ -0,0 +1,72 @@ +// +// Regression_6a677815fdca5cb0d6a9ef7e.swift +// FlipcashTests +// +// Server denial: "tip amount is below the recipient's chat initialization +// fee". The Start Chatting sheet submits the floor `TipFloor.toOpenDM` states, +// which is the recipient's fee converted into the entry currency. The +// conversion rounded half-up, so it could land under the fee — 600 INR at +// 95.95 INR/USD is $6.2533, stated as $6.25, which is 599.69 INR. `isMet` +// judged the entry against that same figure, so it passed locally and the +// server denied it. +// +// Fix: `toOpenDM` rounds the converted fee up, so the stated floor always +// covers the fee at the rate it was converted with. +// + +import Foundation +import Testing +@testable import FlipcashCore + +@Suite("Regression: 6a67781 – converted DM fee floor rounds below the fee", .bug("6a677815fdca5cb0d6a9ef7e")) +struct Regression_6a67781 { + + private let usdRate = Rate(fx: 1, currency: .usd) + private let inrRate = Rate(fx: Decimal(string: "95.95")!, currency: .inr) + private let ngnRate = Rate(fx: 1326, currency: .ngn) + + private var rates: [CurrencyCode: Rate] { + [.usd: usdRate, .inr: inrRate, .ngn: ngnRate] + } + + private func floor(for fee: FiatAmount) -> TipFloor? { + TipFloor.toOpenDM(recipientFee: fee, presets: nil, in: .usd, rates: rates) + } + + @Test("A 600 INR fee is stated as $6.26, which covers it, not $6.25") + func inrFee_floorCoversFee() throws { + let floor = try #require(floor(for: FiatAmount(value: 600, currency: .inr))) + + #expect(floor.displayed == .usd(Decimal(string: "6.26")!)) + #expect(floor.displayed.converting(to: inrRate).value >= 600) + } + + @Test("The $6.25 the sheet used to submit no longer clears the floor locally") + func inrFee_roundedDownEntryIsRejected() throws { + let floor = try #require(floor(for: FiatAmount(value: 600, currency: .inr))) + let entered = ExchangedFiat(nativeAmount: .usd(Decimal(string: "6.25")!), rate: usdRate) + + #expect(!floor.isMet(by: entered)) + } + + @Test("A 1500 NGN fee is stated as $1.14, which covers it, not $1.13") + func ngnFee_floorCoversFee() throws { + let floor = try #require(floor(for: FiatAmount(value: 1500, currency: .ngn))) + + #expect(floor.displayed == .usd(Decimal(string: "1.14")!)) + #expect(floor.displayed.converting(to: ngnRate).value >= 1500) + } + + @Test("A fee that converts exactly is not bumped a cent") + func exactConversion_isUnchanged() throws { + let exactRates: [CurrencyCode: Rate] = [.usd: usdRate, .inr: Rate(fx: 100, currency: .inr)] + let floor = try #require(TipFloor.toOpenDM( + recipientFee: FiatAmount(value: 600, currency: .inr), + presets: nil, + in: .usd, + rates: exactRates + )) + + #expect(floor.displayed == .usd(6)) + } +}