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>
This commit is contained in:
Mattsson
2026-04-27 10:32:15 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent ab63da8324
commit 1af977950b
70 changed files with 9735 additions and 451 deletions
+15
View File
@@ -0,0 +1,15 @@
import type { Extension } from '@/lib/extensions/types'
import { registerAIProposalService } from '@/lib/ai/proposal-service'
import { BedrockAIProposalService } from './lib/bedrock-service'
// Register the Bedrock-backed implementation at extension load time.
// The orchestrator (lib/ai/orchestrator.ts) calls getAIProposalService() at
// event-handle time and will get this instance whenever the extension is
// enabled in extensions.config.json.
registerAIProposalService(new BedrockAIProposalService())
export const aiAgentExtension: Extension = {
id: 'ai-agent',
name: 'AI-agent (beta)',
version: '0.1.0',
}
@@ -0,0 +1,30 @@
/**
* Shared Bedrock Converse client for the ai-agent extension.
* Mirrors inbox-smart-match's setup so the model + env var conventions stay
* consistent across all LLM-backed extensions.
*/
import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime'
let _client: BedrockRuntimeClient | null = null
export function getBedrockClient(): 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
}
export function getModelId(): string {
return process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
}
export function getMaxTokens(): number {
return parseInt(process.env.BEDROCK_MAX_TOKENS || '2048', 10)
}
@@ -0,0 +1,44 @@
/**
* BedrockAIProposalService — the AIProposalService implementation registered
* by the ai-agent extension. Each method dispatches to the relevant generator
* and returns whatever the generator produced (proposal / request / null).
*/
import type {
AIProposalService,
AIRequestResult,
BookingProposalResult,
GenerateBookingContext,
GenerateMatchContext,
MatchProposalResult,
} from '@/lib/ai/proposal-service'
import { generateMatchForExtension } from './generate-match'
import { generateBookingForExtension } from './generate-booking'
export class BedrockAIProposalService implements AIProposalService {
isEnabled(): boolean {
// The extension only loads when enabled in extensions.config.json, so any
// registered instance is enabled by definition. We still gate on AWS
// credentials so a misconfigured env surfaces as "null -> needs_manual"
// rather than a Bedrock exception per call.
return Boolean(
process.env.AWS_ACCESS_KEY_ID &&
process.env.AWS_SECRET_ACCESS_KEY &&
process.env.AWS_REGION
)
}
async generateMatchProposal(
ctx: GenerateMatchContext
): Promise<MatchProposalResult | AIRequestResult | null> {
if (!this.isEnabled()) return null
return generateMatchForExtension(ctx)
}
async generateBookingProposal(
ctx: GenerateBookingContext
): Promise<BookingProposalResult | AIRequestResult | null> {
if (!this.isEnabled()) return null
return generateBookingForExtension(ctx)
}
}
@@ -0,0 +1,328 @@
/**
* Booking proposal generator for the ai-agent extension.
*
* Takes a matched receipt + transaction and returns a balanced journal-entry
* proposal in the BookingProposalPayload shape. Uses existing counterparty
* templates as seeds in the prompt so recurring merchants converge fast.
*
* Returns an AIRequestResult when the LLM chooses to clarify (e.g.,
* can't tell business vs private), or null on outage.
*
* Also verifies the proposed lines balance (sum debits = sum credits); when
* the LLM returns unbalanced lines the result is degraded to a clarify ask
* rather than being silently wrong.
*/
import {
ConverseCommand,
type ContentBlock,
type Message,
} from '@aws-sdk/client-bedrock-runtime'
import { findFiscalPeriod } from '@/lib/bookkeeping/engine'
import { createClient as createServiceClient } from '@supabase/supabase-js'
import type {
AIRequestResult,
BookingProposalResult,
GenerateBookingContext,
} from '@/lib/ai/proposal-service'
import type {
BookingProposalLine,
BookingProposalCounterpartyTemplate,
BookingProposalPayload,
VatTreatment,
} from '@/types'
import { getBedrockClient, getModelId, getMaxTokens } from './bedrock-client'
import {
BOOKING_PROMPT_VERSION,
BOOKING_SYSTEM_PROMPT,
BOOKING_TOOL_CONFIG,
} from './prompts/booking-prompt'
export async function generateBookingForExtension(
ctx: GenerateBookingContext
): Promise<BookingProposalResult | AIRequestResult | null> {
const serviceClient = createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
// Resolve fiscal period for the transaction date — a prerequisite for any booking.
const fiscalPeriodId = await findFiscalPeriod(
serviceClient,
ctx.companyId,
ctx.matchedTransaction.date
)
if (!fiscalPeriodId) {
return {
kind: 'request',
request: {
request_type: 'needs_manual',
message:
'Ingen öppen räkenskapsperiod täcker transaktionens datum. Skapa perioden eller bokför manuellt.',
},
provenance: { prompt_version: BOOKING_PROMPT_VERSION },
}
}
// Brief the LLM with receipt + transaction + relevant templates.
const extracted = ctx.inboxItem.extracted_data as Record<string, unknown> | null
const relevantTemplates = ctx.existingTemplates
.filter((t) => t.is_active)
.slice(0, 20)
.map((t) => ({
counterparty: t.counterparty_name,
debit: t.debit_account,
credit: t.credit_account,
vat_treatment: t.vat_treatment,
category: t.category,
source: t.source,
occurrences: t.occurrence_count,
}))
const userPrompt = `Kvittodata (extraherad):
${JSON.stringify(extracted, null, 2)}
Matchad banktransaktion:
${JSON.stringify(
{
id: ctx.matchedTransaction.id,
date: ctx.matchedTransaction.date,
description: ctx.matchedTransaction.description,
amount: ctx.matchedTransaction.amount,
amount_sek: ctx.matchedTransaction.amount_sek,
currency: ctx.matchedTransaction.currency,
merchant_name: ctx.matchedTransaction.merchant_name,
},
null,
2
)}
Företagstyp: ${ctx.entityType}
Befintliga motpartsmallar (upp till 20):
${JSON.stringify(relevantTemplates, null, 2)}
Föreslå ett balanserat verifikat. Transaktionens belopp är bruttobeloppet som betalas från 1930.`
const messages: Message[] = [{ role: 'user', content: [{ text: userPrompt }] }]
let response
try {
response = await getBedrockClient().send(
new ConverseCommand({
modelId: getModelId(),
messages,
system: [{ text: BOOKING_SYSTEM_PROMPT }],
toolConfig: BOOKING_TOOL_CONFIG,
inferenceConfig: { maxTokens: getMaxTokens(), temperature: 0 },
})
)
} catch (err) {
console.error('[ai-agent/booking] Bedrock call failed:', err)
return null
}
const usage = {
input_tokens: response.usage?.inputTokens ?? 0,
output_tokens: response.usage?.outputTokens ?? 0,
}
const toolUse = response.output?.message?.content?.find(
(b): b is ContentBlock.ToolUseMember => 'toolUse' in b && b.toolUse !== undefined
)
if (!toolUse?.toolUse?.input) return null
const raw = toolUse.toolUse.input as Record<string, unknown>
const action = raw.action === 'clarify_business_private' ? 'clarify_business_private' : 'propose'
const confidence = clampConfidence(Number(raw.confidence))
const reasoning = typeof raw.reasoning === 'string' ? raw.reasoning.trim() : ''
if (action === 'clarify_business_private') {
return {
kind: 'request',
request: {
request_type: 'clarify_business_private',
message:
typeof raw.clarify_message === 'string' && raw.clarify_message.trim().length > 0
? raw.clarify_message.trim()
: 'Är detta en affärsutgift eller privat?',
required_fields: { is_business: 'boolean' },
},
provenance: {
model: getModelId(),
prompt_version: BOOKING_PROMPT_VERSION,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
},
}
}
const proposalRaw = raw.proposal as Record<string, unknown> | null | undefined
if (!proposalRaw) {
return null
}
const rawLines = extractLines(proposalRaw.lines)
const vatTreatment = extractVatTreatment(proposalRaw.vat_treatment)
const defaultPrivate = Boolean(proposalRaw.default_private)
const counterpartyTpl = extractCounterpartyTemplate(proposalRaw.counterparty_template_proposal)
// Claude often returns lines that are off by a cent or two due to the way
// it does 25% VAT math on awkward totals (e.g. 183,30 split as net 146,64
// + VAT 36,66 — fine — but sometimes 146,64 + 36,67 from rounding up).
// Repair those silently; the journal engine can't post unbalanced entries
// anyway, and the human-facing answer (same accounts, same rate) is identical.
const { lines, repaired } = repairRounding(rawLines)
if (!linesBalanced(lines)) {
const totalDebit = lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = lines.reduce((s, l) => s + l.credit_amount, 0)
console.warn('[ai-agent/generate-booking] unbalanced proposal', {
totalDebit, totalCredit, diff: totalDebit - totalCredit, lines,
})
return {
kind: 'request',
request: {
request_type: 'needs_manual',
message:
`AI:n producerade ett obalanserat verifikat (debet ${totalDebit.toFixed(2)} vs kredit ${totalCredit.toFixed(2)}). Bokför manuellt eller försök igen via Bearbeta befintliga.`,
},
provenance: {
model: getModelId(),
prompt_version: BOOKING_PROMPT_VERSION,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
},
}
}
if (repaired) {
console.log('[ai-agent/generate-booking] auto-repaired rounding on booking lines')
}
const payload: BookingProposalPayload = {
lines,
vat_treatment: vatTreatment,
default_private: defaultPrivate,
counterparty_template_proposal: counterpartyTpl,
fiscal_period_id: fiscalPeriodId,
entry_date: ctx.matchedTransaction.date,
description: buildDescription(ctx),
}
return {
kind: 'proposal',
proposal: payload,
confidence,
reasoning,
provenance: {
model: getModelId(),
prompt_version: BOOKING_PROMPT_VERSION,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
},
}
}
function clampConfidence(raw: number): number {
if (!isFinite(raw)) return 0
return Math.min(1, Math.max(0, raw / 100))
}
function extractLines(raw: unknown): BookingProposalLine[] {
if (!Array.isArray(raw)) return []
return raw
.map((item) => item as Record<string, unknown>)
.filter((item) => typeof item.account_number === 'string')
.map((item) => ({
account_number: String(item.account_number),
debit_amount: Number(item.debit_amount) || 0,
credit_amount: Number(item.credit_amount) || 0,
description: typeof item.description === 'string' ? item.description : '',
}))
}
function extractVatTreatment(raw: unknown): VatTreatment | null {
const allowed: VatTreatment[] = [
'standard_25',
'reduced_12',
'reduced_6',
'reverse_charge',
'export',
'exempt',
]
if (typeof raw !== 'string') return null
return (allowed as string[]).includes(raw) ? (raw as VatTreatment) : null
}
function extractCounterpartyTemplate(
raw: unknown
): BookingProposalCounterpartyTemplate | null {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
if (
typeof r.counterparty_name !== 'string' ||
typeof r.debit_account !== 'string' ||
typeof r.credit_account !== 'string'
) {
return null
}
return {
counterparty_name: r.counterparty_name,
debit_account: r.debit_account,
credit_account: r.credit_account,
vat_treatment: extractVatTreatment(r.vat_treatment),
category:
typeof r.category === 'string' && r.category.length > 0
? (r.category as BookingProposalCounterpartyTemplate['category'])
: null,
}
}
function linesBalanced(lines: BookingProposalLine[]): boolean {
if (lines.length < 2) return false
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
return Math.abs(totalDebit - totalCredit) < 0.005 && totalDebit > 0
}
// Adjust sub-5-öre discrepancies silently by nudging the largest debit
// line. Only repairs imbalances up to 0.05 kr — anything larger is treated
// as a real error (Claude got confused, not just a rounding quirk) and
// bubbles up via the existing needs_manual fallback.
function repairRounding(lines: BookingProposalLine[]): { lines: BookingProposalLine[]; repaired: boolean } {
if (lines.length < 2) return { lines, repaired: false }
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
const diff = totalDebit - totalCredit
const absDiff = Math.abs(diff)
if (absDiff < 0.005) return { lines, repaired: false }
if (absDiff > 0.05) return { lines, repaired: false }
// Pick the single biggest debit line to absorb the adjustment — usually
// the expense account, not the VAT line. Subtract if debit is over,
// add if debit is under. Round to öre precision.
const withIndex = lines.map((l, idx) => ({ l, idx }))
const biggestDebit = withIndex
.filter((x) => x.l.debit_amount > 0)
.sort((a, b) => b.l.debit_amount - a.l.debit_amount)[0]
if (!biggestDebit) return { lines, repaired: false }
const adjusted = [...lines]
const current = adjusted[biggestDebit.idx]
adjusted[biggestDebit.idx] = {
...current,
debit_amount: Math.round((current.debit_amount - diff) * 100) / 100,
}
return { lines: adjusted, repaired: true }
}
function buildDescription(ctx: GenerateBookingContext): string {
const merchant =
ctx.matchedTransaction.merchant_name ||
ctx.matchedTransaction.description ||
'Okänd handlare'
return `AI-förslag: ${merchant}`
}
@@ -0,0 +1,194 @@
/**
* Match proposal generator for the ai-agent extension.
*
* Returns a MatchProposalResult when the LLM identifies a good candidate,
* an AIRequestResult when input is insufficient (bad extraction) or no
* candidates are available (user must upload the missing transaction first),
* or null on Bedrock outage so the orchestrator emits a 'needs_manual' ask.
*/
import {
ConverseCommand,
type ContentBlock,
type Message,
} from '@aws-sdk/client-bedrock-runtime'
import { createClient as createServiceClient } from '@supabase/supabase-js'
import { fetchCandidateTransactions, getMatchAnchors } from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
import type { ExtractedDocument } from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
import type {
AIRequestResult,
GenerateMatchContext,
MatchProposalResult,
} from '@/lib/ai/proposal-service'
import type { MatchProposalAlternative } from '@/types'
import { getBedrockClient, getModelId, getMaxTokens } from './bedrock-client'
import {
MATCH_PROMPT_VERSION,
MATCH_SYSTEM_PROMPT,
MATCH_TOOL_CONFIG,
} from './prompts/match-prompt'
export async function generateMatchForExtension(
ctx: GenerateMatchContext
): Promise<MatchProposalResult | AIRequestResult | null> {
const extracted = ctx.inboxItem.extracted_data as unknown as ExtractedDocument | null
// Guard: extraction quality.
const anchors = getMatchAnchors(extracted)
if (!anchors) {
return {
kind: 'request',
request: {
request_type: 'reupload_document',
message:
'Jag kunde inte läsa av datum eller belopp från kvittot. Ladda upp en tydligare bild så försöker jag igen.',
},
provenance: { prompt_version: MATCH_PROMPT_VERSION },
}
}
// Fetch candidates using the shared deterministic narrowing.
const serviceClient = createServiceClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
let candidates
try {
candidates = await fetchCandidateTransactions(serviceClient, ctx.companyId, extracted)
} catch (err) {
console.error('[ai-agent/match] fetchCandidateTransactions failed:', err)
return null
}
if (candidates.length === 0) {
return {
kind: 'request',
request: {
request_type: 'pick_transaction',
message:
'Jag hittade ingen matchande banktransaktion. Vänta på nästa banksync eller välj manuellt.',
options: { candidates: [] },
},
provenance: { prompt_version: MATCH_PROMPT_VERSION },
}
}
// Call Bedrock.
const receiptBrief = {
merchant: anchors.counterpartyName,
amount: anchors.amount,
currency: anchors.currency,
date: anchors.date,
vat_amount: extracted?.totals?.vatAmount ?? null,
}
const candidateLines = candidates.map((c) => ({
id: c.id,
date: c.date,
description: c.description,
amount: c.amount,
amount_sek: c.amount_sek,
currency: c.currency,
merchant_name: c.merchant_name,
}))
const userPrompt = `Kvitto:
${JSON.stringify(receiptBrief, null, 2)}
Kandidat-transaktioner:
${JSON.stringify(candidateLines, null, 2)}
Vilken matchar? Om ingen matchar, returnera matched=false.`
const messages: Message[] = [{ role: 'user', content: [{ text: userPrompt }] }]
let response
try {
response = await getBedrockClient().send(
new ConverseCommand({
modelId: getModelId(),
messages,
system: [{ text: MATCH_SYSTEM_PROMPT }],
toolConfig: MATCH_TOOL_CONFIG,
inferenceConfig: { maxTokens: getMaxTokens(), temperature: 0 },
})
)
} catch (err) {
console.error('[ai-agent/match] Bedrock call failed:', err)
return null
}
const usage = {
input_tokens: response.usage?.inputTokens ?? 0,
output_tokens: response.usage?.outputTokens ?? 0,
}
const toolUse = response.output?.message?.content?.find(
(b): b is ContentBlock.ToolUseMember => 'toolUse' in b && b.toolUse !== undefined
)
if (!toolUse?.toolUse?.input) {
return null
}
const raw = toolUse.toolUse.input as Record<string, unknown>
const matched = Boolean(raw.matched)
const rawId = typeof raw.transaction_id === 'string' ? raw.transaction_id : null
const confidence = clampConfidence(Number(raw.confidence))
const reasoning = typeof raw.reasoning === 'string' ? raw.reasoning.trim() : ''
// Resolve alternatives, filtering to only valid candidate IDs.
const candidateIds = new Set(candidates.map((c) => c.id))
const rawAlts = Array.isArray(raw.alternatives) ? raw.alternatives : []
const alternatives: MatchProposalAlternative[] = rawAlts
.map((a) => a as Record<string, unknown>)
.filter((a) => typeof a.transaction_id === 'string' && candidateIds.has(a.transaction_id as string))
.map((a) => ({
transaction_id: a.transaction_id as string,
confidence: clampConfidence(Number(a.confidence)),
reasoning: typeof a.reasoning === 'string' ? a.reasoning.trim() : '',
}))
.slice(0, 3)
if (!matched || !rawId || !candidateIds.has(rawId)) {
// LLM declined or returned unresolvable ID — degrade to pick_transaction ask.
return {
kind: 'request',
request: {
request_type: 'pick_transaction',
message:
'AI:n är osäker på matchning. Välj manuellt bland kandidaterna eller vänta på fler banktransaktioner.',
options: { candidates: candidateLines },
},
provenance: {
model: getModelId(),
prompt_version: MATCH_PROMPT_VERSION,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
},
}
}
return {
kind: 'proposal',
proposal: {
matched_transaction_id: rawId,
alternatives,
top_confidence: confidence,
},
confidence,
reasoning,
provenance: {
model: getModelId(),
prompt_version: MATCH_PROMPT_VERSION,
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
},
}
}
function clampConfidence(raw: number): number {
if (!isFinite(raw)) return 0
return Math.min(1, Math.max(0, raw / 100))
}
@@ -0,0 +1,166 @@
/**
* Booking prompt — given an extracted receipt + matched transaction + any
* existing counterparty templates, propose a complete journal entry.
*
* The v1 schema is deliberately narrow: standard expense with input VAT
* (optional) paid from 1930. Reverse-charge / EU / import paths are out
* of scope for the first receipts-only release; those still funnel to
* manual via a clarify_business_private request if the LLM is unsure.
*/
import type { ToolConfiguration } from '@aws-sdk/client-bedrock-runtime'
export const BOOKING_PROMPT_VERSION = '2026-04-27-v3'
export const BOOKING_SYSTEM_PROMPT = `Du är en expert på svensk bokföring enligt BAS-kontoplanen. Du föreslår hur ett kvitto ska bokföras mot en matchad banktransaktion.
Indata:
- Extraherad kvittodata (handlare, belopp, moms, datum)
- Matchad banktransaktion (beskrivning, belopp, datum)
- Företagstyp (enskild firma eller aktiebolag)
- Befintliga mallar för samma motpart (om några)
Uppgift: föreslå ett balanserat verifikat (Debet = Kredit). Verifikatet MÅSTE balansera: summan av alla debet-rader ska vara EXAKT lika med summan av alla kredit-rader.
Mönstret för en standardutgift med svensk moms:
Debet 5xxx/6xxx (kostnadskonto, nettobelopp)
Debet 2641 Ingående moms (om standardmoms 25%, 12% eller 6%)
Kredit 1930 Företagskonto (bruttobelopp)
Mönstret för en utgift utan svensk moms (utländsk leverantör, momsfritt kvitto):
Debet 5xxx/6xxx (kostnadskonto, hela beloppet)
Kredit 1930 Företagskonto (hela beloppet)
Om privat uttag (enskild firma) — använd 2013 istället för kostnadskontot.
Riktlinjer:
- Välj lämpligt BAS-kostnadskonto utifrån typ av inköp (t.ex. 5410 IT-utrustning, 5611 Drivmedel, 5810 Representation, 6540 IT-tjänster)
- Momsavdrag: standard 25% → 2641, 12% → 2641, 6% → 2641. Sätt vat_treatment 'standard_25' / 'reduced_12' / 'reduced_6'.
- Om kvittot saknar momsspecifikation men är svensk handelsrelaterad — anta standard_25
- Om kvittot är från en utländsk leverantör (ej svensk org/momsnummer) och INTE visar någon moms — använd mönstret utan moms. Sätt vat_treatment='exempt'. Använd INTE kontona 2614, 2615, 2645, 2646, 2647, 2648 — omvänd skattskyldighet är utanför scope i v1.
- Om inköpet troligen är privat (t.ex. matvaror för hushåll, nöjen) och företagstyp = enskild firma — sätt default_private=true och använd 2013
- Representation: endast 50% moms avdragsgillt — för v1, föreslå utan reducering och flagga i reasoning att användaren bör kontrollera
- Om du är osäker på business vs private, eller om fakturan ser ut att kräva omvänd skattskyldighet (t.ex. EU-leverantör med momsnummer men 0% moms) — returnera hellre ett ai_request av typ 'clarify_business_private' än att gissa
VIKTIGT — momsövergång på livsmedel (Prop. 2025/26:55):
- Från och med 1 april 2026 (t.o.m. 31 december 2027) sänks momsen på livsmedel från 12 % till 6 %. Återgår till 12 % den 1 januari 2028.
- Avgör momssats utifrån KVITTOTS DATUM (matchad transaktionsdatum):
* Livsmedel/dagligvaror (ICA, Coop, Hemköp, Willys, Lidl, City Gross, Tempo, Mathem, Netto, Mat.se m.fl.):
- Datum < 2026-04-01: vat_treatment='reduced_12'
- Datum 2026-04-01 — 2027-12-31: vat_treatment='reduced_6'
- Datum >= 2028-01-01: vat_treatment='reduced_12'
* Restaurang/servering (eat-in på restaurang, café, lunchställe, bistro): ALLTID vat_treatment='reduced_12' (omfattas inte av sänkningen).
* Take-away/avhämtning räknas som livsmedel — följ datumlogiken ovan.
* Alkohol är alltid 25 % oavsett — om kvittot uppenbart är alkohol, vat_treatment='standard_25'.
- Om det är otydligt om kvittot är livsmedel eller servering (t.ex. ICA med både matvaror och deli), välj den dominerande posten utifrån beloppet och förklara valet i reasoning.
KONTROLLERA innan du returnerar: addera alla debit_amount, addera alla credit_amount, verifiera att summorna är EXAKT lika. Om de inte är det — räkna om.
Resonera på svenska. Var konkret: vilket konto och varför.
Anropa ALLTID verktyget propose_booking med resultatet.`
export const BOOKING_TOOL_CONFIG: ToolConfiguration = {
tools: [
{
toolSpec: {
name: 'propose_booking',
description: 'Returnera ett balanserat verifikatförslag eller en fråga till användaren',
inputSchema: {
json: {
type: 'object',
required: ['action', 'confidence', 'reasoning'],
properties: {
action: {
type: 'string',
enum: ['propose', 'clarify_business_private'],
description:
'propose = konkret förslag. clarify_business_private = be användaren avgöra om privat/business.',
},
confidence: {
type: 'integer',
minimum: 0,
maximum: 100,
},
reasoning: {
type: 'string',
description: '1-3 meningar på svenska som förklarar förslaget.',
},
proposal: {
type: ['object', 'null'],
description: 'Endast när action=propose.',
required: ['lines', 'vat_treatment', 'default_private'],
properties: {
lines: {
type: 'array',
minItems: 2,
items: {
type: 'object',
required: ['account_number', 'debit_amount', 'credit_amount', 'description'],
properties: {
account_number: {
type: 'string',
pattern: '^\\d{4}$',
description: '4-siffrigt BAS-kontonummer',
},
debit_amount: { type: 'number', minimum: 0 },
credit_amount: { type: 'number', minimum: 0 },
description: { type: 'string' },
},
},
},
vat_treatment: {
type: ['string', 'null'],
enum: [
'standard_25',
'reduced_12',
'reduced_6',
'reverse_charge',
'export',
'exempt',
null,
],
},
default_private: {
type: 'boolean',
description: 'true för privat uttag (enskild firma 2013)',
},
counterparty_template_proposal: {
type: ['object', 'null'],
description:
'Föreslå en motpartsmall om handlaren är återkommande och bokföringsmönstret är tydligt.',
required: ['counterparty_name', 'debit_account', 'credit_account'],
properties: {
counterparty_name: { type: 'string' },
debit_account: { type: 'string', pattern: '^\\d{4}$' },
credit_account: { type: 'string', pattern: '^\\d{4}$' },
vat_treatment: {
type: ['string', 'null'],
enum: [
'standard_25',
'reduced_12',
'reduced_6',
'reverse_charge',
'export',
'exempt',
null,
],
},
category: { type: ['string', 'null'] },
},
},
},
},
clarify_message: {
type: ['string', 'null'],
description:
'Endast när action=clarify_business_private. Kort fråga på svenska till användaren.',
},
},
},
},
},
},
],
toolChoice: { any: {} },
}
@@ -0,0 +1,80 @@
/**
* Match prompt — given an extracted receipt + candidate transactions,
* the LLM picks the best match (or explains that none fit).
*
* Bump MATCH_PROMPT_VERSION on any prompt change so the pinned version on
* stored proposals remains accurate for audit + drift analysis.
*/
import type { ToolConfiguration } from '@aws-sdk/client-bedrock-runtime'
export const MATCH_PROMPT_VERSION = '2026-04-23-v1'
export const MATCH_SYSTEM_PROMPT = `Du är en expert på svensk bokföring. Du matchar kvitton mot banktransaktioner för ett företag som använder gnubok.
Indata:
- Extraherad kvittodata (handlare, belopp, valuta, datum, momsbelopp)
- Upp till 5 kandidat-banktransaktioner (id, beskrivning, belopp, valuta, datum)
Uppgift: identifiera vilken (om någon) banktransaktion motsvarar kvittot.
Riktlinjer:
- Belopp bör vara identiskt eller väldigt nära (valutaväxling tillkommer om olika valutor)
- Datum: banktransaktionen bokförs ofta 0-3 dagar efter kvittodatumet
- Bankbeskrivningar är ofta förkortade versaler — matcha semantiskt, inte bokstavligt
- Om inget är trovärdigt, returnera matched=false med en kort motivering
- Motivera alltid kort på svenska varför du valde (eller inte valde)
Anropa ALLTID verktyget match_receipt_for_agent med resultatet.`
export const MATCH_TOOL_CONFIG: ToolConfiguration = {
tools: [
{
toolSpec: {
name: 'match_receipt_for_agent',
description: 'Returnera den bäst matchande kandidaten eller förklara att ingen matchar',
inputSchema: {
json: {
type: 'object',
required: ['matched', 'confidence', 'reasoning', 'alternatives'],
properties: {
matched: {
type: 'boolean',
description: 'true om en kandidat matchar, annars false',
},
transaction_id: {
type: ['string', 'null'],
description: 'id för vald kandidat (null när matched=false)',
},
confidence: {
type: 'integer',
minimum: 0,
maximum: 100,
description: 'Säkerhet 0-100. Sätt lågt när matched=false.',
},
reasoning: {
type: 'string',
description: '1-2 meningar på svenska som förklarar valet.',
},
alternatives: {
type: 'array',
description:
'Upp till 3 övriga kandidater som användaren kan välja istället, rankade efter sannolikhet (endast tillagda om matched=true).',
items: {
type: 'object',
required: ['transaction_id', 'confidence', 'reasoning'],
properties: {
transaction_id: { type: 'string' },
confidence: { type: 'integer', minimum: 0, maximum: 100 },
reasoning: { type: 'string' },
},
},
},
},
},
},
},
},
],
toolChoice: { any: {} },
}
+23
View File
@@ -0,0 +1,23 @@
{
"id": "ai-agent",
"sector": "general",
"exportName": "aiAgentExtension",
"entryPoint": "@/extensions/general/ai-agent",
"requiredEnvVars": [
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_REGION"
],
"optionalEnvVars": ["BEDROCK_MODEL_ID", "BEDROCK_MAX_TOKENS"],
"npmDependencies": ["@aws-sdk/client-bedrock-runtime"],
"definition": {
"name": "AI-agent (beta)",
"category": "operations",
"icon": "Sparkles",
"dataPattern": "core",
"hasOwnData": false,
"readsCoreTables": ["invoice_inbox_items", "transactions", "ai_proposals", "ai_requests", "processing_history"],
"description": "Autonom bokföring — AI föreslår match + bokföring, du godkänner.",
"longDescription": "När ett kvitto kommer in föreslår AI-agenten först vilken banktransaktion som matchar, sedan hur det ska bokföras. Du granskar och godkänner varje steg — inget bokförs automatiskt. Om AI:n inte kan producera ett förslag (oläslig bild, ingen matchande transaktion, osäker moms) frågar den dig specifikt vad som behövs."
}
}
+519 -18
View File
@@ -3,6 +3,8 @@ import { NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { classifyDocument } from './lib/classify-document'
import { analyzeExpenseWithTextract, checkTotalsAgreement } from './lib/textract-expense'
import type { TextractExpenseResult, AgreementResult } from './lib/textract-expense'
import {
verifyInboundWebhook,
fetchReceivingEmail,
@@ -88,20 +90,13 @@ async function uploadAndClassify(
console.error('[invoice-inbox] Failed to append DocumentIngested:', err)
}
// Classify with AI
let classificationResult
let classificationError: string | null = null
try {
classificationResult = await classifyDocument({
fileBuffer: Buffer.from(file.buffer),
mimeType: file.type,
fileName: file.name,
})
} catch (err) {
// Keep the technical message in the server log; present Swedish to users.
console.error('[invoice-inbox/classify] Bedrock classify failed:', err)
classificationError = toSwedishInboxError(err)
}
// Classify with AI (Claude) + OCR (Textract) in parallel, then cross-check.
const verification = await extractWithVerification(
Buffer.from(file.buffer),
file.type,
file.name
)
const { classificationResult, classificationError, textract, agreement, needsReview } = verification
// Audit: DocumentExtractionAttempted (fires whether classification succeeded or failed)
try {
@@ -163,11 +158,13 @@ async function uploadAndClassify(
.insert({
company_id: companyId,
user_id: userId,
status: classificationError ? 'error' : 'ready',
// OCR-only rows land as 'ready' with the Textract numbers populated —
// still usable, just without Claude's semantic layer.
status: classificationError && !textract ? 'error' : 'ready',
source,
document_id: doc.id,
document_type: classificationResult?.documentType || 'unknown',
extracted_data: classificationResult?.extractedData || null,
document_type: classificationResult?.documentType || (textract ? 'receipt' : 'unknown'),
extracted_data: enrichExtractedData(classificationResult, textract, agreement),
raw_llm_response: classificationResult?.rawResponse || null,
confidence: classificationResult?.confidence
? classificationResult.confidence / 100
@@ -182,13 +179,18 @@ async function uploadAndClassify(
raw_email_payload: emailMeta?.messageId
? { messageId: emailMeta.messageId, filename: file.name }
: null,
error_message: classificationError,
// Only surface the error when both reads failed. OCR fallback is a
// successful outcome from the user's perspective.
error_message: classificationError && !textract ? classificationError : null,
correlation_id: correlationId,
})
.select('*')
.single()
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
if (needsReview) {
console.log('[invoice-inbox] OCR disagrees with Claude on inbox', inbox.id, agreement)
}
// Audit: DocumentClassified (only when classification succeeded)
if (!classificationError && classificationResult) {
@@ -260,6 +262,225 @@ async function uploadAndClassify(
}
}
// ── Extraction + OCR cross-check helper ──────────────────────
//
// Runs Claude (vision) and Textract (receipt-specialized OCR) in parallel on
// the same file and returns an agreement verdict. Claude owns structure and
// semantics (merchant, line items, VAT treatment); Textract owns the raw
// numbers as a hallucination anchor. Disagreement on the total by more than
// 1 öre downgrades the row to needs_review so the UI can flag it.
//
// The two calls are independent; either can fail without killing the other.
// Claude-only is the historical default and still works if Textract skips
// (unsupported mime, too-large file, missing AWS perms).
interface ExtractionWithVerification {
classificationResult: Awaited<ReturnType<typeof classifyDocument>> | undefined
classificationError: string | null
textract: TextractExpenseResult | null
agreement: AgreementResult | null
needsReview: boolean
}
async function extractWithVerification(
fileBuffer: Buffer,
mimeType: string,
fileName: string
): Promise<ExtractionWithVerification> {
const claudePromise = classifyDocument({ fileBuffer, mimeType, fileName })
.then((r) => ({ ok: true as const, value: r }))
.catch((err) => {
console.error('[invoice-inbox/classify] Bedrock classify failed:', err)
return { ok: false as const, error: toSwedishInboxError(err) }
})
const textractPromise = analyzeExpenseWithTextract(fileBuffer, mimeType)
const [claudeResult, textract] = await Promise.all([claudePromise, textractPromise])
const classificationResult = claudeResult.ok ? claudeResult.value : undefined
const classificationError = claudeResult.ok ? null : claudeResult.error
// Pull Claude's receipt/invoice total for comparison. Handles both shapes.
const data = classificationResult?.extractedData as
| { totals?: { total?: number | null } }
| null
| undefined
const claudeTotal = data?.totals?.total ?? null
const agreement = checkTotalsAgreement(claudeTotal, textract)
// needs_review when the two reads disagree. Missing-total cases are handled
// elsewhere (rowNeedsRescan) and shouldn't double-flag here.
const needsReview = agreement !== null && !agreement.agrees
return { classificationResult, classificationError, textract, agreement, needsReview }
}
// Build the enriched extracted_data JSON: Claude's output plus an _ocr and
// _verification block. Stays non-breaking — existing consumers read the
// top-level fields unchanged; new consumers (UI, audit) can read the nested
// verification block.
function enrichExtractedData(
classificationResult: Awaited<ReturnType<typeof classifyDocument>> | undefined,
textract: TextractExpenseResult | null,
agreement: AgreementResult | null
): Record<string, unknown> | null {
if (!classificationResult?.extractedData) {
// Claude failed but Textract may have run. Surface OCR as a fallback so
// the UI can still show _something_ from the receipt.
if (!textract) return null
return {
merchant: textract.vendor ? { name: textract.vendor.value } : null,
receipt: textract.date ? { date: textract.date.value, currency: textract.currency } : null,
totals: textract.total
? { total: textract.total.value, vatAmount: textract.tax?.value ?? null, subtotal: textract.subtotal?.value ?? null }
: null,
_ocr: textract,
_verification: { claude_available: false },
_source: 'ocr_only',
}
}
return {
...(classificationResult.extractedData as unknown as Record<string, unknown>),
_ocr: textract,
_verification: agreement,
}
}
// ── Rescan helper ────────────────────────────────────────────
// Re-runs classification on an existing inbox item's source file. Used by the
// per-row "Skanna igen" button and the batch "Skanna oskannade" action so
// users can recover rows that errored or came back with incomplete data
// without reuploading. The row is updated in place — same id, same document,
// refreshed extracted_data and status.
async function rescanInboxItem(
supabase: import('@supabase/supabase-js').SupabaseClient,
companyId: string,
inboxItemId: string
): Promise<{ ok: true; id: string } | { ok: false; id: string; error: string }> {
const { data: item } = await supabase
.from('invoice_inbox_items')
.select('id, company_id, document_id, correlation_id, status')
.eq('id', inboxItemId)
.eq('company_id', companyId)
.maybeSingle()
if (!item) return { ok: false, id: inboxItemId, error: 'Inbox item not found' }
if (!item.document_id) return { ok: false, id: inboxItemId, error: 'No source document attached' }
if (item.status === 'confirmed') return { ok: false, id: inboxItemId, error: 'Already booked' }
const { data: doc } = await supabase
.from('document_attachments')
.select('storage_path, mime_type, file_name')
.eq('id', item.document_id)
.eq('company_id', companyId)
.maybeSingle()
if (!doc || !doc.storage_path) return { ok: false, id: inboxItemId, error: 'Document file missing' }
const { data: blob, error: dlError } = await supabase.storage
.from('documents')
.download(doc.storage_path)
if (dlError || !blob) return { ok: false, id: inboxItemId, error: dlError?.message || 'Download failed' }
const buffer = Buffer.from(await blob.arrayBuffer())
const verification = await extractWithVerification(
buffer,
doc.mime_type ?? 'application/octet-stream',
doc.file_name ?? 'document'
)
const { classificationResult, classificationError, textract, agreement } = verification
const { error: updateError } = await supabase
.from('invoice_inbox_items')
.update({
status: classificationError && !textract ? 'error' : 'ready',
document_type: classificationResult?.documentType ?? (textract ? 'receipt' : 'unknown'),
extracted_data: enrichExtractedData(classificationResult, textract, agreement),
raw_llm_response: classificationResult?.rawResponse ?? null,
confidence: classificationResult?.confidence ? classificationResult.confidence / 100 : null,
error_message: classificationError && !textract ? classificationError : null,
})
.eq('id', inboxItemId)
.eq('company_id', companyId)
if (updateError) return { ok: false, id: inboxItemId, error: updateError.message }
// Audit — same event shape as initial classify so the history timeline is consistent.
if (item.correlation_id) {
try {
await appendProcessingHistory({
companyId,
correlationId: item.correlation_id,
aggregateType: 'Document',
aggregateId: item.document_id,
eventType: 'DocumentExtractionAttempted',
payload: {
document_id: item.document_id,
inbox_item_id: inboxItemId,
succeeded: !classificationError,
document_type: classificationResult?.documentType ?? null,
confidence: classificationResult?.confidence ? classificationResult.confidence / 100 : null,
llm_input_tokens: classificationResult?.usage?.inputTokens ?? 0,
llm_output_tokens: classificationResult?.usage?.outputTokens ?? 0,
error: classificationError,
retry: true,
},
actor: { type: 'llm', id: 'classify-document' },
occurredAt: new Date(),
})
} catch (err) {
console.error('[invoice-inbox/rescan] appendProcessingHistory failed:', err)
}
}
// Re-emit classified event on any successful extraction (Claude or OCR
// fallback) so the AI orchestrator can generate a match proposal.
const succeeded = classificationResult != null || textract != null
if (succeeded) {
try {
const { data: refreshed } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', inboxItemId)
.maybeSingle()
if (refreshed) {
await eventBus.emit({
type: 'inbox_item.classified',
payload: {
inboxItem: refreshed as unknown as InvoiceInboxItem,
documentType: refreshed.document_type,
confidence: refreshed.confidence,
correlationId: item.correlation_id ?? crypto.randomUUID(),
userId: (refreshed as { user_id: string }).user_id,
companyId,
},
})
}
} catch { /* non-blocking */ }
}
return succeeded
? { ok: true, id: inboxItemId }
: { ok: false, id: inboxItemId, error: classificationError ?? 'Extraction failed' }
}
// A row needs rescanning when extraction failed, or when it succeeded but
// came back without the fields we actually need to propose a match (total +
// date). Keep the heuristic permissive — false positives just mean a user
// clicking "Skanna igen" on a good row, which is harmless.
function needsRescan(row: {
status: string | null
extracted_data: unknown
}): boolean {
if (row.status === 'error') return true
const data = row.extracted_data as { totals?: { total?: number | null } } | null
if (!data) return true
if (data.totals?.total == null) return true
return false
}
// ── Admin/owner check helper ──────────────────────────────────
async function isCompanyAdmin(
@@ -329,6 +550,179 @@ export const invoiceInboxExtension: Extension = {
},
},
// ── Rescan (per-row or batch) ───────────────────────────
// Re-runs the LLM classifier against the already-uploaded source file.
// Body shapes:
// { inbox_item_ids: uuid[] } → rescan those rows (must belong to company)
// {} → rescan all receipt rows that look stuck
// (status='error' or missing extracted totals)
{
method: 'POST',
path: '/rescan',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
let body: { inbox_item_ids?: string[] } = {}
try { body = await request.json() } catch { /* allow empty body */ }
let ids: string[] = []
if (Array.isArray(body.inbox_item_ids) && body.inbox_item_ids.length > 0) {
ids = body.inbox_item_ids.filter((id): id is string => typeof id === 'string')
} else {
const { data: rows } = await ctx.supabase
.from('invoice_inbox_items')
.select('id, status, extracted_data')
.eq('company_id', ctx.companyId)
.eq('document_type', 'receipt')
.not('status', 'eq', 'confirmed')
.not('document_id', 'is', null)
.limit(100)
ids = (rows ?? []).filter((r) => needsRescan(r)).map((r) => r.id)
}
if (ids.length === 0) {
return NextResponse.json({ data: { rescanned: 0, failed: 0, outcomes: [] } })
}
// Serial rather than parallel — Bedrock rate limits kick in hard past
// ~5 concurrent. 100-receipt rescan × 3s each = 5min, acceptable as a
// backgrounded action. The UI fires and refreshes, doesn't block.
const outcomes: Array<{ id: string; ok: boolean; error?: string }> = []
for (const id of ids) {
const res = await rescanInboxItem(ctx.supabase, ctx.companyId, id)
outcomes.push(res.ok ? { id, ok: true } : { id, ok: false, error: res.error })
}
const rescanned = outcomes.filter((o) => o.ok).length
const failed = outcomes.length - rescanned
return NextResponse.json({ data: { rescanned, failed, outcomes } })
},
},
// ── Manual extraction fallback ──────────────────────────
// Lets the user type merchant/date/total when the LLM can't read the image.
// The picture is untouched (still attached as source document); we just
// overwrite extracted_data with user-supplied values and flip status to
// 'ready' so downstream matching can proceed.
{
method: 'POST',
path: '/manual-extract',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
let body: {
inbox_item_id?: string
merchant?: string
date?: string
total?: number
currency?: string
vat_amount?: number | null
} = {}
try { body = await request.json() } catch { /* fall through to validation */ }
const { inbox_item_id, merchant, date, total, currency } = body
if (!inbox_item_id || !merchant || !date || typeof total !== 'number') {
return NextResponse.json(
{ error: 'inbox_item_id, merchant, date och total krävs.' },
{ status: 400 }
)
}
const { data: item } = await ctx.supabase
.from('invoice_inbox_items')
.select('id, document_id, correlation_id, status')
.eq('id', inbox_item_id)
.eq('company_id', ctx.companyId)
.maybeSingle()
if (!item) return NextResponse.json({ error: 'Kvittot hittades inte.' }, { status: 404 })
if (!item.document_id) {
return NextResponse.json({ error: 'Kvittobild saknas — ladda upp en bild först.' }, { status: 400 })
}
if (item.status === 'confirmed') {
return NextResponse.json({ error: 'Redan bokfört.' }, { status: 409 })
}
const extracted_data = {
merchant: { name: merchant },
receipt: { date, currency: currency ?? 'SEK' },
totals: {
total,
vatAmount: typeof body.vat_amount === 'number' ? body.vat_amount : null,
subtotal: null,
},
lineItems: null,
flags: null,
_entry_method: 'manual',
}
const { error: updateError } = await ctx.supabase
.from('invoice_inbox_items')
.update({
status: 'ready',
document_type: 'receipt',
extracted_data,
confidence: 1,
error_message: null,
})
.eq('id', inbox_item_id)
.eq('company_id', ctx.companyId)
if (updateError) return NextResponse.json({ error: updateError.message }, { status: 500 })
if (item.correlation_id) {
try {
await appendProcessingHistory({
companyId: ctx.companyId,
correlationId: item.correlation_id,
aggregateType: 'Document',
aggregateId: item.document_id,
eventType: 'DocumentExtractionAttempted',
payload: {
document_id: item.document_id,
inbox_item_id,
succeeded: true,
document_type: 'receipt',
confidence: 1,
llm_input_tokens: 0,
llm_output_tokens: 0,
error: null,
manual: true,
},
actor: { type: 'user', id: ctx.userId },
occurredAt: new Date(),
})
} catch (err) {
console.error('[invoice-inbox/manual-extract] appendProcessingHistory failed:', err)
}
}
// Fire match-proposal generation via the classified event.
try {
const { data: refreshed } = await ctx.supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', inbox_item_id)
.maybeSingle()
if (refreshed) {
await eventBus.emit({
type: 'inbox_item.classified',
payload: {
inboxItem: refreshed as unknown as InvoiceInboxItem,
documentType: 'receipt',
confidence: 1,
correlationId: item.correlation_id ?? crypto.randomUUID(),
userId: ctx.userId,
companyId: ctx.companyId,
},
})
}
} catch { /* non-blocking */ }
return NextResponse.json({ data: { ok: true } })
},
},
// ── List inbox items ────────────────────────────────────
{
method: 'GET',
@@ -427,6 +821,113 @@ export const invoiceInboxExtension: Extension = {
},
},
// ── Attach a source document to an existing inbox item ──
// For rows that ended up without a picture (e.g. email came through but
// attachment extraction failed, or manually-created rows). Uploads the
// file, links document_id on the row, then runs classify so the numbers
// get extracted in the same request.
{
method: 'POST',
path: '/items/:id/attach-document',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) return NextResponse.json({ error: 'No file provided' }, { status: 400 })
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: `File too large (max ${MAX_FILE_SIZE / 1024 / 1024} MB)` }, { status: 400 })
}
if (!UPLOAD_ALLOWED_MIME_TYPES.has(file.type)) {
return NextResponse.json(
{ error: `Unsupported file type: ${file.type}. Allowed: PDF, JPEG, PNG, HEIC, WebP` },
{ status: 400 }
)
}
const { data: item } = await ctx.supabase
.from('invoice_inbox_items')
.select('id, document_id, status, correlation_id')
.eq('id', id)
.eq('company_id', ctx.companyId)
.maybeSingle()
if (!item) return NextResponse.json({ error: 'Inbox item not found' }, { status: 404 })
if (item.status === 'confirmed') {
return NextResponse.json({ error: 'Redan bokfört — kan inte ersätta bilden.' }, { status: 409 })
}
if (item.document_id) {
return NextResponse.json({ error: 'Kvittot har redan en bild.' }, { status: 409 })
}
try {
const buffer = await file.arrayBuffer()
const doc = await uploadDocument(ctx.supabase, ctx.userId, ctx.companyId, {
name: file.name,
buffer,
type: file.type,
}, {
upload_source: 'file_upload',
})
const { error: linkError } = await ctx.supabase
.from('invoice_inbox_items')
.update({ document_id: doc.id })
.eq('id', id)
.eq('company_id', ctx.companyId)
if (linkError) {
return NextResponse.json({ error: linkError.message }, { status: 500 })
}
// Audit the ingest — mirrors the initial-upload path.
if (item.correlation_id) {
try {
await appendProcessingHistory({
companyId: ctx.companyId,
correlationId: item.correlation_id,
aggregateType: 'Document',
aggregateId: doc.id,
eventType: 'DocumentIngested',
payload: {
channel: 'upload',
document_id: doc.id,
inbox_item_id: id,
mime_type: file.type,
size_bytes: file.size,
attached_to_existing: true,
},
actor: { type: 'user', id: ctx.userId },
occurredAt: new Date(),
})
} catch (err) {
console.error('[invoice-inbox/attach-document] appendProcessingHistory failed:', err)
}
}
// Now run classification on the freshly-attached image.
const rescan = await rescanInboxItem(ctx.supabase, ctx.companyId, id)
return NextResponse.json({
data: {
document_id: doc.id,
inbox_item_id: id,
classified: rescan.ok,
error: rescan.ok ? null : rescan.error,
},
})
} catch (error) {
console.error('[invoice-inbox/attach-document] Failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Attach failed' },
{ status: 500 }
)
}
},
},
// ── Get this company's inbox address ────────────────────
{
method: 'GET',
@@ -0,0 +1,191 @@
/**
* 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,
}
}