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:
Mattsson
2026-08-30 16:38:30 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 3cf2e10740
commit 8f68421fef
15 changed files with 1049 additions and 1 deletions
@@ -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/)
})
})
+248
View File
@@ -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',
+67 -1
View File
@@ -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