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 && (
setShowSelection(!showSelection)} variant="outline" size="sm">
{showSelection ? 'Cancel' : 'Select'}
@@ -115,7 +119,7 @@ export default function LoansPage() {
) : (
<>
{showSelection && myLoans.length > 0 && (
-
+
+ {
+ loadIncomingOffers();
+ refresh?.();
+ loadOffers();
+ }}
+ />
>
)}
diff --git a/src/app/(authenticated)/dashboard/loans/useLoanList.ts b/src/app/(authenticated)/dashboard/loans/useLoanList.ts
index 1fc605164..ca0952bba 100644
--- a/src/app/(authenticated)/dashboard/loans/useLoanList.ts
+++ b/src/app/(authenticated)/dashboard/loans/useLoanList.ts
@@ -20,6 +20,7 @@ export function useLoanList() {
const [showSelection, setShowSelection] = useState(false);
const [activeTab, setActiveTab] = useState('my-loans');
const [myOffers, setMyOffers] = useState([]);
+ const [incomingOffers, setIncomingOffers] = useState([]);
const [availableLoans, setAvailableLoans] = useState([]);
const [availablePage, setAvailablePage] = useState(1);
const [availableTotal, setAvailableTotal] = useState(0);
@@ -53,6 +54,17 @@ export function useLoanList() {
}
}, []);
+ const loadIncomingOffers = useCallback(async () => {
+ try {
+ const result = await loansService.getIncomingOffers();
+ if (result.success) {
+ setIncomingOffers(result.offers || []);
+ }
+ } catch (err) {
+ logger.error('Failed to load incoming offers', { error: err }, 'LoansPage');
+ }
+ }, []);
+
const loadAvailableLoans = useCallback(async () => {
try {
const result = await loansService.getAvailableLoans(undefined, {
@@ -75,10 +87,13 @@ export function useLoanList() {
if (activeTab === 'offers') {
loadOffers();
}
+ if (activeTab === 'my-loans') {
+ loadIncomingOffers();
+ }
if (activeTab === 'available') {
loadAvailableLoans();
}
- }, [activeTab, user?.id, loadOffers, loadAvailableLoans]);
+ }, [activeTab, user?.id, loadIncomingOffers, loadOffers, loadAvailableLoans]);
const switchTab = (tab: ActiveTab) => {
setActiveTab(tab);
@@ -107,6 +122,7 @@ export function useLoanList() {
setCreateDialogOpen(false);
refresh();
loadOffers();
+ loadIncomingOffers();
loadAvailableLoans();
toast.success('Loan created successfully!');
};
@@ -127,6 +143,7 @@ export function useLoanList() {
switchTab,
myLoans: memoizedLoans,
myOffers,
+ incomingOffers,
availableLoans,
availablePage,
setAvailablePage,
@@ -140,7 +157,9 @@ export function useLoanList() {
total,
setPage,
loadOffers,
+ loadIncomingOffers,
loadAvailableLoans,
+ refresh,
handleBulkDelete,
executeBulkDelete,
handleLoanCreated,
diff --git a/src/components/loans/AvailableLoans.tsx b/src/components/loans/AvailableLoans.tsx
index bd0f3ac09..b7f1bc692 100644
--- a/src/components/loans/AvailableLoans.tsx
+++ b/src/components/loans/AvailableLoans.tsx
@@ -106,12 +106,16 @@ export function AvailableLoans({ loans, onOfferMade }: AvailableLoansProps) {
)}
{/* Action Buttons */}
-
-
handleMakeOffer(loan)}>
+
+
handleMakeOffer(loan)}
+ >
Make Offer
-
+
Details
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 && (
+
+ handleReject(offer)}
+ disabled={submitting}
+ >
+ Reject
+
+ setActiveOffer(offer)}
+ disabled={submitting}
+ >
+ Accept and Record Payment
+
+
+ )}
+
+
+ );
+ })}
+
+
+ {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.
+
+
+
+
+
+
+
+ >
+ );
+}
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 && (
@@ -69,7 +69,7 @@ export function LoanOffersList({ offers, onOfferUpdated: _onOfferUpdated }: Loan
{/* Offer Details */}
-
+
Offer Amount
@@ -103,14 +103,14 @@ export function LoanOffersList({ offers, onOfferUpdated: _onOfferUpdated }: Loan
{offer.terms && (
Terms & Conditions
-
+
{offer.terms}
)}
{/* Status-specific info */}
-
+
{offer.status === STATUS.LOAN_OFFERS.PENDING && offer.expires_at && (
Expires {formatRelativeTime(offer.expires_at)}
@@ -123,24 +123,24 @@ export function LoanOffersList({ offers, onOfferUpdated: _onOfferUpdated }: Loan
)}
-
+
{offer.status === STATUS.LOAN_OFFERS.PENDING && (
<>
-
+
Message
Cancel Offer
>
)}
{offer.status === STATUS.LOAN_OFFERS.ACCEPTED && (
-
+
View Agreement
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' && (
-
+
)}
-
+
-
+
handleOpenChange(false)}
disabled={loading}
>
Cancel
-
+
{loading && }
Submit Offer
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;