From b6f65e9f6ab45db73bbf43144d8ac2660871920e Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sun, 2 Aug 2026 15:20:29 +0700 Subject: [PATCH 1/5] feat(server): support managed demo processing and reset --- .env.example | 10 ++ apps/server/.env.example | 15 +- apps/server/scripts/seed.ts | 2 + .../documents/r2-document-store.test.ts | 11 ++ .../adapters/documents/r2-document-store.ts | 4 +- .../extraction/extraction-schema.test.ts | 11 ++ .../adapters/extraction/extraction-schema.ts | 2 +- ...oogle-ai-studio-extraction-adapter.test.ts | 165 ++++++++++++++++++ .../google-ai-studio-extraction-adapter.ts | 151 ++++++++++++++++ .../extraction/model-extraction-adapter.ts | 19 ++ .../openrouter-extraction-adapter.ts | 22 +-- .../processing-adapters-factory.test.ts | 13 +- .../adapters/processing-adapters-factory.ts | 16 +- apps/server/src/app.ts | 32 +--- apps/server/src/db/seeds/demo-reference.ts | 65 +++++++ .../src/demo/synthetic-fixtures.test.ts | 22 +++ apps/server/src/demo/synthetic-fixtures.ts | 35 ++++ apps/server/src/lib/create-app.ts | 16 +- apps/server/src/lib/env-config.ts | 48 +++-- .../administration/administration.handlers.ts | 8 +- .../administration/administration.index.ts | 6 +- .../administration/administration.test.ts | 15 +- ...nvoice-case-processing.integration.test.ts | 63 +++++++ .../src/services/invoice-case-processing.ts | 35 ++-- .../services/invoice-validation-context.ts | 98 +++++++++++ .../server/src/services/reset-service.test.ts | 15 +- apps/server/src/services/reset-service.ts | 36 ++-- apps/server/src/worker.ts | 7 +- docker-compose.prod.yml | 18 ++ docker-compose.yml | 18 ++ 30 files changed, 858 insertions(+), 120 deletions(-) create mode 100644 apps/server/src/adapters/extraction/google-ai-studio-extraction-adapter.test.ts create mode 100644 apps/server/src/adapters/extraction/google-ai-studio-extraction-adapter.ts create mode 100644 apps/server/src/adapters/extraction/model-extraction-adapter.ts create mode 100644 apps/server/src/db/seeds/demo-reference.ts create mode 100644 apps/server/src/demo/synthetic-fixtures.test.ts create mode 100644 apps/server/src/demo/synthetic-fixtures.ts create mode 100644 apps/server/src/services/invoice-validation-context.ts diff --git a/.env.example b/.env.example index 8deec46..575e60e 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,13 @@ POSTGRES_DB=app # Server CORS_ORIGINS=http://localhost:80 + +# Optional managed local-first demo configuration. Keep secrets server-side. +# DATABASE_URL=postgresql://... +# GOOGLE_AI_STUDIO_API_KEY= +# GOOGLE_AI_STUDIO_MODEL=gemini-2.5-flash +# R2_ACCOUNT_ID= +# R2_ACCESS_KEY_ID= +# R2_SECRET_ACCESS_KEY= +# R2_BUCKET_NAME= +# R2_ENDPOINT= diff --git a/apps/server/.env.example b/apps/server/.env.example index 1837c5c..a9c175c 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -5,6 +5,8 @@ PORT=3000 HOST=localhost # Database (PostgreSQL) +# Set DATABASE_URL to the Neon direct (unpooled) connection string for managed PostgreSQL. +DATABASE_URL= DATABASE_USER=postgres DATABASE_PASSWORD=postgres DATABASE_DB=app @@ -39,12 +41,20 @@ DOCUMENT_MAX_PAGES=5 # Public demo controls DEMO_MODE=private +# Allow the authenticated administrator to reset data while DEMO_MODE=private. +DEMO_RESET_ENABLED=false DEMO_EXTRACTION_QUOTA_PER_ACCOUNT=20 DEMO_EXTRACTION_QUOTA_PER_IP=5 DEMO_QUOTA_PERIOD_HOURS=24 DEMO_INTAKE_RATE_LIMIT_MAX=10 -# Optional real extraction +# Optional Google AI Studio extraction (preferred when configured) +GOOGLE_AI_STUDIO_API_KEY= +GOOGLE_AI_STUDIO_MODEL=gemini-2.5-flash +GOOGLE_AI_STUDIO_API_URL=https://generativelanguage.googleapis.com/v1beta +GOOGLE_AI_STUDIO_TIMEOUT_MS=60000 + +# Optional OpenRouter extraction (fallback provider for local development) OPENROUTER_API_KEY= EXTRACTION_API_URL=https://openrouter.ai/api/v1/chat/completions OPENROUTER_PRIMARY_MODEL= @@ -57,3 +67,6 @@ R2_ACCESS_KEY_ID= R2_SECRET_ACCESS_KEY= R2_BUCKET_NAME= R2_ENDPOINT= + +# Document preparation timeout +DOCUMENT_PREPARATION_TIMEOUT_MS=30000 diff --git a/apps/server/scripts/seed.ts b/apps/server/scripts/seed.ts index 2279655..e1c5158 100644 --- a/apps/server/scripts/seed.ts +++ b/apps/server/scripts/seed.ts @@ -1,6 +1,7 @@ import process from 'node:process'; import { db, pool } from '../src/db/index.js'; +import { seedDemoReferenceData } from '../src/db/seeds/demo-reference.js'; import { seedIdentityUsers } from '../src/db/seeds/identity.js'; const password = process.env.SEED_USER_PASSWORD; @@ -11,6 +12,7 @@ if (!password || password.length < 12) { try { await seedIdentityUsers(db, password); + await seedDemoReferenceData(db); } finally { await pool.end(); } diff --git a/apps/server/src/adapters/documents/r2-document-store.test.ts b/apps/server/src/adapters/documents/r2-document-store.test.ts index bb8eb1d..84e7c07 100644 --- a/apps/server/src/adapters/documents/r2-document-store.test.ts +++ b/apps/server/src/adapters/documents/r2-document-store.test.ts @@ -91,6 +91,17 @@ describe('R2DocumentStore', () => { expect(mockSend).toHaveBeenCalledTimes(1); }); + it('uses a unique object key for each upload, including exact duplicates', async () => { + const buffer = await makePdfBytes(); + mockSend.mockResolvedValue({}); + + const first = await store.put(makeUpload(buffer)); + const second = await store.put(makeUpload(buffer)); + + expect(second.contentHash).toBe(first.contentHash); + expect(second.objectKey).not.toBe(first.objectKey); + }); + it('opens document as readable stream', async () => { const testData = Buffer.from('test-content'); const mockStream = Readable.from([testData]); diff --git a/apps/server/src/adapters/documents/r2-document-store.ts b/apps/server/src/adapters/documents/r2-document-store.ts index bc26276..914b70d 100644 --- a/apps/server/src/adapters/documents/r2-document-store.ts +++ b/apps/server/src/adapters/documents/r2-document-store.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import type { Readable } from 'node:stream'; import { @@ -58,7 +58,7 @@ export class R2DocumentStore implements DocumentStore { const buffer = Buffer.concat(chunks); const contentHash = hash.digest('hex'); - const objectKey = contentHash; + const objectKey = `documents/${randomUUID()}`; const inspection = await inspectDocument(buffer, this.config.maxPages); diff --git a/apps/server/src/adapters/extraction/extraction-schema.test.ts b/apps/server/src/adapters/extraction/extraction-schema.test.ts index cf92883..1662802 100644 --- a/apps/server/src/adapters/extraction/extraction-schema.test.ts +++ b/apps/server/src/adapters/extraction/extraction-schema.test.ts @@ -17,4 +17,15 @@ describe('extracted invoice schema', () => { }) ).toThrow(); }); + + it('defaults missing evidence ambiguous to false', () => { + const withoutAmbiguous = { + ...CLEAN_INVOICE_FIXTURE, + evidence: CLEAN_INVOICE_FIXTURE.evidence.map( + ({ ambiguous: _ambiguous, ...evidence }) => evidence + ) + }; + const parsed = parseExtractedInvoice(withoutAmbiguous); + expect(parsed.evidence.every((evidence) => evidence.ambiguous === false)).toBe(true); + }); }); diff --git a/apps/server/src/adapters/extraction/extraction-schema.ts b/apps/server/src/adapters/extraction/extraction-schema.ts index 758c629..249f527 100644 --- a/apps/server/src/adapters/extraction/extraction-schema.ts +++ b/apps/server/src/adapters/extraction/extraction-schema.ts @@ -22,7 +22,7 @@ const fieldEvidenceSchema = z pageNumber: z.number().int().positive(), excerpt: z.string().max(2_000).optional(), confidence: z.enum(EXTRACTION_CONFIDENCE_LEVELS), - ambiguous: z.boolean() + ambiguous: z.boolean().default(false) }) .strict(); diff --git a/apps/server/src/adapters/extraction/google-ai-studio-extraction-adapter.test.ts b/apps/server/src/adapters/extraction/google-ai-studio-extraction-adapter.test.ts new file mode 100644 index 0000000..ed42cf6 --- /dev/null +++ b/apps/server/src/adapters/extraction/google-ai-studio-extraction-adapter.test.ts @@ -0,0 +1,165 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { CLEAN_INVOICE_FIXTURE } from './fixtures/clean-invoice.js'; +import { + GoogleAiStudioExtractionAdapter, + createGoogleAiStudioConfig +} from './google-ai-studio-extraction-adapter.js'; + +const pages = [ + { + pageNumber: 1, + text: 'Invoice INV-2026-0001', + image: null, + width: 612, + height: 792 + } +] as const; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('GoogleAiStudioExtractionAdapter', () => { + it('sends text pages and parses validated JSON output', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + modelVersion: 'gemini-2.5-flash-001', + candidates: [{ content: { parts: [{ text: JSON.stringify(CLEAN_INVOICE_FIXTURE) }] } }], + usageMetadata: { promptTokenCount: 120, candidatesTokenCount: 80 } + }), + { status: 200 } + ) + ); + vi.stubGlobal('fetch', fetchMock); + + const adapter = new GoogleAiStudioExtractionAdapter( + createGoogleAiStudioConfig({ GOOGLE_AI_STUDIO_API_KEY: 'test-key' }) + ); + const result = await adapter.extract({ pages }); + + expect(result.extracted).toEqual(CLEAN_INVOICE_FIXTURE); + expect(result.metadata).toMatchObject({ + model: 'gemini-2.5-flash-001', + fallbackUsed: false, + tokenUsage: { prompt: 120, completion: 80 } + }); + + const [url, request] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent' + ); + expect(request.headers).toMatchObject({ + 'x-goog-api-key': 'test-key', + 'Content-Type': 'application/json' + }); + expect(JSON.parse(String(request.body))).toMatchObject({ + contents: [{ role: 'user' }], + generationConfig: { temperature: 0, responseMimeType: 'application/json' } + }); + }); + + it('defaults omitted evidence ambiguity to false', async () => { + const outputWithoutAmbiguity = { + ...CLEAN_INVOICE_FIXTURE, + evidence: CLEAN_INVOICE_FIXTURE.evidence.map( + ({ ambiguous: _ambiguous, ...evidence }) => evidence + ) + }; + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: JSON.stringify(outputWithoutAmbiguity) }] } }] + }), + { status: 200 } + ) + ); + vi.stubGlobal('fetch', fetchMock); + + const adapter = new GoogleAiStudioExtractionAdapter( + createGoogleAiStudioConfig({ GOOGLE_AI_STUDIO_API_KEY: 'test-key' }) + ); + const result = await adapter.extract({ pages }); + + expect(result.extracted.evidence).toEqual( + CLEAN_INVOICE_FIXTURE.evidence.map((evidence) => ({ ...evidence, ambiguous: false })) + ); + }); + + it('sends rendered pages as inline PNG data', async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + candidates: [{ content: { parts: [{ text: JSON.stringify(CLEAN_INVOICE_FIXTURE) }] } }] + }), + { status: 200 } + ) + ); + vi.stubGlobal('fetch', fetchMock); + + const adapter = new GoogleAiStudioExtractionAdapter( + createGoogleAiStudioConfig({ GOOGLE_AI_STUDIO_API_KEY: 'test-key' }) + ); + await adapter.extract({ + pages: [{ ...pages[0], text: null, image: Buffer.from('png-bytes') }] + }); + + const request = fetchMock.mock.calls[0]?.[1] as RequestInit; + const body = JSON.parse(String(request.body)) as { + contents: Array<{ parts: Array> }>; + }; + expect(body.contents[0]?.parts).toContainEqual({ + inline_data: { mime_type: 'image/png', data: Buffer.from('png-bytes').toString('base64') } + }); + }); + + it('propagates timeout aborts without making a fallback request', async () => { + const fetchMock = vi.fn( + (_url: string, request: RequestInit) => + new Promise((_, reject) => { + request.signal?.addEventListener('abort', () => { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + reject(error); + }); + }) + ); + vi.stubGlobal('fetch', fetchMock); + + const adapter = new GoogleAiStudioExtractionAdapter( + createGoogleAiStudioConfig({ + GOOGLE_AI_STUDIO_API_KEY: 'test-key', + GOOGLE_AI_STUDIO_TIMEOUT_MS: '1' + }) + ); + + await expect(adapter.extract({ pages })).rejects.toMatchObject({ name: 'AbortError' }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('fails when Google returns invalid structured output', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValue( + new Response( + JSON.stringify({ candidates: [{ content: { parts: [{ text: '{"bad":true}' }] } }] }), + { status: 200 } + ) + ) + ); + + const adapter = new GoogleAiStudioExtractionAdapter( + createGoogleAiStudioConfig({ GOOGLE_AI_STUDIO_API_KEY: 'test-key' }) + ); + await expect(adapter.extract({ pages })).rejects.toThrow(); + }); + + it('requires an API key in configuration', () => { + expect(() => createGoogleAiStudioConfig({})).toThrow( + 'GOOGLE_AI_STUDIO_API_KEY environment variable is required' + ); + }); +}); diff --git a/apps/server/src/adapters/extraction/google-ai-studio-extraction-adapter.ts b/apps/server/src/adapters/extraction/google-ai-studio-extraction-adapter.ts new file mode 100644 index 0000000..21225f3 --- /dev/null +++ b/apps/server/src/adapters/extraction/google-ai-studio-extraction-adapter.ts @@ -0,0 +1,151 @@ +import type { PreparedPage } from '../documents/document-preparation.js'; + +import { SYSTEM_PROMPT, PROMPT_VERSION } from './extraction-prompt.js'; +import { parseExtractedInvoice } from './extraction-schema.js'; +import type { ModelExtractionAdapter, ModelExtractionResult } from './model-extraction-adapter.js'; + +const DEFAULT_MODEL = 'gemini-2.5-flash'; +const DEFAULT_API_URL = 'https://generativelanguage.googleapis.com/v1beta'; + +export interface GoogleAiStudioConfig { + apiKey: string; + model: string; + apiUrl: string; + timeoutMs: number; + promptVersion: string; +} + +interface GeminiResponse { + candidates?: Array<{ + content?: { + parts?: Array<{ text?: string }>; + }; + }>; + modelVersion?: string; + usageMetadata?: { + promptTokenCount?: number; + candidatesTokenCount?: number; + }; +} + +export class GoogleAiStudioExtractionAdapter implements ModelExtractionAdapter { + constructor(private readonly config: GoogleAiStudioConfig) {} + + async extract(input: { pages: readonly PreparedPage[] }): Promise { + if (!this.config.apiKey) { + throw new Error('Google AI Studio API key is required'); + } + + const userParts = buildUserParts(input.pages); + if (userParts.length === 0) { + throw new Error('At least one page with image or text content is required for extraction'); + } + + const startedAt = Date.now(); + const response = await this.callModel(userParts); + + return { + extracted: parseExtractedInvoice(parseResponseContent(response)), + metadata: { + model: response.modelVersion ?? this.config.model, + fallbackUsed: false, + promptVersion: this.config.promptVersion, + durationMs: Date.now() - startedAt, + tokenUsage: response.usageMetadata + ? { + prompt: response.usageMetadata.promptTokenCount ?? 0, + completion: response.usageMetadata.candidatesTokenCount ?? 0 + } + : undefined + } + }; + } + + private async callModel(userParts: Array>): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs); + + try { + const response = await fetch( + `${this.config.apiUrl}/models/${encodeURIComponent(this.config.model)}:generateContent`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-goog-api-key': this.config.apiKey + }, + signal: controller.signal, + body: JSON.stringify({ + systemInstruction: { parts: [{ text: SYSTEM_PROMPT }] }, + contents: [{ role: 'user', parts: userParts }], + generationConfig: { + temperature: 0, + responseMimeType: 'application/json' + } + }) + } + ); + + if (!response.ok) { + throw new Error(`Google AI Studio HTTP ${response.status}`); + } + + return (await response.json()) as GeminiResponse; + } finally { + clearTimeout(timeoutId); + } + } +} + +function buildUserParts(pages: readonly PreparedPage[]): Array> { + const parts: Array> = [ + { text: 'Extract all invoice fields from the following document pages.' } + ]; + + for (const page of pages) { + if (page.image) { + parts.push({ + inline_data: { + mime_type: 'image/png', + data: page.image.toString('base64') + } + }); + } else if (page.text) { + parts.push({ text: `Page ${page.pageNumber}:\n${page.text}` }); + } + } + + return parts.length > 1 ? parts : []; +} + +function parseResponseContent(data: GeminiResponse): unknown { + const raw = data.candidates?.[0]?.content?.parts + ?.map((part) => part.text ?? '') + .join('') + .trim(); + + if (!raw) throw new Error('Empty response from Google AI Studio'); + + try { + return JSON.parse(raw); + } catch { + throw new Error('Google AI Studio returned non-JSON content'); + } +} + +export function createGoogleAiStudioConfig( + env: NodeJS.ProcessEnv = process.env +): GoogleAiStudioConfig { + const apiKey = env.GOOGLE_AI_STUDIO_API_KEY ?? ''; + if (!apiKey) { + throw new Error('GOOGLE_AI_STUDIO_API_KEY environment variable is required'); + } + + return { + apiKey, + model: env.GOOGLE_AI_STUDIO_MODEL ?? DEFAULT_MODEL, + apiUrl: env.GOOGLE_AI_STUDIO_API_URL ?? DEFAULT_API_URL, + timeoutMs: Number(env.GOOGLE_AI_STUDIO_TIMEOUT_MS ?? 60_000), + promptVersion: PROMPT_VERSION + }; +} diff --git a/apps/server/src/adapters/extraction/model-extraction-adapter.ts b/apps/server/src/adapters/extraction/model-extraction-adapter.ts new file mode 100644 index 0000000..d998173 --- /dev/null +++ b/apps/server/src/adapters/extraction/model-extraction-adapter.ts @@ -0,0 +1,19 @@ +import type { PreparedPage } from '@/adapters/documents/document-preparation.js'; +import type { ExtractedInvoice } from '@/domain/invoice-cases/extracted-invoice.js'; + +export interface ModelExtractionMetadata { + model: string; + fallbackUsed: boolean; + promptVersion: string; + durationMs: number; + tokenUsage?: { prompt: number; completion: number }; +} + +export interface ModelExtractionResult { + extracted: ExtractedInvoice; + metadata: ModelExtractionMetadata; +} + +export interface ModelExtractionAdapter { + extract(input: { pages: readonly PreparedPage[] }): Promise; +} diff --git a/apps/server/src/adapters/extraction/openrouter-extraction-adapter.ts b/apps/server/src/adapters/extraction/openrouter-extraction-adapter.ts index 8cd3e2d..e7603da 100644 --- a/apps/server/src/adapters/extraction/openrouter-extraction-adapter.ts +++ b/apps/server/src/adapters/extraction/openrouter-extraction-adapter.ts @@ -1,9 +1,10 @@ -import type { ExtractedInvoice } from '@/domain/invoice-cases/extracted-invoice.js'; - import type { PreparedPage } from '../documents/document-preparation.js'; import { SYSTEM_PROMPT, PROMPT_VERSION } from './extraction-prompt.js'; import { parseExtractedInvoice } from './extraction-schema.js'; +import type { ModelExtractionAdapter, ModelExtractionResult } from './model-extraction-adapter.js'; + +export type { ModelExtractionResult as ExtractionCallResult } from './model-extraction-adapter.js'; const DEFAULT_EXTRACTION_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; @@ -16,19 +17,6 @@ export interface OpenRouterConfig { promptVersion: string; } -export interface ExtractionMetadata { - model: string; - fallbackUsed: boolean; - promptVersion: string; - durationMs: number; - tokenUsage?: { prompt: number; completion: number }; -} - -export interface ExtractionCallResult { - extracted: ExtractedInvoice; - metadata: ExtractionMetadata; -} - interface ModelResponse { model: string; choices?: Array<{ @@ -37,10 +25,10 @@ interface ModelResponse { usage?: { prompt_tokens?: number; completion_tokens?: number }; } -export class OpenRouterExtractionAdapter { +export class OpenRouterExtractionAdapter implements ModelExtractionAdapter { constructor(private readonly config: OpenRouterConfig) {} - async extract(input: { pages: readonly PreparedPage[] }): Promise { + async extract(input: { pages: readonly PreparedPage[] }): Promise { if (!this.config.apiKey) { throw new Error('OpenRouter API key is required'); } diff --git a/apps/server/src/adapters/processing-adapters-factory.test.ts b/apps/server/src/adapters/processing-adapters-factory.test.ts index c4a42e6..6e4ffd0 100644 --- a/apps/server/src/adapters/processing-adapters-factory.test.ts +++ b/apps/server/src/adapters/processing-adapters-factory.test.ts @@ -5,10 +5,11 @@ import { R2DocumentStore } from './documents/r2-document-store.js'; import { createProcessingAdapters } from './processing-adapters-factory.js'; describe('processing adapters factory', () => { - it('returns local store and deterministic extractor when no R2 or OpenRouter config', () => { + it('returns local store and deterministic extractor when no managed config is present', () => { const adapters = createProcessingAdapters({}); expect(adapters.documentStore).toBeInstanceOf(LocalDocumentStore); expect(adapters.realExtractor).toBeUndefined(); + expect(adapters.realExtractorKind).toBeUndefined(); expect(adapters.preparationService).toBeUndefined(); }); @@ -27,6 +28,16 @@ describe('processing adapters factory', () => { OPENROUTER_API_KEY: 'sk-test-key' }); expect(adapters.realExtractor).toBeDefined(); + expect(adapters.realExtractorKind).toBe('openrouter'); + expect(adapters.preparationService).toBeDefined(); + }); + + it('prefers Google AI Studio when its key is present', () => { + const adapters = createProcessingAdapters({ + GOOGLE_AI_STUDIO_API_KEY: 'test-key' + }); + expect(adapters.realExtractor).toBeDefined(); + expect(adapters.realExtractorKind).toBe('google_ai_studio'); expect(adapters.preparationService).toBeDefined(); }); }); diff --git a/apps/server/src/adapters/processing-adapters-factory.ts b/apps/server/src/adapters/processing-adapters-factory.ts index 51eb06d..469bf03 100644 --- a/apps/server/src/adapters/processing-adapters-factory.ts +++ b/apps/server/src/adapters/processing-adapters-factory.ts @@ -4,6 +4,11 @@ import type { DocumentPreparationService } from './documents/document-preparatio import { createDocumentStore } from './documents/document-store-factory.js'; import { createDeterministicInvoiceExtractor } from './extraction/deterministic-extractor.js'; import type { InvoiceExtractionPort } from './extraction/extraction-port.js'; +import { + GoogleAiStudioExtractionAdapter, + createGoogleAiStudioConfig +} from './extraction/google-ai-studio-extraction-adapter.js'; +import type { ModelExtractionAdapter } from './extraction/model-extraction-adapter.js'; import { OpenRouterExtractionAdapter, createOpenRouterConfig @@ -12,16 +17,18 @@ import { export interface ProcessingAdapters { documentStore: DocumentStore; extractor: InvoiceExtractionPort; - realExtractor?: OpenRouterExtractionAdapter; + realExtractor?: ModelExtractionAdapter; + realExtractorKind?: 'google_ai_studio' | 'openrouter'; preparationService?: DocumentPreparationService; } export function createProcessingAdapters(env: NodeJS.ProcessEnv = process.env): ProcessingAdapters { const documentStore = createDocumentStore(env); - if (env.OPENROUTER_API_KEY) { - const config = createOpenRouterConfig(env); - const realExtractor = new OpenRouterExtractionAdapter(config); + if (env.GOOGLE_AI_STUDIO_API_KEY || env.OPENROUTER_API_KEY) { + const realExtractor = env.GOOGLE_AI_STUDIO_API_KEY + ? new GoogleAiStudioExtractionAdapter(createGoogleAiStudioConfig(env)) + : new OpenRouterExtractionAdapter(createOpenRouterConfig(env)); const preparationService = createDocumentPreparationService({ store: documentStore, timeoutMs: Number(env.DOCUMENT_PREPARATION_TIMEOUT_MS ?? 30_000) @@ -30,6 +37,7 @@ export function createProcessingAdapters(env: NodeJS.ProcessEnv = process.env): documentStore, extractor: createDeterministicInvoiceExtractor(), realExtractor, + realExtractorKind: env.GOOGLE_AI_STUDIO_API_KEY ? 'google_ai_studio' : 'openrouter', preparationService }; } diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 9ea4499..7ec46f7 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,7 +1,3 @@ -import { Readable } from 'node:stream'; - -import { PDFDocument, StandardFonts } from 'pdf-lib'; - import { db } from './db/index.js'; import { withTransaction } from './db/transaction.js'; @@ -31,6 +27,7 @@ import { createQuotaService } from './services/quota-service.js'; import { createResetService } from './services/reset-service.js'; import { createDocumentStore } from './adapters/documents/document-store-factory.js'; +import { resolvePublicFixture } from './demo/synthetic-fixtures.js'; import { enqueueInvoiceCasePosting, enqueueInvoiceCaseProcessing, @@ -38,32 +35,10 @@ import { } from './jobs/queue.js'; const documentStore = createDocumentStore(); -const resetService = createResetService(db, env.DEMO_MODE); +const resetService = createResetService(db, env.DEMO_MODE, env.DEMO_RESET_ENABLED); const administrationService = createAdministrationService(db); const quotaService = createQuotaService(db, withTransaction); -async function resolvePublicFixture(fixtureId: string) { - if ( - ![ - 'clean-001', - 'ambiguous-001', - 'high-value-001', - 'foreign-currency-001', - 'duplicate-001' - ].includes(fixtureId) - ) { - throw new Error('Unknown public fixture'); - } - const pdf = await PDFDocument.create(); - const page = pdf.addPage([612, 792]); - const font = await pdf.embedFont(StandardFonts.Helvetica); - page.drawText(`Trestle synthetic fixture: ${fixtureId}`, { x: 48, y: 720, size: 18, font }); - return { - filename: `${fixtureId}.pdf`, - bytes: Readable.from(Buffer.from(await pdf.save())) - }; -} - const app = createApp(); const repos = { @@ -122,7 +97,8 @@ const router = app createAdministrationRouter({ reset: resetService, administration: administrationService, - demoMode: env.DEMO_MODE + demoMode: env.DEMO_MODE, + resetEnabled: env.DEMO_RESET_ENABLED }) ); diff --git a/apps/server/src/db/seeds/demo-reference.ts b/apps/server/src/db/seeds/demo-reference.ts new file mode 100644 index 0000000..f540f3c --- /dev/null +++ b/apps/server/src/db/seeds/demo-reference.ts @@ -0,0 +1,65 @@ +import { sql } from 'drizzle-orm'; + +import type { DatabaseExecutor } from '../transaction.js'; + +const DEMO_VENDOR_ID = '00000000-0000-4000-8000-000000000010'; +const DEMO_PURCHASE_ORDER_ID = '00000000-0000-4000-8000-000000000011'; + +export async function seedDemoReferenceData(database: DatabaseExecutor): Promise { + await database.execute(sql` + INSERT INTO vendors (id, name, normalized_name, active) + VALUES (${DEMO_VENDOR_ID}, 'Acme Supplies', 'acme supplies', true) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + normalized_name = EXCLUDED.normalized_name, + active = EXCLUDED.active, + updated_at = now() + `); + + await database.execute(sql` + INSERT INTO purchase_orders (id, number, vendor_id, currency, remaining_amount_minor, active) + VALUES (${DEMO_PURCHASE_ORDER_ID}, 'PO-1001', ${DEMO_VENDOR_ID}, 'USD', 100000, true) + ON CONFLICT (id) DO UPDATE SET + number = EXCLUDED.number, + vendor_id = EXCLUDED.vendor_id, + currency = EXCLUDED.currency, + remaining_amount_minor = EXCLUDED.remaining_amount_minor, + active = EXCLUDED.active, + updated_at = now() + `); + + await database.execute(sql` + INSERT INTO purchase_order_lines ( + purchase_order_id, + line_number, + description, + remaining_quantity, + remaining_amount_minor + ) + VALUES (${DEMO_PURCHASE_ORDER_ID}, 1, 'Paper', '100', 100000) + ON CONFLICT (purchase_order_id, line_number) DO UPDATE SET + description = EXCLUDED.description, + remaining_quantity = EXCLUDED.remaining_quantity, + remaining_amount_minor = EXCLUDED.remaining_amount_minor, + updated_at = now() + `); + + await database.execute(sql` + INSERT INTO approval_policies ( + id, + high_value_currency, + high_value_threshold_minor, + purchase_order_tolerance_basis_points, + missing_purchase_order_requires_approval, + version + ) + VALUES (1, 'USD', 100000, 200, true, 0) + ON CONFLICT (id) DO UPDATE SET + high_value_currency = EXCLUDED.high_value_currency, + high_value_threshold_minor = EXCLUDED.high_value_threshold_minor, + purchase_order_tolerance_basis_points = EXCLUDED.purchase_order_tolerance_basis_points, + missing_purchase_order_requires_approval = EXCLUDED.missing_purchase_order_requires_approval, + version = EXCLUDED.version, + updated_at = now() + `); +} diff --git a/apps/server/src/demo/synthetic-fixtures.test.ts b/apps/server/src/demo/synthetic-fixtures.test.ts new file mode 100644 index 0000000..75a7022 --- /dev/null +++ b/apps/server/src/demo/synthetic-fixtures.test.ts @@ -0,0 +1,22 @@ +import { PDFDocument } from 'pdf-lib'; +import { describe, expect, it } from 'vitest'; + +import { PUBLIC_FIXTURE_IDS, resolvePublicFixture } from './synthetic-fixtures.js'; + +describe('public synthetic fixtures', () => { + it('returns server-owned invoice PDFs for every public fixture id', async () => { + for (const fixtureId of PUBLIC_FIXTURE_IDS) { + const fixture = await resolvePublicFixture(fixtureId); + const chunks: Buffer[] = []; + for await (const chunk of fixture.bytes) chunks.push(Buffer.from(chunk)); + const pdf = await PDFDocument.load(Buffer.concat(chunks)); + + expect(fixture.filename).toBe(`${fixtureId}.pdf`); + expect(pdf.getPageCount()).toBe(1); + } + }); + + it('rejects unknown fixture ids', async () => { + await expect(resolvePublicFixture('not-a-fixture')).rejects.toThrow('Unknown public fixture'); + }); +}); diff --git a/apps/server/src/demo/synthetic-fixtures.ts b/apps/server/src/demo/synthetic-fixtures.ts new file mode 100644 index 0000000..c5c139e --- /dev/null +++ b/apps/server/src/demo/synthetic-fixtures.ts @@ -0,0 +1,35 @@ +import { Readable } from 'node:stream'; + +import { PDFDocument, StandardFonts } from 'pdf-lib'; + +const CLEAN_INVOICE_PDF_BASE64 = + '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'; + +export const PUBLIC_FIXTURE_IDS = [ + 'clean-001', + 'ambiguous-001', + 'high-value-001', + 'foreign-currency-001', + 'duplicate-001' +] as const; + +export async function resolvePublicFixture(fixtureId: string) { + if (!(PUBLIC_FIXTURE_IDS as readonly string[]).includes(fixtureId)) { + throw new Error('Unknown public fixture'); + } + + let bytes = Buffer.from(CLEAN_INVOICE_PDF_BASE64, 'base64'); + if (fixtureId !== 'clean-001') { + const pdf = await PDFDocument.load(bytes); + const page = pdf.getPages()[0]; + if (!page) throw new Error('Synthetic fixture PDF has no pages'); + const font = await pdf.embedFont(StandardFonts.Helvetica); + page.drawText(`Scenario: ${fixtureId}`, { x: 48, y: 100, size: 10, font }); + bytes = Buffer.from(await pdf.save()); + } + + return { + filename: `${fixtureId}.pdf`, + bytes: Readable.from(bytes) + }; +} diff --git a/apps/server/src/lib/create-app.ts b/apps/server/src/lib/create-app.ts index 3bbd2d5..83a21e7 100644 --- a/apps/server/src/lib/create-app.ts +++ b/apps/server/src/lib/create-app.ts @@ -69,15 +69,15 @@ export default function createApp() { ) ); - if (!env.isTest) { - app.use('*', async (c, next) => { - if (c.req.path === '/health') { - return next(); - } + // if (!env.isTest) { + // app.use('*', async (c, next) => { + // if (c.req.path === '/health') { + // return next(); + // } - return rateLimit(c, next); - }); - } + // return rateLimit(c, next); + // }); + // } app.notFound(notFound); app.onError(onError); diff --git a/apps/server/src/lib/env-config.ts b/apps/server/src/lib/env-config.ts index e269a24..16415df 100644 --- a/apps/server/src/lib/env-config.ts +++ b/apps/server/src/lib/env-config.ts @@ -6,6 +6,8 @@ import * as z from 'zod'; const envFile = process.env.NODE_ENV === 'test' ? '.env.test' : '.env'; dotenv.config({ path: envFile }); +const optionalUrl = z.preprocess((value) => (value === '' ? undefined : value), z.url().optional()); + const envSchema = z .object({ HOST: z.string().min(1).default('localhost'), @@ -40,9 +42,9 @@ const envSchema = z DATABASE_PORT: z.coerce.number().int().positive().default(5432), - DATABASE_USER: z.string(), + DATABASE_USER: z.string().optional(), - DATABASE_PASSWORD: z.string(), // minimum of 16 characters are recommended + DATABASE_PASSWORD: z.string().optional(), // minimum of 16 characters are recommended DATABASE_DB: z.string().default('postgres'), @@ -67,8 +69,15 @@ const envSchema = z DOCUMENT_MAX_PAGES: z.coerce.number().int().positive().max(5).default(5), + DOCUMENT_PREPARATION_TIMEOUT_MS: z.coerce.number().int().positive().default(30_000), + DEMO_MODE: z.enum(['public', 'private']).default('private'), + DEMO_RESET_ENABLED: z + .enum(['true', 'false']) + .default('false') + .transform((value) => value === 'true'), + DEMO_EXTRACTION_QUOTA_PER_ACCOUNT: z.coerce.number().int().positive().default(20), DEMO_EXTRACTION_QUOTA_PER_IP: z.coerce.number().int().positive().default(5), @@ -77,7 +86,24 @@ const envSchema = z DEMO_INTAKE_RATE_LIMIT_MAX: z.coerce.number().int().positive().default(10), DEMO_IP_HMAC_SECRET: z.string().min(16).default('development-demo-ip-secret'), - DEMO_ACCOUNT_ID: z.uuid().default('00000000-0000-4000-8000-000000000001') + DEMO_ACCOUNT_ID: z.uuid().default('00000000-0000-4000-8000-000000000001'), + + GOOGLE_AI_STUDIO_API_KEY: z.string().optional(), + GOOGLE_AI_STUDIO_MODEL: z.string().min(1).default('gemini-2.5-flash'), + GOOGLE_AI_STUDIO_API_URL: z.url().default('https://generativelanguage.googleapis.com/v1beta'), + GOOGLE_AI_STUDIO_TIMEOUT_MS: z.coerce.number().int().positive().default(60_000), + + OPENROUTER_API_KEY: z.string().optional(), + EXTRACTION_API_URL: z.url().default('https://openrouter.ai/api/v1/chat/completions'), + OPENROUTER_PRIMARY_MODEL: z.string().min(1).default('google/gemini-2.5-flash'), + OPENROUTER_FALLBACK_MODEL: z.string().default('openai/gpt-4o'), + OPENROUTER_TIMEOUT_MS: z.coerce.number().int().positive().default(60_000), + + R2_ACCOUNT_ID: z.string().optional(), + R2_ACCESS_KEY_ID: z.string().optional(), + R2_SECRET_ACCESS_KEY: z.string().optional(), + R2_BUCKET_NAME: z.string().optional(), + R2_ENDPOINT: optionalUrl }) .superRefine((env, ctx) => { if (env.DATABASE_URL) return; @@ -92,13 +118,13 @@ const envSchema = z }); } - // if (env.DATABASE_PASSWORD.length < 16) { - // ctx.addIssue({ - // code: 'custom', - // message: 'DATABASE_PASSWORD must be at least 16 characters when DATABASE_URL is not set', - // path: ['DATABASE_PASSWORD'] - // }); - // } + if (!env.DATABASE_PASSWORD) { + ctx.addIssue({ + code: 'custom', + message: 'DATABASE_PASSWORD is required when DATABASE_URL is not set', + path: ['DATABASE_PASSWORD'] + }); + } }); const parsedEnv = envSchema.safeParse(process.env); @@ -118,7 +144,7 @@ const envData = parsedEnv.data; const DATABASE_URL = envData.DATABASE_URL ? envData.DATABASE_URL - : `postgresql://${envData.DATABASE_USER}:${envData.DATABASE_PASSWORD}@${envData.DATABASE_HOST}:${envData.DATABASE_PORT}/${envData.DATABASE_DB}`; + : `postgresql://${encodeURIComponent(envData.DATABASE_USER!)}:${encodeURIComponent(envData.DATABASE_PASSWORD!)}@${envData.DATABASE_HOST}:${envData.DATABASE_PORT}/${envData.DATABASE_DB}`; export type Env = z.infer; diff --git a/apps/server/src/routes/administration/administration.handlers.ts b/apps/server/src/routes/administration/administration.handlers.ts index 2b3214e..777eec7 100644 --- a/apps/server/src/routes/administration/administration.handlers.ts +++ b/apps/server/src/routes/administration/administration.handlers.ts @@ -16,13 +16,15 @@ import type { export function resetDemoHandler( reset: ResetService, - demoMode: string = env.DEMO_MODE + demoMode: string = env.DEMO_MODE, + resetEnabled: boolean = env.DEMO_RESET_ENABLED ): AppRouteHandler { return async (c) => { - if (demoMode !== 'public') { + if (demoMode !== 'public' && !resetEnabled) { return c.json( { - message: 'Demo reset is only available in public demo mode', + message: + 'Demo reset is only available in public demo mode or when DEMO_RESET_ENABLED=true', status: HttpStatus.FORBIDDEN }, HttpStatus.FORBIDDEN diff --git a/apps/server/src/routes/administration/administration.index.ts b/apps/server/src/routes/administration/administration.index.ts index fe8343d..11259db 100644 --- a/apps/server/src/routes/administration/administration.index.ts +++ b/apps/server/src/routes/administration/administration.index.ts @@ -12,12 +12,16 @@ export function createAdministrationRouter(options: { reset: ResetService; administration: AdministrationService; demoMode?: string; + resetEnabled?: boolean; }) { const router = createRouter(); router.use('/administration/*', requireAuthentication); router.use('/administration/*', requirePermission('administration.demo.reset')); return router - .openapi(routes.resetDemo, handlers.resetDemoHandler(options.reset, options.demoMode)) + .openapi( + routes.resetDemo, + handlers.resetDemoHandler(options.reset, options.demoMode, options.resetEnabled) + ) .openapi(routes.getPolicy, handlers.getPolicyHandler(options.administration)) .openapi(routes.updatePolicy, handlers.updatePolicyHandler(options.administration)) .openapi(routes.listVendors, handlers.listVendorsHandler(options.administration)) diff --git a/apps/server/src/routes/administration/administration.test.ts b/apps/server/src/routes/administration/administration.test.ts index f69f2f8..7ead52e 100644 --- a/apps/server/src/routes/administration/administration.test.ts +++ b/apps/server/src/routes/administration/administration.test.ts @@ -14,7 +14,7 @@ const administrator: Actor = { }; const ap: Actor = { ...administrator, role: 'ap_specialist' }; -function buildApp(actor: Actor | null, demoMode: string = 'public') { +function buildApp(actor: Actor | null, demoMode: string = 'public', resetEnabled = false) { const reset = { resetDemoState: vi.fn().mockResolvedValue({ truncated: true, @@ -34,7 +34,8 @@ function buildApp(actor: Actor | null, demoMode: string = 'public') { createAdministrationRouter({ reset: reset as never, administration: administration as never, - demoMode + demoMode, + resetEnabled }), (testApp) => { testApp.use('*', async (c, next) => { @@ -71,7 +72,7 @@ describe('administration reset route', () => { expect(reset.resetDemoState).not.toHaveBeenCalled(); }); - it('rejects demo reset when demo mode is private', async () => { + it('rejects demo reset when private mode is not explicitly enabled', async () => { const { app, reset } = buildApp(administrator, 'private'); const response = await app.request('/administration/demo/reset', { method: 'POST' }); @@ -79,6 +80,14 @@ describe('administration reset route', () => { expect(reset.resetDemoState).not.toHaveBeenCalled(); }); + it('allows an administrator reset when private mode is explicitly enabled', async () => { + const { app, reset } = buildApp(administrator, 'private', true); + const response = await app.request('/administration/demo/reset', { method: 'POST' }); + + expect(response.status).toBe(200); + expect(reset.resetDemoState).toHaveBeenCalledOnce(); + }); + it('reads and updates policy with expected version', async () => { const { app, administration } = buildApp(administrator); administration.getPolicy.mockResolvedValue({ diff --git a/apps/server/src/services/invoice-case-processing.integration.test.ts b/apps/server/src/services/invoice-case-processing.integration.test.ts index b388a3c..73df6ee 100644 --- a/apps/server/src/services/invoice-case-processing.integration.test.ts +++ b/apps/server/src/services/invoice-case-processing.integration.test.ts @@ -83,6 +83,69 @@ describe('Invoice Case clean processing', () => { await pool.end(); }); + it('uses the supplied approval policy during processing validation', async () => { + const userId = randomUUID(); + const sourceDocumentId = randomUUID(); + const contentHash = randomUUID().replaceAll('-', '').padEnd(64, '0'); + await database.insert(usersTable).values({ + id: userId, + email: `${userId}@t8.test`, + displayName: 'T8 AP Specialist', + role: 'ap_specialist', + passwordHash: '$argon2id$v=19$m=19456,t=2,p=1$synthetic$synthetic' + }); + + const repository = createInvoiceCaseRepository(database); + const invoiceCase = await database.transaction((transaction) => + createInvoiceCaseRepository(transaction).createCase({ + ownerId: userId, + sourceDocument: { + contentHash, + objectKey: `documents/${sourceDocumentId}`, + filename: 'high-value-invoice.pdf', + mimeType: 'application/pdf', + sizeBytes: 128, + pageCount: 1, + uploadedBy: userId + } + }) + ); + const service = createInvoiceCaseProcessingService({ + connectionString: env.DATABASE_URL, + transaction: (work) => database.transaction(work), + createRepository: createInvoiceCaseRepository, + repository, + extractor: createDeterministicInvoiceExtractor(), + references: { + vendors: [{ normalizedName: 'Acme Supplies' }], + purchaseOrders: [{ number: 'PO-1001', vendorName: 'Acme Supplies', currency: 'USD' }] + }, + loadValidationContext: async () => ({ + references: { + vendors: [{ normalizedName: 'Acme Supplies' }], + purchaseOrders: [{ number: 'PO-1001', vendorName: 'Acme Supplies', currency: 'USD' }] + }, + policy: { + highValueThreshold: { currency: 'USD', amountMinor: 10_000 }, + purchaseOrderTolerancePercent: 2, + missingPurchaseOrderRequiresApproval: true + } + }) + }); + + await service.process({ caseId: invoiceCase.id, expectedVersion: 0 }); + const findings = await database + .select() + .from(validationFindingsTable) + .where(eq(validationFindingsTable.invoiceCaseId, invoiceCase.id)); + + expect(findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ code: 'high_value_invoice', severity: 'approval_required' }) + ]) + ); + }); + it('persists clean workflow effects once and replays without duplicates', async () => { const userId = randomUUID(); const sourceDocumentId = randomUUID(); diff --git a/apps/server/src/services/invoice-case-processing.ts b/apps/server/src/services/invoice-case-processing.ts index d7edae5..3e05767 100644 --- a/apps/server/src/services/invoice-case-processing.ts +++ b/apps/server/src/services/invoice-case-processing.ts @@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto'; import type { DocumentPreparationService } from '@/adapters/documents/document-preparation-service.js'; import { CLEAN_INVOICE_FIXTURE_ID } from '@/adapters/extraction/deterministic-extractor.js'; import type { InvoiceExtractionPort } from '@/adapters/extraction/extraction-port.js'; -import type { OpenRouterExtractionAdapter } from '@/adapters/extraction/openrouter-extraction-adapter.js'; +import type { ModelExtractionAdapter } from '@/adapters/extraction/model-extraction-adapter.js'; import { acceptExtractedInvoice, normalizeInvoiceNumber, @@ -24,6 +24,8 @@ import type { DatabaseExecutor } from '@/db/transaction.js'; import type { InvoiceCaseRepository } from '@/repositories/invoice-case.repository.js'; +import type { InvoiceValidationContext } from './invoice-validation-context.js'; + export interface InvoiceCaseProcessingService { process(input: InvoiceCaseProcessingPayload): Promise<{ status: 'suspended' | 'already_processed' | 'extraction_failed'; @@ -40,12 +42,14 @@ export interface InvoiceCaseProcessingDependencies { createRepository: (executor: DatabaseExecutor) => InvoiceCaseRepository; repository: InvoiceCaseRepository; extractor: InvoiceExtractionPort; - realExtractor?: OpenRouterExtractionAdapter; + realExtractor?: ModelExtractionAdapter; + realExtractorKind?: 'google_ai_studio' | 'openrouter'; preparationService?: DocumentPreparationService; references: { vendors: readonly VendorReference[]; purchaseOrders: readonly PurchaseOrderReference[]; }; + loadValidationContext?: () => Promise; } export function createInvoiceCaseProcessingService( @@ -94,14 +98,20 @@ export function createInvoiceCaseProcessingService( extracted: await dependencies.extractor.extract({ fixtureId: CLEAN_INVOICE_FIXTURE_ID }) }; }, - validate: async (input) => ({ - ...input, - accepted: acceptExtractedInvoice({ - revisionId: randomUUID(), - extracted: input.extracted, - references: dependencies.references - }) - }), + validate: async (input) => { + const context = dependencies.loadValidationContext + ? await dependencies.loadValidationContext() + : { references: dependencies.references }; + return { + ...input, + accepted: acceptExtractedInvoice({ + revisionId: randomUUID(), + extracted: input.extracted, + references: context.references, + policy: context.policy + }) + }; + }, persist: async (input) => { let findings = input.accepted.findings; const draft = input.accepted.revision.draft; @@ -130,7 +140,10 @@ export function createInvoiceCaseProcessingService( revision: input.accepted.revision, evidence: input.accepted.evidence, findings, - adapter: 'extractionMeta' in input ? 'openrouter' : 'deterministic', + adapter: + 'extractionMeta' in input + ? (dependencies.realExtractorKind ?? 'openrouter') + : 'deterministic', extractionMetadata: 'extractionMeta' in input ? (input as { extractionMeta: Record }).extractionMeta diff --git a/apps/server/src/services/invoice-validation-context.ts b/apps/server/src/services/invoice-validation-context.ts new file mode 100644 index 0000000..fe7a9f0 --- /dev/null +++ b/apps/server/src/services/invoice-validation-context.ts @@ -0,0 +1,98 @@ +import { eq, inArray } from 'drizzle-orm'; + +import type { ApprovalPolicy } from '@/domain/approval-policy/types.js'; +import type { InvoiceValidationReferences } from '@/domain/invoice-cases/validation.js'; + +import type { db } from '@/db/index.js'; +import { + approvalPoliciesTable, + purchaseOrderLinesTable, + purchaseOrdersTable, + vendorsTable +} from '@/db/schemas/invoice-cases.js'; + +export interface InvoiceValidationContext { + readonly references: InvoiceValidationReferences; + readonly policy?: ApprovalPolicy; +} + +type Database = typeof db; + +export function createInvoiceValidationContextLoader(database: Database) { + return async (): Promise => { + const vendors = await database + .select({ + id: vendorsTable.id, + normalizedName: vendorsTable.name, + taxId: vendorsTable.taxId, + active: vendorsTable.active + }) + .from(vendorsTable); + + const purchaseOrders = await database + .select({ + id: purchaseOrdersTable.id, + number: purchaseOrdersTable.number, + vendorId: purchaseOrdersTable.vendorId, + vendorName: vendorsTable.name, + currency: purchaseOrdersTable.currency, + active: purchaseOrdersTable.active, + remainingAmountMinor: purchaseOrdersTable.remainingAmountMinor + }) + .from(purchaseOrdersTable) + .innerJoin(vendorsTable, eq(purchaseOrdersTable.vendorId, vendorsTable.id)); + + const purchaseOrderIds = purchaseOrders.map((purchaseOrder) => purchaseOrder.id); + const lines = + purchaseOrderIds.length > 0 + ? await database + .select({ + purchaseOrderId: purchaseOrderLinesTable.purchaseOrderId, + lineNumber: purchaseOrderLinesTable.lineNumber, + itemCode: purchaseOrderLinesTable.itemCode, + description: purchaseOrderLinesTable.description, + remainingQuantity: purchaseOrderLinesTable.remainingQuantity, + remainingAmountMinor: purchaseOrderLinesTable.remainingAmountMinor + }) + .from(purchaseOrderLinesTable) + .where(inArray(purchaseOrderLinesTable.purchaseOrderId, purchaseOrderIds)) + : []; + const linesByPurchaseOrder = new Map(); + for (const line of lines) { + const existing = linesByPurchaseOrder.get(line.purchaseOrderId) ?? []; + existing.push(line); + linesByPurchaseOrder.set(line.purchaseOrderId, existing); + } + + const [policy] = await database + .select() + .from(approvalPoliciesTable) + .where(eq(approvalPoliciesTable.id, 1)); + + return { + references: { + vendors, + purchaseOrders: purchaseOrders.map((purchaseOrder) => ({ + ...purchaseOrder, + lines: (linesByPurchaseOrder.get(purchaseOrder.id) ?? []).map((line) => ({ + lineNumber: line.lineNumber, + itemCode: line.itemCode ?? undefined, + description: line.description, + remainingQuantity: line.remainingQuantity, + remainingAmountMinor: line.remainingAmountMinor + })) + })) + }, + policy: policy + ? { + highValueThreshold: { + currency: policy.highValueCurrency, + amountMinor: policy.highValueThresholdMinor + }, + purchaseOrderTolerancePercent: policy.purchaseOrderToleranceBasisPoints / 100, + missingPurchaseOrderRequiresApproval: policy.missingPurchaseOrderRequiresApproval + } + : undefined + }; + }; +} diff --git a/apps/server/src/services/reset-service.test.ts b/apps/server/src/services/reset-service.test.ts index 89d8483..8f8d741 100644 --- a/apps/server/src/services/reset-service.test.ts +++ b/apps/server/src/services/reset-service.test.ts @@ -44,16 +44,27 @@ describe('reset service', () => { expect(result.policyRestored).toBe(true); }); - it('resetDemoState refuses to run when demo mode is not public', async () => { + it('resetDemoState refuses to run when private mode is not explicitly enabled', async () => { const database = makeDatabase(); const service = createResetService(database as never, 'private'); await expect(service.resetDemoState()).rejects.toThrow( - 'Demo reset is only available in public demo mode' + 'Demo reset is only available in public demo mode or when DEMO_RESET_ENABLED=true' ); expect(database.execute).not.toHaveBeenCalled(); }); + it('resetDemoState runs when private mode is explicitly enabled', async () => { + const database = makeDatabase(); + const service = createResetService(database as never, 'private', true); + + await expect(service.resetDemoState()).resolves.toMatchObject({ + truncated: true, + policyRestored: true + }); + expect(database.execute).toHaveBeenCalledTimes(5); + }); + it('cleanOrphanedDocuments never deletes referenced keys', async () => { let callCount = 0; const database = makeDatabase(); diff --git a/apps/server/src/services/reset-service.ts b/apps/server/src/services/reset-service.ts index a64b5c3..488645f 100644 --- a/apps/server/src/services/reset-service.ts +++ b/apps/server/src/services/reset-service.ts @@ -2,6 +2,7 @@ import { sql } from 'drizzle-orm'; import type { DocumentStore } from '@/adapters/documents/document-port.js'; +import { seedDemoReferenceData } from '@/db/seeds/demo-reference.js'; import type { DatabaseExecutor } from '@/db/transaction.js'; export interface ResetResult { @@ -11,11 +12,17 @@ export interface ResetResult { readonly orphanedKeys: readonly string[]; } -export function createResetService(database: DatabaseExecutor, demoMode: string = 'private') { +export function createResetService( + database: DatabaseExecutor, + demoMode: string = 'private', + resetEnabled = false +) { return { resetDemoState: async (): Promise => { - if (demoMode !== 'public') { - throw new Error('Demo reset is only available in public demo mode'); + if (demoMode !== 'public' && !resetEnabled) { + throw new Error( + 'Demo reset is only available in public demo mode or when DEMO_RESET_ENABLED=true' + ); } await database.execute(sql` @@ -38,28 +45,7 @@ export function createResetService(database: DatabaseExecutor, demoMode: string CASCADE `); - await database.execute(sql` - INSERT INTO vendors (id, name, normalized_name, active) - VALUES ('00000000-0000-4000-8000-000000000010', 'Acme Supplies', 'acme supplies', true) - `); - await database.execute(sql` - INSERT INTO purchase_orders (id, number, vendor_id, currency, remaining_amount_minor, active) - VALUES ('00000000-0000-4000-8000-000000000011', 'PO-1001', '00000000-0000-4000-8000-000000000010', 'USD', 100000, true) - `); - await database.execute(sql` - INSERT INTO purchase_order_lines (purchase_order_id, line_number, description, remaining_quantity, remaining_amount_minor) - VALUES ('00000000-0000-4000-8000-000000000011', 1, 'Office supplies', '100', 100000) - `); - await database.execute(sql` - INSERT INTO approval_policies (id, high_value_currency, high_value_threshold_minor, purchase_order_tolerance_basis_points, missing_purchase_order_requires_approval, version) - VALUES (1, 'USD', 100000, 200, true, 0) - ON CONFLICT (id) DO UPDATE SET - high_value_currency = EXCLUDED.high_value_currency, - high_value_threshold_minor = EXCLUDED.high_value_threshold_minor, - purchase_order_tolerance_basis_points = EXCLUDED.purchase_order_tolerance_basis_points, - missing_purchase_order_requires_approval = EXCLUDED.missing_purchase_order_requires_approval, - version = EXCLUDED.version - `); + await seedDemoReferenceData(database); return { truncated: true, diff --git a/apps/server/src/worker.ts b/apps/server/src/worker.ts index e33c9bf..d114331 100644 --- a/apps/server/src/worker.ts +++ b/apps/server/src/worker.ts @@ -7,6 +7,7 @@ import { createInvoiceCaseRepository } from './repositories/invoice-case.reposit import { createInvoiceCasePostingService } from './services/invoice-case-posting.js'; import { createInvoiceCaseProcessingService } from './services/invoice-case-processing.js'; +import { createInvoiceValidationContextLoader } from './services/invoice-validation-context.js'; import { createResetService } from './services/reset-service.js'; import { createDeterministicAccountingSystem } from './adapters/accounting/deterministic-accounting.js'; @@ -22,18 +23,20 @@ const processor = createInvoiceCaseProcessingService({ repository, extractor: adapters.extractor, realExtractor: adapters.realExtractor, + realExtractorKind: adapters.realExtractorKind, preparationService: adapters.preparationService, references: { vendors: [{ normalizedName: 'Acme Supplies' }], purchaseOrders: [{ number: 'PO-1001', vendorName: 'Acme Supplies', currency: 'USD' }] - } + }, + loadValidationContext: createInvoiceValidationContextLoader(db) }); const posting = createInvoiceCasePostingService({ transaction: withTransaction, createRepository: createInvoiceCaseRepository, accounting: createDeterministicAccountingSystem() }); -const reset = createResetService(db, env.DEMO_MODE); +const reset = createResetService(db, env.DEMO_MODE, env.DEMO_RESET_ENABLED); const runner = await startWorker({ taskList: createInvoiceCaseProcessingTaskList({ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 1f6ddcb..e8be4f2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -24,11 +24,20 @@ services: environment: NODE_ENV: production DEMO_MODE: ${DEMO_MODE:-private} + DATABASE_URL: ${DATABASE_URL:-} DATABASE_HOST: postgres DATABASE_USER: ${POSTGRES_USER} DATABASE_PASSWORD: ${POSTGRES_PASSWORD} DATABASE_DB: ${POSTGRES_DB} CORS_ORIGINS: ${CORS_ORIGINS} + GOOGLE_AI_STUDIO_API_KEY: ${GOOGLE_AI_STUDIO_API_KEY:-} + GOOGLE_AI_STUDIO_MODEL: ${GOOGLE_AI_STUDIO_MODEL:-gemini-2.5-flash} + GOOGLE_AI_STUDIO_TIMEOUT_MS: ${GOOGLE_AI_STUDIO_TIMEOUT_MS:-60000} + R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-} + R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-} + R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-} + R2_BUCKET_NAME: ${R2_BUCKET_NAME:-} + R2_ENDPOINT: ${R2_ENDPOINT:-} DOCUMENT_STORAGE_ROOT: /var/lib/trestle/documents volumes: - document_data:/var/lib/trestle/documents @@ -50,11 +59,20 @@ services: environment: NODE_ENV: production DEMO_MODE: ${DEMO_MODE:-private} + DATABASE_URL: ${DATABASE_URL:-} DATABASE_HOST: postgres DATABASE_USER: ${POSTGRES_USER} DATABASE_PASSWORD: ${POSTGRES_PASSWORD} DATABASE_DB: ${POSTGRES_DB} CORS_ORIGINS: ${CORS_ORIGINS} + GOOGLE_AI_STUDIO_API_KEY: ${GOOGLE_AI_STUDIO_API_KEY:-} + GOOGLE_AI_STUDIO_MODEL: ${GOOGLE_AI_STUDIO_MODEL:-gemini-2.5-flash} + GOOGLE_AI_STUDIO_TIMEOUT_MS: ${GOOGLE_AI_STUDIO_TIMEOUT_MS:-60000} + R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-} + R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-} + R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-} + R2_BUCKET_NAME: ${R2_BUCKET_NAME:-} + R2_ENDPOINT: ${R2_ENDPOINT:-} DOCUMENT_STORAGE_ROOT: /var/lib/trestle/documents volumes: - document_data:/var/lib/trestle/documents diff --git a/docker-compose.yml b/docker-compose.yml index 6048531..640e02f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,11 +26,20 @@ services: environment: NODE_ENV: development DEMO_MODE: ${DEMO_MODE:-private} + DATABASE_URL: ${DATABASE_URL:-} DATABASE_HOST: postgres DATABASE_USER: ${POSTGRES_USER} DATABASE_PASSWORD: ${POSTGRES_PASSWORD} DATABASE_DB: ${POSTGRES_DB} CORS_ORIGINS: http://localhost:3001 + GOOGLE_AI_STUDIO_API_KEY: ${GOOGLE_AI_STUDIO_API_KEY:-} + GOOGLE_AI_STUDIO_MODEL: ${GOOGLE_AI_STUDIO_MODEL:-gemini-2.5-flash} + GOOGLE_AI_STUDIO_TIMEOUT_MS: ${GOOGLE_AI_STUDIO_TIMEOUT_MS:-60000} + R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-} + R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-} + R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-} + R2_BUCKET_NAME: ${R2_BUCKET_NAME:-} + R2_ENDPOINT: ${R2_ENDPOINT:-} LOGIN_RATE_LIMIT_MAX: 100 RATE_LIMIT_MAX: 500 DOCUMENT_STORAGE_ROOT: /var/lib/trestle/documents @@ -56,10 +65,19 @@ services: environment: NODE_ENV: development DEMO_MODE: ${DEMO_MODE:-private} + DATABASE_URL: ${DATABASE_URL:-} DATABASE_HOST: postgres DATABASE_USER: ${POSTGRES_USER} DATABASE_PASSWORD: ${POSTGRES_PASSWORD} DATABASE_DB: ${POSTGRES_DB} + GOOGLE_AI_STUDIO_API_KEY: ${GOOGLE_AI_STUDIO_API_KEY:-} + GOOGLE_AI_STUDIO_MODEL: ${GOOGLE_AI_STUDIO_MODEL:-gemini-2.5-flash} + GOOGLE_AI_STUDIO_TIMEOUT_MS: ${GOOGLE_AI_STUDIO_TIMEOUT_MS:-60000} + R2_ACCOUNT_ID: ${R2_ACCOUNT_ID:-} + R2_ACCESS_KEY_ID: ${R2_ACCESS_KEY_ID:-} + R2_SECRET_ACCESS_KEY: ${R2_SECRET_ACCESS_KEY:-} + R2_BUCKET_NAME: ${R2_BUCKET_NAME:-} + R2_ENDPOINT: ${R2_ENDPOINT:-} DOCUMENT_STORAGE_ROOT: /var/lib/trestle/documents volumes: - ./apps/server:/app/apps/server From 6d03be14955abc10d83c1e9e3748713975e8f6e7 Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sun, 2 Aug 2026 15:20:40 +0700 Subject: [PATCH 2/5] fix(web): clarify invoice workflow actions --- apps/web/src/components/ui/loading-label.tsx | 11 +++++ .../features/invoice-cases/api/commands.ts | 4 +- .../components/finance-decision-control.tsx | 26 +++++++---- .../components/invoice-case-action-bar.tsx | 45 ++++++++++++++---- .../components/invoice-correction-form.tsx | 24 ++++++++-- .../components/invoice-draft-panel.tsx | 11 +++-- .../components/rejection-dialog.tsx | 11 +++-- apps/web/src/locales/en/common.json | 3 +- apps/web/src/locales/id/common.json | 3 +- .../__tests__/invoice-intake-page.test.tsx | 41 +++++++++++++++++ apps/web/src/pages/administration.tsx | 46 +++++++++++++------ apps/web/src/pages/invoice-intake.tsx | 34 +++++++------- 12 files changed, 196 insertions(+), 63 deletions(-) create mode 100644 apps/web/src/components/ui/loading-label.tsx diff --git a/apps/web/src/components/ui/loading-label.tsx b/apps/web/src/components/ui/loading-label.tsx new file mode 100644 index 0000000..96cb0da --- /dev/null +++ b/apps/web/src/components/ui/loading-label.tsx @@ -0,0 +1,11 @@ +import { CircleNotchIcon } from '@phosphor-icons/react'; +import type { ReactNode } from 'react'; + +export function LoadingLabel({ children }: { children: ReactNode }) { + return ( + + + ); +} diff --git a/apps/web/src/features/invoice-cases/api/commands.ts b/apps/web/src/features/invoice-cases/api/commands.ts index 7011d86..e22701a 100644 --- a/apps/web/src/features/invoice-cases/api/commands.ts +++ b/apps/web/src/features/invoice-cases/api/commands.ts @@ -84,9 +84,7 @@ export type ReprocessInvoiceCaseInput = { }; export async function reprocessInvoiceCase({ caseId, expectedVersion }: ReprocessInvoiceCaseInput) { - const response = await apiClient.post(`invoice-cases/${caseId}/reprocess`, { - json: { expectedVersion } - }); + const response = await apiClient.post(`invoice-cases/${caseId}/reprocess`, { expectedVersion }); return readApiResponse>(response); } diff --git a/apps/web/src/features/invoice-cases/components/finance-decision-control.tsx b/apps/web/src/features/invoice-cases/components/finance-decision-control.tsx index 96ce1b4..048bc48 100644 --- a/apps/web/src/features/invoice-cases/components/finance-decision-control.tsx +++ b/apps/web/src/features/invoice-cases/components/finance-decision-control.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; +import { LoadingLabel } from '@/components/ui/loading-label'; import type { InvoiceCaseDetail } from '../api/types'; @@ -72,6 +73,7 @@ export function FinanceDecisionControl({ detail }: FinanceDecisionControlProps) type="button" variant="ghost" size="sm" + disabled={financeMutation.isPending} onClick={() => { setDialog(null); setExplanation(''); @@ -84,15 +86,18 @@ export function FinanceDecisionControl({ detail }: FinanceDecisionControlProps) variant="destructive" size="sm" disabled={!canSubmit} + aria-busy={financeMutation.isPending} onClick={() => submit(dialog)} > - {financeMutation.isPending - ? isReturn - ? t('review.finance.returning') - : t('review.finance.rejecting') - : isReturn - ? t('review.finance.confirmReturn') - : t('review.finance.confirmReject')} + {financeMutation.isPending ? ( + + {isReturn ? t('review.finance.returning') : t('review.finance.rejecting')} + + ) : isReturn ? ( + t('review.finance.confirmReturn') + ) : ( + t('review.finance.confirmReject') + )} @@ -106,9 +111,14 @@ export function FinanceDecisionControl({ detail }: FinanceDecisionControlProps) variant="default" size="lg" disabled={financeMutation.isPending} + aria-busy={financeMutation.isPending} onClick={() => submit('approve')} > - {financeMutation.isPending ? t('review.finance.approving') : t('review.finance.approve')} + {financeMutation.isPending ? ( + {t('review.finance.approving')} + ) : ( + t('review.finance.approve') + )} ); @@ -289,8 +300,12 @@ function ActionControl({ return (
-
); @@ -300,8 +315,18 @@ function ActionControl({ return (
-
); @@ -311,8 +336,12 @@ function ActionControl({ return (
-
); diff --git a/apps/web/src/features/invoice-cases/components/invoice-correction-form.tsx b/apps/web/src/features/invoice-cases/components/invoice-correction-form.tsx index e68cae5..24d5770 100644 --- a/apps/web/src/features/invoice-cases/components/invoice-correction-form.tsx +++ b/apps/web/src/features/invoice-cases/components/invoice-correction-form.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; +import { LoadingLabel } from '@/components/ui/loading-label'; import type { CorrectionRevisionInput, InvoiceCaseDetail } from '../api/types'; @@ -299,13 +300,26 @@ export function InvoiceCorrectionForm({ detail, onCancel }: InvoiceCorrectionFor
- -
diff --git a/apps/web/src/features/invoice-cases/components/invoice-draft-panel.tsx b/apps/web/src/features/invoice-cases/components/invoice-draft-panel.tsx index 2b1398d..e2e1587 100644 --- a/apps/web/src/features/invoice-cases/components/invoice-draft-panel.tsx +++ b/apps/web/src/features/invoice-cases/components/invoice-draft-panel.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { LoadingLabel } from '@/components/ui/loading-label'; import { cn } from '@/lib/utils'; @@ -314,6 +315,7 @@ function FindingResolveControl({ type="button" variant="ghost" size="sm" + disabled={resolveMutation.isPending} onClick={() => { setOpen(false); setText(''); @@ -325,6 +327,7 @@ function FindingResolveControl({ type="button" size="sm" disabled={!canSubmit} + aria-busy={resolveMutation.isPending} onClick={() => resolveMutation.mutate({ caseId: detail.id, @@ -334,9 +337,11 @@ function FindingResolveControl({ }) } > - {resolveMutation.isPending - ? t('review.findings.resolutionResolving') - : t('review.findings.resolutionConfirm')} + {resolveMutation.isPending ? ( + {t('review.findings.resolutionResolving')} + ) : ( + t('review.findings.resolutionConfirm') + )} diff --git a/apps/web/src/features/invoice-cases/components/rejection-dialog.tsx b/apps/web/src/features/invoice-cases/components/rejection-dialog.tsx index 561906b..e7479be 100644 --- a/apps/web/src/features/invoice-cases/components/rejection-dialog.tsx +++ b/apps/web/src/features/invoice-cases/components/rejection-dialog.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; +import { LoadingLabel } from '@/components/ui/loading-label'; import type { InvoiceCaseDetail } from '../api/types'; @@ -68,6 +69,7 @@ export function RejectionDialog({ detail }: RejectionDialogProps) { type="button" variant="ghost" size="sm" + disabled={rejectMutation.isPending} onClick={() => { setOpen(false); setReason(''); @@ -80,6 +82,7 @@ export function RejectionDialog({ detail }: RejectionDialogProps) { variant="destructive" size="sm" disabled={!canSubmit} + aria-busy={rejectMutation.isPending} onClick={() => rejectMutation.mutate({ caseId: detail.id, @@ -88,9 +91,11 @@ export function RejectionDialog({ detail }: RejectionDialogProps) { }) } > - {rejectMutation.isPending - ? t('review.rejection.rejecting') - : t('review.rejection.confirm')} + {rejectMutation.isPending ? ( + {t('review.rejection.rejecting')} + ) : ( + t('review.rejection.confirm') + )} diff --git a/apps/web/src/locales/en/common.json b/apps/web/src/locales/en/common.json index dcb77b6..e07b584 100644 --- a/apps/web/src/locales/en/common.json +++ b/apps/web/src/locales/en/common.json @@ -376,7 +376,8 @@ "action": "Reset demo", "confirmTitle": "Reset the demo state?", "confirmDescription": "This action removes current demo work and cannot be undone.", - "confirm": "Confirm reset" + "confirm": "Confirm reset", + "confirming": "Resetting demo…" }, "empty": { "title": "Administration is ready for configuration", diff --git a/apps/web/src/locales/id/common.json b/apps/web/src/locales/id/common.json index 996c336..74880b2 100644 --- a/apps/web/src/locales/id/common.json +++ b/apps/web/src/locales/id/common.json @@ -379,7 +379,8 @@ "action": "Reset demo", "confirmTitle": "Reset status demo?", "confirmDescription": "Tindakan ini menghapus pekerjaan demo saat ini dan tidak dapat dibatalkan.", - "confirm": "Konfirmasi reset" + "confirm": "Konfirmasi reset", + "confirming": "Mereset demo…" }, "empty": { "title": "Administrasi siap dikonfigurasi", diff --git a/apps/web/src/pages/__tests__/invoice-intake-page.test.tsx b/apps/web/src/pages/__tests__/invoice-intake-page.test.tsx index c3909a9..ec4a81c 100644 --- a/apps/web/src/pages/__tests__/invoice-intake-page.test.tsx +++ b/apps/web/src/pages/__tests__/invoice-intake-page.test.tsx @@ -20,6 +20,46 @@ describe('InvoiceIntakePage', () => { expect( await screen.findByText('Invoice received and queued for processing.') ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Submit invoice' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Cancel' })).not.toBeInTheDocument(); + expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument(); + }); + + it('hides submit and cancel while the upload is in progress', async () => { + const user = userEvent.setup(); + let resolveRequest!: () => void; + const requestFinished = new Promise((resolve) => { + resolveRequest = resolve; + }); + server.use( + http.post('http://localhost:3000/invoice-cases/intake', async () => { + await requestFinished; + return HttpResponse.json({ + result: { + invoiceCase: { + id: '11111111-1111-4111-8111-111111111111', + state: 'received', + version: 0 + }, + duplicate: false + }, + message: 'Invoice Case created', + status: 201 + }); + }) + ); + + renderPage({ initialPath: '/cases/new' }); + await user.upload(await screen.findByLabelText('Source Document'), invoiceFile()); + await user.click(screen.getByRole('button', { name: 'Submit invoice' })); + + expect(screen.queryByRole('button', { name: 'Submit invoice' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Cancel' })).not.toBeInTheDocument(); + + resolveRequest(); + expect( + await screen.findByText('Invoice received and queued for processing.') + ).toBeInTheDocument(); }); it('links exact duplicates to the existing Invoice Case', async () => { @@ -52,6 +92,7 @@ describe('InvoiceIntakePage', () => { 'href', '/cases/22222222-2222-4222-8222-222222222222' ); + expect(screen.queryByText('invoice.pdf')).not.toBeInTheDocument(); }); it('shows upload failure without losing selected file', async () => { diff --git a/apps/web/src/pages/administration.tsx b/apps/web/src/pages/administration.tsx index fe8029c..fe9d2b2 100644 --- a/apps/web/src/pages/administration.tsx +++ b/apps/web/src/pages/administration.tsx @@ -1,6 +1,8 @@ import { useState, type FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/button'; +import { LoadingLabel } from '@/components/ui/loading-label'; import { useAdministrationPolicy, useAdministrationPurchaseOrders, @@ -123,9 +125,17 @@ export function AdministrationPage() { {t('administration.policy.missingPo')}
- + {updatePolicy.isError ? (

{updatePolicy.error instanceof ApiError && updatePolicy.error.status === 409 @@ -197,13 +207,15 @@ export function AdministrationPage() {

{t('administration.reset.description')}

- +
@@ -219,23 +231,31 @@ export function AdministrationPage() { {t('administration.reset.confirmDescription')}

- - + {resetDemo.isPending ? ( + {t('administration.reset.confirming')} + ) : ( + t('administration.reset.confirm') + )} +
diff --git a/apps/web/src/pages/invoice-intake.tsx b/apps/web/src/pages/invoice-intake.tsx index 52ab0a6..deaea43 100644 --- a/apps/web/src/pages/invoice-intake.tsx +++ b/apps/web/src/pages/invoice-intake.tsx @@ -110,7 +110,7 @@ export function InvoiceIntakePage() {

) : null} - {file ? ( + {file && !result ? (
) : null} -
- - - {t('intake.actions.cancel')} - -
+ {!submit.isPending && !result ? ( +
+ + + {t('intake.actions.cancel')} + +
+ ) : null}
); From 26c5ffde143477deb19a6b2884decfc5e49bb706 Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sun, 2 Aug 2026 15:20:48 +0700 Subject: [PATCH 3/5] docs: add complete hosted demo runbook --- DEMO_GUIDE.md | 264 ++++++++ README.md | 568 +++++++++--------- examples/invoices/README.md | 22 + examples/invoices/acme-clean-invoice-002.pdf | Bin 0 -> 2052 bytes .../acme-currency-mismatch-invoice-004.pdf | Bin 0 -> 2051 bytes examples/invoices/acme-duplicate-invoice.pdf | Bin 0 -> 2163 bytes .../invoices/acme-missing-po-invoice-003.pdf | Bin 0 -> 1991 bytes examples/invoices/clean-acme-invoice.pdf | Bin 0 -> 1663 bytes 8 files changed, 569 insertions(+), 285 deletions(-) create mode 100644 DEMO_GUIDE.md create mode 100644 examples/invoices/README.md create mode 100644 examples/invoices/acme-clean-invoice-002.pdf create mode 100644 examples/invoices/acme-currency-mismatch-invoice-004.pdf create mode 100644 examples/invoices/acme-duplicate-invoice.pdf create mode 100644 examples/invoices/acme-missing-po-invoice-003.pdf create mode 100644 examples/invoices/clean-acme-invoice.pdf diff --git a/DEMO_GUIDE.md b/DEMO_GUIDE.md new file mode 100644 index 0000000..aa05ba1 --- /dev/null +++ b/DEMO_GUIDE.md @@ -0,0 +1,264 @@ +# Trestle demo guide + +This guide is the click-by-click walkthrough for a hosted or local Trestle +demonstration. Use only the synthetic PDFs in `examples/invoices/`. + +## Before you start + +You need: + +- A running web app and API at `http://localhost:3001` and `http://localhost:3000`. +- A running worker. The API accepts uploads, but the worker moves cases beyond + `Received`. +- Three seeded accounts with the same password. Create or update them with: + + ```bash + SEED_USER_PASSWORD='<12+ character password>' pnpm --filter server db:seed + ``` + +- An administrator reset enabled. For local private mode, set + `DEMO_RESET_ENABLED=true` and restart the API. Public mode enables reset by + default. + +Open the app at . + +## Demo accounts + +| Role | Email | Use it for | +| ---------------- | ------------------------------- | ------------------------------------------------------------ | +| AP Specialist | `ap.specialist@trestle.demo` | Upload, review, correct, confirm, reject, and post | +| Finance Approver | `finance.approver@trestle.demo` | Approve, return, or reject policy exceptions | +| Administrator | `administrator@trestle.demo` | Configure policy, inspect reference data, and reset the demo | + +The password is intentionally not stored in the repository. + +## Seeded reference data + +A reset restores this baseline: + +- Vendor: `Acme Supplies` +- Purchase Order: `PO-1001` +- Currency: `USD` +- Remaining Purchase Order amount: `USD 1,000.00` +- One Purchase Order line: `Paper`, quantity `100` +- High-value approval threshold: `USD 1,000.00` +- Purchase Order tolerance: `2%` +- Missing Purchase Order requires Finance approval: enabled + +Reset between scenarios unless a scenario explicitly says to build on the +previous case. Reset removes cases, documents, findings, decisions, posting +attempts, and quotas; it restores the reference data above. + +## Scenario map + +| Scenario | File | Main path | Expected outcome | +| ---------------------- | ---------------------------------------- | ---------------------------------------------------------- | --------------------------------------- | +| Clean AP posting | `clean-acme-invoice.pdf` | AP review → confirm → post | `Posted` | +| High-value approval | `acme-clean-invoice-002.pdf` | AP review → Finance approval → post | `Posted` | +| Finance return | `acme-clean-invoice-002.pdf` | AP → Finance return → correction → Finance approval → post | `Posted` | +| Missing Purchase Order | `acme-missing-po-invoice-003.pdf` | AP review → Finance approval, or warning-only variant | `Posted` or `Awaiting Finance Approval` | +| Currency mismatch | `acme-currency-mismatch-invoice-004.pdf` | AP review → Blocking Finding | Reject or correct | +| Business duplicate | `acme-duplicate-invoice.pdf` | AP review → duplicate finding | Reject or correct | +| Exact duplicate upload | Any identical file twice | Intake duplicate check | Existing case link; no new case | + +The PDFs and their intended checks are also documented in +[`examples/invoices/README.md`](examples/invoices/README.md). + +## Scenario 1: clean AP-only posting + +This is the shortest complete workflow. + +1. Reset the demo as Administrator. +2. Sign in as AP Specialist. +3. Open **Cases → New case**. +4. Upload `examples/invoices/clean-acme-invoice.pdf` and submit it. +5. Wait for the case to move through `Received` and `Processing` to + `Awaiting AP Review`. Keep the worker running. +6. Review the Source Document, extracted Invoice Draft, evidence, and findings. +7. Resolve any resolvable findings and acknowledge warnings if the page asks + for acknowledgement. Blocking Findings must be corrected or resolved before + confirmation. +8. Click **Confirm draft**. The case should become `Ready to Post`. +9. Click **Post invoice**. +10. Wait for the worker. The case should become `Posted` and show an Accounting + System reference in the timeline. + +What this demonstrates: asynchronous processing, evidence-based AP review, +server-confirmed confirmation, explicit posting, and an idempotent accounting +result. + +## Scenario 2: high-value Finance approval + +The second clean sample is above the default threshold only after lowering the +policy for the demonstration. + +1. Reset the demo. +2. Sign in as Administrator and open **Administration**. +3. Set the high-value threshold to `10000` minor units, which is `USD 100.00`. + Leave the other policy settings unchanged and save it. +4. Sign in as AP Specialist. +5. Upload `examples/invoices/acme-clean-invoice-002.pdf`. +6. Wait for `Awaiting AP Review`. Review the evidence and resolve or acknowledge + any findings that the page allows you to handle. +7. Click **Confirm draft**. The case should become + `Awaiting Finance Approval`. +8. Sign in as Finance Approver and open the Finance queue. +9. Open the case and inspect the Source Document, Invoice Draft, findings, and + AP confirmation. +10. Click **Approve**, then confirm the decision. +11. Sign in as AP Specialist, open the case, and click **Post invoice**. +12. Wait for `Posted` and inspect the Accounting System reference. + +What this demonstrates: an Approval Required finding routes only after AP +Confirmation, and Finance approval is required before posting. + +## Scenario 3: Finance returns a case for correction + +Use the same low threshold as Scenario 2. + +1. Reset the demo. +2. As Administrator, set the high-value threshold to `10000` minor units and + save the policy. +3. As AP Specialist, upload `acme-clean-invoice-002.pdf` and wait for + `Awaiting AP Review`. +4. Confirm the draft. The case should enter `Awaiting Finance Approval`. +5. As Finance Approver, open the case and choose **Return for correction**. +6. Enter an explanation, such as `Please verify the line description against +the source document.`, and confirm the return. +7. As AP Specialist, reopen the case. It should be `Returned for Correction`. +8. Open the correction form, make a deliberate correction, and save it. For a + safe demonstration, change the line description to the wording visible in + the PDF while leaving the amount, currency, and Purchase Order unchanged. +9. Confirm the new revision. The case should return to + `Awaiting Finance Approval`. +10. As Finance Approver, approve the new revision. +11. As AP Specialist, post the case and verify `Posted`. + +What this demonstrates: a Finance decision is tied to a revision, a correction +invalidates the old approval, and the revised case requires a new decision. + +## Scenario 4: missing Purchase Order policy + +The default policy routes a missing Purchase Order to Finance. + +### Approval-required variant + +1. Reset the demo and leave **Missing Purchase Order requires approval** enabled. +2. As AP Specialist, upload `acme-missing-po-invoice-003.pdf`. +3. Wait for `Awaiting AP Review` and inspect the `purchase_order_missing` + finding. +4. Confirm the draft. The case should become `Awaiting Finance Approval`. +5. As Finance Approver, inspect and approve the case. +6. As AP Specialist, post it and inspect the timeline. + +### Finance rejection variant + +Follow steps 1–9 above, then choose **Reject** as Finance Approver, enter a +reason, and confirm the rejection. The case should become `Rejected` and the +reason should remain in the Case Timeline. + +### Warning-only variant + +1. Reset the demo. +2. As Administrator, disable **Missing Purchase Order requires approval** and + save the policy. +3. Upload the same file as AP Specialist. +4. Acknowledge the warning and confirm the draft. The case should become + `Ready to Post` instead of entering the Finance queue. +5. Post it as AP Specialist. + +What this demonstrates: the same deterministic finding can route to Finance or +remain a warning depending on the server-side Approval Policy. + +## Scenario 5: currency mismatch and rejection + +1. Reset the demo. +2. As AP Specialist, upload `acme-currency-mismatch-invoice-004.pdf`. +3. Wait for `Awaiting AP Review`. +4. Inspect the `purchase_order_currency_mismatch` Blocking Finding. The PDF + uses EUR while `PO-1001` is USD. +5. Because the finding blocks confirmation, choose **Reject case**. +6. Enter a reason and confirm the rejection. +7. Verify that the case becomes `Rejected` and that the reason appears in the + Case Timeline. + +This is the recommended path for the supplied fixture. A correction path is +also possible if you deliberately change the draft to match authoritative +source and reference data, then revalidate before confirming. + +## Scenario 6: duplicate handling + +This scenario covers both duplicate protections. + +### Business duplicate + +1. Reset the demo. +2. Upload `clean-acme-invoice.pdf` as AP Specialist and let it finish + processing. +3. Upload `acme-duplicate-invoice.pdf`. It has different PDF bytes but uses + the same supplier and invoice number as the clean sample. +4. Open the second case after processing. It should contain a non-overridable + `business_duplicate` Blocking Finding. +5. Reject the duplicate with a reason. + +### Exact content duplicate + +1. Upload the exact same `acme-duplicate-invoice.pdf` one more time. +2. Intake should stop before creating another case and provide a link to the + existing case. +3. Open the existing case from that link. Its Source Document preview should + remain available. + +Do not use the “different bytes” business-duplicate file as proof of exact +content duplication: upload the same file bytes twice for that check. + +## Recovery actions + +### Extraction failure + +If a case reaches `Extraction Failed`: + +1. Check that the worker is running. +2. Read the failure message and timeline entry. +3. Fix the provider or document configuration if needed. +4. Click **Retry extraction**. Do not upload the same document again. + +### Posting failure + +`Posting Failed` is retryable only for temporary Accounting System failures. +Click **Retry posting** after the worker is available. Repeated attempts use the +same idempotency key and should produce one Accounting System reference. + +The standard web demo uses the deterministic accounting adapter with normal +success behavior. Temporary and permanent accounting failure modes are covered +by automated integration tests; they are not currently selectable from the +Administrator UI. + +### Stale version conflict + +If another user changes a case while it is open, a command can return a version +conflict. Reload the case, review the current timeline and findings, then repeat +the permitted action against the latest revision. + +## Hosted-demo operator checklist + +Before sharing the URL: + +- Use a separate database and private document bucket for the demonstration. +- Use synthetic PDFs only. +- Keep provider keys in the API/worker environment, never the web build. +- Use a direct, unpooled Neon connection string. Mastra bootstrap relies on + session-level PostgreSQL advisory locks and can hang through a `-pooler` URL. +- Set the exact production web origin in `CORS_ORIGINS`. +- Use HTTPS so secure session cookies work correctly. +- Set extraction quotas and review the model provider spending limit. +- Set `DEMO_MODE=public` only after the public intake path is ready. +- Confirm the Administrator reset works before sharing the URL. + +## Public-mode note + +The API's public mode accepts server-known fixture IDs and rejects arbitrary +uploads. The current web intake surface is file-upload based, so use +`DEMO_MODE=private` for the hosted browser walkthrough unless the deployment +includes a fixture-picker client. Do not advertise public mode as a browser +upload flow without that client integration. diff --git a/README.md b/README.md index 75e0de5..d356b3b 100644 --- a/README.md +++ b/README.md @@ -1,391 +1,389 @@ # Trestle -Human-governed document operations for supplier invoice processing. +Trestle is a human-governed document operations application for supplier +invoice processing. AI extracts an Invoice Draft and Field Evidence; +deterministic rules validate financial facts; authorized people control +correction, confirmation, Finance approval, rejection, and posting. -Trestle turns invoices into reviewable, auditable cases. AI extracts structured data and Field Evidence; deterministic rules validate financial facts; authorized people retain control over correction, confirmation, approval, rejection, and posting. +> Trestle is a synthetic-data MVP and portfolio demonstration. Do not upload +> confidential or production documents. -> [!IMPORTANT] -> Trestle is an operational MVP and demo system. Use synthetic fixtures for public demonstrations. Production documents require private mode, configured storage, managed PostgreSQL, and reviewed deployment secrets. +## Start here -## What Trestle Does +Choose the document that matches what you need: -Trestle supports three pre-created demonstration roles: +| Need | Guide | +| --------------------------------------- | ------------------------------------------------------------ | +| Run the application locally | [Quick start](#quick-start) | +| Walk through every user-facing scenario | [`DEMO_GUIDE.md`](DEMO_GUIDE.md) | +| Pick an invoice for a test | [`examples/invoices/README.md`](examples/invoices/README.md) | +| Understand the product and boundaries | [Product](#product) | +| Configure Neon, R2, or Gemini | [Managed-service setup](#managed-service-setup) | +| Run tests and builds | [Development](#development) | +| Prepare a hosted synthetic demo | [Deployment checklist](#deployment-checklist) | -| Role | Responsibilities | -| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| AP Specialist | Submit a Source Document, review and correct the Invoice Draft, resolve findings, confirm the current revision, and initiate posting | -| Finance Approver | Review policy exceptions, approve, return for correction, or reject the current case revision | -| Administrator | Maintain limited Approval Policy, inspect reference data and operations, and reset synthetic demo data | +## Product -Core workflow: +### Roles -1. An AP Specialist submits one PDF, JPEG, or PNG Source Document. -2. The API stores the document, creates an Invoice Case, and queues processing. -3. A worker prepares the document, extracts fields and evidence, then applies deterministic validation. -4. The AP Specialist compares document and draft, corrects fields, resolves findings, and confirms the current version. -5. Clean cases become Ready to Post. Cases with Approval Required findings enter the shared Finance queue. -6. A Finance Approver approves, returns, or rejects the current revision. -7. An AP Specialist explicitly posts an eligible case to the simulated Accounting System. -8. The Case Timeline retains attempts, corrections, findings, decisions, retries, actors, timestamps, and versions. +| Role | Responsibilities | +| ---------------- | ----------------------------------------------------------------------------- | +| AP Specialist | Upload, review, correct, resolve findings, confirm, reject, and post invoices | +| Finance Approver | Approve, return, or reject cases requiring Finance review | +| Administrator | Configure Approval Policy, inspect reference data, and reset synthetic data | -### Lifecycle +Pre-created accounts: ```text -Received -> Processing -> Awaiting AP Review - -> Extraction Failed -> Processing | Rejected - -Awaiting AP Review -> Ready to Post - -> Awaiting Finance Approval - -> Processing | Rejected - -Awaiting Finance Approval -> Ready to Post - -> Returned for Correction - -> Rejected - -Returned for Correction -> Awaiting Finance Approval - -> Ready to Post - -> Processing | Rejected - -Ready to Post -> Posting -> Posted - -> Posting Failed -> Posting | Returned for Correction | Rejected +ap.specialist@trestle.demo +finance.approver@trestle.demo +administrator@trestle.demo ``` -`Posted` and `Rejected` are terminal and immutable. Validation Findings explain whether transitions are allowed; they are not lifecycle states. - -## Product Principles - -- PostgreSQL is authoritative for financial state, permissions, corrections, decisions, and posting records. -- AI extracts values, confidence categories, and evidence. It never validates financial rules, selects authoritative records, authorizes, approves, rejects, or posts. -- Every financial command checks actor authority, lifecycle state, and expected Invoice Case version. -- Human corrections remain authoritative. Reprocessing proposes a revision instead of overwriting them. -- Retryable side effects use stable idempotency keys and tolerate at-least-once delivery. -- Source Documents, invoice values, raw prompts, signed URLs, credentials, and session tokens stay out of ordinary logs and traces. -- Public demonstrations use synthetic documents and bounded model usage. - -## MVP Scope - -Included: - -- One seeded Organization with AP Specialist, Finance Approver, and Administrator accounts -- Single-document intake for content-verified PDF, JPEG, and PNG files up to 10 MB and 5 pages -- Multimodal extraction with strict structured output and Field Evidence -- Deterministic arithmetic, duplicate, Vendor, Purchase Order, and two-way matching rules -- AP Confirmation, conditional Finance Approval, correction, rejection, and explicit posting -- Durable workflow suspension, background jobs, retries, idempotency, and optimistic concurrency -- Local filesystem and private Cloudflare R2 document-storage adapters -- Simulated Accounting System with deterministic success and failure modes -- Versioned synthetic evaluation reports for quality, evidence, latency, usage, and estimated cost - -Excluded: - -- Multiple Organizations, registration, invitations, subscriptions, and billing -- Email ingestion, batch intake, external notifications, and native mobile apps -- Real ERP integration, payments, bank-detail processing, or currency conversion -- Vendor or Purchase Order creation, goods receipts, or three-way matching -- Generic workflow and policy builders, advanced analytics, or autonomous financial actions -- Confidential production documents or unrestricted public uploads +All three use the password supplied through `SEED_USER_PASSWORD`. The password +is never committed. -## Implementation Status - -| Area | Current repository | Accepted MVP target | -| --------------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | -| Web | React SPA with Work Queue, review workbench, Finance decisions, recovery actions, and Administration UI | Implemented | -| API | Hono/OpenAPI routes for auth, intake, cases, commands, documents, Finance, administration, and health | Implemented | -| Persistence | PostgreSQL/Drizzle aggregate, references, attempts, evidence, findings, decisions, events, quotas, and balances | Implemented | -| Background work | Graphile Worker with separate API/worker processes, bounded attempts, replay-safe handlers, and cron maintenance | Implemented | -| Workflow | Mastra PostgreSQL snapshots coordinated against authoritative PostgreSQL state | Implemented | -| AI | OpenRouter vision adapter with strict validation, timeout, fallback, and sanitized usage metadata | Implemented; opt-in by environment | -| Documents | Private local filesystem or S3-compatible R2 adapter with authorized access and 10 MB/5-page limits | Implemented | -| Accounting | Idempotent simulated Accounting System with temporary and permanent failure modes | Implemented | -| Auth | Seeded accounts, opaque sessions, role permissions, origin checks, secure cookies, and bounded login throttling | Implemented | -| Tests | Domain, PostgreSQL, workflow, authorization, worker, frontend, evaluation, and redaction coverage | Implemented | -| Deployment | Development and production-shaped Compose stacks with separate API/worker services and persistent documents | Implemented; managed DB/R2 wiring remains deployment-specific | - -> [!WARNING] -> Do not put production credentials or confidential documents in the public demo. Public mode accepts only server-known synthetic fixtures and applies account/IP quotas. - -## Architecture - -Current runtime: +### Workflow ```text -Browser SPA -> Hono API -> Repository port -> Drizzle -> PostgreSQL -``` +Received → Processing → Awaiting AP Review + → Extraction Failed → Processing | Rejected -Accepted target keeps one modular server codebase with separate API and worker processes: - -```mermaid -flowchart LR - Web[React web app] --> API[Hono API] - API --> DB[(PostgreSQL)] - API --> Documents[Private document store] - Worker[Node worker] --> DB - Worker --> Documents - Worker --> Model[Extraction provider] - Worker --> Accounting[Simulated Accounting System] -``` +Awaiting AP Review → Ready to Post + → Awaiting Finance Approval + → Returned for Correction | Rejected -API and worker may scale independently, but both call the same application operations and adapters. PostgreSQL owns business state; workflow snapshots own recoverable execution checkpoints. +Awaiting Finance Approval → Ready to Post + → Returned for Correction | Rejected -## Technology - -### Current Stack +Ready to Post → Posting → Posted + → Posting Failed → Posting | Returned for Correction | Rejected +``` -| Layer | Technology | -| --------------------------- | ------------------------------------------------------ | -| Web | React 19, TypeScript 5.9, Vite 8 | -| Routing and server state | TanStack Router, TanStack Query | -| Browser transport and state | Ky, Zustand, Immer | -| UI | Tailwind CSS 4, shadcn, Base UI, Phosphor Icons | -| Localization | i18next with English and Indonesian resources | -| API | Hono 4, Zod, `@hono/zod-openapi`, Scalar | -| Data | PostgreSQL 16, Drizzle ORM, `node-postgres` | -| Logging and middleware | Pino, CORS, secure headers, request IDs, rate limiting | -| Tests | Vitest, Testing Library, MSW, Hono `testClient` | -| Tooling | pnpm workspaces, oxlint, oxfmt, Husky, lint-staged | +`Posted` and `Rejected` are terminal states. Every case requires AP +Confirmation. Finance approval is required only when current findings and the +Approval Policy require it. -Document preparation uses `pdfjs-dist`, `@napi-rs/canvas`, and `sharp`; R2 uses the AWS S3 client; workflows use Mastra PostgreSQL and jobs use Graphile Worker. +### Product boundaries -## Repository Layout +- PostgreSQL is authoritative for financial state, permissions, corrections, + decisions, reference data, and posting records. +- AI extracts values and evidence. It does not authorize, match authoritative + records, validate financial rules, approve, reject, or post. +- Human corrections create revisions and invalidate decisions tied to older + revisions. +- Source Documents are private and stored outside PostgreSQL. Document hashes + are used for exact duplicate detection; object keys are opaque per-upload + identifiers. +- Public demonstrations must use synthetic documents and bounded extraction + quotas. -```text -trestle/ -├── apps/ -│ ├── server/ # Hono API, Drizzle, repositories, tests -│ └── web/ # React SPA, routes, features, UI, tests -├── config/ -│ └── typescript/ # Shared TypeScript configuration -├── packages/ # Reserved workspace area; currently empty -├── docker-compose.yml # Development stack -├── docker-compose.prod.yml # Production-shaped template -└── pnpm-workspace.yaml -``` +## Quick start -## Prerequisites +### Prerequisites - Node.js 22 or newer - pnpm 10 -- Docker with Docker Compose for the recommended setup +- Docker with Docker Compose for the recommended local stack -## Getting Started +### Option A: Docker Compose -### Docker +This is the simplest local demonstration. It uses local PostgreSQL and local +private document storage. ```bash cp .env.example .env +pnpm install pnpm dev ``` -Docker Compose starts PostgreSQL, API, worker, and web services. API startup applies Drizzle, Graphile Worker, and controlled Mastra bootstrap; the worker processes queued jobs separately. Documents persist in the `document_data` volume. It serves: +Open: - Web: -- API: +- API health: - OpenAPI schema: - API reference: -Seed demo accounts with a server-side password before using the application: +In a second terminal, seed the accounts and reference data inside the server +container: ```bash -SEED_USER_PASSWORD='local-only-password' pnpm --filter server db:seed +docker compose exec -e SEED_USER_PASSWORD='local-only-password' server pnpm --filter server db:seed ``` -Stop the stack without deleting database data: +The server startup applies Drizzle, Graphile Worker, and Mastra bootstrap +steps. The Compose stack includes the worker. Then follow +[`DEMO_GUIDE.md`](DEMO_GUIDE.md). + +Stop without deleting data: ```bash docker compose down ``` -Delete the local PostgreSQL volume only when discarded data is acceptable: +Delete the local database volume only when its data is disposable: ```bash docker compose down -v ``` -### Local Processes +### Option B: Direct local processes -Start PostgreSQL, then configure each application: +Use this when PostgreSQL already runs outside Compose. ```bash cp apps/server/.env.example apps/server/.env cp apps/web/.env.example apps/web/.env pnpm install -pnpm dev:local ``` -`dev:local` starts web and server processes in parallel. Server `predev` applies Drizzle, Graphile, and Mastra bootstrap steps. - -## Environment - -Root `.env` configures Docker Compose PostgreSQL values: - -| Variable | Required | Purpose | -| ------------------- | ------------------ | ------------------------------- | -| `POSTGRES_USER` | Yes | Development PostgreSQL user | -| `POSTGRES_PASSWORD` | Yes | Development PostgreSQL password | -| `POSTGRES_DB` | Yes | Development database name | -| `CORS_ORIGINS` | Production Compose | Allowed web origin | - -`apps/server/.env` configures a direct server process: - -| Variable | Default | Purpose | -| ----------------------------------- | ----------------------- | --------------------------------------- | -| `NODE_ENV` | `production` | `development`, `production`, or `test` | -| `HOST` | `localhost` | Bind hostname | -| `PORT` | `3000` | API port | -| `DATABASE_HOST` | `localhost` | PostgreSQL host | -| `DATABASE_PORT` | `5432` | PostgreSQL port | -| `DATABASE_USER` | Required | PostgreSQL user | -| `DATABASE_PASSWORD` | Required | PostgreSQL password | -| `DATABASE_DB` | `postgres` | Database name | -| `DATABASE_POOL_MAX` | `10` | Maximum pool connections | -| `CORS_ORIGINS` | `http://localhost:3001` | Comma-separated allowed origins | -| `RATE_LIMIT_WINDOW_MS` | `60000` | Rate-limit window in milliseconds | -| `RATE_LIMIT_MAX` | `50` | Requests allowed per window | -| `BODY_SIZE_LIMIT` | `102400` | Request body limit in bytes | -| `DOCUMENT_STORAGE_ROOT` | `./var/documents` | Private local document storage | -| `DEMO_MODE` | `private` | `public` restricts intake to fixtures | -| `DEMO_EXTRACTION_QUOTA_PER_ACCOUNT` | `20` | Account quota per period | -| `DEMO_EXTRACTION_QUOTA_PER_IP` | `5` | HMAC-hashed IP quota per period | -| `DEMO_QUOTA_PERIOD_HOURS` | `24` | Quota window | -| `OPENROUTER_API_KEY` | Empty | Enables real extraction when configured | -| `R2_*` | Empty | Enables private S3-compatible storage | - -`apps/web/.env` contains one public value: - -| Variable | Purpose | -| -------------- | -------------------------------------------------- | -| `VITE_API_URL` | Hono API base URL, such as `http://localhost:3000` | - -Only `VITE_` variables may enter the browser bundle. Never place secrets in web environment variables. - -## Commands +Configure the server database, then run the setup steps explicitly: ```bash -# Development -pnpm dev # Docker Compose stack -pnpm dev:local # Direct web and server processes -pnpm dev:web -pnpm dev:server +pnpm --filter server db:migrate +pnpm --filter server queue:migrate +pnpm --filter server mastra:bootstrap +SEED_USER_PASSWORD='local-only-password' pnpm --filter server db:seed +``` -# Verification -pnpm lint -pnpm typecheck -pnpm test -- --run -pnpm build +Start the web/API processes and worker in separate terminals: -# Focused tests -pnpm test:web -- --run -pnpm test:server -- --run +```bash +pnpm dev:local +pnpm --filter server worker +``` -# End-to-end (requires running Docker stack) -E2E=1 pnpm test:e2e +`pnpm dev:local` starts the web and API. It does not start the worker. -# Database -pnpm --filter server db:generate # Generate migration from schema changes -pnpm db:migrate # Apply committed development migrations -pnpm db:migrate:test # Apply migrations to test database -pnpm db:push # Disposable local exploration only -pnpm db:studio +## Managed-service setup -# UI primitives -pnpm --filter web shadcn:add -``` +Use this path to run the web, API, and worker locally while Neon stores +PostgreSQL data, Cloudflare R2 stores private documents, and Google AI Studio +performs extraction. -Review generated migration SQL before applying or committing it. Do not rewrite migrations that another environment may have applied. +### 1. Create the services -## Testing +Prepare: -- Web tests use jsdom, Testing Library, `userEvent`, and MSW at the network boundary. -- `renderPage()` creates an isolated memory router and Query Client for route behavior. -- Server route tests use Hono `testClient` with fresh in-memory repository adapters. -- Server setup still requires a disposable PostgreSQL test database for migrations and transaction infrastructure. -- In-memory adapters do not prove PostgreSQL constraints, locking, transactionality, or SQL behavior. -- Playwright end-to-end scenarios run opt-in with `E2E=1` against a running Docker stack. +- A Neon database and its **direct/unpooled** connection string. +- A private R2 bucket and an API token limited to that bucket. +- A Google AI Studio API key with an appropriate spending limit. -The repository currently has no tracked `apps/server/.env.test.example`. Create `apps/server/.env.test` from the server environment shape and point it at a disposable test database before running server tests. Do not reuse production data. +Do not use a Neon hostname containing `-pooler` for Trestle's +`DATABASE_URL`. Mastra bootstrap uses session-level PostgreSQL advisory locks; +transaction pooling does not preserve the required session. -Full quality gate: +### 2. Configure the server ```bash -pnpm lint -pnpm typecheck -pnpm test -- --run -pnpm build +cp apps/server/.env.example apps/server/.env ``` -## API +Set these server-only values: + +```dotenv +DATABASE_URL= +R2_ACCOUNT_ID= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= +R2_BUCKET_NAME= +GOOGLE_AI_STUDIO_API_KEY= +CORS_ORIGINS=http://localhost:3001 +DEMO_MODE=private +DEMO_RESET_ENABLED=true +``` -Important routes: +The R2 endpoint is derived from `R2_ACCOUNT_ID`; no R2 secret belongs in +`apps/web/.env`. -- `GET /health` -- `POST /auth/login`, `POST /auth/logout`, `GET /auth/session` -- `POST /invoice-cases/intake` -- `GET /invoice-cases`, `GET /invoice-cases/{id}`, timeline and private document access -- AP commands: confirm, correct, resolve, reprocess, reject, post -- Finance command: `POST /invoice-cases/{id}/finance-decision` -- Administration: policy, Vendors, Purchase Orders, usage summary, and demo reset -- `GET /health`, `GET /doc`, `GET /reference` +### 3. Bootstrap Neon and seed the demo -All public routes validate through Zod/OpenAPI schemas. Commands require authenticated actors, explicit role permissions, lifecycle checks, and expected versions. Browser transport uses Ky with normalized error envelopes and TanStack Query invalidation. +```bash +pnpm --filter server db:migrate +pnpm --filter server queue:migrate +pnpm --filter server mastra:bootstrap +SEED_USER_PASSWORD='local-only-password' pnpm --filter server db:seed +``` -## Evaluation +If Mastra bootstrap hangs, verify that `DATABASE_URL` is direct/unpooled and +inspect Neon for a stale advisory-lock holder before terminating it. -The offline evaluation harness lives under `apps/server/src/evals/`. It scores versioned synthetic manifests without network calls: +### 4. Start all local processes ```bash -pnpm --filter server test -- --run scorer +pnpm dev:local +pnpm --filter server worker ``` -The opt-in live command requires `OPENROUTER_API_KEY` and reports harness, schema, prompt, model, token, and score metadata: +Upload one of the PDFs from `examples/invoices/`, then follow the complete +walkthrough in [`DEMO_GUIDE.md`](DEMO_GUIDE.md). -```bash -pnpm --filter server exec tsx scripts/eval-live.ts +## Demo modes and safety + +| Mode | Intake | Reset | Intended use | +| --------- | ------------------------------------------------- | ---------------------------------- | ---------------------------------------------------------------------- | +| `private` | Arbitrary authenticated PDF uploads | Requires `DEMO_RESET_ENABLED=true` | Local or access-controlled hosted walkthrough | +| `public` | Server-known fixture IDs only at the API boundary | Enabled for the public demo | Synthetic public deployment after a fixture-picker client is available | + +Current browser intake is file-upload based. Keep `DEMO_MODE=private` for the +hosted browser walkthrough unless the deployment provides a fixture-picker +client for the public fixture API. + +Recommended safety settings: + +```dotenv +DEMO_EXTRACTION_QUOTA_PER_ACCOUNT=20 +DEMO_EXTRACTION_QUOTA_PER_IP=5 +DEMO_QUOTA_PERIOD_HOURS=24 +DEMO_INTAKE_RATE_LIMIT_MAX=20 ``` -Live extraction is never part of the CI quality gate. +## Configuration by concern + +### Database and runtime -## UI Direction +| Variable | Purpose | +| ------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| `DATABASE_URL` | Full PostgreSQL connection string; use direct Neon URL for Mastra | +| `DATABASE_HOST`, `DATABASE_PORT`, `DATABASE_USER`, `DATABASE_PASSWORD`, `DATABASE_DB` | Components used to construct a URL when `DATABASE_URL` is absent | +| `HOST`, `PORT` | API bind address and port | +| `CORS_ORIGINS` | Exact browser origins allowed to call the API | +| `DATABASE_POOL_MAX` | PostgreSQL pool size | -Trestle uses a light, restrained casework interface designed for long document-review sessions. Source Documents, financial values, findings, and human decisions outrank decoration. The target workbench places the document beside the editable draft on desktop and switches between Document and Draft panels on small screens. +### Document storage -UI work follows semantic OKLCH tokens, persistent labels, keyboard access, explicit status text, 44 pixel touch targets, reduced-motion support, and no color-only meaning. +| Variable | Purpose | +| ----------------------------------------------------------------------------- | ------------------------------------- | +| `DOCUMENT_STORAGE_ROOT` | Local private document root | +| `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET_NAME` | Enable private Cloudflare R2 storage | +| `DOCUMENT_MAX_BYTES` | Maximum document size; default 10 MiB | +| `DOCUMENT_MAX_PAGES` | Maximum PDF pages; default 5 | -## Development Guidance +### Extraction -Key implementation rules: +| Variable | Purpose | +| ------------------------------------------------------- | ---------------------------------------------------- | +| `GOOGLE_AI_STUDIO_API_KEY` | Preferred direct Gemini provider; server/worker only | +| `GOOGLE_AI_STUDIO_MODEL` | Gemini model; default `gemini-2.5-flash` | +| `GOOGLE_AI_STUDIO_TIMEOUT_MS` | Gemini timeout; default 60 seconds | +| `OPENROUTER_API_KEY` | Optional alternate provider | +| `OPENROUTER_PRIMARY_MODEL`, `OPENROUTER_FALLBACK_MODEL` | OpenRouter model chain | -- Keep routes thin and delegate through explicit ports. -- Keep browser server state in TanStack Query and shareable navigation state in TanStack Router. -- Validate external input, environment values, uploads, model output, and integration responses. -- Store money as integer minor units with ISO currency. -- Mock at network or port boundaries and use PostgreSQL integration tests where database semantics matter. -- Update context or ADRs when implementation changes durable boundaries or invariants. +Without a provider key, processing uses the deterministic extractor path used +for offline development and tests. -## Recovery And Operations +### Demo controls -- Processing failures become `extraction_failed`; authorized AP users can retry through the existing reprocess command. -- Temporary posting failures retry with the same idempotency key. Permanent Accounting rejection is inspectable and cannot be blindly retried. -- Workflow snapshots are resumable; missing or invalid snapshots become inspectable processing failures. -- Background jobs have bounded attempts. Duplicate processing and posting jobs are replay-safe. -- Structured logs carry correlation, case, workflow, job, and attempt identifiers while redacting document content, invoice values, prompts, credentials, signed URLs, and provider output. +| Variable | Purpose | +| ----------------------------------- | ----------------------------------------------------------------------------------- | +| `DEMO_MODE` | `private` for arbitrary authenticated uploads; `public` for fixture-only API intake | +| `DEMO_RESET_ENABLED` | Allows the authenticated Administrator reset in private mode | +| `DEMO_EXTRACTION_QUOTA_PER_ACCOUNT` | Per-account extraction quota | +| `DEMO_EXTRACTION_QUOTA_PER_IP` | Per-IP extraction quota | +| `DEMO_QUOTA_PERIOD_HOURS` | Quota period | -## Deployment +Only `VITE_` values may enter the web bundle. Keep all database, R2, and model +credentials in the server/worker environment. -`docker-compose.prod.yml` builds one server image into separate API and worker services, runs health-gated startup, and persists local documents. Hosted deployments should replace local PostgreSQL with managed PostgreSQL and configure private R2 through `R2_*` variables. +## Development -The web image requires `VITE_API_URL` at build time because Vite embeds it in the static bundle: +### Commands ```bash -docker build \ - -f apps/server/Dockerfile.prod \ - -t trestle-server:latest \ - . - -docker build \ - -f apps/web/Dockerfile.prod \ - --build-arg VITE_API_URL=https://api.example.com \ - -t trestle-web:latest \ - . +# Run applications +pnpm dev # Docker Compose stack +pnpm dev:local # Direct web and API processes +pnpm dev:web +pnpm dev:server +pnpm --filter server worker + +# Quality gates +pnpm lint +pnpm typecheck +pnpm test -- --run +pnpm build + +# Focused checks +pnpm test:web -- --run +pnpm test:server -- --run +E2E=1 pnpm test:e2e + +# Database +pnpm --filter server db:generate +pnpm db:migrate +pnpm db:migrate:test +pnpm db:push # Disposable local exploration only +pnpm db:studio +``` + +Review generated migration SQL before applying or committing it. Do not reuse +production data for test databases. + +### Repository layout + +```text +trestle/ +├── apps/server/ # Hono API, worker, Drizzle, repositories, tests +├── apps/web/ # React SPA, routes, features, and UI +├── examples/invoices/ # Synthetic PDFs for the demo walkthrough +├── config/ # Shared TypeScript configuration +├── docker-compose.yml # Local development stack +├── docker-compose.prod.yml # Production-shaped template +├── DEMO_GUIDE.md # Click-by-click scenario runbook +└── README.md # Project overview and setup +``` + +### Testing boundaries + +- Web tests use Testing Library and MSW at the network boundary. +- Server route tests use Hono clients and fresh in-memory adapters. +- PostgreSQL, queue, workflow, and storage semantics require integration tests. +- End-to-end browser scenarios run with `E2E=1` against a running stack. +- Live model evaluation is manual and is not part of CI. + +## Architecture at a glance + +```text +Browser SPA + │ + ▼ +Hono API ───────► PostgreSQL (authoritative business state) + │ ▲ + ▼ │ +Private document store │ + │ +Node worker ─────────────┘ + │ + ├── extraction provider + ├── Mastra workflow checkpoints + ├── Graphile Worker jobs + └── simulated Accounting System ``` -Review CORS origins, production cookie settings, database backups, R2 least-privilege credentials, and the `DEMO_MODE` setting before deployment. Set `DEMO_MODE=public` only for synthetic demonstrations with bounded quotas; use `private` (the default) for any real document processing. Never expose `OPENROUTER_API_KEY`, database credentials, R2 secrets, or session material to the web bundle. +The API and worker are separate processes from the same server codebase. The +worker prepares documents, extracts untrusted model output, validates it with +deterministic rules, resumes suspended workflows, and performs idempotent +posting. Mastra checkpoints execution; PostgreSQL owns financial authority. + +## Deployment checklist + +Before publishing a synthetic demo: + +- Use a separate managed database and private document bucket. +- Use HTTPS and set exact production `CORS_ORIGINS`. +- Use a direct/unpooled Neon URL for migrations and Mastra bootstrap. +- Pass provider keys only to API/worker server environments. +- Keep `DEMO_MODE=private` for arbitrary uploads, or provide a fixture picker + before switching to public fixture-only mode. +- Set extraction quotas and a provider spending limit. +- Verify Administrator reset, AP posting, Finance approval, duplicate handling, + and rejection using [`DEMO_GUIDE.md`](DEMO_GUIDE.md). +- Never expose database credentials, R2 secrets, model keys, sessions, signed + URLs, or real documents. + +## Known boundaries + +Trestle does not include registration, multi-organization tenancy, email +ingestion, batch upload, vendor or Purchase Order creation, three-way matching, +currency conversion, real ERP integration, payments, billing, mobile apps, or +autonomous financial decisions. diff --git a/examples/invoices/README.md b/examples/invoices/README.md new file mode 100644 index 0000000..0e0bf2b --- /dev/null +++ b/examples/invoices/README.md @@ -0,0 +1,22 @@ +# Synthetic invoice examples + +These PDFs are fictional and contain no real supplier or customer data. They +are intended for the walkthrough in [`../../DEMO_GUIDE.md`](../../DEMO_GUIDE.md). + +| File | Intended scenario | +| ---------------------------------------- | ------------------------------------------------------------------------------------ | +| `clean-acme-invoice.pdf` | Clean AP review, matching `Acme Supplies` / `PO-1001` | +| `acme-clean-invoice-002.pdf` | Second clean invoice; use it for high-value Finance approval | +| `acme-duplicate-invoice.pdf` | Different bytes but same invoice number; produces a business-duplicate finding | +| `acme-missing-po-invoice-003.pdf` | Missing Purchase Order; demonstrates approval-required or warning-only policy | +| `acme-currency-mismatch-invoice-004.pdf` | EUR invoice against USD `PO-1001`; demonstrates a currency-mismatch blocking finding | + +## Duplicate test + +1. Upload `clean-acme-invoice.pdf` and let it finish processing. +2. Upload `acme-duplicate-invoice.pdf` to create a different-byte business duplicate. +3. Upload `acme-duplicate-invoice.pdf` again to test exact content-hash duplicate intake. + +The second upload of the same bytes should link to the existing case rather than +creating another case. See the full role-by-role steps in +[`DEMO_GUIDE.md`](../../DEMO_GUIDE.md). diff --git a/examples/invoices/acme-clean-invoice-002.pdf b/examples/invoices/acme-clean-invoice-002.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a3f8c0f0153fe744bac8af35958cda36a5b31c13 GIT binary patch literal 2052 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 K00`vh=JY3}#J_?7 literal 0 HcmV?d00001 diff --git a/examples/invoices/acme-duplicate-invoice.pdf b/examples/invoices/acme-duplicate-invoice.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f9f0c0630121c839bcdb9272799f2fb6f5c1de54 GIT binary patch literal 2163 zcmai#X*3iH8^^O`nIcP=ij28ttYeI2rV)uOjdkoKTxPkJF~bd?+=~>SCKhKB%`8^PAh;FDpGL4C;3#+k0B%Ph|3V7{ArSh8fK#Uc5i}~nKNJvM zQtB~yZP7w%im%1CYC99v-ezll*6w}R6^U%6fW(!gR3cr+e2rfcd@`?pa_dhc+1{}W zZ|aTi`f`+0`Lo$8&ProSMczo8X7fy~*pp0)6; z%W{3_NvVXHL%E^;*C#=#|pLn2|X{m zyS~oG<~@o9CVbI0ItGA;4V5?_X}4vuhJZR^cj)@fCft|iCSUY^Bi=aik_??WMR;`k zI{(JF{Gc9dv@Bh=gq|gtBT?3X%+T;a|99Jo*SbF8LA4)9(d1l4dsHKWOJ9`y0Qp8 zUyRdc)sR^En{mm0?F+BPdNX@W)%+G==ZFKYSL(YP5K*~jFiyfSwBlcSavl4j#C@q8<}b)Mc_dB=Dfj@BGHmTiK;-z3<4jhWb*pif;cK042fv@^rBS1Y&CJTIEO}d z8^PEiJK`_4$#5JUXZ;k>v4zBw@b|l`J*g6{N#i)3X744Du$aFPX2-2W0zc(CB(hnZ z3{8y?fzo!}jd5lLNjIC>kfQRcuKnKfM6F&^bSE;mtj;o-BW&Mgy@*>4F4BB)*bDBH zlz>b-!Zx*^t_w{D;04{Tj=T-DoUGJN`u6jB3ymu?b?`H`;P`r_1)n<+-t05mn&c7; zA%%LR^QBVbCz_fcbC$&f1)@jXL`v2CS|{XvV~}`3o6?NUpfqk-nSJ*_I#Yc(%RL)- zJ|DIzopyKLeWRw7<^hhhsdT8qYGGjtv6c^qV+p==-}G1W3vw(hO6vB)B8{$|g|`L+ zdk=S^CdeUJ|DT9~J}Rfwy|mkH`VB1@xDQ4VUY1Wf7?iZ@8T9(mL`=|PwFofW(PFW{ z;SteJ%dA5;TmyqHa{@n{cN0dtE0}dKB@8W&8i4^y-h4J!CMc06&nA(w*1_hb&+Gl` zDAi>WhR&qq8zG7AfxtfRv4MN$y>##Cq5B$fA)!s;g}_aI_l`u-3SIxaCFk8jqE{cb z2FTL_XDnH$9c zJf+(JEid-@iQKZo385(}N5FIL$hQW0A2SCRmQOR3gv+m+UMy}Fe=8#8wbni$@Jx>) zXNU@VY@uB6%eoi;V`M*r#9P+ZwTFg|;O8ubcSWbS^Iro9Wc+{0vzLA672w4G1D9(| z7y$%#3yO!3 zdMfW0b@4po*6vP?PmI^p?-La}zxswwMfF^HOf}8eU{4vAe(deXtkX^Iw=LAS!$ctS z`kw{DTZ23v6WYT$ncD#ZUHeXtrtJ$OF1F?zEnl`z3D~t#Xc9O^cx_r%JW_NBxn_9jQ>8lm&8ymlU>EwWTweze&Yj}vM?zWpFemnc47r-s}N1nUS6UJUUdnNrz(Ry;N$8e$SSxs8?q|i(1VT^RNU{^XK`E;JVDs)YrSFm5@ zWKsJX;}S+!#$SwDy#!mLk46+>iPBE1bq{fcqf&P&93UxX>>6fHL+yTTU)2m3(NxCS zJ9cwJ@3W?5<4p4-!Qv_wO6wQH;DvEwNQ&gx6;h9AmGWsgen^aXEK-$#D0H%7N}C)| zI|~rT#Rh~@>|j7}CXDjfP+`W1{RGP0%&&GQYb`vr)$GQB?(G9GiL>pC!H2 z{k%LJ{J2EheOkIuxCyYt!2_LDq4C+{b2^;~ZQEXOzHM>KNB+v!$eVY6>w3!0jBTzT zeiIib+I4D!>xt+TxobamVLP7dm%?mn&5mMUf|W^JH$IxN5gRqOIWhFfGR8f0F(J@+ zJIKLc@Io`0?6XanDnU+c3e76Rw#E~Bm;9b>J|F#ovC;oBwo6bf0ff*6z?~@+8c1(% z;tYUe$V3WA|L^>VFZ*xjdpZ+{{}|ms9{~3Nd4mvpB6=X7?;?0Y^j<@EPkyid_h*-T z@<4hY=%CPpC^2iBE%P(6)s#j$0Yn{*`a*xp14~~zZ`BQ1p)V<9J Nw4nh20Ouej literal 0 HcmV?d00001 diff --git a/examples/invoices/acme-missing-po-invoice-003.pdf b/examples/invoices/acme-missing-po-invoice-003.pdf new file mode 100644 index 0000000000000000000000000000000000000000..210ae6893912dede967fd3538a39f8819bc98298 GIT binary patch literal 1991 zcmai#c{JPU8pqKx{7UUxX+;r?5O3&Mp&0x%c=v5&wK(0CBU9ve(R+oD5pC^P{12_1tWgo8|si~)0VAdY}X2S)>> zsygqHqW88^+ykv1`-=O^=G58=M|NUgh6tIP%U|(>X#6P=>yHOj$g9I8gxiCz$fnZJ z&M~vy#LdJm3*jZ5U!dILV0VS1L-gJk&P~y@w8NFoU5lr*qxbUzTJUy9rz^{j5wMgq zjza@Pn{$#yFlYHY5ir>4$7-iWyh=?Fq;o32$8E zdy&Jd>&4zP#!c3)oy%6jy_-06(5$1h$tXmX7d$f1VKr65o~=hNPsaIy-=gyBKKIoR zEO}|}=Q_~Cv8@Xo(;4yEbVHwE)pQ06n@Ukl3Q0UC>fD8xv4WueZ9Q}}A-XZ+@KWmP zWcbLS5M~u&dd0R|E@Zizv^4Y!TMuoO&uT^W*n6MKlZRqi3~S-Rye0X8S6<0N!-Xtw z$|{Xa_j~Y4oy?gL$W!kKbb8^Ud2s>NX!eh?TUh!O;|L#|u(hj>f6OA`L@u>6 zU@y5VE*okJa~9RTdE5KwltI?S8v{;H-dJD`yz^j8amnlXxVGtOgC{HxE9aAv)iyh% zFBZyi!irQxkAhycvUIniuSAfX9G4u>m;#}+T*{cH=GW?1bfid?+5wb1;sm69g>;zA zAU($iW81d9#y0k^EXpSoRxEeOX1b=Fp}EKXz8vkG^0|${oSU3K%r4$kDnsGXHGBaR z`dRZ$>PV8d3(+}N{TYT!^tNCe_z+5cOeS=|Oy>)Z%67BdFR&#*vLLjRYHe_HpAT_2s&w8?34 zxU~>)5|)7BeG0x8M47C}eF%{sFulaIM%~mO`JH405_yzeR^axEAAdJJ?DYkcg~)Me z?ZkcFZZSdOE}_-Eg5ape2t$6!m(eRPa1*DAU4RFvT?hys*qd)tJ#=zodU9-kX;gRNtX{T5^jnis2M% z`sH9(;Npl5*m5Sd&AeAMd1qK2j~LkR??~e<$cA_W=or+0hx=H4BLZOj|Ipx>7>fo$ z;31J7glGWb1~UAH5PTc~q;K#SHw>Zr|HF+bPnRf;rC9HZ_iD4*$(N(+qrYWZN^rDG z$H`KDeo2?H;+`3v*zVe+-qw~9vd{T(B=knR>70=%Zlmwlf(icNcQr1&0eJIb;s+S> zZPCqw&8@qE?OG5jC&{B!sd8#4)#yT(*6wc_WAtc_X<${A6nti@{_#4E6XJk3zo8^n zz7o?OFdaqgxdoBo5$2I{739I}q{Y;L!!hh%XF7$388c^pxE>oxW4h%Ms~VJ^M2y|# zJPpwi&ED<#%OG~fPS0{Tel3_fPcqu1w5?@~)G|RF?Miu-~X1A7KIr%PQW>%4h z5~SKMcAxK~OmhFC$yti8?PRjn+DTtuo5FmR+cpB9>MO!CqEdsbRNSkae{#$-V4-F% zh^K^XJV3xzl`|g(4>C~e$}_ztA4gRk zg&dK&9mBc(!Gp#ltk4H>2y11{)k1Nr4#H}U^bj35`taa*-swNH-_SN)*wr1C|C)cN zq^GhgjT||V`<>?F2*G#)34i=E)W}#5P*bym+y4j0-;@>r literal 0 HcmV?d00001 diff --git a/examples/invoices/clean-acme-invoice.pdf b/examples/invoices/clean-acme-invoice.pdf new file mode 100644 index 0000000000000000000000000000000000000000..cf92e87014eb81d53ab12c1f5d26c7dfa06b9906 GIT binary patch literal 1663 zcmai!dpOg39LHVe9DeSp(1l+vNy;{x%hrq*bH7Ax71^}88#4?k=2FKs)K+d~D%IRO z!mx4@<%FD))s(W3JB3a{M^ESJ={!%*bH0ClzTeODyg%PRUaya~vju)HT;CYbPFg3-2fkv_*`B9FO0H{4F%%2tj8p90%EEb5QQAvbQfL?Uf zT@a*naAU%y>I?p*++Lo#LTR^z`U%DN;%v8b>4(~9V~X>Ufs^)8F1VeMd9dK=`T^dc zoJVt#&t|oVzUYVZW2`s*g2}?6fjhjDE8!7~!qF#|&JGUg6`D^PKVxdLn_~{BN>v*K zW%u#EU|XKC*};Z8l@OYm-hpn2Si}rZwpsZe*|}5>y8n0u-{4{Lq3b0HA)l0(igcJI_CADLo&S$GzxT!D=*{G3j+}sw0hkCA$ z7@b(WE)FMhI9_T=?{1I|+r-PPa`|F-6>mPA^49#Ia#s$YZ*iDU(hv^Q@*vhCFT`vL zU?!?sVr9Nf5J8Emk94xBU31ymZfVrjE$6TNl~N$wi0rm0y}&iZ%F`{z>Yo;k&)J^K zEb=G5xTs>N+p6nD>?LK~dUYdtkkVFKbk!QV!OlGfl6z}S_gA+9J$Jbr^Ndid*&Znq z$)V&zhBCF`zPF#M&ujJ;mUk;)s?)s+O_S)p=V`~ka@6;XW34}*rpOu{x;*BKCqHv} zKtfyMtenMbHwPpi(Cz<#cH7k76@|uePm{e}HqMmV6Vf9UP`m6Z`>*Leo!QS0+)*yM zVTXPSZhsqhsw#4{?tr)v!{++)xIj+Z<++=vmunEyH92BzF@EN7__FT(PYE>`EGHWDroB9p?Bf_3AjA*k5p^mn3ad z`F*r7C!!bkl4V53J{?Vu)<8U_rIg4CE@{lQ*L0ARvwPkk2xC&*{sW=SIKAdqQ~~ID zdZD!Oxje;$tSfOS;|igJ@2R8u7Cv8ZTp!|G*|&gdj2i#&$y|D?7&@Vxl~5UrR9t~6 z&dsitL=-_<#tFXe3)5u_P4B~XhlOwV<~(DY1?O1L1EjFy|5CkPeN_Q4`9CKP#fFnW zs1q^Bl@9YLe7?ocCXAOiVa7(*oLKf?4MvI`zGm*IW(cvNc&2}a5X+IsT+rL~b- zsZ6F?(PB;Gp6KU`%!MHj#49bWN$tl+;KV)S`S|Kw_sh;+oJCfaF6bn6g@NWO zY=zL#yNb!#BqEyFfKwAuruL*V6aXmt_$OtJc&*%AQJ8CAK-rl$0$n2|f5CH4H4{jO!uvS1{u=pD5tyDu)M zBNI|6dN?mCz_1A+LL9wZk6Z{bWz-tIZi?F>I;p_;P%bjg)5>}!$SqJAJ5hsS8r4cn zTTkX1#P@cnY<+Y?t~iC^YZdmyy3X(HhZ5CF@f~L z$beIfnGoH3jL7=9`dNULT>`D^IG)D+70s;kzr2slN*B9!-zZd2<;2m4Hr-|^I&Iso zJajAO?6sC%6#1A0a=x?jNX3gz%onE!TZL&Zr!cy#3+k{h^kaAOYjxAny4K+(XeIMh z%1=;4{eYrtU>pgA!vLrYg+c=j);lf$)FzBf0TJKopYHE(ta`eT$luXK8URoa@CXQB z4>16}zlIzq(bpef>+$RNANTyG_BKTkP*S{2`az~sdd(?2i(5GoWQMGx{!#9TEpk_H gwLT`WR@~$#e~+XQs5ClteJ~tpXb5O)TRP$Y0jlWdumAu6 literal 0 HcmV?d00001 From abeece112565dfd44e500bbd059cbd1ebc2cfcac Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sun, 2 Aug 2026 15:21:09 +0700 Subject: [PATCH 4/5] chore: ignore local agent artifacts --- .gitignore | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitignore b/.gitignore index cc4a5d9..61ca812 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,12 @@ coverage .cache tmp temp +var/ + +.agents +.cortexkit +context/ +docs/ +CONTEXT.md +AGENTS.md +graphify* From 27d521679dece178bc6c3eac93ac3751e53d6e04 Mon Sep 17 00:00:00 2001 From: Muhammad Fadhil Date: Sun, 2 Aug 2026 15:21:45 +0700 Subject: [PATCH 5/5] chore(server): keep disabled development limiter type-safe --- apps/server/src/lib/create-app.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/lib/create-app.ts b/apps/server/src/lib/create-app.ts index 83a21e7..0d5a497 100644 --- a/apps/server/src/lib/create-app.ts +++ b/apps/server/src/lib/create-app.ts @@ -9,7 +9,6 @@ import { secureHeaders } from 'hono/secure-headers'; import { notFound, onError } from '../middlewares/common.js'; import { validateRequestOrigin } from '../middlewares/origin.js'; import { pinoLogger } from '../middlewares/pino-logger.js'; -import { rateLimit } from '../middlewares/rate-limit.js'; import { env } from './env-config.js'; import { errorResponse, HttpStatus } from './response.js';