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:
Mattsson
2026-05-04 11:12:29 +02:00
committed by GitHub
parent 5e1b0f791d
commit bb855d2ddc
46 changed files with 6507 additions and 971 deletions
@@ -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')
})
})