From 176481b571114a38e5b1afaeb475c5fe96dd525d Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:19:21 +0200 Subject: [PATCH] feat(loans): wire borrower payoff handoff in responsive UI Adds incoming-offer management to the loans dashboard, connects accept flows to payment creation/completion and refinance obligation creation, and tightens touched loan dialogs/cards for mobile-first responsiveness. Co-authored-by: Cursor --- __tests__/unit/domain/loans/payments.test.ts | 17 + docs/development/loans-flow.md | 4 +- .../(authenticated)/dashboard/loans/page.tsx | 17 +- .../dashboard/loans/useLoanList.ts | 21 +- src/components/loans/AvailableLoans.tsx | 10 +- .../loans/IncomingLoanOffersList.tsx | 384 ++++++++++++++++++ src/components/loans/LoanOffersList.tsx | 20 +- src/components/loans/MakeOfferDialog.tsx | 15 +- src/config/loan-payments.ts | 1 + src/domain/loans/payments.ts | 15 +- src/services/loans/index.ts | 9 +- src/services/loans/queries/offers.ts | 67 +++ src/types/loans.ts | 15 + 13 files changed, 567 insertions(+), 28 deletions(-) create mode 100644 src/components/loans/IncomingLoanOffersList.tsx diff --git a/__tests__/unit/domain/loans/payments.test.ts b/__tests__/unit/domain/loans/payments.test.ts index 4a9fad823..76285d452 100644 --- a/__tests__/unit/domain/loans/payments.test.ts +++ b/__tests__/unit/domain/loans/payments.test.ts @@ -99,6 +99,23 @@ describe('createLoanPayment domain', () => { message: 'Payer and recipient must be different users', }); }); + + it('allows the recipient to record a pending payment with an explicit payer', async () => { + const result = await createLoanPayment( + recipientId, + { + loan_id: loanId, + amount: 1000, + currency: 'CHF', + payment_type: 'payoff', + payer_id: payerId, + recipient_id: recipientId, + }, + mockSupabaseForCreate() as never + ); + + expect(result.ok).toBe(true); + }); }); describe('completeLoanPayment domain', () => { diff --git a/docs/development/loans-flow.md b/docs/development/loans-flow.md index 1f1d01820..ebd67d05b 100644 --- a/docs/development/loans-flow.md +++ b/docs/development/loans-flow.md @@ -1,6 +1,6 @@ created_date: 2025-12-04 last_modified_date: 2026-07-09 -last_modified_summary: Loan offer writes now route through /api/loans/offers; the loans vertical is API-first for create/respond/payment flows. +last_modified_summary: Borrower dashboard now wires incoming offers through accept -> payment -> obligation flow with mobile-first offer management UI. # OrangeCat Loans Flow (Refinance & Payoff) @@ -27,6 +27,7 @@ last_modified_summary: Loan offer writes now route through /api/loans/offers; th ## Implementation notes - Frontend now loads “My Offers” and borrower offer lists; borrower can accept/reject offers per loan. +- Borrower dashboard surfaces incoming offers under `My Loans`, with responsive cards and an accept dialog that records payment details on all screen sizes. - Service layer includes `getUserOffers` and typed offer mutations routed via `/api/loans/offers`; UI uses service calls with toasts for feedback. - RLS and backend validations remain the source of truth; loan mutations use `/api/loans`, `/api/loans/offers`, `/api/loans/obligation`, and `/api/loans/payments` (no direct DB access from UI). - Currency source of truth lives in `src/config/currencies.ts` and is reused by UI, validation, services, and DB constraints (CHF included). @@ -61,6 +62,7 @@ last_modified_summary: Loan offer writes now route through /api/loans/offers; th ## Implemented now - Borrower can accept an offer and immediately open “Record Payoff” to create and complete a payment record (payer = offerer, recipient = borrower for now). +- Incoming offer acceptance now calls `respondToOffer` -> `createPayment` -> `completePayment`, and refinance completion also creates the obligation loan in the same flow. - Offer creation / update / accept / reject route through `/api/loans/offers` handlers instead of browser Supabase writes. - Service layer: `createPayment` / `completePayment` call `/api/loans/payments`; `completePayment` accepts optional `createObligation` to create the refinance obligation loan in one step. - UI uses typed forms with validation, toasts for feedback, and disables controls while processing. diff --git a/src/app/(authenticated)/dashboard/loans/page.tsx b/src/app/(authenticated)/dashboard/loans/page.tsx index 35b091ea8..b64959f1d 100644 --- a/src/app/(authenticated)/dashboard/loans/page.tsx +++ b/src/app/(authenticated)/dashboard/loans/page.tsx @@ -12,6 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Coins, DollarSign, Target, TrendingUp } from 'lucide-react'; import EmptyState from '@/components/ui/EmptyState'; import { AvailableLoans } from '@/components/loans/AvailableLoans'; +import { IncomingLoanOffersList } from '@/components/loans/IncomingLoanOffersList'; import { LoanOffersList } from '@/components/loans/LoanOffersList'; import { CreateLoanDialog } from '@/components/loans/CreateLoanDialog'; import { useLoanList } from './useLoanList'; @@ -33,6 +34,7 @@ export default function LoansPage() { switchTab, myLoans, myOffers, + incomingOffers, availableLoans, availablePage, setAvailablePage, @@ -45,7 +47,9 @@ export default function LoansPage() { page, total, setPage, + refresh, loadOffers, + loadIncomingOffers, loadAvailableLoans, handleBulkDelete, executeBulkDelete, @@ -62,7 +66,7 @@ export default function LoansPage() { } const headerActions = ( -
+
{activeTab === 'my-loans' && myLoans.length > 0 && ( - diff --git a/src/components/loans/IncomingLoanOffersList.tsx b/src/components/loans/IncomingLoanOffersList.tsx new file mode 100644 index 000000000..872ec04a5 --- /dev/null +++ b/src/components/loans/IncomingLoanOffersList.tsx @@ -0,0 +1,384 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import type { LoanOffer } from '@/types/loans'; +import loansService from '@/services/loans'; +import { toast } from 'sonner'; +import { logger } from '@/utils/logger'; +import { STATUS } from '@/config/database-constants'; +import { formatRelativeTime } from '@/utils/dates'; +import { getLoanOfferStatusColor } from '@/config/loans'; +import { formatLoanAmount } from './useLoanList'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card'; +import { Badge } from '@/components/ui/badge'; +import Button from '@/components/ui/Button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/Input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { CURRENCY_CODES, PLATFORM_DEFAULT_CURRENCY, type CurrencyCode } from '@/config/currencies'; +import { CheckCircle, Clock, Loader2, XCircle } from 'lucide-react'; + +const acceptOfferSchema = z.object({ + payment_method: z.enum(['bitcoin', 'lightning', 'bank_transfer', 'card', 'other']), + transaction_id: z.string().max(200).optional(), + notes: z.string().max(500).optional(), +}); + +type AcceptOfferForm = z.infer; + +interface IncomingLoanOffersListProps { + offers: LoanOffer[]; + borrowerId: string; + onOfferUpdated?: () => void; +} + +function getStatusIcon(status: string) { + switch (status) { + case STATUS.LOAN_OFFERS.ACCEPTED: + return ; + case STATUS.LOAN_OFFERS.REJECTED: + return ; + default: + return ; + } +} + +export function IncomingLoanOffersList({ + offers, + borrowerId, + onOfferUpdated, +}: IncomingLoanOffersListProps) { + const [activeOffer, setActiveOffer] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const pendingOffers = useMemo( + () => offers.filter(offer => offer.status === STATUS.LOAN_OFFERS.PENDING), + [offers] + ); + + const form = useForm({ + resolver: zodResolver(acceptOfferSchema), + defaultValues: { + payment_method: 'bank_transfer', + transaction_id: '', + notes: '', + }, + }); + + const handleReject = async (offer: LoanOffer) => { + setSubmitting(true); + try { + const result = await loansService.respondToOffer(offer.id, false); + if (!result.success) { + toast.error(result.error || 'Failed to reject offer'); + return; + } + toast.success('Offer rejected'); + onOfferUpdated?.(); + } catch (error) { + logger.error('Failed to reject loan offer', error, 'IncomingLoanOffers'); + toast.error('Failed to reject offer'); + } finally { + setSubmitting(false); + } + }; + + const handleAccept = async (values: AcceptOfferForm) => { + if (!activeOffer) { + return; + } + + setSubmitting(true); + try { + const respond = await loansService.respondToOffer(activeOffer.id, true, values.notes); + if (!respond.success) { + toast.error(respond.error || 'Failed to accept offer'); + return; + } + + const loanCurrency = + (activeOffer.loans?.currency as CurrencyCode | undefined) ?? PLATFORM_DEFAULT_CURRENCY; + const payment = await loansService.createPayment({ + loan_id: activeOffer.loan_id, + offer_id: activeOffer.id, + amount: activeOffer.offer_amount, + currency: CURRENCY_CODES.includes(loanCurrency) ? loanCurrency : PLATFORM_DEFAULT_CURRENCY, + payment_type: activeOffer.offer_type === 'refinance' ? 'refinance' : 'payoff', + payer_id: activeOffer.offerer_id, + recipient_id: borrowerId, + payment_method: values.payment_method, + transaction_id: values.transaction_id || undefined, + notes: values.notes || undefined, + }); + if (!payment.success || !payment.payment) { + toast.error(payment.error || 'Offer accepted, but payment record failed'); + onOfferUpdated?.(); + return; + } + + const complete = await loansService.completePayment(payment.payment.id, { + ...(activeOffer.offer_type === 'refinance' + ? { + createObligation: { + lenderProfileName: + activeOffer.profiles?.display_name || + activeOffer.profiles?.username || + 'New lender', + }, + } + : {}), + }); + if (!complete.success) { + toast.error(complete.error || 'Payment created, but completion failed'); + onOfferUpdated?.(); + return; + } + + toast.success( + activeOffer.offer_type === 'refinance' + ? 'Offer accepted and refinance handoff completed' + : 'Offer accepted and payoff recorded' + ); + form.reset(); + setActiveOffer(null); + onOfferUpdated?.(); + } catch (error) { + logger.error('Failed to accept loan offer', error, 'IncomingLoanOffers'); + toast.error('Failed to accept offer'); + } finally { + setSubmitting(false); + } + }; + + if (offers.length === 0) { + return null; + } + + return ( + <> +
+
+

Incoming Offers

+

+ Review lender proposals on your loans and complete the payoff/refinance handoff. +

+
+ +
+ {offers.map(offer => { + const offererLabel = + offer.profiles?.display_name || offer.profiles?.username || 'Community lender'; + const loanTitle = offer.loans?.title || 'Loan'; + + return ( + + +
+
+ + {offer.offer_type === 'refinance' ? 'Refinance Offer' : 'Payoff Offer'} + + + {offererLabel} on {loanTitle} • {formatRelativeTime(offer.created_at)} + +
+ + {getStatusIcon(offer.status)} + {offer.status} + +
+
+ + +
+
+

Offer Amount

+

+ {formatLoanAmount(offer.offer_amount, offer.loans?.currency || undefined)} +

+
+ {offer.interest_rate !== undefined && offer.interest_rate !== null && ( +
+

Interest Rate

+

{offer.interest_rate}%

+
+ )} + {offer.term_months !== undefined && offer.term_months !== null && ( +
+

Term

+

{offer.term_months} months

+
+ )} +
+

Loan Balance

+

+ {formatLoanAmount( + offer.loans?.remaining_balance || 0, + offer.loans?.currency || undefined + )} +

+
+
+ + {offer.terms && ( +
+

Terms

+

+ {offer.terms} +

+
+ )} + + {offer.conditions && ( +
+

Conditions

+

+ {offer.conditions} +

+
+ )} + + {offer.status === STATUS.LOAN_OFFERS.PENDING && ( +
+ + +
+ )} +
+
+ ); + })} +
+ + {pendingOffers.length === 0 && ( +

All current offers have been processed.

+ )} +
+ + !open && setActiveOffer(null)}> + + + Accept Offer and Record Payment + + Confirm the payment handoff details. Refinance offers will also create the new + obligation loan when the payment is marked completed. + + + +
+ + ( + + Payment Method + + + + )} + /> + + ( + + Transaction Reference + + + + + Optional, but helpful if you want to trace the transfer later. + + + + )} + /> + + ( + + Notes + + + + + + )} + /> + +
+ + +
+ + +
+
+ + ); +} diff --git a/src/components/loans/LoanOffersList.tsx b/src/components/loans/LoanOffersList.tsx index c65e9159c..5c8fbfd71 100644 --- a/src/components/loans/LoanOffersList.tsx +++ b/src/components/loans/LoanOffersList.tsx @@ -44,9 +44,9 @@ export function LoanOffersList({ offers, onOfferUpdated: _onOfferUpdated }: Loan {offers.map(offer => ( -
+
-
+
{offer.offer_type === 'refinance' ? 'Refinance Offer' : 'Payoff Offer'} @@ -57,7 +57,7 @@ export function LoanOffersList({ offers, onOfferUpdated: _onOfferUpdated }: Loan
Made {formatRelativeTime(offer.created_at)}
-
+
{offer.status === STATUS.LOAN_OFFERS.PENDING && ( )} {offer.status === STATUS.LOAN_OFFERS.ACCEPTED && ( - diff --git a/src/components/loans/MakeOfferDialog.tsx b/src/components/loans/MakeOfferDialog.tsx index c24faa14e..224ad8620 100644 --- a/src/components/loans/MakeOfferDialog.tsx +++ b/src/components/loans/MakeOfferDialog.tsx @@ -68,14 +68,14 @@ export function MakeOfferDialog({ {loan.description} -
-
+
+

Remaining Balance

{formatLoanCurrency(loan.remaining_balance, loan.currency)}

-
+

Current Rate

{loan.interest_rate ? `${loan.interest_rate}%` : 'N/A'} @@ -145,7 +145,7 @@ export function MakeOfferDialog({ /> {watchOfferType === 'refinance' && ( -

+
)} -
+
-
+
- diff --git a/src/config/loan-payments.ts b/src/config/loan-payments.ts index 5da59c312..04a4f3ea5 100644 --- a/src/config/loan-payments.ts +++ b/src/config/loan-payments.ts @@ -29,6 +29,7 @@ export const createLoanPaymentSchema = z.object({ amount: z.number().positive('amount must be greater than 0'), currency: z.enum(CURRENCY_CODES), payment_type: z.enum(LOAN_PAYMENT_TYPES), + payer_id: z.string().uuid().optional(), recipient_id: z.string().uuid('recipient_id must be a UUID'), transaction_id: z.string().max(200).optional(), payment_method: z.enum(LOAN_PAYMENT_METHODS).optional(), diff --git a/src/domain/loans/payments.ts b/src/domain/loans/payments.ts index 0b8b9016d..e2dc76a22 100644 --- a/src/domain/loans/payments.ts +++ b/src/domain/loans/payments.ts @@ -21,17 +21,26 @@ type PaymentResult = }; export async function createLoanPayment( - payerUserId: string, + userId: string, input: CreateLoanPaymentBody, supabase: AnySupabaseClient ): Promise { - if (payerUserId === input.recipient_id) { + const payerId = input.payer_id ?? userId; + + if (payerId === input.recipient_id) { return { ok: false, reason: 'forbidden', message: 'Payer and recipient must be different users', }; } + if (userId !== payerId && userId !== input.recipient_id) { + return { + ok: false, + reason: 'forbidden', + message: 'You must be a party to the payment', + }; + } const { data: loanRow, error: loanErr } = await supabase .from(getTableName('loan')) @@ -55,7 +64,7 @@ export async function createLoanPayment( amount: input.amount, currency: input.currency, payment_type: input.payment_type, - payer_id: payerUserId, + payer_id: payerId, recipient_id: input.recipient_id, transaction_id: input.transaction_id ?? null, payment_method: input.payment_method ?? null, diff --git a/src/services/loans/index.ts b/src/services/loans/index.ts index 702a77cbe..761e99464 100644 --- a/src/services/loans/index.ts +++ b/src/services/loans/index.ts @@ -19,7 +19,7 @@ import type { // Import modular functions import { getLoan, getUserLoans, getAvailableLoans, getLoanCategories } from './queries/loans'; -import { getLoanOffers, getUserOffers } from './queries/offers'; +import { getIncomingOffers, getLoanOffers, getUserOffers } from './queries/offers'; import { createLoan, updateLoan, deleteLoan, createObligationLoan } from './mutations/loans'; import { createLoanOffer, updateLoanOffer, respondToOffer } from './mutations/offers'; import { createPayment, completePayment } from './mutations/payments'; @@ -101,6 +101,13 @@ class LoansService { return getUserOffers(query, pagination); } + async getIncomingOffers( + query?: LoanOffersQuery, + pagination?: Pagination + ): Promise { + return getIncomingOffers(query, pagination); + } + async respondToOffer( offerId: string, accept: boolean, diff --git a/src/services/loans/queries/offers.ts b/src/services/loans/queries/offers.ts index f91ab3e30..b141b56f9 100644 --- a/src/services/loans/queries/offers.ts +++ b/src/services/loans/queries/offers.ts @@ -134,3 +134,70 @@ export async function getUserOffers( return { success: false, error: 'Failed to get offers' }; } } + +/** + * Get offers received on the current user's loans. + */ +export async function getIncomingOffers( + query?: LoanOffersQuery, + pagination?: Pagination +): Promise { + try { + const userId = await getCurrentUserId(); + if (!userId) { + return { success: false, error: 'Authentication required' }; + } + + let dbQuery = supabase + .from(DATABASE_TABLES.LOAN_OFFERS) + .select( + ` + *, + profiles!loan_offers_offerer_id_fkey ( + username, + display_name, + avatar_url + ), + loans!loan_offers_loan_id_fkey ( + id, + title, + remaining_balance, + interest_rate, + currency, + status, + user_id + ) + `, + { count: 'exact' } + ) + .eq('loans.user_id', userId); + + if (query?.status) { + dbQuery = dbQuery.eq('status', query.status); + } + if (query?.offer_type) { + dbQuery = dbQuery.eq('offer_type', query.offer_type); + } + + const sortBy = query?.sort_by || 'created_at'; + const sortOrder = query?.sort_order || 'desc'; + dbQuery = dbQuery.order(sortBy, { ascending: sortOrder === 'asc' }); + + const pageSize = Math.min(pagination?.pageSize || DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE); + const page = pagination?.page || 1; + const offset = (page - 1) * pageSize; + dbQuery = dbQuery.range(offset, offset + pageSize - 1); + + const { data, error, count } = await dbQuery; + + if (error) { + logger.error('Failed to get incoming offers', error, 'Loans'); + return { success: false, error: error.message }; + } + + return { success: true, offers: data || [], total: count || 0 }; + } catch (error) { + logger.error('Exception getting incoming offers', error, 'Loans'); + return { success: false, error: 'Failed to get incoming offers' }; + } +} diff --git a/src/types/loans.ts b/src/types/loans.ts index 637cffd8e..fce8fdeca 100644 --- a/src/types/loans.ts +++ b/src/types/loans.ts @@ -96,6 +96,20 @@ export interface LoanOffer { updated_at: string; accepted_at?: string; rejected_at?: string; + profiles?: { + username?: string | null; + display_name?: string | null; + avatar_url?: string | null; + } | null; + loans?: { + id: string; + title?: string | null; + remaining_balance?: number | null; + interest_rate?: number | null; + currency?: CurrencyCode | string | null; + status?: string | null; + user_id?: string | null; + } | null; } interface LoanPayment { @@ -162,6 +176,7 @@ export interface CreateLoanPaymentRequest { amount: number; currency: CurrencyCode; payment_type: PaymentType; + payer_id?: string; recipient_id: string; transaction_id?: string; payment_method?: PaymentMethod;