From 4b1bdea0fee5b0cacd6803370cc1abdea77d7e0b Mon Sep 17 00:00:00 2001 From: Georgy Butaev <41178744+g-but@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:41:36 +0200 Subject: [PATCH] feat(loans): route offer mutations through API Moves loan offer create, update, and borrower response flows behind internal /api/loans/offers handlers so the entire loans write path now runs through API and domain validation instead of browser Supabase calls. Co-authored-by: Cursor --- __tests__/unit/domain/loans/offers.test.ts | 160 ++++++++++++++++ docs/AUDIT_REPORT.md | 2 +- docs/development/loans-flow.md | 7 +- .../api/loans/offers/[id]/respond/route.ts | 59 ++++++ src/app/api/loans/offers/[id]/route.ts | 57 ++++++ src/app/api/loans/offers/route.ts | 53 ++++++ src/config/api-routes.ts | 3 + src/config/loan-offers.ts | 46 +++++ src/domain/loans/offers.ts | 178 ++++++++++++++++++ src/services/loans/api-client.ts | 80 ++++++++ src/services/loans/mutations/offers.ts | 178 +----------------- 11 files changed, 651 insertions(+), 172 deletions(-) create mode 100644 __tests__/unit/domain/loans/offers.test.ts create mode 100644 src/app/api/loans/offers/[id]/respond/route.ts create mode 100644 src/app/api/loans/offers/[id]/route.ts create mode 100644 src/app/api/loans/offers/route.ts create mode 100644 src/config/loan-offers.ts create mode 100644 src/domain/loans/offers.ts diff --git a/__tests__/unit/domain/loans/offers.test.ts b/__tests__/unit/domain/loans/offers.test.ts new file mode 100644 index 000000000..6dd7b0872 --- /dev/null +++ b/__tests__/unit/domain/loans/offers.test.ts @@ -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', + }); + }); +}); diff --git a/docs/AUDIT_REPORT.md b/docs/AUDIT_REPORT.md index 2475050e1..6b8d2a36e 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]** 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) diff --git a/docs/development/loans-flow.md b/docs/development/loans-flow.md index 83eba7974..1f1d01820 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 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) @@ -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. @@ -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. diff --git a/src/app/api/loans/offers/[id]/respond/route.ts b/src/app/api/loans/offers/[id]/respond/route.ts new file mode 100644 index 000000000..c3dcd3c64 --- /dev/null +++ b/src/app/api/loans/offers/[id]/respond/route.ts @@ -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'); + } +}); diff --git a/src/app/api/loans/offers/[id]/route.ts b/src/app/api/loans/offers/[id]/route.ts new file mode 100644 index 000000000..0250db25c --- /dev/null +++ b/src/app/api/loans/offers/[id]/route.ts @@ -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'); + } +}); diff --git a/src/app/api/loans/offers/route.ts b/src/app/api/loans/offers/route.ts new file mode 100644 index 000000000..6b0806f90 --- /dev/null +++ b/src/app/api/loans/offers/route.ts @@ -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'); + } +}); diff --git a/src/config/api-routes.ts b/src/config/api-routes.ts index 6f377bda0..b1e77d862 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`, + 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`, diff --git a/src/config/loan-offers.ts b/src/config/loan-offers.ts new file mode 100644 index 000000000..efaf5ad34 --- /dev/null +++ b/src/config/loan-offers.ts @@ -0,0 +1,46 @@ +/** + * Loan offer contract SSOT — refinance and payoff offers. + * + * Created: 2026-07-09 + * Last Modified: 2026-07-09 + * Last Modified Summary: Initial schema set for /api/loans/offers routes. + */ + +import { z } from 'zod'; +import { STATUS } from '@/config/database-constants'; + +export const LOAN_OFFER_TYPES = ['refinance', 'payoff'] as const; + +export const createLoanOfferSchema = z + .object({ + loan_id: z.string().uuid('loan_id must be a UUID'), + offer_type: z.enum(LOAN_OFFER_TYPES), + offer_amount: z.number().positive('offer_amount must be greater than 0'), + interest_rate: z.number().min(0).max(100).optional(), + term_months: z.number().int().min(1).max(360).optional(), + terms: z.string().max(2000).optional(), + conditions: z.string().max(1000).optional(), + is_binding: z.boolean().optional(), + }) + .refine( + data => data.offer_type === 'payoff' || (data.interest_rate !== undefined && data.term_months), + { + message: 'Refinance offers require interest_rate and term_months', + path: ['interest_rate'], + } + ); + +export const updateLoanOfferSchema = z.object({ + status: z.enum([ + STATUS.LOAN_OFFERS.PENDING, + STATUS.LOAN_OFFERS.ACCEPTED, + STATUS.LOAN_OFFERS.REJECTED, + STATUS.LOAN_OFFERS.EXPIRED, + STATUS.LOAN_OFFERS.CANCELLED, + ]), +}); + +export const respondToLoanOfferSchema = z.object({ + accept: z.boolean(), + notes: z.string().max(1000).optional(), +}); diff --git a/src/domain/loans/offers.ts b/src/domain/loans/offers.ts new file mode 100644 index 000000000..9c7bb1cc1 --- /dev/null +++ b/src/domain/loans/offers.ts @@ -0,0 +1,178 @@ +/** + * Loan offer domain — create, update, and borrower response flows. + */ + +import { STATUS } from '@/config/database-constants'; +import { DATABASE_TABLES } from '@/config/database-tables'; +import { getTableName } from '@/config/entity-registry'; +import type { AnySupabaseClient } from '@/lib/supabase/types'; +import { logger } from '@/utils/logger'; +import type { CreateLoanOfferRequest, UpdateLoanOfferRequest } from '@/types/loans'; + +export type LoanOfferRow = Record & { id: string }; + +type OfferResult = + | { ok: true; offer: LoanOfferRow } + | { + ok: false; + reason: + | 'loan_not_found' + | 'forbidden' + | 'below_minimum' + | 'not_found' + | 'invalid_state' + | 'error'; + message: string; + }; + +type LoanLookup = { + id: string; + user_id: string; + status: string | null; + minimum_offer_amount: number | null; +}; + +async function loadLoan( + supabase: AnySupabaseClient, + loanId: string +): Promise<{ loan: LoanLookup | null; error: unknown }> { + const { data, error } = await supabase + .from(getTableName('loan')) + .select('id, user_id, status, minimum_offer_amount') + .eq('id', loanId) + .maybeSingle(); + return { loan: (data as LoanLookup | null) ?? null, error }; +} + +export async function createLoanOffer( + userId: string, + input: CreateLoanOfferRequest, + supabase: AnySupabaseClient +): Promise { + const { loan, error } = await loadLoan(supabase, input.loan_id); + if (error) { + logger.error('Failed to verify loan for offer creation', error, 'LoanOffers'); + return { ok: false, reason: 'error', message: 'Failed to verify loan' }; + } + if (!loan || loan.status !== STATUS.LOANS.ACTIVE) { + return { ok: false, reason: 'loan_not_found', message: 'Loan not found or not active' }; + } + if (loan.user_id === userId) { + return { ok: false, reason: 'forbidden', message: 'Cannot offer on your own loan' }; + } + if ( + loan.minimum_offer_amount !== null && + loan.minimum_offer_amount !== undefined && + input.offer_amount < loan.minimum_offer_amount + ) { + return { + ok: false, + reason: 'below_minimum', + message: 'Offer amount below minimum required', + }; + } + + const { data, error: insertError } = await supabase + .from(DATABASE_TABLES.LOAN_OFFERS) + .insert({ + loan_id: input.loan_id, + offerer_id: userId, + offer_type: input.offer_type, + offer_amount: input.offer_amount, + interest_rate: input.interest_rate ?? null, + term_months: input.term_months ?? null, + terms: input.terms ?? null, + conditions: input.conditions ?? null, + is_binding: input.is_binding ?? false, + status: STATUS.LOAN_OFFERS.PENDING, + }) + .select('*') + .single(); + + if (insertError) { + logger.error('Failed to create loan offer', insertError, 'LoanOffers'); + return { ok: false, reason: 'error', message: 'Failed to create offer' }; + } + + return { ok: true, offer: data as LoanOfferRow }; +} + +export async function updateLoanOffer( + userId: string, + offerId: string, + input: UpdateLoanOfferRequest, + supabase: AnySupabaseClient +): Promise { + const { data, error } = await supabase + .from(DATABASE_TABLES.LOAN_OFFERS) + .update(input) + .eq('id', offerId) + .eq('offerer_id', userId) + .select('*') + .maybeSingle(); + + if (error) { + logger.error('Failed to update loan offer', error, 'LoanOffers'); + return { ok: false, reason: 'error', message: 'Failed to update offer' }; + } + if (!data) { + return { ok: false, reason: 'not_found', message: 'Offer not found' }; + } + + return { ok: true, offer: data as LoanOfferRow }; +} + +export async function respondToLoanOffer( + userId: string, + offerId: string, + accept: boolean, + supabase: AnySupabaseClient +): Promise { + const { data: offer, error: fetchError } = await supabase + .from(DATABASE_TABLES.LOAN_OFFERS) + .select('id, loan_id, status, loans!inner(user_id)') + .eq('id', offerId) + .maybeSingle(); + + if (fetchError) { + logger.error('Failed to fetch loan offer', fetchError, 'LoanOffers'); + return { ok: false, reason: 'error', message: 'Failed to load offer' }; + } + if (!offer) { + return { ok: false, reason: 'not_found', message: 'Offer not found' }; + } + + const joinedLoans = (offer as { loans: Array<{ user_id: string }> | { user_id: string } }).loans; + const ownerUserId = Array.isArray(joinedLoans) ? joinedLoans[0]?.user_id : joinedLoans.user_id; + if (ownerUserId !== userId) { + return { ok: false, reason: 'forbidden', message: 'Unauthorized to respond to this offer' }; + } + if ((offer as { status: string | null }).status !== STATUS.LOAN_OFFERS.PENDING) { + return { + ok: false, + reason: 'invalid_state', + message: 'Only pending offers can be accepted or rejected', + }; + } + + const status = accept ? STATUS.LOAN_OFFERS.ACCEPTED : STATUS.LOAN_OFFERS.REJECTED; + const now = new Date().toISOString(); + const updateData: Record = { + status, + ...(accept ? { accepted_at: now, rejected_at: null } : { rejected_at: now, accepted_at: null }), + }; + + const { data, error } = await supabase + .from(DATABASE_TABLES.LOAN_OFFERS) + .update(updateData) + .eq('id', offerId) + .select('*') + .single(); + + if (error) { + logger.error('Failed to respond to loan offer', error, 'LoanOffers'); + return { ok: false, reason: 'error', message: 'Failed to respond to offer' }; + } + + return { ok: true, offer: data as LoanOfferRow }; +} diff --git a/src/services/loans/api-client.ts b/src/services/loans/api-client.ts index 091670a49..15cda2afa 100644 --- a/src/services/loans/api-client.ts +++ b/src/services/loans/api-client.ts @@ -10,7 +10,10 @@ import { logger } from '@/utils/logger'; import type { Loan, CreateLoanRequest, + CreateLoanOfferRequest, + LoanOfferResponse, UpdateLoanRequest, + UpdateLoanOfferRequest, LoanResponse, CreateLoanPaymentRequest, LoanPaymentResponse, @@ -104,6 +107,17 @@ async function parseLoanEnvelope(res: Response): Promise { return { success: true, loan: json.data }; } +async function parseOfferEnvelope(res: Response): Promise { + const json = (await res.json().catch(() => ({}))) as ApiEnvelope; + 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' }; + } + return { success: true, offer: json.data }; +} + export async function getLoanViaApi(loanId: string): Promise { try { const res = await fetch(API_ROUTES.LOANS.BY_ID(loanId), { credentials: 'include' }); @@ -204,6 +218,72 @@ export async function createObligationLoanViaApi(params: { } } +export async function createLoanOfferViaApi( + request: CreateLoanOfferRequest +): Promise { + try { + const res = await fetch(API_ROUTES.LOANS.OFFERS, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(request), + }); + const result = await parseOfferEnvelope(res); + if (result.success) { + logger.info('Loan offer created via API', { offerId: result.offer?.id }, 'Loans'); + } + return result; + } catch (error) { + logger.error('createLoanOfferViaApi failed', error, 'Loans'); + return { success: false, error: 'Failed to create offer' }; + } +} + +export async function updateLoanOfferViaApi( + offerId: string, + request: UpdateLoanOfferRequest +): Promise { + try { + const res = await fetch(API_ROUTES.LOANS.OFFER_BY_ID(offerId), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify(request), + }); + const result = await parseOfferEnvelope(res); + if (result.success) { + logger.info('Loan offer updated via API', { offerId }, 'Loans'); + } + return result; + } catch (error) { + logger.error('updateLoanOfferViaApi failed', error, 'Loans'); + return { success: false, error: 'Failed to update offer' }; + } +} + +export async function respondToOfferViaApi( + offerId: string, + accept: boolean, + notes?: string +): Promise { + try { + const res = await fetch(API_ROUTES.LOANS.OFFER_RESPOND(offerId), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ accept, notes }), + }); + const result = await parseOfferEnvelope(res); + if (result.success) { + logger.info('Loan offer responded via API', { offerId, accept }, 'Loans'); + } + return result; + } catch (error) { + logger.error('respondToOfferViaApi failed', error, 'Loans'); + return { success: false, error: 'Failed to respond to offer' }; + } +} + export async function createPaymentViaApi( request: CreateLoanPaymentRequest ): Promise { diff --git a/src/services/loans/mutations/offers.ts b/src/services/loans/mutations/offers.ts index 2f10f20df..ecde21196 100644 --- a/src/services/loans/mutations/offers.ts +++ b/src/services/loans/mutations/offers.ts @@ -1,96 +1,25 @@ /** * LOANS SERVICE - Loan Offer Mutations * - * Created: 2025-01-30 - * Last Modified: 2025-01-30 - * Last Modified Summary: Extracted from loans/index.ts for modularity + * All offer writes route through /api/loans/offers. */ -import supabase from '@/lib/supabase/browser'; -import { DATABASE_TABLES } from '@/config/database-tables'; -import { logger } from '@/utils/logger'; import type { CreateLoanOfferRequest, UpdateLoanOfferRequest, LoanOfferResponse, } from '@/types/loans'; -import { getCurrentUserId } from '../utils/auth'; +import { + createLoanOfferViaApi, + updateLoanOfferViaApi, + respondToOfferViaApi, +} from '@/services/loans/api-client'; /** * Create a loan offer */ export async function createLoanOffer(request: CreateLoanOfferRequest): Promise { - try { - const userId = await getCurrentUserId(); - if (!userId) { - return { success: false, error: 'Authentication required' }; - } - - // Use database function if available - try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { data, error } = await (supabase.rpc as any)('create_loan_offer', { - p_loan_id: request.loan_id, - p_offerer_id: userId, - p_offer_type: request.offer_type, - p_offer_amount: request.offer_amount, - p_interest_rate: request.interest_rate, - p_term_months: request.term_months, - p_terms: request.terms, - }); - - if (error) { - logger.warn('Database function failed, using fallback', error, 'Loans'); - throw error; - } - - if (!data?.success) { - return { success: false, error: data?.error || 'Failed to create offer' }; - } - - // Get the created offer - const { data: offer, error: fetchError } = await ( - supabase - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .from(DATABASE_TABLES.LOAN_OFFERS) as any - ) - .select() - .eq('id', data.offer_id) - .single(); - - if (fetchError) { - logger.error('Failed to fetch created offer', fetchError, 'Loans'); - return { success: false, error: 'Offer created but failed to retrieve' }; - } - - return { success: true, offer }; - } catch (dbError) { - logger.warn('Using fallback offer creation', dbError, 'Loans'); - - // Fallback: direct insert - const { data, error } = await ( - supabase - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .from(DATABASE_TABLES.LOAN_OFFERS) as any - ) - .insert({ - ...request, - offerer_id: userId, - }) - .select() - .single(); - - if (error) { - logger.error('Fallback offer creation failed', error, 'Loans'); - return { success: false, error: error.message }; - } - - return { success: true, offer: data }; - } - } catch (error) { - logger.error('Exception creating loan offer', error, 'Loans'); - return { success: false, error: 'Failed to create offer' }; - } + return createLoanOfferViaApi(request); } /** @@ -100,33 +29,7 @@ export async function updateLoanOffer( offerId: string, request: UpdateLoanOfferRequest ): 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_OFFERS) as any - ) - .update(request) - .eq('id', offerId) - .eq('offerer_id', userId) - .select() - .single(); - - if (error) { - logger.error('Failed to update loan offer', error, 'Loans'); - return { success: false, error: error.message }; - } - - return { success: true, offer: data }; - } catch (error) { - logger.error('Exception updating loan offer', error, 'Loans'); - return { success: false, error: 'Failed to update offer' }; - } + return updateLoanOfferViaApi(offerId, request); } /** @@ -135,68 +38,7 @@ export async function updateLoanOffer( export async function respondToOffer( offerId: string, accept: boolean, - _notes?: string + notes?: string ): Promise { - try { - const userId = await getCurrentUserId(); - if (!userId) { - return { success: false, error: 'Authentication required' }; - } - - // Verify user owns the loan - const { data: offer, error: fetchError } = await ( - supabase - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .from(DATABASE_TABLES.LOAN_OFFERS) as any - ) - .select( - ` - *, - loans!inner(user_id) - ` - ) - .eq('id', offerId) - .single(); - - if (fetchError || !offer) { - return { success: false, error: 'Offer not found' }; - } - - if ((offer.loans as { user_id: string }).user_id !== userId) { - return { success: false, error: 'Unauthorized to respond to this offer' }; - } - - const status = accept ? 'accepted' : 'rejected'; - const updateData: Record = { - status, - updated_at: new Date().toISOString(), - }; - - if (accept) { - updateData.accepted_at = new Date().toISOString(); - } else { - updateData.rejected_at = new Date().toISOString(); - } - - const { data, error } = await ( - supabase - // eslint-disable-next-line @typescript-eslint/no-explicit-any - .from(DATABASE_TABLES.LOAN_OFFERS) as any - ) - .update(updateData) - .eq('id', offerId) - .select() - .single(); - - if (error) { - logger.error('Failed to respond to offer', error, 'Loans'); - return { success: false, error: error.message }; - } - - logger.info(`Offer ${status}`, { offerId, loanId: offer.loan_id }, 'Loans'); - return { success: true, offer: data }; - } catch (error) { - logger.error('Exception responding to offer', error, 'Loans'); - return { success: false, error: 'Failed to respond to offer' }; - } + return respondToOfferViaApi(offerId, accept, notes); }