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>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3cf2e10740
commit
8f68421fef
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 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/)
|
||||
})
|
||||
})
|
||||
@@ -279,6 +279,12 @@ import { writeSkatteverketAudit } from '@/extensions/general/skatteverket/lib/au
|
||||
import { skvAuthCodeToStructured } from '@/extensions/general/skatteverket/lib/error-map'
|
||||
import { findCompanyTokenUser, hasVerifiedGrant } from '@/extensions/general/skatteverket/lib/resolve-auth'
|
||||
import { getSystemAuthMode, isSystemAuthConfigured } from '@/extensions/general/skatteverket/lib/system-auth/config'
|
||||
// Stage-time preview for book_skattekonto_row(s): same deterministic rule
|
||||
// matcher the skattekonto list page and the booking commit path use. The
|
||||
// actual booking runs in the extension's registry-resolved commit service on
|
||||
// approval; staging never books.
|
||||
import { attachBookingSuggestions } from '@/extensions/general/skatteverket/lib/skattekonto-booking'
|
||||
import { SKATTEKONTO_ACCOUNT } from '@/lib/skatteverket/manual-verifikat-prefill'
|
||||
import { formatRedovisningsperiod } from '@/lib/skatteverket/format'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import { commitPendingOperation } from '@/lib/pending-operations/commit'
|
||||
@@ -974,6 +980,24 @@ function mapSkatteverketError(err: unknown): Error {
|
||||
return err instanceof Error ? err : new Error(String(err))
|
||||
}
|
||||
|
||||
// ── Skattekonto row booking (stage side) ─────────────────────
|
||||
//
|
||||
// Shape of the skattekonto_transactions columns the book_skattekonto_row(s)
|
||||
// tools select at stage time (both call sites keep the select string literal
|
||||
// so the no-phantom-columns scanner can resolve it): enough for the reviewer
|
||||
// preview + the same bookability gates the commit path enforces
|
||||
// (requireSettled batch booking in skatteverket/lib/skattekonto-booking.ts).
|
||||
|
||||
interface SkattekontoStageRow {
|
||||
id: string
|
||||
transaktionsdatum: string
|
||||
transaktionstext: string
|
||||
belopp_skatteverket: number | string
|
||||
status: string
|
||||
is_ignored: boolean | null
|
||||
journal_entry_id: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Count ERROR-level findings in a /kontrollera response body.
|
||||
*
|
||||
@@ -11393,6 +11417,230 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_book_skattekonto_row',
|
||||
title: 'Book Skattekonto Row',
|
||||
description:
|
||||
'Book one settled skattekonto row as a posted verifikat: 1630 against the counter account matched from skattekonto rules. Refused for already-booked, ignored, upcoming or rule-less rows. Stages; booking happens at approval. dry_run previews.',
|
||||
catalogVisibility: 'search',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
skattekonto_transaction_id: {
|
||||
type: 'string',
|
||||
description: 'The skattekonto_transactions row id (from the skattekonto reconciliation bridge).',
|
||||
},
|
||||
dry_run: { type: 'boolean' },
|
||||
idempotency_key: { type: 'string' },
|
||||
},
|
||||
required: ['skattekonto_transaction_id'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const transactionId = args.skattekonto_transaction_id
|
||||
if (typeof transactionId !== 'string' || transactionId.length === 0) {
|
||||
throw new Error('skattekonto_transaction_id is required')
|
||||
}
|
||||
|
||||
const { data: row } = await supabase
|
||||
.from('skattekonto_transactions')
|
||||
.select('id, transaktionsdatum, transaktionstext, belopp_skatteverket, status, is_ignored, journal_entry_id')
|
||||
.eq('id', transactionId)
|
||||
.eq('company_id', companyId)
|
||||
.maybeSingle()
|
||||
if (!row) throw new Error('Skattekonto-transaktionen hittades inte.')
|
||||
const tx = row as SkattekontoStageRow
|
||||
|
||||
// Same gates the commit path enforces (requireSettled batch booking),
|
||||
// surfaced at stage time so the reviewer never approves a doomed op.
|
||||
if (tx.journal_entry_id) throw new Error('Transaktionen är redan bokförd.')
|
||||
if (tx.is_ignored) {
|
||||
throw new Error('Transaktionen är ignorerad. Återställ den innan du bokför.')
|
||||
}
|
||||
if (tx.status !== 'booked') {
|
||||
throw new Error('Händelsen är inte genomförd hos Skatteverket ännu och kan inte bokföras.')
|
||||
}
|
||||
|
||||
const [enriched] = await attachBookingSuggestions(supabase, companyId, [tx])
|
||||
if (!enriched.booking_suggestion) {
|
||||
throw new Error(
|
||||
enriched.booking_gate === 'requires_employer'
|
||||
? 'Ingen motkontoregel: raden kräver arbetsgivarregistrering (troligen privat A-skatt). Bokför den manuellt.'
|
||||
: 'Ingen motkontoregel matchade raden. Bokför den manuellt med gnubok_create_voucher.',
|
||||
)
|
||||
}
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
'book_skattekonto_row',
|
||||
`Bokför skattekontohändelse: ${tx.transaktionstext}`,
|
||||
{ transaction_id: tx.id },
|
||||
{
|
||||
skattekonto_transaction_id: tx.id,
|
||||
transaction_date: tx.transaktionsdatum,
|
||||
transaction_text: tx.transaktionstext,
|
||||
// Mirrors the exact verifikat text bokforSkattekontoTransaction
|
||||
// writes, so the reviewer sees what lands in the ledger (motpart
|
||||
// Skatteverket is carried in the text + the Skatteverket-id note).
|
||||
verifikat_description: `Skattekonto: ${tx.transaktionstext}`,
|
||||
amount: Number(tx.belopp_skatteverket),
|
||||
skattekonto_account: SKATTEKONTO_ACCOUNT,
|
||||
suggested_counter_account: enriched.booking_suggestion.account,
|
||||
suggested_counter_account_name: enriched.booking_suggestion.account_name,
|
||||
rule_label: enriched.booking_suggestion.label,
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description:
|
||||
'After approval the row lands as a posted verifikat. Re-read the skattekonto bridge to confirm it shows as booked.',
|
||||
tool: 'gnubok_get_reconciliation_status',
|
||||
args: { account_key: 'skattekonto' },
|
||||
},
|
||||
{
|
||||
dryRun: args.dry_run === true,
|
||||
idempotencyKey: args.idempotency_key as string | undefined,
|
||||
dateForPeriodCheck: tx.transaktionsdatum,
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_book_skattekonto_rows',
|
||||
title: 'Book Skattekonto Rows (Batch)',
|
||||
description:
|
||||
'Book up to 200 settled skattekonto rows as posted verifikat (1630 + rule-matched counter account per row). Unbookable rows (already booked, ignored, upcoming, no rule) are skipped and listed in the preview. Stages; booking happens at approval. dry_run previews.',
|
||||
catalogVisibility: 'search',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
skattekonto_transaction_ids: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
minItems: 1,
|
||||
maxItems: 200,
|
||||
description: 'skattekonto_transactions row ids to book (duplicates are ignored).',
|
||||
},
|
||||
dry_run: { type: 'boolean' },
|
||||
idempotency_key: { type: 'string' },
|
||||
},
|
||||
required: ['skattekonto_transaction_ids'],
|
||||
},
|
||||
outputSchema: STAGED_OPERATION_SCHEMA,
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
async execute(args, companyId, userId, supabase, actor) {
|
||||
const rawIds = args.skattekonto_transaction_ids
|
||||
if (!Array.isArray(rawIds) || rawIds.length === 0) {
|
||||
throw new Error('skattekonto_transaction_ids is required (non-empty array)')
|
||||
}
|
||||
const ids = [
|
||||
...new Set(rawIds.filter((v): v is string => typeof v === 'string' && v.length > 0)),
|
||||
]
|
||||
if (ids.length === 0 || ids.length > 200) {
|
||||
throw new Error('skattekonto_transaction_ids must contain 1-200 row ids')
|
||||
}
|
||||
|
||||
const { data: rows } = await supabase
|
||||
.from('skattekonto_transactions')
|
||||
.select('id, transaktionsdatum, transaktionstext, belopp_skatteverket, status, is_ignored, journal_entry_id')
|
||||
.in('id', ids)
|
||||
.eq('company_id', companyId)
|
||||
const found = (rows ?? []) as SkattekontoStageRow[]
|
||||
|
||||
const foundIds = new Set(found.map((r) => r.id))
|
||||
const skipped: Array<{ skattekonto_transaction_id: string; reason: string }> = []
|
||||
for (const id of ids) {
|
||||
if (!foundIds.has(id)) {
|
||||
skipped.push({ skattekonto_transaction_id: id, reason: 'TRANSACTION_NOT_FOUND' })
|
||||
}
|
||||
}
|
||||
|
||||
// Same per-row gates as the single-row tool; blocked rows become
|
||||
// skipped-with-reason preview data instead of aborting the batch.
|
||||
const enriched = await attachBookingSuggestions(supabase, companyId, found)
|
||||
const bookable: typeof enriched = []
|
||||
for (const r of enriched) {
|
||||
const reason = r.journal_entry_id
|
||||
? 'ALREADY_BOOKED'
|
||||
: r.is_ignored
|
||||
? 'ROW_IGNORED'
|
||||
: r.status !== 'booked'
|
||||
? 'NOT_SETTLED'
|
||||
: !r.booking_suggestion
|
||||
? 'NO_COUNTER_ACCOUNT'
|
||||
: null
|
||||
if (reason) {
|
||||
skipped.push({ skattekonto_transaction_id: r.id, reason })
|
||||
continue
|
||||
}
|
||||
bookable.push(r)
|
||||
}
|
||||
if (bookable.length === 0) {
|
||||
const reasons = [...new Set(skipped.map((s) => s.reason))].join(', ')
|
||||
throw new Error(`Inga bokförbara rader: alla ${ids.length} hoppades över (${reasons}).`)
|
||||
}
|
||||
|
||||
const bookableIds = bookable.map((r) => r.id)
|
||||
// Oldest row first: lock conflicts live in the past, so the period
|
||||
// check should probe the earliest affärshändelse date in the batch.
|
||||
const earliestDate = bookable.map((r) => r.transaktionsdatum).sort()[0]
|
||||
const totalAmount =
|
||||
Math.round(bookable.reduce((sum, r) => sum + Number(r.belopp_skatteverket), 0) * 100) / 100
|
||||
|
||||
return stagePendingOperation(
|
||||
supabase,
|
||||
companyId,
|
||||
userId,
|
||||
'book_skattekonto_rows',
|
||||
`Bokför ${bookableIds.length} skattekontohändelser`,
|
||||
{ ids: bookableIds },
|
||||
{
|
||||
row_count: bookableIds.length,
|
||||
total_amount: totalAmount,
|
||||
skattekonto_account: SKATTEKONTO_ACCOUNT,
|
||||
rows: bookable.slice(0, 50).map((r) => ({
|
||||
skattekonto_transaction_id: r.id,
|
||||
transaction_date: r.transaktionsdatum,
|
||||
transaction_text: r.transaktionstext,
|
||||
verifikat_description: `Skattekonto: ${r.transaktionstext}`,
|
||||
amount: Number(r.belopp_skatteverket),
|
||||
suggested_counter_account: r.booking_suggestion?.account ?? null,
|
||||
rule_label: r.booking_suggestion?.label ?? null,
|
||||
})),
|
||||
...(bookable.length > 50 ? { rows_truncated: true } : {}),
|
||||
...(skipped.length > 0 ? { skipped } : {}),
|
||||
},
|
||||
actor,
|
||||
{
|
||||
description:
|
||||
'After approval each row lands as its own posted verifikat. Re-read the skattekonto bridge to confirm.',
|
||||
tool: 'gnubok_get_reconciliation_status',
|
||||
args: { account_key: 'skattekonto' },
|
||||
},
|
||||
{
|
||||
dryRun: args.dry_run === true,
|
||||
idempotencyKey: args.idempotency_key as string | undefined,
|
||||
dateForPeriodCheck: earliestDate,
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'gnubok_list_cash_accounts',
|
||||
title: 'List Cash Accounts',
|
||||
|
||||
@@ -30,7 +30,10 @@ import { currentSkvEnvironment, resolveReadAuth } from './lib/resolve-auth'
|
||||
import { probeCompanyGrants } from './lib/grant-probe'
|
||||
import { formatRedovisare } from '@/lib/skatteverket/format'
|
||||
import { createExtensionContext } from '@/lib/extensions/context-factory'
|
||||
import type { SkvSubmitResult } from '@/lib/pending-operations/skatteverket-commit'
|
||||
import type {
|
||||
SkvSubmitResult,
|
||||
SkattekontoBookCommitResult,
|
||||
} from '@/lib/pending-operations/skatteverket-commit'
|
||||
import {
|
||||
agiPostUnderlag,
|
||||
agiGetKontrollresultat,
|
||||
@@ -2503,6 +2506,9 @@ export const skatteverketExtension: Extension = {
|
||||
services: {
|
||||
commitSubmitVatDeclaration,
|
||||
commitSubmitAgi,
|
||||
// Commit side of the staged book_skattekonto_row(s) MCP ops: books
|
||||
// already-synced skattekonto rows through the bookkeeping engine.
|
||||
commitBookSkattekontoRows,
|
||||
// Read service for the v1 REST endpoint (issue #1663): filed
|
||||
// momsdeklarationer (inlamnat) and beslut (beslutat). Contract in
|
||||
// lib/skatteverket/declaration-status.ts.
|
||||
@@ -2694,6 +2700,66 @@ const EXTENSION_DISABLED_RESULT: Extract<SkvSubmitResult, { ok: false }> = {
|
||||
error: 'Skatteverket-integrationen är inte aktiverad i denna miljö.',
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit service for the staged book_skattekonto_row / book_skattekonto_rows
|
||||
* MCP operations. Registry-resolved by lib/pending-operations/commit.ts on
|
||||
* approval. Books already-synced skattekonto_transactions rows as posted
|
||||
* verifikat through the SAME batch helper the HTTP bokfor-batch route uses
|
||||
* (draft + commit per row via the bookkeeping engine, requireSettled): no
|
||||
* SKV API call is involved. Row failures come back as per-row data, never as
|
||||
* a thrown error, so a partial batch commits with the failures listed.
|
||||
*
|
||||
* Gated on SKATTEVERKET_ENABLED for parity with the HTTP surface: the
|
||||
* dispatcher blocks the whole extension route family behind that flag, so a
|
||||
* direct lib call must not book where the web app could not. Recoverable:
|
||||
* the op stays reviewable and a re-approve works once the env is enabled.
|
||||
*/
|
||||
async function commitBookSkattekontoRows(
|
||||
supabase: SupabaseClient,
|
||||
userId: string,
|
||||
companyId: string,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<SkattekontoBookCommitResult> {
|
||||
if (!skatteverketEnabled()) return EXTENSION_DISABLED_RESULT
|
||||
|
||||
const raw = params.ids
|
||||
const ids = Array.isArray(raw)
|
||||
? [...new Set(raw.filter((v): v is string => typeof v === 'string' && v.length > 0))]
|
||||
: []
|
||||
if (ids.length === 0 || ids.length > 200) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'INVALID_PARAMS',
|
||||
http_status: 400,
|
||||
recoverable: false,
|
||||
error: 'ids måste vara en lista med 1-200 transaktions-id.',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await bokforSkattekontoTransactionsBatch(supabase, companyId, userId, ids)
|
||||
return { ok: true, ...result }
|
||||
} catch (err) {
|
||||
// bokforSkattekontoTransactionsBatch catches per-row errors itself; a
|
||||
// throw here is a batch-level failure (e.g. rule-context load): internal,
|
||||
// non-recoverable, the user re-stages after the underlying issue is fixed.
|
||||
log.error('commitBookSkattekontoRows failed', {
|
||||
companyId,
|
||||
rowCount: ids.length,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
// Fixed public message: the raw exception stays in the server log above
|
||||
// and must not flow back to API callers (it can carry DB/row details).
|
||||
return {
|
||||
ok: false,
|
||||
code: 'SKATTEKONTO_BOOKING_FAILED',
|
||||
http_status: 500,
|
||||
recoverable: false,
|
||||
error: 'Skattekonto-raderna kunde inte bokföras. Försök igen eller kontakta support.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a thrown error inside a commit service to a SkvSubmitResult.
|
||||
* SkatteverketAuthError (connection / scope / quota) is recoverable: the op
|
||||
|
||||
Reference in New Issue
Block a user