diff --git a/apps/server/.env.example b/apps/server/.env.example index f45244d..1837c5c 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -46,6 +46,7 @@ DEMO_INTAKE_RATE_LIMIT_MAX=10 # Optional real extraction OPENROUTER_API_KEY= +EXTRACTION_API_URL=https://openrouter.ai/api/v1/chat/completions OPENROUTER_PRIMARY_MODEL= OPENROUTER_FALLBACK_MODEL= OPENROUTER_TIMEOUT_MS=60000 diff --git a/apps/server/src/adapters/extraction/openrouter-extraction-adapter.test.ts b/apps/server/src/adapters/extraction/openrouter-extraction-adapter.test.ts index 04562c5..b44f7fc 100644 --- a/apps/server/src/adapters/extraction/openrouter-extraction-adapter.test.ts +++ b/apps/server/src/adapters/extraction/openrouter-extraction-adapter.test.ts @@ -4,6 +4,7 @@ import type { PreparedPage } from '../documents/document-preparation.js'; import { OpenRouterExtractionAdapter, + createOpenRouterConfig, type OpenRouterConfig } from './openrouter-extraction-adapter.js'; import type { ExtractionCallResult } from './openrouter-extraction-adapter.js'; @@ -50,6 +51,7 @@ function makePages(): PreparedPage[] { function makeConfig(overrides: Partial = {}): OpenRouterConfig { return { apiKey: 'test-key', + apiUrl: 'https://openrouter.example.test/v1/chat/completions', primaryModel: 'google/gemini-2.5-flash', fallbackModel: 'openai/gpt-4o', timeoutMs: 5_000, @@ -91,6 +93,16 @@ describe('OpenRouterExtractionAdapter', () => { vi.restoreAllMocks(); }); + it('reads a configurable extraction API URL', () => { + expect( + createOpenRouterConfig({ + OPENROUTER_API_KEY: 'test-key', + EXTRACTION_API_URL: + 'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions' + }).apiUrl + ).toBe('https://generativelanguage.googleapis.com/v1beta/openai/chat/completions'); + }); + it('extracts invoice from primary model successfully', async () => { const fetchMock = vi .fn() @@ -105,6 +117,9 @@ describe('OpenRouterExtractionAdapter', () => { expect(result.metadata.fallbackUsed).toBe(false); expect(result.metadata.promptVersion).toBe('invoice-extraction-v1'); expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0]?.[0]).toBe( + 'https://openrouter.example.test/v1/chat/completions' + ); }); it('falls back when primary returns invalid schema', async () => { diff --git a/apps/server/src/adapters/extraction/openrouter-extraction-adapter.ts b/apps/server/src/adapters/extraction/openrouter-extraction-adapter.ts index b4e7a9b..8cd3e2d 100644 --- a/apps/server/src/adapters/extraction/openrouter-extraction-adapter.ts +++ b/apps/server/src/adapters/extraction/openrouter-extraction-adapter.ts @@ -5,10 +5,11 @@ import type { PreparedPage } from '../documents/document-preparation.js'; import { SYSTEM_PROMPT, PROMPT_VERSION } from './extraction-prompt.js'; import { parseExtractedInvoice } from './extraction-schema.js'; -const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions'; +const DEFAULT_EXTRACTION_API_URL = 'https://openrouter.ai/api/v1/chat/completions'; export interface OpenRouterConfig { apiKey: string; + apiUrl: string; primaryModel: string; fallbackModel: string; timeoutMs: number; @@ -102,7 +103,7 @@ export class OpenRouterExtractionAdapter { const timeoutId = setTimeout(() => controller.abort(), this.config.timeoutMs); try { - const response = await fetch(OPENROUTER_URL, { + const response = await fetch(this.config.apiUrl, { method: 'POST', headers: { Authorization: `Bearer ${this.config.apiKey}`, @@ -191,6 +192,7 @@ export function createOpenRouterConfig(env: NodeJS.ProcessEnv = process.env): Op } return { apiKey, + apiUrl: env.EXTRACTION_API_URL ?? DEFAULT_EXTRACTION_API_URL, primaryModel: env.OPENROUTER_PRIMARY_MODEL ?? 'google/gemini-2.5-flash', fallbackModel: env.OPENROUTER_FALLBACK_MODEL ?? 'openai/gpt-4o', timeoutMs: Number(env.OPENROUTER_TIMEOUT_MS ?? 60_000), diff --git a/apps/server/src/domain/invoice-cases/invoice-case.test.ts b/apps/server/src/domain/invoice-cases/invoice-case.test.ts index 6e41c17..75a9864 100644 --- a/apps/server/src/domain/invoice-cases/invoice-case.test.ts +++ b/apps/server/src/domain/invoice-cases/invoice-case.test.ts @@ -15,6 +15,7 @@ import { financeDecision, recordFindings, rejectInvoiceCase, + retryPosting, requestReprocessing, resolveFinding, transitionInvoiceCase, @@ -221,6 +222,26 @@ describe('Invoice Case authority', () => { DomainRuleError ); }); + + it('retries a failed posting with the AP owner and current version', () => { + const readyToPost = confirmAp(reviewCase(), { expectedVersion: 2, actor: owner }); + const posting = beginPosting(readyToPost, { expectedVersion: 3, actor: owner }); + const failed = transitionInvoiceCase(posting, { + expectedVersion: 4, + to: 'posting_failed' + }); + + expect(retryPosting(failed, { expectedVersion: 5, actor: owner })).toMatchObject({ + state: 'posting', + version: 6 + }); + expect(() => retryPosting(failed, { expectedVersion: 4, actor: owner })).toThrowError( + DomainRuleError + ); + expect(() => retryPosting(failed, { expectedVersion: 5, actor: finance })).toThrowError( + DomainRuleError + ); + }); }); describe('Invoice Draft revisions and validation', () => { diff --git a/apps/server/src/domain/invoice-cases/invoice-case.ts b/apps/server/src/domain/invoice-cases/invoice-case.ts index 4bab85f..2f9b549 100644 --- a/apps/server/src/domain/invoice-cases/invoice-case.ts +++ b/apps/server/src/domain/invoice-cases/invoice-case.ts @@ -338,6 +338,20 @@ export function beginPosting( return updateCase(invoiceCase, { state: 'posting' }); } +export function retryPosting( + invoiceCase: InvoiceCase, + input: { expectedVersion: number; actor: InvoiceCaseActor } +): InvoiceCase { + assertMutable(invoiceCase); + assertVersion(invoiceCase, input.expectedVersion); + assertApOwner(invoiceCase, input.actor, 'invoice.post'); + if (invoiceCase.state !== 'posting_failed') { + throw new DomainRuleError('invalid_transition', 'Case is not waiting for posting retry'); + } + + return updateCase(invoiceCase, { state: 'posting' }); +} + export function completePosting( invoiceCase: InvoiceCase, input: { expectedVersion: number } diff --git a/apps/server/src/repositories/invoice-case.repository.ts b/apps/server/src/repositories/invoice-case.repository.ts index 82f9dfa..a39e09c 100644 --- a/apps/server/src/repositories/invoice-case.repository.ts +++ b/apps/server/src/repositories/invoice-case.repository.ts @@ -198,6 +198,7 @@ export interface InvoiceCaseRepository { errorCode?: string | null; }) => Promise; findPostingAttempt: (id: string) => Promise; + findLatestPostingAttempt: (caseId: string) => Promise; } async function findInvoiceCase( @@ -786,6 +787,16 @@ export function createInvoiceCaseRepository(database: DatabaseExecutor): Invoice return attempt ?? null; }, + findLatestPostingAttempt: async (caseId) => { + const [attempt] = await database + .select() + .from(postingAttemptsTable) + .where(eq(postingAttemptsTable.invoiceCaseId, caseId)) + .orderBy(desc(postingAttemptsTable.createdAt)) + .limit(1); + return attempt ?? null; + }, + createFinanceDecision: async (input) => { await database.insert(financeDecisionsTable).values({ invoiceCaseId: input.caseId, diff --git a/apps/server/src/services/invoice-case-command.test.ts b/apps/server/src/services/invoice-case-command.test.ts index e225fd1..8400e7d 100644 --- a/apps/server/src/services/invoice-case-command.test.ts +++ b/apps/server/src/services/invoice-case-command.test.ts @@ -27,6 +27,13 @@ function makeDependencies(state: Record) { status: 'pending', externalReference: null }), + findLatestPostingAttempt: vi.fn().mockResolvedValue(null), + updatePostingAttempt: vi.fn().mockResolvedValue({ + id: '44444444-4444-4444-8444-444444444444', + status: 'pending', + externalReference: null, + errorCode: null + }), applyCorrection: vi .fn() .mockResolvedValue({ id: caseId, state: 'awaiting_ap_review', version: 3 }), @@ -155,6 +162,55 @@ describe('Invoice Case command service', () => { ); }); + it('reuses the existing posting attempt when retrying a temporary failure', async () => { + const setup = makeDependencies({ + id: caseId, + ownerId: actor.id, + state: 'posting_failed', + version: 5, + currentDraftRevisionId: revisionId, + findings: [], + apConfirmation: { actorId: actor.id, draftRevisionId: revisionId } + }); + const attempt = { + id: '44444444-4444-4444-8444-444444444444', + invoiceCaseId: caseId, + draftRevisionId: revisionId, + idempotencyKey: `invoice-case:${caseId}:revision:${revisionId}`, + status: 'failed' as const, + externalReference: null, + errorCode: 'temporary_failure' + }; + setup.repository.findLatestPostingAttempt.mockResolvedValue(attempt); + setup.repository.advanceState.mockResolvedValue({ id: caseId, state: 'posting', version: 6 }); + const service = createInvoiceCaseCommandService(setup.dependencies as never); + + const result = await service.post({ actor, caseId, expectedVersion: 5 }); + + expect(result).toMatchObject({ + invoiceCase: { id: caseId, state: 'posting', version: 6 }, + postingAttempt: { status: 'pending', externalReference: null } + }); + expect(setup.repository.createPostingAttempt).not.toHaveBeenCalled(); + expect(setup.repository.updatePostingAttempt).toHaveBeenCalledWith({ + id: attempt.id, + status: 'pending', + externalReference: null, + errorCode: null + }); + expect(setup.enqueue).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + jobKey: `invoice-case:${caseId}:posting:${revisionId}`, + payload: expect.objectContaining({ + postingAttemptId: attempt.id, + idempotencyKey: attempt.idempotencyKey, + expectedVersion: 6 + }) + }) + ); + }); + it('corrects draft with a human revision and invalidates prior confirmation', async () => { const setup = makeDependencies({ id: caseId, diff --git a/apps/server/src/services/invoice-case-command.ts b/apps/server/src/services/invoice-case-command.ts index 12d1bb9..dc1551f 100644 --- a/apps/server/src/services/invoice-case-command.ts +++ b/apps/server/src/services/invoice-case-command.ts @@ -7,6 +7,7 @@ import { createInvoiceCase, financeDecision, rejectInvoiceCase, + retryPosting, requestReprocessing, resolveFinding as resolveFindingDomain } from '@/domain/invoice-cases/invoice-case.js'; @@ -191,11 +192,31 @@ export function createInvoiceCaseCommandService( const current = await repository.findCommandState(input.caseId); if (!current) throw new InvoiceCaseCommandNotFoundError(); + const retryAttempt = + current.state === 'posting_failed' + ? await repository.findLatestPostingAttempt(input.caseId) + : null; + if (current.state === 'posting_failed') { + if ( + !retryAttempt || + retryAttempt.status !== 'failed' || + retryAttempt.errorCode === 'permanent_rejection' + ) { + throw new InvoiceCaseCommandConflictError('posting_not_retryable'); + } + if (retryAttempt.draftRevisionId !== current.currentDraftRevisionId) { + throw new InvoiceCaseCommandConflictError('posting_attempt_stale'); + } + } + const next = runDomain(() => - beginPosting(toDomainState(current), { - expectedVersion: input.expectedVersion, - actor: input.actor - }) + (current.state === 'posting_failed' ? retryPosting : beginPosting)( + toDomainState(current), + { + expectedVersion: input.expectedVersion, + actor: input.actor + } + ) ); const draftRevisionId = next.currentDraftRevisionId!; const updated = await repository.advanceState({ @@ -209,13 +230,22 @@ export function createInvoiceCaseCommandService( }); if (!updated) throw new InvoiceCaseCommandConflictError('stale_version'); - const idempotencyKey = `invoice-case:${input.caseId}:revision:${draftRevisionId}`; - const postingAttempt = await repository.createPostingAttempt({ - caseId: input.caseId, - draftRevisionId, - idempotencyKey, - status: 'pending' - }); + const idempotencyKey = + retryAttempt?.idempotencyKey ?? + `invoice-case:${input.caseId}:revision:${draftRevisionId}`; + const postingAttempt = retryAttempt + ? ((await repository.updatePostingAttempt({ + id: retryAttempt.id, + status: 'pending', + externalReference: null, + errorCode: null + })) ?? retryAttempt) + : await repository.createPostingAttempt({ + caseId: input.caseId, + draftRevisionId, + idempotencyKey, + status: 'pending' + }); await dependencies.enqueue(executor, { jobKey: `invoice-case:${input.caseId}:posting:${draftRevisionId}`, payload: { 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 96904fc..b388a3c 100644 --- a/apps/server/src/services/invoice-case-processing.integration.test.ts +++ b/apps/server/src/services/invoice-case-processing.integration.test.ts @@ -1,9 +1,9 @@ import { randomUUID } from 'node:crypto'; -import { eq } from 'drizzle-orm'; +import { eq, inArray, like } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/node-postgres'; import { Pool } from 'pg'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; import { createDeterministicInvoiceExtractor } from '@/adapters/extraction/deterministic-extractor.js'; import { enqueueInvoiceCaseProcessing } from '@/jobs/queue.js'; @@ -35,6 +35,50 @@ describe('Invoice Case clean processing', () => { database = drizzle({ client: pool }); }); + const cleanupFixtures = async () => { + const matchingRevisions = await database + .select({ caseId: invoiceDraftRevisionsTable.invoiceCaseId }) + .from(invoiceDraftRevisionsTable) + .where(eq(invoiceDraftRevisionsTable.invoiceNumber, 'INV-2026-0001')); + const matchingCaseIds = matchingRevisions.map((revision) => revision.caseId); + if (matchingCaseIds.length > 0) { + const matchingCases = await database + .select({ sourceDocumentId: invoiceCasesTable.sourceDocumentId }) + .from(invoiceCasesTable) + .where(inArray(invoiceCasesTable.id, matchingCaseIds)); + await database + .delete(invoiceCasesTable) + .where(inArray(invoiceCasesTable.id, matchingCaseIds)); + await database.delete(sourceDocumentsTable).where( + inArray( + sourceDocumentsTable.id, + matchingCases.map((item) => item.sourceDocumentId) + ) + ); + } + + const users = await database + .select({ id: usersTable.id }) + .from(usersTable) + .where(like(usersTable.email, '%@t8.test')); + const userIds = users.map((user) => user.id); + if (userIds.length === 0) return; + + await database.delete(invoiceCasesTable).where(inArray(invoiceCasesTable.ownerId, userIds)); + await database + .delete(sourceDocumentsTable) + .where(inArray(sourceDocumentsTable.uploadedBy, userIds)); + await database.delete(usersTable).where(inArray(usersTable.id, userIds)); + }; + + beforeEach(async () => { + await cleanupFixtures(); + }); + + afterEach(async () => { + await cleanupFixtures(); + }); + afterAll(async () => { await pool.end(); }); @@ -121,11 +165,5 @@ describe('Invoice Case clean processing', () => { 'processing_started', 'processing_completed' ]); - - await database.delete(invoiceCasesTable).where(eq(invoiceCasesTable.id, invoiceCase.id)); - await database - .delete(sourceDocumentsTable) - .where(eq(sourceDocumentsTable.id, invoiceCase.sourceDocumentId)); - await database.delete(usersTable).where(eq(usersTable.id, userId)); }); }); diff --git a/e2e/invoice-workflows.spec.ts b/e2e/invoice-workflows.spec.ts index 425759c..9451fdf 100644 --- a/e2e/invoice-workflows.spec.ts +++ b/e2e/invoice-workflows.spec.ts @@ -153,10 +153,12 @@ test.describe('required invoice workflows', () => { await submitInvoice(page, 'e2e-duplicate.pdf', true); }); - test('temporary posting failure exposes a retry action', async ({ page }) => { + test('temporary posting retry keeps one accounting outcome', async ({ page }) => { await signIn(page, 'ap.specialist@trestle.demo'); await page.goto('/cases?status=posting_failed'); await page.getByRole('link', { name: 'INV-E2E-POSTFAIL' }).click(); - await expect(page.getByRole('button', { name: 'Retry posting' })).toBeVisible(); + await page.getByRole('button', { name: 'Retry posting' }).click(); + await expect(page.getByRole('heading', { name: 'Posted' })).toBeVisible({ timeout: 20_000 }); + await expect(page.getByText(/ACC-[0-9A-F]{16}/)).toBeVisible(); }); });