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
43 changes: 26 additions & 17 deletions Flipcash/Core/Screens/Settings/SetMinimumTipScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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? {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading