feat: MCP Apps receipt matcher widget (#84)

* feat: add MCP Apps receipt matcher widget for inline receipt-to-transaction matching

Adds an interactive HTML widget that renders inline in Claude Desktop via MCP Apps
(SEP-1865), letting users drag receipts onto uncategorized transactions to categorize,
book, and attach documents in one flow without leaving the conversation.

- Extract categorizeTransactionCore() shared by both categorization tools
- Add gnubok_receipt_matcher tool with _meta.ui for MCP Apps widget rendering
- Add gnubok_categorize_with_receipt tool (categorize + upload document)
- Add resources/list and resources/read protocol handlers
- Self-contained widget HTML with MCP Apps bridge, drag-and-drop, client-side resize

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

* fix: address review feedback — buffer slice, MIME type, HTML escaping

- Fix Buffer.buffer slab pool issue: slice to exact byte range to avoid
  corrupted uploads for small files (≤4096 bytes)
- Update MIME type to image/jpeg after canvas re-encode (was keeping
  original file.type, causing content-type mismatch in DB)
- Escape tx.date through esc() for consistent innerHTML sanitization
- Remove unused `reset` variable in tests

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-22 21:13:18 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 6b51d13ba7
commit 7571b85d1a
3 changed files with 1197 additions and 148 deletions
@@ -0,0 +1,452 @@
/**
* Tests for receipt matcher tools and MCP Apps protocol additions
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createQueuedMockSupabase,
makeTransaction,
makeDocumentAttachment,
} from '@/tests/helpers'
// ── Mocks ──
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(),
createServiceClient: vi.fn().mockResolvedValue({
storage: {
getBucket: vi.fn().mockResolvedValue({ data: { name: 'documents' } }),
},
}),
}))
vi.mock('@/lib/auth/api-keys', () => ({
extractBearerToken: vi.fn().mockReturnValue('test-token'),
validateApiKey: vi.fn().mockResolvedValue({ userId: 'user-1' }),
createServiceClientNoCookies: vi.fn(),
}))
vi.mock('@/lib/bookkeeping/category-mapping', () => ({
buildMappingResultFromCategory: vi.fn().mockReturnValue({
debit_account: '6110',
credit_account: '1930',
vat_lines: [{ account_number: '2641', amount: 74.75 }],
}),
}))
vi.mock('@/lib/bookkeeping/transaction-entries', () => ({
createTransactionJournalEntry: vi.fn().mockResolvedValue({ id: 'je-123' }),
}))
vi.mock('@/lib/events/bus', () => ({
eventBus: { emit: vi.fn().mockResolvedValue(undefined), clear: vi.fn() },
}))
const mockUploadDocument = vi.fn()
vi.mock('@/lib/core/documents/document-service', () => ({
uploadDocument: (...args: unknown[]) => mockUploadDocument(...args),
}))
vi.mock('@/lib/invoices/vat-rules', () => ({
getVatRules: vi.fn(),
getAvailableVatRates: vi.fn(),
}))
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: vi.fn(),
convertToSEK: vi.fn(),
}))
vi.mock('@/lib/reports/income-statement', () => ({
generateIncomeStatement: vi.fn(),
}))
vi.mock('@/lib/reports/kpi', () => ({
calculateGrossMargin: vi.fn(),
calculateCashPosition: vi.fn(),
calculateExpenseRatio: vi.fn(),
calculateAvgPaymentDays: vi.fn(),
}))
vi.mock('@/lib/reports/trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
vi.mock('@/lib/reports/ar-ledger', () => ({
generateARLedger: vi.fn(),
}))
vi.mock('@/lib/reports/monthly-breakdown', () => ({
generateMonthlyBreakdown: vi.fn(),
}))
import { handleMcpRequest } from '../server'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { eventBus } from '@/lib/events/bus'
// Helper: make a JSON-RPC request
function mcpRequest(method: string, params?: Record<string, unknown>, id: number | string = 1): Request {
return new Request('http://localhost:3000/api/extensions/ext/mcp-server/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer test-token' },
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
})
}
async function parseResult(response: Response) {
const json = await response.json()
return json.result
}
describe('MCP Receipt Matcher', () => {
let supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']
let enqueueMany: ReturnType<typeof createQueuedMockSupabase>['enqueueMany']
beforeEach(() => {
vi.clearAllMocks()
;(eventBus as { clear: ReturnType<typeof vi.fn> }).clear()
const mock = createQueuedMockSupabase()
supabase = mock.supabase
enqueueMany = mock.enqueueMany
vi.mocked(createServiceClientNoCookies).mockReturnValue(supabase as never)
})
// ── Protocol: initialize includes resources capability ──
describe('initialize', () => {
it('returns resources capability', async () => {
const res = await handleMcpRequest(mcpRequest('initialize', { protocolVersion: '2025-03-26' }))
const result = await parseResult(res)
expect(result.capabilities.tools).toEqual({ listChanged: false })
expect(result.capabilities.resources).toEqual({ listChanged: false })
})
})
// ── Protocol: tools/list includes _meta for receipt matcher ──
describe('tools/list', () => {
it('includes _meta.ui for gnubok_receipt_matcher', async () => {
const res = await handleMcpRequest(mcpRequest('tools/list'))
const result = await parseResult(res)
const receiptTool = result.tools.find((t: { name: string }) => t.name === 'gnubok_receipt_matcher')
expect(receiptTool).toBeDefined()
expect(receiptTool._meta).toEqual({
ui: { resourceUri: 'ui://receipt-matcher/app.html' },
})
})
it('includes gnubok_categorize_with_receipt', async () => {
const res = await handleMcpRequest(mcpRequest('tools/list'))
const result = await parseResult(res)
const tool = result.tools.find((t: { name: string }) => t.name === 'gnubok_categorize_with_receipt')
expect(tool).toBeDefined()
expect(tool.inputSchema.required).toContain('file_data')
expect(tool.inputSchema.required).toContain('filename')
expect(tool.inputSchema.required).toContain('mime_type')
})
it('does not include _meta for tools without it', async () => {
const res = await handleMcpRequest(mcpRequest('tools/list'))
const result = await parseResult(res)
const categorizeTool = result.tools.find(
(t: { name: string }) => t.name === 'gnubok_categorize_transaction'
)
expect(categorizeTool).toBeDefined()
expect(categorizeTool._meta).toBeUndefined()
})
})
// ── Protocol: resources/list ──
describe('resources/list', () => {
it('returns the receipt-matcher resource', async () => {
const res = await handleMcpRequest(mcpRequest('resources/list'))
const result = await parseResult(res)
expect(result.resources).toHaveLength(1)
expect(result.resources[0]).toEqual({
uri: 'ui://receipt-matcher/app.html',
name: 'Receipt Matcher',
description: 'Interactive widget for matching receipts to uncategorized transactions',
mimeType: 'text/html;profile=mcp-app',
})
})
})
// ── Protocol: resources/read ──
describe('resources/read', () => {
it('returns HTML for the receipt matcher', async () => {
const res = await handleMcpRequest(
mcpRequest('resources/read', { uri: 'ui://receipt-matcher/app.html' })
)
const result = await parseResult(res)
expect(result.contents).toHaveLength(1)
expect(result.contents[0].uri).toBe('ui://receipt-matcher/app.html')
expect(result.contents[0].mimeType).toBe('text/html;profile=mcp-app')
expect(result.contents[0].text).toContain('<!DOCTYPE html>')
expect(result.contents[0].text).toContain('Kvittomatchning')
})
it('returns error for unknown resource URI', async () => {
const res = await handleMcpRequest(
mcpRequest('resources/read', { uri: 'ui://unknown/thing' })
)
const json = await res.json()
expect(json.error).toBeDefined()
expect(json.error.code).toBe(-32602)
expect(json.error.message).toContain('Resource not found')
})
})
// ── gnubok_receipt_matcher tool ──
describe('gnubok_receipt_matcher', () => {
it('returns uncategorized transactions with categories and vat_treatments', async () => {
const tx1 = makeTransaction({ id: 'tx-1', description: 'ICA', amount: -150 })
const tx2 = makeTransaction({ id: 'tx-2', description: 'Consulting', amount: 15000 })
enqueueMany([
{ data: [tx1, tx2], error: null },
])
const res = await handleMcpRequest(
mcpRequest('tools/call', { name: 'gnubok_receipt_matcher', arguments: {} })
)
const result = await parseResult(res)
// Should have structuredContent for MCP Apps
expect(result.structuredContent).toBeDefined()
expect(result.structuredContent.transactions).toHaveLength(2)
expect(result.structuredContent.categories).toContain('expense_office')
expect(result.structuredContent.vat_treatments).toContain('standard_25')
// Should also have regular content
expect(result.content).toHaveLength(1)
expect(result.content[0].type).toBe('text')
})
it('returns empty array when no uncategorized transactions', async () => {
enqueueMany([{ data: [], error: null }])
const res = await handleMcpRequest(
mcpRequest('tools/call', { name: 'gnubok_receipt_matcher', arguments: {} })
)
const result = await parseResult(res)
expect(result.structuredContent.transactions).toHaveLength(0)
})
})
// ── gnubok_categorize_with_receipt tool ──
describe('gnubok_categorize_with_receipt', () => {
const validDataUri = 'data:image/jpeg;base64,/9j/4AAQSkZJRg=='
const tx = makeTransaction({
id: 'tx-1',
description: 'Kontorsmaterial',
amount: -299,
currency: 'SEK',
})
it('categorizes and uploads receipt document', async () => {
const doc = makeDocumentAttachment({ id: 'doc-1', journal_entry_id: 'je-123' })
mockUploadDocument.mockResolvedValue(doc)
enqueueMany([
{ data: tx, error: null }, // fetch transaction
{ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null }, // company_settings
{ data: null, error: null }, // fiscal_periods upsert
{ data: null, error: null }, // transaction update
])
const res = await handleMcpRequest(
mcpRequest('tools/call', {
name: 'gnubok_categorize_with_receipt',
arguments: {
transaction_id: 'tx-1',
category: 'expense_office',
vat_treatment: 'standard_25',
file_data: validDataUri,
filename: 'receipt.jpg',
mime_type: 'image/jpeg',
},
})
)
const result = await parseResult(res)
const parsed = JSON.parse(result.content[0].text)
expect(parsed.success).toBe(true)
expect(parsed.journal_entry_created).toBe(true)
expect(parsed.journal_entry_id).toBe('je-123')
expect(parsed.document_id).toBe('doc-1')
expect(parsed.debit_account).toBe('6110')
expect(parsed.credit_account).toBe('1930')
// Verify uploadDocument was called with correct args
expect(mockUploadDocument).toHaveBeenCalledWith(
expect.anything(),
'user-1',
expect.objectContaining({
name: 'receipt.jpg',
type: 'image/jpeg',
}),
expect.objectContaining({
upload_source: 'api',
journal_entry_id: 'je-123',
})
)
})
it('rejects invalid data URI', async () => {
const res = await handleMcpRequest(
mcpRequest('tools/call', {
name: 'gnubok_categorize_with_receipt',
arguments: {
transaction_id: 'tx-1',
category: 'expense_office',
file_data: 'not-a-data-uri',
filename: 'receipt.jpg',
mime_type: 'image/jpeg',
},
})
)
const result = await parseResult(res)
const parsed = JSON.parse(result.content[0].text)
expect(parsed.error).toContain('Invalid file_data')
expect(result.isError).toBe(true)
})
it('returns document_error when upload fails', async () => {
mockUploadDocument.mockRejectedValue(new Error('Storage full'))
enqueueMany([
{ data: tx, error: null }, // fetch transaction
{ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null },
{ data: null, error: null }, // fiscal_periods upsert
{ data: null, error: null }, // transaction update
])
const res = await handleMcpRequest(
mcpRequest('tools/call', {
name: 'gnubok_categorize_with_receipt',
arguments: {
transaction_id: 'tx-1',
category: 'expense_office',
file_data: validDataUri,
filename: 'receipt.jpg',
mime_type: 'image/jpeg',
},
})
)
const result = await parseResult(res)
const parsed = JSON.parse(result.content[0].text)
expect(parsed.success).toBe(true)
expect(parsed.journal_entry_created).toBe(true)
expect(parsed.document_id).toBeNull()
expect(parsed.document_error).toBe('Storage full')
})
it('skips document upload when transaction already has journal entry', async () => {
const bookedTx = makeTransaction({
id: 'tx-2',
journal_entry_id: 'je-existing',
amount: -100,
})
enqueueMany([
{ data: bookedTx, error: null }, // fetch transaction (already booked)
])
const res = await handleMcpRequest(
mcpRequest('tools/call', {
name: 'gnubok_categorize_with_receipt',
arguments: {
transaction_id: 'tx-2',
category: 'expense_office',
file_data: validDataUri,
filename: 'receipt.jpg',
mime_type: 'image/jpeg',
},
})
)
const result = await parseResult(res)
const parsed = JSON.parse(result.content[0].text)
// Should not have uploaded — already booked
expect(mockUploadDocument).not.toHaveBeenCalled()
expect(parsed.document_id).toBeNull()
})
})
// ── gnubok_categorize_transaction still works after refactor ──
describe('gnubok_categorize_transaction (refactored)', () => {
it('returns result without transaction field', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: -500 })
enqueueMany([
{ data: tx, error: null }, // fetch transaction
{ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null },
{ data: null, error: null }, // fiscal_periods upsert
{ data: null, error: null }, // transaction update
])
const res = await handleMcpRequest(
mcpRequest('tools/call', {
name: 'gnubok_categorize_transaction',
arguments: { transaction_id: 'tx-1', category: 'expense_office' },
})
)
const result = await parseResult(res)
const parsed = JSON.parse(result.content[0].text)
expect(parsed.success).toBe(true)
expect(parsed.journal_entry_created).toBe(true)
expect(parsed.category).toBe('expense_office')
expect(parsed.transaction).toBeUndefined()
})
})
// ── tools/call structuredContent ──
describe('tools/call structuredContent', () => {
it('includes structuredContent for tools with _meta.ui', async () => {
enqueueMany([{ data: [], error: null }])
const res = await handleMcpRequest(
mcpRequest('tools/call', { name: 'gnubok_receipt_matcher', arguments: {} })
)
const result = await parseResult(res)
expect(result.structuredContent).toBeDefined()
expect(result.content).toBeDefined()
})
it('does not include structuredContent for regular tools', async () => {
const tx = makeTransaction({ id: 'tx-1', amount: -500 })
enqueueMany([
{ data: tx, error: null },
{ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null },
{ data: null, error: null },
{ data: null, error: null },
])
const res = await handleMcpRequest(
mcpRequest('tools/call', {
name: 'gnubok_categorize_transaction',
arguments: { transaction_id: 'tx-1', category: 'expense_office' },
})
)
const result = await parseResult(res)
expect(result.structuredContent).toBeUndefined()
expect(result.content).toBeDefined()
})
})
})
+362 -148
View File
@@ -10,6 +10,7 @@ import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-ent
import { eventBus } from '@/lib/events/bus'
import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import {
calculateGrossMargin,
@@ -20,9 +21,10 @@ import {
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateARLedger } from '@/lib/reports/ar-ledger'
import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
import { RECEIPT_MATCHER_HTML } from './widget-html'
// 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 } from '@/types'
import type { Transaction, TransactionCategory, EntityType, VatTreatment, Invoice, Currency, DocumentUploadSource } from '@/types'
// ── JSON-RPC types ───────────────────────────────────────────
@@ -54,6 +56,7 @@ interface McpTool {
description: string
inputSchema: Record<string, unknown>
annotations: McpToolAnnotations
_meta?: { ui: { resourceUri: string } }
execute: (
args: Record<string, unknown>,
userId: string,
@@ -76,6 +79,180 @@ const VALID_VAT_TREATMENTS = [
'standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt',
] as const
// ── Shared categorization logic ──────────────────────────────
async function categorizeTransactionCore(
txId: string,
category: TransactionCategory,
vatTreatment: VatTreatment | undefined,
userId: string,
supabase: SupabaseClient
): Promise<{
success: boolean
journal_entry_created: boolean
journal_entry_id: string | null
journal_entry_error: string | null
category: string
debit_account: string
credit_account: string
amount: number
currency: string
transaction: Transaction
}> {
// Validate category
if (!VALID_CATEGORIES.includes(category as typeof VALID_CATEGORIES[number])) {
throw new Error(
`Invalid category "${category}". Valid categories: ${VALID_CATEGORIES.join(', ')}`
)
}
if (vatTreatment && !VALID_VAT_TREATMENTS.includes(vatTreatment as typeof VALID_VAT_TREATMENTS[number])) {
throw new Error(
`Invalid vat_treatment "${vatTreatment}". Valid: ${VALID_VAT_TREATMENTS.join(', ')}`
)
}
const isBusiness = category !== 'private'
// Fetch the transaction
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('*')
.eq('id', txId)
.eq('user_id', userId)
.single()
if (fetchError || !transaction) {
throw new Error('Transaction not found. Check the transaction_id is correct.')
}
if (transaction.journal_entry_id) {
return {
success: true,
journal_entry_created: false,
journal_entry_id: transaction.journal_entry_id,
journal_entry_error: 'Transaction already has a journal entry — use gnubok_list_uncategorized_transactions to find unbooked ones.',
category,
debit_account: '',
credit_account: '',
amount: Math.abs(transaction.amount),
currency: transaction.currency,
transaction: transaction as Transaction,
}
}
// Get entity type
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, fiscal_year_start_month')
.eq('user_id', userId)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Build mapping
const mappingResult = buildMappingResultFromCategory(
category,
transaction as Transaction,
isBusiness,
entityType,
vatTreatment
)
if (!mappingResult.debit_account || !mappingResult.credit_account) {
throw new Error(
`No account mapping for category "${category}" with entity type "${entityType}". ` +
'Try a different category or check your chart of accounts.'
)
}
// Ensure fiscal period exists
const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1
const txDate = new Date(transaction.date)
const txMonth = txDate.getMonth() + 1
const txYear = txDate.getFullYear()
let periodStartYear: number
if (fiscalYearStartMonth === 1) {
periodStartYear = txYear
} else if (txMonth >= fiscalYearStartMonth) {
periodStartYear = txYear
} else {
periodStartYear = txYear - 1
}
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
const periodStart = `${periodStartYear}-${startMonth}-01`
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
const lastDay = new Date(endYear, endMonth, 0).getDate()
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
const periodName = fiscalYearStartMonth === 1
? `Räkenskapsår ${periodStartYear}`
: `Räkenskapsår ${periodStartYear}/${endYear}`
await supabase
.from('fiscal_periods')
.upsert(
{ user_id: userId, name: periodName, period_start: periodStart, period_end: periodEnd },
{ onConflict: 'user_id,period_start,period_end' }
)
// Create journal entry
let journalEntryId: string | null = null
let journalEntryError: string | null = null
try {
const journalEntry = await createTransactionJournalEntry(
supabase,
userId,
transaction as Transaction,
mappingResult
)
if (journalEntry) {
journalEntryId = journalEntry.id
}
} catch (err) {
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
}
// Update transaction
await supabase
.from('transactions')
.update({
is_business: isBusiness,
category,
journal_entry_id: journalEntryId,
})
.eq('id', txId)
// Emit event so extensions (mapping rules, etc.) can react
await eventBus.emit({
type: 'transaction.categorized',
payload: {
transaction: transaction as Transaction,
account: mappingResult.debit_account,
taxCode: mappingResult.vat_lines[0]?.account_number || '',
userId,
},
})
return {
success: true,
journal_entry_created: !!journalEntryId,
journal_entry_id: journalEntryId,
journal_entry_error: journalEntryError,
category,
debit_account: mappingResult.debit_account,
credit_account: mappingResult.credit_account,
amount: Math.abs(transaction.amount),
currency: transaction.currency,
transaction: transaction as Transaction,
}
}
// ── Tools ────────────────────────────────────────────────────
const tools: McpTool[] = [
@@ -200,154 +377,153 @@ const tools: McpTool[] = [
openWorldHint: false,
},
async execute(args, userId, supabase) {
const txId = args.transaction_id as string
const category = args.category as TransactionCategory
const vatTreatment = args.vat_treatment as VatTreatment | undefined
// Validate category
if (!VALID_CATEGORIES.includes(category as typeof VALID_CATEGORIES[number])) {
throw new Error(
`Invalid category "${category}". Valid categories: ${VALID_CATEGORIES.join(', ')}`
)
}
if (vatTreatment && !VALID_VAT_TREATMENTS.includes(vatTreatment as typeof VALID_VAT_TREATMENTS[number])) {
throw new Error(
`Invalid vat_treatment "${vatTreatment}". Valid: ${VALID_VAT_TREATMENTS.join(', ')}`
)
}
const isBusiness = category !== 'private'
// Fetch the transaction
const { data: transaction, error: fetchError } = await supabase
.from('transactions')
.select('*')
.eq('id', txId)
.eq('user_id', userId)
.single()
if (fetchError || !transaction) {
throw new Error('Transaction not found. Check the transaction_id is correct.')
}
if (transaction.journal_entry_id) {
return {
success: true,
journal_entry_created: false,
message: 'Transaction already has a journal entry — use gnubok_list_uncategorized_transactions to find unboooked ones.',
journal_entry_id: transaction.journal_entry_id,
}
}
// Get entity type
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type, fiscal_year_start_month')
.eq('user_id', userId)
.single()
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Build mapping
const mappingResult = buildMappingResultFromCategory(
category,
transaction as Transaction,
isBusiness,
entityType,
vatTreatment
const result = await categorizeTransactionCore(
args.transaction_id as string,
args.category as TransactionCategory,
args.vat_treatment as VatTreatment | undefined,
userId,
supabase
)
// Strip internal transaction field from public response
const { transaction: _tx, ...publicResult } = result
return publicResult
},
},
if (!mappingResult.debit_account || !mappingResult.credit_account) {
throw new Error(
`No account mapping for category "${category}" with entity type "${entityType}". ` +
'Try a different category or check your chart of accounts.'
)
}
// ── Receipt matcher tool ──────────────────────────────────────
// Ensure fiscal period exists
const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1
const txDate = new Date(transaction.date)
const txMonth = txDate.getMonth() + 1
const txYear = txDate.getFullYear()
let periodStartYear: number
if (fiscalYearStartMonth === 1) {
periodStartYear = txYear
} else if (txMonth >= fiscalYearStartMonth) {
periodStartYear = txYear
} else {
periodStartYear = txYear - 1
}
const startMonth = String(fiscalYearStartMonth).padStart(2, '0')
const periodStart = `${periodStartYear}-${startMonth}-01`
const endYear = fiscalYearStartMonth === 1 ? periodStartYear : periodStartYear + 1
const endMonth = fiscalYearStartMonth === 1 ? 12 : fiscalYearStartMonth - 1
const lastDay = new Date(endYear, endMonth, 0).getDate()
const periodEnd = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`
const periodName = fiscalYearStartMonth === 1
? `Räkenskapsår ${periodStartYear}`
: `Räkenskapsår ${periodStartYear}/${endYear}`
await supabase
.from('fiscal_periods')
.upsert(
{ user_id: userId, name: periodName, period_start: periodStart, period_end: periodEnd },
{ onConflict: 'user_id,period_start,period_end' }
)
// Create journal entry
let journalEntryId: string | null = null
let journalEntryError: string | null = null
try {
const journalEntry = await createTransactionJournalEntry(
supabase,
userId,
transaction as Transaction,
mappingResult
)
if (journalEntry) {
journalEntryId = journalEntry.id
}
} catch (err) {
journalEntryError = err instanceof Error ? err.message : 'Unknown error'
}
// Update transaction
await supabase
.from('transactions')
.update({
is_business: isBusiness,
category,
journal_entry_id: journalEntryId,
})
.eq('id', txId)
// Emit event so extensions (mapping rules, etc.) can react
await eventBus.emit({
type: 'transaction.categorized',
payload: {
transaction: transaction as Transaction,
account: mappingResult.debit_account,
taxCode: mappingResult.vat_lines[0]?.account_number || '',
userId,
{
name: 'gnubok_receipt_matcher',
description:
'Open the receipt matcher widget. Shows uncategorized transactions with drag-and-drop ' +
'receipt attachment. Renders an interactive UI inline in the conversation.\n\n' +
'Args:\n' +
' - limit (number, optional): Max transactions to show, 1–50 (default: 20)\n\n' +
'Returns JSON:\n' +
' { transactions: [...], categories: [...], vat_treatments: [...] }\n\n' +
'Examples:\n' +
' - "Match my receipts" → call with no args\n' +
' - "Open receipt matcher" → call with no args',
inputSchema: {
type: 'object',
properties: {
limit: {
type: 'number',
description: 'Max transactions to show, 1–50 (default 20)',
},
})
},
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
_meta: { ui: { resourceUri: 'ui://receipt-matcher/app.html' } },
async execute(args, userId, supabase) {
const limit = Math.min(Math.max(1, Number(args.limit) || 20), 50)
const { data, error } = await supabase
.from('transactions')
.select(
'id, date, description, amount, currency, merchant_name, reference, is_business, category'
)
.eq('user_id', userId)
.is('journal_entry_id', null)
.order('date', { ascending: false })
.limit(limit)
if (error) throw new Error(`Database error: ${error.message}`)
return {
success: true,
journal_entry_created: !!journalEntryId,
journal_entry_id: journalEntryId,
journal_entry_error: journalEntryError,
category,
debit_account: mappingResult.debit_account,
credit_account: mappingResult.credit_account,
amount: Math.abs(transaction.amount),
currency: transaction.currency,
transactions: data ?? [],
categories: [...VALID_CATEGORIES],
vat_treatments: [...VALID_VAT_TREATMENTS],
}
},
},
{
name: 'gnubok_categorize_with_receipt',
description:
'Categorize a transaction and attach a receipt document in one operation. ' +
'Called by the receipt matcher widget — not typically used directly.\n\n' +
'Args:\n' +
' - transaction_id (string, required): UUID of the transaction\n' +
' - category (string, required): One of: ' + VALID_CATEGORIES.join(', ') + '\n' +
' - vat_treatment (string, optional): One of: ' + VALID_VAT_TREATMENTS.join(', ') + '\n' +
' - file_data (string, required): Data URI of the receipt file\n' +
' - filename (string, required): Original filename\n' +
' - mime_type (string, required): MIME type (image/jpeg, image/png, application/pdf)\n\n' +
'Returns JSON:\n' +
' { success: boolean, journal_entry_created: boolean, journal_entry_id?: string,\n' +
' document_id?: string, category: string, debit_account: string, credit_account: string }',
inputSchema: {
type: 'object',
properties: {
transaction_id: { type: 'string', description: 'UUID of the transaction' },
category: { type: 'string', description: 'Transaction category', enum: [...VALID_CATEGORIES] },
vat_treatment: { type: 'string', description: 'VAT treatment override', enum: [...VALID_VAT_TREATMENTS] },
file_data: { type: 'string', description: 'Data URI of the receipt (e.g. data:image/jpeg;base64,...)' },
filename: { type: 'string', description: 'Original filename' },
mime_type: { type: 'string', description: 'MIME type' },
},
required: ['transaction_id', 'category', 'file_data', 'filename', 'mime_type'],
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
},
async execute(args, userId, supabase) {
const fileData = args.file_data as string
const filename = args.filename as string
const mimeType = args.mime_type as string
// Validate data URI
const commaIdx = fileData.indexOf(',')
if (!fileData.startsWith('data:') || commaIdx === -1) {
throw new Error('Invalid file_data: expected a data URI (data:<mime>;base64,...)')
}
const base64 = fileData.slice(commaIdx + 1)
const buffer = Buffer.from(base64, 'base64')
// Categorize transaction
const result = await categorizeTransactionCore(
args.transaction_id as string,
args.category as TransactionCategory,
args.vat_treatment as VatTreatment | undefined,
userId,
supabase
)
// Upload document and link to journal entry
let documentId: string | null = null
let documentError: string | null = null
if (result.journal_entry_created && result.journal_entry_id) {
try {
const doc = await uploadDocument(supabase, userId, {
name: filename,
buffer: buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength) as ArrayBuffer,
type: mimeType,
}, {
upload_source: 'api' as DocumentUploadSource,
journal_entry_id: result.journal_entry_id,
})
documentId = doc.id
} catch (err) {
documentError = err instanceof Error ? err.message : 'Document upload failed'
}
}
const { transaction: _tx, ...publicResult } = result
return {
...publicResult,
document_id: documentId,
document_error: documentError,
}
},
},
@@ -1303,6 +1479,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
protocolVersion: negotiatedVersion,
capabilities: {
tools: { listChanged: false },
resources: { listChanged: false },
},
serverInfo: SERVER_INFO,
instructions: 'gnubok — Swedish bookkeeping via conversation. List transactions, categorize, create invoices, view reports.',
@@ -1325,6 +1502,7 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
description: t.description,
inputSchema: t.inputSchema,
annotations: t.annotations,
...(t._meta ? { _meta: t._meta } : {}),
})),
})
)
@@ -1346,11 +1524,13 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
try {
const result = await tool.execute(toolArgs, userId, supabase)
return NextResponse.json(
jsonRpc(id ?? null, {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
})
)
const response: Record<string, unknown> = {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
}
if (tool._meta?.ui) {
response.structuredContent = result
}
return NextResponse.json(jsonRpc(id ?? null, response))
} catch (err) {
const message = err instanceof Error ? err.message : 'Tool execution failed'
return NextResponse.json(
@@ -1362,6 +1542,40 @@ export async function handleMcpRequest(request: Request): Promise<Response> {
}
}
case 'resources/list':
return NextResponse.json(
jsonRpc(id ?? null, {
resources: [
{
uri: 'ui://receipt-matcher/app.html',
name: 'Receipt Matcher',
description: 'Interactive widget for matching receipts to uncategorized transactions',
mimeType: 'text/html;profile=mcp-app',
},
],
})
)
case 'resources/read': {
const uri = (params as Record<string, unknown>)?.uri as string
if (uri === 'ui://receipt-matcher/app.html') {
return NextResponse.json(
jsonRpc(id ?? null, {
contents: [
{
uri,
mimeType: 'text/html;profile=mcp-app',
text: RECEIPT_MATCHER_HTML,
},
],
})
)
}
return NextResponse.json(
jsonRpcError(id ?? null, -32602, `Resource not found: "${uri}"`)
)
}
default:
return NextResponse.json(
jsonRpcError(id ?? null, -32601, `Method not found: "${method}"`)
@@ -0,0 +1,383 @@
/**
* Receipt Matcher Widget — MCP Apps inline HTML
*
* Self-contained HTML document rendered in an iframe by MCP Apps hosts
* (Claude Desktop, etc.). Communicates exclusively via postMessage
* (JSON-RPC 2.0 over the MCP Apps protocol). No fetch() calls.
*/
export const RECEIPT_MATCHER_HTML = `<!DOCTYPE html>
<html lang="sv">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kvittomatchning — gnubok</title>
<style>
:root {
--bg: #fafafa;
--surface: #ffffff;
--border: rgba(0,0,0,0.1);
--text: #1a1a1a;
--text-muted: #6b6b6b;
--success: #5a7a5a;
--success-bg: rgba(90,122,90,0.08);
--error: #b35a3a;
--error-bg: rgba(179,90,58,0.08);
--accent: #3b3b3b;
--drop-bg: rgba(0,0,0,0.03);
--drop-active: rgba(90,122,90,0.12);
}
.dark {
--bg: #161616;
--surface: #1e1e1e;
--border: rgba(255,255,255,0.1);
--text: #e5e5e5;
--text-muted: #999;
--success: #7aab7a;
--success-bg: rgba(122,171,122,0.1);
--error: #d4816a;
--error-bg: rgba(212,129,106,0.1);
--accent: #ccc;
--drop-bg: rgba(255,255,255,0.03);
--drop-active: rgba(122,171,122,0.12);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 13px;
line-height: 1.5;
padding: 12px;
}
.header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
.header h1 { font-size: 15px; font-weight: 600; }
.counter { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.loading { text-align: center; padding: 32px; color: var(--text-muted); }
table { width: 100%; border-collapse: collapse; }
th {
text-align: left; font-weight: 500; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.05em;
color: var(--text-muted); padding: 6px 8px;
border-bottom: 1px solid var(--border);
}
td { padding: 8px; border-bottom: 1px solid var(--border); vertical-align: middle; }
tr.booked { background: var(--success-bg); }
tr.error-row { background: var(--error-bg); }
.amount { font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
.amount.negative { color: var(--error); }
.amount.positive { color: var(--success); }
select {
font-size: 12px; padding: 4px 6px; border: 1px solid var(--border);
border-radius: 4px; background: var(--surface); color: var(--text);
max-width: 140px;
}
.drop-zone {
border: 1px dashed var(--border); border-radius: 6px;
padding: 8px 12px; text-align: center; cursor: pointer;
color: var(--text-muted); font-size: 11px;
transition: background 0.15s, border-color 0.15s;
min-width: 120px;
}
.drop-zone.active { background: var(--drop-active); border-color: var(--success); }
.drop-zone.has-file { border-style: solid; border-color: var(--success); color: var(--success); }
.drop-zone.disabled { opacity: 0.5; pointer-events: none; }
.check { color: var(--success); font-weight: 600; }
.error-msg { color: var(--error); font-size: 11px; margin-top: 2px; }
.booking { font-size: 11px; color: var(--text-muted); }
.empty { text-align: center; padding: 32px; color: var(--text-muted); }
</style>
</head>
<body>
<div class="header">
<h1>Kvittomatchning</h1>
<span class="counter" id="counter"></span>
</div>
<div id="content"><div class="loading">Laddar transaktioner\u2026</div></div>
<script>
(function() {
// ── MCP Apps Bridge ──
let rpcId = 1;
const pending = new Map();
let transactions = [];
let categories = [];
let vatTreatments = [];
let booked = 0;
function sendRequest(method, params) {
const id = rpcId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*');
});
}
function sendNotification(method, params) {
window.parent.postMessage({ jsonrpc: '2.0', method, params }, '*');
}
function callTool(name, args) {
return sendRequest('tools/call', { name: name, arguments: args });
}
window.addEventListener('message', function(e) {
const msg = e.data;
if (!msg || msg.jsonrpc !== '2.0') return;
// Response to our request
if (msg.id != null && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
if (msg.error) reject(msg.error);
else resolve(msg.result);
return;
}
// Notifications from host
if (msg.method === 'ui/notifications/tool-result') {
const sc = msg.params?.structuredContent;
if (sc) {
transactions = sc.transactions || [];
categories = sc.categories || [];
vatTreatments = sc.vat_treatments || [];
render();
}
return;
}
if (msg.method === 'ui/notifications/tool-input') return; // loading state already shown
if (msg.method === 'ui/notifications/host-context-changed') {
applyTheme(msg.params);
return;
}
});
function applyTheme(ctx) {
if (!ctx) return;
if (ctx.theme === 'dark') document.documentElement.classList.add('dark');
else document.documentElement.classList.remove('dark');
}
// ── Initialize ──
sendRequest('ui/initialize', {
name: 'gnubok-receipt-matcher',
version: '1.0.0'
}).then(function(res) {
if (res && res.hostContext) applyTheme(res.hostContext);
sendNotification('ui/notifications/initialized');
}).catch(function() {
sendNotification('ui/notifications/initialized');
});
// ── Render ──
function render() {
const el = document.getElementById('content');
const counterEl = document.getElementById('counter');
counterEl.textContent = booked + ' av ' + transactions.length + ' bokf\\u00f6rda';
if (!transactions.length) {
el.innerHTML = '<div class="empty">Inga okategoriserade transaktioner.</div>';
return;
}
let html = '<table><thead><tr>' +
'<th>Datum</th><th>Beskrivning</th><th class="amount">Belopp</th>' +
'<th>Kategori</th><th>Moms</th><th>Kvitto</th>' +
'</tr></thead><tbody>';
transactions.forEach(function(tx, i) {
const isBooked = tx._booked;
const hasError = tx._error;
const cls = isBooked ? 'booked' : (hasError ? 'error-row' : '');
const amt = Number(tx.amount);
const amtClass = amt < 0 ? 'negative' : 'positive';
const formatted = amt.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' kr';
html += '<tr class="' + cls + '" data-idx="' + i + '">';
html += '<td>' + esc(tx.date || '') + '</td>';
html += '<td>' + esc(tx.description || tx.merchant_name || '') + '</td>';
html += '<td class="amount ' + amtClass + '">' + formatted + '</td>';
if (isBooked) {
html += '<td colspan="3"><span class="check">\\u2713 Bokf\\u00f6rd</span></td>';
} else if (tx._booking) {
html += '<td colspan="3"><span class="booking">Bokar\\u2026</span></td>';
} else {
html += '<td><select id="cat-' + i + '">' + categoryOptions(tx) + '</select></td>';
html += '<td><select id="vat-' + i + '">' + vatOptions() + '</select></td>';
html += '<td>' + dropZone(i) + '</td>';
}
if (hasError) {
html += '</tr><tr class="error-row"><td colspan="6"><span class="error-msg">' + esc(tx._error) + '</span></td>';
}
html += '</tr>';
});
html += '</tbody></table>';
el.innerHTML = html;
// Bind drop zones
document.querySelectorAll('.drop-zone').forEach(function(dz) {
const idx = parseInt(dz.dataset.idx);
dz.addEventListener('dragover', function(e) { e.preventDefault(); dz.classList.add('active'); });
dz.addEventListener('dragleave', function() { dz.classList.remove('active'); });
dz.addEventListener('drop', function(e) {
e.preventDefault();
dz.classList.remove('active');
const file = e.dataTransfer.files[0];
if (file) handleFile(idx, file);
});
dz.addEventListener('click', function() {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*,application/pdf';
input.onchange = function() { if (input.files[0]) handleFile(idx, input.files[0]); };
input.click();
});
});
}
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function categoryOptions(tx) {
const cats = categories.length ? categories : [
'income_services','income_products','income_other',
'expense_equipment','expense_software','expense_travel','expense_office',
'expense_marketing','expense_professional_services','expense_education',
'expense_representation','expense_consumables','expense_vehicle',
'expense_telecom','expense_bank_fees','expense_card_fees',
'expense_currency_exchange','expense_other','private'
];
const labels = {
income_services:'Int\\u00e4kt tj\\u00e4nster', income_products:'Int\\u00e4kt varor', income_other:'Int\\u00e4kt \\u00f6vrigt',
expense_equipment:'Utrustning', expense_software:'Programvara', expense_travel:'Resor',
expense_office:'Kontor', expense_marketing:'Marknadsf\\u00f6ring',
expense_professional_services:'Konsulter', expense_education:'Utbildning',
expense_representation:'Representation', expense_consumables:'F\\u00f6rbrukning',
expense_vehicle:'Fordon', expense_telecom:'Telekom', expense_bank_fees:'Bankavgifter',
expense_card_fees:'Kortavgifter', expense_currency_exchange:'Valutav\\u00e4xling',
expense_other:'\\u00d6vrig kostnad', private:'Privat'
};
const guess = (tx.amount < 0) ? 'expense_other' : 'income_services';
return cats.map(function(c) {
return '<option value="' + c + '"' + (c === guess ? ' selected' : '') + '>' + (labels[c] || c) + '</option>';
}).join('');
}
function vatOptions() {
const vats = vatTreatments.length ? vatTreatments : ['standard_25','reduced_12','reduced_6','reverse_charge','export','exempt'];
const labels = { standard_25:'25%', reduced_12:'12%', reduced_6:'6%', reverse_charge:'Omv\\u00e4nd', export:'Export', exempt:'Undantagen' };
return vats.map(function(v) {
return '<option value="' + v + '"' + (v === 'standard_25' ? ' selected' : '') + '>' + (labels[v] || v) + '</option>';
}).join('');
}
function dropZone(idx) {
const tx = transactions[idx];
if (tx._file) return '<div class="drop-zone has-file disabled" data-idx="' + idx + '">' + esc(tx._file) + '</div>';
return '<div class="drop-zone" data-idx="' + idx + '">Sl\\u00e4pp kvitto</div>';
}
// ── File handling ──
function handleFile(idx, file) {
if (!file.type.match(/^image\\/|application\\/pdf/)) {
transactions[idx]._error = 'Filtypen st\\u00f6ds inte. Anv\\u00e4nd bild eller PDF.';
render();
return;
}
const reader = new FileReader();
reader.onload = function() {
if (file.type.startsWith('image/')) {
resizeImage(reader.result, function(dataUri) {
transactions[idx]._file = file.name;
transactions[idx]._dataUri = dataUri;
transactions[idx]._mimeType = 'image/jpeg';
render();
bookTransaction(idx);
});
} else {
// PDF — send as-is (no resize)
transactions[idx]._file = file.name;
transactions[idx]._dataUri = reader.result;
transactions[idx]._mimeType = file.type;
render();
bookTransaction(idx);
}
};
reader.readAsDataURL(file);
}
function resizeImage(dataUri, cb) {
const img = new Image();
img.onload = function() {
const MAX = 1600;
let w = img.width, h = img.height;
if (w > MAX || h > MAX) {
if (w > h) { h = Math.round(h * MAX / w); w = MAX; }
else { w = Math.round(w * MAX / h); h = MAX; }
}
const canvas = document.createElement('canvas');
canvas.width = w; canvas.height = h;
canvas.getContext('2d').drawImage(img, 0, 0, w, h);
cb(canvas.toDataURL('image/jpeg', 0.85));
};
img.src = dataUri;
}
// ── Booking ──
function bookTransaction(idx) {
const tx = transactions[idx];
const catEl = document.getElementById('cat-' + idx);
const vatEl = document.getElementById('vat-' + idx);
if (!catEl || !vatEl) return;
tx._booking = true;
tx._error = null;
render();
callTool('gnubok_categorize_with_receipt', {
transaction_id: tx.id,
category: catEl.value,
vat_treatment: vatEl.value,
file_data: tx._dataUri,
filename: tx._file,
mime_type: tx._mimeType
}).then(function(res) {
tx._booking = false;
// The tool result comes back as content[0].text (JSON string)
let result;
if (res && res.structuredContent) {
result = res.structuredContent;
} else if (res && res.content && res.content[0]) {
try { result = JSON.parse(res.content[0].text); } catch(e) { result = {}; }
}
if (result && result.error) {
tx._error = result.error;
} else {
tx._booked = true;
booked++;
}
render();
sendNotification('ui/updateContext', {
content: [{ type: 'text', text: 'Bokf\\u00f6rt ' + booked + ' av ' + transactions.length + ' transaktioner med kvitton.' }]
});
}).catch(function(err) {
tx._booking = false;
tx._error = (err && err.message) || 'Kunde inte boka transaktionen.';
render();
});
}
})();
</script>
</body>
</html>`;