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:
@@ -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: {} },
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user