Files
accounted/extensions/general/mcp-server/__tests__/attention.test.ts
T
Jakob Wennberg d11d0a2e90 feat(reconciliation): match one bank event to several verifikationer (1:N) (#1553) (#2029)
One bank row can now settle several vouchers: journal_entry_id stays NULL and one transaction_voucher_links row per voucher carries a signed allocated_amount slice (sum must equal the row within the link tolerance, each slice bounded by the voucher's net line on the account). linkTransactionToVouchers does the locked transaction UPDATE first and rolls back on a failed junction insert; unlink and the re-booking guards understand junction-only rows; a storno of one of the N vouchers releases the row when the remaining slices no longer sum to its amount. The worksheet's right pane becomes multi-select when exactly one bank row is picked (Koppla only at difference 0); the v1/dashboard pair schemas accept allocations; the MCP reconcile resolver and executor carry 1:N pairs; skattekonto keeps single-pointer semantics. Closes #1553
2026-08-30 11:56:15 +02:00

429 lines
18 KiB
TypeScript

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 15 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. unbookedIds (every pointer-null business row; the junction-linked
// ones are subtracted by a lookup that runs after slot 15 and only
// when this list is non-empty)
enqueue({ data: [] })
// 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 })
// 15. unlinked-document candidates. fetchUnlinkedDocuments returns early on
// an empty candidate set, so it consumes exactly this one slot; with
// candidates it consumes eight more, one per referencing table.
enqueue({ data: [] })
}
describe('Accounted://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({ data: txns.map((t) => ({ id: t.id })) }) // unbookedIds
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('does not count a row anchored only through transaction_voucher_links as unbooked (bulk-book, 1:N split)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const today = new Date().toISOString().slice(0, 10)
const txns = [
{ id: 't-split', date: today, amount: -800, currency: 'SEK', description: 'Utlägg', merchant_name: null },
{ id: 't-open', date: today, amount: -200, currency: 'SEK', description: 'Office', merchant_name: 'Clas Ohlson' },
]
enqueue({ data: txns.map((t) => ({ id: t.id })) }) // 1. unbookedIds
enqueue({ data: txns }) // 2. unbookedSamples
for (let i = 3; i <= 14; i += 1) enqueue({ data: i === 13 || i === 14 ? null : [], count: 0 })
enqueue({ data: [] }) // 15. unlinked-document candidates
enqueue({ data: [{ transaction_id: 't-split' }] }) // 16. fetchJunctionLinkedTxIds
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
const cat = result.categories.find((c) => c.key === 'unbooked_transactions')
expect(cat?.count).toBe(1)
expect(cat?.samples.map((s) => s.id)).toEqual(['t-open'])
expect(cat?.next?.args).toEqual({ transaction_id: 't-open' })
})
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({ data: [{ id: 't-old' }] })
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('surfaces documents that nothing references, pointing at the link tool', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
// Slots 1-14 empty, then this test's own candidate set in slot 15 and the
// eight reference lookups behind it.
for (let i = 0; i < 14; i += 1) enqueue({ data: i === 12 || i === 13 ? null : [], count: 0 })
enqueue({
data: [
{ id: 'doc-new', file_name: 'kvitto.pdf', mime_type: 'application/pdf', file_size_bytes: 900, upload_source: 'api', created_at: '2026-08-20T00:00:00.000Z' },
{ id: 'doc-old', file_name: 'faktura.pdf', mime_type: 'application/pdf', file_size_bytes: 900, upload_source: 'file_upload', created_at: '2026-08-01T00:00:00.000Z' },
],
})
for (let i = 0; i < 8; i += 1) enqueue({ data: [] })
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
const cat = result.categories.find((c) => c.key === 'unlinked_documents')
expect(cat).toBeDefined()
expect(cat?.count).toBe(2)
expect(cat?.severity).toBe('warning')
expect(cat?.next?.tool).toBe('gnubok_link_document_to_voucher')
// Oldest first, matching every other category's "start with the stalest" hint.
expect(cat?.next?.args).toEqual({ document_id: 'doc-old' })
})
it('omits the unlinked-documents category when every candidate is claimed', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
for (let i = 0; i < 14; i += 1) enqueue({ data: i === 12 || i === 13 ? null : [], count: 0 })
enqueue({
data: [
{ id: 'doc-1', file_name: 'kvitto.pdf', mime_type: 'application/pdf', file_size_bytes: 900, upload_source: 'api', created_at: '2026-08-20T00:00:00.000Z' },
],
})
// The first referencing table claims it; the remaining seven return empty.
enqueue({ data: [{ document_id: 'doc-1' }] })
for (let i = 0; i < 7; i += 1) enqueue({ data: [] })
const result = (await attentionResource.read(ctx(supabase))) as AttentionResponse
expect(result.categories.find((c) => c.key === 'unlinked_documents')).toBeUndefined()
})
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
enqueue({ data: [] }) // unlinked-document candidates
// 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({ data: [{ id: 't-1' }] }) // unbookedIds
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)
})
})
describe('Accounted://attention: reconciliation_due', () => {
it('adds the category when signed-off accounts have fallen behind the previous month end', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueEmpty(enqueue)
// countReconciliationDue: sign-offs (adoption + coverage), cash accounts, skattekonto rows.
enqueue({
data: [{ account_key: 'skattekonto', through_date: '2020-01-31', reopened_at: null }],
})
enqueue({ data: [{ id: '11111111-1111-4111-8111-111111111111', iban: null, currency: 'SEK', updated_at: null }] })
enqueue({ count: 3 })
const out = (await attentionResource.read(ctx(supabase))) as AttentionResponse
const cat = out.categories.find((c) => c.key === 'reconciliation_due')
expect(cat).toBeDefined()
// The bank account and the skattekonto are both due.
expect(cat?.count).toBe(2)
expect(cat?.next?.resource).toBe('Accounted://reconciliation/summary')
})
it('stays silent for a company that never signed anything off', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueueEmpty(enqueue)
enqueue({ data: [] })
const out = (await attentionResource.read(ctx(supabase))) as AttentionResponse
expect(out.categories.find((c) => c.key === 'reconciliation_due')).toBeUndefined()
})
})