import { eq, and, gte, lte, sql, isNull, ilike, isNotNull, desc, ne } from 'drizzle-orm'; import type { PgColumn } from 'drizzle-orm/pg-core'; import { db } from '@/lib/db'; import { expenses, invoices, invoiceExpenses } from '@/lib/db/schema/financial'; import { buildListQuery } from '@/lib/db/query-builder'; import { createAuditLog, toAuditJson, type AuditMeta } from '@/lib/audit'; import { diffEntity } from '@/lib/entity-diff'; import { softDelete, restore } from '@/lib/db/utils'; import { CodedError, NotFoundError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import { convert } from '@/lib/services/currency'; import { logger } from '@/lib/logger'; import type { CreateExpenseInput, UpdateExpenseInput, ListExpensesInput, } from '@/lib/validators/expenses'; export type { ListExpensesInput }; /** * Trim + collapse whitespace on free-text trip labels so "Palm Beach 2026" * and " Palm Beach 2026 " end up identical for the group-by + filter. * Empty / whitespace-only collapses to null. */ function normalizeTripLabel(input: string | null | undefined): string | null { if (input == null) return null; const trimmed = input.replace(/\s+/g, ' ').trim(); return trimmed.length > 0 ? trimmed : null; } /** * Distinct trip labels used in this port, ordered by most-recent first * so the autocomplete surfaces "what reps actually used lately" rather * than alphabetically. Powers the `tripLabel` on the expense * form. Read-only — no mutation hooks. */ export async function listTripLabels(portId: string, search?: string): Promise { const conditions = [ eq(expenses.portId, portId), isNotNull(expenses.tripLabel), ne(expenses.tripLabel, ''), isNull(expenses.archivedAt), ]; if (search && search.trim().length > 0) { conditions.push(ilike(expenses.tripLabel, `%${search.trim()}%`)); } const rows = await db .selectDistinct({ tripLabel: expenses.tripLabel, latest: sql`MAX(${expenses.expenseDate})`.as('latest'), }) .from(expenses) .where(and(...conditions)) .groupBy(expenses.tripLabel) .orderBy(desc(sql`MAX(${expenses.expenseDate})`)) .limit(50); return rows.map((r) => r.tripLabel as string).filter((s): s is string => Boolean(s)); } export async function listExpenses(portId: string, query: ListExpensesInput) { const filters = []; if (query.category) { filters.push(eq(expenses.category, query.category)); } if (query.paymentStatus) { filters.push(eq(expenses.paymentStatus, query.paymentStatus)); } if (query.currency) { filters.push(eq(expenses.currency, query.currency)); } if (query.payer) { filters.push(eq(expenses.payer, query.payer)); } if (query.tripLabel) { filters.push(eq(expenses.tripLabel, query.tripLabel)); } if (query.dateFrom) { filters.push(gte(expenses.expenseDate, new Date(query.dateFrom))); } if (query.dateTo) { filters.push(lte(expenses.expenseDate, new Date(query.dateTo))); } return buildListQuery({ table: expenses, portIdColumn: expenses.portId, portId, idColumn: expenses.id, updatedAtColumn: expenses.updatedAt, filters, page: query.page, pageSize: query.limit, searchColumns: [expenses.establishmentName, expenses.description], searchTerm: query.search, includeArchived: query.includeArchived, archivedAtColumn: expenses.archivedAt, sort: query.sort ? { column: expenses[query.sort as keyof typeof expenses] as unknown as PgColumn, direction: query.order, } : undefined, }); } export async function getExpenseById(id: string, portId: string) { const expense = await db.query.expenses.findFirst({ where: and(eq(expenses.id, id), eq(expenses.portId, portId)), }); if (!expense) throw new NotFoundError('Expense'); return expense; } export async function createExpense(portId: string, data: CreateExpenseInput, meta: AuditMeta) { let amountUsd: string | null = null; let exchangeRate: string | null = null; if (data.currency !== 'USD') { const conversion = await convert(data.amount, data.currency, 'USD'); if (conversion) { amountUsd = String(conversion.result); exchangeRate = String(conversion.rate); } else { // BR-040: if rate unavailable, save without conversion + log warning logger.warn( { currency: data.currency }, 'Currency rate unavailable, saving expense without USD conversion', ); } } else { amountUsd = String(data.amount); exchangeRate = '1'; } const [expense] = await db .insert(expenses) .values({ portId, establishmentName: data.establishmentName, amount: String(data.amount), currency: data.currency, amountUsd, exchangeRate, paymentMethod: data.paymentMethod, category: data.category, payer: data.payer, expenseDate: data.expenseDate, description: data.description, tripLabel: normalizeTripLabel(data.tripLabel), receiptFileIds: data.receiptFileIds ?? [], noReceiptAcknowledged: data.noReceiptAcknowledged ?? false, paymentStatus: data.paymentStatus, paymentDate: data.paymentDate ?? null, paymentReference: data.paymentReference ?? null, paymentNotes: data.paymentNotes ?? null, createdBy: meta.userId, }) .returning(); if (!expense) throw new CodedError('INSERT_RETURNING_EMPTY', { internalMessage: 'Expense insert returned no row', }); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'expense', entityId: expense.id, newValue: toAuditJson(expense), ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'expense:created', { expenseId: expense.id, amount: Number(expense.amount), currency: expense.currency, category: expense.category ?? '', }); // Schedule a duplicate-detection sweep. Best-effort - we don't want a // queue-side hiccup to fail the user's create. try { const { getQueue } = await import('@/lib/queue'); await getQueue('maintenance').add('expense-dedup-scan', { expenseId: expense.id }); } catch (err) { logger.warn({ err, expenseId: expense.id }, 'Failed to enqueue expense-dedup-scan'); } return expense; } export async function updateExpense( id: string, portId: string, data: UpdateExpenseInput, meta: AuditMeta, ) { const existing = await getExpenseById(id, portId); // The create-time validator enforces "receipt OR no-receipt-ack" via // `.refine`, but `updateExpenseSchema` is `.partial()` so the rule is // dropped. Re-assert it here against the merged (existing + patch) // shape so a PATCH can't slip an unexplained receipt-less expense // past the create-time guard. const mergedReceiptIds = data.receiptFileIds !== undefined ? data.receiptFileIds : existing.receiptFileIds; const mergedAck = data.noReceiptAcknowledged !== undefined ? data.noReceiptAcknowledged : existing.noReceiptAcknowledged; const hasReceipts = Array.isArray(mergedReceiptIds) && mergedReceiptIds.length > 0; if (!hasReceipts && !mergedAck) { throw new CodedError('EXPENSES_RECEIPT_REQUIRED'); } const updateData: Record = { ...data, updatedAt: new Date() }; if (data.tripLabel !== undefined) { updateData.tripLabel = normalizeTripLabel(data.tripLabel); } // Re-convert to USD if amount or currency changed const newAmount = data.amount ?? Number(existing.amount); const newCurrency = data.currency ?? existing.currency; if (data.amount !== undefined || data.currency !== undefined) { if (newCurrency !== 'USD') { const conversion = await convert(newAmount, newCurrency, 'USD'); if (conversion) { updateData.amountUsd = String(conversion.result); updateData.exchangeRate = String(conversion.rate); } else { logger.warn( { currency: newCurrency }, 'Currency rate unavailable during update, clearing USD conversion', ); updateData.amountUsd = null; updateData.exchangeRate = null; } } else { updateData.amountUsd = String(newAmount); updateData.exchangeRate = '1'; } } if (data.amount !== undefined) updateData.amount = String(data.amount); const { diff } = diffEntity(toAuditJson(existing), updateData); const [updated] = await db .update(expenses) .set(updateData as Record) .where(and(eq(expenses.id, id), eq(expenses.portId, portId))) .returning(); if (!updated) throw new NotFoundError('Expense'); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'expense', entityId: id, oldValue: toAuditJson(existing), newValue: toAuditJson(updated), metadata: { diff }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'expense:updated', { expenseId: id, changedFields: Object.keys(diff), }); return updated; } export async function archiveExpense(id: string, portId: string, meta: AuditMeta) { const existing = await getExpenseById(id, portId); // BR-045: Check if linked to non-draft invoice const linkedInvoice = await db .select({ invoiceId: invoiceExpenses.invoiceId }) .from(invoiceExpenses) .innerJoin(invoices, eq(invoices.id, invoiceExpenses.invoiceId)) .where(and(eq(invoiceExpenses.expenseId, id), sql`${invoices.status} != 'draft'`)) .limit(1); if (linkedInvoice.length > 0) { throw new CodedError('EXPENSES_INVOICE_LINKED'); } await softDelete(expenses, expenses.id, id); void createAuditLog({ userId: meta.userId, portId, action: 'archive', entityType: 'expense', entityId: id, oldValue: toAuditJson(existing), ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'expense:archived', { expenseId: id }); } export async function restoreExpense(id: string, portId: string, meta: AuditMeta) { await getExpenseById(id, portId); await restore(expenses, expenses.id, id); const restored = await getExpenseById(id, portId); void createAuditLog({ userId: meta.userId, portId, action: 'restore', entityType: 'expense', entityId: id, newValue: toAuditJson(restored), ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'expense:updated', { expenseId: id, changedFields: ['archivedAt'], }); return restored; } export async function addReceiptFiles( id: string, portId: string, fileIds: string[], meta: AuditMeta, ) { await getExpenseById(id, portId); const [updated] = await db .update(expenses) .set({ receiptFileIds: sql`array_cat(receipt_file_ids, ${fileIds}::text[])`, updatedAt: new Date(), } as Record) .where(and(eq(expenses.id, id), eq(expenses.portId, portId))) .returning(); if (!updated) throw new NotFoundError('Expense'); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'expense', entityId: id, metadata: { addedFileIds: fileIds }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return updated; }