From 403fa31fb314b6064adfc1d2b743b1a70c3d12b1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 14:25:35 -0400 Subject: [PATCH] fix(profile): floor Minimum To Chat on the regional tip minimum The screen floored the fee at 1 unit of the balance currency, a fraction of a cent in IDR, NGN or JMD. Those entries passed locally and came back from the server as invalidAmount, and the dialog then named the 1-unit floor the entry already cleared, so users retried the same amount (Bugsnag 6aac6bd). Judge the fee against TipFloor.systemMinimum with the entry currency's preset row, or the USD fallback, as the send flow does. A server rejection after that check passes no longer repeats a floor, and the failure log now carries the fee's currency and amount. --- .../Settings/SetMinimumTipScreen.swift | 43 ++++++----- .../Flip API/Services/ProfileService.swift | 6 +- .../Regression_6aac6bdf6293b02dff667d25.swift | 72 +++++++++++++++++++ 3 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 FlipcashTests/Regressions/Regression_6aac6bdf6293b02dff667d25.swift 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) + } +}