Supp/invoice bfl errors (#390)

* feat(accounting): update accounting method validation and messaging for aktiebolag and enskild firma

* Remove AI subsystem and related code

- Deleted AI proposals and requests persistence logic from `lib/ai/proposals/persist.ts`.
- Removed re-validation logic for proposals in `lib/ai/proposals/re-validate.ts`.
- Cleaned up schemas related to AI flows in `lib/api/schemas.ts`.
- Removed AI-related fields from bookkeeping engine in `lib/bookkeeping/engine.ts`.
- Eliminated AI event types from `lib/events/types.ts`.
- Updated tests to reflect the removal of AI-related functionality in `lib/extensions/__tests__/sectors.test.ts`.
- Adjusted initialization logic in `lib/init.ts` to exclude AI proposal handler registration.
- Cleaned up transaction ingestion logic in `lib/transactions/ingest.ts` to remove AI flow checks.
- Updated helper functions in `tests/helpers.ts` to remove AI-related settings.
- Removed AI-related types and interfaces from `types/index.ts`.
- Added migration script to drop AI-related tables and settings from the database.

* fix(migrations): ensure foreign key constraint is dropped before removing AI tables

* feat(invoice-inbox): implement deterministic invoice field extraction and inbox provisioning

- Added `extract-invoice-fields.ts` for extracting fields from PDF invoices using regex and pdfjs-dist, replacing the previous AI classifier.
- Introduced `inbox-provisioning.ts` to manage company inbox addresses and rotation of inboxes using Supabase RPCs.
- Created `resend-inbound.ts` for handling inbound email events and attachments via the Resend API.
- Defined the extension manifest for the invoice inbox, specifying required environment variables and descriptions.
- Migrated database schema to remove AI-related columns and tighten the status enum in `invoice_inbox_items`.

* feat(invoice-inbox): remove AI-specific columns and tighten status enum

* fix(skattekonto): remove manual entry creation reference from transaction input

* fix(schemas): remove accounting method validation for aktiebolag in UpdateSettingsSchema
This commit is contained in:
Mattsson
2026-05-05 09:53:37 +02:00
committed by GitHub
parent f3fd4c0822
commit fa7d4075cf
108 changed files with 1491 additions and 13234 deletions
-15
View File
@@ -1,15 +0,0 @@
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',
}
@@ -1,30 +0,0 @@
/**
* 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)
}
@@ -1,44 +0,0 @@
/**
* 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)
}
}
@@ -1,328 +0,0 @@
/**
* 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}`
}
@@ -1,194 +0,0 @@
/**
* 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))
}
@@ -1,166 +0,0 @@
/**
* 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: {} },
}
@@ -1,80 +0,0 @@
/**
* 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
@@ -1,23 +0,0 @@
{
"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."
}
}
@@ -30,14 +30,27 @@ export default function BankingSettingsPanel() {
const [isConnecting, setIsConnecting] = useState(false)
const [connectingBankName, setConnectingBankName] = useState<string | null>(null)
const connectingRef = useRef(false)
const releaseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [showCsvFallback, setShowCsvFallback] = useState(false)
const [psuType, setPsuType] = useState<'personal' | 'business'>('business')
// Must match STALE_THRESHOLD_MS in extensions/general/enable-banking/index.ts
const PENDING_LOCK_MS = 30 * 1000
useEffect(() => {
fetchConnections()
return () => {
if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current)
}
}, [])
function releaseConnectingLock() {
connectingRef.current = false
setIsConnecting(false)
setConnectingBankName(null)
}
async function fetchConnections() {
setIsLoading(true)
const { data: { user } } = await supabase.auth.getUser()
@@ -51,6 +64,22 @@ export default function BankingSettingsPanel() {
.order('created_at', { ascending: false })
setBankConnections(connections || [])
// If a pending connection exists from a recent attempt (e.g. user bounced back from
// the bank's auth page), keep the connect button disabled until the server-side lock expires.
const freshPending = (connections || []).find((c) => c.status === 'pending')
if (freshPending) {
const age = Date.now() - new Date(freshPending.created_at).getTime()
const remaining = PENDING_LOCK_MS - age
if (remaining > 0) {
connectingRef.current = true
setIsConnecting(true)
setConnectingBankName(freshPending.bank_name)
if (releaseTimerRef.current) clearTimeout(releaseTimerRef.current)
releaseTimerRef.current = setTimeout(releaseConnectingLock, remaining)
}
}
setIsLoading(false)
}
+1 -1
View File
@@ -134,7 +134,7 @@ export const enableBankingExtension: Extension = {
if (recentPending) {
const pendingAge = Date.now() - new Date(recentPending.created_at).getTime()
const STALE_THRESHOLD_MS = 5 * 60 * 1000 // 5 minutes
const STALE_THRESHOLD_MS = 30 * 1000 // 30 seconds — long enough to cover the redirect handoff, short enough that an abandoned attempt doesn't block the user
if (pendingAge < STALE_THRESHOLD_MS) {
log.info('[enable-banking] Rejecting duplicate connect — recent pending exists', {
@@ -1,139 +0,0 @@
import { describe, it, expect } from 'vitest'
import {
fetchCandidateTransactions,
getMatchAnchors,
} from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
import { createQueuedMockSupabase } from '@/tests/helpers'
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
function makeReceipt(overrides?: Partial<ReceiptExtractionResult>): ReceiptExtractionResult {
return {
merchant: { name: 'Willys Hemma', orgNumber: null, vatNumber: null, isForeign: false },
receipt: { date: '2026-04-15', time: null, currency: 'SEK' },
lineItems: [],
totals: { subtotal: 239, vatAmount: 60, total: 299 },
flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false },
confidence: 0.9,
...overrides,
} as ReceiptExtractionResult
}
describe('fetchCandidateTransactions', () => {
it('returns empty list when extracted data is missing', async () => {
const { supabase } = createQueuedMockSupabase()
const result = await fetchCandidateTransactions(supabase as never, 'company-1', null)
expect(result).toEqual([])
})
it('returns empty list when no anchors could be derived', async () => {
const { supabase } = createQueuedMockSupabase()
const result = await fetchCandidateTransactions(
supabase as never,
'company-1',
makeReceipt({ totals: { subtotal: 0, vatAmount: 0, total: 0 } } as never)
)
expect(result).toEqual([])
})
it('ranks candidates by amount proximity and returns top 5', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// 1. already-matched lookup (none)
enqueue({ data: [] })
// 2. candidate transactions
enqueue({
data: [
{ id: 't1', date: '2026-04-15', description: 'A', amount: -400, amount_sek: null, currency: 'SEK', merchant_name: null },
{ id: 't2', date: '2026-04-14', description: 'B', amount: -299, amount_sek: null, currency: 'SEK', merchant_name: null }, // perfect match
{ id: 't3', date: '2026-04-16', description: 'C', amount: -305, amount_sek: null, currency: 'SEK', merchant_name: null }, // close
{ id: 't4', date: '2026-04-15', description: 'D', amount: -150, amount_sek: null, currency: 'SEK', merchant_name: null },
{ id: 't5', date: '2026-04-13', description: 'E', amount: -298, amount_sek: null, currency: 'SEK', merchant_name: null },
{ id: 't6', date: '2026-04-15', description: 'F', amount: -600, amount_sek: null, currency: 'SEK', merchant_name: null },
],
})
const result = await fetchCandidateTransactions(supabase as never, 'company-1', makeReceipt())
expect(result).toHaveLength(5)
expect(result[0].id).toBe('t2') // exact match sorted first
expect(result[1].id).toBe('t5') // ±1 next
})
it('excludes transactions already claimed by other inbox items', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// 1. already-matched lookup — t2 is already taken
enqueue({ data: [{ matched_transaction_id: 't2' }] })
// 2. candidate transactions
enqueue({
data: [
{ id: 't1', date: '2026-04-15', description: 'A', amount: -400, amount_sek: null, currency: 'SEK', merchant_name: null },
{ id: 't2', date: '2026-04-14', description: 'B', amount: -299, amount_sek: null, currency: 'SEK', merchant_name: null },
{ id: 't3', date: '2026-04-16', description: 'C', amount: -305, amount_sek: null, currency: 'SEK', merchant_name: null },
],
})
const result = await fetchCandidateTransactions(supabase as never, 'company-1', makeReceipt())
const ids = result.map((c) => c.id)
expect(ids).not.toContain('t2')
expect(ids).toContain('t3')
})
it('anchors invoices on dueDate with a ±14d window', () => {
const invoice: InvoiceExtractionResult = {
supplier: { name: 'Acme AB', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
invoice: { invoiceNumber: 'INV-1', invoiceDate: '2026-03-01', dueDate: '2026-03-31', paymentReference: null, currency: 'SEK' },
lineItems: [],
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
vatBreakdown: [],
confidence: 0.9,
}
const anchors = getMatchAnchors(invoice)
expect(anchors).not.toBeNull()
expect(anchors!.date).toBe('2026-03-31')
expect(anchors!.windowDaysBefore).toBe(14)
expect(anchors!.windowDaysAfter).toBe(14)
})
it('anchors invoices without dueDate on invoiceDate with a -7/+45 day window', () => {
const invoice: InvoiceExtractionResult = {
supplier: { name: 'Acme AB', orgNumber: null, vatNumber: null, address: null, bankgiro: null, plusgiro: null },
invoice: { invoiceNumber: 'INV-1', invoiceDate: '2026-03-01', dueDate: null, paymentReference: null, currency: 'SEK' },
lineItems: [],
totals: { subtotal: 800, vatAmount: 200, total: 1000 },
vatBreakdown: [],
confidence: 0.9,
}
const anchors = getMatchAnchors(invoice)
expect(anchors).not.toBeNull()
expect(anchors!.date).toBe('2026-03-01')
expect(anchors!.windowDaysBefore).toBe(7)
expect(anchors!.windowDaysAfter).toBe(45)
})
it('anchors receipts on receipt date with a ±7d window', () => {
const anchors = getMatchAnchors(makeReceipt())
expect(anchors).not.toBeNull()
expect(anchors!.date).toBe('2026-04-15')
expect(anchors!.windowDaysBefore).toBe(7)
expect(anchors!.windowDaysAfter).toBe(7)
})
it('uses amount_sek when receipt is foreign currency', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: [] }) // no already-matched
enqueue({
data: [
{ id: 't1', date: '2026-04-15', description: 'USD-denom', amount: -895, amount_sek: -895, currency: 'SEK', merchant_name: null },
],
})
const usdReceipt = makeReceipt({
receipt: { date: '2026-04-15', time: null, currency: 'USD' },
totals: { subtotal: 80, vatAmount: 0, total: 85 },
} as never)
const result = await fetchCandidateTransactions(supabase as never, 'company-1', usdReceipt)
// Amount doesn't perfectly match but the function should return it anyway
expect(result).toHaveLength(1)
expect(result[0].id).toBe('t1')
})
})
@@ -1,134 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// Mock Bedrock SDK before importing the module under test
const mockSend = vi.fn()
vi.mock('@aws-sdk/client-bedrock-runtime', () => {
class ConverseCommand {
public input: unknown
constructor(input: unknown) { this.input = input }
}
class BedrockRuntimeClient {
send(command: unknown) { return mockSend(command) }
}
return { BedrockRuntimeClient, ConverseCommand }
})
import { matchReceiptToCandidate } from '@/extensions/general/inbox-smart-match/lib/match-receipt'
import type { ReceiptExtractionResult } from '@/types'
import type { CandidateTransaction } from '@/extensions/general/inbox-smart-match/lib/fetch-candidates'
function makeExtracted(): ReceiptExtractionResult {
return {
merchant: { name: 'Willys Hemma', orgNumber: null, vatNumber: null, isForeign: false },
receipt: { date: '2026-04-15', time: null, currency: 'SEK' },
lineItems: [],
totals: { subtotal: 239, vatAmount: 60, total: 299 },
flags: { isRestaurant: false, isSystembolaget: false, isForeignMerchant: false },
confidence: 0.9,
} as ReceiptExtractionResult
}
function makeCandidates(): CandidateTransaction[] {
return [
{ id: 't1', date: '2026-04-15', description: 'WILLYS SÖDERM', amount: -299, amount_sek: null, currency: 'SEK', merchant_name: 'Willys' },
{ id: 't2', date: '2026-04-14', description: 'ICA MAXI', amount: -312, amount_sek: null, currency: 'SEK', merchant_name: 'ICA' },
]
}
function mockBedrockResponse(toolInput: Record<string, unknown>) {
mockSend.mockResolvedValue({
output: {
message: {
content: [
{
toolUse: {
toolUseId: 'id',
name: 'match_receipt',
input: toolInput,
},
},
],
},
},
usage: { inputTokens: 100, outputTokens: 20 },
})
}
describe('matchReceiptToCandidate', () => {
beforeEach(() => {
mockSend.mockReset()
process.env.AWS_ACCESS_KEY_ID = 'test'
process.env.AWS_SECRET_ACCESS_KEY = 'test'
process.env.AWS_REGION = 'eu-north-1'
})
it('returns a matched result when LLM picks a valid candidate', async () => {
mockBedrockResponse({
matched: true,
transaction_id: 't1',
confidence: 96,
reasoning: 'Exakt belopp och datum. Willys matchar WILLYS SÖDERM.',
})
const result = await matchReceiptToCandidate({
extracted: makeExtracted(),
candidates: makeCandidates(),
})
expect(result.matched).toBe(true)
expect(result.transactionId).toBe('t1')
expect(result.confidence).toBeCloseTo(0.96, 2)
expect(result.reasoning).toContain('Willys')
})
it('returns no match when LLM says matched=false', async () => {
mockBedrockResponse({
matched: false,
transaction_id: null,
confidence: 10,
reasoning: 'Ingen kandidat har rätt belopp eller handlare.',
})
const result = await matchReceiptToCandidate({
extracted: makeExtracted(),
candidates: makeCandidates(),
})
expect(result.matched).toBe(false)
expect(result.transactionId).toBeNull()
expect(result.confidence).toBeCloseTo(0.1, 2)
})
it('degrades to no-match when LLM returns unknown transaction_id', async () => {
mockBedrockResponse({
matched: true,
transaction_id: 'hallucinated-id-not-in-candidates',
confidence: 80,
reasoning: 'Detta är fel id',
})
const result = await matchReceiptToCandidate({
extracted: makeExtracted(),
candidates: makeCandidates(),
})
expect(result.matched).toBe(false)
expect(result.transactionId).toBeNull()
expect(result.confidence).toBe(0)
})
it('returns safe default when no tool use in response', async () => {
mockSend.mockResolvedValue({
output: { message: { content: [] } },
usage: { inputTokens: 0, outputTokens: 0 },
})
const result = await matchReceiptToCandidate({
extracted: makeExtracted(),
candidates: makeCandidates(),
})
expect(result.matched).toBe(false)
expect(result.transactionId).toBeNull()
})
})
@@ -1,117 +0,0 @@
import type { Extension } from '@/lib/extensions/types'
import type { EventPayload } from '@/lib/events/types'
import type { InvoiceInboxItem } from '@/types'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createClient } from '@supabase/supabase-js'
import { processInboxItemMatch } from './lib/process-match'
const EXTENSION_ID = 'inbox-smart-match'
// The handler always uses a service-role client:
// - processing_history has no INSERT RLS policy (audit integrity) — only service-role can append
// - every query is scoped by company_id from the event payload
// - we write pseudonymous IDs only (PII validator enforces this at the append layer)
// Using ctx.supabase is unsafe because the registry wrapper may build an anon-key
// client when no user session exists (e.g. webhook path).
function getServiceSupabase(): SupabaseClient {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
}
export const inboxSmartMatchExtension: Extension = {
id: EXTENSION_ID,
name: 'Smart matchning',
version: '0.1.0',
eventHandlers: [
// When an inbox item is freshly classified, try to match it to a transaction
{
eventType: 'inbox_item.classified',
handler: async (payload: EventPayload<'inbox_item.classified'>) => {
// Match both receipts and supplier invoices — other document types
// (government letters, unknown) have nothing to match against.
if (payload.documentType !== 'receipt' && payload.documentType !== 'supplier_invoice') {
return
}
const supabase = getServiceSupabase()
try {
await processInboxItemMatch(
{
supabase,
companyId: payload.companyId,
userId: payload.userId,
extensionId: EXTENSION_ID,
triggerReason: 'classified',
},
payload.inboxItem
)
} catch (err) {
console.error(
`[${EXTENSION_ID}] Failed to process classified inbox item ${payload.inboxItem.id}:`,
err
)
}
},
},
// When new transactions land, retry matching on any receipts still waiting
{
eventType: 'transaction.synced',
handler: async (payload: EventPayload<'transaction.synced'>) => {
const newTransactionIds = payload.transactions.map((t) => t.id).filter(Boolean)
if (newTransactionIds.length === 0) return
const supabase = getServiceSupabase()
// Find pending receipts/invoices for this company. Cap at 10 per sync
// so one big bank import doesn't time out the handler; leftover pending
// items pick up on the next sync.
const { data: pendingItems, error } = await supabase
.from('invoice_inbox_items')
.select('*')
.eq('company_id', payload.companyId)
.in('document_type', ['receipt', 'supplier_invoice'])
.eq('status', 'ready')
.eq('match_method', 'pending_transaction')
.order('created_at', { ascending: false })
.limit(10)
if (error) {
console.error(`[${EXTENSION_ID}] Failed to fetch pending receipts:`, error)
return
}
if (!pendingItems || pendingItems.length === 0) return
// Run LLM calls in parallel; one failing receipt shouldn't stop the others.
const results = await Promise.allSettled(
(pendingItems as InvoiceInboxItem[]).map((item) =>
processInboxItemMatch(
{
supabase,
companyId: payload.companyId,
userId: payload.userId,
extensionId: EXTENSION_ID,
triggerReason: 'transaction_synced',
},
item
)
)
)
results.forEach((r, i) => {
if (r.status === 'rejected') {
const itemId = (pendingItems[i] as InvoiceInboxItem).id
console.error(
`[${EXTENSION_ID}] Retroactive match failed for item ${itemId}:`,
r.reason
)
}
})
},
},
],
}
@@ -1,170 +0,0 @@
/**
* Candidate transaction fetcher — deterministic narrowing before the LLM call.
*
* Pulls unbooked expense transactions near the document's payment date,
* ordered by how close their amount is to the document total. Limits to top 5
* so the LLM has a focused candidate set and the token cost stays bounded.
*
* Anchor date selection:
* - Receipts: receipt date ±7 days (paid on the spot)
* - Invoices with dueDate: dueDate ±14 days (covers early and late payments)
* - Invoices without dueDate: invoiceDate, window shifted forward to cover
* standard 30-day terms (invoiceDate-7 .. invoiceDate+45)
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { InvoiceExtractionResult, ReceiptExtractionResult } from '@/types'
const MAX_CANDIDATES = 5
export interface CandidateTransaction {
id: string
date: string
description: string
amount: number
amount_sek: number | null
currency: string
merchant_name: string | null
}
export type ExtractedDocument = ReceiptExtractionResult | InvoiceExtractionResult
export interface MatchAnchors {
date: string
amount: number
currency: string
counterpartyName: string | null
windowDaysBefore: number
windowDaysAfter: number
}
function isInvoiceExtraction(e: ExtractedDocument): e is InvoiceExtractionResult {
return 'invoice' in e && typeof (e as InvoiceExtractionResult).invoice === 'object'
}
/**
* Extract the reference date and absolute amount from a classified document's
* extracted data. Returns null if required fields are missing.
*/
export function getMatchAnchors(extracted: ExtractedDocument | null): MatchAnchors | null {
if (!extracted) return null
let date: string | null
let currency: string
let counterpartyName: string | null
let windowDaysBefore: number
let windowDaysAfter: number
if (isInvoiceExtraction(extracted)) {
const dueDate = extracted.invoice?.dueDate ?? null
const invoiceDate = extracted.invoice?.invoiceDate ?? null
if (dueDate) {
date = dueDate
windowDaysBefore = 14
windowDaysAfter = 14
} else {
date = invoiceDate
windowDaysBefore = 7
windowDaysAfter = 45
}
currency = extracted.invoice?.currency ?? 'SEK'
counterpartyName = extracted.supplier?.name ?? null
} else {
date = extracted.receipt?.date ?? null
currency = extracted.receipt?.currency ?? 'SEK'
counterpartyName = extracted.merchant?.name ?? null
windowDaysBefore = 7
windowDaysAfter = 7
}
const amount = extracted.totals?.total ?? null
if (!date || amount == null || amount <= 0) return null
return { date, amount, currency, counterpartyName, windowDaysBefore, windowDaysAfter }
}
/**
* Fetch up to MAX_CANDIDATES unbooked expense transactions near the document's
* date + amount. Ordering prefers exact amount matches first.
*/
export async function fetchCandidateTransactions(
supabase: SupabaseClient,
companyId: string,
extracted: ExtractedDocument | null
): Promise<CandidateTransaction[]> {
const anchors = getMatchAnchors(extracted)
if (!anchors) return []
const anchorDate = new Date(anchors.date)
if (isNaN(anchorDate.getTime())) return []
const windowStart = new Date(anchorDate)
windowStart.setUTCDate(windowStart.getUTCDate() - anchors.windowDaysBefore)
const windowEnd = new Date(anchorDate)
windowEnd.setUTCDate(windowEnd.getUTCDate() + anchors.windowDaysAfter)
// Exclude transactions already claimed by any other inbox item in this
// company. The partial unique index on (company_id, matched_transaction_id)
// is the final guard against concurrent double-matches, but filtering up
// front saves an LLM roundtrip on the obvious cases.
const { data: claimed, error: claimedError } = await supabase
.from('invoice_inbox_items')
.select('matched_transaction_id')
.eq('company_id', companyId)
.not('matched_transaction_id', 'is', null)
if (claimedError) {
throw new Error(`Failed to load matched transactions: ${claimedError.message}`)
}
const excludedIds = new Set(
(claimed ?? [])
.map((row) => (row as { matched_transaction_id: string | null }).matched_transaction_id)
.filter((id): id is string => typeof id === 'string' && id.length > 0)
)
// Pull negative-amount (expense) transactions without a journal entry in the window
const { data, error } = await supabase
.from('transactions')
.select('id, date, description, amount, amount_sek, currency, merchant_name')
.eq('company_id', companyId)
.is('journal_entry_id', null)
.lt('amount', 0)
.gte('date', windowStart.toISOString().slice(0, 10))
.lte('date', windowEnd.toISOString().slice(0, 10))
.order('date', { ascending: false })
.limit(50)
if (error) {
throw new Error(`Failed to fetch candidate transactions: ${error.message}`)
}
if (!data || data.length === 0) return []
const filtered = excludedIds.size > 0
? data.filter((tx) => !excludedIds.has(tx.id as string))
: data
if (filtered.length === 0) return []
// Rank candidates by amount proximity. For SEK documents we compare directly,
// for other currencies we prefer amount_sek if the document amount has been converted.
const anchorAbs = Math.abs(anchors.amount)
const scored = filtered.map((tx) => {
const txAmount = Math.abs(Number(tx.amount) || 0)
const txSek = tx.amount_sek == null ? null : Math.abs(Number(tx.amount_sek))
const primaryDiff = Math.abs(txAmount - anchorAbs)
const sekDiff = txSek == null ? Infinity : Math.abs(txSek - anchorAbs)
const bestDiff = Math.min(primaryDiff, sekDiff)
return { tx, diff: bestDiff }
})
scored.sort((a, b) => a.diff - b.diff)
return scored.slice(0, MAX_CANDIDATES).map(({ tx }) => ({
id: tx.id,
date: tx.date,
description: tx.description ?? '',
amount: Number(tx.amount),
amount_sek: tx.amount_sek == null ? null : Number(tx.amount_sek),
currency: tx.currency ?? 'SEK',
merchant_name: tx.merchant_name ?? null,
}))
}
@@ -1,200 +0,0 @@
/**
* Text-only LLM matcher — decides which candidate bank transaction (if any)
* corresponds to a classified receipt. Uses Bedrock Converse with structured
* tool output. No image input: the receipt is already represented by the
* extracted data, and candidates are pure text.
*/
import {
BedrockRuntimeClient,
ConverseCommand,
type ContentBlock,
type Message,
type ToolConfiguration,
} from '@aws-sdk/client-bedrock-runtime'
import type { CandidateTransaction, ExtractedDocument } from './fetch-candidates'
import { getMatchAnchors } from './fetch-candidates'
export interface ReceiptMatchResult {
matched: boolean
transactionId: string | null
confidence: number // 0..1
reasoning: string
usage: { inputTokens: number; outputTokens: number }
}
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
}
const SYSTEM_PROMPT = `Du är en expert på att matcha svenska bokföringsdokument (kvitton, fakturor) mot banktransaktioner.
Du får:
- Dokumentdata (handlare/leverantör, belopp, valuta, datum) från AI-extraktion
- En lista med kandidat-banktransaktioner (id, beskrivning, belopp, valuta, datum)
Uppgift: identifiera vilken (om någon) banktransaktion som motsvarar dokumentet.
Resonera utifrån:
- Belopp: bör vara identiskt eller mycket nära (ta hänsyn till valutaväxling om olika valutor)
- Datum: för kvitton bokförs banktransaktionen ofta 0-3 dagar efter köpet; för leverantörsfakturor kan betalningen ske flera dagar till veckor efter fakturadatum
- Handlare/leverantör: bankens beskrivning är ofta förkortad/versaler ("WILLYS SÖDERM" = "Willys Hemma Södermalm"). Matcha semantiskt, inte bokstavligt
Om inget förslag är trovärdigt — returnera matched=false.
Anropa ALLTID verktyget match_receipt med resultatet.
Motivering ska vara kort, på svenska och förklara varför transaktionen valdes.`
const MATCH_TOOL: ToolConfiguration = {
tools: [
{
toolSpec: {
name: 'match_receipt',
description: 'Returnera vilken kandidat-transaktion som matchar kvittot',
inputSchema: {
json: {
type: 'object',
required: ['matched', 'confidence', 'reasoning'],
properties: {
matched: {
type: 'boolean',
description: 'true om en kandidat matchar, false annars',
},
transaction_id: {
type: ['string', 'null'],
description: 'id för den matchande kandidaten (null om 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 beslutet.',
},
},
},
},
},
},
],
toolChoice: { any: {} },
}
export interface MatchReceiptInput {
extracted: ExtractedDocument
candidates: CandidateTransaction[]
}
/**
* Call Bedrock to choose the best matching transaction.
* Returns a neutral result (matched=false) if the model doesn't find a fit or
* the tool schema is missing from the response.
*/
export async function matchReceiptToCandidate(
input: MatchReceiptInput
): Promise<ReceiptMatchResult> {
const anchors = getMatchAnchors(input.extracted)
const receiptBrief = {
merchant: anchors?.counterpartyName ?? null,
amount: anchors?.amount ?? null,
currency: anchors?.currency ?? 'SEK',
date: anchors?.date ?? null,
vat_amount: input.extracted.totals?.vatAmount ?? null,
}
const candidateLines = input.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 = `Dokument:
${JSON.stringify(receiptBrief, null, 2)}
Kandidat-transaktioner:
${JSON.stringify(candidateLines, null, 2)}
Vilken transaktion matchar dokumentet? Om ingen matchar, returnera matched=false.`
const messages: Message[] = [
{
role: 'user',
content: [{ text: userPrompt }],
},
]
const modelId = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
const maxTokens = parseInt(process.env.BEDROCK_MAX_TOKENS || '1024', 10)
const command = new ConverseCommand({
modelId,
messages,
system: [{ text: SYSTEM_PROMPT }],
toolConfig: MATCH_TOOL,
inferenceConfig: { maxTokens, temperature: 0 },
})
const response = await getClient().send(command)
const usage = {
inputTokens: response.usage?.inputTokens ?? 0,
outputTokens: response.usage?.outputTokens ?? 0,
}
const outputMessage = response.output?.message
if (!outputMessage?.content) {
return { matched: false, transactionId: null, confidence: 0, reasoning: 'Inget LLM-svar', usage }
}
const toolUseBlock = outputMessage.content.find(
(block): block is ContentBlock.ToolUseMember => 'toolUse' in block && block.toolUse !== undefined
)
if (!toolUseBlock?.toolUse?.input) {
return { matched: false, transactionId: null, confidence: 0, reasoning: 'Inget verktygsanrop', usage }
}
const raw = toolUseBlock.toolUse.input as Record<string, unknown>
const matched = Boolean(raw.matched)
const rawId = typeof raw.transaction_id === 'string' ? raw.transaction_id : null
const transactionId = matched
? (input.candidates.find((c) => c.id === rawId)?.id ?? null)
: null
const confidenceRaw = Number(raw.confidence)
const confidence =
isFinite(confidenceRaw)
? Math.min(1, Math.max(0, confidenceRaw / 100))
: 0
const reasoning = typeof raw.reasoning === 'string' ? raw.reasoning.trim() : ''
// If LLM said matched but we can't resolve the transaction_id to a candidate,
// degrade gracefully to unmatched so downstream isn't left dangling.
if (matched && !transactionId) {
return {
matched: false,
transactionId: null,
confidence: 0,
reasoning: reasoning || 'LLM angav ogiltigt transaction_id',
usage,
}
}
return { matched, transactionId, confidence, reasoning, usage }
}
@@ -1,214 +0,0 @@
/**
* Core matching flow — deterministic narrowing + LLM call + persistence +
* processing_history audit events. Called from both the classify handler and
* the transaction-sync retroactive handler.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import type { InvoiceInboxItem } from '@/types'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { fetchCandidateTransactions, type ExtractedDocument } from './fetch-candidates'
import { matchReceiptToCandidate } from './match-receipt'
export interface MatchContext {
supabase: SupabaseClient
companyId: string
userId: string
extensionId: string
triggerReason: 'classified' | 'transaction_synced'
}
export interface MatchOutcome {
status: 'matched' | 'no_match' | 'pending_transaction' | 'skipped'
transactionId: string | null
confidence: number
reasoning: string
}
/**
* Process a single classified-receipt inbox item through the matcher pipeline.
* Writes match fields + appends processing_history. Swallows internal errors
* so one failing receipt doesn't break the whole event handler.
*/
export async function processInboxItemMatch(
ctx: MatchContext,
item: InvoiceInboxItem
): Promise<MatchOutcome> {
const tag = `[inbox-smart-match] item=${item.id} trigger=${ctx.triggerReason}`
// Match both receipts and supplier invoices — both have comparable anchors
// (date, amount, counterparty, currency) and the downstream LLM prompt is
// shape-agnostic.
if (item.document_type !== 'receipt' && item.document_type !== 'supplier_invoice') {
return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' }
}
if (item.status !== 'ready') {
return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' }
}
if (!item.extracted_data) {
return { status: 'skipped', transactionId: null, confidence: 0, reasoning: '' }
}
const correlationId = item.correlation_id ?? crypto.randomUUID()
// If we just minted a fresh correlation_id (legacy row predating the column),
// persist it so retries reuse the same thread through processing_history.
if (!item.correlation_id) {
const { error: corrError } = await ctx.supabase
.from('invoice_inbox_items')
.update({ correlation_id: correlationId })
.eq('id', item.id)
if (corrError) {
console.error(`${tag} — failed to persist correlation_id:`, corrError)
// non-fatal — we still proceed with matching under the in-memory ID
}
}
const extracted = item.extracted_data as unknown as ExtractedDocument
const candidates = await fetchCandidateTransactions(ctx.supabase, ctx.companyId, extracted)
// Append DeterministicMatch event — records that the narrowing ran
let deterministicEventId: string
try {
deterministicEventId = await appendProcessingHistory({
companyId: ctx.companyId,
correlationId,
aggregateType: 'MatchProposal',
aggregateId: item.id,
eventType: 'MatchAttemptedDeterministic',
payload: {
inbox_item_id: item.id,
candidate_count: candidates.length,
candidate_ids: candidates.map((c) => c.id),
window_days: 7,
trigger: ctx.triggerReason,
},
actor: { type: 'system', id: ctx.extensionId },
occurredAt: new Date(),
})
} catch (err) {
console.error(`${tag} — failed to append MatchAttemptedDeterministic:`, err)
return { status: 'no_match', transactionId: null, confidence: 0, reasoning: '' }
}
// No candidates → mark pending, wait for bank sync
if (candidates.length === 0) {
await ctx.supabase
.from('invoice_inbox_items')
.update({
match_method: 'pending_transaction',
match_confidence: null,
matched_transaction_id: null,
match_reasoning: 'Inväntar matchande banktransaktion',
})
.eq('id', item.id)
return {
status: 'pending_transaction',
transactionId: null,
confidence: 0,
reasoning: 'Inväntar matchande banktransaktion',
}
}
// LLM chooses among candidates
let llm
try {
llm = await matchReceiptToCandidate({ extracted, candidates })
} catch (err) {
console.error(`${tag} — LLM matcher failed:`, err)
// Don't overwrite existing state on LLM failure; just log and exit
return { status: 'no_match', transactionId: null, confidence: 0, reasoning: '' }
}
// Record the LLM attempt in processing_history
try {
await appendProcessingHistory({
companyId: ctx.companyId,
correlationId,
causationId: deterministicEventId,
aggregateType: 'MatchProposal',
aggregateId: item.id,
eventType: 'MatchAttemptedLlm',
payload: {
inbox_item_id: item.id,
matched: llm.matched,
chosen_transaction_id: llm.transactionId,
confidence: llm.confidence,
llm_input_tokens: llm.usage.inputTokens,
llm_output_tokens: llm.usage.outputTokens,
candidate_count: candidates.length,
},
actor: { type: 'llm', id: 'match_receipt' },
occurredAt: new Date(),
})
} catch (err) {
console.error(`${tag} — failed to append MatchAttemptedLlm:`, err)
}
// Persist match. The (company_id, matched_transaction_id) partial unique
// index means a concurrent second inbox item trying to claim the same
// transaction will get a 23505 — we catch that and downgrade this one
// to pending_transaction instead of overwriting the winner.
if (llm.matched && llm.transactionId) {
const { error: updateError } = await ctx.supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: llm.transactionId,
match_confidence: llm.confidence,
match_method: 'llm',
match_reasoning: llm.reasoning,
})
.eq('id', item.id)
if (updateError) {
const code = (updateError as { code?: string }).code
if (code === '23505') {
// Another receipt won the race for this transaction.
await ctx.supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: null,
match_method: 'pending_transaction',
match_confidence: null,
match_reasoning: 'Transaktionen matchades först till ett annat kvitto',
})
.eq('id', item.id)
return {
status: 'pending_transaction',
transactionId: null,
confidence: 0,
reasoning: 'Transaktionen matchades först till ett annat kvitto',
}
}
console.error(`${tag} — failed to persist match:`, updateError)
return { status: 'no_match', transactionId: null, confidence: 0, reasoning: '' }
}
return {
status: 'matched',
transactionId: llm.transactionId,
confidence: llm.confidence,
reasoning: llm.reasoning,
}
}
// LLM said no match among the candidates — record explanatory reasoning
await ctx.supabase
.from('invoice_inbox_items')
.update({
matched_transaction_id: null,
match_confidence: llm.confidence,
match_method: 'pending_transaction',
match_reasoning: llm.reasoning || 'AI kunde inte hitta matchande transaktion bland kandidaterna',
})
.eq('id', item.id)
return {
status: 'no_match',
transactionId: null,
confidence: llm.confidence,
reasoning: llm.reasoning,
}
}
@@ -1,23 +0,0 @@
{
"id": "inbox-smart-match",
"sector": "general",
"exportName": "inboxSmartMatchExtension",
"entryPoint": "@/extensions/general/inbox-smart-match",
"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": "Smart matchning",
"category": "operations",
"icon": "Sparkles",
"dataPattern": "core",
"hasOwnData": false,
"readsCoreTables": ["invoice_inbox_items", "transactions", "processing_history"],
"description": "AI-driven matchning av kvitton mot banktransaktioner",
"longDescription": "När ett kvitto klassificeras i inkorgen föreslår AI den mest sannolika matchande banktransaktionen, med motivering. Körs även retroaktivt när nya transaktioner synkas in. Kräver AWS Bedrock."
}
}
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi } from 'vitest'
import { invoiceInboxExtension } from '@/extensions/general/invoice-inbox'
import {
createQueuedMockSupabase,
@@ -38,7 +38,6 @@ function buildCtx(supabase: unknown, overrides: Partial<ExtensionContext> = {}):
}
const SUPPLIER_UUID = '00000000-0000-4000-8000-000000000001'
const ITEM_UUID = '00000000-0000-4000-8000-000000000002'
const VALID_CONVERT_BODY = {
supplier_id: SUPPLIER_UUID,
@@ -68,7 +67,7 @@ describe('POST /items/:id/convert', () => {
it('returns 404 when item not found', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'Not found' } }) // fetch inbox item
enqueue({ data: null, error: { message: 'Not found' } })
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/convert', {
@@ -81,9 +80,14 @@ describe('POST /items/:id/convert', () => {
expect(status).toBe(404)
})
it('returns 409 when item status is not ready', async () => {
it('returns 409 when item already linked to a supplier invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeInvoiceInboxItem({ status: 'confirmed' }) }) // fetch inbox item
enqueue({
data: makeInvoiceInboxItem({
status: 'received',
created_supplier_invoice_id: 'existing-1',
}),
})
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/convert', {
@@ -98,12 +102,12 @@ describe('POST /items/:id/convert', () => {
it('returns 400 when required fields missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) }) // fetch inbox item
enqueue({ data: makeInvoiceInboxItem({ status: 'received' }) })
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/convert', {
method: 'POST',
body: { items: [] }, // missing required fields
body: { items: [] },
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, ctx)
@@ -113,8 +117,8 @@ describe('POST /items/:id/convert', () => {
it('returns 404 when supplier not found in company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) }) // fetch inbox item
enqueue({ data: null, error: { message: 'Not found' } }) // fetch supplier
enqueue({ data: makeInvoiceInboxItem({ status: 'received' }) })
enqueue({ data: null, error: { message: 'Not found' } })
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/convert', {
@@ -129,7 +133,7 @@ describe('POST /items/:id/convert', () => {
it('successfully converts inbox item to supplier invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inboxItem = makeInvoiceInboxItem({ status: 'ready', document_id: 'doc-1' })
const inboxItem = makeInvoiceInboxItem({ status: 'received', document_id: 'doc-1' })
const supplier = makeSupplier({ id: 'supplier-1' })
const createdInvoice = {
id: 'invoice-1',
@@ -142,13 +146,13 @@ describe('POST /items/:id/convert', () => {
status: 'registered',
}
enqueue({ data: inboxItem }) // fetch inbox item
enqueue({ data: supplier }) // fetch supplier
enqueue({ data: 42 }) // get_next_arrival_number RPC
enqueue({ data: createdInvoice }) // insert supplier_invoices
enqueue({ data: null, error: null }) // insert supplier_invoice_items
enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) }) // company_settings
enqueue({ data: null, error: null }) // update inbox item
enqueue({ data: inboxItem })
enqueue({ data: supplier })
enqueue({ data: 42 })
enqueue({ data: createdInvoice })
enqueue({ data: null, error: null })
enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) })
enqueue({ data: null, error: null })
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/convert', {
@@ -166,13 +170,13 @@ describe('POST /items/:id/convert', () => {
it('emits supplier_invoice.registered and supplier_invoice.confirmed events', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) })
enqueue({ data: makeInvoiceInboxItem({ status: 'received' }) })
enqueue({ data: makeSupplier({ id: SUPPLIER_UUID }) })
enqueue({ data: 42 }) // arrival number
enqueue({ data: { id: 'invoice-1', status: 'registered' } }) // insert
enqueue({ data: null, error: null }) // insert items
enqueue({ data: 42 })
enqueue({ data: { id: 'invoice-1', status: 'registered' } })
enqueue({ data: null, error: null })
enqueue({ data: makeCompanySettings({ accounting_method: 'cash' }) })
enqueue({ data: null, error: null }) // update inbox item
enqueue({ data: null, error: null })
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/convert', {
@@ -192,14 +196,14 @@ describe('POST /items/:id/convert', () => {
const { createSupplierInvoiceRegistrationEntry } = await import('@/lib/bookkeeping/supplier-invoice-entries')
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: makeInvoiceInboxItem({ status: 'ready' }) })
enqueue({ data: makeInvoiceInboxItem({ status: 'received' }) })
enqueue({ data: makeSupplier({ id: SUPPLIER_UUID }) })
enqueue({ data: 42 }) // arrival number
enqueue({ data: { id: 'invoice-1', status: 'registered' } }) // insert invoice
enqueue({ data: null, error: null }) // insert items
enqueue({ data: 42 })
enqueue({ data: { id: 'invoice-1', status: 'registered' } })
enqueue({ data: null, error: null })
enqueue({ data: makeCompanySettings({ accounting_method: 'accrual' }) })
enqueue({ data: null, error: null }) // update registration_journal_entry_id
enqueue({ data: null, error: null }) // update inbox item
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/convert', {
@@ -213,25 +217,17 @@ describe('POST /items/:id/convert', () => {
expect(status).toBe(200)
expect(body.data.registration_journal_entry_id).toBe('je-1')
expect(createSupplierInvoiceRegistrationEntry).toHaveBeenCalled()
// The emitted supplier_invoice.confirmed payload must reflect the just-written
// registration_journal_entry_id so the core handler's payload-level guard
// short-circuits instead of double-posting.
const emitCalls = (ctx.emit as ReturnType<typeof vi.fn>).mock.calls
const confirmed = emitCalls.find((c) => c[0].type === 'supplier_invoice.confirmed')
expect(confirmed).toBeDefined()
expect(confirmed![0].payload.supplierInvoice.registration_journal_entry_id).toBe('je-1')
})
})
// ── PATCH /items/:id/reject ──────────────────────────────────
// ── DELETE /items/:id ────────────────────────────────────────
describe('PATCH /items/:id/reject', () => {
const route = findRoute('PATCH', '/items/:id/reject')
describe('DELETE /items/:id', () => {
const route = findRoute('DELETE', '/items/:id')
it('returns 401 when no context', async () => {
const request = createMockRequest('/items/item-1/reject', {
method: 'PATCH',
const request = createMockRequest('/items/item-1', {
method: 'DELETE',
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, undefined)
@@ -241,11 +237,11 @@ describe('PATCH /items/:id/reject', () => {
it('returns 404 when item not found', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: null, error: { message: 'Not found' } })
enqueue({ data: null })
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/reject', {
method: 'PATCH',
const request = createMockRequest('/items/item-1', {
method: 'DELETE',
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, ctx)
@@ -253,13 +249,13 @@ describe('PATCH /items/:id/reject', () => {
expect(status).toBe(404)
})
it('returns 409 when item already confirmed', async () => {
it('returns 409 when item is linked to a supplier invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'item-1', status: 'confirmed' } })
enqueue({ data: { id: 'item-1', created_supplier_invoice_id: 'inv-1' } })
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/reject', {
method: 'PATCH',
const request = createMockRequest('/items/item-1', {
method: 'DELETE',
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, ctx)
@@ -267,20 +263,19 @@ describe('PATCH /items/:id/reject', () => {
expect(status).toBe(409)
})
it('updates item status to rejected', async () => {
it('deletes a free-standing inbox item', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'item-1', status: 'ready' } }) // fetch
enqueue({ data: null, error: null }) // update
enqueue({ data: { id: 'item-1', created_supplier_invoice_id: null } })
enqueue({ data: null, error: null })
const ctx = buildCtx(supabase)
const request = createMockRequest('/items/item-1/reject', {
method: 'PATCH',
const request = createMockRequest('/items/item-1', {
method: 'DELETE',
searchParams: { _id: 'item-1' },
})
const res = await route.handler(request, ctx)
const { status, body } = await parseJsonResponse<{ data: { id: string; status: string } }>(res)
const { status, body } = await parseJsonResponse<{ data: { deleted: boolean } }>(res)
expect(status).toBe(200)
expect(body.data.status).toBe('rejected')
expect(body.data.deleted).toBe(true)
})
})
@@ -0,0 +1,150 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
// Mock pdfjs-dist so we can drive the regex extractors with canned text
// without building actual PDF binaries.
const mockGetDocument = vi.fn()
vi.mock('pdfjs-dist/legacy/build/pdf.mjs', () => ({
getDocument: (...args: unknown[]) => mockGetDocument(...args),
}))
function fakePdf(text: string) {
return {
promise: Promise.resolve({
numPages: 1,
getPage: () =>
Promise.resolve({
getTextContent: () =>
Promise.resolve({
items: text.split(/\s+/).map((str) => ({ str })),
}),
}),
}),
}
}
describe('extractInvoiceFields', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns empty result for non-PDF mime type', async () => {
const { data, rawText } = await extractInvoiceFields({
buffer: Buffer.from(''),
mimeType: 'image/png',
fileName: 'foo.png',
})
expect(rawText).toBeNull()
expect(data.totals.total).toBeNull()
expect(data.supplier.orgNumber).toBeNull()
})
it('returns empty result when pdfjs extracts no text (image-only PDF)', async () => {
mockGetDocument.mockReturnValueOnce(fakePdf(''))
const { data, rawText } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'scan.pdf',
})
expect(rawText).toBe('')
expect(data.totals.total).toBeNull()
})
it('extracts a Luhn-valid org number', async () => {
// 5560125790 is a valid Swedish AB org-nr (Luhn-checked)
mockGetDocument.mockReturnValueOnce(fakePdf('Lev: Acme AB Org.nr 556012-5790 Faktura'))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.supplier.orgNumber).toBe('5560125790')
})
it('rejects org-nrs with bad Luhn digit', async () => {
mockGetDocument.mockReturnValueOnce(fakePdf('Org.nr 556012-5791'))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.supplier.orgNumber).toBeNull()
})
it('extracts a Luhn-valid OCR reference', async () => {
mockGetDocument.mockReturnValueOnce(fakePdf('OCR-nummer: 12345674'))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.invoice.paymentReference).toBe('12345674')
})
it('extracts a Luhn-valid bankgiro', async () => {
// 991-2346 is the canonical test bankgiro (Luhn-valid) used in lib/bankgiro/__tests__
mockGetDocument.mockReturnValueOnce(fakePdf('Bankgiro 991-2346 Plusgiro'))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.supplier.bankgiro).toBe('991-2346')
})
it('parses Swedish-formatted totals', async () => {
mockGetDocument.mockReturnValueOnce(
fakePdf('Att betala 12 345,67 kr')
)
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.totals.total).toBe(12345.67)
})
it('parses Förfallodatum and normalizes to ISO', async () => {
mockGetDocument.mockReturnValueOnce(fakePdf('Förfallodatum 2026-06-15'))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.invoice.dueDate).toBe('2026-06-15')
})
it('extracts an invoice number after Fakturanr', async () => {
mockGetDocument.mockReturnValueOnce(fakePdf('Fakturanr F-2024-001 Datum'))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.invoice.invoiceNumber).toBe('F-2024-001')
})
it('keeps SEK as default currency when no foreign code is present', async () => {
mockGetDocument.mockReturnValueOnce(fakePdf('Total 100 kr'))
const { data } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(data.invoice.currency).toBe('SEK')
})
it('returns empty result when pdfjs throws', async () => {
mockGetDocument.mockImplementationOnce(() => {
throw new Error('boom')
})
const { data, rawText } = await extractInvoiceFields({
buffer: Buffer.from('%PDF'),
mimeType: 'application/pdf',
fileName: 'f.pdf',
})
expect(rawText).toBeNull()
expect(data.totals.total).toBeNull()
})
})
File diff suppressed because it is too large Load Diff
@@ -1,637 +0,0 @@
/**
* 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',
}
// Bedrock rejects image bytes > 5 MB. Keep headroom under that ceiling.
const BEDROCK_IMAGE_BYTE_LIMIT = 4_500_000
// Shrink an image until it fits Bedrock's 5 MB cap. Steps down the longest edge
// and JPEG quality in sequence — preserves legibility of receipt text while
// guaranteeing we stay under the limit (or throwing if a photo is so dense it
// can't be compressed enough, which in practice never happens below 500px).
async function fitImageForBedrock(
buffer: Buffer,
mimeType: string
): Promise<{ buffer: Buffer; format: 'jpeg' | 'png' | 'webp' | 'gif' }> {
const originalFormat = MIME_TO_IMAGE_FORMAT[mimeType] as 'jpeg' | 'png' | 'webp' | 'gif'
if (buffer.byteLength <= BEDROCK_IMAGE_BYTE_LIMIT) {
return { buffer, format: originalFormat }
}
// Re-encode to JPEG while shrinking. PNG at receipt-scale is usually 3-5×
// larger than an equivalent JPEG, so JPEG is the right target format even
// for PNG input.
const dimensionSteps = [2400, 1800, 1400, 1000, 800]
const qualitySteps = [85, 75, 60]
for (const maxDim of dimensionSteps) {
for (const quality of qualitySteps) {
const candidate = await sharp(buffer)
.rotate() // respect EXIF orientation
.resize({ width: maxDim, height: maxDim, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality, mozjpeg: true })
.toBuffer()
if (candidate.byteLength <= BEDROCK_IMAGE_BYTE_LIMIT) {
return { buffer: candidate, format: 'jpeg' }
}
}
}
throw new Error('Bilden kunde inte komprimeras tillräckligt för AI-tolkning.')
}
// ── 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, then fit to Bedrock's byte limit
if (mimeType === 'image/heic' || mimeType === 'image/heif') {
const jpegBuffer = await sharp(fileBuffer).rotate().jpeg({ quality: 90, mozjpeg: true }).toBuffer()
const fitted = await fitImageForBedrock(jpegBuffer, 'image/jpeg')
return {
image: {
format: fitted.format,
source: { bytes: new Uint8Array(fitted.buffer) },
},
}
}
// Standard image formats — downscale if the buffer exceeds Bedrock's 5 MB cap
const imageFormat = MIME_TO_IMAGE_FORMAT[mimeType]
if (imageFormat) {
const fitted = await fitImageForBedrock(fileBuffer, mimeType)
return {
image: {
format: fitted.format,
source: { bytes: new Uint8Array(fitted.buffer) },
},
}
}
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
}
@@ -1,45 +0,0 @@
/**
* Maps raw AWS Bedrock / infrastructure errors to Swedish user-facing sentences
* for the invoice-inbox error_message column. We keep this local to the
* extension rather than in lib/errors so the patterns can evolve with the
* Bedrock SDK without churning the shared helper.
*/
const PATTERNS: Array<[RegExp, (match: RegExpMatchArray) => string]> = [
[
/image exceeds 5 MB maximum: (\d+) bytes/i,
(m) => {
const mb = (Number(m[1]) / 1024 / 1024).toFixed(1)
return `Bilden är för stor för AI-tolkning (${mb} MB, max 5 MB). Skicka ett mindre foto eller en PDF.`
},
],
[
/image exceeds .+ maximum/i,
() => 'Bilden är för stor för AI-tolkning. Skicka ett mindre foto eller en PDF.',
],
[/ThrottlingException|TooManyRequestsException|Rate exceeded/i, () => 'AI-tjänsten är överbelastad just nu. Försök igen om en stund.'],
[/AccessDeniedException/i, () => 'Åtkomst till AI-tjänsten nekades. Kontakta support.'],
[/ValidationException.+modelId/i, () => 'AI-modellen är felkonfigurerad. Kontakta support.'],
[/InternalServerException|ServiceUnavailable/i, () => 'AI-tjänsten är tillfälligt otillgänglig. Försök igen om en stund.'],
[/Unsupported MIME type: (.+)/i, (m) => `Filformatet stöds inte (${m[1]}). Använd PDF, JPEG, PNG, HEIC eller WebP.`],
[/No content in Bedrock response|No tool use result in Bedrock response/i, () => 'AI-tjänsten svarade inte med strukturerad data. Försök igen.'],
[/Failed to fetch received email/i, () => 'Kunde inte hämta e-postmeddelandet från inkorgstjänsten. Försök igen.'],
[/Failed to fetch attachment|Download URL returned/i, () => 'Kunde inte ladda ner bilagan från inkorgstjänsten.'],
]
export function toSwedishInboxError(raw: unknown): string {
const message = raw instanceof Error ? raw.message : typeof raw === 'string' ? raw : 'Okänt fel'
for (const [pattern, build] of PATTERNS) {
const match = message.match(pattern)
if (match) return build(match)
}
// Preserve any message that's already Swedish (heuristic: contains å/ä/ö
// or a known Swedish word). Otherwise surface a generic fallback and log
// the technical detail through stderr rather than the user's screen.
if (/[åäö]|bild|faktura|inkorg|leverant/i.test(message)) {
return message
}
return 'Kunde inte bearbeta dokumentet. Försök igen eller kontakta support.'
}
@@ -0,0 +1,307 @@
// Deterministic Swedish invoice field extraction.
//
// Replaces the deleted AI classifier. We pull text out of the PDF with
// pdfjs-dist and run regex extractors against it. Each extractor is
// independent — a missing field stays null rather than dragging down a
// neighbour. Validators (Luhn for org-nr/OCR/bankgiro) keep false
// positives near zero.
//
// Image-only PDFs and non-PDF mime types come back with all fields null.
// The inbox item is still created so the user can register manually.
import type { InvoiceExtractionResult } from '@/types'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
import { validateOcrReference, validateBankgiroNumber } from '@/lib/bankgiro/luhn'
// Below this we treat the document as image-only / unreadable and skip
// regex extraction. pdfjs-dist returns near-zero text for scanned PDFs.
const MIN_TEXT_CHARS_FOR_EXTRACTION = 10
export interface ExtractionInput {
buffer: Buffer
mimeType: string
fileName: string
}
export interface ExtractionOutput {
data: InvoiceExtractionResult
/** Pulled from the PDF; null when the file isn't a text-based PDF. */
rawText: string | null
}
/**
* Extract invoice fields from a PDF buffer. Returns an InvoiceExtractionResult
* whether or not anything matched — empty fields are null, lineItems is [],
* and totals are null. Never throws on parse failure (returns empty result).
*/
export async function extractInvoiceFields(input: ExtractionInput): Promise<ExtractionOutput> {
const text = await tryExtractPdfText(input)
if (!text || text.length < MIN_TEXT_CHARS_FOR_EXTRACTION) {
return { data: emptyResult(), rawText: text }
}
const data: InvoiceExtractionResult = {
supplier: {
name: extractSupplierName(text),
orgNumber: extractOrgNumber(text),
vatNumber: extractVatNumber(text),
address: null,
bankgiro: extractBankgiro(text),
plusgiro: extractPlusgiro(text),
},
invoice: {
invoiceNumber: extractInvoiceNumber(text),
invoiceDate: extractDate(text, /faktura(?:datum|date)|utfärdat/i),
dueDate: extractDate(text, /förfallo(?:datum|dag)|due\s*date|betala\s*senast/i),
paymentReference: extractOcrReference(text),
currency: extractCurrency(text),
},
lineItems: [],
totals: extractTotals(text),
vatBreakdown: extractVatBreakdown(text),
confidence: 0,
}
return { data, rawText: text }
}
function emptyResult(): InvoiceExtractionResult {
return {
supplier: {
name: null,
orgNumber: null,
vatNumber: null,
address: null,
bankgiro: null,
plusgiro: null,
},
invoice: {
invoiceNumber: null,
invoiceDate: null,
dueDate: null,
paymentReference: null,
currency: 'SEK',
},
lineItems: [],
totals: { subtotal: null, vatAmount: null, total: null },
vatBreakdown: [],
confidence: 0,
}
}
// ── PDF text extraction ─────────────────────────────────────────────
async function tryExtractPdfText(input: ExtractionInput): Promise<string | null> {
if (input.mimeType !== 'application/pdf') return null
try {
const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs')
const loadingTask = pdfjs.getDocument({
data: new Uint8Array(input.buffer),
isEvalSupported: false,
disableFontFace: true,
})
const pdf = await loadingTask.promise
const pages: string[] = []
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
const content = await page.getTextContent()
const pageText = content.items
.map((item) => ('str' in item ? item.str : ''))
.join(' ')
pages.push(pageText)
}
return pages.join('\n').replace(/[ \t]+/g, ' ').trim()
} catch (err) {
console.warn('[invoice-inbox/extract] pdfjs failed:', err instanceof Error ? err.message : err)
return null
}
}
// ── Field extractors ────────────────────────────────────────────────
function extractOrgNumber(text: string): string | null {
const candidates = text.match(/\b\d{6}-?\d{4}\b/g) ?? []
for (const c of candidates) {
const normalized = normalizeOrgNumber(c)
if (normalized) return normalized
}
return null
}
function extractVatNumber(text: string): string | null {
const m = text.match(/\bSE\d{10}\d{2}\b/i)
return m ? m[0].toUpperCase() : null
}
function extractOcrReference(text: string): string | null {
// Anchor on "OCR" / "Referens" / "Bet.ref" labels; widen to any digit
// run on the same logical line if no labelled hit found.
const labelled = text.match(
/(?:OCR(?:-?nummer)?|Referens(?:nummer)?|Bet\.?\s*ref\.?|Betalningsreferens)[^\d\n]{0,40}(\d[\d\s]{3,30}\d)/i
)
if (labelled) {
const digits = labelled[1].replace(/\s/g, '')
if (validateOcrReference(digits)) return digits
}
// Fallback: look for any standalone digit run that passes Luhn (4-25 digits)
const candidates = text.match(/\b\d{4,25}\b/g) ?? []
for (const c of candidates) {
if (validateOcrReference(c)) return c
}
return null
}
function extractBankgiro(text: string): string | null {
const labelled = text.match(/Bankgiro(?:nr)?[^\d\n]{0,20}(\d{3,4}-?\d{4})/i)
if (labelled && validateBankgiroNumber(labelled[1])) {
return labelled[1].includes('-') ? labelled[1] : insertBankgiroHyphen(labelled[1])
}
// Fallback: any 7-8 digit number with hyphen that passes Luhn
const candidates = text.match(/\b\d{3,4}-\d{4}\b/g) ?? []
for (const c of candidates) {
if (validateBankgiroNumber(c)) return c
}
return null
}
function insertBankgiroHyphen(digits: string): string {
if (digits.length === 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`
if (digits.length === 8) return `${digits.slice(0, 4)}-${digits.slice(4)}`
return digits
}
function extractPlusgiro(text: string): string | null {
const m = text.match(/Plusgiro(?:nr)?[^\d\n]{0,20}(\d{1,8}-\d)/i)
return m ? m[1] : null
}
function extractInvoiceNumber(text: string): string | null {
const m = text.match(
/(?:Faktura(?:nr|nummer)?|Invoice\s*(?:no|number|#))[^\w\n]{0,8}([A-Z0-9][A-Z0-9\-/]{2,20})/i
)
return m ? m[1].trim() : null
}
function extractDate(text: string, anchor: RegExp): string | null {
// Look for a date within ~40 chars of the anchor
const re = new RegExp(
`(?:${anchor.source})[^\\d\\n]{0,40}(\\d{4}[-/.]\\d{1,2}[-/.]\\d{1,2}|\\d{1,2}[-/.]\\d{1,2}[-/.]\\d{4})`,
'i'
)
const m = text.match(re)
if (!m) return null
return normalizeDate(m[1])
}
function normalizeDate(raw: string): string | null {
const sep = raw.match(/[-/.]/)
if (!sep) return null
const parts = raw.split(/[-/.]/).map((p) => p.trim())
if (parts.length !== 3) return null
let yyyy: string, mm: string, dd: string
if (parts[0].length === 4) {
[yyyy, mm, dd] = parts
} else if (parts[2].length === 4) {
[dd, mm, yyyy] = parts
} else {
return null
}
const m = mm.padStart(2, '0')
const d = dd.padStart(2, '0')
if (!/^\d{4}$/.test(yyyy) || !/^\d{2}$/.test(m) || !/^\d{2}$/.test(d)) return null
// Sanity check
const month = parseInt(m, 10)
const day = parseInt(d, 10)
if (month < 1 || month > 12 || day < 1 || day > 31) return null
return `${yyyy}-${m}-${d}`
}
function extractCurrency(text: string): string {
// Default SEK; only switch if a 3-letter currency code appears with an amount nearby
const m = text.match(/\b(EUR|USD|GBP|NOK|DKK|CHF)\b/i)
return m ? m[1].toUpperCase() : 'SEK'
}
function extractTotals(text: string): { subtotal: number | null; vatAmount: number | null; total: number | null } {
const total = findAmountNear(text, /(?:Att\s*betala|Totalt\s*att\s*betala|Summa\s*att\s*betala|Total(?:summa)?|Belopp\s*att\s*betala)/i)
const vatAmount = findAmountNear(text, /(?:Total\s*moms|Moms(?:\s*totalt)?|VAT(?:\s*total)?)/i)
const subtotal = findAmountNear(text, /(?:Netto(?:summa)?|Subtotal|Summa\s*excl(?:\.|usive)?\s*moms|Belopp\s*excl(?:\.|usive)?\s*moms)/i)
return { subtotal, vatAmount, total }
}
function findAmountNear(text: string, anchor: RegExp): number | null {
const re = new RegExp(`(?:${anchor.source})[^\\d\\n-]{0,60}([0-9][\\d\\s.,]*[0-9])`, 'i')
const m = text.match(re)
if (!m) return null
return parseSwedishAmount(m[1])
}
function parseSwedishAmount(raw: string): number | null {
// Swedish uses space as thousands sep and comma as decimal: "1 234,56".
// Also tolerate "1,234.56" (international) and "1234.56".
const cleaned = raw.replace(/\s/g, '')
let normalized: string
if (/,/.test(cleaned) && /\./.test(cleaned)) {
// Both present — assume thousands+decimal. Decide by last separator.
const lastComma = cleaned.lastIndexOf(',')
const lastDot = cleaned.lastIndexOf('.')
if (lastComma > lastDot) {
normalized = cleaned.replace(/\./g, '').replace(',', '.')
} else {
normalized = cleaned.replace(/,/g, '')
}
} else if (/,/.test(cleaned)) {
// Only comma — Swedish decimal
normalized = cleaned.replace(',', '.')
} else {
normalized = cleaned
}
const n = parseFloat(normalized)
return Number.isFinite(n) ? Math.round(n * 100) / 100 : null
}
function extractVatBreakdown(text: string): Array<{ rate: number; base: number; amount: number }> {
const out: Array<{ rate: number; base: number; amount: number }> = []
// Match patterns like "Moms 25% 800,00 200,00" or "25% moms 200,00"
const lineRe = /(?:Moms\s*)?(\d{1,2})\s*%[^\n\d-]{0,30}([0-9][\d\s.,]*[0-9])(?:[^\n\d-]{0,30}([0-9][\d\s.,]*[0-9]))?/gi
let m: RegExpExecArray | null
while ((m = lineRe.exec(text)) !== null) {
const rate = parseInt(m[1], 10)
if (![25, 12, 6, 0].includes(rate)) continue
const a = parseSwedishAmount(m[2])
const b = m[3] ? parseSwedishAmount(m[3]) : null
if (a == null) continue
// Two amounts: base then VAT amount. One amount: just VAT, derive base.
if (b != null) {
out.push({ rate, base: a, amount: b })
} else if (rate > 0) {
const base = Math.round((a / (rate / 100)) * 100) / 100
out.push({ rate, base, amount: a })
}
}
// Dedup by rate (keep first hit)
const seen = new Set<number>()
return out.filter((row) => {
if (seen.has(row.rate)) return false
seen.add(row.rate)
return true
})
}
function extractSupplierName(text: string): string | null {
// Heuristic: first non-blank, non-numeric line in the first 500 chars,
// skipping obvious header words.
const head = text.slice(0, 500)
const lines = head.split(/\n|(?:\s{4,})/).map((l) => l.trim()).filter(Boolean)
const skip = /^(faktura|invoice|kvitto|receipt|sida|page|datum|date)$/i
for (const line of lines) {
if (skip.test(line)) continue
if (/^\d/.test(line)) continue
if (line.length < 3 || line.length > 80) continue
return line
}
return null
}
@@ -1,191 +0,0 @@
/**
* 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,
}
}
@@ -5,23 +5,18 @@
"entryPoint": "@/extensions/general/invoice-inbox",
"workspace": "@/components/extensions/general/InvoiceInboxWorkspace",
"requiredEnvVars": [
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
"AWS_REGION",
"RESEND_API_KEY",
"RESEND_INBOUND_DOMAIN",
"RESEND_INBOUND_WEBHOOK_SECRET"
],
"optionalEnvVars": ["BEDROCK_MODEL_ID", "BEDROCK_MAX_TOKENS"],
"npmDependencies": ["@aws-sdk/client-bedrock-runtime"],
"definition": {
"name": "Dokumentinkorg",
"category": "import",
"icon": "Inbox",
"dataPattern": "both",
"hasOwnData": true,
"readsCoreTables": ["document_attachments", "suppliers", "transactions"],
"description": "AI-klassificering och extraktion av leverantörsfakturor och kvitton",
"longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt, klassificeras med AI (leverantör, belopp, moms) och matchas mot transaktioner. Kräver AWS Bedrock och Resend."
"readsCoreTables": ["document_attachments", "suppliers"],
"description": "Vidarebefordra leverantörsfakturor till en unik adress – dokumenten landar här med extraherade fält",
"longDescription": "Varje bolag får en unik fakturainkorg-adress. Fakturor som skickas dit fångas automatiskt och fält som org.nr, OCR, bankgiro, belopp och förfallodatum extraheras deterministiskt från PDF-texten. Inga AI-anrop, inga molntjänster utöver Resend för e-postmottagning."
}
}
@@ -27,7 +27,7 @@ export const capabilitiesResource: McpResource = {
const { data: settings } = await supabase
.from('company_settings')
.select('bookkeeping_locked_through, vat_registered, pays_salaries, ai_flow_enabled')
.select('bookkeeping_locked_through, vat_registered, pays_salaries')
.eq('company_id', companyId)
.maybeSingle()
@@ -26,7 +26,7 @@ export const companyCurrentResource: McpResource = {
accounting_method, default_voucher_series,
bookkeeping_locked_through, auto_lock_period_days,
invoice_prefix, next_invoice_number, invoice_default_days,
is_sandbox, ai_flow_enabled
is_sandbox
`)
.eq('company_id', companyId)
.maybeSingle()
+33 -62
View File
@@ -56,7 +56,7 @@ import {
generateInvoiceEmailSubject,
} from '@/lib/email/invoice-templates'
import { uploadDocument, MAX_DOCUMENT_SIZE } from '@/lib/core/documents/document-service'
// classifyDocument is dynamically imported from invoice-inbox (may not be enabled)
import { extractInvoiceFields } from '@/extensions/general/invoice-inbox/lib/extract-invoice-fields'
// ensureInitialized() is called by the extension router (ext/[...path]/route.ts)
// which dispatches to this handler — no duplicate call needed here.
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, CompanySettings, Customer, InvoiceItem } from '@/types'
@@ -2298,15 +2298,14 @@ const tools: McpTool[] = [
{
name: 'gnubok_upload_document',
description:
'Upload a document (invoice, receipt) to the inbox for AI classification.\n\n' +
'Upload a document (invoice, receipt) to the inbox. Runs deterministic field extraction (pdfjs + regex) on text-based PDFs.\n\n' +
'Args:\n' +
' - file_name (string, required): File name with extension (e.g. "faktura.pdf")\n' +
' - file_content_base64 (string, required): Base64-encoded file content\n' +
' - mime_type (string, optional): MIME type. Inferred from extension if omitted.\n\n' +
'Returns JSON:\n' +
' { document_id, inbox_item_id, status, document_type, extracted_data, confidence }\n\n' +
'Supported types: PDF, JPEG, PNG, HEIC, WebP. Max 20 MB.\n' +
'Classification runs synchronously (~2-5 seconds).',
' { document_id, inbox_item_id, status, extracted_data }\n\n' +
'Supported types: PDF, JPEG, PNG, HEIC, WebP. Max 20 MB.',
inputSchema: {
type: 'object',
properties: {
@@ -2353,53 +2352,42 @@ const tools: McpTool[] = [
throw new Error(`File too large (max ${MAX_DOCUMENT_SIZE / 1024 / 1024} MB)`)
}
// Store in WORM archive
const doc = await uploadDocument(supabase, userId, companyId, {
name: fileName,
buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
type: mimeType,
}, { upload_source: 'api' })
// Classify — skipped when invoice-inbox extension is not enabled
// (dynamic import of classify-document pulls @aws-sdk/client-bedrock-runtime which breaks the build)
let classificationResult: { documentType?: string; extractedData?: unknown; rawResponse?: unknown; confidence?: number } | undefined
let classificationError: string | null = null
classificationError = 'invoice-inbox extension not enabled'
const { data: extracted } = await extractInvoiceFields({
buffer,
mimeType,
fileName,
})
// Supplier matching
let matchedSupplierId: string | null = null
if (classificationResult?.documentType === 'supplier_invoice' && classificationResult.extractedData) {
const extractedData = classificationResult.extractedData as { supplier?: { orgNumber?: string | null } }
const orgNumber = extractedData.supplier?.orgNumber
if (orgNumber) {
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', orgNumber.replace(/\D/g, ''))
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
if (extracted.supplier.orgNumber) {
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', extracted.supplier.orgNumber)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
// Create inbox item
const { data: inbox, error: inboxError } = await supabase
.from('invoice_inbox_items')
.insert({
company_id: companyId,
user_id: userId,
status: classificationError ? 'error' : 'ready',
status: 'received',
source: 'upload',
document_id: doc.id,
document_type: classificationResult?.documentType || 'unknown',
extracted_data: classificationResult?.extractedData || null,
raw_llm_response: classificationResult?.rawResponse || null,
confidence: classificationResult?.confidence ? classificationResult.confidence / 100 : null,
extracted_data: extracted as unknown as Record<string, unknown>,
matched_supplier_id: matchedSupplierId,
error_message: classificationError,
})
.select('id, status, document_type, confidence')
.select('id, status')
.single()
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
@@ -2408,10 +2396,8 @@ const tools: McpTool[] = [
document_id: doc.id,
inbox_item_id: inbox.id,
status: inbox.status,
document_type: inbox.document_type,
extracted_data: classificationResult?.extractedData || null,
confidence: inbox.confidence,
error_message: classificationError,
extracted_data: extracted,
matched_supplier_id: matchedSupplierId,
}
},
},
@@ -2419,28 +2405,22 @@ const tools: McpTool[] = [
{
name: 'gnubok_list_inbox_items',
description:
'List document inbox items (classified invoices, receipts, etc.).\n\n' +
'List document inbox items (received supplier-invoice documents).\n\n' +
'Args:\n' +
' - status (string, optional): Filter by status (pending, processing, ready, confirmed, rejected, error)\n' +
' - document_type (string, optional): Filter by type (supplier_invoice, receipt, government_letter, unknown)\n' +
' - status (string, optional): Filter by status (received, error)\n' +
' - limit (number, optional): Max results, 1–50 (default 20)\n\n' +
'Returns JSON:\n' +
' { items: [{ id, status, document_type, confidence, source, created_at,\n' +
' vendor_name, amount, invoice_date, matched_supplier_id }],\n' +
' { items: [{ id, status, source, created_at, vendor_name, amount,\n' +
' invoice_date, matched_supplier_id, created_supplier_invoice_id }],\n' +
' count: number }',
inputSchema: {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['pending', 'processing', 'ready', 'confirmed', 'rejected', 'error'],
enum: ['received', 'error'],
description: 'Filter by status',
},
document_type: {
type: 'string',
enum: ['supplier_invoice', 'receipt', 'government_letter', 'unknown'],
description: 'Filter by document type',
},
limit: {
type: 'number',
description: 'Max results (default 20, max 50)',
@@ -2456,53 +2436,44 @@ const tools: McpTool[] = [
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
const status = args.status as string | undefined
const documentType = args.document_type as string | undefined
let query = supabase
.from('invoice_inbox_items')
.select('id, status, document_type, confidence, source, created_at, extracted_data, matched_supplier_id, email_from, email_subject, error_message')
.select('id, status, source, created_at, extracted_data, matched_supplier_id, created_supplier_invoice_id, email_from, email_subject, error_message')
.eq('company_id', companyId)
.order('created_at', { ascending: false })
.limit(limit)
if (status) query = query.eq('status', status)
if (documentType) query = query.eq('document_type', documentType)
const { data, error } = await query
if (error) throw new Error(`Database error: ${error.message}`)
// Extract key fields from extracted_data for summary
const items = (data || []).map((item) => {
const extracted = item.extracted_data as Record<string, unknown> | null
let vendorName: string | null = null
let amount: number | null = null
let invoiceDate: string | null = null
if (extracted && item.document_type === 'supplier_invoice') {
if (extracted) {
const supplier = extracted.supplier as Record<string, unknown> | undefined
const invoice = extracted.invoice as Record<string, unknown> | undefined
const totals = extracted.totals as Record<string, unknown> | undefined
vendorName = (supplier?.name as string) || null
amount = (totals?.total as number) || null
invoiceDate = (invoice?.invoiceDate as string) || null
} else if (extracted && item.document_type === 'receipt') {
const merchant = extracted.merchant as Record<string, unknown> | undefined
const totals = extracted.totals as Record<string, unknown> | undefined
vendorName = (merchant?.name as string) || null
amount = (totals?.total as number) || null
}
return {
id: item.id,
status: item.status,
document_type: item.document_type,
confidence: item.confidence,
source: item.source,
created_at: item.created_at,
vendor_name: vendorName,
amount,
invoice_date: invoiceDate,
matched_supplier_id: item.matched_supplier_id,
created_supplier_invoice_id: item.created_supplier_invoice_id,
email_from: item.email_from,
email_subject: item.email_subject,
error_message: item.error_message,
@@ -2520,8 +2491,8 @@ const tools: McpTool[] = [
'Args:\n' +
' - inbox_item_id (string, required): UUID of the inbox item\n\n' +
'Returns JSON:\n' +
' Full inbox item with id, status, document_type, confidence, source,\n' +
' extracted_data (complete), matched_supplier_id, email metadata, timestamps.',
' Full inbox item with id, status, source, extracted_data (complete),\n' +
' matched_supplier_id, created_supplier_invoice_id, email metadata, timestamps.',
inputSchema: {
type: 'object',
properties: {
@@ -89,7 +89,7 @@ export function createReceiptExtractedPayload(
badge: '/icons/badge-72.png',
tag: `receipt-extracted-${receiptId}`,
data: {
url: '/receipts',
url: '/transactions',
type: 'receipt_extracted',
id: receiptId,
},
@@ -107,7 +107,7 @@ export function createReceiptMatchedPayload(
badge: '/icons/badge-72.png',
tag: `receipt-matched-${receiptId}`,
data: {
url: '/receipts',
url: '/transactions',
type: 'receipt_matched',
id: receiptId,
},
@@ -243,7 +243,6 @@ export async function bokforSkattekontoTransaction(
source_id: tx.id,
notes: `Genererad från skattekonto-synk. Skatteverket-id: ${tx.transaktionsidentitet ?? '–'}`,
lines,
created_via: 'manual',
}
const entry = await createDraftEntry(supabase, companyId, userId, input)