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
160 changes: 160 additions & 0 deletions __tests__/unit/domain/loans/offers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* Loan offer schema + domain
*/

import { createLoanOfferSchema } from '@/config/loan-offers';
import { STATUS } from '@/config/database-constants';
import { createLoanOffer, respondToLoanOffer, updateLoanOffer } from '@/domain/loans/offers';

describe('createLoanOfferSchema', () => {
it('requires refinance offers to include interest rate and term', () => {
const parsed = createLoanOfferSchema.safeParse({
loan_id: '00000000-0000-4000-8000-000000000001',
offer_type: 'refinance',
offer_amount: 1500,
});

expect(parsed.success).toBe(false);
});
});

describe('createLoanOffer domain', () => {
const loanId = '00000000-0000-4000-8000-000000000011';
const offererId = '00000000-0000-4000-8000-000000000022';
const ownerId = '00000000-0000-4000-8000-000000000033';

function createSupabase(minimumOfferAmount: number | null = null) {
return {
from: jest.fn((table: string) => {
if (table === 'loans') {
return {
select: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
maybeSingle: jest.fn().mockResolvedValue({
data: {
id: loanId,
user_id: ownerId,
status: STATUS.LOANS.ACTIVE,
minimum_offer_amount: minimumOfferAmount,
},
error: null,
}),
}),
}),
};
}

return {
insert: jest.fn().mockReturnValue({
select: jest.fn().mockReturnValue({
single: jest.fn().mockResolvedValue({
data: { id: 'offer-1', loan_id: loanId, offerer_id: offererId },
error: null,
}),
}),
}),
};
}),
};
}

it('creates a valid offer', async () => {
const result = await createLoanOffer(
offererId,
{
loan_id: loanId,
offer_type: 'payoff',
offer_amount: 1200,
},
createSupabase() as never
);

expect(result.ok).toBe(true);
});

it('rejects offers below the loan minimum', async () => {
const result = await createLoanOffer(
offererId,
{
loan_id: loanId,
offer_type: 'payoff',
offer_amount: 400,
},
createSupabase(500) as never
);

expect(result).toEqual({
ok: false,
reason: 'below_minimum',
message: 'Offer amount below minimum required',
});
});
});

describe('updateLoanOffer domain', () => {
it('returns not_found when the offer does not belong to the caller', async () => {
const supabase = {
from: jest.fn().mockReturnValue({
update: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
select: jest.fn().mockReturnValue({
maybeSingle: jest.fn().mockResolvedValue({ data: null, error: null }),
}),
}),
}),
}),
}),
};

const result = await updateLoanOffer(
'user-1',
'offer-1',
{ status: STATUS.LOAN_OFFERS.CANCELLED },
supabase as never
);

expect(result).toEqual({
ok: false,
reason: 'not_found',
message: 'Offer not found',
});
});
});

describe('respondToLoanOffer domain', () => {
const ownerId = '00000000-0000-4000-8000-000000000044';

it('rejects responses to non-pending offers', async () => {
const supabase = {
from: jest.fn((table: string) => {
if (table === 'loan_offers') {
return {
select: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
maybeSingle: jest.fn().mockResolvedValue({
data: {
id: 'offer-2',
loan_id: 'loan-2',
status: STATUS.LOAN_OFFERS.ACCEPTED,
loans: { user_id: ownerId },
},
error: null,
}),
}),
}),
};
}
return null;
}),
};

const result = await respondToLoanOffer(ownerId, 'offer-2', true, supabase as never);

expect(result).toEqual({
ok: false,
reason: 'invalid_state',
message: 'Only pending offers can be accepted or rejected',
});
});
});
2 changes: 1 addition & 1 deletion docs/AUDIT_REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,4 @@ grep -rn '\[#' src/ # design token audit — expect 0
2. **[B2]** Regenerate `database.generated.ts` and migrate Supabase client imports
3. ~~**[C1]** Migrate dialog/select/dropdown to semantic tokens~~ — done 2026-07-09
4. ~~**[D1]** Add stakeholders + timeline to v1 SDK~~ — done 2026-07-09
5. ~~**[E1]** Loan browser writes~~ — loans + payments + obligation route through API (2026-07-09); offers still browser-side
5. ~~**[E1]** Loan browser writes~~ — loans + offers + payments + obligation route through API (2026-07-09)
7 changes: 4 additions & 3 deletions 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 payments route through POST /api/loans/payments and complete endpoint; optional obligation creation on refinance.
last_modified_summary: Loan offer writes now route through /api/loans/offers; the loans vertical is API-first for create/respond/payment flows.

# OrangeCat Loans Flow (Refinance & Payoff)

Expand All @@ -27,8 +27,8 @@ last_modified_summary: Loan payments route through POST /api/loans/payments and
## Implementation notes

- Frontend now loads “My Offers” and borrower offer lists; borrower can accept/reject offers per loan.
- Service layer includes `getUserOffers` and reuses `respondToOffer`; UI uses typed service calls with toasts for feedback.
- RLS and backend validations remain the source of truth; loan mutations use `/api/loans`, `/api/loans/obligation`, and `/api/loans/payments` (no direct DB access from UI).
- 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).
- Assets now use real Supabase CRUD APIs (`/api/assets` and `/api/assets/[id]`) with edit/delete support; entity form drives both create and edit flows.

Expand Down Expand Up @@ -61,5 +61,6 @@ last_modified_summary: Loan payments route through POST /api/loans/payments and
## 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).
- 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.
59 changes: 59 additions & 0 deletions src/app/api/loans/offers/[id]/respond/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* POST /api/loans/offers/:id/respond — borrower accepts or rejects an offer.
*/

import { withAuth, type AuthenticatedRequest } from '@/lib/api/withAuth';
import {
apiBadRequest,
apiForbidden,
apiInternalError,
apiNotFound,
apiSuccess,
apiValidationError,
} from '@/lib/api/standardResponse';
import { respondToLoanOfferSchema } from '@/config/loan-offers';
import { respondToLoanOffer } from '@/domain/loans/offers';
import { logger } from '@/utils/logger';

interface RouteContext {
params: Promise<{ id: string }>;
}

export const POST = withAuth(async (request: AuthenticatedRequest, { params }: RouteContext) => {
try {
const { user, supabase } = request;
const { id: offerId } = await params;

let body: unknown;
try {
body = await request.json();
} catch {
return apiBadRequest('Invalid JSON body');
}

const parsed = respondToLoanOfferSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError('Invalid request', parsed.error.flatten());
}

const result = await respondToLoanOffer(user.id, offerId, parsed.data.accept, supabase);
if (!result.ok) {
switch (result.reason) {
case 'not_found':
return apiNotFound(result.message);
case 'forbidden':
return apiForbidden(result.message);
case 'invalid_state':
return apiBadRequest(result.message);
default:
logger.error('Loan offer response failed', { message: result.message }, 'LoansAPI');
return apiInternalError(result.message);
}
}

return apiSuccess(result.offer);
} catch (error) {
logger.error('Unexpected error in POST /api/loans/offers/:id/respond', error, 'LoansAPI');
return apiInternalError('Internal server error');
}
});
57 changes: 57 additions & 0 deletions src/app/api/loans/offers/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* PUT /api/loans/offers/:id — offerer-side offer updates.
*/

import { withAuth, type AuthenticatedRequest } from '@/lib/api/withAuth';
import {
apiBadRequest,
apiForbidden,
apiInternalError,
apiNotFound,
apiSuccess,
apiValidationError,
} from '@/lib/api/standardResponse';
import { updateLoanOfferSchema } from '@/config/loan-offers';
import { updateLoanOffer } from '@/domain/loans/offers';
import { logger } from '@/utils/logger';

interface RouteContext {
params: Promise<{ id: string }>;
}

export const PUT = withAuth(async (request: AuthenticatedRequest, { params }: RouteContext) => {
try {
const { user, supabase } = request;
const { id: offerId } = await params;

let body: unknown;
try {
body = await request.json();
} catch {
return apiBadRequest('Invalid JSON body');
}

const parsed = updateLoanOfferSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError('Invalid request', parsed.error.flatten());
}

const result = await updateLoanOffer(user.id, offerId, parsed.data, supabase);
if (!result.ok) {
switch (result.reason) {
case 'not_found':
return apiNotFound(result.message);
case 'forbidden':
return apiForbidden(result.message);
default:
logger.error('Loan offer update failed', { message: result.message }, 'LoansAPI');
return apiInternalError(result.message);
}
}

return apiSuccess(result.offer);
} catch (error) {
logger.error('Unexpected error in PUT /api/loans/offers/:id', error, 'LoansAPI');
return apiInternalError('Internal server error');
}
});
53 changes: 53 additions & 0 deletions src/app/api/loans/offers/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* POST /api/loans/offers — create a refinance or payoff offer.
*/

import { withAuth, type AuthenticatedRequest } from '@/lib/api/withAuth';
import {
apiBadRequest,
apiCreated,
apiForbidden,
apiInternalError,
apiNotFound,
apiValidationError,
} from '@/lib/api/standardResponse';
import { createLoanOfferSchema } from '@/config/loan-offers';
import { createLoanOffer } from '@/domain/loans/offers';
import { logger } from '@/utils/logger';

export const POST = withAuth(async (request: AuthenticatedRequest) => {
try {
const { user, supabase } = request;

let body: unknown;
try {
body = await request.json();
} catch {
return apiBadRequest('Invalid JSON body');
}

const parsed = createLoanOfferSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError('Invalid request', parsed.error.flatten());
}

const result = await createLoanOffer(user.id, parsed.data, supabase);
if (!result.ok) {
switch (result.reason) {
case 'loan_not_found':
return apiNotFound(result.message);
case 'forbidden':
case 'below_minimum':
return apiForbidden(result.message);
default:
logger.error('Loan offer create failed', { message: result.message }, 'LoansAPI');
return apiInternalError(result.message);
}
}

return apiCreated(result.offer);
} catch (error) {
logger.error('Unexpected error in POST /api/loans/offers', error, 'LoansAPI');
return apiInternalError('Internal server error');
}
});
3 changes: 3 additions & 0 deletions src/config/api-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ export const API_ROUTES = {
BASE: ENTITY_REGISTRY['loan'].apiEndpoint,
BY_ID: (id: string) => `${ENTITY_REGISTRY['loan'].apiEndpoint}/${id}`,
OBLIGATION: `${ENTITY_REGISTRY['loan'].apiEndpoint}/obligation`,
OFFERS: `${ENTITY_REGISTRY['loan'].apiEndpoint}/offers`,
OFFER_BY_ID: (id: string) => `${ENTITY_REGISTRY['loan'].apiEndpoint}/offers/${id}`,
OFFER_RESPOND: (id: string) => `${ENTITY_REGISTRY['loan'].apiEndpoint}/offers/${id}/respond`,
PAYMENTS: `${ENTITY_REGISTRY['loan'].apiEndpoint}/payments`,
PAYMENT_COMPLETE: (id: string) =>
`${ENTITY_REGISTRY['loan'].apiEndpoint}/payments/${id}/complete`,
Expand Down
Loading
Loading