diff --git a/DECISIONS.md b/DECISIONS.md index ca753548..d7681aa8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1357,5 +1357,6 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-29] get_vat_ruta_source_lines ACL restored in a NEW migration (20260829090500) rather than by editing 20260828172003: that file DROPped the 9-arg overload and CREATEd the 11-arg one without restating REVOKE/GRANT, and DROP FUNCTION discards the ACL, so the new signature silently fell back to EXECUTE for PUBLIC (anon included); the migration is already applied on prod, so a follow-up file is the only compliant path. Rule going forward: every DROP + CREATE of an RPC must restate its REVOKE ALL FROM PUBLIC, anon / GRANT EXECUTE TO authenticated, service_role, and tests/pg/vat-ruta-drilldown-reconcile.pg.test.ts now pins it with has_function_privilege (anon false, authenticated and service_role true, exactly one overload). [2026-08-29] PR #1756 replacement (rebind on PSD2 remap, amends the 2026-07-09 #916 entry): when upsertFromPsd2 resolves a duplicate row for the same connection+uid, the duplicate's MOVABLE transactions (unbooked, unmatched, not anchored via transaction_voucher_links or a payment row: the #1570 single-row move gate) are rebound onto the promoted row BEFORE the duplicate is resolved, so categorize/booking proposes the ledger the user just mapped instead of the overflow slot; a duplicate that still holds booked or anchored rows is demoted to manual as before and never deleted (their vouchers carry the old 19xx line, and the #1643 orphan guards handle the released twin). The contributor's unconditional rebind-all-then-delete was narrowed for that reason. [2026-08-29] Database errors now keep their SQLSTATE: new lib/errors/db-error.ts (dbError/errorCauseTag), applied at the 54 `throw new Error(\`Database error: ${err.message}\`)` sites in the MCP server AND, far more importantly, at lib/supabase/fetch-all.ts:74 where `throw new Error(error.message)` was the single highest-traffic strip point in the codebase (31 callers; every paginated read). isTransientFailure() checks the driver code FIRST and 57014 (statement timeout) is already in TRANSIENT_SQLSTATES, so discarding it turned a retryable timeout into UNKNOWN_ERROR ("Något gick fel. Försök igen."), which an agent cannot dispatch on. Traced end to end: gnubok_query_journal -> fetchEntryLines -> fetchAllRows (code stripped here) -> the tool's own sanitizeDbError, which ALREADY had a correct TRANSIENT_ERROR branch with a "retry or narrow with date_from/date_to" hint that could never fire because getStructuredError saw an anonymous Error. Measured on prod over 60 days with bot actors excluded: 1 024 real-agent failures, 645 UNKNOWN_ERROR across 60 actors and 57 companies; query_journal failed 164 times at p50 8 110 ms while every other failing tool sat at 1-315 ms; 82 retry streaks, 462 wasted repeat calls, 53.1% of error calls inside a streak. fetch-all passes context=null so the driver message stays VERBATIM (sanitizeDbError and other callers match on the existing text; this change adds the code, it does not reword). Attaching `code` is safe because extractCode() only accepts /^[A-Z_]+$/ and every SQLSTATE/PostgREST code contains digits, so it cannot hijack the application error registry (pinned by a test). dbError also never renders the literal "undefined": a driver-level failure with no message produced "Database error: undefined", the string that made these unsearchable. errorCauseTag() returns a PII-safe SQLSTATE for telemetry; the raw driver message can quote row values in a constraint violation and belongs in the server log, never in event_log. NOT ratcheted: check:types reports 538 vs baseline 539 because main fixed an unrelated error in own-account-detector.test.ts after the baseline was set; the gate only fails on an INCREASE, so the baseline is left alone rather than adding unrelated churn to this diff. +[2026-08-30] book_skattekonto_row(s) tier 'medium' + scope 'transactions:write': rule-driven booking with no caller-supplied lines mirrors book_mileage_period (not create_voucher's 'high'); scope follows reconcile_residual (books an outside row). Commit service gates on SKATTEVERKET_ENABLED for HTTP-dispatcher parity, recoverable so the op stays pending. [2026-08-30] Reminder text overrides (company_settings.reminder_text_overrides, level_1..3 x subject/body): the defaults are expressed as placeholder patterns (REMINDER_EMAIL_DEFAULT_TEXTS) and BOTH the stock mail and overrides render through the same substitution pipeline (applyPlaceholders + escape per output variant), so the settings-UI prefill is byte-for-byte the mail that goes out and cannot drift; this differs from the invoice_email_texts precedent, whose hand-written pattern forms can drift from the coded defaults. The level-3 default body is now an explicit inkassovarning (8 days, fordran till inkasso, costs per lag (1981:739)) but the level TITLE stays 'Slutlig paminnelse': the title is reused as the level name in settings labels and subject prefix, and renaming it everywhere is wording churn beyond the ask. An overridden subject owns the whole line (no automatic ' (inkl. drojsmalsranta)' suffix; {belopp} already includes surcharges), the stock subject keeps the suffix byte-identically. No pg test for the migration: a declarative CHECK (jsonb_typeof object) identical in shape to invoice_email_texts (20260703091000), which also shipped without one. The v1 REST/MCP update_company_settings surface was NOT extended: it is a curated field set with staged operations and its own placeholder refinement, a separate parity slice. typecheck/antipattern baselines deliberately not ratcheted in this diff: both one-count drops predate the branch (main drift), gates only fail on increase. [2026-08-30] PR #2021 round 2 (#546): the relayed Peppol buyer restriction now says the customer's org number must not be a personnummer (prepareParty('buyer') in lib/invoices/peppol-bis-billing.ts refuses it with BUYER_PARTICIPANT_IDENTIFIER_UNSUPPORTED, so an enskild firma CUSTOMER is refused, not only an enskild firma sender), Step 4 of the invoicing-rules workflow points at the Peppol section so a top-down reader never reaches the external-provider fallback first, the mark-sent recovery is scoped to the still-draft invoice in every text (INVOICE_MARK_SENT_REPAIR_REQUIRED leaves the invoice sent with the verifikat posted and a second mark-sent returns 409; the reviewer's proposed repair tool gnubok_link_invoice_to_voucher is the PAYMENT link and requires status sent/overdue/partially_paid, so no tool is named and the repair is left to support), and the verifikat parenthetical says "under faktureringsmetoden" (kontantmetod and defer_invoice_booking companies get none at issue). The guard test now also pins the two v1 route descriptions by reading the route source (apiskill:check only detects generated-vs-source drift, not a truth regression). The atom bump was seeded as a THIRD append-only migration (20260830101500, atom v9) rather than consolidating to one: the Supabase preview branch for the PR (xxnqggttsefleehmarjo) has applied both 20260829000100 and 20260829010000 per its schema_migrations, so deleting either would leave a remote with versions absent from the repo, the orphan class the migration rule forbids; all three seeds are idempotent upserts with the version guard, so prod applying them in sequence ends at v9. The generator's max-plus-one name (20260829010001) was renamed to 20260830101500 for the same reason as round 1 (newer than every file on origin/main and every sibling worktree; skills:check hashes content, the pg replay test globs the seed). diff --git a/components/pending-operations/vocabulary.ts b/components/pending-operations/vocabulary.ts index 9e2c55f9..5a90fd80 100644 --- a/components/pending-operations/vocabulary.ts +++ b/components/pending-operations/vocabulary.ts @@ -52,6 +52,9 @@ export const OPERATION_LABEL_KEYS: Record = { match_batch_allocate: 'type_match_batch_allocate', bulk_book_transactions: 'type_bulk_book_transactions', bulk_book_inbox_items: 'type_bulk_book_inbox_items', + // Skattekonto row booking + book_skattekonto_row: 'type_book_skattekonto_row', + book_skattekonto_rows: 'type_book_skattekonto_rows', // Periods, year-end, depreciation close_period: 'type_close_period', lock_period: 'type_lock_period', diff --git a/extensions/general/mcp-server/__tests__/skattekonto-booking-tools.test.ts b/extensions/general/mcp-server/__tests__/skattekonto-booking-tools.test.ts new file mode 100644 index 00000000..c4d4753e --- /dev/null +++ b/extensions/general/mcp-server/__tests__/skattekonto-booking-tools.test.ts @@ -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; 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 } + + 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 } + } + + 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 + 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 } + + 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/) + }) +}) diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 8e22537a..faf7c431 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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', diff --git a/extensions/general/skatteverket/index.ts b/extensions/general/skatteverket/index.ts index f245c319..29b01f29 100644 --- a/extensions/general/skatteverket/index.ts +++ b/extensions/general/skatteverket/index.ts @@ -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 = { 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, +): Promise { + 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 diff --git a/lib/auth/scope-catalog.ts b/lib/auth/scope-catalog.ts index 594480a7..5a642bf3 100644 --- a/lib/auth/scope-catalog.ts +++ b/lib/auth/scope-catalog.ts @@ -236,6 +236,10 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_bulk_book_transactions: 'transactions:write', gnubok_bulk_book_inbox_items: 'transactions:write', gnubok_auto_match_period: 'transactions:write', + // Skattekonto row booking writes a verifikat from an outside (SKV) row: + // same scope family as reconcile_residual / bulk_book above. + gnubok_book_skattekonto_row: 'transactions:write', + gnubok_book_skattekonto_rows: 'transactions:write', // Customers gnubok_list_customers: 'customers:read', gnubok_create_customer: 'customers:write', diff --git a/lib/pending-operations/__tests__/skattekonto-book-executor.test.ts b/lib/pending-operations/__tests__/skattekonto-book-executor.test.ts new file mode 100644 index 00000000..9a2e0043 --- /dev/null +++ b/lib/pending-operations/__tests__/skattekonto-book-executor.test.ts @@ -0,0 +1,223 @@ +/** + * Unit tests for commitBookSkattekontoRows (op types book_skattekonto_row / + * book_skattekonto_rows), driven through the public commitPendingOperation + * dispatcher. + * + * The MCP staging tools never book; the approved op's executor resolves the + * skatteverket extension's commitBookSkattekontoRows service via the registry + * (core cannot import @/extensions) and translates its result into the op + * lifecycle: + * - ok with >= 1 booked row → committed (per-row results in result_data) + * - ok with 0 booked rows → rejected (409, reasons in result_data) + * - recoverable failure → released back to 'pending' + * - non-recoverable failure → rejected + * + * A FAKE extension is registered so no real booking code runs: this isolates + * the core wiring (registry resolution + lifecycle), same pattern as + * skatteverket-executors.test.ts. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { extensionRegistry } from '@/lib/extensions/registry' +import type { Extension } from '@/lib/extensions/types' +import type { SkattekontoBookCommitResult } from '@/lib/pending-operations/skatteverket-commit' +import type { PendingOperation } from '@/types' +import { commitPendingOperation } from '../commit' + +function makePendingOp(overrides: Partial): PendingOperation { + return { + id: 'op-1', + user_id: 'user-1', + company_id: 'company-1', + operation_type: 'book_skattekonto_rows', + status: 'pending', + title: 'test', + params: {}, + preview_data: {}, + result_data: null, + actor_type: 'user', + actor_id: null, + actor_label: null, + risk_level: 'medium', + created_at: '2026-08-01T00:00:00Z', + resolved_at: null, + updated_at: '2026-08-01T00:00:00Z', + ...overrides, + } as PendingOperation +} + +function registerFakeSkatteverket( + services: Record Promise>, +): void { + extensionRegistry.register({ + id: 'skatteverket', + name: 'fake-skatteverket', + version: '0.0.0', + services, + } as unknown as Extension) +} + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() +}) +afterEach(() => { + extensionRegistry.clear() +}) + +describe('commitPendingOperation: book_skattekonto_row(s)', () => { + it('happy batch path: committed with per-row results, actor userId passed through', async () => { + const book = vi.fn().mockResolvedValue({ + ok: true, + results: [ + { id: 'skv-1', ok: true, journal_entry_id: 'je-1', voucher_number: 41, voucher_series: 'A' }, + { id: 'skv-2', ok: true, journal_entry_id: 'je-2', voucher_number: 42, voucher_series: 'A' }, + ], + summary: { total: 2, succeeded: 2, failed: 0 }, + }) + registerFakeSkatteverket({ commitBookSkattekontoRows: book }) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher commit update + + const op = makePendingOp({ params: { ids: ['skv-1', 'skv-2'] } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ summary: { total: 2, succeeded: 2, failed: 0 } }) + // The approving human is the actor: userId is threaded explicitly because + // the service-role client nulls auth.uid(). + expect(book).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', { + ids: ['skv-1', 'skv-2'], + }) + }) + + it('single-row op shape { transaction_id } is normalised to an id list', async () => { + const book = vi.fn().mockResolvedValue({ + ok: true, + results: [{ id: 'skv-1', ok: true, journal_entry_id: 'je-1', voucher_number: 7, voucher_series: 'A' }], + summary: { total: 1, succeeded: 1, failed: 0 }, + }) + registerFakeSkatteverket({ commitBookSkattekontoRows: book }) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ data: null, error: null }) + + const op = makePendingOp({ + operation_type: 'book_skattekonto_row', + params: { transaction_id: 'skv-1' }, + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(book).toHaveBeenCalledWith(expect.anything(), 'user-1', 'company-1', { ids: ['skv-1'] }) + }) + + it('partial batch still commits, with the failed rows in result_data', async () => { + const book = vi.fn().mockResolvedValue({ + ok: true, + results: [ + { id: 'skv-1', ok: true, journal_entry_id: 'je-1', voucher_number: 41, voucher_series: 'A' }, + { id: 'skv-2', ok: false, error_code: 'PERIOD_LOCKED', error_message: 'låst period' }, + ], + summary: { total: 2, succeeded: 1, failed: 1 }, + }) + registerFakeSkatteverket({ commitBookSkattekontoRows: book }) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ data: null, error: null }) + + const op = makePendingOp({ params: { ids: ['skv-1', 'skv-2'] } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ summary: { total: 2, succeeded: 1, failed: 1 } }) + }) + + it('zero booked rows → rejected 409 with per-row reasons, never silently committed', async () => { + const book = vi.fn().mockResolvedValue({ + ok: true, + results: [ + { id: 'skv-1', ok: false, error_code: 'ALREADY_BOOKED', error_message: 'Transaktionen är redan bokförd.' }, + ], + summary: { total: 1, succeeded: 0, failed: 1 }, + }) + registerFakeSkatteverket({ commitBookSkattekontoRows: book }) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ data: null, error: null }) // reject update + + const op = makePendingOp({ params: { ids: ['skv-1'] } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(409) + expect(result.error).toMatch(/redan bokförd/) + }) + + it('no service registered → failed EXTENSION_DISABLED, op released to pending', async () => { + // registry is empty (afterEach cleared it; nothing registered here) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // release-to-pending update + + const op = makePendingOp({ params: { ids: ['skv-1'] } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.code).toBe('EXTENSION_DISABLED') + expect(result.http_status).toBe(503) + }) + + it('recoverable service result → released to pending with the structured code', async () => { + const book = vi.fn().mockResolvedValue({ + ok: false, code: 'EXTENSION_DISABLED', http_status: 503, recoverable: true, + error: 'Skatteverket-integrationen är inte aktiverad i denna miljö.', + }) + registerFakeSkatteverket({ commitBookSkattekontoRows: book }) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ data: null, error: null }) + + const op = makePendingOp({ params: { ids: ['skv-1'] } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.code).toBe('EXTENSION_DISABLED') + expect(result.http_status).toBe(503) + }) + + it('non-recoverable service result → op rejected (consumed)', async () => { + const book = vi.fn().mockResolvedValue({ + ok: false, code: 'SKATTEKONTO_BOOKING_FAILED', http_status: 500, recoverable: false, + error: 'rule context load failed', + }) + registerFakeSkatteverket({ commitBookSkattekontoRows: book }) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ data: null, error: null }) // reject update + + const op = makePendingOp({ params: { ids: ['skv-1'] } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(500) + expect(result.error).toMatch(/rule context/) + }) + + it('missing ids → 400 without resolving the extension service', async () => { + const book = vi.fn() + registerFakeSkatteverket({ commitBookSkattekontoRows: book }) + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ data: null, error: null }) // reject update + + const op = makePendingOp({ params: {} }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(book).not.toHaveBeenCalled() + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index fe885b18..c0404200 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -106,6 +106,7 @@ import { extensionRegistry } from '@/lib/extensions/registry' import { SkatteverketRecoverableError, type SkatteverketCommitServices, + type SkattekontoBookingCommitService, type SkvSubmitResult, } from '@/lib/pending-operations/skatteverket-commit' import { PartialCommitError } from '@/lib/pending-operations/errors' @@ -5899,6 +5900,80 @@ async function commitSubmitAgi( return handleSkvSubmitResult(result) } +// ── Skattekonto row booking commit handler ──────────────────────── +// +// Commit side of the staged book_skattekonto_row / book_skattekonto_rows MCP +// ops. The booking logic lives in the skatteverket extension +// (lib/skattekonto-booking.ts, same helper the HTTP bokfor-batch route uses), +// so this reaches it through the registry-resolved `services` channel exactly +// like the filing handlers above. Separate getter: the booking service must +// work (or fail recoverable) independently of the SKV filing services. + +function getSkattekontoBookingService(): SkattekontoBookingCommitService { + const services = extensionRegistry.get('skatteverket')?.services as + | Partial + | undefined + if (!services?.commitBookSkattekontoRows) { + // Extension absent or not wired. Recoverable: leave the op pending so a + // re-enable + re-approve works without re-staging. + throw new SkatteverketRecoverableError( + 'Skatteverket-integrationen är inte tillgänglig.', + 'EXTENSION_DISABLED', + 503, + ) + } + return services as SkattekontoBookingCommitService +} + +async function commitBookSkattekontoRows( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record, +): Promise { + // Accept both staged shapes: the single-row op stores { transaction_id }, + // the batch op stores { ids }. Normalised to one id list for the service. + const ids = Array.isArray(params.ids) + ? params.ids.filter((v): v is string => typeof v === 'string' && v.length > 0) + : typeof params.transaction_id === 'string' && params.transaction_id.length > 0 + ? [params.transaction_id] + : [] + if (ids.length === 0) { + return { error: 'ids (eller transaction_id) krävs', status: 400 } + } + + const services = getSkattekontoBookingService() + const result = await services.commitBookSkattekontoRows(supabase, userId, companyId, { ids }) + if (!result.ok) { + if (result.recoverable) { + throw new SkatteverketRecoverableError(result.error, result.code, result.http_status) + } + return { error: result.error, status: result.http_status, errorCode: result.code } + } + + if (result.summary.succeeded === 0) { + // Nothing was booked: reject (consume) the op with the per-row reasons in + // result_data so the user fixes the rows (unignore, unlock period, add a + // rule) and re-stages. 409: the dominant causes are state conflicts + // (already booked / ignored / unsettled). + const firstError = result.results.find((r) => !r.ok) + return { + error: firstError?.error_message ?? 'Ingen skattekontorad kunde bokföras.', + status: 409, + data: { results: result.results, summary: result.summary }, + } + } + + log.info('book_skattekonto_rows committed', { + companyId, + operationType: 'book_skattekonto_rows', + total: result.summary.total, + succeeded: result.summary.succeeded, + failed: result.summary.failed, + }) + return { data: { results: result.results, summary: result.summary } } +} + // ── Multi-tx commit handlers (PRs #603/#606/#608/#610) ──────────── // // Both wrap their SQL RPC. The RPCs do all the heavy lifting (locking, @@ -6626,6 +6701,10 @@ async function commitPendingOperationInner( case 'reconciliation_residual': result = await commitReconciliationResidual(supabase, userId, companyId, pendingOp.params) break + case 'book_skattekonto_row': + case 'book_skattekonto_rows': + result = await commitBookSkattekontoRows(supabase, userId, companyId, pendingOp.params) + break case 'submit_vat_declaration': result = await commitSubmitVatDeclaration(supabase, userId, companyId, pendingOp.params) break diff --git a/lib/pending-operations/risk-tiers.ts b/lib/pending-operations/risk-tiers.ts index bb195aaf..67cb189b 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -218,6 +218,13 @@ export const OPERATION_RISK_TIERS: Record = { // links the selection: a typed, bounded booking like categorize_transaction, // undone by storno + unmatch, so 'medium' rather than create_voucher's 'high'. reconciliation_residual: 'medium', + // Book synced skattekonto rows as posted verifikat: 1630 against the + // skattekonto_rules-matched counter account, amounts straight from the + // synced Skatteverket data. No caller-supplied lines (the agent passes only + // row ids), reversible via storno: same bounded-booking tier as + // book_mileage_period, not create_voucher's arbitrary-line 'high'. + book_skattekonto_row: 'medium', + book_skattekonto_rows: 'medium', // ── Körjournal (mileage) ─────────────────────────────────────────── // A trip row is pure travel documentation: no booking impact until a diff --git a/lib/pending-operations/skatteverket-commit.ts b/lib/pending-operations/skatteverket-commit.ts index c973fd74..7a808c08 100644 --- a/lib/pending-operations/skatteverket-commit.ts +++ b/lib/pending-operations/skatteverket-commit.ts @@ -10,6 +10,8 @@ * contract without core ever importing the extension. */ +import type { SkattekontoBatchResult } from '@/types/skatteverket' + /** Result returned by the extension's commitSubmitVatDeclaration / commitSubmitAgi. */ export type SkvSubmitResult = | ({ @@ -48,6 +50,39 @@ export interface SkatteverketCommitServices { ) => Promise } +/** + * Result returned by the extension's commitBookSkattekontoRows service. + * On ok the shape is the same per-row list + summary the HTTP bokfor-batch + * endpoint returns (types/skatteverket.ts): row failures are data, never a + * thrown error, so a partially successful batch still commits the op with + * the failed rows listed in result_data. + */ +export type SkattekontoBookCommitResult = + | ({ ok: true } & SkattekontoBatchResult) + | { + ok: false + code: string + http_status: number + /** Same semantics as SkvSubmitResult: true releases the op back to pending. */ + recoverable: boolean + error: string + } + +/** + * Booking service a fully-wired skatteverket extension exposes on `services`. + * Separate from SkatteverketCommitServices so the skattekonto booking path + * (local bookkeeping over already-synced rows) does not depend on the SKV + * filing services being wired, and vice versa. + */ +export interface SkattekontoBookingCommitService { + commitBookSkattekontoRows: ( + supabase: unknown, + userId: string, + companyId: string, + params: Record, + ) => Promise +} + /** * Thrown by a commit executor when the failure is recoverable (extension * disabled, no SKV connection, rate-limited). The dispatcher catches it, diff --git a/messages/en.json b/messages/en.json index 38f82491..df315b02 100644 --- a/messages/en.json +++ b/messages/en.json @@ -737,6 +737,8 @@ "type_vacation_year_close": "Vacation year close", "type_submit_vat_declaration": "VAT declaration", "type_submit_agi": "Employer declaration", + "type_book_skattekonto_row": "Book tax account row", + "type_book_skattekonto_rows": "Book tax account rows", "origin_agent_chat": "Suggested by the AI assistant via chat", "origin_mcp": "Suggested by an AI assistant via {label}", "origin_api": "Suggested via API integration", diff --git a/messages/sv.json b/messages/sv.json index f08c3c78..24191ab8 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -737,6 +737,8 @@ "type_vacation_year_close": "Semesterårsavslut", "type_submit_vat_declaration": "Momsdeklaration", "type_submit_agi": "Arbetsgivardeklaration", + "type_book_skattekonto_row": "Bokför skattekontohändelse", + "type_book_skattekonto_rows": "Bokför skattekontohändelser", "origin_agent_chat": "Föreslaget av AI-assistenten via chatt", "origin_mcp": "Föreslaget av AI-assistent via {label}", "origin_api": "Föreslaget via API-integration", diff --git a/supabase/migrations/20260830130000_pending_operations_add_book_skattekonto.sql b/supabase/migrations/20260830130000_pending_operations_add_book_skattekonto.sql new file mode 100644 index 00000000..3fc9103d --- /dev/null +++ b/supabase/migrations/20260830130000_pending_operations_add_book_skattekonto.sql @@ -0,0 +1,103 @@ +-- Add 'book_skattekonto_row' and 'book_skattekonto_rows' to the +-- pending_operations operation_type CHECK constraint. +-- +-- gnubok_book_skattekonto_row / gnubok_book_skattekonto_rows (MCP) stage +-- "book synced skattekonto row(s) as posted verifikat" (1630 against the +-- skattekonto_rules-matched counter account). The user approves in Granskning +-- and commitBookSkattekontoRows in lib/pending-operations/commit.ts dispatches +-- through the skatteverket extension's registry-resolved services channel into +-- bokforSkattekontoTransactionsBatch: the SAME draft+commit-per-row helper the +-- HTTP bokfor-batch route uses. This closes the surface gap where skattekonto +-- READ and RECONCILE were already exposed as MCP tools but BOOKING existed +-- only behind the cookie-session extension routes. Risk 'medium': no +-- caller-supplied lines (agents pass only row ids), amounts come from the +-- synced Skatteverket data, reversible via storno. +-- +-- NOTE on the value list: this constraint is re-created wholesale (the +-- established pattern here), so the list below is every value of the +-- constraint as left by 20260828160000 PLUS the new values. Dropping any +-- existing value here would silently revoke it. +-- +-- NOT VALID + separate VALIDATE migration (paired file, same pattern as +-- 20260828160000 / 20260828160001). +-- +-- pg-test: tests/pg/pending-operations-op-type-audit.pg.test.ts asserts every +-- op type staged in server.ts or tiered in risk-tiers.ts is accepted here. +ALTER TABLE public.pending_operations + DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check; + +ALTER TABLE public.pending_operations + ADD CONSTRAINT pending_operations_operation_type_check + CHECK (operation_type IN ( + 'categorize_transaction', + 'create_customer', + 'create_invoice', + 'mark_invoice_paid', + 'send_invoice', + 'mark_invoice_sent', + 'match_transaction_invoice', + 'close_period', + 'lock_period', + 'unlock_period', + 'set_opening_balances', + 'run_year_end', + 'post_kontantmetod_cutoff', + 'run_currency_revaluation', + 'import_sie', + 'explain_voucher_gap', + 'uncategorize_transaction', + 'approve_supplier_invoice', + 'credit_supplier_invoice', + 'credit_invoice', + 'convert_invoice', + 'create_transaction', + 'attach_document_to_transaction', + 'create_voucher', + 'correct_entry', + 'reverse_entry', + 'create_supplier', + 'create_supplier_invoice_from_inbox', + 'post_annual_depreciation', + 'link_invoice_voucher', + 'undo_sie_import', + 'match_batch_allocate', + 'bulk_book_transactions', + 'create_salary_run', + 'generate_agi', + 'link_transaction_journal_entry', + 'link_supplier_invoice_voucher', + 'submit_vat_declaration', + 'submit_agi', + 'create_article', + 'update_article', + 'bulk_book_inbox_items', + 'create_dimension_value', + 'retag_line_dimensions', + 'link_document_to_voucher', + 'update_payslip_line', + 'set_run_salary', + 'register_absence', + 'create_employee', + 'update_employee', + 'set_employee_opening_balances', + 'vacation_year_close', + 'create_account', + 'update_account', + 'set_voucher_note', + 'book_salary_run', + 'delete_absence', + 'update_company_settings', + 'update_customer', + 'update_invoice', + 'create_recurring_schedule', + 'update_recurring_schedule', + 'log_mileage_trip', + 'book_mileage_period', + 'link_documents_to_vouchers', + 'reconciliation_match', + 'reconciliation_unmatch', + 'reconciliation_signoff', + 'reconciliation_residual', + 'book_skattekonto_row', + 'book_skattekonto_rows' + )) NOT VALID; diff --git a/supabase/migrations/20260830130001_validate_pending_operations_book_skattekonto.sql b/supabase/migrations/20260830130001_validate_pending_operations_book_skattekonto.sql new file mode 100644 index 00000000..46ffa833 --- /dev/null +++ b/supabase/migrations/20260830130001_validate_pending_operations_book_skattekonto.sql @@ -0,0 +1,6 @@ +-- Validate the operation type CHECK re-added in 20260830130000. +-- This separate transaction avoids a full-table scan while the preceding +-- migration holds its stronger table lock. + +ALTER TABLE public.pending_operations + VALIDATE CONSTRAINT pending_operations_operation_type_check; diff --git a/types/index.ts b/types/index.ts index d3c12b19..1cbd0128 100644 --- a/types/index.ts +++ b/types/index.ts @@ -2568,6 +2568,11 @@ export type PendingOperationType = | 'reconciliation_signoff' // Book the remainder of a bank selection as a fee/interest/rounding verifikat and link it. | 'reconciliation_residual' + // Book synced skattekonto rows as posted verifikat (1630 + rule-matched + // counter account), same helper as the HTTP bokfor-batch route. The + // single-row op stores { transaction_id }; the batch op stores { ids }. + | 'book_skattekonto_row' + | 'book_skattekonto_rows' // PR5: Skatteverket filing via MCP. Commit = "send for BankID signing" // (returns a signing link); the user's signature in the browser files it. | 'submit_vat_declaration'