Files
pn-new-crm/src/lib/services/expenses.ts
Matt f183f58b0c fix(audit-wave-10): types-auditor fixes — Tx type, BerthDetailData, parseBody, toAuditJson
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>
2026-05-13 12:27:08 +02:00

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;
}