Files
accounted/extensions/general/invoice-inbox/lib/textract-expense.ts
T
MattssonandClaude Opus 4.7 1af977950b Ai/full autonomous flow (#359)
* Refactor bookkeeping error handling and introduce new error classes

- Introduced new error classes for better error categorization:
  - JournalEntryNotBalancedError
  - FiscalPeriodNotFoundError
  - EntryDateOutsideFiscalPeriodError
  - JournalEntryNotFoundError
  - CannotReverseNonPostedError
  - CannotCorrectNonPostedError
  - EntryAlreadyReversedError
  - CurrencyRevaluationAlreadyExistsError
  - InvalidMappingResultError
  - BookkeepingDatabaseError

- Updated existing functions in engine.ts and transaction-entries.ts to throw specific errors instead of generic ones.
- Enhanced error response handling in get-error-message.ts to provide localized messages for new error types.
- Added unit tests for new error classes and error handling functions to ensure correctness and coverage.

* feat(ai): implement AI proposal application and persistence

- Add apply.ts to handle the application of AI proposals, including match and booking steps.
- Introduce persist.ts for inserting and managing AI requests and proposals, ensuring unique constraints.
- Create re-validate.ts for validating proposals before acceptance, checking for stale conditions.
- Define database migrations for ai_requests and ai_proposals tables, including constraints and indexes.
- Enhance journal_entries with AI provenance tracking, linking entries to AI proposals.
- Update categorization_templates to distinguish AI-corrected templates.
- Add company settings for toggling AI flow and managing backfill processes.
- Extend processing_history to include AI-related events for better tracking.

* feat: add uncategorized transactions API and UI for transaction selection

- Implemented a new API endpoint for fetching uncategorized transactions with pagination and filtering options.
- Created ChangeTransactionDialog component for selecting alternative transactions based on AI proposals.
- Developed ReceiptDetailDialog to display detailed information about receipts, including upload functionality.
- Added TransactionDetailDialog for viewing transaction details with links to the transaction list.
- Introduced receipt quality assessment logic to evaluate extracted receipt data.
- Implemented feature flagging for the AI bookkeeping agent to control availability in different environments.

* feat: add manual receipt extraction dialog and integrate AWS Textract for expense analysis

- Added ManualExtractDialog component for user input when AI fails to extract receipt data.
- Implemented ReceiptsList component to manage and display uploaded receipts, including upload and rescan functionalities.
- Introduced Textract integration for analyzing expenses, extracting fields like total, vendor, and date.
- Updated package.json to include @aws-sdk/client-textract dependency.

* fix(ai): handle livsmedel VAT transition (12% → 6%) in booking prompt and re-validate guard

Add date-aware guidance to BOOKING_SYSTEM_PROMPT for the temporary livsmedel
VAT cut (Prop. 2025/26:55, 2026-04-01 to 2027-12-31), with restaurang/servering
carve-out at 12%. Add a re-validate safety net that rejects clearly-stale rate
labels for grocery-chain merchants relative to the entry date.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 10:32:15 +02:00

192 lines
7.0 KiB
TypeScript

/**
* AWS Textract AnalyzeExpense — deterministic field extraction for receipts
* and invoices. Runs in parallel with the Claude vision pass; numbers from
* Textract act as an anti-hallucination anchor for the final cross-check.
*
* Why receipt-specialized OCR over generic AnalyzeDocument: AnalyzeExpense is
* tuned for the expense-document family (SUMMARY_FIELDS like TOTAL, TAX,
* VENDOR_NAME, INVOICE_RECEIPT_DATE with field-level confidence scores).
* Generic OCR returns raw text and positions — useful for nothing on its own.
*
* Failure model: every path is best-effort. If Textract returns an error, is
* unsupported for this mime type, or the file is over the sync-API limit,
* we return null and the caller falls back to Claude-only. Never throws.
*/
import {
TextractClient,
AnalyzeExpenseCommand,
type ExpenseDocument,
type ExpenseField,
} from '@aws-sdk/client-textract'
let _client: TextractClient | null = null
function getClient(): TextractClient {
if (!_client) {
_client = new TextractClient({
region: process.env.AWS_REGION || 'eu-north-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
})
}
return _client
}
// Sync AnalyzeExpense caps at 5 MB per document. Anything bigger we skip
// rather than fall back to the async API — that adds S3 polling complexity
// for a tail case. The common-path receipt is <1 MB.
const MAX_SYNC_BYTES = 5 * 1024 * 1024
// Textract supports: PNG, JPEG, PDF, TIFF. HEIC/WebP → skip (Claude handles
// them fine; a second read isn't worth converting the image).
const SUPPORTED_MIMES = new Set(['application/pdf', 'image/jpeg', 'image/png', 'image/tiff'])
export interface TextractExpenseResult {
total: { value: number; confidence: number } | null
subtotal: { value: number; confidence: number } | null
tax: { value: number; confidence: number } | null
vendor: { value: string; confidence: number } | null
date: { value: string; confidence: number } | null
currency: string | null
// Raw summary fields kept for audit and future use (e.g., line items).
raw_summary: Array<{ type: string; value: string; confidence: number }>
}
export async function analyzeExpenseWithTextract(
fileBuffer: Buffer,
mimeType: string
): Promise<TextractExpenseResult | null> {
if (!SUPPORTED_MIMES.has(mimeType)) return null
if (fileBuffer.byteLength > MAX_SYNC_BYTES) return null
if (!process.env.AWS_ACCESS_KEY_ID || !process.env.AWS_SECRET_ACCESS_KEY) return null
try {
const client = getClient()
const response = await client.send(
new AnalyzeExpenseCommand({
Document: { Bytes: fileBuffer },
})
)
const doc: ExpenseDocument | undefined = response.ExpenseDocuments?.[0]
if (!doc) return null
const summary = doc.SummaryFields ?? []
return parseSummaryFields(summary)
} catch (err) {
// Don't let OCR failure break the pipeline — the Claude pass still runs.
// Log so we can see rate-limit / auth issues but return null to caller.
console.error('[textract-expense] AnalyzeExpense failed:', err)
return null
}
}
function parseSummaryFields(fields: ExpenseField[]): TextractExpenseResult {
const raw_summary = fields
.map((f) => ({
type: f.Type?.Text ?? 'UNKNOWN',
value: f.ValueDetection?.Text ?? '',
confidence: (f.ValueDetection?.Confidence ?? 0) / 100,
}))
.filter((f) => f.value)
const pickNumber = (type: string): { value: number; confidence: number } | null => {
const field = fields.find((f) => f.Type?.Text === type)
if (!field?.ValueDetection?.Text) return null
const parsed = parseMoneyString(field.ValueDetection.Text)
if (parsed == null) return null
return { value: parsed, confidence: (field.ValueDetection.Confidence ?? 0) / 100 }
}
const pickString = (type: string): { value: string; confidence: number } | null => {
const field = fields.find((f) => f.Type?.Text === type)
if (!field?.ValueDetection?.Text) return null
return {
value: field.ValueDetection.Text.trim(),
confidence: (field.ValueDetection.Confidence ?? 0) / 100,
}
}
const rawDate = pickString('INVOICE_RECEIPT_DATE')
return {
total: pickNumber('TOTAL'),
subtotal: pickNumber('SUBTOTAL'),
tax: pickNumber('TAX'),
vendor: pickString('VENDOR_NAME'),
date: rawDate ? { value: normalizeDate(rawDate.value), confidence: rawDate.confidence } : null,
currency: pickString('CURRENCY')?.value ?? null,
raw_summary,
}
}
// Textract returns money strings like "123,45 kr", "$123.45", "1 234,56 SEK".
// Strip everything but digits + separators, then normalize to period as
// decimal. Returns null when we can't confidently parse.
function parseMoneyString(raw: string): number | null {
const cleaned = raw.replace(/[^\d.,-]/g, '').trim()
if (!cleaned) return null
// Swedish: 1 234,56 → 1234.56 (comma = decimal, space/period = thousands)
// US: 1,234.56 → 1234.56 (comma = thousands, period = decimal)
// Heuristic: if both , and . present, the rightmost is the decimal.
const lastComma = cleaned.lastIndexOf(',')
const lastDot = cleaned.lastIndexOf('.')
let normalized: string
if (lastComma === -1 && lastDot === -1) {
normalized = cleaned
} else if (lastComma > lastDot) {
// Comma is decimal separator
normalized = cleaned.replace(/\./g, '').replace(',', '.')
} else {
// Period is decimal separator
normalized = cleaned.replace(/,/g, '')
}
const num = Number(normalized)
return Number.isFinite(num) ? num : null
}
// Textract returns dates in many formats ("2024-03-14", "14/3/24", "March 14,
// 2024"). We coerce to ISO where possible; leave the original string as a
// fallback. The Claude pass will have its own date, so imperfect parse here
// is fine — cross-check falls back to fuzzy matching if needed.
function normalizeDate(raw: string): string {
const trimmed = raw.trim()
// Already ISO
if (/^\d{4}-\d{2}-\d{2}/.test(trimmed)) return trimmed.slice(0, 10)
const parsed = new Date(trimmed)
if (!isNaN(parsed.getTime())) return parsed.toISOString().slice(0, 10)
return trimmed
}
// Compares a Claude-extracted total against the Textract-extracted total.
// Agreement tolerance is 1 öre (0.01 SEK) — anything more is a real
// disagreement worth flagging, not rounding noise. Returns null when either
// side didn't produce a total (no basis for comparison).
export interface AgreementResult {
agrees: boolean
claude_total: number | null
ocr_total: number | null
ocr_confidence: number | null
delta: number | null
}
export function checkTotalsAgreement(
claudeTotal: number | null | undefined,
textract: TextractExpenseResult | null
): AgreementResult | null {
if (claudeTotal == null || !textract?.total) return null
const delta = Math.abs(claudeTotal - textract.total.value)
return {
agrees: delta <= 0.01,
claude_total: claudeTotal,
ocr_total: textract.total.value,
ocr_confidence: textract.total.confidence,
delta,
}
}