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:
co-authored by
Claude Opus 4.6
parent
6b51d13ba7
commit
7571b85d1a
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>`;
|
||||
Reference in New Issue
Block a user