Files
accounted/extensions/general/mcp-server/__tests__/skattekonto-booking-tools.test.ts
T
MattssonandClaude Fable 5 8f68421fef feat(skatteverket): expose skattekonto row booking via MCP staged operations (#2039)
* feat(skatteverket): expose skattekonto row booking via MCP staged operations

Skattekonto READ and RECONCILE were already MCP tools, but BOOKING rows
existed only behind the cookie-session extension routes. This closes the
flow on API-key surfaces:

- New staged MCP write tools gnubok_book_skattekonto_row and
  gnubok_book_skattekonto_rows (batch, 1-200 ids), catalogVisibility
  'search', STAGED_OPERATION_SCHEMA, stage-time bookability gates and
  rule-matched counter-account preview. Staging never books.
- New pending-operation types book_skattekonto_row / book_skattekonto_rows
  (CHECK constraint migration pair, risk tier medium, sv/en labels).
- Commit executor reaches the skatteverket extension through the
  registry-resolved services channel (core never imports @/extensions):
  new commitBookSkattekontoRows service wraps the SAME
  bokforSkattekontoTransactionsBatch helper the HTTP bokfor-batch route
  uses (draft + commit per row via the bookkeeping engine, requireSettled),
  with the approving user id passed explicitly (auth.uid() is NULL on the
  service client).

No booking math or account mapping changed: auth-surface exposure only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* chore(migrations): move book_skattekonto constraint pair after main's newest version

origin/main gained 20260830101500 while this branch was in flight; keep
the new pending_operations constraint migrations sorted after it so they
apply in order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(skatteverket): resolve CI and review findings for skattekonto booking tools

- Inline the skattekonto_transactions select strings at both stage-time
  call sites so the no-phantom-columns scanner can resolve them (the
  shared const pushed the unresolvable-expression count past the
  ceiling and failed Unit tests 3/4).
- Return a fixed public message from the commitBookSkattekontoRows
  batch-level catch instead of the raw exception text; the raw error
  stays in the server log (Superagent P2).
- Add verifikat_description to the staged previews so the reviewer sees
  the exact ledger text the booking helper writes, Skatteverket motpart
  included (Swedish accounting review).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 16:38:30 +02:00

265 lines
11 KiB
TypeScript

/**
* Unit tests for gnubok_book_skattekonto_row / gnubok_book_skattekonto_rows:
* registration/scope/risk wiring, input validation, tenant-scoped not-found,
* the stage-time bookability gates (already booked / ignored / unsettled /
* no rule), and the staged-approval contract: staging must never book; the
* booking runs only when the approved op's commit executor dispatches into
* the skatteverket extension (covered in
* lib/pending-operations/__tests__/skattekonto-book-executor.test.ts).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { createQueuedMockSupabase } from '@/tests/helpers'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
// Stage-time preview enrichment is mocked so the tests control the rule
// outcome; the booking functions are mocked too so any call to them from the
// staging path (which must never happen) is caught.
const mockAttach = vi.fn()
const mockBokforSingle = vi.fn()
const mockBokforBatch = vi.fn()
vi.mock('@/extensions/general/skatteverket/lib/skattekonto-booking', () => ({
attachBookingSuggestions: (...a: unknown[]) => mockAttach(...a),
bokforSkattekontoTransaction: (...a: unknown[]) => mockBokforSingle(...a),
bokforSkattekontoTransactionsBatch: (...a: unknown[]) => mockBokforBatch(...a),
}))
import { tools } from '../server'
const single = tools.find((t) => t.name === 'gnubok_book_skattekonto_row')!
const batch = tools.find((t) => t.name === 'gnubok_book_skattekonto_rows')!
const noopSupabase = { from: vi.fn() } as never
const ROW = {
id: 'skv-tx-1',
transaktionsdatum: '2026-03-12',
transaktionstext: 'Debiterad preliminärskatt',
belopp_skatteverket: -5000,
status: 'booked',
is_ignored: false,
journal_entry_id: null,
}
const SUGGESTION = {
account: '2518',
account_name: 'Betald F-skatt',
label: 'Debiterad preliminärskatt',
}
/** Default: pass rows through with a matched suggestion, like the real matcher. */
function attachWithSuggestion(): void {
mockAttach.mockImplementation(async (_supabase, _companyId, rows: (typeof ROW)[]) =>
rows.map((r) => ({ ...r, booking_suggestion: SUGGESTION })),
)
}
beforeEach(() => {
vi.clearAllMocks()
attachWithSuggestion()
})
describe('book skattekonto tools: registration', () => {
it('both tools are registered, search-only, staged-schema, strict-input', () => {
for (const t of [single, batch]) {
expect(t).toBeDefined()
expect(t.catalogVisibility).toBe('search')
expect((t.inputSchema as { additionalProperties?: boolean }).additionalProperties).toBe(false)
const out = t.outputSchema as { properties?: Record<string, unknown>; required?: string[] }
expect(out?.properties?.staged).toBeDefined()
expect(out?.required).toContain('staged')
expect(t.description).toMatch(/stag(e|es|ing)/i)
expect(t.annotations.readOnlyHint).toBe(false)
}
})
it('is mapped to transactions:write scope (API keys without it get 403)', () => {
expect(TOOL_SCOPE_MAP.gnubok_book_skattekonto_row).toBe('transactions:write')
expect(TOOL_SCOPE_MAP.gnubok_book_skattekonto_rows).toBe('transactions:write')
})
it('both op types are tiered medium (bounded rule-driven booking)', () => {
expect(OPERATION_RISK_TIERS.book_skattekonto_row).toBe('medium')
expect(OPERATION_RISK_TIERS.book_skattekonto_rows).toBe('medium')
})
})
describe('gnubok_book_skattekonto_row: validation gates', () => {
it('rejects a missing skattekonto_transaction_id before any DB call', async () => {
await expect(
single.execute({}, 'company-1', 'user-1', noopSupabase),
).rejects.toThrow(/skattekonto_transaction_id/)
})
it('rejects when the row does not exist in this company (wrong tenant)', async () => {
const { supabase, findCalls } = createQueuedMockSupabase()
// Queue empty: the row fetch resolves { data: null }.
await expect(
single.execute(
{ skattekonto_transaction_id: 'skv-tx-other-company' },
'company-1', 'user-1', supabase as never,
),
).rejects.toThrow(/hittades inte/)
// The lookup is company-scoped: defense in depth alongside RLS.
expect(findCalls('skattekonto_transactions', 'eq')).toContainEqual(['company_id', 'company-1'])
})
it('rejects an already-booked row at stage time', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { ...ROW, journal_entry_id: 'je-9' } })
await expect(
single.execute({ skattekonto_transaction_id: ROW.id }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/redan bokförd/)
})
it('rejects an ignored row at stage time', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { ...ROW, is_ignored: true } })
await expect(
single.execute({ skattekonto_transaction_id: ROW.id }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/ignorerad/)
})
it('rejects an unsettled (kommande) row at stage time', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { ...ROW, status: 'upcoming' } })
await expect(
single.execute({ skattekonto_transaction_id: ROW.id }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/inte genomförd/)
})
it('rejects a row with no matching counter-account rule', async () => {
mockAttach.mockImplementation(async (_s, _c, rows: (typeof ROW)[]) =>
rows.map((r) => ({ ...r, booking_suggestion: null })),
)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { ...ROW } })
await expect(
single.execute({ skattekonto_transaction_id: ROW.id }, 'company-1', 'user-1', supabase as never),
).rejects.toThrow(/motkontoregel/)
})
})
describe('gnubok_book_skattekonto_row: staging behaviour', () => {
it('dry_run previews the booking without staging or booking anything', async () => {
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: { ...ROW } })
const result = (await single.execute(
{ skattekonto_transaction_id: ROW.id, dry_run: true },
'company-1', 'user-1', supabase as never,
)) as { staged: boolean; dry_run?: boolean; preview: Record<string, unknown> }
expect(result.dry_run).toBe(true)
expect(result.staged).toBe(false)
expect(result.preview).toMatchObject({
skattekonto_transaction_id: ROW.id,
transaction_date: '2026-03-12',
amount: -5000,
skattekonto_account: '1630',
suggested_counter_account: '2518',
rule_label: 'Debiterad preliminärskatt',
})
expect(findCall('pending_operations', 'insert')).toBeUndefined()
expect(mockBokforSingle).not.toHaveBeenCalled()
expect(mockBokforBatch).not.toHaveBeenCalled()
})
it('stages a pending op that requires approval; booking never runs at stage time', async () => {
const { supabase, enqueue, findCall, calls } = createQueuedMockSupabase()
enqueue({ data: { ...ROW } }) // row fetch
enqueue({ data: null }) // period check: company_settings
enqueue({ data: null }) // period check: fiscal_periods
enqueue({ data: { id: 'op-1' } }) // pending_operations insert
const result = (await single.execute(
{ skattekonto_transaction_id: ROW.id },
'company-1', 'user-1', supabase as never, { type: 'api_key' },
)) as {
staged: boolean
operation_id?: string
risk_level: string
approve?: { tool: string; args: Record<string, unknown> }
}
expect(result.staged).toBe(true)
expect(result.operation_id).toBe('op-1')
expect(result.risk_level).toBe('medium')
// The explicit approval contract: the booking happens only through the
// approve tool, never as a side-effect of staging.
expect(result.approve).toEqual({
tool: 'gnubok_approve_pending_operation',
args: { operation_id: 'op-1', company_id: 'company-1' },
})
const inserted = findCall('pending_operations', 'insert')?.[0] as Record<string, unknown>
expect(inserted).toMatchObject({
operation_type: 'book_skattekonto_row',
params: { transaction_id: ROW.id },
risk_level: 'medium',
})
// No journal write of any kind at stage time.
expect(calls.some((c) => c.table === 'journal_entries')).toBe(false)
expect(mockBokforSingle).not.toHaveBeenCalled()
expect(mockBokforBatch).not.toHaveBeenCalled()
})
})
describe('gnubok_book_skattekonto_rows: batch staging', () => {
it('rejects an empty id list before any DB call', async () => {
await expect(
batch.execute({ skattekonto_transaction_ids: [] }, 'company-1', 'user-1', noopSupabase),
).rejects.toThrow(/skattekonto_transaction_ids/)
})
it('rejects more than 200 ids before any DB call', async () => {
const ids = Array.from({ length: 201 }, (_, i) => `skv-tx-${i}`)
await expect(
batch.execute({ skattekonto_transaction_ids: ids }, 'company-1', 'user-1', noopSupabase),
).rejects.toThrow(/1-200/)
})
it('stages only bookable rows; blocked and foreign rows land in skipped with reasons', async () => {
const rows = [
{ ...ROW, id: 'skv-ok' },
{ ...ROW, id: 'skv-booked', journal_entry_id: 'je-1' },
{ ...ROW, id: 'skv-upcoming', status: 'upcoming' },
]
const { supabase, enqueue, findCall } = createQueuedMockSupabase()
enqueue({ data: rows }) // batch row fetch (skv-foreign not returned)
enqueue({ data: null }) // period check: company_settings
enqueue({ data: null }) // period check: fiscal_periods
enqueue({ data: { id: 'op-2' } }) // pending_operations insert
const result = (await batch.execute(
{ skattekonto_transaction_ids: ['skv-ok', 'skv-booked', 'skv-upcoming', 'skv-foreign'] },
'company-1', 'user-1', supabase as never,
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
const inserted = findCall('pending_operations', 'insert')?.[0] as {
operation_type: string
params: { ids: string[] }
}
expect(inserted.operation_type).toBe('book_skattekonto_rows')
expect(inserted.params.ids).toEqual(['skv-ok'])
expect(result.preview.row_count).toBe(1)
expect(result.preview.skipped).toEqual([
{ skattekonto_transaction_id: 'skv-foreign', reason: 'TRANSACTION_NOT_FOUND' },
{ skattekonto_transaction_id: 'skv-booked', reason: 'ALREADY_BOOKED' },
{ skattekonto_transaction_id: 'skv-upcoming', reason: 'NOT_SETTLED' },
])
expect(mockBokforBatch).not.toHaveBeenCalled()
})
it('refuses to stage when no row is bookable, naming the reasons', async () => {
const rows = [{ ...ROW, id: 'skv-booked', journal_entry_id: 'je-1' }]
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: rows })
await expect(
batch.execute(
{ skattekonto_transaction_ids: ['skv-booked'] },
'company-1', 'user-1', supabase as never,
),
).rejects.toThrow(/ALREADY_BOOKED/)
})
})