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:
co-authored by
Claude Opus 4.6
parent
1dcec370b6
commit
c4a6d16e94
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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) }],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user