308 lines
8.4 KiB
TypeScript
308 lines
8.4 KiB
TypeScript
|
|
import { eq, and, gte, lte, sql } from 'drizzle-orm';
|
||
|
|
|
||
|
|
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 any, 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<string, unknown>,
|
||
|
|
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<string, unknown> = { ...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<string, unknown>, updateData);
|
||
|
|
|
||
|
|
const [updated] = await db
|
||
|
|
.update(expenses)
|
||
|
|
.set(updateData as any)
|
||
|
|
.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<string, unknown>,
|
||
|
|
newValue: updated as unknown as Record<string, unknown>,
|
||
|
|
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<string, unknown>,
|
||
|
|
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<string, unknown>,
|
||
|
|
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 any)
|
||
|
|
.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;
|
||
|
|
}
|