Address the CRITICAL + high-leverage HIGH items from the types-auditor:
**C1 — `tx: any` in client-restore.service**
Export a canonical `Tx` type from `lib/db/utils.ts` (derived from
Drizzle's `db.transaction` callback shape) and use it in
`applyReversal` so the 12+ downstream tx writes get full inference.
**C2 — berth-detail page stacked `useQuery<any>` escape hatches**
Export `BerthDetailData` from berth-detail-header and consume it
through useQuery + apiFetch. Removed three `any` escapes in the
highest-traffic detail page. Also collapsed the duplicate `BerthData`
in berth-tabs.tsx to import from berth-detail-header so the two
types can't drift.
**C3 — parseBody migration for portal/public routes**
Replace raw `await req.json() + schema.parse(body)` with the
project-standard `parseBody(req, schema)` helper across 7 routes:
- portal/auth/{change-password, activate, reset-password}
- auth/set-password
- public/{interests, residential-inquiries}
Skipped the three anti-enumeration routes (forgot-password, sign-in,
sign-in-by-identifier) where the manual validation gives opaque
errors on purpose. website-inquiries already wraps the parse in a
custom 400 — left as-is.
**HIGH #5 — `toAuditJson<T>` helper (21 → 0 inline casts)**
Introduce `toAuditJson<T extends object>(row: T): Record<string,
unknown>` in lib/audit.ts (mirrors gdpr-bundle-builder's `toJsonRow`
that already exists for the same reason). Codemod 21 `<row> as unknown
as Record<string, unknown>` sites across:
- invoices.ts × 6
- expenses.ts × 6
- berths.service × 2
- documents.service × 2
- ocr-config.service × 2
- ai-budget.service × 2
- yachts.service, companies.service, company-memberships.service × 1 each
document-templates' `payload as unknown as Record<...>` is a different
shape (Documenso form-values widening, not an audit log) — kept the
manual cast there. Tests stay 1315/1315.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
373 lines
11 KiB
TypeScript
373 lines
11 KiB
TypeScript
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` <Combobox> on the expense
|
|
* form. Read-only — no mutation hooks.
|
|
*/
|
|
export async function listTripLabels(portId: string, search?: string): Promise<string[]> {
|
|
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<Date>`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<string, unknown> = { ...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<string, unknown>)
|
|
.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<string, unknown>)
|
|
.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;
|
|
}
|