+
+
+ ) : null}
+
+ );
+}
diff --git a/src/lib/queue/workers/maintenance.ts b/src/lib/queue/workers/maintenance.ts
index 4f14272..e6ce00e 100644
--- a/src/lib/queue/workers/maintenance.ts
+++ b/src/lib/queue/workers/maintenance.ts
@@ -48,6 +48,17 @@ export const maintenanceWorker = new Worker(
logger.info({ count: allPorts.length }, 'Analytics snapshot refresh complete');
break;
}
+ case 'expense-dedup-scan': {
+ const { expenseId } = job.data as { expenseId: string };
+ if (!expenseId) {
+ logger.warn({ jobId: job.id }, 'expense-dedup-scan missing expenseId');
+ break;
+ }
+ const { markBestDuplicate } = await import('@/lib/services/expense-dedup.service');
+ const matchedId = await markBestDuplicate(expenseId);
+ logger.info({ expenseId, matchedId: matchedId ?? null }, 'expense-dedup-scan complete');
+ break;
+ }
default:
logger.warn({ jobName: job.name }, 'Unknown maintenance job');
}
diff --git a/src/lib/services/audit-search.service.ts b/src/lib/services/audit-search.service.ts
index c8aea97..19453d5 100644
--- a/src/lib/services/audit-search.service.ts
+++ b/src/lib/services/audit-search.service.ts
@@ -50,8 +50,11 @@ export async function searchAuditLogs(options: AuditSearchOptions = {}): Promise
}
if (options.cursor) {
// Strict less-than on (createdAt, id) for stable cursor pagination.
+ // ISO-stringify the date so postgres-js binds it cleanly inside a tuple
+ // comparison; raw Date objects throw under postgres@3.x parameter binding.
+ const cursorAt = options.cursor.createdAt.toISOString();
conds.push(
- sql`(${auditLogs.createdAt}, ${auditLogs.id}) < (${options.cursor.createdAt}, ${options.cursor.id})`,
+ sql`(${auditLogs.createdAt}, ${auditLogs.id}) < (${cursorAt}::timestamptz, ${options.cursor.id})`,
);
}
diff --git a/src/lib/services/documents.service.ts b/src/lib/services/documents.service.ts
index 9cb5391..f58ca22 100644
--- a/src/lib/services/documents.service.ts
+++ b/src/lib/services/documents.service.ts
@@ -60,6 +60,13 @@ function buildHubTabFilters(
if (!tab || tab === 'all') return filters;
switch (tab) {
+ case 'eoi_queue':
+ // EOI documents currently in-flight (drafted, sent, or partially signed).
+ // Used by the dedicated tab on the documents hub to triage EOI signing
+ // pipeline volume separate from the all-doc-types view.
+ filters.push(eq(documents.documentType, 'eoi'));
+ filters.push(inArray(documents.status, ['draft', 'sent', 'partially_signed']));
+ break;
case 'awaiting_them':
// "awaiting them" = pending signers other than the current user.
// Without a known caller email we cannot make that distinction, so
@@ -209,6 +216,7 @@ export async function listDocuments(
export interface HubTabCounts {
all: number;
+ eoi_queue: number;
awaiting_them: number;
awaiting_me: number;
completed: number;
@@ -233,15 +241,16 @@ export async function getHubTabCounts(
return row?.count ?? 0;
}
- const [all, awaiting_them, awaiting_me, completed, expired] = await Promise.all([
+ const [all, eoi_queue, awaiting_them, awaiting_me, completed, expired] = await Promise.all([
tabCount('all'),
+ tabCount('eoi_queue'),
tabCount('awaiting_them'),
tabCount('awaiting_me'),
tabCount('completed'),
tabCount('expired'),
]);
- return { all, awaiting_them, awaiting_me, completed, expired };
+ return { all, eoi_queue, awaiting_them, awaiting_me, completed, expired };
}
// ─── Get by ID ────────────────────────────────────────────────────────────────
diff --git a/src/lib/services/expense-dedup.service.ts b/src/lib/services/expense-dedup.service.ts
index e89f729..43c1a44 100644
--- a/src/lib/services/expense-dedup.service.ts
+++ b/src/lib/services/expense-dedup.service.ts
@@ -69,3 +69,59 @@ export async function markBestDuplicate(expenseId: string): Promise {
+ await db
+ .update(expenses)
+ .set({ duplicateOf: null, dedupScannedAt: sql`now()` })
+ .where(and(eq(expenses.id, expenseId), eq(expenses.portId, portId)));
+}
+
+/**
+ * Merge `sourceId` into `targetId`: combine receipt files, archive the
+ * source, and clear the duplicate-of pointer. Both rows must belong to
+ * the same port; runs inside a single transaction so a partial failure
+ * leaves both rows untouched.
+ */
+export async function mergeDuplicate(
+ sourceId: string,
+ targetId: string,
+ portId: string,
+): Promise {
+ if (sourceId === targetId) {
+ throw new Error('Cannot merge an expense into itself');
+ }
+
+ await db.transaction(async (tx) => {
+ const [source] = await tx
+ .select()
+ .from(expenses)
+ .where(and(eq(expenses.id, sourceId), eq(expenses.portId, portId)));
+ const [target] = await tx
+ .select()
+ .from(expenses)
+ .where(and(eq(expenses.id, targetId), eq(expenses.portId, portId)));
+ if (!source || !target) {
+ throw new Error('Source or target expense not found in this port');
+ }
+
+ const mergedReceipts = Array.from(
+ new Set([...(target.receiptFileIds ?? []), ...(source.receiptFileIds ?? [])]),
+ );
+
+ await tx
+ .update(expenses)
+ .set({ receiptFileIds: mergedReceipts })
+ .where(eq(expenses.id, targetId));
+
+ // Archive the source — preserves audit history, keeps any FKs alive.
+ await tx
+ .update(expenses)
+ .set({ archivedAt: sql`now()`, duplicateOf: null })
+ .where(eq(expenses.id, sourceId));
+ });
+}
diff --git a/src/lib/services/expenses.ts b/src/lib/services/expenses.ts
index 4297fb9..95c92dd 100644
--- a/src/lib/services/expenses.ts
+++ b/src/lib/services/expenses.ts
@@ -11,7 +11,11 @@ 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';
+import type {
+ CreateExpenseInput,
+ UpdateExpenseInput,
+ ListExpensesInput,
+} from '@/lib/validators/expenses';
export type { ListExpensesInput };
@@ -59,7 +63,10 @@ export async function listExpenses(portId: string, query: ListExpensesInput) {
includeArchived: query.includeArchived,
archivedAtColumn: expenses.archivedAt,
sort: query.sort
- ? { column: expenses[query.sort as keyof typeof expenses] as unknown as PgColumn, direction: query.order }
+ ? {
+ column: expenses[query.sort as keyof typeof expenses] as unknown as PgColumn,
+ direction: query.order,
+ }
: undefined,
});
}
@@ -87,7 +94,10 @@ export async function createExpense(
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');
+ logger.warn(
+ { currency: data.currency },
+ 'Currency rate unavailable, saving expense without USD conversion',
+ );
}
} else {
amountUsd = String(data.amount);
@@ -137,6 +147,15 @@ export async function createExpense(
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;
}
@@ -161,7 +180,10 @@ export async function updateExpense(
updateData.amountUsd = String(conversion.result);
updateData.exchangeRate = String(conversion.rate);
} else {
- logger.warn({ currency: newCurrency }, 'Currency rate unavailable during update, clearing USD conversion');
+ logger.warn(
+ { currency: newCurrency },
+ 'Currency rate unavailable during update, clearing USD conversion',
+ );
updateData.amountUsd = null;
updateData.exchangeRate = null;
}
@@ -204,11 +226,7 @@ export async function updateExpense(
return updated;
}
-export async function archiveExpense(
- id: string,
- portId: string,
- meta: ServiceAuditMeta,
-) {
+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
@@ -216,12 +234,7 @@ export async function archiveExpense(
.select({ invoiceId: invoiceExpenses.invoiceId })
.from(invoiceExpenses)
.innerJoin(invoices, eq(invoices.id, invoiceExpenses.invoiceId))
- .where(
- and(
- eq(invoiceExpenses.expenseId, id),
- sql`${invoices.status} != 'draft'`,
- ),
- )
+ .where(and(eq(invoiceExpenses.expenseId, id), sql`${invoices.status} != 'draft'`))
.limit(1);
if (linkedInvoice.length > 0) {
@@ -244,11 +257,7 @@ export async function archiveExpense(
emitToRoom(`port:${portId}`, 'expense:archived', { expenseId: id });
}
-export async function restoreExpense(
- id: string,
- portId: string,
- meta: ServiceAuditMeta,
-) {
+export async function restoreExpense(id: string, portId: string, meta: ServiceAuditMeta) {
await getExpenseById(id, portId);
await restore(expenses, expenses.id, id);
diff --git a/src/lib/services/ocr-config.service.ts b/src/lib/services/ocr-config.service.ts
new file mode 100644
index 0000000..5f33dac
--- /dev/null
+++ b/src/lib/services/ocr-config.service.ts
@@ -0,0 +1,157 @@
+/**
+ * OCR provider config — stored in `system_settings` under the key
+ * `ocr.config`. Each port can either have its own row (port_id = port.id)
+ * or opt into the global row (port_id = null) by setting `useGlobal: true`.
+ */
+
+import { and, eq, isNull } from 'drizzle-orm';
+
+import { db } from '@/lib/db';
+import { systemSettings } from '@/lib/db/schema/system';
+import { encrypt, decrypt } from '@/lib/utils/encryption';
+
+export type OcrProvider = 'openai' | 'claude';
+
+export const OCR_MODELS: Record = {
+ openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo'],
+ claude: ['claude-haiku-4-5', 'claude-sonnet-4-6', 'claude-opus-4-7'],
+};
+
+export const DEFAULT_MODEL: Record = {
+ openai: 'gpt-4o-mini',
+ claude: 'claude-haiku-4-5',
+};
+
+/** Public shape that admin UIs read — never includes the raw key. */
+export interface OcrConfigPublic {
+ provider: OcrProvider;
+ model: string;
+ /** True when an encrypted key is present. We never echo the key itself. */
+ hasApiKey: boolean;
+ /** Port-level rows can opt into the global config. */
+ useGlobal: boolean;
+}
+
+/** Internal shape including the decrypted key — server-side only. */
+export interface OcrConfigResolved extends OcrConfigPublic {
+ apiKey: string | null;
+ /** Source of the resolved row: 'port' | 'global' | 'none'. */
+ source: 'port' | 'global' | 'none';
+}
+
+interface StoredOcrConfig {
+ provider: OcrProvider;
+ model: string;
+ apiKeyEncrypted: string | null;
+ useGlobal: boolean;
+}
+
+const KEY = 'ocr.config';
+
+async function readRow(portId: string | null): Promise {
+ const where =
+ portId === null
+ ? and(eq(systemSettings.key, KEY), isNull(systemSettings.portId))
+ : and(eq(systemSettings.key, KEY), eq(systemSettings.portId, portId));
+ const [row] = await db.select().from(systemSettings).where(where);
+ if (!row) return null;
+ return row.value as unknown as StoredOcrConfig;
+}
+
+async function writeRow(portId: string | null, value: StoredOcrConfig, userId: string) {
+ // upsert: delete + insert keeps logic simple given the (key, port_id) unique index.
+ await db
+ .delete(systemSettings)
+ .where(
+ portId === null
+ ? and(eq(systemSettings.key, KEY), isNull(systemSettings.portId))
+ : and(eq(systemSettings.key, KEY), eq(systemSettings.portId, portId)),
+ );
+ await db.insert(systemSettings).values({
+ key: KEY,
+ portId,
+ value: value as unknown as Record,
+ updatedBy: userId,
+ });
+}
+
+/**
+ * Resolve the active OCR config for a port: port row (unless `useGlobal`),
+ * falling back to the global row, falling back to a default-empty config.
+ */
+export async function getResolvedOcrConfig(portId: string): Promise {
+ const portRow = await readRow(portId);
+ const useGlobal = portRow?.useGlobal === true || !portRow;
+ const sourceRow = useGlobal ? await readRow(null) : portRow;
+ if (!sourceRow) {
+ return {
+ provider: 'openai',
+ model: DEFAULT_MODEL.openai,
+ apiKey: null,
+ hasApiKey: false,
+ useGlobal: portRow?.useGlobal === true,
+ source: 'none',
+ };
+ }
+ return {
+ provider: sourceRow.provider,
+ model: sourceRow.model,
+ apiKey: sourceRow.apiKeyEncrypted ? decrypt(sourceRow.apiKeyEncrypted) : null,
+ hasApiKey: Boolean(sourceRow.apiKeyEncrypted),
+ useGlobal: portRow?.useGlobal === true,
+ source: useGlobal ? 'global' : 'port',
+ };
+}
+
+/** Public-safe view for the admin UI — same shape but never the key. */
+export async function getPublicOcrConfig(portId: string | null): Promise {
+ const row = await readRow(portId);
+ if (!row) {
+ return {
+ provider: 'openai',
+ model: DEFAULT_MODEL.openai,
+ hasApiKey: false,
+ useGlobal: false,
+ };
+ }
+ return {
+ provider: row.provider,
+ model: row.model,
+ hasApiKey: Boolean(row.apiKeyEncrypted),
+ useGlobal: row.useGlobal,
+ };
+}
+
+export interface SaveOcrConfigInput {
+ provider: OcrProvider;
+ model: string;
+ /** When provided, replaces any stored key. When undefined, the existing key is preserved. */
+ apiKey?: string;
+ /** When true, clears the stored key. */
+ clearApiKey?: boolean;
+ useGlobal?: boolean;
+}
+
+export async function saveOcrConfig(
+ portId: string | null,
+ input: SaveOcrConfigInput,
+ userId: string,
+): Promise {
+ const existing = await readRow(portId);
+ let apiKeyEncrypted = existing?.apiKeyEncrypted ?? null;
+ if (input.clearApiKey) {
+ apiKeyEncrypted = null;
+ } else if (input.apiKey !== undefined && input.apiKey.length > 0) {
+ apiKeyEncrypted = encrypt(input.apiKey);
+ }
+ await writeRow(
+ portId,
+ {
+ provider: input.provider,
+ model: input.model,
+ apiKeyEncrypted,
+ useGlobal: portId === null ? false : Boolean(input.useGlobal),
+ },
+ userId,
+ );
+}
diff --git a/src/lib/services/ocr-providers.ts b/src/lib/services/ocr-providers.ts
new file mode 100644
index 0000000..068bf2f
--- /dev/null
+++ b/src/lib/services/ocr-providers.ts
@@ -0,0 +1,172 @@
+/**
+ * Receipt OCR provider adapters. Each adapter takes raw image bytes
+ * and returns a normalized `ParsedReceipt` shape; callers don't care
+ * which provider produced it.
+ */
+
+import OpenAI from 'openai';
+
+import { logger } from '@/lib/logger';
+
+export interface ParsedReceiptLineItem {
+ description: string;
+ amount: number;
+}
+
+export interface ParsedReceipt {
+ establishment: string | null;
+ /** ISO YYYY-MM-DD. */
+ date: string | null;
+ amount: number | null;
+ currency: string | null;
+ lineItems: ParsedReceiptLineItem[];
+ /** 0..1; below 0.6 surfaces "verify mode" UI. */
+ confidence: number;
+}
+
+const EMPTY_RESULT: ParsedReceipt = {
+ establishment: null,
+ date: null,
+ amount: null,
+ currency: null,
+ lineItems: [],
+ confidence: 0,
+};
+
+const SYSTEM_PROMPT =
+ 'You extract structured data from a marina-business receipt image. Return ONLY a JSON object with these keys: establishment (string), date (ISO YYYY-MM-DD), amount (number, total), currency (3-letter ISO code), lineItems (array of {description, amount}), confidence (number 0-1). If a field cannot be read, return null for that field. Set confidence near 0 if the image is unreadable, near 1 if every field was confidently extracted.';
+
+interface RunArgs {
+ imageBuffer: Buffer;
+ mimeType: string;
+ apiKey: string;
+ model: string;
+}
+
+function safeParse(content: string): ParsedReceipt {
+ const cleaned = content.replace(/```json\n?|\n?```/g, '').trim();
+ try {
+ const obj = JSON.parse(cleaned) as Partial;
+ return {
+ establishment: obj.establishment ?? null,
+ date: obj.date ?? null,
+ amount: typeof obj.amount === 'number' ? obj.amount : null,
+ currency: obj.currency ?? null,
+ lineItems: Array.isArray(obj.lineItems) ? obj.lineItems : [],
+ confidence: typeof obj.confidence === 'number' ? obj.confidence : 0,
+ };
+ } catch (err) {
+ logger.warn({ err, contentLen: cleaned.length }, 'OCR provider returned non-JSON');
+ return EMPTY_RESULT;
+ }
+}
+
+async function runOpenAi({
+ imageBuffer,
+ mimeType,
+ apiKey,
+ model,
+}: RunArgs): Promise {
+ const client = new OpenAI({ apiKey });
+ const base64 = imageBuffer.toString('base64');
+ const response = await client.chat.completions.create({
+ model,
+ messages: [
+ { role: 'system', content: SYSTEM_PROMPT },
+ {
+ role: 'user',
+ content: [
+ { type: 'text', text: 'Extract the receipt as JSON.' },
+ {
+ type: 'image_url',
+ image_url: { url: `data:${mimeType};base64,${base64}` },
+ },
+ ],
+ },
+ ],
+ max_tokens: 1024,
+ response_format: { type: 'json_object' },
+ });
+ return safeParse(response.choices[0]?.message?.content ?? '{}');
+}
+
+async function runClaude({
+ imageBuffer,
+ mimeType,
+ apiKey,
+ model,
+}: RunArgs): Promise {
+ const base64 = imageBuffer.toString('base64');
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-api-key': apiKey,
+ 'anthropic-version': '2023-06-01',
+ },
+ body: JSON.stringify({
+ model,
+ max_tokens: 1024,
+ system: SYSTEM_PROMPT,
+ messages: [
+ {
+ role: 'user',
+ content: [
+ {
+ type: 'image',
+ source: { type: 'base64', media_type: mimeType, data: base64 },
+ },
+ { type: 'text', text: 'Extract the receipt as JSON.' },
+ ],
+ },
+ ],
+ }),
+ });
+ if (!res.ok) {
+ const detail = await res.text().catch(() => '');
+ throw new Error(`Claude API ${res.status}: ${detail.slice(0, 200)}`);
+ }
+ const body = (await res.json()) as { content?: Array<{ type: string; text?: string }> };
+ const text = body.content?.find((c) => c.type === 'text')?.text ?? '{}';
+ return safeParse(text);
+}
+
+export async function runOcr(args: {
+ provider: 'openai' | 'claude';
+ imageBuffer: Buffer;
+ mimeType: string;
+ apiKey: string;
+ model: string;
+}): Promise {
+ if (args.provider === 'openai') return runOpenAi(args);
+ return runClaude(args);
+}
+
+/**
+ * Tiny dummy-image probe used by the admin "Test connection" button.
+ * Returns the raw HTTP status so callers can render plain-English errors.
+ */
+export async function testProvider(
+ provider: 'openai' | 'claude',
+ apiKey: string,
+ model: string,
+): Promise<{ ok: true } | { ok: false; reason: string }> {
+ // 1×1 transparent PNG.
+ const pixelPng = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
+ 'base64',
+ );
+ try {
+ await runOcr({
+ provider,
+ imageBuffer: pixelPng,
+ mimeType: 'image/png',
+ apiKey,
+ model,
+ });
+ return { ok: true };
+ } catch (err) {
+ const reason = err instanceof Error ? err.message : 'Unknown error';
+ return { ok: false, reason };
+ }
+}
diff --git a/src/lib/validators/documents.ts b/src/lib/validators/documents.ts
index 29f98d6..1d4009c 100644
--- a/src/lib/validators/documents.ts
+++ b/src/lib/validators/documents.ts
@@ -71,6 +71,7 @@ export type CreateDocumentWizardInput = z.infer {
+ test.skip(
+ !process.env.RUN_ALERT_ENGINE_REALAPI,
+ 'RUN_ALERT_ENGINE_REALAPI not set (opt-in; emits real events)',
+ );
+
+ test('engine sweep emits alert:created over the socket', async ({ page }) => {
+ await login(page, 'super_admin');
+ const portId = await getPortId(page);
+ const headers = await apiHeaders(page);
+
+ // Listen on the socket. We resolve when an alert:created event lands
+ // for our port id, or reject after a timeout.
+ const cookieHeader = await page.evaluate(() => document.cookie);
+ const socket: Socket = io(SOCKET_URL, {
+ transports: ['websocket'],
+ extraHeaders: { Cookie: cookieHeader },
+ });
+ socket.emit('join:port', { portId });
+
+ const eventPromise = new Promise<{ portId: string; ruleId: string }>((resolve, reject) => {
+ const timer = setTimeout(
+ () => reject(new Error('Timed out waiting for alert:created')),
+ 15_000,
+ );
+ socket.on('alert:created', (payload: { portId: string; ruleId: string }) => {
+ if (payload.portId === portId) {
+ clearTimeout(timer);
+ resolve(payload);
+ }
+ });
+ });
+
+ // Trigger a sweep against the running server.
+ const triggerRes = await page.request.post(`/api/v1/admin/alerts/run-engine`, {
+ headers,
+ });
+ expect([200, 404]).toContain(triggerRes.status());
+ if (triggerRes.status() === 404) {
+ // The trigger route is opt-in scaffolding; skip if not present in this build.
+ socket.disconnect();
+ test.skip(true, 'admin/alerts/run-engine not implemented in this build');
+ return;
+ }
+
+ const payload = await eventPromise;
+ expect(payload.portId).toBe(portId);
+
+ socket.disconnect();
+ });
+});
diff --git a/tests/e2e/realapi/receipt-ocr.spec.ts b/tests/e2e/realapi/receipt-ocr.spec.ts
new file mode 100644
index 0000000..a73150f
--- /dev/null
+++ b/tests/e2e/realapi/receipt-ocr.spec.ts
@@ -0,0 +1,132 @@
+import 'dotenv/config';
+import { test, expect } from '@playwright/test';
+import { promises as fs } from 'fs';
+
+import { login, apiHeaders, getPortId } from '../smoke/helpers';
+
+/**
+ * Real-API receipt OCR coverage. Two-step:
+ *
+ * 1. Admin save + test-connection round-trip: writes a real OpenAI key
+ * to the global OCR config, calls /admin/ocr-settings/test (which
+ * sends a 1×1 pixel PNG — essentially free in tokens), and asserts
+ * the provider responds 2xx. Validates the auth + key-storage path.
+ *
+ * 2. Real receipt parse: when REALAPI_RECEIPT_FIXTURE is set to an
+ * image on disk, POSTs it to /api/v1/expenses/scan-receipt and
+ * asserts the parsed payload looks plausible (numeric amount >= 0,
+ * non-empty parsed.confidence).
+ *
+ * Both tests skip when OPENAI_API_KEY isn't set so the suite remains
+ * CI-safe by default.
+ */
+
+const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
+const RECEIPT_FIXTURE = process.env.REALAPI_RECEIPT_FIXTURE;
+
+test.describe('Receipt OCR — real provider', () => {
+ test.skip(!OPENAI_API_KEY, 'OPENAI_API_KEY not configured');
+
+ test('admin can save an OpenAI key and the test endpoint passes', async ({ page }) => {
+ await login(page, 'super_admin');
+ const headers = await apiHeaders(page);
+
+ // Save a global OCR config with the real key. Super-admin only.
+ const saveRes = await page.request.put('/api/v1/admin/ocr-settings', {
+ headers,
+ data: {
+ scope: 'global',
+ provider: 'openai',
+ model: 'gpt-4o-mini',
+ apiKey: OPENAI_API_KEY,
+ },
+ });
+ expect(saveRes.ok()).toBeTruthy();
+
+ const testRes = await page.request.post('/api/v1/admin/ocr-settings/test', {
+ headers,
+ data: {
+ provider: 'openai',
+ model: 'gpt-4o-mini',
+ apiKey: OPENAI_API_KEY,
+ },
+ });
+ expect(testRes.ok()).toBeTruthy();
+ const body = (await testRes.json()) as { ok: boolean; reason?: string };
+ expect(body.ok).toBe(true);
+
+ // Cleanup: clear the global key so subsequent test runs don't accidentally
+ // bill the same OpenAI account if someone forgets to unset it.
+ const cleanupRes = await page.request.put('/api/v1/admin/ocr-settings', {
+ headers,
+ data: {
+ scope: 'global',
+ provider: 'openai',
+ model: 'gpt-4o-mini',
+ clearApiKey: true,
+ },
+ });
+ expect(cleanupRes.ok()).toBeTruthy();
+ });
+
+ test('scan-receipt endpoint returns a parsed payload for a real image', async ({ page }) => {
+ test.skip(!RECEIPT_FIXTURE, 'REALAPI_RECEIPT_FIXTURE not set');
+
+ await login(page, 'super_admin');
+ const portId = await getPortId(page);
+
+ // Configure the per-port OCR with the test key for the duration of this run.
+ await page.request.put('/api/v1/admin/ocr-settings', {
+ headers: { 'Content-Type': 'application/json', 'X-Port-Id': portId },
+ data: {
+ scope: 'port',
+ provider: 'openai',
+ model: 'gpt-4o-mini',
+ apiKey: OPENAI_API_KEY,
+ },
+ });
+
+ const buffer = await fs.readFile(RECEIPT_FIXTURE!);
+ const res = await page.request.post('/api/v1/expenses/scan-receipt', {
+ headers: { 'X-Port-Id': portId },
+ multipart: {
+ file: {
+ name: 'receipt.jpg',
+ mimeType: 'image/jpeg',
+ buffer,
+ },
+ },
+ });
+ expect(res.ok()).toBeTruthy();
+ const body = (await res.json()) as {
+ data: {
+ parsed: {
+ amount: number | null;
+ confidence: number;
+ establishment: string | null;
+ date: string | null;
+ };
+ source: 'ai' | 'manual';
+ };
+ };
+ expect(body.data.source).toBe('ai');
+ // Confidence must be a valid number 0..1 — provider should always emit it.
+ expect(body.data.parsed.confidence).toBeGreaterThanOrEqual(0);
+ expect(body.data.parsed.confidence).toBeLessThanOrEqual(1);
+ // Amount, if present, should be non-negative.
+ if (body.data.parsed.amount !== null) {
+ expect(body.data.parsed.amount).toBeGreaterThanOrEqual(0);
+ }
+
+ // Cleanup
+ await page.request.put('/api/v1/admin/ocr-settings', {
+ headers: { 'Content-Type': 'application/json', 'X-Port-Id': portId },
+ data: {
+ scope: 'port',
+ provider: 'openai',
+ model: 'gpt-4o-mini',
+ clearApiKey: true,
+ },
+ });
+ });
+});
diff --git a/tests/e2e/smoke/04-documents.spec.ts b/tests/e2e/smoke/04-documents.spec.ts
index a4a5526..ac299b5 100644
--- a/tests/e2e/smoke/04-documents.spec.ts
+++ b/tests/e2e/smoke/04-documents.spec.ts
@@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test';
-import { login, navigateTo, PORT_SLUG } from './helpers';
+import { login, navigateTo } from './helpers';
import path from 'path';
test.describe('Document Management', () => {
@@ -26,7 +26,7 @@ test.describe('Document Management', () => {
if (await uploadBtn.isVisible({ timeout: 5_000 }).catch(() => false)) {
// Check for a file input (may be hidden)
const fileInput = page.locator('input[type="file"]').first();
- if (await fileInput.count() > 0) {
+ if ((await fileInput.count()) > 0) {
const testFilePath = path.resolve('tests/e2e/fixtures/test-document.txt');
await fileInput.setInputFiles(testFilePath);
await page.waitForTimeout(5000);
@@ -35,7 +35,7 @@ test.describe('Document Management', () => {
await uploadBtn.click();
await page.waitForTimeout(1000);
const fileInput2 = page.locator('input[type="file"]').first();
- if (await fileInput2.count() > 0) {
+ if ((await fileInput2.count()) > 0) {
const testFilePath = path.resolve('tests/e2e/fixtures/test-document.txt');
await fileInput2.setInputFiles(testFilePath);
await page.waitForTimeout(5000);
@@ -47,4 +47,14 @@ test.describe('Document Management', () => {
const pageContent = page.getByText(/documents|files/i).first();
await expect(pageContent).toBeVisible({ timeout: 5_000 });
});
+
+ test('documents hub shows the EOI queue tab', async ({ page }) => {
+ await navigateTo(page, '/documents');
+ await page.waitForLoadState('networkidle');
+
+ const tab = page.getByRole('tab', { name: /eoi queue/i });
+ await expect(tab).toBeVisible({ timeout: 10_000 });
+ await tab.click();
+ await expect(tab).toHaveAttribute('data-state', 'active');
+ });
});
diff --git a/tests/e2e/smoke/10-dashboard.spec.ts b/tests/e2e/smoke/10-dashboard.spec.ts
index 7bf3659..9ebc861 100644
--- a/tests/e2e/smoke/10-dashboard.spec.ts
+++ b/tests/e2e/smoke/10-dashboard.spec.ts
@@ -41,18 +41,33 @@ test.describe('Dashboard', () => {
expect(errorCount).toBe(0);
});
- // Test 3: Pipeline chart shows bars for stages
- test('pipeline chart renders with stage bars', async ({ page }) => {
+ // Test 3: Pipeline funnel chart renders bars for stages (Phase B analytics)
+ test('pipeline funnel chart renders with stage bars', async ({ page }) => {
await navigateTo(page, '/');
await page.waitForTimeout(3_000);
- // Recharts renders SVG bars — look for the chart container and SVG elements
- const chartSection = page.getByText('Pipeline Overview').first();
+ const chartSection = page.getByText('Pipeline Funnel').first();
await expect(chartSection).toBeVisible({ timeout: 10_000 });
- // Should have an SVG with rect elements (bars) or the recharts container
+ // Recharts SVG container should be present (or empty-state if no data).
const svg = page.locator('.recharts-wrapper svg, .recharts-responsive-container svg').first();
- await expect(svg).toBeVisible({ timeout: 5_000 });
+ const empty = page.getByText('No interests in range').first();
+ const hasSvg = await svg.isVisible({ timeout: 5_000 }).catch(() => false);
+ const hasEmpty = await empty.isVisible({ timeout: 2_000 }).catch(() => false);
+ expect(hasSvg || hasEmpty).toBeTruthy();
+ });
+
+ // Test 6: Date range picker switches analytics window
+ test('date range picker switches between ranges', async ({ page }) => {
+ await navigateTo(page, '/');
+ await expect(page.getByText('Pipeline Funnel').first()).toBeVisible({ timeout: 15_000 });
+
+ // Default is 30d. Click 7d, then 90d, and verify the kpi line label updates.
+ await page.getByTestId('range-7d').click();
+ await expect(page.getByText('Last 7 days')).toBeVisible({ timeout: 5_000 });
+
+ await page.getByTestId('range-90d').click();
+ await expect(page.getByText('Last 90 days')).toBeVisible({ timeout: 5_000 });
});
// Test 4: Activity feed shows recent entries
diff --git a/tests/e2e/smoke/27-alerts.spec.ts b/tests/e2e/smoke/27-alerts.spec.ts
new file mode 100644
index 0000000..d1411df
--- /dev/null
+++ b/tests/e2e/smoke/27-alerts.spec.ts
@@ -0,0 +1,48 @@
+import { test, expect } from '@playwright/test';
+import { login, navigateTo, PORT_SLUG } from './helpers';
+
+test.describe('Alerts (Phase B)', () => {
+ test.beforeEach(async ({ page }) => {
+ await login(page, 'super_admin');
+ });
+
+ test('alert bell renders in topbar with no badge when no alerts', async ({ page }) => {
+ await navigateTo(page, '/');
+ const bell = page.getByTestId('alert-bell');
+ await expect(bell).toBeVisible({ timeout: 15_000 });
+ });
+
+ test('alert bell popover opens', async ({ page }) => {
+ await navigateTo(page, '/');
+ const bell = page.getByTestId('alert-bell');
+ await expect(bell).toBeVisible({ timeout: 15_000 });
+ await bell.click();
+
+ // Popover header is the unambiguous proof that the popover rendered.
+ await expect(page.getByText('Active alerts').first()).toBeVisible({ timeout: 8_000 });
+ });
+
+ test('dashboard renders the alert rail', async ({ page }) => {
+ await navigateTo(page, '/');
+ await expect(page.getByText('Pipeline Funnel').first()).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByTestId('alert-rail')).toBeVisible({ timeout: 5_000 });
+ });
+
+ test('/alerts page loads with three tabs', async ({ page }) => {
+ await navigateTo(page, '/alerts');
+ expect(page.url()).toContain(`/${PORT_SLUG}/alerts`);
+ await expect(page.getByTestId('tab-open')).toBeVisible({ timeout: 10_000 });
+ await expect(page.getByTestId('tab-dismissed')).toBeVisible();
+ await expect(page.getByTestId('tab-resolved')).toBeVisible();
+
+ await page.getByTestId('tab-resolved').click();
+ // No assertion on contents; just smoke that the tab swap doesn't error.
+ await expect(page.getByTestId('tab-resolved')).toHaveAttribute('data-state', 'active');
+ });
+
+ test('sidebar Alerts link navigates to /alerts', async ({ page }) => {
+ await navigateTo(page, '/');
+ await page.getByRole('link', { name: 'Alerts', exact: true }).click();
+ await expect(page).toHaveURL(new RegExp(`/${PORT_SLUG}/alerts`), { timeout: 10_000 });
+ });
+});
diff --git a/tests/e2e/smoke/28-berth-interests-tab.spec.ts b/tests/e2e/smoke/28-berth-interests-tab.spec.ts
new file mode 100644
index 0000000..67ffadf
--- /dev/null
+++ b/tests/e2e/smoke/28-berth-interests-tab.spec.ts
@@ -0,0 +1,36 @@
+import { test, expect } from '@playwright/test';
+import { login, PORT_SLUG } from './helpers';
+
+test.describe('Berth detail — Interests tab (Phase B)', () => {
+ test.beforeEach(async ({ page }) => {
+ await login(page, 'super_admin');
+ });
+
+ test('berth detail page exposes an Interests tab', async ({ page }) => {
+ // Navigate to the berths list, then click the first row to drill in.
+ // The list uses TanStack Table row-click handlers, so we wait for the
+ // table body to populate rather than for `a[href*="/berths/"]`.
+ await page.goto(`/${PORT_SLUG}/berths`);
+ const firstRow = page.locator('tbody tr').first();
+ await expect(firstRow).toBeVisible({ timeout: 20_000 });
+ // The list table uses an `onRowClick` handler on the
that calls
+ // `router.push`. Open the row's actions menu and click "View details"
+ // — the menu item's handler routes the same way and is more reliable
+ // than firing a synthetic
click under React 19 + dev-mode HMR.
+ await firstRow.getByRole('button', { name: /open menu/i }).click();
+ await page.getByRole('menuitem', { name: /view details/i }).click();
+ await page.waitForURL(/\/berths\/[^/]+$/, { timeout: 10_000 });
+
+ const tab = page.getByRole('tab', { name: 'Interests', exact: true });
+ await expect(tab).toBeVisible({ timeout: 10_000 });
+ await tab.click();
+ await expect(tab).toHaveAttribute('data-state', 'active');
+
+ // Confirm the new tab body replaced the old stub. The body might be:
+ // - a populated table
+ // - the empty-state ("No interests linked to this berth")
+ // - the loading skeleton (still fetching)
+ // The negative assertion against the old stub copy is the primary signal.
+ await expect(page.getByText('Interests coming soon')).not.toBeVisible({ timeout: 2_000 });
+ });
+});
diff --git a/tests/e2e/smoke/29-receipt-scanner.spec.ts b/tests/e2e/smoke/29-receipt-scanner.spec.ts
new file mode 100644
index 0000000..901cdc4
--- /dev/null
+++ b/tests/e2e/smoke/29-receipt-scanner.spec.ts
@@ -0,0 +1,50 @@
+import { test, expect } from '@playwright/test';
+import { login, PORT_SLUG } from './helpers';
+
+test.describe('Receipt scanner PWA (Phase B)', () => {
+ test.beforeEach(async ({ page }) => {
+ await login(page, 'super_admin');
+ });
+
+ test('scanner page loads with capture button and no dashboard chrome', async ({ page }) => {
+ // First-hit dev compile of the brand-new (scanner) route group can exceed 30s.
+ await page.goto(`/${PORT_SLUG}/scan`, { timeout: 60_000 });
+ await expect(page.getByRole('heading', { name: /scan a receipt/i })).toBeVisible({
+ timeout: 20_000,
+ });
+ await expect(page.getByTestId('scan-capture')).toBeVisible({ timeout: 10_000 });
+
+ // No sidebar / topbar elements should be present on the scanner.
+ const dashboardLinks = page.getByRole('link', { name: 'Dashboard' });
+ expect(await dashboardLinks.count()).toBe(0);
+ });
+
+ test('per-port manifest is served with correct content type and scope', async ({ page }) => {
+ const res = await page.request.get(`/${PORT_SLUG}/scan/manifest.webmanifest`, {
+ timeout: 60_000,
+ });
+ expect(res.status()).toBe(200);
+ expect(res.headers()['content-type']).toContain('application/manifest+json');
+ const body = (await res.json()) as {
+ name: string;
+ scope: string;
+ start_url: string;
+ display: string;
+ };
+ expect(body.scope).toBe(`/${PORT_SLUG}/scan`);
+ expect(body.start_url).toBe(`/${PORT_SLUG}/scan`);
+ expect(body.display).toBe('standalone');
+ expect(body.name.toLowerCase()).toContain('scanner');
+ });
+
+ test('admin OCR settings page renders both provider and model selectors', async ({ page }) => {
+ await page.goto(`/${PORT_SLUG}/admin/ocr`, { timeout: 60_000 });
+ await expect(page.getByRole('heading', { name: /receipt ocr/i })).toBeVisible({
+ timeout: 30_000,
+ });
+ await expect(page.getByTestId('save-port')).toBeVisible({ timeout: 15_000 });
+ // Provider + model selects use Radix triggers, not native