diff --git a/__tests__/unit/domain/loans/payments.test.ts b/__tests__/unit/domain/loans/payments.test.ts new file mode 100644 index 000000000..4a9fad823 --- /dev/null +++ b/__tests__/unit/domain/loans/payments.test.ts @@ -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', + }); + }); +}); diff --git a/docs/AUDIT_REPORT.md b/docs/AUDIT_REPORT.md index 563263bea..2475050e1 100644 --- a/docs/AUDIT_REPORT.md +++ b/docs/AUDIT_REPORT.md @@ -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 diff --git a/docs/development/loans-flow.md b/docs/development/loans-flow.md index 989fce062..83eba7974 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: 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) @@ -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. @@ -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. diff --git a/src/app/api/loans/payments/[id]/complete/route.ts b/src/app/api/loans/payments/[id]/complete/route.ts new file mode 100644 index 000000000..299d8b1ad --- /dev/null +++ b/src/app/api/loans/payments/[id]/complete/route.ts @@ -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'); + } +}); diff --git a/src/app/api/loans/payments/route.ts b/src/app/api/loans/payments/route.ts new file mode 100644 index 000000000..74469d608 --- /dev/null +++ b/src/app/api/loans/payments/route.ts @@ -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'); + } +}); diff --git a/src/config/api-routes.ts b/src/config/api-routes.ts index 4cb184683..6f377bda0 100644 --- a/src/config/api-routes.ts +++ b/src/config/api-routes.ts @@ -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', diff --git a/src/config/database-constants.ts b/src/config/database-constants.ts index 68b432e5f..abaa9e30f 100644 --- a/src/config/database-constants.ts +++ b/src/config/database-constants.ts @@ -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', diff --git a/src/config/loan-payments.ts b/src/config/loan-payments.ts new file mode 100644 index 000000000..5da59c312 --- /dev/null +++ b/src/config/loan-payments.ts @@ -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; + +/** 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; diff --git a/src/domain/loans/payments.ts b/src/domain/loans/payments.ts new file mode 100644 index 000000000..0b8b9016d --- /dev/null +++ b/src/domain/loans/payments.ts @@ -0,0 +1,197 @@ +/** + * Loan payment domain — payoff / refinance payment records. + */ +import { DATABASE_TABLES } from '@/config/database-tables'; +import { getTableName } from '@/config/entity-registry'; +import { STATUS } from '@/config/database-constants'; +import type { CreateLoanPaymentBody, CompleteLoanPaymentBody } from '@/config/loan-payments'; +import { createObligationLoan } from '@/domain/loans/obligation'; +import type { AnySupabaseClient } from '@/lib/supabase/types'; +import { logger } from '@/utils/logger'; + +export type LoanPaymentRow = Record & { id: string }; +export type ObligationLoanRow = Record & { id: string }; + +type PaymentResult = + | { ok: true; payment: LoanPaymentRow; obligationLoan?: ObligationLoanRow } + | { + ok: false; + reason: 'loan_not_found' | 'forbidden' | 'not_found' | 'invalid_state' | 'error'; + message: string; + }; + +export async function createLoanPayment( + payerUserId: string, + input: CreateLoanPaymentBody, + supabase: AnySupabaseClient +): Promise { + if (payerUserId === input.recipient_id) { + return { + ok: false, + reason: 'forbidden', + message: 'Payer and recipient must be different users', + }; + } + + const { data: loanRow, error: loanErr } = await supabase + .from(getTableName('loan')) + .select('id') + .eq('id', input.loan_id) + .maybeSingle(); + + if (loanErr) { + logger.error('Failed to verify loan for payment', loanErr, 'LoanPayments'); + return { ok: false, reason: 'error', message: 'Failed to verify loan' }; + } + if (!loanRow) { + return { ok: false, reason: 'loan_not_found', message: 'Loan not found' }; + } + + const { data, error } = await supabase + .from(DATABASE_TABLES.LOAN_PAYMENTS) + .insert({ + loan_id: input.loan_id, + offer_id: input.offer_id ?? null, + amount: input.amount, + currency: input.currency, + payment_type: input.payment_type, + payer_id: payerUserId, + recipient_id: input.recipient_id, + transaction_id: input.transaction_id ?? null, + payment_method: input.payment_method ?? null, + notes: input.notes ?? null, + status: STATUS.LOAN_PAYMENTS.PENDING, + processed_at: null, + }) + .select('*') + .single(); + + if (error) { + logger.error('Failed to create loan payment', error, 'LoanPayments'); + return { ok: false, reason: 'error', message: 'Failed to create payment' }; + } + + return { ok: true, payment: data as LoanPaymentRow }; +} + +export async function completeLoanPayment( + userId: string, + paymentId: string, + input: CompleteLoanPaymentBody, + supabase: AnySupabaseClient +): Promise { + const { data: existing, error: fetchErr } = await supabase + .from(DATABASE_TABLES.LOAN_PAYMENTS) + .select('*') + .eq('id', paymentId) + .maybeSingle(); + + if (fetchErr) { + logger.error('Failed to load loan payment', fetchErr, 'LoanPayments'); + return { ok: false, reason: 'error', message: 'Failed to load payment' }; + } + if (!existing) { + return { ok: false, reason: 'not_found', message: 'Payment not found' }; + } + + const payment = existing as LoanPaymentRow & { + payer_id: string; + recipient_id: string; + status: string; + payment_type: string; + loan_id: string; + offer_id: string | null; + amount: number; + currency: string; + }; + + if (payment.payer_id !== userId && payment.recipient_id !== userId) { + return { + ok: false, + reason: 'forbidden', + message: 'You are not a party to this payment', + }; + } + + if (payment.status === STATUS.LOAN_PAYMENTS.COMPLETED) { + return { ok: true, payment }; + } + + if (payment.status !== STATUS.LOAN_PAYMENTS.PENDING) { + return { + ok: false, + reason: 'invalid_state', + message: `Payment cannot be completed from status "${payment.status}"`, + }; + } + + const { data: updated, error: updateErr } = await supabase + .from(DATABASE_TABLES.LOAN_PAYMENTS) + .update({ + status: STATUS.LOAN_PAYMENTS.COMPLETED, + processed_at: new Date().toISOString(), + }) + .eq('id', paymentId) + .select('*') + .single(); + + if (updateErr) { + logger.error('Failed to complete loan payment', updateErr, 'LoanPayments'); + return { ok: false, reason: 'error', message: 'Failed to complete payment' }; + } + + let obligationLoan: ObligationLoanRow | undefined; + if (input.createObligation && payment.payment_type === 'refinance') { + if (!payment.offer_id) { + return { + ok: false, + reason: 'invalid_state', + message: 'Refinance payments require an offer_id to create an obligation loan', + }; + } + + const { data: offerRow } = await supabase + .from(DATABASE_TABLES.LOAN_OFFERS) + .select('interest_rate, term_months') + .eq('id', payment.offer_id) + .maybeSingle(); + + const obligationResult = await createObligationLoan( + payment.recipient_id, + { + lenderProfileName: input.createObligation.lenderProfileName, + offer: { + loan_id: payment.loan_id, + offer_amount: Number(payment.amount), + interest_rate: + (offerRow as { interest_rate: number | null } | null)?.interest_rate ?? undefined, + term_months: + (offerRow as { term_months: number | null } | null)?.term_months ?? undefined, + currency: payment.currency, + }, + }, + supabase + ); + + if (!obligationResult.ok) { + logger.error( + 'Payment completed but obligation loan failed', + { reason: obligationResult.reason, message: obligationResult.message }, + 'LoanPayments' + ); + return { + ok: false, + reason: obligationResult.reason === 'forbidden' ? 'forbidden' : 'error', + message: obligationResult.message, + }; + } + + obligationLoan = obligationResult.loan; + } + + return { + ok: true, + payment: updated as LoanPaymentRow, + obligationLoan, + }; +} diff --git a/src/services/loans/api-client.ts b/src/services/loans/api-client.ts index 0fbfb14cf..091670a49 100644 --- a/src/services/loans/api-client.ts +++ b/src/services/loans/api-client.ts @@ -7,7 +7,14 @@ import { API_ROUTES } from '@/config/api-routes'; import { logger } from '@/utils/logger'; -import type { Loan, CreateLoanRequest, UpdateLoanRequest, LoanResponse } from '@/types/loans'; +import type { + Loan, + CreateLoanRequest, + UpdateLoanRequest, + LoanResponse, + CreateLoanPaymentRequest, + LoanPaymentResponse, +} from '@/types/loans'; import type { ServiceResult } from '@/types/common'; interface ApiEnvelope { @@ -66,6 +73,26 @@ export function createLoanRequestToApiPayload(request: CreateLoanRequest): Recor return loanToApiPayload(request as unknown as Loan); } +async function parsePaymentEnvelope( + res: Response +): Promise { + const json = (await res.json().catch(() => ({}))) as ApiEnvelope<{ + payment?: LoanPaymentResponse['payment']; + obligationLoan?: Loan; + }>; + if (!res.ok || json.success === false) { + return { success: false, error: json.error || `Request failed (${res.status})` }; + } + if (!json.data?.payment) { + return { success: false, error: 'Empty response from server' }; + } + return { + success: true, + payment: json.data.payment, + ...(json.data.obligationLoan ? { obligationLoan: json.data.obligationLoan } : {}), + }; +} + async function parseLoanEnvelope(res: Response): Promise { const json = (await res.json().catch(() => ({}))) as ApiEnvelope; if (!res.ok || json.success === false) { @@ -177,6 +204,55 @@ export async function createObligationLoanViaApi(params: { } } +export async function createPaymentViaApi( + request: CreateLoanPaymentRequest +): Promise { + try { + const res = await fetch(API_ROUTES.LOANS.PAYMENTS, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(request), + }); + const json = (await res.json().catch(() => ({}))) as ApiEnvelope< + LoanPaymentResponse['payment'] + >; + if (!res.ok || json.success === false) { + return { success: false, error: json.error || `Request failed (${res.status})` }; + } + if (!json.data) { + return { success: false, error: 'Empty response from server' }; + } + logger.info('Loan payment created via API', { paymentId: json.data.id }, 'Loans'); + return { success: true, payment: json.data }; + } catch (error) { + logger.error('createPaymentViaApi failed', error, 'Loans'); + return { success: false, error: 'Failed to create payment' }; + } +} + +export async function completePaymentViaApi( + paymentId: string, + options?: { createObligation?: { lenderProfileName: string } } +): Promise { + try { + const res = await fetch(API_ROUTES.LOANS.PAYMENT_COMPLETE(paymentId), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(options ?? {}), + }); + const result = await parsePaymentEnvelope(res); + if (result.success) { + logger.info('Loan payment completed via API', { paymentId }, 'Loans'); + } + return result; + } catch (error) { + logger.error('completePaymentViaApi failed', error, 'Loans'); + return { success: false, error: 'Failed to complete payment' }; + } +} + export async function deleteLoanViaApi(loanId: string): Promise { try { const res = await fetch(API_ROUTES.LOANS.BY_ID(loanId), { diff --git a/src/services/loans/index.ts b/src/services/loans/index.ts index 9cf6db65a..702a77cbe 100644 --- a/src/services/loans/index.ts +++ b/src/services/loans/index.ts @@ -12,6 +12,7 @@ import type { LoanOffersQuery, CreateLoanPaymentRequest, LoanPaymentResponse, + Loan, Pagination, LoanCategory, } from '@/types/loans'; @@ -114,8 +115,11 @@ class LoansService { return createPayment(request); } - async completePayment(paymentId: string): Promise { - return completePayment(paymentId); + async completePayment( + paymentId: string, + options?: { createObligation?: { lenderProfileName: string } } + ): Promise { + return completePayment(paymentId, options); } // ==================== LOAN CATEGORIES ==================== diff --git a/src/services/loans/mutations/payments.ts b/src/services/loans/mutations/payments.ts index 76e419957..12a67b372 100644 --- a/src/services/loans/mutations/payments.ts +++ b/src/services/loans/mutations/payments.ts @@ -1,85 +1,30 @@ /** * LOANS SERVICE - Payment Mutations * - * Created: 2025-01-30 - * Last Modified: 2025-01-30 - * Last Modified Summary: Extracted from loans/index.ts for modularity + * All payment writes route through /api/loans/payments. */ -import supabase from '@/lib/supabase/browser'; -import { logger } from '@/utils/logger'; -import type { CreateLoanPaymentRequest, LoanPaymentResponse } from '@/types/loans'; -import { STATUS } from '@/config/database-constants'; -import { DATABASE_TABLES } from '@/config/database-tables'; -import { getCurrentUserId } from '../utils/auth'; +import type { CreateLoanPaymentRequest, LoanPaymentResponse, Loan } from '@/types/loans'; +import { createPaymentViaApi, completePaymentViaApi } from '@/services/loans/api-client'; /** - * Record a loan payment + * Record a loan payment (via POST /api/loans/payments). */ export async function createPayment( request: CreateLoanPaymentRequest ): Promise { - try { - const userId = await getCurrentUserId(); - if (!userId) { - return { success: false, error: 'Authentication required' }; - } - - const { data, error } = await ( - supabase - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .from(DATABASE_TABLES.LOAN_PAYMENTS) as any - ) - .insert({ - ...request, - payer_id: userId, - }) - .select() - .single(); - - if (error) { - logger.error('Failed to create payment', error, 'Loans'); - return { success: false, error: error.message }; - } - - return { success: true, payment: data }; - } catch (error) { - logger.error('Exception creating payment', error, 'Loans'); - return { success: false, error: 'Failed to create payment' }; - } + return createPaymentViaApi(request); } /** - * Mark a payment as completed and return the updated payment + * Mark a payment completed (via POST /api/loans/payments/:id/complete). + * + * Pass `createObligation` on refinance payments to create the new obligation loan + * in the same request after funds are confirmed. */ -export async function completePayment(paymentId: string): Promise { - try { - const userId = await getCurrentUserId(); - if (!userId) { - return { success: false, error: 'Authentication required' }; - } - - const { data, error } = await ( - supabase - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .from(DATABASE_TABLES.LOAN_PAYMENTS) as any - ) - .update({ - status: STATUS.LOANS.COMPLETED, - processed_at: new Date().toISOString(), - }) - .eq('id', paymentId) - .select() - .single(); - - if (error) { - logger.error('Failed to complete payment', error, 'Loans'); - return { success: false, error: error.message }; - } - - return { success: true, payment: data }; - } catch (error) { - logger.error('Exception completing payment', error, 'Loans'); - return { success: false, error: 'Failed to complete payment' }; - } +export async function completePayment( + paymentId: string, + options?: { createObligation?: { lenderProfileName: string } } +): Promise { + return completePaymentViaApi(paymentId, options); } diff --git a/supabase/migrations/20260709000000_loan_payments_update_policy.sql b/supabase/migrations/20260709000000_loan_payments_update_policy.sql new file mode 100644 index 000000000..e686ea22d --- /dev/null +++ b/supabase/migrations/20260709000000_loan_payments_update_policy.sql @@ -0,0 +1,9 @@ +-- Allow payment parties to mark loan payments completed (payoff handoff). +-- INSERT/SELECT policies existed from baseline; UPDATE was missing, blocking +-- POST /api/loans/payments/:id/complete. + +CREATE POLICY "Payment parties can update payments" +ON public.loan_payments +FOR UPDATE +USING (auth.uid() = payer_id OR auth.uid() = recipient_id) +WITH CHECK (auth.uid() = payer_id OR auth.uid() = recipient_id);