Add/ai native supp (#385)
* feat(branding): implement dynamic branding in service worker and reports * feat(auth): enhance API key scopes and add bookkeeping write scope - Updated transaction write scope description to include additional tools. - Enhanced reports read scope description to reflect new functionality. - Introduced bookkeeping write scope with relevant description. - Updated SCOPE_GROUPS to include bookkeeping domain. - Modified TOOL_SCOPE_MAP to include new bookkeeping operations. - Updated validateApiKey function to return api_key_id and api_key_name for better actor attribution. feat(tests): add unit tests for MCP resource registry - Created tests for data resources to ensure all required fields are present. - Added tests for resource query parsing and retrieval. feat(resources): implement MCP resources for company and accounting data - Added capabilities resource to expose API key capabilities based on granted scopes. - Implemented chart of accounts resource to retrieve active BAS chart. - Created company current resource to fetch active company details. - Developed active fiscal period resource to check posting eligibility. - Implemented recent activity resource to fetch latest journal entries, invoices, and transactions. - Added VAT treatments resource to provide available VAT rates per customer type. feat(pending-operations): introduce risk tiers for operations - Added risk level classification for pending operations to determine auto-commit eligibility. - Implemented functions to classify operation risk levels and identify high-risk operations. feat(migrations): add actor model and risk tier to pending operations - Updated pending_operations table to include actor type and risk level columns. - Enhanced audit_log to mirror actor information for compliance. - Modified validate_and_increment_api_key function to return actor details. - Expanded operation types in pending_operations to include new high-risk operations. * feat: add auto-commit functionality for low-risk pending operations - Implemented shouldAutoCommit function to determine eligibility for auto-commit based on operation type, actor type, and company settings. - Created commitPendingOperation function to handle execution of pending operations with consistent status updates. - Added tests for shouldAutoCommit to cover various scenarios including high-risk operations, user actors, company opt-in status, and monetary thresholds. - Introduced new columns in company_settings for agent_auto_commit_enabled and agent_auto_commit_max_amount to allow companies to opt-in for auto-commit functionality. - Added SQL migration to update the database schema for new auto-commit settings. * feat(idempotency): implement idempotency key handling for safe retries and cleanup * feat: expand API key scopes and pending operations for bookkeeping - Added 'suppliers:write' scope to API key scopes for supplier invoice management. - Updated SCOPE_GROUPS to include the new 'suppliers:write' scope. - Introduced new pending operation types for bookkeeping: close_period, lock_period, run_year_end, set_opening_balances, run_currency_revaluation, explain_voucher_gap, uncategorize_transaction, approve_supplier_invoice, credit_supplier_invoice, and convert_invoice. - Implemented corresponding commit functions for the new operations in the pending operations module. - Enhanced PendingOperation type to include actor model and risk level attributes. - Added tests for new functionality, ensuring proper behavior and constraints in the database. * feat: implement unlockPeriod functionality and related tests * feat: add agent auto-commit settings and related functionality * feat: add attention resource with comprehensive summary of outstanding tasks * feat: enhance pending operations with 'committing' status and immutability checks, improve idempotency handling, and add original voucher reference for credit notes
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { createQueuedMockSupabase } from '@/tests/helpers'
|
||||
import { attentionResource } from '../resources/attention'
|
||||
|
||||
type AttentionResponse = {
|
||||
generated_at: string
|
||||
summary: { total_items: number; critical: number; warning: number; info: number }
|
||||
categories: Array<{
|
||||
key: string
|
||||
severity: 'critical' | 'warning' | 'info'
|
||||
count: number
|
||||
samples: Array<Record<string, unknown>>
|
||||
next?: { description: string; tool?: string; args?: Record<string, unknown>; resource?: string }
|
||||
}>
|
||||
}
|
||||
|
||||
const ctx = (supabase: ReturnType<typeof createQueuedMockSupabase>['supabase']) => ({
|
||||
supabase: supabase as never,
|
||||
companyId: 'company-1',
|
||||
userId: 'user-1',
|
||||
scopes: [],
|
||||
})
|
||||
|
||||
/**
|
||||
* Enqueues 14 baseline empty results in the order the resource consumes them.
|
||||
* Tests can override individual slots before invoking by enqueueing in advance.
|
||||
*/
|
||||
function enqueueEmpty(enqueue: (r: { data?: unknown; error?: unknown; count?: number | null }) => void) {
|
||||
// 1. unbookedHead
|
||||
enqueue({ count: 0 })
|
||||
// 2. unbookedSamples
|
||||
enqueue({ data: [] })
|
||||
// 3. overdueRows
|
||||
enqueue({ data: [] })
|
||||
// 4. pendingSupplierHead
|
||||
enqueue({ count: 0 })
|
||||
// 5. pendingSupplierSamples
|
||||
enqueue({ data: [] })
|
||||
// 6. pendingOpsHead
|
||||
enqueue({ count: 0 })
|
||||
// 7. pendingOpsSamples
|
||||
enqueue({ data: [] })
|
||||
// 8. unmatchedReceiptsHead
|
||||
enqueue({ count: 0 })
|
||||
// 9. unmatchedReceiptsSamples
|
||||
enqueue({ data: [] })
|
||||
// 10. voucherSeriesRows
|
||||
enqueue({ data: [] })
|
||||
// 11. deadlineRows
|
||||
enqueue({ data: [] })
|
||||
// 12. bankConnRows
|
||||
enqueue({ data: [] })
|
||||
// 13. activePeriodRow
|
||||
enqueue({ data: null })
|
||||
// 14. companySettingsRow
|
||||
enqueue({ data: null })
|
||||
}
|
||||
|
||||
describe('gnubok://attention', () => {
|
||||
it('returns empty summary for a brand-new company', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
enqueueEmpty(enqueue)
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
|
||||
expect(result.generated_at).toMatch(/^\d{4}-\d{2}-\d{2}T/)
|
||||
expect(result.summary).toEqual({ total_items: 0, critical: 0, warning: 0, info: 0 })
|
||||
expect(result.categories).toEqual([])
|
||||
})
|
||||
|
||||
it('classifies recently-unbooked transactions as warning', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const txns = [
|
||||
{ id: 't-1', date: today, amount: -100, currency: 'SEK', description: 'Lunch', merchant_name: 'Café' },
|
||||
{ id: 't-2', date: today, amount: -200, currency: 'SEK', description: 'Office', merchant_name: 'Clas Ohlson' },
|
||||
]
|
||||
|
||||
enqueue({ count: 2 }) // unbookedHead
|
||||
enqueue({ data: txns }) // unbookedSamples
|
||||
enqueue({ data: [] }) // overdueRows
|
||||
enqueue({ count: 0 }) // pendingSupplierHead
|
||||
enqueue({ data: [] }) // pendingSupplierSamples
|
||||
enqueue({ count: 0 }) // pendingOpsHead
|
||||
enqueue({ data: [] }) // pendingOpsSamples
|
||||
enqueue({ count: 0 }) // unmatchedReceiptsHead
|
||||
enqueue({ data: [] }) // unmatchedReceiptsSamples
|
||||
enqueue({ data: [] }) // voucherSeriesRows
|
||||
enqueue({ data: [] }) // deadlineRows
|
||||
enqueue({ data: [] }) // bankConnRows
|
||||
enqueue({ data: null }) // activePeriodRow
|
||||
enqueue({ data: null }) // companySettingsRow
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
|
||||
expect(result.categories).toHaveLength(1)
|
||||
const cat = result.categories[0]
|
||||
expect(cat.key).toBe('unbooked_transactions')
|
||||
expect(cat.severity).toBe('warning')
|
||||
expect(cat.count).toBe(2)
|
||||
expect(cat.samples).toEqual(txns)
|
||||
expect(cat.next?.tool).toBe('gnubok_categorize_transaction')
|
||||
expect(cat.next?.args).toEqual({ transaction_id: 't-1' })
|
||||
expect(result.summary).toEqual({ total_items: 2, critical: 0, warning: 1, info: 0 })
|
||||
})
|
||||
|
||||
it('escalates unbooked transactions to critical when oldest is > 30 days old', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const fortyDaysAgo = new Date(Date.now() - 40 * 86_400_000).toISOString().slice(0, 10)
|
||||
const txns = [{ id: 't-old', date: fortyDaysAgo, amount: -100, currency: 'SEK', description: 'X', merchant_name: null }]
|
||||
|
||||
enqueue({ count: 1 })
|
||||
enqueue({ data: txns })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
expect(result.categories[0]?.severity).toBe('critical')
|
||||
expect(result.summary.critical).toBe(1)
|
||||
})
|
||||
|
||||
it('flags overdue invoices as critical when any are > 30 days past due', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const fortyDaysAgo = new Date(Date.now() - 40 * 86_400_000).toISOString().slice(0, 10)
|
||||
const tenDaysAgo = new Date(Date.now() - 10 * 86_400_000).toISOString().slice(0, 10)
|
||||
const overdue = [
|
||||
{ id: 'i-1', invoice_number: 'F-2024001', customer_id: 'c-1', due_date: fortyDaysAgo, total: 1000, currency: 'SEK', status: 'overdue' },
|
||||
{ id: 'i-2', invoice_number: 'F-2024002', customer_id: 'c-1', due_date: tenDaysAgo, total: 500, currency: 'SEK', status: 'sent' },
|
||||
]
|
||||
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: overdue })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
const cat = result.categories.find((c) => c.key === 'overdue_invoices')
|
||||
expect(cat?.severity).toBe('critical')
|
||||
expect(cat?.count).toBe(2)
|
||||
})
|
||||
|
||||
it('marks pending operations as critical when any high-risk op is queued', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const ops = [
|
||||
{ id: 'op-1', operation_type: 'close_period', title: 'Stäng FY2025', risk_level: 'high', actor_label: 'Claude', created_at: new Date().toISOString() },
|
||||
{ id: 'op-2', operation_type: 'create_customer', title: 'Ny kund', risk_level: 'low', actor_label: 'Claude', created_at: new Date().toISOString() },
|
||||
]
|
||||
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 2 })
|
||||
enqueue({ data: ops })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
const cat = result.categories.find((c) => c.key === 'pending_operations')
|
||||
expect(cat?.severity).toBe('critical')
|
||||
expect(cat?.count).toBe(2)
|
||||
expect(result.summary.critical).toBe(1)
|
||||
})
|
||||
|
||||
it('flags voucher gaps as critical and includes next tool args', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const seriesRows = [{ voucher_series: 'A', fiscal_period_id: 'fp-1' }]
|
||||
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: seriesRows }) // voucherSeriesRows
|
||||
enqueue({ data: [] }) // deadlineRows
|
||||
enqueue({ data: [] }) // bankConnRows
|
||||
enqueue({ data: null }) // activePeriodRow
|
||||
enqueue({ data: null }) // companySettingsRow
|
||||
// Loop body for series 'A':
|
||||
enqueue({ data: [{ gap_start: 5, gap_end: 7 }] }) // detect_voucher_gaps RPC
|
||||
enqueue({ data: [] }) // voucher_gap_explanations follow-up
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
const cat = result.categories.find((c) => c.key === 'voucher_gaps_unexplained')
|
||||
expect(cat?.severity).toBe('critical')
|
||||
expect(cat?.count).toBe(1)
|
||||
expect(cat?.next?.tool).toBe('gnubok_explain_voucher_gap')
|
||||
expect(cat?.next?.args).toEqual({
|
||||
fiscal_period_id: 'fp-1',
|
||||
voucher_series: 'A',
|
||||
gap_start: 5,
|
||||
gap_end: 7,
|
||||
})
|
||||
})
|
||||
|
||||
it('omits voucher_gaps category when all gaps are explained', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const seriesRows = [{ voucher_series: 'A', fiscal_period_id: 'fp-1' }]
|
||||
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: seriesRows })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: [{ gap_start: 5, gap_end: 7 }] })
|
||||
enqueue({
|
||||
data: [{ voucher_series: 'A', gap_start: 5, gap_end: 7, fiscal_period_id: 'fp-1' }],
|
||||
})
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
expect(result.categories.find((c) => c.key === 'voucher_gaps_unexplained')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('flags expired bank consent as critical', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10)
|
||||
const banks = [
|
||||
{ id: 'bc-1', bank_name: 'SEB', status: 'active', consent_expires: yesterday },
|
||||
]
|
||||
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: banks })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
const cat = result.categories.find((c) => c.key === 'bank_consent_expiring')
|
||||
expect(cat?.severity).toBe('critical')
|
||||
expect(cat?.count).toBe(1)
|
||||
})
|
||||
|
||||
it('classifies upcoming lock as info severity', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const inSevenDays = new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10)
|
||||
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: { id: 'fp-1', name: 'FY2026', period_start: '2026-01-01', period_end: '2026-12-31', locked_at: null, is_closed: false } })
|
||||
enqueue({ data: { bookkeeping_locked_through: inSevenDays, auto_lock_period_days: null } })
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
const cat = result.categories.find((c) => c.key === 'period_lock_approaching')
|
||||
expect(cat?.severity).toBe('info')
|
||||
expect(result.summary.info).toBe(1)
|
||||
})
|
||||
|
||||
it('combines multiple categories into a coherent summary', async () => {
|
||||
const { supabase, enqueue } = createQueuedMockSupabase()
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
enqueue({ count: 1 }) // unbookedHead
|
||||
enqueue({ data: [{ id: 't-1', date: today, amount: -50, currency: 'SEK', description: 'X', merchant_name: null }] })
|
||||
enqueue({ data: [] }) // overdueRows
|
||||
enqueue({ count: 1 }) // pendingSupplierHead
|
||||
enqueue({ data: [{ id: 'si-1', supplier_invoice_number: 'L-1', supplier_id: 's-1', total: 1000, currency: 'SEK', due_date: today }] })
|
||||
enqueue({ count: 0 }) // pendingOpsHead
|
||||
enqueue({ data: [] })
|
||||
enqueue({ count: 0 })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: [{ id: 'd-1', title: 'Moms Q1', due_date: today, deadline_type: 'tax', tax_deadline_type: 'vat', status: 'upcoming' }] })
|
||||
enqueue({ data: [] })
|
||||
enqueue({ data: null })
|
||||
enqueue({ data: null })
|
||||
|
||||
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
|
||||
expect(result.categories).toHaveLength(3)
|
||||
expect(new Set(result.categories.map((c) => c.key))).toEqual(
|
||||
new Set(['unbooked_transactions', 'pending_supplier_invoices', 'deadlines_upcoming'])
|
||||
)
|
||||
expect(result.summary.total_items).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -209,17 +209,24 @@ describe('MCP Receipt Matcher', () => {
|
||||
// ── Protocol: resources/list ──
|
||||
|
||||
describe('resources/list', () => {
|
||||
it('returns the receipt-matcher resource', async () => {
|
||||
it('includes the receipt-matcher widget alongside data resources', async () => {
|
||||
const res = await handleMcpRequest(mcpRequest('resources/list'))
|
||||
const result = await parseResult(res)
|
||||
|
||||
expect(result.resources).toHaveLength(1)
|
||||
expect(result.resources[0]).toEqual({
|
||||
const widget = result.resources.find(
|
||||
(r: { uri: string }) => r.uri === 'ui://receipt-matcher/app.html'
|
||||
)
|
||||
expect(widget).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',
|
||||
})
|
||||
|
||||
// Data resources (added in Stream 3 Phase 1) should also be listed.
|
||||
const uris = result.resources.map((r: { uri: string }) => r.uri)
|
||||
expect(uris).toContain('gnubok://company/current')
|
||||
expect(uris).toContain('gnubok://capabilities')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { dataResources, findResource, parseResourceQuery } from '../resources'
|
||||
|
||||
describe('mcp resource registry', () => {
|
||||
it('exposes all data resources with required fields', () => {
|
||||
expect(dataResources).toHaveLength(7)
|
||||
const uris = dataResources.map((r) => r.uri).sort()
|
||||
expect(uris).toEqual([
|
||||
'gnubok://attention',
|
||||
'gnubok://capabilities',
|
||||
'gnubok://chart-of-accounts',
|
||||
'gnubok://company/current',
|
||||
'gnubok://period/active',
|
||||
'gnubok://recent-activity',
|
||||
'gnubok://settings/vat-treatments',
|
||||
])
|
||||
|
||||
for (const r of dataResources) {
|
||||
expect(r.name).toBeTruthy()
|
||||
expect(r.description.length).toBeGreaterThan(20)
|
||||
expect(r.mimeType).toBe('application/json')
|
||||
expect(typeof r.read).toBe('function')
|
||||
}
|
||||
})
|
||||
|
||||
it('matches base URI ignoring query string', () => {
|
||||
const r = findResource('gnubok://recent-activity?limit=5')
|
||||
expect(r?.uri).toBe('gnubok://recent-activity')
|
||||
})
|
||||
|
||||
it('returns null for unknown URI', () => {
|
||||
expect(findResource('gnubok://does-not-exist')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses query params from URI', () => {
|
||||
const q = parseResourceQuery('gnubok://recent-activity?limit=5&offset=10')
|
||||
expect(q?.get('limit')).toBe('5')
|
||||
expect(q?.get('offset')).toBe('10')
|
||||
})
|
||||
|
||||
it('returns undefined when no query', () => {
|
||||
expect(parseResourceQuery('gnubok://capabilities')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('vat-treatments resource', () => {
|
||||
it('returns matrix for all customer types without DB access', async () => {
|
||||
const r = findResource('gnubok://settings/vat-treatments')!
|
||||
const result = (await r.read({
|
||||
// Pure-function resource: no DB calls
|
||||
supabase: undefined as never,
|
||||
companyId: 'irrelevant',
|
||||
userId: 'irrelevant',
|
||||
scopes: [],
|
||||
})) as { treatments: string[]; by_customer_type: Record<string, unknown> }
|
||||
|
||||
expect(result.treatments).toContain('standard_25')
|
||||
expect(result.treatments).toContain('reverse_charge')
|
||||
expect(Object.keys(result.by_customer_type)).toEqual([
|
||||
'individual',
|
||||
'swedish_business',
|
||||
'eu_business',
|
||||
'non_eu_business',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { withNext, toToolError } from '../tool-result'
|
||||
|
||||
describe('withNext', () => {
|
||||
it('returns plain { data } when no hint provided', () => {
|
||||
expect(withNext({ id: 'x' })).toEqual({ data: { id: 'x' } })
|
||||
})
|
||||
|
||||
it('attaches next hint when provided', () => {
|
||||
const result = withNext(
|
||||
{ id: 'x' },
|
||||
{ description: 'Send the invoice', tool: 'gnubok_send_invoice' }
|
||||
)
|
||||
expect(result).toEqual({
|
||||
data: { id: 'x' },
|
||||
next: { description: 'Send the invoice', tool: 'gnubok_send_invoice' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('toToolError', () => {
|
||||
it('produces structured error from arbitrary throw', () => {
|
||||
const result = toToolError(new Error('Period must be locked before closing'))
|
||||
expect(result.error.code).toBe('PERIOD_NOT_LOCKED')
|
||||
expect(result.error.message_sv).toBeTruthy()
|
||||
expect(result.error.message_en).toContain('Period must be locked')
|
||||
expect(result.error.remediation?.tool).toBe('gnubok_lock_period')
|
||||
})
|
||||
|
||||
it('extracts attempted scope from "Insufficient scope:" message', () => {
|
||||
const result = toToolError(
|
||||
new Error('Insufficient scope: this API key does not have the "payroll:write" scope')
|
||||
)
|
||||
expect(result.error.code).toBe('INSUFFICIENT_SCOPE')
|
||||
expect(result.error.remediation?.description).toContain('"payroll:write"')
|
||||
})
|
||||
|
||||
it('handles non-Error throws', () => {
|
||||
const result = toToolError('something broke')
|
||||
expect(result.error.code).toBe('UNKNOWN_ERROR')
|
||||
expect(result.error.message_en).toBe('something broke')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,381 @@
|
||||
import type { McpResource } from './types'
|
||||
import { ACTION_NEEDED_THRESHOLD_DAYS } from '@/lib/deadlines/status-engine'
|
||||
|
||||
type Severity = 'critical' | 'warning' | 'info'
|
||||
|
||||
interface AttentionCategory {
|
||||
key: string
|
||||
label_sv: string
|
||||
severity: Severity
|
||||
count: number
|
||||
samples: Array<Record<string, unknown>>
|
||||
next?: {
|
||||
description: string
|
||||
tool?: string
|
||||
args?: Record<string, unknown>
|
||||
resource?: string
|
||||
}
|
||||
}
|
||||
|
||||
const SAMPLE_LIMIT = 5
|
||||
|
||||
function daysBetween(fromIso: string, toIso: string): number {
|
||||
const ms = new Date(toIso).getTime() - new Date(fromIso).getTime()
|
||||
return Math.round(ms / 86_400_000)
|
||||
}
|
||||
|
||||
export const attentionResource: McpResource = {
|
||||
uri: 'gnubok://attention',
|
||||
name: 'What Needs Attention',
|
||||
description:
|
||||
'One-shot summary of outstanding work for the active company: unbooked transactions, overdue invoices, pending approvals, voucher gaps, upcoming deadlines, bank consent expiry, and period-lock alerts. Each category includes a count, up to 5 sample rows, and a suggested next tool call. Use this at session start to orient before chaining read tools.',
|
||||
mimeType: 'application/json',
|
||||
read: async ({ supabase, companyId }) => {
|
||||
const now = new Date()
|
||||
const today = now.toISOString().slice(0, 10)
|
||||
const horizonDate = new Date(now.getTime() + ACTION_NEEDED_THRESHOLD_DAYS * 86_400_000)
|
||||
const horizon = horizonDate.toISOString().slice(0, 10)
|
||||
|
||||
const [
|
||||
unbookedHead,
|
||||
unbookedSamples,
|
||||
overdueRows,
|
||||
pendingSupplierHead,
|
||||
pendingSupplierSamples,
|
||||
pendingOpsHead,
|
||||
pendingOpsSamples,
|
||||
unmatchedReceiptsHead,
|
||||
unmatchedReceiptsSamples,
|
||||
voucherSeriesRows,
|
||||
deadlineRows,
|
||||
bankConnRows,
|
||||
activePeriodRow,
|
||||
companySettingsRow,
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.eq('is_business', true),
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select('id, date, amount, currency, description, merchant_name')
|
||||
.eq('company_id', companyId)
|
||||
.is('journal_entry_id', null)
|
||||
.eq('is_business', true)
|
||||
.order('date', { ascending: true })
|
||||
.limit(SAMPLE_LIMIT),
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, customer_id, due_date, total, currency, status')
|
||||
.eq('company_id', companyId)
|
||||
.in('status', ['sent', 'overdue'])
|
||||
.lt('due_date', today)
|
||||
.order('due_date', { ascending: true })
|
||||
.limit(100),
|
||||
supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'registered'),
|
||||
supabase
|
||||
.from('supplier_invoices')
|
||||
.select('id, supplier_invoice_number, supplier_id, total, currency, due_date')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'registered')
|
||||
.order('due_date', { ascending: true })
|
||||
.limit(SAMPLE_LIMIT),
|
||||
supabase
|
||||
.from('pending_operations')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'pending'),
|
||||
supabase
|
||||
.from('pending_operations')
|
||||
.select('id, operation_type, title, risk_level, actor_label, created_at')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'pending')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(SAMPLE_LIMIT),
|
||||
supabase
|
||||
.from('receipts')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'confirmed')
|
||||
.is('matched_transaction_id', null),
|
||||
supabase
|
||||
.from('receipts')
|
||||
.select('id, receipt_date, total_amount, currency, merchant_name')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'confirmed')
|
||||
.is('matched_transaction_id', null)
|
||||
.order('receipt_date', { ascending: false, nullsFirst: false })
|
||||
.limit(SAMPLE_LIMIT),
|
||||
supabase
|
||||
.from('voucher_sequences')
|
||||
.select('voucher_series, fiscal_period_id')
|
||||
.eq('company_id', companyId),
|
||||
supabase
|
||||
.from('deadlines')
|
||||
.select('id, title, due_date, deadline_type, tax_deadline_type, status')
|
||||
.eq('company_id', companyId)
|
||||
.eq('is_completed', false)
|
||||
.lte('due_date', horizon)
|
||||
.order('due_date', { ascending: true })
|
||||
.limit(20),
|
||||
supabase
|
||||
.from('bank_connections')
|
||||
.select('id, bank_name, status, consent_expires')
|
||||
.eq('company_id', companyId)
|
||||
.eq('status', 'active')
|
||||
.not('consent_expires', 'is', null),
|
||||
supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end, locked_at, is_closed')
|
||||
.eq('company_id', companyId)
|
||||
.lte('period_start', today)
|
||||
.gte('period_end', today)
|
||||
.maybeSingle(),
|
||||
supabase
|
||||
.from('company_settings')
|
||||
.select('bookkeeping_locked_through, auto_lock_period_days')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle(),
|
||||
])
|
||||
|
||||
const categories: AttentionCategory[] = []
|
||||
|
||||
// ── Unbooked business transactions ──────────────────────────────
|
||||
const unbookedCount = unbookedHead.count ?? 0
|
||||
if (unbookedCount > 0) {
|
||||
const oldest = unbookedSamples.data?.[0]
|
||||
const oldestAgeDays = oldest?.date ? daysBetween(oldest.date, today) : 0
|
||||
categories.push({
|
||||
key: 'unbooked_transactions',
|
||||
label_sv: 'Obokförda affärstransaktioner',
|
||||
severity: oldestAgeDays > 30 ? 'critical' : 'warning',
|
||||
count: unbookedCount,
|
||||
samples: unbookedSamples.data ?? [],
|
||||
next: {
|
||||
description: 'Kategorisera den äldsta obokförda transaktionen.',
|
||||
tool: 'gnubok_categorize_transaction',
|
||||
args: oldest ? { transaction_id: oldest.id } : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Overdue invoices ────────────────────────────────────────────
|
||||
const overdueAll = overdueRows.data ?? []
|
||||
if (overdueAll.length > 0) {
|
||||
const maxOverdueDays = overdueAll.reduce((max, inv) => {
|
||||
const days = inv.due_date ? daysBetween(inv.due_date, today) : 0
|
||||
return Math.max(max, days)
|
||||
}, 0)
|
||||
categories.push({
|
||||
key: 'overdue_invoices',
|
||||
label_sv: 'Förfallna fakturor',
|
||||
severity: maxOverdueDays > 30 ? 'critical' : 'warning',
|
||||
count: overdueAll.length,
|
||||
samples: overdueAll.slice(0, SAMPLE_LIMIT),
|
||||
next: {
|
||||
description: 'Granska förfallna fakturor och skicka påminnelser.',
|
||||
resource: 'gnubok://recent-activity?limit=20',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Pending supplier invoices (status='registered') ─────────────
|
||||
const pendingSupplierCount = pendingSupplierHead.count ?? 0
|
||||
if (pendingSupplierCount > 0) {
|
||||
const oldestRegistered = pendingSupplierSamples.data?.[0]
|
||||
categories.push({
|
||||
key: 'pending_supplier_invoices',
|
||||
label_sv: 'Leverantörsfakturor som väntar på godkännande',
|
||||
severity: 'warning',
|
||||
count: pendingSupplierCount,
|
||||
samples: pendingSupplierSamples.data ?? [],
|
||||
next: {
|
||||
description: 'Godkänn äldsta registrerade leverantörsfakturan.',
|
||||
tool: 'gnubok_approve_supplier_invoice',
|
||||
args: oldestRegistered ? { supplier_invoice_id: oldestRegistered.id } : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Pending operations awaiting human approval ──────────────────
|
||||
const pendingOpsCount = pendingOpsHead.count ?? 0
|
||||
if (pendingOpsCount > 0) {
|
||||
const ops = pendingOpsSamples.data ?? []
|
||||
const hasHighRisk = ops.some((o) => o.risk_level === 'high')
|
||||
categories.push({
|
||||
key: 'pending_operations',
|
||||
label_sv: 'Operationer som väntar på godkännande',
|
||||
severity: hasHighRisk ? 'critical' : 'warning',
|
||||
count: pendingOpsCount,
|
||||
samples: ops,
|
||||
next: {
|
||||
description: 'Be användaren granska kön i /pending innan agenten fortsätter.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Unmatched receipts ──────────────────────────────────────────
|
||||
const unmatchedReceiptsCount = unmatchedReceiptsHead.count ?? 0
|
||||
if (unmatchedReceiptsCount > 0) {
|
||||
const samples = unmatchedReceiptsSamples.data ?? []
|
||||
const oldest = samples[samples.length - 1]
|
||||
categories.push({
|
||||
key: 'unmatched_receipts',
|
||||
label_sv: 'Kvitton utan matchad transaktion',
|
||||
severity: 'warning',
|
||||
count: unmatchedReceiptsCount,
|
||||
samples,
|
||||
next: {
|
||||
description: 'Försök matcha kvitto mot bankhändelse.',
|
||||
tool: 'gnubok_receipt_matcher',
|
||||
args: oldest ? { receipt_id: oldest.id } : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Voucher gaps without explanations ──────────────────────────
|
||||
const seriesRows = (voucherSeriesRows.data ?? []) as Array<{ voucher_series: string; fiscal_period_id: string }>
|
||||
const allGaps: Array<{ series: string; gap_start: number; gap_end: number; fiscal_period_id: string }> = []
|
||||
for (const row of seriesRows) {
|
||||
const { data: gaps } = await supabase.rpc('detect_voucher_gaps', {
|
||||
p_company_id: companyId,
|
||||
p_fiscal_period_id: row.fiscal_period_id,
|
||||
p_series: row.voucher_series,
|
||||
})
|
||||
if (gaps && Array.isArray(gaps)) {
|
||||
for (const g of gaps as Array<{ gap_start: number; gap_end: number }>) {
|
||||
allGaps.push({
|
||||
series: row.voucher_series,
|
||||
gap_start: g.gap_start,
|
||||
gap_end: g.gap_end,
|
||||
fiscal_period_id: row.fiscal_period_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allGaps.length > 0) {
|
||||
const { data: explanations } = await supabase
|
||||
.from('voucher_gap_explanations')
|
||||
.select('voucher_series, gap_start, gap_end, fiscal_period_id')
|
||||
.eq('company_id', companyId)
|
||||
const explainedKeys = new Set(
|
||||
(explanations ?? []).map(
|
||||
(e) => `${e.fiscal_period_id}:${e.voucher_series}:${e.gap_start}:${e.gap_end}`
|
||||
)
|
||||
)
|
||||
const unexplained = allGaps.filter(
|
||||
(g) => !explainedKeys.has(`${g.fiscal_period_id}:${g.series}:${g.gap_start}:${g.gap_end}`)
|
||||
)
|
||||
if (unexplained.length > 0) {
|
||||
const first = unexplained[0]
|
||||
categories.push({
|
||||
key: 'voucher_gaps_unexplained',
|
||||
label_sv: 'Verifikationshål utan förklaring (BFNAR 2013:2)',
|
||||
severity: 'critical',
|
||||
count: unexplained.length,
|
||||
samples: unexplained.slice(0, SAMPLE_LIMIT),
|
||||
next: {
|
||||
description: 'Dokumentera hålet i verifikationsserien.',
|
||||
tool: 'gnubok_explain_voucher_gap',
|
||||
args: first
|
||||
? {
|
||||
fiscal_period_id: first.fiscal_period_id,
|
||||
voucher_series: first.series,
|
||||
gap_start: first.gap_start,
|
||||
gap_end: first.gap_end,
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deadlines upcoming (within 14 days) ─────────────────────────
|
||||
const deadlines = deadlineRows.data ?? []
|
||||
if (deadlines.length > 0) {
|
||||
const anyOverdue = deadlines.some((d) => d.due_date && d.due_date < today)
|
||||
categories.push({
|
||||
key: 'deadlines_upcoming',
|
||||
label_sv: 'Deadlines inom 14 dagar',
|
||||
severity: anyOverdue ? 'critical' : 'warning',
|
||||
count: deadlines.length,
|
||||
samples: deadlines.slice(0, SAMPLE_LIMIT),
|
||||
next: {
|
||||
description: 'Granska kommande deadlines i /deadlines.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Bank consent expiring ───────────────────────────────────────
|
||||
const bankConns = bankConnRows.data ?? []
|
||||
const expiring = bankConns
|
||||
.map((c) => {
|
||||
const daysLeft = c.consent_expires ? daysBetween(today, c.consent_expires) : null
|
||||
return { ...c, days_left: daysLeft }
|
||||
})
|
||||
.filter((c) => c.days_left != null && c.days_left <= ACTION_NEEDED_THRESHOLD_DAYS)
|
||||
if (expiring.length > 0) {
|
||||
const anyExpired = expiring.some((c) => (c.days_left ?? 0) <= 0)
|
||||
categories.push({
|
||||
key: 'bank_consent_expiring',
|
||||
label_sv: 'Bankanslutningar med samtycke som löper ut',
|
||||
severity: anyExpired ? 'critical' : 'warning',
|
||||
count: expiring.length,
|
||||
samples: expiring.slice(0, SAMPLE_LIMIT).map((c) => ({
|
||||
id: c.id,
|
||||
bank_name: c.bank_name,
|
||||
consent_expires: c.consent_expires,
|
||||
days_left: c.days_left,
|
||||
})),
|
||||
next: {
|
||||
description: 'Be användaren förnya bank-samtycket innan det löper ut.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Period lock approaching ─────────────────────────────────────
|
||||
const lockDate = companySettingsRow.data?.bookkeeping_locked_through ?? null
|
||||
if (lockDate && activePeriodRow.data) {
|
||||
const daysUntilLock = daysBetween(today, lockDate)
|
||||
if (daysUntilLock >= 0 && daysUntilLock <= ACTION_NEEDED_THRESHOLD_DAYS) {
|
||||
categories.push({
|
||||
key: 'period_lock_approaching',
|
||||
label_sv: 'Bokföringslås närmar sig',
|
||||
severity: 'info',
|
||||
count: 1,
|
||||
samples: [
|
||||
{
|
||||
lock_date: lockDate,
|
||||
days_until: daysUntilLock,
|
||||
active_period_id: activePeriodRow.data.id,
|
||||
},
|
||||
],
|
||||
next: {
|
||||
description: 'Slutför obokfört arbete innan lock_date.',
|
||||
resource: 'gnubok://period/active',
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Summary tally ───────────────────────────────────────────────
|
||||
const summary = {
|
||||
total_items: categories.reduce((sum, c) => sum + c.count, 0),
|
||||
critical: categories.filter((c) => c.severity === 'critical').length,
|
||||
warning: categories.filter((c) => c.severity === 'warning').length,
|
||||
info: categories.filter((c) => c.severity === 'info').length,
|
||||
}
|
||||
|
||||
return {
|
||||
generated_at: now.toISOString(),
|
||||
summary,
|
||||
categories,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { McpResource } from './types'
|
||||
import { TOOL_SCOPE_MAP, hasScope } from '@/lib/auth/api-keys'
|
||||
|
||||
interface Capability {
|
||||
tool: string
|
||||
scope: string
|
||||
granted: boolean
|
||||
state_blocked: boolean
|
||||
reason: string | null
|
||||
}
|
||||
|
||||
export const capabilitiesResource: McpResource = {
|
||||
uri: 'gnubok://capabilities',
|
||||
name: 'Capabilities',
|
||||
description: 'What the current API key can actually do given (a) its granted scopes and (b) the current company state. Surfaces blockers like locked periods so the agent knows ahead of time why an action would fail.',
|
||||
mimeType: 'application/json',
|
||||
read: async ({ supabase, companyId, scopes }) => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
const { data: activePeriod } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, is_closed, locked_at, opening_balances_set, period_end')
|
||||
.eq('company_id', companyId)
|
||||
.lte('period_start', today)
|
||||
.gte('period_end', today)
|
||||
.maybeSingle()
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bookkeeping_locked_through, vat_registered, pays_salaries, ai_flow_enabled')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
const periodIsLocked = !!activePeriod?.locked_at || !!activePeriod?.is_closed
|
||||
const periodMissing = !activePeriod
|
||||
const companyLocked = !!settings?.bookkeeping_locked_through
|
||||
&& settings.bookkeeping_locked_through >= today
|
||||
|
||||
const stateBlockers: Record<string, string | null> = {
|
||||
// Scope → reason it's blocked by current state, or null
|
||||
'transactions:write': periodMissing
|
||||
? 'No fiscal period covers today\'s date — open a period first'
|
||||
: periodIsLocked
|
||||
? 'Active period is closed/locked'
|
||||
: companyLocked
|
||||
? 'Company-wide bookkeeping lock is in effect'
|
||||
: null,
|
||||
'invoices:write': periodMissing ? 'No fiscal period covers today\'s date' : null,
|
||||
'payroll:write': !settings?.pays_salaries
|
||||
? 'Company is not configured to pay salaries (settings.pays_salaries=false)'
|
||||
: null,
|
||||
}
|
||||
|
||||
const capabilities: Capability[] = Object.entries(TOOL_SCOPE_MAP).map(
|
||||
([tool, scope]) => {
|
||||
const granted = hasScope(scopes, scope)
|
||||
const stateReason = stateBlockers[scope] ?? null
|
||||
return {
|
||||
tool,
|
||||
scope,
|
||||
granted,
|
||||
state_blocked: granted && !!stateReason,
|
||||
reason: !granted
|
||||
? `Scope "${scope}" not granted to this API key`
|
||||
: stateReason,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
granted_scopes: scopes,
|
||||
active_period: activePeriod ?? null,
|
||||
company_lock_date: settings?.bookkeeping_locked_through ?? null,
|
||||
vat_registered: settings?.vat_registered ?? false,
|
||||
pays_salaries: settings?.pays_salaries ?? false,
|
||||
capabilities,
|
||||
summary: {
|
||||
total: capabilities.length,
|
||||
granted: capabilities.filter((c) => c.granted).length,
|
||||
state_blocked: capabilities.filter((c) => c.state_blocked).length,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { McpResource } from './types'
|
||||
|
||||
interface AccountSummary {
|
||||
account_number: string
|
||||
account_name: string
|
||||
account_class: number
|
||||
account_type: string
|
||||
normal_balance: string
|
||||
is_active: boolean
|
||||
default_vat_code: string | null
|
||||
}
|
||||
|
||||
export const chartOfAccountsResource: McpResource = {
|
||||
uri: 'gnubok://chart-of-accounts',
|
||||
name: 'Chart of Accounts (BAS)',
|
||||
description: 'The active BAS chart of accounts for the current company, grouped by account class (1=assets, 2=liabilities/equity, 3=revenue, 4=COGS, 5-7=expenses, 8=financial). Use to look up account numbers before booking entries.',
|
||||
mimeType: 'application/json',
|
||||
read: async ({ supabase, companyId }) => {
|
||||
const { data, error } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.select('account_number, account_name, account_class, account_type, normal_balance, is_active, default_vat_code')
|
||||
.eq('company_id', companyId)
|
||||
.order('account_number', { ascending: true })
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to read chart of accounts: ${error.message}`)
|
||||
}
|
||||
|
||||
const accounts = (data ?? []) as AccountSummary[]
|
||||
|
||||
const byClass: Record<number, AccountSummary[]> = {}
|
||||
for (const a of accounts) {
|
||||
if (!byClass[a.account_class]) byClass[a.account_class] = []
|
||||
byClass[a.account_class].push(a)
|
||||
}
|
||||
|
||||
return {
|
||||
total: accounts.length,
|
||||
classes: {
|
||||
'1': { label: 'Tillgångar', accounts: byClass[1] ?? [] },
|
||||
'2': { label: 'Eget kapital och skulder', accounts: byClass[2] ?? [] },
|
||||
'3': { label: 'Rörelseintäkter', accounts: byClass[3] ?? [] },
|
||||
'4': { label: 'Material- och varukostnader', accounts: byClass[4] ?? [] },
|
||||
'5': { label: 'Övriga externa rörelseutgifter', accounts: byClass[5] ?? [] },
|
||||
'6': { label: 'Övriga externa rörelseutgifter (forts.)', accounts: byClass[6] ?? [] },
|
||||
'7': { label: 'Personalkostnader', accounts: byClass[7] ?? [] },
|
||||
'8': { label: 'Finansiella poster', accounts: byClass[8] ?? [] },
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { McpResource } from './types'
|
||||
|
||||
export const companyCurrentResource: McpResource = {
|
||||
uri: 'gnubok://company/current',
|
||||
name: 'Active Company',
|
||||
description: 'The currently active company: identity, entity type, fiscal year config, lock date, base currency, and VAT registration. Read this first to understand the bookkeeping context.',
|
||||
mimeType: 'application/json',
|
||||
read: async ({ supabase, companyId }) => {
|
||||
const { data: company, error: companyError } = await supabase
|
||||
.from('companies')
|
||||
.select('id, name, org_number, entity_type, archived_at, created_at')
|
||||
.eq('id', companyId)
|
||||
.single()
|
||||
|
||||
if (companyError || !company) {
|
||||
throw new Error(`Company not found: ${companyError?.message ?? 'unknown'}`)
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select(`
|
||||
company_name, trade_name, address_line1, address_line2, postal_code, city, country,
|
||||
phone, email, website,
|
||||
pays_salaries, f_skatt, vat_registered, vat_number, moms_period,
|
||||
fiscal_year_start_month,
|
||||
accounting_method, default_voucher_series,
|
||||
bookkeeping_locked_through, auto_lock_period_days,
|
||||
invoice_prefix, next_invoice_number, invoice_default_days,
|
||||
is_sandbox, ai_flow_enabled
|
||||
`)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
return {
|
||||
company,
|
||||
settings: settings ?? null,
|
||||
base_currency: 'SEK',
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { McpResource, ResourceContext } from './types'
|
||||
import { companyCurrentResource } from './company-current'
|
||||
import { chartOfAccountsResource } from './chart-of-accounts'
|
||||
import { periodActiveResource } from './period-active'
|
||||
import { recentActivityResource } from './recent-activity'
|
||||
import { capabilitiesResource } from './capabilities'
|
||||
import { vatTreatmentsResource } from './vat-treatments'
|
||||
import { attentionResource } from './attention'
|
||||
|
||||
export const dataResources: McpResource[] = [
|
||||
companyCurrentResource,
|
||||
chartOfAccountsResource,
|
||||
periodActiveResource,
|
||||
recentActivityResource,
|
||||
capabilitiesResource,
|
||||
vatTreatmentsResource,
|
||||
attentionResource,
|
||||
]
|
||||
|
||||
export function findResource(uri: string): McpResource | null {
|
||||
// Strip any query string for matching
|
||||
const baseUri = uri.split('?')[0]
|
||||
return dataResources.find((r) => r.uri === baseUri) ?? null
|
||||
}
|
||||
|
||||
export function parseResourceQuery(uri: string): URLSearchParams | undefined {
|
||||
const qIndex = uri.indexOf('?')
|
||||
if (qIndex < 0) return undefined
|
||||
return new URLSearchParams(uri.slice(qIndex + 1))
|
||||
}
|
||||
|
||||
export type { McpResource, ResourceContext }
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { McpResource } from './types'
|
||||
|
||||
export const periodActiveResource: McpResource = {
|
||||
uri: 'gnubok://period/active',
|
||||
name: 'Active Fiscal Period',
|
||||
description: 'The fiscal period that the current date falls within: lock state, opening-balance status, retention deadline. Use to check whether new entries can be posted.',
|
||||
mimeType: 'application/json',
|
||||
read: async ({ supabase, companyId }) => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
const { data: active, error: activeError } = await supabase
|
||||
.from('fiscal_periods')
|
||||
.select('id, name, period_start, period_end, is_closed, closed_at, locked_at, opening_balances_set, retention_expires_at, opening_balance_entry_id, closing_entry_id, previous_period_id')
|
||||
.eq('company_id', companyId)
|
||||
.lte('period_start', today)
|
||||
.gte('period_end', today)
|
||||
.maybeSingle()
|
||||
|
||||
if (activeError && activeError.code !== 'PGRST116') {
|
||||
throw new Error(`Failed to read active period: ${activeError.message}`)
|
||||
}
|
||||
|
||||
const { data: settings } = await supabase
|
||||
.from('company_settings')
|
||||
.select('bookkeeping_locked_through, auto_lock_period_days')
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
|
||||
const periodLockedAt = active?.locked_at ?? null
|
||||
const isClosed = active?.is_closed ?? null
|
||||
const companyLockDate = settings?.bookkeeping_locked_through ?? null
|
||||
|
||||
const canPostEntries = active
|
||||
? !isClosed && !periodLockedAt
|
||||
: false
|
||||
|
||||
return {
|
||||
active_period: active ?? null,
|
||||
company_lock: {
|
||||
bookkeeping_locked_through: companyLockDate,
|
||||
auto_lock_period_days: settings?.auto_lock_period_days ?? null,
|
||||
},
|
||||
can_post_entries: canPostEntries,
|
||||
reason_blocked: !active
|
||||
? 'No fiscal period covers today\'s date'
|
||||
: isClosed
|
||||
? 'Active period is closed (status: stängd)'
|
||||
: periodLockedAt
|
||||
? 'Active period is locked (status: låst)'
|
||||
: null,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { McpResource } from './types'
|
||||
|
||||
export const recentActivityResource: McpResource = {
|
||||
uri: 'gnubok://recent-activity',
|
||||
name: 'Recent Activity',
|
||||
description: 'Most recent journal entries, invoices, and bank transactions for the current company. Optional ?limit=N (default 20, max 100). Use to orient on the latest state without burning tool calls.',
|
||||
mimeType: 'application/json',
|
||||
read: async ({ supabase, companyId, query }) => {
|
||||
const limit = Math.min(Math.max(Number(query?.get('limit') ?? 20), 1), 100)
|
||||
|
||||
const [journalEntries, invoices, transactions] = await Promise.all([
|
||||
supabase
|
||||
.from('journal_entries')
|
||||
.select('id, voucher_number, voucher_series, entry_date, description, status, created_at')
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit),
|
||||
supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, customer_id, invoice_date, due_date, total_amount, currency, status, created_at')
|
||||
.eq('company_id', companyId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit),
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select('id, date, description, amount, currency, journal_entry_id, category, merchant_name')
|
||||
.eq('company_id', companyId)
|
||||
.order('date', { ascending: false })
|
||||
.limit(limit),
|
||||
])
|
||||
|
||||
return {
|
||||
limit,
|
||||
journal_entries: journalEntries.data ?? [],
|
||||
invoices: invoices.data ?? [],
|
||||
transactions: transactions.data ?? [],
|
||||
uncategorized_transaction_count: (transactions.data ?? []).filter(
|
||||
(t) => !t.journal_entry_id
|
||||
).length,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||
import type { ApiKeyScope } from '@/lib/auth/api-keys'
|
||||
|
||||
export interface ResourceContext {
|
||||
supabase: SupabaseClient
|
||||
companyId: string
|
||||
userId: string
|
||||
scopes: ApiKeyScope[]
|
||||
query?: URLSearchParams
|
||||
}
|
||||
|
||||
export interface McpResource {
|
||||
uri: string
|
||||
name: string
|
||||
description: string
|
||||
mimeType: string
|
||||
read: (ctx: ResourceContext) => Promise<unknown>
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { McpResource } from './types'
|
||||
import { getAvailableVatRates, getVatRules } from '@/lib/invoices/vat-rules'
|
||||
import type { CustomerType } from '@/types'
|
||||
|
||||
const CUSTOMER_TYPES: CustomerType[] = ['individual', 'swedish_business', 'eu_business', 'non_eu_business']
|
||||
|
||||
export const vatTreatmentsResource: McpResource = {
|
||||
uri: 'gnubok://settings/vat-treatments',
|
||||
name: 'VAT Treatments',
|
||||
description: 'Available VAT treatments and rates per customer type, and the resulting moms ruta on the VAT declaration. Use before creating invoices to pick the right VAT rate.',
|
||||
mimeType: 'application/json',
|
||||
read: async () => {
|
||||
const matrix: Record<string, unknown> = {}
|
||||
|
||||
for (const ct of CUSTOMER_TYPES) {
|
||||
matrix[ct] = {
|
||||
unvalidated_vat: {
|
||||
rates: getAvailableVatRates(ct, false),
|
||||
default_rule: getVatRules(ct, false),
|
||||
},
|
||||
validated_vat: {
|
||||
rates: getAvailableVatRates(ct, true),
|
||||
default_rule: getVatRules(ct, true),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
treatments: ['standard_25', 'reduced_12', 'reduced_6', 'reverse_charge', 'export', 'exempt'],
|
||||
by_customer_type: matrix,
|
||||
notes: {
|
||||
eu_business_validated: 'Reverse charge applies — invoice 0%, customer self-accounts via moms ruta 39.',
|
||||
non_eu_business: 'Export — invoice 0%, no Swedish VAT, moms ruta 40.',
|
||||
mixed_rate: 'Invoice line items can have individual VAT rates; the engine generates per-rate lines.',
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Helpers for shaping MCP tool results in an agent-actionable form.
|
||||
*
|
||||
* Two additive concepts:
|
||||
* 1. `next` — when a tool succeeds and there's an obvious follow-up tool or
|
||||
* resource the agent should call, expose it directly so Claude doesn't
|
||||
* have to re-derive it from prose.
|
||||
* 2. structured errors — failures include a stable code, English + Swedish
|
||||
* messages, and a remediation hint when one exists.
|
||||
*
|
||||
* Both are folded into the JSON `text` payload that the JSON-RPC handler
|
||||
* already serializes — no protocol change needed, and existing string-only
|
||||
* consumers keep working.
|
||||
*/
|
||||
import { getStructuredError, type StructuredError } from '@/lib/errors/get-structured-error'
|
||||
|
||||
export interface NextActionHint {
|
||||
description: string
|
||||
tool?: string
|
||||
args?: Record<string, unknown>
|
||||
resource?: string
|
||||
}
|
||||
|
||||
export interface AgentToolResult<T = unknown> {
|
||||
data: T
|
||||
next?: NextActionHint
|
||||
}
|
||||
|
||||
export interface AgentToolError {
|
||||
error: StructuredError
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a successful tool payload with an optional `next` hint. Returns the
|
||||
* payload as-is if the input is already wrapped (idempotent), or a plain object
|
||||
* if no hint is supplied.
|
||||
*/
|
||||
export function withNext<T>(data: T, next?: NextActionHint): AgentToolResult<T> {
|
||||
return next ? { data, next } : { data }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a thrown error into the structured tool-error envelope the agent
|
||||
* sees. If the error is a string already containing "Insufficient scope:",
|
||||
* the attempted scope is propagated to the remediation hint so the agent can
|
||||
* surface a precise request to the user.
|
||||
*/
|
||||
export function toToolError(err: unknown, opts: { toolName?: string } = {}): AgentToolError {
|
||||
let attemptedScope: string | undefined
|
||||
const message = err instanceof Error ? err.message : typeof err === 'string' ? err : ''
|
||||
const scopeMatch = message.match(/Insufficient scope: this API key does not have the "([^"]+)" scope/)
|
||||
if (scopeMatch) attemptedScope = scopeMatch[1]
|
||||
|
||||
return {
|
||||
error: getStructuredError(err, { attemptedScope, toolName: opts.toolName }),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user