From f38d2134962a51b02c343a1fbd335a5abbd9ec29 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 14:04:06 -0400 Subject: [PATCH 1/2] fix(payments): check funds before the request pay sheet and route Add Money from confirm sheets Paying a request without enough funds only checked after the swipe. Request pay now checks funds in prepare() and, when short, shows the insufficient-funds dialog (with Add More Cash) instead of opening the swipe sheet. Currency Info send now observes depositMint, so Add More Cash routes to the deposit instead of doing nothing. For request pay, group send and Currency Info send, Add Money tapped while the confirm sheet is up closes that sheet first and navigates once it has dismissed, since the router cannot dismiss a local sheet. Failed swipes still leave the sheet up with the dialog over it. --- .../PaymentRequestDetailsScreen.swift | 24 ++++- .../PaymentRequestPayment.swift | 79 ++++++++++++---- .../Core/Screens/Send/CurrencySendFlow.swift | 20 ++++ .../Core/Screens/Send/GroupSendFlow.swift | 24 ++++- .../InsufficientFundsSheetTests.swift | 93 +++++++++++++++++++ 5 files changed, 219 insertions(+), 21 deletions(-) create mode 100644 FlipcashTests/InsufficientFundsSheetTests.swift diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index e57ca07e7..4b58343a1 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -232,7 +232,7 @@ struct PaymentRequestDetailsScreen: View { private func pay() { Task { switch await payFlow.start(requestID, sessionContainer: sessionContainer) { - case .ready: + case .ready, .insufficientFunds: break case .nothingToPay, .closed, .failed: hasPaymentIntent = false @@ -375,9 +375,29 @@ private struct PaymentRequestPaySheetPresenter: ViewModifier { @Environment(Session.self) private var session @Environment(SessionContainer.self) private var sessionContainer + @Environment(AppRouter.self) private var router + + /// Add More Cash tapped while the swipe sheet was up; routed once the sheet has dismissed. + @State private var pendingDeposit: PublicKey? func body(content: Content) -> some View { - content.sheet(isPresented: $flow.isPaying) { + content + .onChange(of: flow.payment?.depositMint) { _, mint in + guard let mint else { return } + flow.payment?.depositMint = nil + if flow.isPaying { + // The router can't dismiss this local sheet, so close it and navigate after. + pendingDeposit = mint + flow.isPaying = false + } else { + router.navigate(to: .currencyInfoForDeposit(mint)) + } + } + .sheet(isPresented: $flow.isPaying, onDismiss: { + guard let mint = pendingDeposit else { return } + pendingDeposit = nil + router.navigate(to: .currencyInfoForDeposit(mint)) + }) { // The share reads paid once the payment lands; the sheet stays up through its success state. if let payment = flow.payment, let request = payment.request, let creator = request.creator { PaymentRequestPaySheet( diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift index 4596bb39f..c8de18b03 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift @@ -30,12 +30,24 @@ final class PaymentRequestPayment { case nothingToPay /// The request stopped accepting payments; "Request Closed" was shown. case closed + /// The picked token can't cover the share; the insufficient-funds dialog was shown. + case insufficientFunds case failed } + /// Whether the picked token covers the viewer's share. + enum Affordability: Equatable { + case sufficient + case insufficient + /// No share, rate or balance to measure against; the swipe's own check decides. + case unknown + } + let requestID: PaymentRequestID private(set) var selectedBalance: ExchangedBalance? + /// Set when the insufficient-funds dialog's Add Money is tapped; the presenter routes to the deposit and clears it. + var depositMint: PublicKey? @ObservationIgnored private let requests: PaymentRequests @ObservationIgnored private let session: Session @@ -93,7 +105,38 @@ final class PaymentRequestPayment { showRequestClosed() return .closed } - return intentID != nil && share != nil ? .ready : .nothingToPay + guard intentID != nil && share != nil else { return .nothingToPay } + // Checked before the swipe sheet opens, so a short balance never gets as far as a swipe. + if await affordability() == .insufficient { return .insufficientFunds } + return .ready + } + + /// Whether the picked token covers the share. Shows the insufficient-funds dialog when it doesn't. + func affordability() async -> Affordability { + guard let request, let share, let selectedBalance, + let fiat = shareFiat(share, of: request), + let priced = await price(fiat, mint: selectedBalance.stored.mint) else { return .unknown } + switch session.hasSufficientFunds(for: priced.amount) { + case .sufficient: + return .sufficient + case .insufficient(let shortfall): + showInsufficientFunds(shortfall: shortfall) + return .insufficient + } + } + + /// The share in the request's currency, nil when the request's currency or amount can't be read. + private func shareFiat(_ share: GroupPaymentRequest.Share, of request: GroupPaymentRequest) -> FiatAmount? { + guard let currency = request.split.flatMap({ try? CurrencyCode(currencyCode: $0.currency) }), + let value = Decimal(string: "\(share.nativeAmount)") else { return nil } + return FiatAmount(value: value, currency: currency) + } + + /// `fiat` priced into `mint` at a fresh rate. + private func price(_ fiat: FiatAmount, mint: PublicKey) async -> (amount: ExchangedFiat, pin: VerifiedState)? { + guard let pin = await ratesController.currentPinnedState(for: fiat.currency, mint: mint), + let amount = session.price(fiat, mint: mint, at: pin) else { return nil } + return (amount, pin) } /// Pays exactly the share: its amount in the request's currency, priced into the picked token. @@ -101,14 +144,13 @@ final class PaymentRequestPayment { if needsRefetch { switch await prepare() { case .ready: break - case .nothingToPay, .closed, .failed: return .failed + case .nothingToPay, .closed, .insufficientFunds, .failed: return .failed } } guard let request, let share, let creator = request.creator, let intentID, let intentKey = try? PublicKey(intentID), let chatID = request.chatID, - let currency = request.split.flatMap({ try? CurrencyCode(currencyCode: $0.currency) }), - let value = Decimal(string: "\(share.nativeAmount)") else { + let fiat = shareFiat(share, of: request) else { showSendError() return .failed } @@ -124,8 +166,7 @@ final class PaymentRequestPayment { } let mint = selectedBalance.stored.mint - guard let pin = await ratesController.currentPinnedState(for: currency, mint: mint), - let amount = session.price(FiatAmount(value: value, currency: currency), mint: mint, at: pin) else { + guard let (amount, pin) = await price(fiat, mint: mint) else { ErrorReporting.captureError( RatesController.Error.exchangeRateUnavailable, reason: "No fresh rate at request payment", @@ -138,19 +179,10 @@ final class PaymentRequestPayment { return .failed } + // Backstop: the balance can drop between the pre-check and the swipe. switch session.hasSufficientFunds(for: amount) { case .insufficient(let shortfall): - if let shortfall { - session.dialogItem = .error( - title: "You're \(shortfall.nativeAmount.formatted()) Short", - subtitle: "Add more cash, or try again with a lower amount" - ) - } else { - session.dialogItem = .error( - title: "You Need More Cash", - subtitle: "Please add more cash, or try again with a lower amount" - ) - } + showInsufficientFunds(shortfall: shortfall) return .failed case .sufficient: break @@ -210,6 +242,19 @@ final class PaymentRequestPayment { } } + private func showInsufficientFunds(shortfall: ExchangedFiat?) { + let title = shortfall.map { "You're \($0.nativeAmount.formatted()) Short" } ?? "You Need More Cash" + let subtitle = shortfall == nil + ? "Please add more cash, or try again with a lower amount" + : "Add more cash, or try again with a lower amount" + session.dialogItem = .error(title: title, subtitle: subtitle) { + .destructive("Add More Cash") { [weak self] in + self?.depositMint = self?.selectedBalance?.stored.mint + }; + .dismiss(kind: .subtle) + } + } + private func showRequestClosed() { session.dialogItem = .error( title: "Request Closed", diff --git a/Flipcash/Core/Screens/Send/CurrencySendFlow.swift b/Flipcash/Core/Screens/Send/CurrencySendFlow.swift index 14ac0681e..9eaffabff 100644 --- a/Flipcash/Core/Screens/Send/CurrencySendFlow.swift +++ b/Flipcash/Core/Screens/Send/CurrencySendFlow.swift @@ -149,6 +149,10 @@ private struct CurrencySelectRecipientScreen: View { @State private var query = "" @State private var confirming: ConfirmTarget? @State private var handoff = CurrencySendHandoff() + /// Add More Cash tapped while the confirm sheet was up; routed once the sheet has dismissed. + @State private var pendingDeposit: PublicKey? + /// The last confirmed send's model; kept after the sheet goes so its Add More Cash can still route. + @State private var lastModel: SendAmountViewModel? /// What the confirm sheet is open for: the recipient, and the send view model built for them. private struct ConfirmTarget: Identifiable { @@ -198,9 +202,24 @@ private struct CurrencySelectRecipientScreen: View { } .navigationTitle("Select Recipient") .navigationBarTitleDisplayMode(.inline) + .onChange(of: lastModel?.depositMint) { _, mint in + guard let mint else { return } + lastModel?.depositMint = nil + if confirming != nil { + // The router can't dismiss this local sheet, so close it and navigate after. + pendingDeposit = mint + confirming = nil + } else { + router.navigate(to: .currencyInfoForDeposit(mint)) + } + } .sheet(item: $confirming, onDismiss: { // After the dismiss animation: nothing on the stack moves while the sheet is up. if handoff.sheetDidDismiss() { leaveFlow() } + if let mint = pendingDeposit { + pendingDeposit = nil + router.navigate(to: .currencyInfoForDeposit(mint)) + } }) { target in CurrencySendConfirmSheet(target: target.model, amount: viewModel.enteredTotal, name: target.name, username: target.username, userID: target.userID, picture: target.picture) { handoff.sendSucceeded() @@ -289,6 +308,7 @@ private struct CurrencySelectRecipientScreen: View { ) else { return } let model = SendAmountViewModel(sessionContainer: sessionContainer, target: target, mint: viewModel.mint) model.enteredAmount = viewModel.enteredAmount + lastModel = model confirming = ConfirmTarget( id: chat.id, model: model, diff --git a/Flipcash/Core/Screens/Send/GroupSendFlow.swift b/Flipcash/Core/Screens/Send/GroupSendFlow.swift index 6db8dbdde..c700e693f 100644 --- a/Flipcash/Core/Screens/Send/GroupSendFlow.swift +++ b/Flipcash/Core/Screens/Send/GroupSendFlow.swift @@ -59,7 +59,8 @@ private struct GroupSendFlowContent: View { // Like the share-to-chats screen, the flow closes only from its X. .interactiveDismissDisabled() .onChange(of: viewModel.depositMint) { _, mint in - guard let mint else { return } + // Once people are being picked, Select People owns this: its confirm sheet may be up. + guard path.isEmpty, let mint else { return } viewModel.depositMint = nil router.navigate(to: .currencyInfoForDeposit(mint)) } @@ -132,9 +133,13 @@ private struct GroupSendAmountScreen: View { private struct GroupSendSelectPeopleScreen: View { + @Environment(AppRouter.self) private var router + let viewModel: GroupSendViewModel @State private var isConfirming = false + /// Add Money tapped while the confirm sheet was up; routed once the sheet has dismissed. + @State private var pendingDeposit: PublicKey? var body: some View { Background(color: .backgroundMain) { @@ -163,7 +168,22 @@ private struct GroupSendSelectPeopleScreen: View { .task(id: viewModel.conversationID) { await viewModel.refreshMembers() } - .sheet(isPresented: $isConfirming) { + .onChange(of: viewModel.depositMint) { _, mint in + guard let mint else { return } + viewModel.depositMint = nil + if isConfirming { + // The router can't dismiss this local sheet, so close it and navigate after. + pendingDeposit = mint + isConfirming = false + } else { + router.navigate(to: .currencyInfoForDeposit(mint)) + } + } + .sheet(isPresented: $isConfirming, onDismiss: { + guard let mint = pendingDeposit else { return } + pendingDeposit = nil + router.navigate(to: .currencyInfoForDeposit(mint)) + }) { GroupSendConfirmSheet(viewModel: viewModel) } } diff --git a/FlipcashTests/InsufficientFundsSheetTests.swift b/FlipcashTests/InsufficientFundsSheetTests.swift new file mode 100644 index 000000000..8e76456c0 --- /dev/null +++ b/FlipcashTests/InsufficientFundsSheetTests.swift @@ -0,0 +1,93 @@ +// +// InsufficientFundsSheetTests.swift +// FlipcashTests +// + +import Foundation +import Testing +import FlipcashCore +import FlipcashUI +@testable import Flipcash + +@MainActor +@Suite("Insufficient funds over a swipe sheet") +struct InsufficientFundsSheetTests { + + // MARK: - Request pay pre-check + + @Test("A share over the balance is insufficient before any swipe sheet, with an Add More Cash action") + func overBalanceShare() async throws { + let (payment, container) = try await makePayment(shareAmount: 150) + + #expect(await payment.affordability() == .insufficient) + + let dialog = try #require(container.session.dialogItem) + #expect(dialog.actions.first?.title == "Add More Cash") + dialog.actions[0].perform(isChecked: false) + #expect(payment.depositMint == .usdf) + } + + @Test("A share within the balance is sufficient and shows no dialog") + func withinBalanceShare() async throws { + let (payment, container) = try await makePayment(shareAmount: 20) + + #expect(await payment.affordability() == .sufficient) + #expect(container.session.dialogItem == nil) + } + + // MARK: - Currency Info send + + @Test("Add More Cash on a short Currency Info send routes to the deposit for the sent currency") + func currencySendAddMoney() async { + let mock = MockSession() + let viewModel = SendAmountViewModel( + sessionContainer: .mock, + target: .contact(SendAmountViewModelTests.makeContact()), + mint: nil, + sender: mock, + resolver: mock + ) + viewModel.selectCurrencyAction(exchangedBalance: ExchangedBalance.makeTest(quarks: 0)) + viewModel.enteredAmount = "5" + + #expect(await viewModel.sendAction() == .failed) + let dialog = viewModel.session.dialogItem + let add = dialog?.actions.first { $0.title == "Add More Cash" } + #expect(add != nil) + add?.perform(isChecked: false) + + #expect(viewModel.depositMint == viewModel.selectedBalance?.stored.mint) + #expect(viewModel.depositMint != nil) + } + + // MARK: - Helpers + + private func makePayment(shareAmount: Double) async throws -> (PaymentRequestPayment, SessionContainer) { + let container = try await SendAmountViewModelTests.makeReadyToSendContainer() + let id = PaymentRequestID(data: Data([7])) + let creator = UUID() + container.paymentRequests.ingest([ + GroupPaymentRequest( + id: id, + creator: creator, + createdAt: .now, + status: .open, + closedAt: nil, + version: 1, + chatID: ConversationID.test(1), + split: .init( + text: .plaintext(title: "Dinner", description: ""), + currency: "usd", + endsAt: .now.addingTimeInterval(3600), + shares: [ + .init(userID: creator, nativeAmount: shareAmount, settlement: .creator), + .init(userID: container.session.userID, nativeAmount: shareAmount), + ] + ) + ) + ]) + let payment = PaymentRequestPayment(requestID: id, sessionContainer: container) + try #require(payment.selectedBalance != nil) + return (payment, container) + } +} From 51152f1ebdea0e00aea004b4d2ad1f5157902a32 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 28 Oct 2026 15:08:18 -0400 Subject: [PATCH 2/2] fix(payments): drop the request pay balance pre-check The payer picks the token in the swipe sheet, so checking the token selected before the sheet opens blocked payers who hold the funds in another currency. prepare() no longer checks funds; a ready share opens the pay sheet as before. The post-swipe check in submit() still runs against the picked token, and its insufficient-funds dialog keeps the Add More Cash action. --- .../PaymentRequestDetailsScreen.swift | 2 +- .../PaymentRequestPayment.swift | 31 +---------- .../InsufficientFundsSheetTests.swift | 53 ------------------- 3 files changed, 3 insertions(+), 83 deletions(-) diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift index 4b58343a1..c01bf194d 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestDetailsScreen.swift @@ -232,7 +232,7 @@ struct PaymentRequestDetailsScreen: View { private func pay() { Task { switch await payFlow.start(requestID, sessionContainer: sessionContainer) { - case .ready, .insufficientFunds: + case .ready: break case .nothingToPay, .closed, .failed: hasPaymentIntent = false diff --git a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift index c8de18b03..605071e20 100644 --- a/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift +++ b/Flipcash/Core/Screens/PaymentRequest/PaymentRequestPayment.swift @@ -30,19 +30,9 @@ final class PaymentRequestPayment { case nothingToPay /// The request stopped accepting payments; "Request Closed" was shown. case closed - /// The picked token can't cover the share; the insufficient-funds dialog was shown. - case insufficientFunds case failed } - /// Whether the picked token covers the viewer's share. - enum Affordability: Equatable { - case sufficient - case insufficient - /// No share, rate or balance to measure against; the swipe's own check decides. - case unknown - } - let requestID: PaymentRequestID private(set) var selectedBalance: ExchangedBalance? @@ -105,24 +95,7 @@ final class PaymentRequestPayment { showRequestClosed() return .closed } - guard intentID != nil && share != nil else { return .nothingToPay } - // Checked before the swipe sheet opens, so a short balance never gets as far as a swipe. - if await affordability() == .insufficient { return .insufficientFunds } - return .ready - } - - /// Whether the picked token covers the share. Shows the insufficient-funds dialog when it doesn't. - func affordability() async -> Affordability { - guard let request, let share, let selectedBalance, - let fiat = shareFiat(share, of: request), - let priced = await price(fiat, mint: selectedBalance.stored.mint) else { return .unknown } - switch session.hasSufficientFunds(for: priced.amount) { - case .sufficient: - return .sufficient - case .insufficient(let shortfall): - showInsufficientFunds(shortfall: shortfall) - return .insufficient - } + return intentID != nil && share != nil ? .ready : .nothingToPay } /// The share in the request's currency, nil when the request's currency or amount can't be read. @@ -144,7 +117,7 @@ final class PaymentRequestPayment { if needsRefetch { switch await prepare() { case .ready: break - case .nothingToPay, .closed, .insufficientFunds, .failed: return .failed + case .nothingToPay, .closed, .failed: return .failed } } guard let request, let share, let creator = request.creator, diff --git a/FlipcashTests/InsufficientFundsSheetTests.swift b/FlipcashTests/InsufficientFundsSheetTests.swift index 8e76456c0..161d39dd4 100644 --- a/FlipcashTests/InsufficientFundsSheetTests.swift +++ b/FlipcashTests/InsufficientFundsSheetTests.swift @@ -13,28 +13,6 @@ import FlipcashUI @Suite("Insufficient funds over a swipe sheet") struct InsufficientFundsSheetTests { - // MARK: - Request pay pre-check - - @Test("A share over the balance is insufficient before any swipe sheet, with an Add More Cash action") - func overBalanceShare() async throws { - let (payment, container) = try await makePayment(shareAmount: 150) - - #expect(await payment.affordability() == .insufficient) - - let dialog = try #require(container.session.dialogItem) - #expect(dialog.actions.first?.title == "Add More Cash") - dialog.actions[0].perform(isChecked: false) - #expect(payment.depositMint == .usdf) - } - - @Test("A share within the balance is sufficient and shows no dialog") - func withinBalanceShare() async throws { - let (payment, container) = try await makePayment(shareAmount: 20) - - #expect(await payment.affordability() == .sufficient) - #expect(container.session.dialogItem == nil) - } - // MARK: - Currency Info send @Test("Add More Cash on a short Currency Info send routes to the deposit for the sent currency") @@ -59,35 +37,4 @@ struct InsufficientFundsSheetTests { #expect(viewModel.depositMint == viewModel.selectedBalance?.stored.mint) #expect(viewModel.depositMint != nil) } - - // MARK: - Helpers - - private func makePayment(shareAmount: Double) async throws -> (PaymentRequestPayment, SessionContainer) { - let container = try await SendAmountViewModelTests.makeReadyToSendContainer() - let id = PaymentRequestID(data: Data([7])) - let creator = UUID() - container.paymentRequests.ingest([ - GroupPaymentRequest( - id: id, - creator: creator, - createdAt: .now, - status: .open, - closedAt: nil, - version: 1, - chatID: ConversationID.test(1), - split: .init( - text: .plaintext(title: "Dinner", description: ""), - currency: "usd", - endsAt: .now.addingTimeInterval(3600), - shares: [ - .init(userID: creator, nativeAmount: shareAmount, settlement: .creator), - .init(userID: container.session.userID, nativeAmount: shareAmount), - ] - ) - ) - ]) - let payment = PaymentRequestPayment(requestID: id, sessionContainer: container) - try #require(payment.selectedBalance != nil) - return (payment, container) - } }