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

import { createLoanPaymentSchema } from '@/config/loan-payments';
import { createLoanPayment, completeLoanPayment } from '@/domain/loans/payments';
import { STATUS } from '@/config/database-constants';

jest.mock('@/domain/loans/obligation', () => ({
createObligationLoan: jest.fn().mockResolvedValue({
ok: true,
loan: { id: 'obligation-loan-id', status: 'active' },
}),
}));

describe('createLoanPaymentSchema', () => {
it('accepts a valid payoff payload', () => {
const parsed = createLoanPaymentSchema.safeParse({
loan_id: '00000000-0000-4000-8000-000000000001',
amount: 500,
currency: 'CHF',
payment_type: 'payoff',
recipient_id: '00000000-0000-4000-8000-000000000002',
payment_method: 'lightning',
});
expect(parsed.success).toBe(true);
});
});

describe('createLoanPayment domain', () => {
const payerId = '00000000-0000-4000-8000-000000000099';
const recipientId = '00000000-0000-4000-8000-000000000088';
const loanId = '00000000-0000-4000-8000-000000000077';

function mockSupabaseForCreate() {
const paymentRow = {
id: 'payment-1',
status: STATUS.LOAN_PAYMENTS.PENDING,
payer_id: payerId,
recipient_id: recipientId,
};
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 }, error: null }),
}),
}),
};
}
return {
insert: jest.fn().mockReturnValue({
select: jest.fn().mockReturnValue({
single: jest.fn().mockResolvedValue({ data: paymentRow, error: null }),
}),
}),
};
}),
};
}

it('creates a pending payment for the authenticated payer', async () => {
const result = await createLoanPayment(
payerId,
{
loan_id: loanId,
amount: 1000,
currency: 'CHF',
payment_type: 'payoff',
recipient_id: recipientId,
},
mockSupabaseForCreate() as never
);

expect(result.ok).toBe(true);
if (result.ok) {
expect(result.payment.id).toBe('payment-1');
}
});

it('rejects self-payments', async () => {
const result = await createLoanPayment(
payerId,
{
loan_id: loanId,
amount: 1000,
currency: 'CHF',
payment_type: 'payoff',
recipient_id: payerId,
},
mockSupabaseForCreate() as never
);

expect(result).toEqual({
ok: false,
reason: 'forbidden',
message: 'Payer and recipient must be different users',
});
});
});

describe('completeLoanPayment domain', () => {
const userId = '00000000-0000-4000-8000-000000000088';

it('forbids users who are not payment parties', async () => {
const supabase = {
from: jest.fn().mockReturnValue({
select: jest.fn().mockReturnValue({
eq: jest.fn().mockReturnValue({
maybeSingle: jest.fn().mockResolvedValue({
data: {
id: 'payment-1',
payer_id: 'other-payer',
recipient_id: 'other-recipient',
status: STATUS.LOAN_PAYMENTS.PENDING,
payment_type: 'payoff',
},
error: null,
}),
}),
}),
}),
};

const result = await completeLoanPayment(userId, 'payment-1', {}, supabase as never);
expect(result).toEqual({
ok: false,
reason: 'forbidden',
message: 'You are not a party to this payment',
});
});
});
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]** Inventory browser-supabase write sites; migrate loans first~~ — loan obligation route done 2026-07-09; payments/offers still browser-side
5. ~~**[E1]** Loan browser writes~~ — loans + payments + obligation route through API (2026-07-09); offers still browser-side
6 changes: 3 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: Obligation loan creation routes through POST /api/loans/obligation (no browser Supabase writes).
last_modified_summary: Loan payments route through POST /api/loans/payments and complete endpoint; optional obligation creation on refinance.

# OrangeCat Loans Flow (Refinance & Payoff)

Expand Down Expand Up @@ -28,7 +28,7 @@ last_modified_summary: Obligation loan creation routes through POST /api/loans/o

- 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` and `/api/loans/obligation` (no direct DB access from UI).
- 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).
- 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,5 @@ last_modified_summary: Obligation loan creation routes through POST /api/loans/o
## 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).
- Service layer adds `completePayment` and `createObligationLoan` scaffolding for new-loan creation post-payoff (`createObligationLoan` calls `POST /api/loans/obligation`; wire after payment completion in UI).
- 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.
67 changes: 67 additions & 0 deletions src/app/api/loans/payments/[id]/complete/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* POST /api/loans/payments/:id/complete — mark a payment completed.
*
* Optionally creates an obligation loan when `createObligation` is supplied on
* refinance payments (payoff handoff — see docs/development/loans-flow.md).
*/
import { withAuth, type AuthenticatedRequest } from '@/lib/api/withAuth';
import {
apiSuccess,
apiBadRequest,
apiValidationError,
apiForbidden,
apiNotFound,
apiInternalError,
} from '@/lib/api/standardResponse';
import { completeLoanPaymentSchema } from '@/config/loan-payments';
import { completeLoanPayment } from '@/domain/loans/payments';
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: paymentId } = await params;

let body: unknown = {};
try {
const text = await request.text();
if (text.trim()) {
body = JSON.parse(text);
}
} catch {
return apiBadRequest('Invalid JSON body');
}

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

const result = await completeLoanPayment(user.id, paymentId, parsed.data, 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 payment complete failed', { message: result.message }, 'LoansAPI');
return apiInternalError(result.message);
}
}

return apiSuccess({
payment: result.payment,
...(result.obligationLoan ? { obligationLoan: result.obligationLoan } : {}),
});
} catch (error) {
logger.error('Unexpected error in POST /api/loans/payments/:id/complete', error, 'LoansAPI');
return apiInternalError('Internal server error');
}
});
51 changes: 51 additions & 0 deletions src/app/api/loans/payments/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* POST /api/loans/payments — record a loan payment (payoff / refinance handoff).
*/
import { withAuth, type AuthenticatedRequest } from '@/lib/api/withAuth';
import {
apiCreated,
apiBadRequest,
apiValidationError,
apiForbidden,
apiNotFound,
apiInternalError,
} from '@/lib/api/standardResponse';
import { createLoanPaymentSchema } from '@/config/loan-payments';
import { createLoanPayment } from '@/domain/loans/payments';
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 = createLoanPaymentSchema.safeParse(body);
if (!parsed.success) {
return apiValidationError('Invalid request', parsed.error.flatten());
}

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

return apiCreated(result.payment);
} catch (error) {
logger.error('Unexpected error in POST /api/loans/payments', 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`,
PAYMENTS: `${ENTITY_REGISTRY['loan'].apiEndpoint}/payments`,
PAYMENT_COMPLETE: (id: string) =>
`${ENTITY_REGISTRY['loan'].apiEndpoint}/payments/${id}/complete`,
COLLATERAL: '/api/loan-collateral',
},
PROFILE: '/api/profile',
Expand Down
6 changes: 6 additions & 0 deletions src/config/database-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ export const STATUS = {
EXPIRED: 'expired',
CANCELLED: 'cancelled',
},
LOAN_PAYMENTS: {
PENDING: 'pending',
COMPLETED: 'completed',
FAILED: 'failed',
REFUNDED: 'refunded',
},
TRANSACTIONS: {
PENDING: 'pending',
COMPLETED: 'completed',
Expand Down
49 changes: 49 additions & 0 deletions src/config/loan-payments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Loan payment contract SSOT — payoff / refinance payment records.
*
* Created: 2026-07-09
* Last Modified: 2026-07-09
* Last Modified Summary: Initial schema for /api/loans/payments routes.
*/

import { z } from 'zod';
import { CURRENCY_CODES } from '@/config/currencies';

/** Mirrors loan_payments.payment_type CHECK constraint. */
export const LOAN_PAYMENT_TYPES = ['monthly', 'lump_sum', 'refinance', 'payoff'] as const;
export type LoanPaymentType = (typeof LOAN_PAYMENT_TYPES)[number];

/** Mirrors loan_payments.payment_method CHECK constraint. */
export const LOAN_PAYMENT_METHODS = [
'bitcoin',
'lightning',
'bank_transfer',
'card',
'other',
] as const;
export type LoanPaymentMethod = (typeof LOAN_PAYMENT_METHODS)[number];

export const createLoanPaymentSchema = z.object({
loan_id: z.string().uuid('loan_id must be a UUID'),
offer_id: z.string().uuid().optional(),
amount: z.number().positive('amount must be greater than 0'),
currency: z.enum(CURRENCY_CODES),
payment_type: z.enum(LOAN_PAYMENT_TYPES),
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(),
notes: z.string().max(500).optional(),
});

export type CreateLoanPaymentBody = z.infer<typeof createLoanPaymentSchema>;

/** Optional obligation creation after refinance payment completion. */
export const completeLoanPaymentSchema = z.object({
createObligation: z
.object({
lenderProfileName: z.string().min(1).max(200),
})
.optional(),
});

export type CompleteLoanPaymentBody = z.infer<typeof completeLoanPaymentSchema>;
Loading
Loading