feat: document inbox + fix MCP pending operations user_id (#172)

* fix: always use business PSU type for bank connections

EF (sole trader) users connecting to Nordea got personal accounts
because psu_type was set to 'personal' based on entity_type. Since
gnubok is accounting software, all bank connections should use
'business' PSU type regardless of entity type.

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

* feat: document inbox extension with AI classification

Add invoice-inbox extension for email-based document processing with
AI-powered classification, supplier matching, and inbox management.
Includes MCP tools for document upload/listing, migration, and
supporting changes across document service, API keys, and banking.

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

* fix: disable invoice-inbox extension, use dynamic import in MCP server

Keep invoice-inbox out of extensions.config.json until ready for
production. MCP server now dynamically imports classifyDocument to
avoid breaking when the extension is disabled.

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

* fix: correct file size error message in invoice-inbox upload

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-03 16:45:17 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 1dcec370b6
commit c4a6d16e94
19 changed files with 4444 additions and 306 deletions
+479
View File
@@ -0,0 +1,479 @@
import type { Extension, ExtensionContext } from '@/lib/extensions/types'
import { NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { classifyDocument } from './lib/classify-document'
import { encryptState, decryptState, encryptToken, decryptToken } from './lib/gmail-helpers'
import { scanGmailConnection } from './lib/gmail-scanner'
import type { InvoiceExtractionResult } from '@/types'
const MAX_FILE_SIZE = 10 * 1024 * 1024 // Match MAX_DOCUMENT_SIZE from document-service
const UPLOAD_ALLOWED_MIME_TYPES = new Set([
'application/pdf',
'image/jpeg',
'image/png',
'image/heic',
'image/heif',
'image/webp',
])
const STATE_TTL_MS = 10 * 60 * 1000
// ── Shared helper: upload + classify + create inbox item ─────
async function uploadAndClassify(
supabase: import('@supabase/supabase-js').SupabaseClient,
userId: string,
companyId: string,
file: { name: string; buffer: ArrayBuffer; type: string },
source: 'upload' | 'email',
emailMeta?: { from?: string | null; subject?: string | null; receivedAt?: string | null; messageId?: string }
) {
// Store in WORM archive
const doc = await uploadDocument(supabase, userId, companyId, {
name: file.name,
buffer: file.buffer,
type: file.type,
}, {
upload_source: source === 'email' ? 'email' : 'file_upload',
})
// Classify with AI
let classificationResult
let classificationError: string | null = null
try {
classificationResult = await classifyDocument({
fileBuffer: Buffer.from(file.buffer),
mimeType: file.type,
fileName: file.name,
})
} catch (err) {
classificationError = err instanceof Error ? err.message : 'Classification failed'
}
// Supplier matching
let matchedSupplierId: string | null = null
if (classificationResult?.documentType === 'supplier_invoice' && classificationResult.extractedData) {
const extractedData = classificationResult.extractedData as InvoiceExtractionResult
const orgNumber = extractedData.supplier?.orgNumber
const supplierName = extractedData.supplier?.name
if (orgNumber) {
const normalized = orgNumber.replace(/\D/g, '')
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', normalized)
.limit(1)
.maybeSingle()
if (s) matchedSupplierId = s.id
}
if (!matchedSupplierId && supplierName) {
const { data: s } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.ilike('name', supplierName)
.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',
source,
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,
matched_supplier_id: matchedSupplierId,
email_from: emailMeta?.from || null,
email_subject: emailMeta?.subject || null,
email_received_at: emailMeta?.receivedAt || null,
raw_email_payload: emailMeta?.messageId
? { messageId: emailMeta.messageId, filename: file.name }
: null,
error_message: classificationError,
})
.select('id, status, document_type, confidence, matched_supplier_id, error_message')
.single()
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
return {
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,
matched_supplier_id: inbox.matched_supplier_id,
error_message: inbox.error_message,
}
}
// ── Extension definition ─────────────────────────────────────
export const invoiceInboxExtension: Extension = {
id: 'invoice-inbox',
name: 'Dokumentinkorg',
version: '1.0.0',
apiRoutes: [
// ── Upload ──────────────────────────────────────────────
{
method: 'POST',
path: '/upload',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json({ error: `File too large (max ${MAX_FILE_SIZE / 1024 / 1024} MB)` }, { status: 400 })
}
if (!UPLOAD_ALLOWED_MIME_TYPES.has(file.type)) {
return NextResponse.json(
{ error: `Unsupported file type: ${file.type}. Allowed: PDF, JPEG, PNG, HEIC, WebP` },
{ status: 400 }
)
}
try {
const buffer = await file.arrayBuffer()
const result = await uploadAndClassify(
ctx.supabase,
ctx.userId,
ctx.companyId,
{ name: file.name, buffer, type: file.type },
'upload'
)
return NextResponse.json({ data: result })
} catch (error) {
console.error('[invoice-inbox/upload] Failed:', error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Upload failed' },
{ status: 500 }
)
}
},
},
// ── List inbox items ────────────────────────────────────
{
method: 'GET',
path: '/items',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const url = new URL(request.url)
const status = url.searchParams.get('status')
const documentType = url.searchParams.get('document_type')
const limit = Math.min(Math.max(1, Number(url.searchParams.get('limit')) || 20), 50)
let query = ctx.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')
.eq('company_id', ctx.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) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data: { items: data, count: data?.length ?? 0 } })
},
},
// ── Get single inbox item ───────────────────────────────
{
method: 'GET',
path: '/items/:id',
handler: async (request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const url = new URL(request.url)
const id = url.searchParams.get('_id')
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 })
const { data, error } = await ctx.supabase
.from('invoice_inbox_items')
.select('*')
.eq('id', id)
.eq('company_id', ctx.companyId)
.single()
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
if (!data) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json({ data })
},
},
// ── Gmail OAuth: get auth URL ───────────────────────────
{
method: 'GET',
path: '/gmail/auth',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const clientId = process.env.GOOGLE_CLIENT_ID
if (!clientId) {
return NextResponse.json({ error: 'Google OAuth not configured' }, { status: 500 })
}
if (!process.env.GMAIL_TOKEN_ENCRYPTION_KEY) {
return NextResponse.json({ error: 'GMAIL_TOKEN_ENCRYPTION_KEY is required' }, { status: 500 })
}
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
const redirectUri = `${appUrl}/api/extensions/ext/invoice-inbox/gmail/callback`
const state = encryptState({
companyId: ctx.companyId,
userId: ctx.userId,
exp: Date.now() + STATE_TTL_MS,
})
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: 'https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.labels',
access_type: 'offline',
prompt: 'consent',
state,
})
return NextResponse.json({ data: { authUrl: `https://accounts.google.com/o/oauth2/v2/auth?${params}` } })
},
},
// ── Gmail OAuth: callback (skipAuth — redirect from Google) ─
{
method: 'GET',
path: '/gmail/callback',
skipAuth: true,
handler: async (request: Request) => {
const url = new URL(request.url)
const code = url.searchParams.get('code')
const stateParam = url.searchParams.get('state')
const error = url.searchParams.get('error')
const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
if (error) {
console.error('[gmail/callback] OAuth error:', error)
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_auth_denied`)
}
if (!code || !stateParam) {
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_missing_params`)
}
if (!process.env.GMAIL_TOKEN_ENCRYPTION_KEY) {
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_config_error`)
}
const state = decryptState(stateParam) as { companyId: string; userId: string; exp: number } | null
if (!state || Date.now() > state.exp) {
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_invalid_state`)
}
const { companyId, userId } = state
const redirectUri = `${appUrl}/api/extensions/ext/invoice-inbox/gmail/callback`
try {
// Exchange code for tokens
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
}),
})
if (!tokenResponse.ok) {
console.error('[gmail/callback] Token exchange failed:', await tokenResponse.text())
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_token_exchange`)
}
const tokens = await tokenResponse.json() as {
access_token: string; refresh_token?: string
}
if (!tokens.refresh_token) {
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_no_refresh_token`)
}
// Get user email
const profileResponse = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/profile', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
})
if (!profileResponse.ok) {
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_profile_error`)
}
const profile = await profileResponse.json() as { emailAddress: string }
// Create gnubok-processed label
let gmailLabelId: string | null = null
try {
const labelsResponse = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/labels', {
headers: { Authorization: `Bearer ${tokens.access_token}` },
})
const labelsData = await labelsResponse.json() as { labels: { id: string; name: string }[] }
const existing = labelsData.labels?.find((l) => l.name === 'gnubok-processed')
if (existing) {
gmailLabelId = existing.id
} else {
const createLabelResponse = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/labels', {
method: 'POST',
headers: {
Authorization: `Bearer ${tokens.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'gnubok-processed',
labelListVisibility: 'labelShow',
messageListVisibility: 'show',
}),
})
if (createLabelResponse.ok) {
const label = await createLabelResponse.json() as { id: string }
gmailLabelId = label.id
}
}
} catch (err) {
console.warn('[gmail/callback] Failed to create Gmail label:', err)
}
// Store connection (service-role — no auth cookie in callback)
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
const { error: dbError } = await supabase
.from('email_connections')
.upsert(
{
company_id: companyId,
user_id: userId,
provider: 'gmail',
email_address: profile.emailAddress,
encrypted_token: encryptToken(tokens.refresh_token),
gmail_label_id: gmailLabelId,
status: 'active',
error_message: null,
},
{ onConflict: 'company_id,email_address' }
)
if (dbError) {
console.error('[gmail/callback] DB insert failed:', dbError)
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_db_error`)
}
console.log(`[gmail/callback] Gmail connected for ${profile.emailAddress} (company ${companyId})`)
return NextResponse.redirect(`${appUrl}/settings/banking?gmail=connected`)
} catch (err) {
console.error('[gmail/callback] Unexpected error:', err)
return NextResponse.redirect(`${appUrl}/settings/banking?error=gmail_unexpected`)
}
},
},
// ── Gmail: disconnect ───────────────────────────────────
{
method: 'POST',
path: '/gmail/disconnect',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { error } = await ctx.supabase
.from('email_connections')
.delete()
.eq('company_id', ctx.companyId)
.eq('provider', 'gmail')
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data: { disconnected: true } })
},
},
// ── Gmail: connection status ────────────────────────────
{
method: 'GET',
path: '/gmail/status',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { data, error } = await ctx.supabase
.from('email_connections')
.select('id, email_address, status, last_sync_at, error_message, created_at')
.eq('company_id', ctx.companyId)
.eq('provider', 'gmail')
if (error) return NextResponse.json({ error: error.message }, { status: 500 })
return NextResponse.json({ data: { connections: data || [] } })
},
},
// ── Gmail: manual scan trigger ──────────────────────────
{
method: 'POST',
path: '/gmail/scan',
handler: async (_request: Request, ctx?: ExtensionContext) => {
if (!ctx) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
if (!process.env.GMAIL_TOKEN_ENCRYPTION_KEY) {
return NextResponse.json({ error: 'GMAIL_TOKEN_ENCRYPTION_KEY is required' }, { status: 500 })
}
const { data: connections, error: connError } = await ctx.supabase
.from('email_connections')
.select('*')
.eq('company_id', ctx.companyId)
.eq('status', 'active')
if (connError) return NextResponse.json({ error: 'Failed to fetch connections' }, { status: 500 })
if (!connections?.length) {
return NextResponse.json({ data: { message: 'No active Gmail connections', scanned: 0 } })
}
let totalScanned = 0, totalClassified = 0, totalSkipped = 0, totalErrors = 0
for (const connection of connections) {
const result = await scanGmailConnection(ctx.supabase, connection, ctx.userId, ctx.companyId)
totalScanned += result.scanned
totalClassified += result.classified
totalSkipped += result.skipped
totalErrors += result.errors
}
return NextResponse.json({
data: { scanned: totalScanned, classified: totalClassified, skipped: totalSkipped, errors: totalErrors },
})
},
},
],
}
@@ -0,0 +1,558 @@
/**
* Document Classification Pipeline
*
* Pure function: file buffer + mime type → structured classification result.
* No database, no side effects. Uses AWS Bedrock (Claude Sonnet) for vision-based
* extraction of Swedish financial documents.
*/
import {
BedrockRuntimeClient,
ConverseCommand,
type ContentBlock,
type ToolConfiguration,
type Message,
} from '@aws-sdk/client-bedrock-runtime'
import sharp from 'sharp'
import type {
InvoiceExtractionResult,
ReceiptExtractionResult,
ExtractedLineItem,
ExtractedInvoiceLineItem,
VatBreakdownItem,
} from '@/types'
// ── Types ────────────────────────────────────────────────────
export type DocumentClassificationType = 'supplier_invoice' | 'receipt' | 'government_letter' | 'unknown'
export interface ClassificationInput {
fileBuffer: Buffer
mimeType: string
fileName: string
}
export interface ClassificationResult {
documentType: DocumentClassificationType
extractedData: InvoiceExtractionResult | ReceiptExtractionResult | null
confidence: number
rawResponse: Record<string, unknown>
usage: { inputTokens: number; outputTokens: number }
}
// ── Bedrock client (lazy singleton) ──────────────────────────
let _client: BedrockRuntimeClient | null = null
function getClient(): BedrockRuntimeClient {
if (!_client) {
_client = new BedrockRuntimeClient({
region: process.env.AWS_REGION || 'eu-north-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
})
}
return _client
}
// ── Constants ────────────────────────────────────────────────
const VALID_VAT_RATES = [0, 6, 12, 25]
const MIME_TO_IMAGE_FORMAT: Record<string, string> = {
'image/jpeg': 'jpeg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
}
// ── System prompt ────────────────────────────────────────────
const SYSTEM_PROMPT = `Du är en svensk bokföringsdokumentklassificerare och dataextraktor.
Du analyserar bilder och PDF:er av finansiella dokument (leverantörsfakturor, kvitton, skattedokument) och extraherar strukturerad data.
Kontext:
- Svensk bokföring enligt Bokföringslagen (BFL) och BFNAR
- Giltiga momssatser: 0%, 6%, 12%, 25%
- Organisationsnummer: 10 siffror, format XXXXXX-XXXX
- Vanliga betalningsmetoder: bankgiro, plusgiro, Swish, banköverföring, kort
- Valutor: SEK är standard, men EUR, USD, GBP etc förekommer
Instruktioner:
- Klassificera dokumenttypen
- Extrahera alla synliga fält — returnera null för fält som inte kan utläsas
- Belopp ska vara positiva tal med max 2 decimaler
- 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
Anropa ALLTID verktyget classify_document med resultatet.`
// ── Tool schema for structured output ────────────────────────
const CLASSIFICATION_TOOL: ToolConfiguration = {
tools: [
{
toolSpec: {
name: 'classify_document',
description: 'Klassificera och extrahera data från ett svenskt finansiellt dokument',
inputSchema: {
json: {
type: 'object',
required: ['document_type', 'confidence'],
properties: {
document_type: {
type: 'string',
enum: ['supplier_invoice', 'receipt', 'government_letter', 'unknown'],
description: 'Typ av dokument',
},
confidence: {
type: 'integer',
minimum: 0,
maximum: 100,
description: 'Säkerhet i klassificeringen (0-100)',
},
// Supplier invoice fields
supplier_name: { type: ['string', 'null'] },
supplier_org_number: { type: ['string', 'null'] },
supplier_vat_number: { type: ['string', 'null'] },
supplier_address: { type: ['string', 'null'] },
supplier_bankgiro: { type: ['string', 'null'] },
supplier_plusgiro: { type: ['string', 'null'] },
invoice_number: { type: ['string', 'null'] },
invoice_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
due_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
payment_reference: { type: ['string', 'null'], description: 'OCR-nummer eller betalningsreferens' },
currency: { type: ['string', 'null'], description: 'ISO 4217 (t.ex. SEK, EUR)' },
// Receipt fields
merchant_name: { type: ['string', 'null'] },
merchant_org_number: { type: ['string', 'null'] },
merchant_vat_number: { type: ['string', 'null'] },
merchant_is_foreign: { type: ['boolean', 'null'] },
receipt_date: { type: ['string', 'null'], description: 'YYYY-MM-DD' },
receipt_time: { type: ['string', 'null'], description: 'HH:MM' },
is_restaurant: { type: ['boolean', 'null'] },
is_systembolaget: { type: ['boolean', 'null'] },
// Shared amount fields
amount_excl_vat: { type: ['number', 'null'] },
amount_incl_vat: { type: ['number', 'null'] },
vat_amount: { type: ['number', 'null'] },
// Line items
line_items: {
type: 'array',
items: {
type: 'object',
properties: {
description: { type: 'string' },
quantity: { type: ['number', 'null'] },
unit_price: { type: ['number', 'null'] },
amount: { type: ['number', 'null'] },
vat_rate: { type: ['integer', 'null'], description: '0, 6, 12, or 25' },
},
required: ['description'],
},
},
// VAT breakdown (for invoices)
vat_breakdown: {
type: 'array',
items: {
type: 'object',
properties: {
rate: { type: 'integer' },
base: { type: 'number' },
amount: { type: 'number' },
},
required: ['rate', 'base', 'amount'],
},
},
},
},
},
},
},
],
toolChoice: { any: {} },
}
// ── Core classification function ─────────────────────────────
export async function classifyDocument(input: ClassificationInput): Promise<ClassificationResult> {
const contentBlock = await buildContentBlock(input)
const messages: Message[] = [
{
role: 'user',
content: [
contentBlock,
{ text: 'Analysera detta dokument. Klassificera typ och extrahera all strukturerad data.' },
],
},
]
const modelId = process.env.BEDROCK_MODEL_ID || 'eu.anthropic.claude-sonnet-4-6'
const maxTokens = parseInt(process.env.BEDROCK_MAX_TOKENS || '8192', 10)
const command = new ConverseCommand({
modelId,
messages,
system: [{ text: SYSTEM_PROMPT }],
toolConfig: CLASSIFICATION_TOOL,
inferenceConfig: { maxTokens },
})
const response = await getClient().send(command)
// Extract tool use result from response
const outputMessage = response.output?.message
if (!outputMessage?.content) {
throw new Error('No content in Bedrock response')
}
const toolUseBlock = outputMessage.content.find(
(block): block is ContentBlock.ToolUseMember => 'toolUse' in block && block.toolUse !== undefined
)
if (!toolUseBlock?.toolUse?.input) {
throw new Error('No tool use result in Bedrock response')
}
const rawData = toolUseBlock.toolUse.input as Record<string, unknown>
const usage = {
inputTokens: response.usage?.inputTokens ?? 0,
outputTokens: response.usage?.outputTokens ?? 0,
}
// Validate and map to typed result
const result = mapToClassificationResult(rawData, usage)
// If validation found issues, retry once with correction
if (!result) {
return retryWithCorrection(input, rawData, usage)
}
return result
}
// ── Content block builder ────────────────────────────────────
async function buildContentBlock(input: ClassificationInput): Promise<ContentBlock> {
const { fileBuffer, mimeType } = input
// PDF → document block
if (mimeType === 'application/pdf') {
return {
document: {
format: 'pdf',
name: sanitizeDocName(input.fileName),
source: {
bytes: new Uint8Array(fileBuffer),
},
},
}
}
// HEIC → convert to JPEG via sharp
if (mimeType === 'image/heic' || mimeType === 'image/heif') {
const jpegBuffer = await sharp(fileBuffer).jpeg({ quality: 90 }).toBuffer()
return {
image: {
format: 'jpeg',
source: { bytes: new Uint8Array(jpegBuffer) },
},
}
}
// Standard image formats
const imageFormat = MIME_TO_IMAGE_FORMAT[mimeType]
if (imageFormat) {
return {
image: {
format: imageFormat as 'jpeg' | 'png' | 'webp' | 'gif',
source: { bytes: new Uint8Array(fileBuffer) },
},
}
}
throw new Error(`Unsupported MIME type: ${mimeType}`)
}
/** Sanitize filename for Bedrock document name (alphanumeric, spaces, hyphens, brackets only) */
function sanitizeDocName(fileName: string): string {
const name = fileName.replace(/\.[^.]+$/, '') // strip extension
return name.replace(/[^a-zA-Z0-9\s\-\[\]\(\)åäöÅÄÖ]/g, '').trim() || 'document'
}
// ── Response mapping + validation ────────────────────────────
function mapToClassificationResult(
raw: Record<string, unknown>,
usage: { inputTokens: number; outputTokens: number }
): ClassificationResult | null {
const documentType = raw.document_type as DocumentClassificationType
const confidence = Math.min(100, Math.max(0, Number(raw.confidence) || 0))
if (!['supplier_invoice', 'receipt', 'government_letter', 'unknown'].includes(documentType)) {
return null
}
if (documentType === 'government_letter' || documentType === 'unknown') {
return {
documentType,
extractedData: null,
confidence,
rawResponse: raw,
usage,
}
}
if (documentType === 'supplier_invoice') {
const extractedData = mapToInvoiceExtraction(raw)
if (!extractedData) return null
return { documentType, extractedData, confidence, rawResponse: raw, usage }
}
if (documentType === 'receipt') {
const extractedData = mapToReceiptExtraction(raw)
if (!extractedData) return null
return { documentType, extractedData, confidence, rawResponse: raw, usage }
}
return null
}
function mapToInvoiceExtraction(raw: Record<string, unknown>): InvoiceExtractionResult | null {
const lineItems = mapInvoiceLineItems(raw.line_items)
const vatBreakdown = mapVatBreakdown(raw.vat_breakdown)
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,
confidence: Math.min(1, Math.max(0, Number(raw.confidence) / 100 || 0)),
}
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
- Belopp ska vara positiva tal
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
}
@@ -0,0 +1,76 @@
import crypto from 'crypto'
const ALGORITHM = 'aes-256-gcm'
export function getGmailEncryptionKey(): Buffer {
const secret = process.env.GMAIL_TOKEN_ENCRYPTION_KEY
if (!secret) throw new Error('GMAIL_TOKEN_ENCRYPTION_KEY is required')
return crypto.createHash('sha256').update(secret).digest()
}
export function encryptState(payload: Record<string, unknown>): string {
const key = getGmailEncryptionKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const json = JSON.stringify(payload)
const encrypted = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
}
export function decryptState(encoded: string): Record<string, unknown> | null {
try {
const key = getGmailEncryptionKey()
const combined = Buffer.from(encoded, 'base64url')
const iv = combined.subarray(0, 12)
const tag = combined.subarray(12, 28)
const encrypted = combined.subarray(28)
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(tag)
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
return JSON.parse(decrypted.toString('utf8'))
} catch {
return null
}
}
export function encryptToken(plaintext: string): string {
const key = getGmailEncryptionKey()
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, encrypted]).toString('base64url')
}
export function decryptToken(encoded: string): string | null {
try {
const key = getGmailEncryptionKey()
const combined = Buffer.from(encoded, 'base64url')
const iv = combined.subarray(0, 12)
const tag = combined.subarray(12, 28)
const encrypted = combined.subarray(28)
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
decipher.setAuthTag(tag)
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()])
return decrypted.toString('utf8')
} catch {
return null
}
}
export async function refreshAccessToken(refreshToken: string): Promise<string | null> {
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
refresh_token: refreshToken,
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
grant_type: 'refresh_token',
}),
})
if (!response.ok) return null
const data = await response.json() as { access_token: string }
return data.access_token
}
@@ -0,0 +1,336 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { uploadDocument, computeSHA256 } from '@/lib/core/documents/document-service'
import { classifyDocument } from './classify-document'
import { decryptToken, refreshAccessToken } from './gmail-helpers'
import type { InvoiceExtractionResult } from '@/types'
const MIN_ATTACHMENT_SIZE = 3_000
const MAX_MESSAGES = 30
const ALLOWED_MIME_TYPES = new Set([
'application/pdf',
'application/octet-stream',
'image/jpeg',
'image/png',
'image/heic',
'image/heif',
'image/webp',
])
const SKIP_EXTENSIONS = new Set([
'ics', 'vcf', 'html', 'htm', 'zip', 'rar', 'gz',
'csv', 'json', 'xml', 'txt', 'eml', 'msg',
'mp3', 'mp4', 'mov', 'avi', 'wav',
])
interface GmailMessage {
id: string
payload: {
headers: { name: string; value: string }[]
parts?: GmailPart[]
mimeType: string
body?: { attachmentId?: string; size?: number; data?: string }
}
internalDate: string
}
interface GmailPart {
mimeType: string
filename: string
body: { attachmentId?: string; size?: number; data?: string }
parts?: GmailPart[]
}
function getHeader(message: GmailMessage, name: string): string | null {
return message.payload.headers.find(
(h) => h.name.toLowerCase() === name.toLowerCase()
)?.value ?? null
}
function collectAttachments(parts: GmailPart[] | undefined): GmailPart[] {
if (!parts) return []
const result: GmailPart[] = []
for (const part of parts) {
if (part.filename && part.body?.attachmentId) {
result.push(part)
}
if (part.parts) {
result.push(...collectAttachments(part.parts))
}
}
return result
}
function resolveActualMimeType(mimeType: string, filename: string): string | null {
const ext = filename.split('.').pop()?.toLowerCase()
if (ext && SKIP_EXTENSIONS.has(ext)) return null
if (mimeType === 'application/octet-stream') {
const extMap: Record<string, string> = {
pdf: 'application/pdf',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
webp: 'image/webp',
}
return ext && extMap[ext] ? extMap[ext] : null
}
return mimeType
}
export interface ScanResult {
scanned: number
classified: number
skipped: number
errors: number
}
interface EmailConnection {
id: string
company_id: string
encrypted_token: string
last_sync_at: string | null
gmail_label_id: string | null
}
export async function scanGmailConnection(
supabase: SupabaseClient,
connection: EmailConnection,
userId: string,
companyId: string
): Promise<ScanResult> {
const result: ScanResult = { scanned: 0, classified: 0, skipped: 0, errors: 0 }
const seenFileHashes = new Set<string>()
const refreshToken = decryptToken(connection.encrypted_token)
if (!refreshToken) {
await supabase
.from('email_connections')
.update({ status: 'error', error_message: 'Failed to decrypt refresh token' })
.eq('id', connection.id)
result.errors++
return result
}
const accessToken = await refreshAccessToken(refreshToken)
if (!accessToken) {
await supabase
.from('email_connections')
.update({ status: 'revoked', error_message: 'Token refresh failed — user may have revoked access' })
.eq('id', connection.id)
result.errors++
return result
}
// Build Gmail search query
let afterDate: string
if (connection.last_sync_at) {
const d = new Date(connection.last_sync_at)
afterDate = `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`
} else {
const d = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000)
afterDate = `${d.getFullYear()}/${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}`
}
let query = `has:attachment after:${afterDate}`
if (connection.gmail_label_id) {
query += ' -label:gnubok-processed'
}
const listUrl = `https://gmail.googleapis.com/gmail/v1/users/me/messages?q=${encodeURIComponent(query)}&maxResults=${MAX_MESSAGES}`
const listResponse = await fetch(listUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!listResponse.ok) {
console.error('[gmail/scan] Failed to list messages:', await listResponse.text())
result.errors++
return result
}
const listData = await listResponse.json() as { messages?: { id: string }[] }
const messageIds = listData.messages || []
for (const { id: messageId } of messageIds) {
try {
const msgResponse = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}?format=full`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
)
if (!msgResponse.ok) continue
const message = await msgResponse.json() as GmailMessage
const emailFrom = getHeader(message, 'From')
const emailSubject = getHeader(message, 'Subject')
const emailDate = message.internalDate
? new Date(parseInt(message.internalDate)).toISOString()
: null
const attachments = collectAttachments(message.payload.parts)
for (const attachment of attachments) {
if (!attachment.body.attachmentId) continue
if ((attachment.body.size ?? 0) < MIN_ATTACHMENT_SIZE) continue
if (!ALLOWED_MIME_TYPES.has(attachment.mimeType)) continue
const resolvedMimeType = resolveActualMimeType(attachment.mimeType, attachment.filename)
if (!resolvedMimeType) continue
// Deduplicate by message ID + filename
const { data: existing } = await supabase
.from('invoice_inbox_items')
.select('id')
.eq('company_id', companyId)
.eq('source', 'email')
.filter('raw_email_payload->>messageId', 'eq', messageId)
.filter('raw_email_payload->>filename', 'eq', attachment.filename)
.limit(1)
.maybeSingle()
if (existing) {
result.skipped++
continue
}
// Download attachment
const attResponse = await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/attachments/${attachment.body.attachmentId}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
)
if (!attResponse.ok) {
result.errors++
continue
}
const attData = await attResponse.json() as { data: string }
const fileBuffer = Buffer.from(attData.data, 'base64url')
// Deduplicate by file content hash
const fileHash = await computeSHA256(fileBuffer.buffer.slice(
fileBuffer.byteOffset,
fileBuffer.byteOffset + fileBuffer.byteLength
))
if (seenFileHashes.has(fileHash)) {
result.skipped++
continue
}
const { data: existingByHash } = await supabase
.from('document_attachments')
.select('id')
.eq('company_id', companyId)
.eq('sha256_hash', fileHash)
.limit(1)
.maybeSingle()
if (existingByHash) {
result.skipped++
seenFileHashes.add(fileHash)
continue
}
seenFileHashes.add(fileHash)
// Store in WORM archive
const doc = await uploadDocument(supabase, userId, companyId, {
name: attachment.filename,
buffer: fileBuffer.buffer.slice(
fileBuffer.byteOffset,
fileBuffer.byteOffset + fileBuffer.byteLength
),
type: resolvedMimeType,
}, {
upload_source: 'email',
})
// Classify
let classificationResult
let classificationError: string | null = null
try {
classificationResult = await classifyDocument({
fileBuffer,
mimeType: resolvedMimeType,
fileName: attachment.filename,
})
} catch (err) {
classificationError = err instanceof Error ? err.message : 'Classification failed'
console.error('[gmail/scan] Classification failed:', err)
}
// Find matching supplier
let matchedSupplierId: string | null = null
if (classificationResult?.documentType === 'supplier_invoice' && classificationResult.extractedData) {
const extractedData = classificationResult.extractedData as InvoiceExtractionResult
const orgNumber = extractedData.supplier?.orgNumber
if (orgNumber) {
const normalized = orgNumber.replace(/\D/g, '')
const { data: supplierByOrg } = await supabase
.from('suppliers')
.select('id')
.eq('company_id', companyId)
.eq('org_number', normalized)
.limit(1)
.maybeSingle()
if (supplierByOrg) matchedSupplierId = supplierByOrg.id
}
}
// Create inbox item
await supabase.from('invoice_inbox_items').insert({
company_id: companyId,
user_id: userId,
status: classificationError ? 'error' : 'ready',
source: 'email',
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,
matched_supplier_id: matchedSupplierId,
email_from: emailFrom,
email_subject: emailSubject,
email_received_at: emailDate,
raw_email_payload: { messageId, filename: attachment.filename },
error_message: classificationError,
})
if (classificationError) {
result.errors++
} else {
result.classified++
}
result.scanned++
}
// Label message as processed
if (connection.gmail_label_id) {
try {
await fetch(
`https://gmail.googleapis.com/gmail/v1/users/me/messages/${messageId}/modify`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ addLabelIds: [connection.gmail_label_id] }),
}
)
} catch {
// Non-blocking
}
}
} catch (err) {
console.error('[gmail/scan] Error processing message:', err)
result.errors++
}
}
// Update last_sync_at
await supabase
.from('email_connections')
.update({ last_sync_at: new Date().toISOString(), error_message: null })
.eq('id', connection.id)
return result
}
@@ -0,0 +1,20 @@
{
"id": "invoice-inbox",
"sector": "general",
"exportName": "invoiceInboxExtension",
"entryPoint": "@/extensions/general/invoice-inbox",
"workspace": null,
"requiredEnvVars": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION"],
"optionalEnvVars": ["BEDROCK_MODEL_ID", "BEDROCK_MAX_TOKENS", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GMAIL_TOKEN_ENCRYPTION_KEY"],
"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": "Ladda upp eller ta emot dokument via Gmail. AI klassificerar dokumenttyp, extraherar strukturerad data (leverantör, belopp, moms) och matchar mot transaktioner. Kräver AWS Bedrock-åtkomst."
}
}
+353 -82
View File
@@ -39,7 +39,8 @@ import {
generateInvoiceEmailText,
generateInvoiceEmailSubject,
} from '@/lib/email/invoice-templates'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { uploadDocument, MAX_DOCUMENT_SIZE } from '@/lib/core/documents/document-service'
// classifyDocument is dynamically imported from invoice-inbox (may not be enabled)
// 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'
@@ -77,6 +78,7 @@ interface McpTool {
_meta?: { ui: { resourceUri: string } }
execute: (
args: Record<string, unknown>,
companyId: string,
userId: string,
supabase: SupabaseClient
) => Promise<unknown>
@@ -102,6 +104,7 @@ const VALID_VAT_TREATMENTS = [
async function stagePendingOperation(
supabase: SupabaseClient,
companyId: string,
userId: string,
operationType: string,
title: string,
params: Record<string, unknown>,
@@ -111,6 +114,7 @@ async function stagePendingOperation(
.from('pending_operations')
.insert({
company_id: companyId,
user_id: userId,
operation_type: operationType,
title,
params,
@@ -174,7 +178,7 @@ async function categorizeTransactionCore(
.from('transactions')
.select('*')
.eq('id', txId)
.eq('company_id', userId)
.eq('company_id', companyId)
.single()
if (fetchError || !transaction) {
@@ -200,7 +204,7 @@ async function categorizeTransactionCore(
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, fiscal_year_start_month')
.eq('company_id', userId)
.eq('company_id', companyId)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
@@ -376,7 +380,7 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 100)
const offset = Math.max(0, Number(args.offset) || 0)
@@ -384,7 +388,7 @@ const tools: McpTool[] = [
const { count: totalCount, error: countError } = await supabase
.from('transactions')
.select('id', { count: 'exact', head: true })
.eq('company_id', userId)
.eq('company_id', companyId)
.is('journal_entry_id', null)
if (countError) throw new Error(`Database error: ${countError.message}`)
@@ -394,7 +398,7 @@ const tools: McpTool[] = [
.select(
'id, date, description, amount, currency, merchant_name, reference, is_business, category'
)
.eq('company_id', userId)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.order('date', { ascending: false })
.range(offset, offset + limit - 1)
@@ -461,14 +465,14 @@ const tools: McpTool[] = [
idempotentHint: false,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
// Compute the preview (accounts, amounts, VAT lines)
const result = await categorizeTransactionCore(
args.transaction_id as string,
args.category as TransactionCategory,
args.vat_treatment as VatTreatment | undefined,
userId,
userId,
companyId,
supabase,
false // preview mode — execution happens via web UI commit
)
@@ -484,7 +488,7 @@ const tools: McpTool[] = [
.from('transactions')
.select('description, merchant_name, amount, currency')
.eq('id', args.transaction_id as string)
.eq('company_id', userId)
.eq('company_id', companyId)
.single()
const txDesc = tx
@@ -492,7 +496,7 @@ const tools: McpTool[] = [
: String(args.transaction_id)
// Stage for user approval
return stagePendingOperation(supabase, userId, 'categorize_transaction',
return stagePendingOperation(supabase, companyId, userId, 'categorize_transaction',
`Kategorisera: ${txDesc}`,
{
transaction_id: args.transaction_id,
@@ -541,7 +545,7 @@ const tools: McpTool[] = [
openWorldHint: false,
},
_meta: { ui: { resourceUri: 'ui://receipt-matcher/app.html' } },
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
const { data, error } = await supabase
@@ -549,7 +553,7 @@ const tools: McpTool[] = [
.select(
'id, date, description, amount, currency, merchant_name, reference, is_business, category'
)
.eq('company_id', userId)
.eq('company_id', companyId)
.is('journal_entry_id', null)
.order('date', { ascending: false })
.limit(limit)
@@ -581,11 +585,11 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, userId, supabase) {
async execute(_args, companyId, userId, supabase) {
const { data, error } = await supabase
.from('customers')
.select('id, name, customer_type, email, org_number, vat_number, default_payment_terms, city, country')
.eq('company_id', userId)
.eq('company_id', companyId)
.order('name')
if (error) throw new Error(`Database error: ${error.message}`)
@@ -641,7 +645,7 @@ const tools: McpTool[] = [
idempotentHint: false,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const name = args.name as string
const customerType = args.customer_type as string
@@ -663,7 +667,7 @@ const tools: McpTool[] = [
country: (args.country as string) || 'Sweden',
}
return stagePendingOperation(supabase, userId, 'create_customer',
return stagePendingOperation(supabase, companyId, userId, 'create_customer',
`Ny kund: ${params.name}`,
params,
params // params ARE the preview for customers
@@ -703,14 +707,14 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100)
const status = args.status as string | undefined
let query = supabase
.from('invoices')
.select('id, invoice_number, status, customer_id, total, currency, invoice_date, due_date, document_type, customers(name)', { count: 'exact' })
.eq('company_id', userId)
.eq('company_id', companyId)
if (status) {
query = query.eq('status', status)
@@ -800,7 +804,7 @@ const tools: McpTool[] = [
idempotentHint: false,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const customerId = args.customer_id as string
const items = args.items as Array<{
description: string
@@ -829,7 +833,7 @@ const tools: McpTool[] = [
.from('customers')
.select('*')
.eq('id', customerId)
.eq('company_id', userId)
.eq('company_id', companyId)
.single()
if (custError || !customer) {
@@ -866,7 +870,7 @@ const tools: McpTool[] = [
}
// Stage for user approval instead of creating directly
return stagePendingOperation(supabase, userId, 'create_invoice',
return stagePendingOperation(supabase, companyId, userId, 'create_invoice',
`Ny faktura: ${customer.name} ${Math.round(total * 100) / 100} ${currency}`,
{
customer_id: customerId,
@@ -924,7 +928,7 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
let periodId = args.period_id as string | undefined
// If no period specified, find the most recent one
@@ -932,7 +936,7 @@ const tools: McpTool[] = [
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id, name')
.eq('company_id', userId)
.eq('company_id', companyId)
.order('period_start', { ascending: false })
.limit(1)
.single()
@@ -948,7 +952,7 @@ const tools: McpTool[] = [
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', periodId)
.eq('company_id', userId)
.eq('company_id', companyId)
.single()
if (!period) throw new Error('Fiscal period not found.')
@@ -957,7 +961,7 @@ const tools: McpTool[] = [
const { data: lines, error } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(status, user_id, fiscal_period_id)')
.eq('journal_entries.company_id', userId)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', periodId)
.in('journal_entries.status', ['posted', 'reversed'])
@@ -967,7 +971,7 @@ const tools: McpTool[] = [
const { data: accounts } = await supabase
.from('chart_of_accounts')
.select('account_number, account_name')
.eq('company_id', userId)
.eq('company_id', companyId)
const accountMap = new Map((accounts ?? []).map((a: { account_number: string; account_name: string }) => [a.account_number, a.account_name]))
@@ -1043,7 +1047,7 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const periodType = args.period_type as string
const year = Number(args.year)
const period = Number(args.period)
@@ -1078,7 +1082,7 @@ const tools: McpTool[] = [
const { data: lines, error } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(entry_date, status, user_id)')
.eq('journal_entries.company_id', userId)
.eq('journal_entries.company_id', companyId)
.in('journal_entries.status', ['posted', 'reversed'])
.gte('journal_entries.entry_date', startDate)
.lte('journal_entries.entry_date', endDate)
@@ -1175,14 +1179,14 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
let periodId = args.period_id as string | undefined
if (!periodId) {
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', userId)
.eq('company_id', companyId)
.order('period_start', { ascending: false })
.limit(1)
.single()
@@ -1198,7 +1202,7 @@ const tools: McpTool[] = [
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', periodId)
.eq('company_id', userId)
.eq('company_id', companyId)
.single()
if (!period) throw new Error('Fiscal period not found.')
@@ -1206,14 +1210,14 @@ const tools: McpTool[] = [
// Run queries in parallel (same as the KPI API route)
const [incomeStatement, trialBalance, arLedger, monthlyBreakdown, paidInvoices] =
await Promise.all([
generateIncomeStatement(supabase, userId, periodId!),
generateTrialBalance(supabase, userId, periodId!),
generateARLedger(supabase, userId),
generateMonthlyBreakdown(supabase, userId, periodId!),
generateIncomeStatement(supabase, companyId, periodId!),
generateTrialBalance(supabase, companyId, periodId!),
generateARLedger(supabase, companyId),
generateMonthlyBreakdown(supabase, companyId, periodId!),
supabase
.from('invoices')
.select('invoice_date, paid_at')
.eq('company_id', userId)
.eq('company_id', companyId)
.eq('status', 'paid')
.not('paid_at', 'is', null),
])
@@ -1285,14 +1289,14 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
let periodId = args.period_id as string | undefined
if (!periodId) {
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', userId)
.eq('company_id', companyId)
.order('period_start', { ascending: false })
.limit(1)
.single()
@@ -1307,12 +1311,12 @@ const tools: McpTool[] = [
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', periodId)
.eq('company_id', userId)
.eq('company_id', companyId)
.single()
if (!period) throw new Error('Fiscal period not found.')
const result = await generateIncomeStatement(supabase, userId, periodId!)
const result = await generateIncomeStatement(supabase, companyId, periodId!)
result.period = { start: period.period_start, end: period.period_end }
return {
@@ -1353,7 +1357,7 @@ const tools: McpTool[] = [
idempotentHint: false,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const invoiceId = args.invoice_id as string
if (!invoiceId) throw new Error('invoice_id is required')
@@ -1361,7 +1365,7 @@ const tools: McpTool[] = [
.from('invoices')
.select('*, customer:customers(*)')
.eq('id', invoiceId)
.eq('company_id', userId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) throw new Error('Invoice not found')
@@ -1371,7 +1375,7 @@ const tools: McpTool[] = [
const paymentDate = (args.payment_date as string) || new Date().toISOString().split('T')[0]
return stagePendingOperation(supabase, userId, 'mark_invoice_paid',
return stagePendingOperation(supabase, companyId, userId, 'mark_invoice_paid',
`Betald: ${invoice.invoice_number} ${invoice.customer?.name || ''} ${invoice.total} ${invoice.currency}`,
{ invoice_id: invoiceId, payment_date: paymentDate },
{
@@ -1415,7 +1419,7 @@ const tools: McpTool[] = [
idempotentHint: false,
openWorldHint: true,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const invoiceId = args.invoice_id as string
if (!invoiceId) throw new Error('invoice_id is required')
@@ -1428,7 +1432,7 @@ const tools: McpTool[] = [
.from('invoices')
.select('*, customer:customers(*)')
.eq('id', invoiceId)
.eq('company_id', userId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) throw new Error('Invoice not found')
@@ -1436,7 +1440,7 @@ const tools: McpTool[] = [
const customer = invoice.customer as Customer
if (!customer.email) throw new Error('Customer has no email address. Update customer details first.')
return stagePendingOperation(supabase, userId, 'send_invoice',
return stagePendingOperation(supabase, companyId, userId, 'send_invoice',
`Skicka: ${invoice.invoice_number} till ${customer.email}`,
{ invoice_id: invoiceId },
{
@@ -1476,7 +1480,7 @@ const tools: McpTool[] = [
idempotentHint: false,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const invoiceId = args.invoice_id as string
if (!invoiceId) throw new Error('invoice_id is required')
@@ -1484,13 +1488,13 @@ const tools: McpTool[] = [
.from('invoices')
.select('*, customer:customers(*)')
.eq('id', invoiceId)
.eq('company_id', userId)
.eq('company_id', companyId)
.single()
if (invoiceError || !invoice) throw new Error('Invoice not found')
if (invoice.status !== 'draft') throw new Error('Only draft invoices can be marked as sent')
return stagePendingOperation(supabase, userId, 'mark_invoice_sent',
return stagePendingOperation(supabase, companyId, userId, 'mark_invoice_sent',
`Markera skickad: ${invoice.invoice_number} ${invoice.customer?.name || ''}`,
{ invoice_id: invoiceId },
{
@@ -1520,11 +1524,11 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, userId, supabase) {
async execute(_args, companyId, userId, supabase) {
const { data, error } = await supabase
.from('suppliers')
.select('id, name, supplier_type, email, phone, org_number, vat_number, default_expense_account, default_payment_terms, default_currency, city, country')
.eq('company_id', userId)
.eq('company_id', companyId)
.order('name', { ascending: true })
if (error) throw new Error(`Database error: ${error.message}`)
@@ -1562,14 +1566,14 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 50), 100)
const status = (args.status as string) || 'all'
let query = supabase
.from('supplier_invoices')
.select('id, supplier_invoice_number, invoice_date, due_date, status, total, total_sek, currency, vat_treatment, remaining_amount, supplier:suppliers(id, name)')
.eq('company_id', userId)
.eq('company_id', companyId)
if (status !== 'all') {
if (status === 'to_pay') {
@@ -1612,13 +1616,13 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 100), 200)
const { data, error } = await supabase
.from('categorization_templates')
.select('id, counterparty_name, counterparty_aliases, debit_account, credit_account, vat_treatment, vat_account, category, line_pattern, occurrence_count, confidence, last_seen_date, source')
.eq('company_id', userId)
.eq('company_id', companyId)
.eq('is_active', true)
.order('occurrence_count', { ascending: false })
.limit(limit)
@@ -1664,7 +1668,7 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const ids = args.transaction_ids as string[]
if (!ids || ids.length === 0) throw new Error('transaction_ids is required (non-empty array)')
const limitedIds = ids.slice(0, 20)
@@ -1673,7 +1677,7 @@ const tools: McpTool[] = [
const { data: transactions, error: txError } = await supabase
.from('transactions')
.select('*')
.eq('company_id', userId)
.eq('company_id', companyId)
.in('id', limitedIds)
if (txError) throw new Error(`Database error: ${txError.message}`)
@@ -1683,7 +1687,7 @@ const tools: McpTool[] = [
const { data: mappingRules } = await supabase
.from('mapping_rules')
.select('*')
.or(`company_id.eq.${userId},company_id.is.null`)
.or(`company_id.eq.${companyId},company_id.is.null`)
.eq('is_active', true)
.order('priority', { ascending: false })
@@ -1691,7 +1695,7 @@ const tools: McpTool[] = [
const { data: historicalTxns } = await supabase
.from('transactions')
.select('category')
.eq('company_id', userId)
.eq('company_id', companyId)
.not('is_business', 'is', null)
.neq('category', 'uncategorized')
.neq('category', 'private')
@@ -1704,7 +1708,7 @@ const tools: McpTool[] = [
// Batch counterparty template matching
const counterpartyMatches = await findCounterpartyTemplatesBatch(
supabase, userId, transactions as Transaction[]
supabase, companyId, transactions as Transaction[]
)
// Generate suggestions per transaction
@@ -1760,14 +1764,14 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const activeOnly = args.active_only !== false
const accountClass = args.account_class as number | undefined
let query = supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class, account_group, account_type, normal_balance, is_active, description')
.eq('company_id', userId)
.eq('company_id', companyId)
.order('sort_order')
if (activeOnly) query = query.eq('is_active', true)
@@ -1804,14 +1808,14 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
let periodId = args.period_id as string | undefined
if (!periodId) {
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', userId)
.eq('company_id', companyId)
.order('period_start', { ascending: false })
.limit(1)
.single()
@@ -1824,12 +1828,12 @@ const tools: McpTool[] = [
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', periodId)
.eq('company_id', userId)
.eq('company_id', companyId)
.single()
if (!period) throw new Error('Fiscal period not found.')
const result = await generateBalanceSheet(supabase, userId, periodId!)
const result = await generateBalanceSheet(supabase, companyId, periodId!)
return {
period_name: period.name,
@@ -1865,14 +1869,14 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
let periodId = args.period_id as string | undefined
if (!periodId) {
const { data: periods } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', userId)
.eq('company_id', companyId)
.order('period_start', { ascending: false })
.limit(1)
.single()
@@ -1884,7 +1888,7 @@ const tools: McpTool[] = [
const accountFrom = args.account_from as string | undefined
const accountTo = args.account_to as string | undefined
return await generateGeneralLedger(supabase, userId, periodId!, accountFrom, accountTo)
return await generateGeneralLedger(supabase, companyId, periodId!, accountFrom, accountTo)
},
},
@@ -1911,9 +1915,9 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const asOfDate = args.as_of_date as string | undefined
return await generateARLedger(supabase, userId, asOfDate)
return await generateARLedger(supabase, companyId, asOfDate)
},
},
@@ -1940,9 +1944,9 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const asOfDate = args.as_of_date as string | undefined
return await generateSupplierLedger(supabase, userId, asOfDate)
return await generateSupplierLedger(supabase, companyId, asOfDate)
},
},
@@ -1978,7 +1982,7 @@ const tools: McpTool[] = [
idempotentHint: false,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const transactionId = args.transaction_id as string
const invoiceId = args.invoice_id as string
if (!transactionId || !invoiceId) throw new Error('transaction_id and invoice_id are required')
@@ -1988,7 +1992,7 @@ const tools: McpTool[] = [
.from('transactions')
.select('id, description, merchant_name, amount, currency, invoice_id')
.eq('id', transactionId)
.eq('company_id', userId)
.eq('company_id', companyId)
.single()
if (txError || !transaction) throw new Error('Transaction not found')
@@ -1999,7 +2003,7 @@ const tools: McpTool[] = [
.from('invoices')
.select('*, customer:customers(*)')
.eq('id', invoiceId)
.eq('company_id', userId)
.eq('company_id', companyId)
.single()
if (invError || !invoice) throw new Error('Invoice not found')
@@ -2009,7 +2013,7 @@ const tools: McpTool[] = [
const txDesc = transaction.merchant_name || transaction.description || transactionId
return stagePendingOperation(supabase, userId, 'match_transaction_invoice',
return stagePendingOperation(supabase, companyId, userId, 'match_transaction_invoice',
`Matcha: ${txDesc} → ${invoice.invoice_number}`,
{ transaction_id: transactionId, invoice_id: invoiceId },
{
@@ -2042,11 +2046,11 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(_args, userId, supabase) {
async execute(_args, companyId, userId, supabase) {
const { data, error } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, status')
.eq('company_id', userId)
.eq('company_id', companyId)
.order('period_start', { ascending: false })
if (error) throw new Error(`Database error: ${error.message}`)
@@ -2081,10 +2085,277 @@ const tools: McpTool[] = [
idempotentHint: true,
openWorldHint: false,
},
async execute(args, userId, supabase) {
async execute(args, companyId, userId, supabase) {
const dateFrom = args.date_from as string | undefined
const dateTo = args.date_to as string | undefined
return await getReconciliationStatus(supabase, userId, dateFrom, dateTo)
return await getReconciliationStatus(supabase, companyId, dateFrom, dateTo)
},
},
// ── Document Inbox Tools ────────────────────────────────────
{
name: 'gnubok_upload_document',
description:
'Upload a document (invoice, receipt) to the inbox for AI classification.\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).',
inputSchema: {
type: 'object',
properties: {
file_name: { type: 'string', description: 'File name with extension (e.g. "faktura.pdf")' },
file_content_base64: { type: 'string', description: 'Base64-encoded file content' },
mime_type: { type: 'string', description: 'MIME type (optional, inferred from extension)' },
},
required: ['file_name', 'file_content_base64'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const fileName = args.file_name as string
const base64Content = args.file_content_base64 as string
let mimeType = args.mime_type as string | undefined
if (!mimeType) {
const ext = fileName.split('.').pop()?.toLowerCase()
const mimeMap: Record<string, string> = {
pdf: 'application/pdf',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
heic: 'image/heic',
webp: 'image/webp',
}
mimeType = ext ? mimeMap[ext] : undefined
if (!mimeType) throw new Error(`Cannot infer MIME type from extension: .${ext}`)
}
const allowedMimeTypes = new Set([
'application/pdf', 'image/jpeg', 'image/png', 'image/heic', 'image/webp',
])
if (!allowedMimeTypes.has(mimeType)) {
throw new Error(`Unsupported file type: ${mimeType}. Allowed: PDF, JPEG, PNG, HEIC, WebP`)
}
const buffer = Buffer.from(base64Content, 'base64')
if (buffer.byteLength > MAX_DOCUMENT_SIZE) {
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 (invoice-inbox extension may not be enabled)
let classificationResult
let classificationError: string | null = null
try {
const { classifyDocument } = await import('@/extensions/general/invoice-inbox/lib/classify-document')
classificationResult = await classifyDocument({
fileBuffer: buffer,
mimeType,
fileName,
})
} catch (err) {
classificationError = err instanceof Error ? err.message : 'Classification failed'
}
// 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
}
}
// 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',
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,
matched_supplier_id: matchedSupplierId,
error_message: classificationError,
})
.select('id, status, document_type, confidence')
.single()
if (inboxError) throw new Error(`Failed to create inbox item: ${inboxError.message}`)
return {
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,
}
},
},
{
name: 'gnubok_list_inbox_items',
description:
'List document inbox items (classified invoices, receipts, etc.).\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' +
' - 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' +
' count: number }',
inputSchema: {
type: 'object',
properties: {
status: {
type: 'string',
enum: ['pending', 'processing', 'ready', 'confirmed', 'rejected', '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)',
},
},
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
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')
.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') {
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,
email_from: item.email_from,
email_subject: item.email_subject,
error_message: item.error_message,
}
})
return { items, count: items.length }
},
},
{
name: 'gnubok_get_inbox_item',
description:
'Get a single document inbox item with full extracted data.\n\n' +
'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.',
inputSchema: {
type: 'object',
properties: {
inbox_item_id: { type: 'string', description: 'UUID of the inbox item' },
},
required: ['inbox_item_id'],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
async execute(args, companyId, userId, supabase) {
const id = args.inbox_item_id as string
const { data, error } = await supabase
.from('invoice_inbox_items')
.select('*, document_attachments(id, file_name, mime_type, file_size_bytes, created_at)')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (error) throw new Error(`Database error: ${error.message}`)
if (!data) throw new Error('Inbox item not found')
return data
},
},
]
@@ -2248,7 +2519,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
}
try {
const result = await tool.execute(toolArgs, companyId, supabase)
const result = await tool.execute(toolArgs, companyId, userId, supabase)
const response: Record<string, unknown> = {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
}