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>
2026-03-26 11:52:51 +01:00
|
|
|
import { eq, and, gte, lte, sql } from 'drizzle-orm';
|
2026-03-26 12:29:55 +01:00
|
|
|
import type { PgColumn } from 'drizzle-orm/pg-core';
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
import { db } from '@/lib/db';
|
|
|
|
|
import { expenses, invoices, invoiceExpenses } from '@/lib/db/schema/financial';
|
|
|
|
|
import { buildListQuery } from '@/lib/db/query-builder';
|
2026-04-29 01:58:42 +02:00
|
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
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';
|
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.
PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
right-rail, three-tab page (active/dismissed/resolved), socket-driven
invalidation. Bell lazy-loads list on popover open to keep cold pages
fast in non-dashboard routes.
PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count
surfaces in tab label.
PR7 Interests-by-berth tab on berth detail — replaces the stub.
PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow
banner on detail w/ Merge / Not-a-duplicate, transactional merge
consolidates receipts and archives the source.
PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
its own (scanner) group with no dashboard chrome, dynamic per-port
manifest, OpenAI + Claude provider abstraction, admin OCR settings
page (port-level + super-admin global default w/ opt-in fallback),
test-connection endpoint, manual-entry fallback when no key is
configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
existing GIN index, cursor pagination, filters for entity/action/user
/date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
cleanly without their gate envs so CI stays green.
Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
|
|
|
import type {
|
|
|
|
|
CreateExpenseInput,
|
|
|
|
|
UpdateExpenseInput,
|
|
|
|
|
ListExpensesInput,
|
|
|
|
|
} from '@/lib/validators/expenses';
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
|
|
|
|
|
export type { ListExpensesInput };
|
|
|
|
|
|
|
|
|
|
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
|
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.
PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
right-rail, three-tab page (active/dismissed/resolved), socket-driven
invalidation. Bell lazy-loads list on popover open to keep cold pages
fast in non-dashboard routes.
PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count
surfaces in tab label.
PR7 Interests-by-berth tab on berth detail — replaces the stub.
PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow
banner on detail w/ Merge / Not-a-duplicate, transactional merge
consolidates receipts and archives the source.
PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
its own (scanner) group with no dashboard chrome, dynamic per-port
manifest, OpenAI + Claude provider abstraction, admin OCR settings
page (port-level + super-admin global default w/ opt-in fallback),
test-connection endpoint, manual-entry fallback when no key is
configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
existing GIN index, cursor pagination, filters for entity/action/user
/date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
cleanly without their gate envs so CI stays green.
Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
|
|
|
? {
|
|
|
|
|
column: expenses[query.sort as keyof typeof expenses] as unknown as PgColumn,
|
|
|
|
|
direction: query.order,
|
|
|
|
|
}
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
: 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 01:58:42 +02:00
|
|
|
export async function createExpense(portId: string, data: CreateExpenseInput, meta: AuditMeta) {
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
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
|
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.
PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
right-rail, three-tab page (active/dismissed/resolved), socket-driven
invalidation. Bell lazy-loads list on popover open to keep cold pages
fast in non-dashboard routes.
PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count
surfaces in tab label.
PR7 Interests-by-berth tab on berth detail — replaces the stub.
PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow
banner on detail w/ Merge / Not-a-duplicate, transactional merge
consolidates receipts and archives the source.
PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
its own (scanner) group with no dashboard chrome, dynamic per-port
manifest, OpenAI + Claude provider abstraction, admin OCR settings
page (port-level + super-admin global default w/ opt-in fallback),
test-connection endpoint, manual-entry fallback when no key is
configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
existing GIN index, cursor pagination, filters for entity/action/user
/date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
cleanly without their gate envs so CI stays green.
Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
|
|
|
logger.warn(
|
|
|
|
|
{ currency: data.currency },
|
|
|
|
|
'Currency rate unavailable, saving expense without USD conversion',
|
|
|
|
|
);
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
}
|
|
|
|
|
} 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 ?? '',
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-04 22:57:01 +02:00
|
|
|
// Schedule a duplicate-detection sweep. Best-effort - we don't want a
|
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.
PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
right-rail, three-tab page (active/dismissed/resolved), socket-driven
invalidation. Bell lazy-loads list on popover open to keep cold pages
fast in non-dashboard routes.
PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count
surfaces in tab label.
PR7 Interests-by-berth tab on berth detail — replaces the stub.
PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow
banner on detail w/ Merge / Not-a-duplicate, transactional merge
consolidates receipts and archives the source.
PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
its own (scanner) group with no dashboard chrome, dynamic per-port
manifest, OpenAI + Claude provider abstraction, admin OCR settings
page (port-level + super-admin global default w/ opt-in fallback),
test-connection endpoint, manual-entry fallback when no key is
configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
existing GIN index, cursor pagination, filters for entity/action/user
/date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
cleanly without their gate envs so CI stays green.
Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
|
|
|
// 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');
|
|
|
|
|
}
|
|
|
|
|
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
return expense;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function updateExpense(
|
|
|
|
|
id: string,
|
|
|
|
|
portId: string,
|
|
|
|
|
data: UpdateExpenseInput,
|
2026-04-29 01:58:42 +02:00
|
|
|
meta: AuditMeta,
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
) {
|
|
|
|
|
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 {
|
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.
PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
right-rail, three-tab page (active/dismissed/resolved), socket-driven
invalidation. Bell lazy-loads list on popover open to keep cold pages
fast in non-dashboard routes.
PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count
surfaces in tab label.
PR7 Interests-by-berth tab on berth detail — replaces the stub.
PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow
banner on detail w/ Merge / Not-a-duplicate, transactional merge
consolidates receipts and archives the source.
PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
its own (scanner) group with no dashboard chrome, dynamic per-port
manifest, OpenAI + Claude provider abstraction, admin OCR settings
page (port-level + super-admin global default w/ opt-in fallback),
test-connection endpoint, manual-entry fallback when no key is
configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
existing GIN index, cursor pagination, filters for entity/action/user
/date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
cleanly without their gate envs so CI stays green.
Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
|
|
|
logger.warn(
|
|
|
|
|
{ currency: newCurrency },
|
|
|
|
|
'Currency rate unavailable during update, clearing USD conversion',
|
|
|
|
|
);
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
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)
|
2026-03-26 12:06:18 +01:00
|
|
|
.set(updateData as Record<string, unknown>)
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
.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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 01:58:42 +02:00
|
|
|
export async function archiveExpense(id: string, portId: string, meta: AuditMeta) {
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
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))
|
feat(phase-b): ship analytics dashboard, alerts, scanner PWA, dedup, audit view
Phase B (Insights & Alerts) PR4-11 in one drop. Builds on the schema +
service skeletons committed in PRs 1-3.
PR4 Analytics dashboard — 4 chart types (funnel/timeline/breakdown/source),
date-range picker (today/7d/30d/90d), CSV+PNG export per card.
PR5 Alert rail UI + /alerts page — topbar bell w/ live count, dashboard
right-rail, three-tab page (active/dismissed/resolved), socket-driven
invalidation. Bell lazy-loads list on popover open to keep cold pages
fast in non-dashboard routes.
PR6 EOI queue tab on documents hub — filters to in-flight EOIs, count
surfaces in tab label.
PR7 Interests-by-berth tab on berth detail — replaces the stub.
PR8 Expense duplicate detection — BullMQ job runs scan on create, yellow
banner on detail w/ Merge / Not-a-duplicate, transactional merge
consolidates receipts and archives the source.
PR9 Receipt scanner PWA + multi-provider AI — port-scoped /scan route in
its own (scanner) group with no dashboard chrome, dynamic per-port
manifest, OpenAI + Claude provider abstraction, admin OCR settings
page (port-level + super-admin global default w/ opt-in fallback),
test-connection endpoint, manual-entry fallback when no key is
configured. Verify form always shown before save — no ghost rows.
PR10 Audit log read view — swap to tsvector full-text search on the
existing GIN index, cursor pagination, filters for entity/action/user
/date range, batched actor-email resolution.
PR11 Real-API tests — opt-in receipt-ocr.spec (admin save+test, optional
real-receipt parse via REALAPI_RECEIPT_FIXTURE) and alert-engine
socket-fanout spec gated behind RUN_ALERT_ENGINE_REALAPI. Both skip
cleanly without their gate envs so CI stays green.
Test totals: vitest 690 -> 713, smoke 130 -> 138, realapi +2 opt-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:21:55 +02:00
|
|
|
.where(and(eq(invoiceExpenses.expenseId, id), sql`${invoices.status} != 'draft'`))
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
.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 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-29 01:58:42 +02:00
|
|
|
export async function restoreExpense(id: string, portId: string, meta: AuditMeta) {
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
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[],
|
2026-04-29 01:58:42 +02:00
|
|
|
meta: AuditMeta,
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
) {
|
|
|
|
|
await getExpenseById(id, portId);
|
|
|
|
|
|
|
|
|
|
const [updated] = await db
|
|
|
|
|
.update(expenses)
|
|
|
|
|
.set({
|
|
|
|
|
receiptFileIds: sql`array_cat(receipt_file_ids, ${fileIds}::text[])`,
|
|
|
|
|
updatedAt: new Date(),
|
2026-03-26 12:06:18 +01:00
|
|
|
} as Record<string, unknown>)
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
.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;
|
|
|
|
|
}
|