Files
accounted/extensions/general/invoice-inbox/lib/classify-document.ts
T
Jakob WennbergandClaude Opus 4.7 0076aa85f8 feat: arcim inbox (Resend Inbound) + smart-match extension + commit metadata (#286)
* feat: multi-series SIE import, reusable FiscalYearSelector, library templates in picker

- SIE import preserves each voucher's source series (B/C/I/V/...), essential
  for Fortnox migrations where series carry semantic meaning (kundfakturor,
  inbetalningar, etc.). Target numbering still goes through next_voucher_number
  per series; source (series, number) is stored in the migration mapping for
  BFNAR 2013:2 audit trail.
- Execute route reads company_settings.default_voucher_series as the fallback
  for vouchers arriving without a series (SIE4I).
- Extract shared FiscalYearSelector component; adopt in /reports and
  /bookkeeping.
- Transaction TemplatePicker now surfaces user-created library templates
  (company + team scope) alongside the static registry, with a helper to
  convert simple library templates into the BookingTemplate shape.
- Exclude 8999 "Årets resultat" from income statement financial section and
  monthly breakdown so year-end closing entries don't cancel the net result.

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

* test: skip Bokio SIE regression when fixtures are absent

/dev_docs is gitignored (contains anonymised customer exports), so the
integration test can't find its input files in CI. Gate the suite on
fixture presence so it still runs locally.

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

* fix: address Greptile review feedback

- convertLibraryToBookingTemplate: default entity_applicability to 'all'
  when the source template has no entity_type, so TemplatePicker doesn't
  silently hide it for companies with a set entity type.
- FiscalYearSelector: fire onReady in the no-company early-return branch
  so consumers (e.g. ReportsPage) don't get stuck in a loading skeleton
  while the company context is still hydrating.

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

* feat: arcim inbox + smart-match extension + commit metadata

Three threads, all gated off in extensions.config.json (invoice-inbox and
inbox-smart-match are not in the enabled list for this PR).

invoice-inbox: Gmail OAuth -> Resend Inbound (v2.0.0)
- Remove gmail-scanner / gmail-helpers
- Add resend-inbound.ts (webhook verify, attachment fetch) and
  inbox-provisioning.ts (per-company @arcim.io address with rotation)
- Replace /gmail/* routes with /inbox/address and admin-only /inbox/rotate
- Workspace UI: card layout + MatchBlock surfacing AI transaction matches
- classify-document: tightened discount/total prompt; cap confidence at
  50% when line items do not reconcile with amount_incl_vat
- Manifest requires RESEND_API_KEY, RESEND_INBOUND_DOMAIN,
  RESEND_INBOUND_WEBHOOK_SECRET

inbox-smart-match (new extension)
- Event-driven AI matching of receipts to bank transactions
- Listens on inbox_item.classified (match now) and transaction.synced
  (retro-match receipts waiting for a transaction)
- Uses service-role client; processing_history append is scoped by
  company_id from the event payload

commit metadata + audit plumbing
- journal_entries gains commit_method and rubric_version columns
- commit_journal_entry RPC accepts both (BFNAR 2013:2 behandlingshistorik)
- processing-history PII detector strips UUID-shaped substrings before
  personnummer pattern matching (UUIDs were triggering false positives)
- New generic inbox_item.classified event

Migrations
- arcim_inbox: company_inboxes table, resend_email_id, email_body_text,
  auto-provision trigger, drops obsolete email_connections
- journal_entry_commit_metadata: new columns + updated RPC
- inbox_attachment_composite: resend_attachment_id + composite unique index
- inbox_smart_match: correlation_id, match_reasoning, expanded match_method
  CHECK, pending-match and correlation indexes

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-20 21:50:07 +02:00

597 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Document Classification Pipeline
*
* Pure function: file buffer + mime type → structured classification result.
* No database, no side effects. Uses AWS Bedrock (Claude Sonnet) for vision-based
* extraction of Swedish financial documents.
*/
import {
BedrockRuntimeClient,
ConverseCommand,
type ContentBlock,
type ToolConfiguration,
type Message,
} from '@aws-sdk/client-bedrock-runtime'
import sharp from 'sharp'
import type {
InvoiceExtractionResult,
ReceiptExtractionResult,
ExtractedLineItem,
ExtractedInvoiceLineItem,
VatBreakdownItem,
} from '@/types'
// ── Types ────────────────────────────────────────────────────
export type DocumentClassificationType = 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown'
export interface ClassificationInput {
fileBuffer: Buffer
mimeType: string
fileName: string
}
export interface ClassificationResult {
documentType: DocumentClassificationType
extractedData: InvoiceExtractionResult | ReceiptExtractionResult | null
confidence: number
rawResponse: Record<string, unknown>
usage: { inputTokens: number; outputTokens: number }
}
// ── Bedrock client (lazy singleton) ──────────────────────────
let _client: BedrockRuntimeClient | null = null
function getClient(): BedrockRuntimeClient {
if (!_client) {
_client = new BedrockRuntimeClient({
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
}
// ── Constants ────────────────────────────────────────────────
const VALID_VAT_RATES = [0, 6, 12, 25]
const MIME_TO_IMAGE_FORMAT: Record<string, string> = {
'image/jpeg': 'jpeg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
}
// ── System prompt ────────────────────────────────────────────
const SYSTEM_PROMPT = `Du är en svensk bokföringsdokumentklassificerare och dataextraktor.
Du analyserar bilder och PDF:er av finansiella dokument (leverantörsfakturor, kvitton, skattedokument) och extraherar strukturerad data.
Kontext:
- Svensk bokföring enligt Bokföringslagen (BFL) och BFNAR
- Giltiga momssatser: 0%, 6%, 12%, 25%
- Organisationsnummer: 10 siffror, format XXXXXX-XXXX
- Vanliga betalningsmetoder: bankgiro, plusgiro, Swish, banköverföring, kort
- Valutor: SEK är standard, men EUR, USD, GBP etc förekommer
Instruktioner:
- Klassificera dokumenttypen
- Extrahera alla synliga fält — returnera null för fält som inte kan utläsas
- Belopp med max 2 decimaler. Totaler (amount_excl_vat, amount_incl_vat, vat_amount) är alltid positiva. line_items.amount är normalt positiva men KAN vara negativa för rabattrader.
- Datum i ISO-format (YYYY-MM-DD)
- Momssats som heltal (0, 6, 12, eller 25)
- Ge en confidence-poäng 0-100 för hur säker du är på klassificeringen och extraktionen
KRITISKT — Totaler och rabatter:
- amount_incl_vat MÅSTE motsvara fakturans slutbelopp ("Amount due", "Att betala", "Totalt", "Subtotal" när moms saknas). Summera ALDRIG delrader om fakturan anger ett explicit totalbelopp — använd det.
- Om en rad har en rabatt/discount under sig (t.ex. "Discount (-$10.00)", "Rabatt -100 kr"), extrahera radens NETTO-belopp (brutto − rabatt), INTE bruttobeloppet. Exempel: en rad "Compute Hours $50.68" följd av "Discount -$10.00" → line_items.amount = 40.68, inte 50.68.
- Summan av line_items.amount + moms MÅSTE bli lika med amount_incl_vat. Om det inte stämmer har du antingen missat en rabatt eller dubbelräknat en rad — kontrollera och justera.
- Om du är osäker på hur rabatter ska fördelas, lägg en enskild negativ "Rabatt"-rad i line_items så summan blir rätt.
Anropa ALLTID verktyget classify_document med resultatet.`
// ── Tool schema for structured output ────────────────────────
const CLASSIFICATION_TOOL: ToolConfiguration = {
tools: [
{
toolSpec: {
name: 'classify_document',
description: 'Klassificera och extrahera data från ett svenskt finansiellt dokument',
inputSchema: {
json: {
type: 'object',
required: ['document_type', 'confidence'],
properties: {
document_type: {
type: 'string',
enum: ['supplier_invoice', 'receipt', 'government_letter', 'unknown'],
description: 'Typ av dokument',
},
confidence: {
type: 'integer',
minimum: 0,
maximum: 100,
description: 'Säkerhet i klassificeringen (0-100)',
},
// Supplier invoice fields
supplier_name: { type: ['string', 'null'] },
supplier_org_number: { type: ['string', 'null'] },
supplier_vat_number: { type: ['string', 'null'] },
supplier_address: { type: ['string', 'null'] },
supplier_bankgiro: { type: ['string', 'null'] },
supplier_plusgiro: { type: ['string', 'null'] },
invoice_number: { type: ['string', 'null'] },
invoice_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
due_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
payment_reference: { type: ['string', 'null'], description: 'OCR-nummer eller betalningsreferens' },
currency: { type: ['string', 'null'], description: 'ISO 4217 (t.ex. SEK, EUR)' },
// Receipt fields
merchant_name: { type: ['string', 'null'] },
merchant_org_number: { type: ['string', 'null'] },
merchant_vat_number: { type: ['string', 'null'] },
merchant_is_foreign: { type: ['boolean', 'null'] },
receipt_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
receipt_time: { type: ['string', 'null'], description: 'HH:MM' },
is_restaurant: { type: ['boolean', 'null'] },
is_systembolaget: { type: ['boolean', 'null'] },
// Shared amount fields
amount_excl_vat: { type: ['number', 'null'] },
amount_incl_vat: { type: ['number', 'null'] },
vat_amount: { type: ['number', 'null'] },
// Line items
line_items: {
type: 'array',
items: {
type: 'object',
properties: {
description: { type: 'string' },
quantity: { type: ['number', 'null'] },
unit_price: { type: ['number', 'null'] },
amount: { type: ['number', 'null'] },
vat_rate: { type: ['integer', 'null'], description: '0, 6, 12, or 25' },
},
required: ['description'],
},
},
// VAT breakdown (for invoices)
vat_breakdown: {
type: 'array',
items: {
type: 'object',
properties: {
rate: { type: 'integer' },
base: { type: 'number' },
amount: { type: 'number' },
},
required: ['rate', 'base', 'amount'],
},
},
},
},
},
},
},
],
toolChoice: { any: {} },
}
// ── Core classification function ─────────────────────────────
export async function classifyDocument(input: ClassificationInput): Promise<ClassificationResult> {
const contentBlock = await buildContentBlock(input)
const messages: Message[] = [
{
role: 'user',
content: [
contentBlock,
{ text: 'Analysera detta dokument. Klassificera typ och extrahera all strukturerad data.' },
],
},
]
const modelId = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
const maxTokens = parseInt(process.env.BEDROCK_MAX_TOKENS || '8192', 10)
const command = new ConverseCommand({
modelId,
messages,
system: [{ text: SYSTEM_PROMPT }],
toolConfig: CLASSIFICATION_TOOL,
inferenceConfig: { maxTokens },
})
const response = await getClient().send(command)
// Extract tool use result from response
const outputMessage = response.output?.message
if (!outputMessage?.content) {
throw new Error('No content in Bedrock response')
}
const toolUseBlock = outputMessage.content.find(
(block): block is ContentBlock.ToolUseMember => 'toolUse' in block && block.toolUse !== undefined
)
if (!toolUseBlock?.toolUse?.input) {
throw new Error('No tool use result in Bedrock response')
}
const rawData = toolUseBlock.toolUse.input as Record<string, unknown>
const usage = {
inputTokens: response.usage?.inputTokens ?? 0,
outputTokens: response.usage?.outputTokens ?? 0,
}
// Validate and map to typed result
const result = mapToClassificationResult(rawData, usage)
// If validation found issues, retry once with correction
if (!result) {
return retryWithCorrection(input, rawData, usage)
}
return result
}
// ── Content block builder ────────────────────────────────────
async function buildContentBlock(input: ClassificationInput): Promise<ContentBlock> {
const { fileBuffer, mimeType } = input
// PDF → document block
if (mimeType === 'application/pdf') {
return {
document: {
format: 'pdf',
name: sanitizeDocName(input.fileName),
source: {
bytes: new Uint8Array(fileBuffer),
},
},
}
}
// HEIC → convert to JPEG via sharp
if (mimeType === 'image/heic' || mimeType === 'image/heif') {
const jpegBuffer = await sharp(fileBuffer).jpeg({ quality: 90 }).toBuffer()
return {
image: {
format: 'jpeg',
source: { bytes: new Uint8Array(jpegBuffer) },
},
}
}
// Standard image formats
const imageFormat = MIME_TO_IMAGE_FORMAT[mimeType]
if (imageFormat) {
return {
image: {
format: imageFormat as 'jpeg' | 'png' | 'webp' | 'gif',
source: { bytes: new Uint8Array(fileBuffer) },
},
}
}
throw new Error(`Unsupported MIME type: ${mimeType}`)
}
/** Sanitize filename for Bedrock document name (alphanumeric, spaces, hyphens, brackets only) */
function sanitizeDocName(fileName: string): string {
const name = fileName.replace(/\.[^.]+$/, '') // strip extension
return name.replace(/[^a-zA-Z0-9\s\-\[\]\(\)åäöÅÄÖ]/g, '').trim() || 'document'
}
// ── Response mapping + validation ────────────────────────────
function mapToClassificationResult(
raw: Record<string, unknown>,
usage: { inputTokens: number; outputTokens: number }
): ClassificationResult | null {
const documentType = raw.document_type as DocumentClassificationType
const confidence = Math.min(100, Math.max(0, Number(raw.confidence) || 0))
if (!['supplier_invoice', 'receipt', 'government_letter', 'unknown'].includes(documentType)) {
return null
}
if (documentType === 'government_letter' || documentType === 'unknown') {
return {
documentType,
extractedData: null,
confidence,
rawResponse: raw,
usage,
}
}
if (documentType === 'supplier_invoice') {
const extractedData = mapToInvoiceExtraction(raw)
if (!extractedData) return null
// mapToInvoiceExtraction caps its own confidence to 50% when line items
// don't reconcile with amount_incl_vat. Propagate that cap to the outer
// confidence so invoice_inbox_items.confidence (used by the UI badge)
// also reflects the reconciliation failure.
const effectiveConfidence = Math.min(confidence, Math.round(extractedData.confidence * 100))
return { documentType, extractedData, confidence: effectiveConfidence, rawResponse: raw, usage }
}
if (documentType === 'receipt') {
const extractedData = mapToReceiptExtraction(raw)
if (!extractedData) return null
return { documentType, extractedData, confidence, rawResponse: raw, usage }
}
return null
}
// Sum of line items must approximately match the extracted subtotal.
// Allows 0.02 * max(|subtotal|, 1) tolerance for rounding. Returns true if the extraction
// is internally consistent; false signals the model skipped a discount or double-counted.
function invoiceTotalsAreConsistent(raw: Record<string, unknown>): boolean {
const subtotal = Number(raw.amount_excl_vat)
const total = Number(raw.amount_incl_vat)
const vat = Number(raw.vat_amount) || 0
const items = Array.isArray(raw.line_items) ? raw.line_items : []
if (!items.length) return true // nothing to compare against
if (!isFinite(subtotal) && !isFinite(total)) return true
const sumOfLines = items.reduce((acc, item) => {
if (typeof item !== 'object' || item === null) return acc
const amount = Number((item as Record<string, unknown>).amount)
return acc + (isFinite(amount) ? amount : 0)
}, 0)
const anchor = isFinite(subtotal) ? subtotal : total - vat
const tolerance = Math.max(0.02, Math.abs(anchor) * 0.02)
return Math.abs(sumOfLines - anchor) <= tolerance
}
function mapToInvoiceExtraction(raw: Record<string, unknown>): InvoiceExtractionResult | null {
const lineItems = mapInvoiceLineItems(raw.line_items)
const vatBreakdown = mapVatBreakdown(raw.vat_breakdown)
const totalsConsistent = invoiceTotalsAreConsistent(raw)
const result: InvoiceExtractionResult = {
supplier: {
name: strOrNull(raw.supplier_name),
orgNumber: strOrNull(raw.supplier_org_number),
vatNumber: strOrNull(raw.supplier_vat_number),
address: strOrNull(raw.supplier_address),
bankgiro: strOrNull(raw.supplier_bankgiro),
plusgiro: strOrNull(raw.supplier_plusgiro),
},
invoice: {
invoiceNumber: strOrNull(raw.invoice_number),
invoiceDate: dateOrNull(raw.invoice_date),
dueDate: dateOrNull(raw.due_date),
paymentReference: strOrNull(raw.payment_reference),
currency: strOrNull(raw.currency) || 'SEK',
},
lineItems,
totals: {
subtotal: roundAmount(raw.amount_excl_vat),
vatAmount: roundAmount(raw.vat_amount),
total: roundAmount(raw.amount_incl_vat),
},
vatBreakdown,
// If totals don't reconcile with line items, cap confidence at 50% so the UI flags it
confidence: (() => {
const raw_conf = Math.min(1, Math.max(0, Number(raw.confidence) / 100 || 0))
return totalsConsistent ? raw_conf : Math.min(raw_conf, 0.5)
})(),
}
return result
}
function mapToReceiptExtraction(raw: Record<string, unknown>): ReceiptExtractionResult | null {
const lineItems = mapReceiptLineItems(raw.line_items)
const result: ReceiptExtractionResult = {
merchant: {
name: strOrNull(raw.merchant_name),
orgNumber: strOrNull(raw.merchant_org_number),
vatNumber: strOrNull(raw.merchant_vat_number),
isForeign: Boolean(raw.merchant_is_foreign),
},
receipt: {
date: dateOrNull(raw.receipt_date),
time: strOrNull(raw.receipt_time),
currency: strOrNull(raw.currency) || 'SEK',
},
lineItems,
totals: {
subtotal: roundAmount(raw.amount_excl_vat),
vatAmount: roundAmount(raw.vat_amount),
total: roundAmount(raw.amount_incl_vat),
},
flags: {
isRestaurant: Boolean(raw.is_restaurant),
isSystembolaget: Boolean(raw.is_systembolaget),
isForeignMerchant: Boolean(raw.merchant_is_foreign),
},
confidence: Math.min(1, Math.max(0, Number(raw.confidence) / 100 || 0)),
}
return result
}
// ── Line item mappers ────────────────────────────────────────
function mapInvoiceLineItems(items: unknown): ExtractedInvoiceLineItem[] {
if (!Array.isArray(items)) return []
return items
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
.map((item) => ({
description: String(item.description || ''),
quantity: typeof item.quantity === 'number' ? item.quantity : 1,
unitPrice: roundAmount(item.unit_price),
lineTotal: roundAmount(item.amount) ?? 0,
vatRate: validateVatRate(item.vat_rate),
accountSuggestion: null,
}))
}
function mapReceiptLineItems(items: unknown): ExtractedLineItem[] {
if (!Array.isArray(items)) return []
return items
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
.map((item) => ({
description: String(item.description || ''),
quantity: typeof item.quantity === 'number' ? item.quantity : 1,
unitPrice: roundAmount(item.unit_price),
lineTotal: roundAmount(item.amount) ?? 0,
vatRate: validateVatRate(item.vat_rate),
suggestedCategory: null,
}))
}
function mapVatBreakdown(items: unknown): VatBreakdownItem[] {
if (!Array.isArray(items)) return []
return items
.filter((item): item is Record<string, unknown> => typeof item === 'object' && item !== null)
.filter((item) => VALID_VAT_RATES.includes(Number(item.rate)))
.map((item) => ({
rate: Number(item.rate),
base: Math.round(Number(item.base || 0) * 100) / 100,
amount: Math.round(Number(item.amount || 0) * 100) / 100,
}))
}
// ── Retry with correction ────────────────────────────────────
async function retryWithCorrection(
input: ClassificationInput,
previousResult: Record<string, unknown>,
previousUsage: { inputTokens: number; outputTokens: number }
): Promise<ClassificationResult> {
const contentBlock = await buildContentBlock(input)
const messages: Message[] = [
{
role: 'user',
content: [
contentBlock,
{ text: 'Analysera detta dokument. Klassificera typ och extrahera all strukturerad data.' },
],
},
{
role: 'assistant',
content: [
{
toolUse: {
toolUseId: 'retry_1',
name: 'classify_document',
input: previousResult as Record<string, unknown>,
} as ContentBlock.ToolUseMember['toolUse'],
},
],
},
{
role: 'user',
content: [
{
toolResult: {
toolUseId: 'retry_1',
status: 'error',
content: [
{
text: `Valideringen misslyckades. Kontrollera:
- document_type måste vara ett av: supplier_invoice, receipt, government_letter, unknown
- Datum i format YYYY-MM-DD
- Momssatser måste vara 0, 6, 12, eller 25
- Totaler: amount_incl_vat ska vara fakturans slutbelopp. Summa av line_items.amount + vat_amount MÅSTE bli lika med amount_incl_vat. Om rader har rabatt under sig, använd NETTO-beloppet per rad, eller lägg till en separat negativ rabattrad så summan stämmer.
Försök igen med korrigerad data.`,
},
],
},
},
],
},
]
const modelId = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
const maxTokens = parseInt(process.env.BEDROCK_MAX_TOKENS || '8192', 10)
try {
const command = new ConverseCommand({
modelId,
messages,
system: [{ text: SYSTEM_PROMPT }],
toolConfig: CLASSIFICATION_TOOL,
inferenceConfig: { maxTokens },
})
const response = await getClient().send(command)
const outputMessage = response.output?.message
const toolUseBlock = outputMessage?.content?.find(
(block): block is ContentBlock.ToolUseMember => 'toolUse' in block && block.toolUse !== undefined
)
if (!toolUseBlock?.toolUse?.input) {
throw new Error('No tool use in retry response')
}
const rawData = toolUseBlock.toolUse.input as Record<string, unknown>
const retryUsage = {
inputTokens: previousUsage.inputTokens + (response.usage?.inputTokens ?? 0),
outputTokens: previousUsage.outputTokens + (response.usage?.outputTokens ?? 0),
}
const result = mapToClassificationResult(rawData, retryUsage)
if (result) return result
} catch {
// Retry failed — fall through to error return
}
// Both attempts failed — return error result with raw data
return {
documentType: 'unknown',
extractedData: null,
confidence: 0,
rawResponse: previousResult,
usage: previousUsage,
}
}
// ── Utility helpers ──────────────────────────────────────────
function strOrNull(val: unknown): string | null {
if (typeof val === 'string' && val.trim().length > 0) return val.trim()
return null
}
function dateOrNull(val: unknown): string | null {
if (typeof val !== 'string') return null
// Validate ISO date format YYYY-MM-DD
const match = val.match(/^\d{4}-\d{2}-\d{2}$/)
if (!match) return null
const d = new Date(val)
if (isNaN(d.getTime())) return null
return val
}
function roundAmount(val: unknown): number | null {
if (val === null || val === undefined) return null
const n = Number(val)
if (isNaN(n)) return null
return Math.round(n * 100) / 100
}
function validateVatRate(val: unknown): number | null {
if (val === null || val === undefined) return null
const n = Number(val)
if (VALID_VAT_RATES.includes(n)) return n
return null
}