diff --git a/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift b/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift index c94c0a312..036b28515 100644 --- a/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift +++ b/Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift @@ -42,9 +42,9 @@ struct SendTipSheet: View { } HStack(spacing: 8) { - presetChip(.low) - presetChip(.medium) - presetChip(.high) + ForEach(tipFlow.offeredTiers, id: \.self) { tier in + presetChip(tier) + } customChip } diff --git a/Flipcash/Core/Screens/Main/Tips/TipFlow.swift b/Flipcash/Core/Screens/Main/Tips/TipFlow.swift index bc9ec1420..65be97891 100644 --- a/Flipcash/Core/Screens/Main/Tips/TipFlow.swift +++ b/Flipcash/Core/Screens/Main/Tips/TipFlow.swift @@ -78,9 +78,25 @@ final class TipFlow { } } - /// The fiat amount the current selection stands for. + /// The fiat amount the current selection stands for, or nil when it no + /// longer clears the tip floor — the recipient's fee resolves after the + /// sheet is up, so a selection made before it landed can fall under it. var selectedAmount: Decimal? { - amount(for: selection) + guard let amount = amount(for: selection), + submission?.unmetTipMinimum(entered: amount) == nil else { + return nil + } + return amount + } + + /// The preset tiers on offer. A tier below the floor this tip has to clear + /// isn't offered: a chip never passes through the amount entry, so it would + /// only be rejected on the swipe. + var offeredTiers: [TipSelection] { + [.low, .medium, .high].filter { tier in + guard let amount = amount(for: tier) else { return true } + return submission?.unmetTipMinimum(entered: amount) == nil + } } // MARK: - Entry - @@ -387,11 +403,15 @@ final class TipFlow { } /// Whether `balance` holds at least the tip minimum, so the picker can - /// disable tokens that can't fund even the smallest tip. No presets means - /// the server remains the authority — every token stays enabled. + /// disable tokens that can't fund even the smallest tip. No floor means the + /// server remains the authority — every token stays enabled. func meetsMinimum(_ balance: ExchangedBalance) -> Bool { - guard let presets else { return true } - return presets.meetsMinimum(balance.exchangedFiat) + guard let floor = submission?.tipFloor( + in: balance.exchangedFiat.nativeAmount.currency + ) else { + return true + } + return floor.isMet(by: balance.exchangedFiat) } // MARK: - Submission - diff --git a/Flipcash/Core/Screens/Send/SendAmountScreen.swift b/Flipcash/Core/Screens/Send/SendAmountScreen.swift index 26b949456..1fe35ff17 100644 --- a/Flipcash/Core/Screens/Send/SendAmountScreen.swift +++ b/Flipcash/Core/Screens/Send/SendAmountScreen.swift @@ -62,9 +62,10 @@ private struct SendAmountScreenContent: View { // MARK: - Body - - /// The tip floor when one applies, otherwise what's left to spend. A tip - /// states its minimum up front and reports a breach through a dialog on - /// submit, so the hint never reddens on that path. + /// The tip floor when one applies, otherwise what's left to spend. Only the + /// payment that opens a tip DM carries a floor here — it states its minimum + /// up front and reports a breach through a dialog on submit, so the hint + /// never reddens on that path. private var hint: EnterAmountHeader.Hint { if let minimum = viewModel.tipMinimum { .caption("\(minimum.formatted()) minimum") @@ -85,7 +86,7 @@ private struct SendAmountScreenContent: View { hint: hint )) ) { - SwipeControl(text: viewModel.isTipTarget ? "Swipe to Tip" : "Swipe to Send") { + SwipeControl(text: viewModel.opensTipDM ? "Swipe to Tip" : "Swipe to Send") { switch await viewModel.sendAction() { case .success: didSucceed = true diff --git a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift index c7bf1559f..f90387aaa 100644 --- a/Flipcash/Core/Screens/Send/SendAmountViewModel.swift +++ b/Flipcash/Core/Screens/Send/SendAmountViewModel.swift @@ -33,11 +33,18 @@ final class SendAmountViewModel { @ObservationIgnored let session: Session @ObservationIgnored let ratesController: RatesController + @ObservationIgnored let conversationController: ConversationController @ObservationIgnored let sender: any DirectSending @ObservationIgnored let resolver: any RecipientResolving @ObservationIgnored let target: SendTarget + @ObservationIgnored private let flipClient: FlipClient @ObservationIgnored private let amountValidator = AmountValidator() + /// The tip recipient's profile, which carries the fee they charge to open a + /// DM. Nil for a contact send, and until a fetch lands for a recipient the + /// cache didn't already hold. + private(set) var recipientProfile: Profile? + /// The mint this flow opened for, replayed by the re-resolve below. @ObservationIgnored private let initialMint: PublicKey? @@ -61,21 +68,47 @@ final class SendAmountViewModel { return enteredFiat.onChainAmount.quarks > 0 } - /// True when this send pays a tip recipient rather than a contact. Drives - /// the swipe label and whether a minimum applies. - var isTipTarget: Bool { - if case .tip = target { true } else { false } + /// True when this send would be the payment that opens the tip DM — the one + /// the recipient's fee buys. Drives the swipe label and which floor applies. + /// False for a contact send and for a thread that already exists. + var opensTipDM: Bool { + guard case .tip(let recipient) = target else { return false } + // The same rule `ConversationScreen.chatExists` draws: a tip DM's id is + // derived locally, so the feed holding it is what says the chat is real. + return conversationController.conversation( + withID: .tipDm(between: session.userID, and: recipient.userID) + ) == nil + } + + /// The floor this entry has to clear when the amount is priced in + /// `currency`, or nil when it has none. + /// + /// A fee the recipient sets buys the conversation, so it applies to exactly + /// one payment: the tip that opens the DM. Past that the tip card falls back + /// to the regional minimum every tip carries, and an in-chat send — a plain + /// send into an open thread — carries no floor at all. + func tipFloor(in currency: CurrencyCode) -> TipFloor? { + guard case .tip(let recipient) = target else { return nil } + let presets = session.userFlags?.tipPresets(for: currency) + guard opensTipDM else { + switch recipient.origin { + case .chat: return nil + case .tipcard: return .systemMinimum(presets: presets) + } + } + return .toOpenDM( + recipientFee: recipientProfile?.minDmChatInitFee, + presets: presets, + in: currency, + rates: ratesController.cachedRates + ) } /// The tip floor for the display currency, stated under the amount so a - /// rejection is the exception rather than the flow. Nil for a contact send, - /// and until the server's presets arrive. + /// rejection is the exception rather than the flow. Nil when this entry + /// carries no floor, and until the server's presets arrive. var tipMinimum: FiatAmount? { - guard isTipTarget, - let presets = session.userFlags?.tipPresets(for: ratesController.balanceCurrency) else { - return nil - } - return FiatAmount(value: presets.minimum, currency: presets.currency) + tipFloor(in: ratesController.balanceCurrency)?.displayed } /// The keypad buffer parsed to a positive amount, or nil. @@ -108,12 +141,21 @@ final class SendAmountViewModel { self.session = session self.ratesController = ratesController + self.conversationController = sessionContainer.conversationController + self.flipClient = sessionContainer.flipClient self.sender = sender ?? session self.resolver = resolver ?? session self.target = target self.initialMint = mint self.selectedBalance = resolved + if case .tip(let recipient) = target { + self.recipientProfile = session.cachedUserProfile(for: recipient.userID) + if recipientProfile == nil { + loadRecipientProfile(recipient.userID) + } + } + if let resolved { syncGlobalTokenSelection(to: resolved) } else { @@ -121,6 +163,22 @@ final class SendAmountViewModel { } } + /// Fills in the recipient's fee for the in-chat entry, whose counterpart + /// arrives from the conversation rather than a resolved tip card (which has + /// already cached one). Best effort: a miss leaves the entry on the regional + /// minimum, with the server still the authority on the swipe. + private func loadRecipientProfile(_ userID: UserID) { + Task { [weak self] in + guard let self else { return } + guard let profile = try? await flipClient.fetchProfile( + userID: userID, + owner: session.ownerKeyPair + ) else { return } + session.cacheUserProfile(profile, for: userID) + recipientProfile = profile + } + } + // MARK: - Balance resolution - /// Re-runs the initial resolve when the balance list or display rate @@ -170,33 +228,32 @@ final class SendAmountViewModel { return await submit(entered: entered) } - /// Whether `entered` clears the server's tip minimum for a tip target, - /// surfacing the minimum dialog when it doesn't. Contact sends always - /// pass. The one gate both the swipe path and the custom-amount entry use. - func enforceTipMinimum(entered: Decimal) -> Bool { - switch target { - case .contact: - return true - case .tip: - guard let exchangedFiat = selectedBalance?.enteredFiat( - for: entered, - rate: ratesController.rateForBalanceCurrency() - ), let presets = session.userFlags?.tipPresets(for: exchangedFiat.nativeAmount.currency) else { - // No presets (or no balance yet) — the server remains the authority. - return true - } - guard presets.meetsMinimum(exchangedFiat) else { - let minimum = FiatAmount(value: presets.minimum, currency: presets.currency) - // Grey rather than red: the entry is under a stated floor, not a - // failure, and the floor is already on screen (node 9553:20236). - session.dialogItem = .info( - title: "\(minimum.formatted()) Minimum Tip", - subtitle: "Please enter a higher amount" - ) - return false - } - return true + /// The floor `entered` falls short of, or nil when it clears — or when no + /// floor applies. The silent half of ``enforceTipMinimum(entered:)``, for + /// deciding which amounts to offer rather than judging one. + func unmetTipMinimum(entered: Decimal) -> TipFloor? { + guard let exchangedFiat = selectedBalance?.enteredFiat( + for: entered, + rate: ratesController.rateForBalanceCurrency() + ), let floor = tipFloor(in: exchangedFiat.nativeAmount.currency) else { + // No floor (or no balance yet) — the server remains the authority. + return nil } + return floor.isMet(by: exchangedFiat) ? nil : floor + } + + /// Whether `entered` clears this entry's tip floor, surfacing the minimum + /// dialog when it doesn't. Contact sends always pass. The one gate both the + /// swipe path and the custom-amount entry use. + func enforceTipMinimum(entered: Decimal) -> Bool { + guard let floor = unmetTipMinimum(entered: entered) else { return true } + // Grey rather than red: the entry is under a stated floor, not a + // failure, and the floor is already on screen (node 9553:20236). + session.dialogItem = .info( + title: "\(floor.displayed.formatted()) Minimum Tip", + subtitle: "Please enter a higher amount" + ) + return false } /// The full submission path for an already-validated amount in the display diff --git a/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift b/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift index 3884b1abe..074189aa8 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift @@ -77,6 +77,23 @@ extension FiatAmount { precondition(currency == rate.currency, "rate.currency must match self.currency") return FiatAmount(value: value / rate.fx, currency: .usd) } + + /// This amount restated in `currency`, routed through USD the way the rate + /// table is keyed, and rounded to the target's display precision. `nil` when + /// either leg of the conversion has no rate. + public func converted(to currency: CurrencyCode, rates: [CurrencyCode: Rate]) -> FiatAmount? { + if self.currency == currency { + return self + } + guard let ownRate = rates[self.currency] else { return nil } + let usd = convertingToUSD(rate: ownRate) + if currency == .usd { + return FiatAmount(value: usd.value.rounded(to: currency.maximumFractionDigits), currency: .usd) + } + guard let targetRate = rates[currency] else { return nil } + let converted = usd.converting(to: targetRate) + return FiatAmount(value: converted.value.rounded(to: currency.maximumFractionDigits), currency: currency) + } } // MARK: - Formatting - diff --git a/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift b/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift new file mode 100644 index 000000000..550f204c7 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift @@ -0,0 +1,78 @@ +// +// TipFloor.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// The minimum a tip has to clear, and where that minimum came from. +/// +/// Two floors exist and they are not interchangeable: a recipient can charge a +/// fee to be written to, and the server publishes a regional minimum every tip +/// carries. The fee buys the conversation, so it applies to exactly one +/// payment — the tip that opens the DM. Which of the two applies is the +/// caller's decision; this type only states and enforces the one it holds. +public enum TipFloor: Equatable, Sendable { + + /// The recipient's own fee to open a DM with them, already restated in the + /// currency the amount is being entered in. + case recipientFee(FiatAmount) + + /// The server's regional minimum for the entry currency, or its USD row. + case preset(UserFlags.TipPresets) + + /// The floor as it reads under the amount entry. + public var displayed: FiatAmount { + switch self { + case .recipientFee(let fee): + fee + case .preset(let presets): + FiatAmount(value: presets.minimum, currency: presets.currency) + } + } + + /// Whether `entered` clears this floor. Both sides compare at display + /// precision — what we display is what we accept. + public func isMet(by entered: ExchangedFiat) -> Bool { + switch self { + case .recipientFee(let fee): + // The fee is resolved into the entry currency by `toOpenDM`, so a + // mismatch here means no rate reached it. Comparing across + // currencies would trap; the server remains the authority instead. + guard entered.nativeAmount.currency == fee.currency else { return true } + let value = entered.nativeAmount.value.rounded(to: fee.currency.maximumFractionDigits) + return value >= fee.value + case .preset(let presets): + return presets.meetsMinimum(entered) + } + } +} + +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. + /// + /// 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 + /// regional one. + public static func toOpenDM( + recipientFee: FiatAmount?, + presets: UserFlags.TipPresets?, + in currency: CurrencyCode, + rates: [CurrencyCode: Rate] + ) -> TipFloor? { + if let fee = recipientFee?.converted(to: currency, rates: rates), fee.isPositive { + return .recipientFee(fee) + } + return systemMinimum(presets: presets) + } + + /// 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 new file mode 100644 index 000000000..8579972e4 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/TipFloorTests.swift @@ -0,0 +1,153 @@ +import Testing +import Foundation +@testable import FlipcashCore + +@Suite("TipFloor") +struct TipFloorTests { + + private let presets = UserFlags.TipPresets(currency: .usd, minimum: 1, low: 5, medium: 10, high: 20) + + /// CAD at 2 CAD/USD, EUR at 2 EUR/USD — so CAD 10 is USD 5 is EUR 10. + private let rates: [CurrencyCode: Rate] = [ + .usd: Rate(fx: 1, currency: .usd), + .cad: Rate(fx: 2, currency: .cad), + .eur: Rate(fx: 2, currency: .eur), + ] + + // MARK: - Resolution - + + @Test("The recipient's own fee is the floor, not the regional preset") + func recipientFeeWins() { + let floor = TipFloor.toOpenDM( + recipientFee: FiatAmount(value: 25, currency: .usd), + presets: presets, + in: .usd, + rates: rates + ) + + #expect(floor == .recipientFee(FiatAmount(value: 25, currency: .usd))) + } + + @Test("The fee is restated in the currency the amount is entered in") + func convertsFeeIntoEntryCurrency() { + let floor = TipFloor.toOpenDM( + recipientFee: FiatAmount(value: 10, currency: .cad), + presets: presets, + in: .eur, + rates: rates + ) + + #expect(floor == .recipientFee(FiatAmount(value: 10, currency: .eur))) + } + + @Test("Falls back to the preset when the recipient charges nothing") + func fallsBackWhenNoFee() { + let floor = TipFloor.toOpenDM(recipientFee: nil, presets: presets, in: .usd, rates: rates) + + #expect(floor == .preset(presets)) + } + + @Test("Falls back to the preset rather than state a floor in another currency") + func fallsBackWhenRateMissing() { + let floor = TipFloor.toOpenDM( + recipientFee: FiatAmount(value: 10, currency: .cad), + presets: presets, + in: .jpy, + rates: rates // no JPY leg + ) + + #expect(floor == .preset(presets)) + } + + @Test("A zero fee is no fee") + func zeroFeeFallsBack() { + let floor = TipFloor.toOpenDM( + recipientFee: FiatAmount(value: 0, currency: .usd), + presets: presets, + in: .usd, + rates: rates + ) + + #expect(floor == .preset(presets)) + } + + @Test("No fee and no presets leaves no floor") + func nilWithoutEither() { + #expect(TipFloor.toOpenDM(recipientFee: nil, presets: nil, in: .usd, rates: rates) == nil) + #expect(TipFloor.systemMinimum(presets: nil) == nil) + } + + // MARK: - Enforcement - + + @Test("A fee floor compares display-rounded values in its own currency") + func feeComparesDisplayRounded() { + let floor = TipFloor.recipientFee(FiatAmount(value: 5, currency: .usd)) + + #expect(floor.isMet(by: usd(5))) + #expect(!floor.isMet(by: usd(Decimal(string: "4.99")!))) + // 4.996 displays as $5.00, and what we display is what we accept. + #expect(floor.isMet(by: usd(Decimal(string: "4.996")!))) + } + + @Test("A fee floor in another currency defers to the server rather than trap") + func feeInAnotherCurrencyPasses() { + let floor = TipFloor.recipientFee(FiatAmount(value: 5, currency: .eur)) + + #expect(floor.isMet(by: usd(1))) + } + + @Test("A preset floor enforces the preset row") + func presetEnforcesRow() { + let floor = TipFloor.preset(presets) + + #expect(floor.isMet(by: usd(1))) + #expect(!floor.isMet(by: usd(Decimal(string: "0.99")!))) + #expect(floor.displayed == FiatAmount(value: 1, currency: .usd)) + } + + private func usd(_ value: Decimal) -> ExchangedFiat { + ExchangedFiat( + nativeAmount: FiatAmount(value: value, currency: .usd), + rate: Rate(fx: 1, currency: .usd) + ) + } +} + +@Suite("FiatAmount.converted") +struct FiatAmountConversionTests { + + private let rates: [CurrencyCode: Rate] = [ + .usd: Rate(fx: 1, currency: .usd), + .cad: Rate(fx: 2, currency: .cad), + .eur: Rate(fx: 2, currency: .eur), + ] + + @Test("Same currency is returned untouched") + func sameCurrency() { + let amount = FiatAmount(value: Decimal(string: "10.005")!, currency: .cad) + + #expect(amount.converted(to: .cad, rates: rates) == amount) + } + + @Test("Routes through USD and rounds to the target's precision") + func routesThroughUSD() { + let cad = FiatAmount(value: 10, currency: .cad) + + #expect(cad.converted(to: .usd, rates: rates) == FiatAmount(value: 5, currency: .usd)) + #expect(cad.converted(to: .eur, rates: rates) == FiatAmount(value: 10, currency: .eur)) + } + + @Test("Rounds to a zero-decimal currency's precision") + func roundsToZeroDecimalCurrency() { + let usd = FiatAmount(value: 1, currency: .usd) + let rates = rates.merging([.jpy: Rate(fx: Decimal(string: "150.4")!, currency: .jpy)]) { _, new in new } + + #expect(usd.converted(to: .jpy, rates: rates) == FiatAmount(value: 150, currency: .jpy)) + } + + @Test("Nil when either leg has no rate") + func nilWithoutRate() { + #expect(FiatAmount(value: 10, currency: .cad).converted(to: .jpy, rates: rates) == nil) + #expect(FiatAmount(value: 10, currency: .jpy).converted(to: .usd, rates: rates) == nil) + } +} diff --git a/FlipcashTests/SendAmountViewModelTests.swift b/FlipcashTests/SendAmountViewModelTests.swift index 9c51b1b2f..38ea538f1 100644 --- a/FlipcashTests/SendAmountViewModelTests.swift +++ b/FlipcashTests/SendAmountViewModelTests.swift @@ -582,20 +582,6 @@ struct SendAmountViewModelTests { // MARK: - Tip targets - private static func makeTipViewModel( - container: SessionContainer, - recipientID: UserID, - mock: MockSession - ) -> SendAmountViewModel { - SendAmountViewModel( - sessionContainer: container, - target: .tip(TipRecipient(userID: recipientID, displayName: "Fred", origin: .tipcard)), - mint: .usdf, - sender: mock, - resolver: mock - ) - } - @Test("A tip send resolves by user id and attaches the derived tip-DM chat metadata") func sendAction_tipTarget_attachesTipDmMetadata() async throws { let container = try await Self.makeReadyToSendContainer() @@ -603,7 +589,7 @@ struct SendAmountViewModelTests { let mock = MockSession() mock.resolveUserIDHandler = { _ in Self.recipient } mock.sendHandler = { _, _, _ in } - let viewModel = Self.makeTipViewModel(container: container, recipientID: recipientID, mock: mock) + let viewModel = Self.makeTipViewModel(container: container, recipientID: recipientID, origin: .tipcard, mock: mock) viewModel.enteredAmount = "5" let outcome = await viewModel.sendAction() @@ -630,7 +616,7 @@ struct SendAmountViewModelTests { let mock = MockSession() mock.resolveUserIDHandler = { _ in Self.recipient } mock.sendHandler = { _, _, _ in } - let viewModel = Self.makeTipViewModel(container: container, recipientID: recipientID, mock: mock) + let viewModel = Self.makeTipViewModel(container: container, recipientID: recipientID, origin: .tipcard, mock: mock) viewModel.enteredAmount = "0\(AmountValidator.localizedDecimalSeparator)50" let outcome = await viewModel.sendAction() @@ -650,7 +636,7 @@ struct SendAmountViewModelTests { let mock = MockSession() mock.resolveUserIDHandler = { _ in Self.recipient } mock.sendHandler = { _, _, _ in } - let viewModel = Self.makeTipViewModel(container: container, recipientID: recipientID, mock: mock) + let viewModel = Self.makeTipViewModel(container: container, recipientID: recipientID, origin: .tipcard, mock: mock) viewModel.enteredAmount = "1" let outcome = await viewModel.sendAction() @@ -682,4 +668,185 @@ struct SendAmountViewModelTests { #expect(outcome == .success) #expect(mock.sendCalls.count == 1) } + + // MARK: - Tip floor + + // The fee a recipient sets buys the *conversation*, so it applies to exactly + // one payment: the tip that opens the DM. Past that the tip card falls back + // to the regional minimum and the in-chat send carries no floor at all. + + private static let presets = UserFlags.TipPresets(currency: .usd, minimum: 1, low: 5, medium: 10, high: 20) + + /// Funded container with the regional presets seeded, so every floor test + /// starts with a system minimum for the recipient's fee to override. + static func makeFloorContainer() async throws -> SessionContainer { + let container = try await makeReadyToSendContainer() + container.session.userFlags = .fixture(tipPresets: [presets]) + return container + } + + /// Writes an existing tip DM into the feed the way a cold start does — the + /// controller's store is private, so it goes through the cache it hydrates from. + static func seedTipDM(in container: SessionContainer, with recipientID: UserID) async throws { + try container.database.upsertConversation( + Conversation( + id: .tipDm(between: container.session.userID, and: recipientID), + members: [ + ConversationMember(userID: container.session.userID, displayName: "Me"), + ConversationMember(userID: recipientID, displayName: "Fred"), + ], + lastMessage: nil, + lastActivity: .now, + type: .tipDm + ) + ) + await container.conversationController.hydrateFromDatabase() + } + + /// A tip recipient's cached profile, carrying the fee they charge to be + /// written to (none by default). + static func makeRecipientProfile(fee: FiatAmount? = nil) -> Profile { + Profile( + displayName: "Fred", + phone: Phone?.none, + email: nil, + minDmChatInitFee: fee + ) + } + + static func makeTipViewModel( + container: SessionContainer, + recipientID: UserID, + origin: TipOrigin, + mock: MockSession = MockSession() + ) -> SendAmountViewModel { + SendAmountViewModel( + sessionContainer: container, + target: .tip(TipRecipient(userID: recipientID, displayName: "Fred", origin: origin)), + mint: .usdf, + sender: mock, + resolver: mock + ) + } + + @Test("The tip that opens a DM from chat has to clear the recipient's own fee") + func tipFloor_chatOpeningDM_isRecipientFee() async throws { + let container = try await Self.makeFloorContainer() + let recipientID = UUID() + container.session.cacheUserProfile( + Self.makeRecipientProfile(fee: .usd(5)), + for: recipientID + ) + + let viewModel = Self.makeTipViewModel(container: container, recipientID: recipientID, origin: .chat) + + #expect(viewModel.opensTipDM) + #expect(viewModel.tipFloor(in: .usd) == .recipientFee(.usd(5))) + #expect(viewModel.tipMinimum == .usd(5)) + } + + @Test("A recipient who charges nothing still carries the regional minimum") + func tipFloor_chatOpeningDM_noFee_isPreset() async throws { + let container = try await Self.makeFloorContainer() + let recipientID = UUID() + container.session.cacheUserProfile( + Self.makeRecipientProfile(), + for: recipientID + ) + + let viewModel = Self.makeTipViewModel(container: container, recipientID: recipientID, origin: .chat) + + #expect(viewModel.opensTipDM) + #expect(viewModel.tipFloor(in: .usd) == .preset(Self.presets)) + } + + @Test("Once the DM exists, an in-chat send carries no floor at all") + func tipFloor_chatExistingDM_hasNoFloor() async throws { + let container = try await Self.makeFloorContainer() + let recipientID = UUID() + try await Self.seedTipDM(in: container, with: recipientID) + container.session.cacheUserProfile( + Self.makeRecipientProfile(fee: .usd(5)), + for: recipientID + ) + + let mock = MockSession() + mock.resolveUserIDHandler = { _ in Self.recipient } + mock.sendHandler = { _, _, _ in } + let viewModel = Self.makeTipViewModel( + container: container, + recipientID: recipientID, + origin: .chat, + mock: mock + ) + viewModel.enteredAmount = "0\(AmountValidator.localizedDecimalSeparator)50" + + #expect(!viewModel.opensTipDM) + #expect(viewModel.tipFloor(in: .usd) == nil) + #expect(viewModel.tipMinimum == nil) + + // The fee bought the conversation; a later tip in the same thread is a + // plain send, so 50c goes through under both the $5 fee and the $1 preset. + let outcome = await viewModel.sendAction() + #expect(outcome == .success) + #expect(mock.sendCalls.count == 1) + } + + @Test("Once the DM exists, the tip card falls back to the regional minimum") + func tipFloor_tipcardExistingDM_isSystemMinimum() async throws { + let container = try await Self.makeFloorContainer() + let recipientID = UUID() + try await Self.seedTipDM(in: container, with: recipientID) + container.session.cacheUserProfile( + Self.makeRecipientProfile(fee: .usd(5)), + for: recipientID + ) + + let viewModel = Self.makeTipViewModel(container: container, recipientID: recipientID, origin: .tipcard) + + #expect(!viewModel.opensTipDM) + #expect(viewModel.tipFloor(in: .usd) == .preset(Self.presets)) + } + + @Test("A tip card tip that opens the DM is blocked below the recipient's fee") + func tipFloor_tipcardOpeningDM_blocksBelowFee() async throws { + let container = try await Self.makeFloorContainer() + let recipientID = UUID() + container.session.cacheUserProfile( + Self.makeRecipientProfile(fee: .usd(5)), + for: recipientID + ) + + let mock = MockSession() + mock.resolveUserIDHandler = { _ in Self.recipient } + mock.sendHandler = { _, _, _ in } + let viewModel = Self.makeTipViewModel( + container: container, + recipientID: recipientID, + origin: .tipcard, + mock: mock + ) + // Over the $1 regional minimum, under the $5 the recipient charges. + viewModel.enteredAmount = "2" + + let outcome = await viewModel.sendAction() + + #expect(outcome == .failed) + #expect(mock.sendCalls.isEmpty) + #expect(container.session.dialogItem?.title == "$5.00 Minimum Tip") + } + + @Test("A contact send has no tip floor and never says Swipe to Tip") + func tipFloor_contactTarget_isNil() async throws { + let container = try await Self.makeFloorContainer() + + let viewModel = SendAmountViewModel( + sessionContainer: container, + target: .contact(Self.makeContact()), + mint: .usdf + ) + + #expect(!viewModel.opensTipDM) + #expect(viewModel.tipFloor(in: .usd) == nil) + } }