From 3776c2373217fe6fc4320917c1df9969d800f9fb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 28 Aug 2026 16:58:50 -0400 Subject: [PATCH] feat(tipping): honour the recipient's minimum on the tip that opens a DM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user can set a fee to be written to, but nothing on iOS read it — every tip was measured against the server's regional preset instead. The fee buys the conversation, so it applies to exactly one payment: the tip that opens the DM. entry | no DM yet | DM exists -----------|--------------------------------------|----------------- in-chat | recipient's fee, preset if they | no minimum | charge none | tip card | recipient's fee | system minimum `TipFloor` holds the two floors and enforces the one it was handed, at display precision. `SendAmountViewModel.opensTipDM` decides which applies, using the rule `ConversationScreen.chatExists` already draws for a tip DM — the feed holding the locally-derived id — so no network call is added. Two consequences follow. The swipe reads "Swipe to Tip" only for the opening payment and reverts to "Swipe to Send" afterwards. A preset chip below the floor is not offered, and `selectedAmount` re-checks it, because the recipient's fee resolves after the sheet is already up. A fee is restated in the entry currency through USD, the way the rate table is keyed. When either leg has no rate it falls back to the regional preset rather than state a floor in a currency the entry isn't using. Ports code-android-app#1366. --- .../Core/Screens/Main/Tips/SendTipSheet.swift | 6 +- Flipcash/Core/Screens/Main/Tips/TipFlow.swift | 32 ++- .../Core/Screens/Send/SendAmountScreen.swift | 9 +- .../Screens/Send/SendAmountViewModel.swift | 131 ++++++++---- .../FlipcashCore/Models/FiatAmount.swift | 17 ++ .../FlipcashCore/Models/TipFloor.swift | 78 +++++++ .../FlipcashCoreTests/TipFloorTests.swift | 153 +++++++++++++ FlipcashTests/SendAmountViewModelTests.swift | 201 ++++++++++++++++-- 8 files changed, 560 insertions(+), 67 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/TipFloorTests.swift 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) + } }