diff --git a/DECISIONS.md b/DECISIONS.md index 7d4ee824..ddb949e3 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1182,6 +1182,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-23] Avstämning page (PR 3) ships without the period picker, the manual two-pane match mode and the sign-off button: the page renders the approved 'Vald riktning' layout (rail + tiles + bridge + actions + banded table) over the PR 2 dashboard routes only, so that it is verifiable on its own; period + sign-off arrive together in PR 4 (both are period-bound), manual N:M matching with residual booking in PR 5. Bank accounts get the same generic body plus links to the existing bank view for the matcher run rather than embedding the 1900-line BankReconciliationView: one body for every account kind is the point of the page, and embedding would have doubled the header. [2026-08-23] Reconciliation sign-off (PR 4) is an append-only attestation table (account_reconciliations) with a reopen stamp, not a flag on the account: who signed what through which date, with the numbers as they stood, is the thing an auditor and the Hem row read, so it must survive a later change of mind. Sign-off is refused with an unexplained difference unless forced with a note (the note is what the next reader sees). Separate scope reconciliation:signoff (write is not enough): an integration that links rows should not be able to attest. The worklist category reconciliation_due is gated on adoption (zero until the company has signed anything off) so the nudge reaches the people who reconcile monthly without becoming a new chore for everyone. Webhook events added additively without bumping API_V1_VERSION: the dated version is reserved for breaking changes; a new event type breaks no existing subscriber. [2026-08-23] Reconciliation agent surfaces (PR 5): the Hem notice for a skattekonto that disagrees with the ledger reads a summary the sync persists (extension_data skattekonto_reconciliation_latest) instead of recomputing the bridge on every render; the same persisted summary feeds nothing else yet. The attention resource's reconciliation_due category and the Hem row share lib/worklist countReconciliationDue (one predicate). Manual N:M matching with residual booking, dropping the local MatchDialog on /skattekonto, restyling the bank view and eval scenarios are deferred to PR 6: they need the two-pane UI and a visual pass, and none of the agent surfaces depend on them. The skattekonto sync cron now orders eligible companies by stalest sync before its 50-per-run cap (never-synced first) instead of raising the cap: a fixed order plus a cap starved the tail. +[2026-08-24] MCP underlag warnings are advisory text, not staging blocks: soft guards must stay bypassable and IB/migration vouchers legitimately lack a kvitto; approve-scope fallback copy is static because stagePendingOperation has no access to key scopes and plumbing them through every staging tool was not worth the diff. [2026-08-24] Inline matcher (PR 6a) applies through the existing /api/reconciliation/bank/run intersection guard rather than new endpoints: the page renders the dry-run pairs and applies per row or strong-only (floor 0.85, re-enforced server-side), so a stale preview can never link a pair the fresh run would not. The old bank view keeps living until 6b (manual N:M + residual booking) reaches parity; only its matcher trips became unnecessary today. [2026-08-24] vat_amount (categorize/bulk_book) is transaction-currency, converted to SEK at booking: the validation bound already read it in transaction currency (the underlag's denomination), and the gross line already converts through resolveSekAmount, so converting the VAT the same way was the only coherent option. Documenting it as SEK instead (the reporter's first suggestion, feedback seq 254607) would force agents to pre-convert with a settlement rate they cannot see. [2026-08-24] Declared currency/voucher_series nullable in three MCP listing schemas on column-nullability alone (no traced null producer): loosening an output schema can only stop false validation failures, never cause one, and legacy rows predate the columns' defaults. Declined (for now) a full Ajv execute-vs-schema round-trip harness in output-schema.test.ts: right long-term answer to this bug class, but a session-sized project of its own; the audit's seven confirmed sites are pinned by a targeted declaration test instead. diff --git a/components/pending-operations/OperationPreview.tsx b/components/pending-operations/OperationPreview.tsx index 20689dfd..73115696 100644 --- a/components/pending-operations/OperationPreview.tsx +++ b/components/pending-operations/OperationPreview.tsx @@ -9,6 +9,7 @@ import { Fragment, createContext, useContext } from 'react' import { cn, formatCurrency } from '@/lib/utils' +import { AttnLine } from '@/components/ui/attn-line' import { VTH_CLASS, VTD_CLASS } from '@/components/ui/dry-table' import type { PendingOperation } from '@/types' import { AttachDocumentPreview } from '@/components/bookkeeping/AttachDocumentPreview' @@ -295,9 +296,24 @@ function VoucherPreview({ data }: { data: Record }) { const lines = (data.lines as VoucherLine[]) || [] const totalDebit = data.total_debit as number | undefined const totalCredit = data.total_credit as number | undefined + // Advisory, mirrors the MCP staging warning: a verifikat for a received + // handling must carry the handling itself (BFL 5 kap 6 §). Gated on the + // staged compliance_warning, not on document_attached: the server decides + // when the warning applies (IB entries are exempt there), so this stays a + // mirror instead of a second, looser policy. Older ops without the field + // render unchanged. The wording stays conditional ("om ... avser en + // mottagen handling"): internal entries (accruals, FX) legitimately lack + // a kvitto, and flagging them as deficient would be wrong. + const missingUnderlag = typeof data.compliance_warning === 'string' return (
+ {missingUnderlag && ( + + Underlag saknas: om verifikatet avser en mottagen handling ska handlingen användas som + verifikation (BFL 5 kap 6 §). + + )}
Datum {String(data.entry_date ?? '')} diff --git a/extensions/general/mcp-server/__tests__/voucher-tools.test.ts b/extensions/general/mcp-server/__tests__/voucher-tools.test.ts index 37ef6ee4..6b837b11 100644 --- a/extensions/general/mcp-server/__tests__/voucher-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/voucher-tools.test.ts @@ -300,13 +300,21 @@ describe('gnubok_create_voucher: staging gates', () => { 'company-1', 'user-1', supabase as never, - )) as { staged: boolean; operation_id?: string; preview: Record } + )) as { staged: boolean; operation_id?: string; message: string; preview: Record } expect(result.staged).toBe(true) expect(result.operation_id).toBe('op-staged') expect(result.preview.total_debit).toBe(250) expect(result.preview.total_credit).toBe(250) + // No document attached: the advisory BFL 5 kap 6§ warning must reach both + // the agent (message) and the /pending approval card (preview) without + // blocking the staging itself. + expect(result.preview.document_attached).toBe(false) + expect(result.preview.compliance_warning).toMatch(/BFL 5 kap 6§/) + expect(result.preview.compliance_warning).toMatch(/underlag/i) + expect(result.message).toMatch(/WARNING:.*underlag/i) + // Critical: the staged pending_operations row must NOT carry source_type. // The executor always hardcodes 'manual'. Look at the insert call. const insertCalls = (supabase.from as ReturnType).mock.calls @@ -376,6 +384,112 @@ describe('gnubok_create_voucher: staging gates', () => { expect(result.preview.inbox_item_id).toBe('inbox-1') expect(result.preview.document_attached).toBe(true) expect(result.preview.will).toMatch(/link the inbox item/i) + // Document attached: the missing-underlag warning must NOT appear. + expect(result.preview.compliance_warning).toBeUndefined() + }) + + it('inbox item without a stored document: no attach claim, inbox-variant warning', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { + id: 'fp-1', + is_closed: false, + period_start: '2026-01-01', + period_end: '2026-12-31', + name: '2026', + }, + error: null, + }) + enqueue({ + data: [ + { account_number: '5410', account_name: 'Förbrukningsinventarier', is_active: true }, + { account_number: '1930', account_name: 'Företagskonto', is_active: true }, + ], + error: null, + }) + enqueue({ + data: { + id: 'inbox-nodoc', + document_id: null, // ingested without attachment, or ON DELETE SET NULL + created_journal_entry_id: null, + created_supplier_invoice_id: null, + }, + error: null, + }) + enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 1 + enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 2 + enqueue({ data: { id: 'op-nodoc' }, error: null }) // pending_operations insert + + const result = (await createVoucher.execute( + { + entry_date: '2026-05-12', + description: 'Inbox item utan dokument', + fiscal_period_id: 'fp-1', + inbox_item_id: 'inbox-nodoc', + lines: [ + { account_number: '5410', debit_amount: 250, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 250 }, + ], + }, + 'company-1', + 'user-1', + supabase as never, + )) as { staged: boolean; preview: Record } + + expect(result.staged).toBe(true) + expect(result.preview.document_attached).toBe(false) + // The envelope must not contradict itself: no "attach the OCR document" + // claim, and the warning must not tell the agent to restage with the + // inbox_item_id it already supplied. + expect(result.preview.will).toMatch(/link the inbox item/i) + expect(result.preview.will).not.toMatch(/attach the OCR document/i) + expect(result.preview.compliance_warning).toMatch(/no stored document/i) + expect(result.preview.compliance_warning).not.toMatch(/restage with inbox_item_id/i) + }) + + it('is_opening_balance: no underlag warning (IB legitimately lacks a kvitto)', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ + data: { + id: 'fp-1', + is_closed: false, + period_start: '2026-01-01', + period_end: '2026-12-31', + name: '2026', + }, + error: null, + }) + enqueue({ + data: [ + { account_number: '1930', account_name: 'Företagskonto', is_active: true }, + { account_number: '2081', account_name: 'Aktiekapital', is_active: true }, + ], + error: null, + }) + enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 1 + enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 2 + enqueue({ data: { id: 'op-ib' }, error: null }) // pending_operations insert + + const result = (await createVoucher.execute( + { + entry_date: '2026-01-01', + description: 'Ingående balans', + fiscal_period_id: 'fp-1', + is_opening_balance: true, + lines: [ + { account_number: '1930', debit_amount: 25000, credit_amount: 0 }, + { account_number: '2081', debit_amount: 0, credit_amount: 25000 }, + ], + }, + 'company-1', + 'user-1', + supabase as never, + )) as { staged: boolean; message: string; preview: Record } + + expect(result.staged).toBe(true) + expect(result.preview.document_attached).toBe(false) + expect(result.preview.compliance_warning).toBeUndefined() + expect(result.message).not.toMatch(/WARNING/) }) it('rejects when inbox_item_id is already booked as a journal entry', async () => { diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 94ac5400..4bb0d328 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -688,13 +688,27 @@ interface StageOptions { * the DB triggers remain the authoritative gate. */ dateForPeriodCheck?: string + /** + * Advisory compliance warning. Appended to the staged message and echoed as + * preview.compliance_warning, which persists into preview_data so the + * /pending approval card carries the same warning. Never blocks: the DB + * triggers and the approver stay authoritative. + */ + complianceNote?: string } +// Keys that can stage typically lack pending_operations:approve (segregation +// of duties, see STAGING_SCOPES in lib/auth/api-keys.ts), so the approve tool +// is filtered out of their tools/list. Every staging response names the web +// fallback so agents on such keys don't hunt for a tool they cannot see. +const APPROVE_SCOPE_FALLBACK = + 'If that tool is missing from your catalog, this API key lacks the pending_operations:approve scope: the user approves at /pending instead.' + function buildApprovalGuidance(operationId: string, riskLevel: 'low' | 'medium' | 'high'): string { if (riskLevel === 'high') { - return `This is an irreversible posting under BFL 5 kap 5§: surface the irreversibility implications to the user and obtain an explicit acknowledgment before committing. Once the user has acknowledged, call gnubok_approve_pending_operation with operation_id="${operationId}" and confirmed=true.` + return `This is an irreversible posting under BFL 5 kap 5§: surface the irreversibility implications, and any compliance_warning in the preview, to the user and obtain an explicit acknowledgment before committing. Once the user has acknowledged, call gnubok_approve_pending_operation with operation_id="${operationId}" and confirmed=true. ${APPROVE_SCOPE_FALLBACK}` } - return `When the user authorises, call gnubok_approve_pending_operation with operation_id="${operationId}".` + return `When the user authorises, call gnubok_approve_pending_operation with operation_id="${operationId}". ${APPROVE_SCOPE_FALLBACK}` } async function stagePendingOperation( @@ -727,6 +741,13 @@ async function stagePendingOperation( assertNoPlaintextPersonnummer(params, 'params') assertNoPlaintextPersonnummer(previewData, 'preview_data') + // Fold the advisory compliance note into the preview so the agent response + // and the persisted preview_data (rendered by the /pending approval card) + // carry the same warning. + if (options.complianceNote) { + previewData = { ...previewData, compliance_warning: options.complianceNote } + } + // params-aware: create/update_recurring_schedule escalate to 'high' when // params.auto_send === true (standing outbound email with no per-send // approval, same side-effect that puts one-off send_invoice at 'high'). @@ -771,7 +792,7 @@ async function stagePendingOperation( dry_run: true, risk_level: riskLevel, actor, - message: `Dry run: would stage "${operationType}" (risk: ${riskLevel}). No changes made.`, + message: `Dry run: would stage "${operationType}" (risk: ${riskLevel}). No changes made.${options.complianceNote ? ` WARNING: ${options.complianceNote}` : ''}`, preview: previewData, ...(periodStatus ? { period_status: periodStatus } : {}), ...(next ? { next: addCompanyToNextHint(next, companyId) as NextActionHint } : {}), @@ -842,7 +863,7 @@ async function stagePendingOperation( operation_id: data.id, risk_level: riskLevel, actor, - message: `Staged as pending_operation ${data.id} (risk: ${riskLevel}). ${buildApprovalGuidance(data.id, riskLevel)} The user can also approve at /pending in the ${branding} web app.`, + message: `Staged as pending_operation ${data.id} (risk: ${riskLevel}). ${buildApprovalGuidance(data.id, riskLevel)} The user can also approve at /pending in the ${branding} web app.${options.complianceNote ? ` WARNING: ${options.complianceNote}` : ''}`, approve: { tool: 'gnubok_approve_pending_operation', args: { operation_id: data.id, company_id: companyId } as Record, @@ -4065,7 +4086,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_create_transactions', title: 'Create Bank Transactions', - description: 'Stage one or more transactions for the user to approve. Each creates a separate pending operation; commit each via gnubok_approve_pending_operation. Use for ingesting external rows (Airtable, CSV). Max 10.', + description: 'Stage bank/cash-account transactions; each becomes a pending operation. For external rows (Airtable, CSV); max 10. A transaction models a cash-account movement: for cashless events (privat utlägg) use gnubok_create_voucher.', outputSchema: { type: 'object', additionalProperties: false, @@ -4458,7 +4479,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_categorize_transaction', title: 'Categorize Bank Transaction', - description: 'Categorize a bank transaction. Stages the verifikat: cost line NET of moms, bank line gross; dimensions bag tags the cost line. account_override books the business side on any active kontoplan account (custom incl.). Commit via gnubok_approve_pending_operation.', + description: 'Categorize a bank transaction. Stages the verifikat: cost line NET of moms, bank line gross (always the tx\'s cash account). account_override books the business side on any active account. Cashless events (privat utlägg): gnubok_create_voucher.', inputSchema: { type: 'object', additionalProperties: false, @@ -8835,7 +8856,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_bulk_book_transactions', title: 'Bulk-Book Transactions', - description: 'Bulk-book N bank txs on the same date into 1 samlingsverifikat (BFL 5 kap 6§). Either link N txs to an existing posted verifikat, or create a new verifikat from caller lines (accept dims bags). All txs share date + direction. Stages.', + description: 'Bulk-book N bank txs (same date, same direction) into 1 samlingsverifikat (BFL 5 kap 6§). Link txs to an existing posted verifikat, or create one from caller lines. Each tx posts its cash-account line. Stages.', inputSchema: { type: 'object', additionalProperties: false, @@ -11342,7 +11363,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_link_document_to_voucher', title: 'Link Document to Voucher', - description: 'Stage linking a document to a posted verifikation. Use for imported/manual vouchers with no bank-tx row. Call gnubok_list_verifikat_without_documents first to find targets. Stages for approval.', + description: 'Stage linking a document to an already-POSTED verifikation (no bank-tx row). For an unbooked handling prefer gnubok_create_voucher with inbox_item_id (BFL 5 kap 6§). Call gnubok_list_verifikat_without_documents for targets.', inputSchema: { type: 'object', additionalProperties: false, @@ -15508,7 +15529,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_create_voucher', title: 'Create Manual Voucher (Verifikation)', - description: 'Stage a manual verifikation with arbitrary balanced lines: capitalization (1010), accruals, FX adjustments, rättelser outside categorize_transaction. Lines accept dimensions bags {sie_dim_no: code or name}. Pass inbox_item_id to book a kvitto direct. HIGH risk.', + description: 'Stage a manual verifikation with balanced lines: capitalization, accruals, FX, rättelser, IB. For a received handling pass inbox_item_id: the document becomes the verifikation (BFL 5 kap 6§); a filename in notes is not underlag. HIGH risk.', inputSchema: { type: 'object', additionalProperties: false, @@ -15524,7 +15545,7 @@ export const tools: McpTool[] = [ description: 'Dimension tags {sie_dim_no: kod eller namn}, e.g. {"6":"P001"}, applied to every line not setting the key itself. Unknown values are rejected: never auto-created.', }, is_opening_balance: { type: 'boolean', description: 'Set true ONLY for a migrated ingående balans (IB). Marks the entry source_type=opening_balance so bank reconciliation excludes it from period movement. Requires every line to be a balance-sheet account (class 1/2) and entry_date = fiscal period start, else rejected. Defaults false.' }, - inbox_item_id: { type: 'string', description: 'Optional inbox item UUID to book directly. On confirm, the inbox item is linked to the new verifikat and its OCR document is attached to the journal entry. Fails if the inbox item is already booked (as voucher) or converted (to supplier invoice).' }, + inbox_item_id: { type: 'string', description: 'Inbox item UUID to book directly. For a received handling this is the BFL 5 kap 6§ path: the document becomes the verifikation. On confirm the item and its document link to the new verifikat. Not in inbox? gnubok_upload_document first. Fails if already booked/converted.' }, lines: { type: 'array', description: 'At least 2 balanced lines. sum(debit_amount) === sum(credit_amount), both > 0.', @@ -15775,16 +15796,38 @@ export const tools: McpTool[] = [ ...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}), inbox_item_id: inboxItemId, document_attached: Boolean(inboxDocumentId), - will: inboxItemId + // The document-attach claim keys off inboxDocumentId, not + // inboxItemId: an inbox item whose document was never stored (or + // was deleted, ON DELETE SET NULL) links but attaches nothing. + will: inboxDocumentId ? 'create a posted journal entry with a fresh sequential voucher number, link the inbox item to it, and attach the OCR document to the verifikat' - : 'create a posted journal entry with a fresh sequential voucher number', + : inboxItemId + ? 'create a posted journal entry with a fresh sequential voucher number and link the inbox item to it (the item has no stored document, so nothing is attached)' + : 'create a posted journal entry with a fresh sequential voucher number', }, actor, { description: 'After commit, confirm the new verifikation lands on the right accounts with gnubok_get_general_ledger or gnubok_query_journal.', tool: 'gnubok_query_journal', }, - { dateForPeriodCheck: entryDate }, + { + dateForPeriodCheck: entryDate, + // Advisory only (the approver decides): a verifikat for a received + // handling posted without its document fails BFL 5 kap 6§, and the + // repair after posting is storno + a consumed voucher number. The + // inbox-item variant avoids a dead-end "restage with inbox_item_id" + // instruction when inbox_item_id WAS supplied but the item carries + // no stored document. IB entries are exempt: a migrated ingående + // balans has no kvitto to attach and warning on it would be a false + // positive that trains approvers to ignore the warning. + ...(inboxDocumentId || isOpeningBalance + ? {} + : { + complianceNote: inboxItemId + ? 'No underlag attached (document_attached: false): the inbox item has no stored document, so nothing will be attached. Upload the handling via gnubok_upload_document and restage, or link it to the posted verifikat with gnubok_link_document_to_voucher.' + : 'No underlag attached (document_attached: false). For a received handling, BFL 5 kap 6§ requires the document itself to serve as the verifikation: restage with inbox_item_id (upload via gnubok_upload_document first if needed), or link the document to the posted verifikat with gnubok_link_document_to_voucher. A filename in description or notes is not underlag.', + }), + }, ) }, }, @@ -16586,7 +16629,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_list_pending_operations', title: 'List Pending Operations', - description: 'List staged pending_operations. Filter by status (default pending), risk_level, or operation_type. Approve via gnubok_approve_pending_operation, discard via gnubok_reject_pending_operation. render_ui=true opens the approval widget.', + description: 'List staged pending_operations. Approve via gnubok_approve_pending_operation, reject via gnubok_reject_pending_operation; without pending_operations:approve use /pending. render_ui=true opens the approval widget.', inputSchema: { type: 'object', additionalProperties: false, @@ -16652,7 +16695,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_approve_pending_operation', title: 'Approve Pending Operation', - description: "Commit a staged pending_operation the user has explicitly authorised. risk_level=high requires confirmed=true: surface the BFL 5 kap 5§ irreversibility first. The /pending web UI offers an equivalent commit path.", + description: "Commit a staged pending_operation the user has explicitly authorised. risk_level=high requires confirmed=true: surface the BFL 5 kap 5§ irreversibility and any preview compliance_warning first. The /pending web UI is an equivalent commit path.", inputSchema: { type: 'object', additionalProperties: false,