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
6 changes: 3 additions & 3 deletions Flipcash/Core/Screens/Main/Tips/SendTipSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
32 changes: 26 additions & 6 deletions Flipcash/Core/Screens/Main/Tips/TipFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 -
Expand Down Expand Up @@ -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 -
Expand Down
9 changes: 5 additions & 4 deletions Flipcash/Core/Screens/Send/SendAmountScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
Expand Down
131 changes: 94 additions & 37 deletions Flipcash/Core/Screens/Send/SendAmountViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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.
Expand Down Expand Up @@ -108,19 +141,44 @@ 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 {
observeBalancesForInitialResolve()
}
}

/// 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
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Models/FiatAmount.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 -
Expand Down
78 changes: 78 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Models/TipFloor.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
Loading