diff --git a/Flipcash/Core/Screens/Settings/SetMinimumTipScreen.swift b/Flipcash/Core/Screens/Settings/SetMinimumTipScreen.swift index e279a12ca..0ad1f8a72 100644 --- a/Flipcash/Core/Screens/Settings/SetMinimumTipScreen.swift +++ b/Flipcash/Core/Screens/Settings/SetMinimumTipScreen.swift @@ -17,12 +17,6 @@ private let logger = Logger(label: "flipcash.minimum-tip") /// behaviour node 9553:113241 asks for. struct SetMinimumTipScreen: View { - /// The floor, stated under the amount so a rejection is the exception - /// rather than the flow. Client-side: the contract carries no minimum, and - /// the server reports a breach as `ErrorSetMinDmChatInitFee.invalidAmount`, - /// which lands on the same dialog. - private static let minimumValue: Decimal = 1 - @Environment(Container.self) private var container @Environment(SessionContainer.self) private var sessionContainer @Environment(RatesController.self) private var ratesController @@ -50,10 +44,6 @@ struct SetMinimumTipScreen: View { private var currency: CurrencyCode { ratesController.balanceCurrency } - private var minimum: FiatAmount { - FiatAmount(value: Self.minimumValue, currency: currency) - } - /// The fee already on the profile, in the currency being entered. A fee set /// in another currency isn't comparable, so it seeds nothing. private var existingFee: FiatAmount? { @@ -107,13 +97,19 @@ struct SetMinimumTipScreen: View { guard let value = AmountValidator().validate(enteredAmount), submitTask == nil else { return } - guard value >= Self.minimumValue else { - dialog = minimumDialog() + let fee = FiatAmount(value: value, currency: currency) + + // The contract carries no minimum; the server's regional tip floor is + // the one it enforces, reported as `ErrorSetMinDmChatInitFee.invalidAmount`. + if let floor = Self.unmetMinimum( + for: fee, + presets: sessionContainer.session.userFlags?.tipPresets(for: currency), + rate: ratesController.rateForBalanceCurrency() + ) { + dialog = minimumDialog(floor) return } - let fee = FiatAmount(value: value, currency: currency) - // Only a replacement is confirmed. A first fee gives nothing up, and // this screen is how the profile checklist sets one. Read off the // profile rather than `existingFee`, which is scoped to the currency @@ -155,7 +151,12 @@ struct SetMinimumTipScreen: View { switch error as? ErrorSetMinDmChatInitFee { case .invalidAmount: - dialog = minimumDialog() + // The local floor already passed, so naming it again would + // invite the same entry back. + dialog = .info( + title: "Amount Too Low", + subtitle: "Please enter a higher amount" + ) case .ok, .denied, .unknown, .transportFailure, .cancelled, .rejected, .none: dialog = .error( title: "Couldn't Save Your Minimum", @@ -166,13 +167,21 @@ struct SetMinimumTipScreen: View { } } - private func minimumDialog() -> DialogItem { + private func minimumDialog(_ floor: TipFloor) -> DialogItem { .info( - title: "\(minimum.formatted()) Minimum", + title: "\(floor.displayed.formatted()) Minimum Tip", subtitle: "Please enter a higher amount" ) } + /// The regional tip minimum `fee` falls short of, or nil when it clears — + /// or when presets or a rate are missing and the server stays the authority. + static func unmetMinimum(for fee: FiatAmount, presets: UserFlags.TipPresets?, rate: Rate?) -> TipFloor? { + guard let rate, rate.currency == fee.currency, + let floor = TipFloor.systemMinimum(presets: presets) else { return nil } + return floor.isMet(by: ExchangedFiat(nativeAmount: fee, rate: rate)) ? nil : floor + } + // MARK: - Seeding - /// The field's starting text for the fee already on the profile, so the diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift index 8eb1dd616..67358cdce 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ProfileService.swift @@ -196,7 +196,11 @@ final class ProfileService: Sendable { let response = try await service.setMinDmChatInitFee(request, options: .unaryDefault) let error = ErrorSetMinDmChatInitFee(rawValue: response.result.rawValue) ?? .unknown guard error == .ok else { - logger.error("Failed to set minimum DM chat init fee", metadata: ["error": "\(error)"]) + logger.error("Failed to set minimum DM chat init fee", metadata: [ + "error": "\(error)", + "currency": "\(fee.currency.rawValue)", + "amount": "\(fee.value)", + ]) throw error } logger.info("Minimum DM chat init fee set") diff --git a/FlipcashTests/Regressions/Regression_6aac6bdf6293b02dff667d25.swift b/FlipcashTests/Regressions/Regression_6aac6bdf6293b02dff667d25.swift new file mode 100644 index 000000000..456d03801 --- /dev/null +++ b/FlipcashTests/Regressions/Regression_6aac6bdf6293b02dff667d25.swift @@ -0,0 +1,72 @@ +// +// Regression_6aac6bdf6293b02dff667d25.swift +// Flipcash +// +// Bug: "Failed to set minimum tip" with `ErrorSetMinDmChatInitFee.invalidAmount`. +// Minimum To Chat floored the fee at 1 unit of the balance currency, a +// fraction of a cent in IDR, NGN, or JMD. The entry passed locally, the +// server refused it, and the dialog repeated the 1-unit floor under an +// amount that already cleared it, so users retried. +// +// Fix: the screen judges the fee against the server's regional tip minimum +// (the entry currency's preset row, or the USD fallback) and names that +// floor in the dialog. +// + +import Foundation +import Testing +@testable import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Regression: 6aac6bd – Minimum To Chat accepts a fee under the regional minimum", .bug("6aac6bdf6293b02dff667d25")) +struct Regression_6aac6bd { + + private let jmdPresets = UserFlags.TipPresets(currency: .jmd, minimum: 150, low: 500, medium: 1000, high: 2000) + private let usdPresets = UserFlags.TipPresets(currency: .usd, minimum: 1, low: 5, medium: 10, high: 20) + private let jmdRate = Rate(fx: 157, currency: .jmd) + private let idrRate = Rate(fx: 16000, currency: .idr) + + @Test("A fee under the currency's own preset minimum is refused, naming that minimum") + func belowOwnPreset() throws { + let floor = try #require(SetMinimumTipScreen.unmetMinimum( + for: FiatAmount(value: 1, currency: .jmd), + presets: jmdPresets, + rate: jmdRate + )) + + #expect(floor.displayed == FiatAmount(value: 150, currency: .jmd)) + } + + @Test("A fee at the preset minimum is accepted") + func atOwnPreset() { + #expect(SetMinimumTipScreen.unmetMinimum( + for: FiatAmount(value: 150, currency: .jmd), + presets: jmdPresets, + rate: jmdRate + ) == nil) + } + + @Test("A currency without its own row is judged in USD against the fallback row") + func belowUSDFallback() { + // IDR 1 is a fraction of a cent; IDR 16000 is $1. + #expect(SetMinimumTipScreen.unmetMinimum( + for: FiatAmount(value: 1, currency: .idr), + presets: usdPresets, + rate: idrRate + ) == .preset(usdPresets)) + #expect(SetMinimumTipScreen.unmetMinimum( + for: FiatAmount(value: 16000, currency: .idr), + presets: usdPresets, + rate: idrRate + ) == nil) + } + + @Test("Without presets or a rate the server stays the authority") + func noFloorWithoutPresetsOrRate() { + let fee = FiatAmount(value: 1, currency: .jmd) + + #expect(SetMinimumTipScreen.unmetMinimum(for: fee, presets: nil, rate: jmdRate) == nil) + #expect(SetMinimumTipScreen.unmetMinimum(for: fee, presets: usdPresets, rate: nil) == nil) + } +}