diff --git a/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift b/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift index d741556be..1c03e19d2 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift @@ -53,7 +53,7 @@ extension TipFloor { /// The floor for the tip that *opens* a DM with a recipient: the fee they /// charge, restated in `currency`, falling back to the regional preset when - /// they charge nothing. Nil when neither is known. + /// they charge nothing or their fee is below it. Nil when neither is known. /// /// The fallback also covers a fee that can't be converted — stating a floor /// in a currency the entry isn't using would be worse than stating the @@ -67,11 +67,24 @@ extension TipFloor { // 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 { + // Every tip carries the regional minimum, so a fee under it would + // pass the fee check and be denied for the minimum. + if let presets, !clears(fee, presets, rates: rates) { + return .preset(presets) + } return .recipientFee(fee) } return systemMinimum(presets: presets) } + /// Whether `fee` meets the preset row's minimum, compared the way + /// `TipPresets.meetsMinimum` compares an entry. A fee that can't be + /// restated in the row's currency keeps its own floor. + private static func clears(_ fee: FiatAmount, _ presets: UserFlags.TipPresets, rates: [CurrencyCode: Rate]) -> Bool { + guard let restated = fee.converted(to: presets.currency, rates: rates) else { return true } + return restated.value >= presets.minimum + } + /// The regional minimum every tip carries, regardless of recipient. public static func systemMinimum(presets: UserFlags.TipPresets?) -> TipFloor? { presets.map { .preset($0) } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TipFloorTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TipFloorTests.swift index 8579972e4..c8bf63c12 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TipFloorTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TipFloorTests.swift @@ -71,6 +71,43 @@ struct TipFloorTests { #expect(floor == .preset(presets)) } + @Test("A fee below the regional minimum is raised to the minimum") + func feeBelowPresetUsesPreset() { + let floor = TipFloor.toOpenDM( + recipientFee: FiatAmount(value: Decimal(string: "0.50")!, currency: .usd), + presets: presets, + in: .usd, + rates: rates + ) + + #expect(floor == .preset(presets)) + } + + @Test("A fee below the USD fallback minimum is raised to it") + func feeBelowFallbackPresetUsesPreset() { + // CAD 1 is USD 0.50, under the USD row's minimum of 1. + let floor = TipFloor.toOpenDM( + recipientFee: FiatAmount(value: 1, currency: .cad), + presets: presets, + in: .cad, + rates: rates + ) + + #expect(floor == .preset(presets)) + } + + @Test("A fee at the regional minimum stays the fee") + func feeAtPresetStaysFee() { + let floor = TipFloor.toOpenDM( + recipientFee: FiatAmount(value: 2, currency: .cad), + presets: presets, + in: .cad, + rates: rates + ) + + #expect(floor == .recipientFee(FiatAmount(value: 2, currency: .cad))) + } + @Test("No fee and no presets leaves no floor") func nilWithoutEither() { #expect(TipFloor.toOpenDM(recipientFee: nil, presets: nil, in: .usd, rates: rates) == nil)