Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
307
src/lib/services/expenses.ts
Normal file
307
src/lib/services/expenses.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user