import { eq, and, gte, lte, sql } 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, type AuditMeta } from '@/lib/audit'; import { diffEntity } from '@/lib/entity-diff'; import { softDelete, restore } from '@/lib/db/utils'; import { NotFoundError, ConflictError } 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 }; 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.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, 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 Error('Insert failed'); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'expense', entityId: expense.id, newValue: expense as unknown as Record, 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 ConflictError( 'Expense must either link a receipt file or acknowledge the no-receipt warning.', ); } const updateData: Record = { ...data, updatedAt: new Date() }; // 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(existing as unknown as Record, 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: existing as unknown as Record, newValue: updated as unknown as Record, 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 ConflictError('Cannot archive expense linked to a non-draft invoice'); } await softDelete(expenses, expenses.id, id); void createAuditLog({ userId: meta.userId, portId, action: 'archive', entityType: 'expense', entityId: id, oldValue: existing as unknown as Record, 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: restored as unknown as Record, 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; }