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
17 changes: 17 additions & 0 deletions __tests__/unit/domain/loans/payments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
4 changes: 3 additions & 1 deletion docs/development/loans-flow.md
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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).
Expand Down Expand Up @@ -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.
17 changes: 15 additions & 2 deletions src/app/(authenticated)/dashboard/loans/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,6 +34,7 @@ export default function LoansPage() {
switchTab,
myLoans,
myOffers,
incomingOffers,
availableLoans,
availablePage,
setAvailablePage,
Expand All @@ -45,7 +47,9 @@ export default function LoansPage() {
page,
total,
setPage,
refresh,
loadOffers,
loadIncomingOffers,
loadAvailableLoans,
handleBulkDelete,
executeBulkDelete,
Expand All @@ -62,7 +66,7 @@ export default function LoansPage() {
}

const headerActions = (
<div className="flex items-center gap-2">
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
{activeTab === 'my-loans' && myLoans.length > 0 && (
<Button onClick={() => setShowSelection(!showSelection)} variant="outline" size="sm">
{showSelection ? 'Cancel' : 'Select'}
Expand Down Expand Up @@ -115,7 +119,7 @@ export default function LoansPage() {
) : (
<>
{showSelection && myLoans.length > 0 && (
<div className="mb-4 flex items-center justify-between">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<label className="flex items-center gap-2 text-sm text-fg-primary">
<input
type="checkbox"
Expand All @@ -139,6 +143,15 @@ export default function LoansPage() {
showSelection={showSelection}
/>
<CommercePagination page={page} limit={12} total={total} onPageChange={setPage} />
<IncomingLoanOffersList
offers={incomingOffers}
borrowerId={user.id}
onOfferUpdated={() => {
loadIncomingOffers();
refresh?.();
loadOffers();
}}
/>
</>
)}
</TabsContent>
Expand Down
21 changes: 20 additions & 1 deletion src/app/(authenticated)/dashboard/loans/useLoanList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function useLoanList() {
const [showSelection, setShowSelection] = useState(false);
const [activeTab, setActiveTab] = useState<ActiveTab>('my-loans');
const [myOffers, setMyOffers] = useState<LoanOffer[]>([]);
const [incomingOffers, setIncomingOffers] = useState<LoanOffer[]>([]);
const [availableLoans, setAvailableLoans] = useState<Loan[]>([]);
const [availablePage, setAvailablePage] = useState(1);
const [availableTotal, setAvailableTotal] = useState(0);
Expand Down Expand Up @@ -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, {
Expand All @@ -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);
Expand Down Expand Up @@ -107,6 +122,7 @@ export function useLoanList() {
setCreateDialogOpen(false);
refresh();
loadOffers();
loadIncomingOffers();
loadAvailableLoans();
toast.success('Loan created successfully!');
};
Expand All @@ -127,6 +143,7 @@ export function useLoanList() {
switchTab,
myLoans: memoizedLoans,
myOffers,
incomingOffers,
availableLoans,
availablePage,
setAvailablePage,
Expand All @@ -140,7 +157,9 @@ export function useLoanList() {
total,
setPage,
loadOffers,
loadIncomingOffers,
loadAvailableLoans,
refresh,
handleBulkDelete,
executeBulkDelete,
handleLoanCreated,
Expand Down
10 changes: 7 additions & 3 deletions src/components/loans/AvailableLoans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,16 @@ export function AvailableLoans({ loans, onOfferMade }: AvailableLoansProps) {
)}

{/* Action Buttons */}
<div className="flex gap-2 pt-2">
<Button size="sm" className="flex-1 gap-1" onClick={() => handleMakeOffer(loan)}>
<div className="flex flex-col gap-2 pt-2 sm:flex-row">
<Button
size="sm"
className="w-full flex-1 gap-1"
onClick={() => handleMakeOffer(loan)}
>
<Target className="h-3 w-3" />
Make Offer
</Button>
<Button variant="outline" size="sm" className="gap-1">
<Button variant="outline" size="sm" className="w-full gap-1 sm:w-auto">
<MessageSquare className="h-3 w-3" />
Details
</Button>
Expand Down
Loading
Loading