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 } 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 }; // AuditMeta type expected by service functions export interface ServiceAuditMeta { userId: string; portId: string; ipAddress: string; userAgent: string; } 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: ServiceAuditMeta, ) { 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 ?? [], 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 ?? '', }); return expense; } export async function updateExpense( id: string, portId: string, data: UpdateExpenseInput, meta: ServiceAuditMeta, ) { const existing = await getExpenseById(id, portId); 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: ServiceAuditMeta, ) { 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: ServiceAuditMeta, ) { 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: ServiceAuditMeta, ) { 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; }