Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,6 +51,7 @@ function makePages(): PreparedPage[] {
function makeConfig(overrides: Partial<OpenRouterConfig> = {}): 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,
Expand Down Expand Up @@ -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()
Expand All @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}`,
Expand Down Expand Up @@ -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),
Expand Down
21 changes: 21 additions & 0 deletions apps/server/src/domain/invoice-cases/invoice-case.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
financeDecision,
recordFindings,
rejectInvoiceCase,
retryPosting,
requestReprocessing,
resolveFinding,
transitionInvoiceCase,
Expand Down Expand Up @@ -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', () => {
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/domain/invoice-cases/invoice-case.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/repositories/invoice-case.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ export interface InvoiceCaseRepository {
errorCode?: string | null;
}) => Promise<PostingAttemptRecord | null>;
findPostingAttempt: (id: string) => Promise<PostingAttemptRecord | null>;
findLatestPostingAttempt: (caseId: string) => Promise<PostingAttemptRecord | null>;
}

async function findInvoiceCase(
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions apps/server/src/services/invoice-case-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,13 @@ function makeDependencies(state: Record<string, unknown>) {
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 }),
Expand Down Expand Up @@ -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,
Expand Down
52 changes: 41 additions & 11 deletions apps/server/src/services/invoice-case-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createInvoiceCase,
financeDecision,
rejectInvoiceCase,
retryPosting,
requestReprocessing,
resolveFinding as resolveFindingDomain
} from '@/domain/invoice-cases/invoice-case.js';
Expand Down Expand Up @@ -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({
Expand All @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
});
Expand Down Expand Up @@ -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));
});
});
6 changes: 4 additions & 2 deletions e2e/invoice-workflows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading