From 1e6e19afe9722085cb4b293d7a0d20e4ea25bfe2 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 25 Aug 2026 09:03:43 +0200 Subject: [PATCH] feat(mcp): gnubok_reconcile_residual stages residual booking + link on a bank account (#1872) The residual door existed for the page and the v1 API (#1862) but not for agents: an MCP client that found a 10 kr bank fee between a selection and its verifikat had to hand the last step back to the user. gnubok_reconcile_residual dry-runs lib/reconciliation/residual.ts at stage time (so zero / cap / direction / skattekonto refusals surface immediately), stages a reconciliation_residual operation with the would-book verifikat as the preview, and commitReconciliationResidual links and books on approval. Risk 'medium' (one typed verifikat bounded by RESIDUAL_MAX_AMOUNT, undone by storno + unmatch); scope transactions:write like the v1 route. The op type is added to the pending_operations CHECK (NOT VALID + VALIDATE pair, list verified against the live prod constraint 2026-08-25), and the tool joins the reconcile_month / close_period loadouts and the reconcile-month skill. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 2 + .../__tests__/reconciliation-tools.test.ts | 85 +++++++++++++ .../general/mcp-server/recommended-tools.ts | 4 + extensions/general/mcp-server/server.ts | 74 +++++++++++ .../mcp-server/skills/reconcile-month.ts | 4 +- lib/auth/api-keys.ts | 2 + .../reconciliation-residual-executor.test.ts | 117 ++++++++++++++++++ lib/pending-operations/commit.ts | 62 ++++++++++ lib/pending-operations/risk-tiers.ts | 5 + ...operations_add_reconciliation_residual.sql | 96 ++++++++++++++ ...ing_operations_reconciliation_residual.sql | 6 + types/index.ts | 2 + 12 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 lib/pending-operations/__tests__/reconciliation-residual-executor.test.ts create mode 100644 supabase/migrations/20260825100000_pending_operations_add_reconciliation_residual.sql create mode 100644 supabase/migrations/20260825100001_validate_pending_operations_reconciliation_residual.sql diff --git a/DECISIONS.md b/DECISIONS.md index 639acb57..9a6c8978 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1192,3 +1192,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-24] query_journal status default 'posted' -> 'all' (posted+reversed, the trial-balance inclusion rule) after a customer's agent summed posted-only lines over a storno-heavy Q2, found phantom VAT residuals on 2614/2641/2645/2647 and demanded a revert of correct books: one-leg sums are never balances. Funded the new status_filter_warning field and richer status docs inside the tools/list token budget by trimming sibling descriptions in the same tool rather than bumping the 59.95K ceiling (the payload guard's own guidance); warning fires off an entry-level opposite-status head count, exact for what the sentence claims and cheap, instead of re-running the line fetch. [2026-08-24] Detach-duplicate underlag ships as a SECURITY DEFINER RPC (detach_underlag_duplicate) instead of loosening the document triggers: the WORM guards stay intact for every other path, the carve-out is transaction-local (gnubok.allow_delete) and audit-logged first, and detach is refused unless another anchored underlag remains on the verifikat (BFL 5 kap 7 par) AND a remaining sibling has an identical sha256_hash (only byte-identical duplicates detach; skeptic-hardened 2026-08-24, along with an enforced posted-status guard and company_id on the audit row). Pinned docs (transactions.document_id / supplier_invoices.document_id) stay replace-only. [2026-08-24] Single-call chat console (general.help, AskConsole → /api/agent/ask) now carries the thread's earlier turns into every model call, via a new optional `history` on the provider-agnostic GenerateTextRequest (real message turns before the prompt in BOTH adapters: Anthropic-family messages array, OpenAI-compatible via AI SDK `messages`; an absent/empty history leaves the request byte-identical to the single-turn call, so hosted extraction and every other caller are untouched). The 08-20 RIP-3 cutover made each turn stateless (conversationId was only the tool actor id), so a follow-up in a resumed thread was answered blind (user report: "frågar vad jag refererar till"). History is loaded server-side from agent_messages (loadChatHistory: text only, hidden + tool rows dropped, alternation repaired, newest 16 rows / 10k chars) rather than sent by the client, so the client cannot forge earlier turns and old streaming threads replay cleanly. Rejected: inlining a transcript into the prompt (works everywhere but weaker turn semantics and blurs data vs instructions) and loading history in AskConsole (client-trusted history). Separately: the docked assistant panel now remembers its open thread per tab in sessionStorage (lib/agent-panel/session-restore) and reopens it after a full reload (the deploy prompt's "Ladda om" wiped it); sessionStorage, not user_preferences, because this is this-tab-this-session state that must not follow the user to other devices or tabs. And DeployReloadPrompt's full-width wrapper gets pointer-events-none: at z-[60] after the panel in DOM order it swallowed clicks on the panel's composer ("går ej att skriva"). +[2026-08-25] /reports/bank-reconciliation retired behind a redirect to /reconciliation instead of kept as a "power" page: everything it did (matcher, manual N:1 matching, residual booking, IB tag, move-to-account) lives on the account-keyed page, and two reconciliation surfaces meant two truths. The catalog slug stays so old links, the report library and ?autorun=1 deep links keep working. +[2026-08-25] reconciliation_residual staged op tiered 'medium', not create_voucher's 'high': it books one typed verifikat (6570/8410/8310/3740 vs bank) bounded by RESIDUAL_MAX_AMOUNT and is undone by storno + unmatch, i.e. the same blast radius as categorize_transaction. Scope is transactions:write (same as the v1 route) because it writes the ledger. diff --git a/extensions/general/mcp-server/__tests__/reconciliation-tools.test.ts b/extensions/general/mcp-server/__tests__/reconciliation-tools.test.ts index 9d3b3445..10c596ef 100644 --- a/extensions/general/mcp-server/__tests__/reconciliation-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/reconciliation-tools.test.ts @@ -11,6 +11,7 @@ const statusMock = vi.fn() const itemsMock = vi.fn() const matchMock = vi.fn() const signoffMock = vi.fn() +const residualMock = vi.fn() vi.mock('@/lib/reconciliation/service', () => ({ getAccountStatus: (...args: unknown[]) => statusMock(...args), @@ -28,6 +29,10 @@ vi.mock('@/lib/reconciliation/actions', () => ({ vi.mock('@/lib/reconciliation/signoff', () => ({ signOffAccount: (...args: unknown[]) => signoffMock(...args), })) +vi.mock('@/lib/reconciliation/residual', async () => { + const actual = await vi.importActual('@/lib/reconciliation/residual') + return { ...actual, bookResidualAndLink: (...args: unknown[]) => residualMock(...args) } +}) import { tools, isDefaultCatalogTool, deriveToolMeta } from '../server' @@ -225,3 +230,83 @@ describe('gnubok_reconcile_signoff', () => { expect(signoffMock).not.toHaveBeenCalled() }) }) + +describe('gnubok_reconcile_residual', () => { + const CASH = '11111111-1111-4111-8111-111111111111' + const KEY = `bank:${CASH}` + const T1 = '22222222-2222-4222-8222-222222222222' + const E1 = '44444444-4444-4444-8444-444444444444' + const wouldBook = { + kind: 'bank_fee', + counter_account: '6570', + ledger_account: '1930', + currency: 'SEK', + transactions_total: -1010, + entry_net: -1000, + residual_amount: -10, + entry_date: '2026-07-31', + description: 'Bankavgift', + lines: [ + { account_number: '6570', debit_amount: 10, credit_amount: 0 }, + { account_number: '1930', debit_amount: 0, credit_amount: 10 }, + ], + } + + beforeEach(() => { + residualMock.mockReset() + }) + + it('is search-only, requires approval, and preflights on the status tool', () => { + expect(isDefaultCatalogTool(tool('gnubok_reconcile_residual'))).toBe(false) + expect(deriveToolMeta(tool('gnubok_reconcile_residual'))).toMatchObject({ + requires_approval: true, + preflight: 'gnubok_get_reconciliation_status', + }) + }) + + it('dry-runs the booking first and stages reconciliation_residual with the verifikat preview', async () => { + const { supabase } = createQueuedMockSupabase() + residualMock.mockResolvedValue({ dry_run: true, would_book: wouldBook }) + const out = (await tool('gnubok_reconcile_residual').execute( + { account_key: KEY, external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee', dry_run: true }, + COMPANY, + USER, + supabase as never, + )) as Record + expect(residualMock).toHaveBeenCalledWith( + supabase, + COMPANY, + USER, + KEY, + { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee', entry_date: undefined, description: undefined }, + { dryRun: true }, + ) + expect(out).toMatchObject({ staged: false, dry_run: true, risk_level: 'medium' }) + expect(out.next).toMatchObject({ tool: 'gnubok_get_reconciliation_status' }) + expect(out.preview).toMatchObject({ account_key: KEY, residual_amount: -10, counter_account: '6570', transaction_count: 1 }) + }) + + it('surfaces a policy refusal (zero, cap, direction, skattekonto) instead of staging', async () => { + const { supabase } = createQueuedMockSupabase() + residualMock.mockRejectedValue(new Error('Restposten pekar åt fel håll för bank_fee.')) + await expect( + tool('gnubok_reconcile_residual').execute( + { account_key: KEY, external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }, + COMPANY, + USER, + supabase as never, + ), + ).rejects.toThrow(/fel håll/) + }) + + it('rejects a malformed account_key and an empty selection before touching anything', async () => { + const { supabase } = createQueuedMockSupabase() + await expect( + tool('gnubok_reconcile_residual').execute({ account_key: '1930', external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }, COMPANY, USER, supabase as never), + ).rejects.toThrow(/Invalid account_key/) + await expect( + tool('gnubok_reconcile_residual').execute({ account_key: KEY, external_ids: [], journal_entry_id: E1, kind: 'bank_fee' }, COMPANY, USER, supabase as never), + ).rejects.toThrow(/1\.\.50/) + expect(residualMock).not.toHaveBeenCalled() + }) +}) diff --git a/extensions/general/mcp-server/recommended-tools.ts b/extensions/general/mcp-server/recommended-tools.ts index 80a26896..b20fc70e 100644 --- a/extensions/general/mcp-server/recommended-tools.ts +++ b/extensions/general/mcp-server/recommended-tools.ts @@ -68,6 +68,7 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [ // staged link (bank accounts and skattekonto alike). 'gnubok_list_reconciliation_items', 'gnubok_reconcile_match', + 'gnubok_reconcile_residual', 'gnubok_reconcile_signoff', 'gnubok_list_voucher_gaps', 'gnubok_explain_voucher_gap', @@ -84,6 +85,9 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [ 'gnubok_list_reconciliation_items', 'gnubok_reconcile_match', 'gnubok_reconcile_unmatch', + // Near-miss on a bank account (fee, interest, rounding): link and book + // the difference in one staged step. + 'gnubok_reconcile_residual', // Rows with no counterpart: book them (bank side) or link to the // verifikat that already holds the affärshändelse. 'gnubok_categorize_transaction', diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index e082953b..b0663565 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -173,6 +173,7 @@ import { getAccountStatus } from '@/lib/reconciliation/service' import { listAccountItems } from '@/lib/reconciliation/items' import { matchPairs } from '@/lib/reconciliation/actions' import { signOffAccount } from '@/lib/reconciliation/signoff' +import { bookResidualAndLink, RESIDUAL_MAX_AMOUNT } from '@/lib/reconciliation/residual' import { parseAccountKey, type ReconciliationItemBucket } from '@/lib/reconciliation/schemas' import { decryptPersonnummer, maskEmployeeForResponse, maskPersonnummer } from '@/lib/salary/personnummer' import { @@ -1388,6 +1389,7 @@ const TOOL_PREFLIGHT_MAP: Record = { gnubok_book_salary_run: 'gnubok_get_salary_run', gnubok_reconcile_match: 'gnubok_get_reconciliation_status', gnubok_reconcile_signoff: 'gnubok_get_reconciliation_status', + gnubok_reconcile_residual: 'gnubok_get_reconciliation_status', } /** @@ -10201,6 +10203,78 @@ export const tools: McpTool[] = [ }, }, + { + name: 'gnubok_reconcile_residual', + title: 'Reconcile: Book Residual and Link', + description: 'Close a near-match on a bank account in one step: link 1..50 bank tx to one verifikat and book the small difference as bank fee (6570), interest (8410/8310) or rounding (3740). Bank only; refused at 0, above the cap, or wrong direction. Stages; dry_run previews.', + catalogVisibility: 'search', + inputSchema: { + type: 'object', + additionalProperties: false, + properties: { + account_key: { type: 'string', description: '"bank:" (skattekonto is refused: Skatteverket posts ränta and avgifter as rows of their own).' }, + external_ids: { type: 'array', items: { type: 'string' }, description: 'transaction_id list (1..50) that together settle the verifikat except for the residual.' }, + journal_entry_id: { type: 'string', description: 'The verifikat the transactions belong to.' }, + kind: { type: 'string', enum: ['bank_fee', 'interest_expense', 'interest_income', 'rounding'], description: 'What the difference is. bank_fee / interest_expense: money left the bank unbooked; interest_income: money arrived unbooked; rounding: either way.' }, + entry_date: { type: 'string', description: 'YYYY-MM-DD for the residual verifikat. Default: the latest transaction date.' }, + description: { type: 'string', description: 'Verifikat text. Default per kind.' }, + dry_run: { type: 'boolean' }, + idempotency_key: { type: 'string' }, + }, + required: ['account_key', 'external_ids', 'journal_entry_id', 'kind'], + }, + outputSchema: STAGED_OPERATION_SCHEMA, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + async execute(args, companyId, userId, supabase, actor) { + const accountKey = args.account_key as string + const externalIds = args.external_ids as string[] + const journalEntryId = args.journal_entry_id as string + const kind = args.kind as 'bank_fee' | 'rounding' | 'interest_income' | 'interest_expense' + if (!parseAccountKey(accountKey)) throw new Error(`Invalid account_key "${accountKey}"`) + if (!Array.isArray(externalIds) || externalIds.length === 0 || externalIds.length > 50) { + throw new Error('external_ids must hold 1..50 transaction ids') + } + // Policy runs now (dry run of the booking) so a refusal (zero, above the + // cap, wrong direction, skattekonto) surfaces here, not at approval time; + // the executor recomputes the residual when the user approves. + const input = { + external_ids: externalIds, + journal_entry_id: journalEntryId, + kind, + entry_date: args.entry_date as string | undefined, + description: args.description as string | undefined, + } + const preview = await bookResidualAndLink(supabase, companyId, userId, accountKey, input, { dryRun: true }) + if (!preview) throw new Error(`Unknown account_key "${accountKey}" for this company`) + if (!preview.dry_run) throw new Error('Unexpected live result from a dry run') + const wouldBook = preview.would_book + return stagePendingOperation( + supabase, + companyId, + userId, + 'reconciliation_residual', + `Bokför restpost ${wouldBook.residual_amount} ${wouldBook.currency} (${kind}) på ${accountKey} och koppla ${externalIds.length} rad(er)`, + { account_key: accountKey, ...input }, + { ...wouldBook, account_key: accountKey, transaction_count: externalIds.length, max_amount: RESIDUAL_MAX_AMOUNT }, + actor, + { + description: 'After approval, re-read the bridge: the selection is matched and the residual verifikat anchors on the first transaction.', + tool: 'gnubok_get_reconciliation_status', + args: { account_key: accountKey }, + }, + { + dryRun: args.dry_run === true, + idempotencyKey: args.idempotency_key as string | undefined, + }, + ) + }, + }, + { name: 'gnubok_list_cash_accounts', title: 'List Cash Accounts', diff --git a/extensions/general/mcp-server/skills/reconcile-month.ts b/extensions/general/mcp-server/skills/reconcile-month.ts index ad7615f3..ab005638 100644 --- a/extensions/general/mcp-server/skills/reconcile-month.ts +++ b/extensions/general/mcp-server/skills/reconcile-month.ts @@ -46,14 +46,14 @@ Per account: outside vs ledger, what was linked, what the user still has to book ## Rules - Links and sign-offs never touch the ledger; booking does, and always stages. -- One outside row links to one verifikat in this version; other shapes come back as UNSUPPORTED_PAIR_SHAPE. A fee or rounding difference needs a residual booking by the user first. +- One or more outside rows link to one verifikat; other shapes come back as UNSUPPORTED_PAIR_SHAPE. A small fee, interest or rounding difference on a bank account is closed with \`gnubok_reconcile_residual({ account_key, external_ids, journal_entry_id, kind, dry_run: true })\`, then without dry_run: it links the rows and books the difference (6570 / 8410 / 8310 / 3740) in one staged step. Anything larger than the cap is a missing booking, not a fee. - Never judge on \`difference\`; the bridge explains it. Judge on \`unexplained_difference\`. - A skattekonto sign-off date cannot pass the saldo snapshot; ask for a fetch. ## Tools used - \`gnubok_get_reconciliation_status\`, \`gnubok_list_reconciliation_items\` (read) -- \`gnubok_reconcile_match\`, \`gnubok_reconcile_unmatch\`, \`gnubok_reconcile_signoff\` (staged writes) +- \`gnubok_reconcile_match\`, \`gnubok_reconcile_unmatch\`, \`gnubok_reconcile_residual\`, \`gnubok_reconcile_signoff\` (staged writes) - \`gnubok_categorize_transaction\`, \`gnubok_link_transaction_to_journal_entry\` (bank-side booking) - \`gnubok_approve_pending_operation\` (when the user approves in chat) - Resource: \`Accounted://reconciliation/summary\` diff --git a/lib/auth/api-keys.ts b/lib/auth/api-keys.ts index 2d960400..d1f426f5 100644 --- a/lib/auth/api-keys.ts +++ b/lib/auth/api-keys.ts @@ -194,6 +194,8 @@ export const TOOL_SCOPE_MAP: Record = { gnubok_reconcile_match: 'reconciliation:write', gnubok_reconcile_unmatch: 'reconciliation:write', gnubok_reconcile_signoff: 'reconciliation:signoff', + // Residual booking writes a verifikat: the same scope that books a bank row. + gnubok_reconcile_residual: 'transactions:write', gnubok_bulk_book_transactions: 'transactions:write', gnubok_bulk_book_inbox_items: 'transactions:write', gnubok_auto_match_period: 'transactions:write', diff --git a/lib/pending-operations/__tests__/reconciliation-residual-executor.test.ts b/lib/pending-operations/__tests__/reconciliation-residual-executor.test.ts new file mode 100644 index 00000000..f2ec6592 --- /dev/null +++ b/lib/pending-operations/__tests__/reconciliation-residual-executor.test.ts @@ -0,0 +1,117 @@ +/** + * commitReconciliationResidual, driven through the public commitPendingOperation + * dispatcher. The booking itself lives in lib/reconciliation/residual.ts (unit + * tested there); these tests cover the wiring: param validation, the + * refusal-to-status mapping, and the committed payload. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { eventBus } from '@/lib/events/bus' +import { createQueuedMockSupabase } from '@/tests/helpers' +import type { PendingOperation } from '@/types' + +const residualMock = vi.fn() +vi.mock('@/lib/reconciliation/residual', async () => { + const actual = await vi.importActual('@/lib/reconciliation/residual') + return { ...actual, bookResidualAndLink: (...args: unknown[]) => residualMock(...args) } +}) + +import { ReconciliationResidualError } from '@/lib/reconciliation/residual' +import { commitPendingOperation } from '../commit' + +const CASH = '11111111-1111-4111-8111-111111111111' +const KEY = `bank:${CASH}` +const T1 = '22222222-2222-4222-8222-222222222222' +const E1 = '44444444-4444-4444-8444-444444444444' + +function makePendingOp(overrides: Partial): PendingOperation { + return { + id: 'op-1', + user_id: 'user-1', + company_id: 'company-1', + operation_type: 'reconciliation_residual', + status: 'pending', + title: 'test', + params: { account_key: KEY, external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee' }, + preview_data: {}, + result_data: null, + actor_type: 'user', + actor_id: null, + actor_label: null, + risk_level: 'medium', + created_at: '2026-08-25T00:00:00Z', + resolved_at: null, + updated_at: '2026-08-25T00:00:00Z', + ...overrides, + } as PendingOperation +} + +beforeEach(() => { + vi.clearAllMocks() + eventBus.clear() +}) + +describe('commitPendingOperation: reconciliation_residual', () => { + it('fails 400 when the staged params are incomplete, without booking', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's reject update + const op = makePendingOp({ params: { account_key: KEY, external_ids: [], journal_entry_id: E1, kind: 'bank_fee' } }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + expect(result.status).toBe('failed') + expect(result.http_status).toBe(400) + expect(residualMock).not.toHaveBeenCalled() + }) + + it('books and links through the shared service and returns the residual verifikat', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ data: null, error: null }) // dispatcher's committed update + residualMock.mockResolvedValue({ + dry_run: false, + residual_journal_entry_id: 'res-1', + residual_amount: -10, + applied: [{ external_id: T1, journal_entry_id: E1 }], + skipped: [], + }) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({})) + expect(residualMock).toHaveBeenCalledWith( + supabase, + 'company-1', + 'user-1', + KEY, + { external_ids: [T1], journal_entry_id: E1, kind: 'bank_fee', entry_date: undefined, description: undefined }, + { dryRun: false }, + ) + expect(result.status).toBe('committed') + expect(result.data).toMatchObject({ account_key: KEY, residual_journal_entry_id: 'res-1', residual_amount: -10 }) + }) + + it('maps a policy refusal to 400 with the residual code, and missing rows to a 404 auto-reject', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ data: null, error: null }) + residualMock.mockRejectedValueOnce(new ReconciliationResidualError('över taket', 'RESIDUAL_TOO_LARGE')) + const refused = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({})) + expect(refused.status).toBe('failed') + expect(refused.http_status).toBe(400) + expect(refused.code).toBe('RESIDUAL_TOO_LARGE') + + const second = createQueuedMockSupabase() + second.enqueue({ data: { id: 'op-1' }, error: null }) + second.enqueue({ data: null, error: null }) + residualMock.mockRejectedValueOnce(new ReconciliationResidualError('saknas', 'RESIDUAL_ROWS_NOT_FOUND')) + const missing = await commitPendingOperation(second.supabase as never, 'user-1', 'company-1', makePendingOp({})) + expect(missing.status).toBe('rejected') + expect(missing.http_status).toBe(404) + }) + + it('404s an account key the company does not own', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: { id: 'op-1' }, error: null }) + enqueue({ data: null, error: null }) + residualMock.mockResolvedValueOnce(null) + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', makePendingOp({})) + expect(result.status).toBe('rejected') + expect(result.http_status).toBe(404) + }) +}) diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 6523caf6..cd5100be 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -25,6 +25,7 @@ import { import { roundOre } from '@/lib/money' import { matchPairs, unmatchLink } from '@/lib/reconciliation/actions' import { signOffAccount } from '@/lib/reconciliation/signoff' +import { bookResidualAndLink, ReconciliationResidualError } from '@/lib/reconciliation/residual' import { getErrorMessage } from '@/lib/errors/get-error-message' import { validateVatNumber } from '@/lib/vat/vies-client' import { @@ -6085,6 +6086,64 @@ async function commitReconciliationSignoff( } } +/** + * reconciliation_residual: book the remainder of a bank selection as a small + * fee / interest / rounding verifikat and link the selection, through the same + * service the page and the v1 API use (lib/reconciliation/residual.ts). The + * amount and direction are recomputed at commit time; a refusal (grown past + * the cap, rows linked meanwhile, locked period) leaves nothing half done. + */ +async function commitReconciliationResidual( + supabase: SupabaseClient, + userId: string, + companyId: string, + params: Record +): Promise { + const accountKey = params.account_key as string | undefined + const externalIds = params.external_ids as string[] | undefined + const journalEntryId = params.journal_entry_id as string | undefined + const kind = params.kind as 'bank_fee' | 'rounding' | 'interest_income' | 'interest_expense' | undefined + if (!accountKey || !Array.isArray(externalIds) || externalIds.length === 0 || !journalEntryId || !kind) { + return { error: 'account_key, external_ids, journal_entry_id and kind are required', status: 400 } + } + try { + const result = await bookResidualAndLink( + supabase, + companyId, + userId, + accountKey, + { + external_ids: externalIds, + journal_entry_id: journalEntryId, + kind, + entry_date: (params.entry_date as string | undefined) ?? undefined, + description: (params.description as string | undefined) ?? undefined, + }, + { dryRun: false }, + ) + if (!result) return { error: `Unknown account_key ${accountKey}`, status: 404 } + if (result.dry_run) return { error: 'Unexpected dry-run result', status: 500 } + return { + data: { + account_key: accountKey, + residual_journal_entry_id: result.residual_journal_entry_id, + residual_amount: result.residual_amount, + applied: result.applied, + skipped: result.skipped, + }, + } + } catch (err) { + if (err instanceof ReconciliationResidualError) { + return { + error: err.message, + errorCode: err.code, + status: err.code === 'RESIDUAL_ROWS_NOT_FOUND' || err.code === 'RESIDUAL_ENTRY_NOT_FOUND' ? 404 : 400, + } + } + throw err + } +} + async function commitLinkTransactionJournalEntry( supabase: SupabaseClient, userId: string, @@ -6429,6 +6488,9 @@ async function commitPendingOperationInner( case 'reconciliation_signoff': result = await commitReconciliationSignoff(supabase, userId, companyId, pendingOp.params) break + case 'reconciliation_residual': + result = await commitReconciliationResidual(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 bdd22d76..c8b1db4b 100644 --- a/lib/pending-operations/risk-tiers.ts +++ b/lib/pending-operations/risk-tiers.ts @@ -210,6 +210,11 @@ export const OPERATION_RISK_TIERS: Record = { // Sign-off writes the attestation row others rely on (overview, Hem, auditor) // but nothing in the ledger, and reopen undoes it: 'medium'. reconciliation_signoff: 'medium', + // Residual booking writes one small verifikat (bank fee / interest / + // rounding, capped at RESIDUAL_MAX_AMOUNT) against the bank account and + // 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', // ── Körjournal (mileage) ─────────────────────────────────────────── // A trip row is pure travel documentation: no booking impact until a diff --git a/supabase/migrations/20260825100000_pending_operations_add_reconciliation_residual.sql b/supabase/migrations/20260825100000_pending_operations_add_reconciliation_residual.sql new file mode 100644 index 00000000..1ee59e62 --- /dev/null +++ b/supabase/migrations/20260825100000_pending_operations_add_reconciliation_residual.sql @@ -0,0 +1,96 @@ +-- Add 'reconciliation_residual' to the pending_operations operation_type CHECK +-- constraint. +-- +-- gnubok_reconcile_residual (MCP) stages "link these bank transactions to this +-- verifikat and book the small difference as fee / interest / rounding" +-- (lib/reconciliation/residual.ts); the user approves it in Granskning and +-- commitReconciliationResidual in lib/pending-operations/commit.ts links and +-- books. Risk 'medium': one typed, capped verifikat against the bank account, +-- undone by storno + unmatch. +-- +-- 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 20260823140001 (the LIVE prod list read on +-- 2026-08-25) PLUS the new value. Dropping any existing value here would +-- silently revoke it. +-- +-- NOT VALID + separate VALIDATE migration (paired file, same pattern as +-- 20260823140001 / 20260823140002). +-- +-- 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', + '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' + )) NOT VALID; diff --git a/supabase/migrations/20260825100001_validate_pending_operations_reconciliation_residual.sql b/supabase/migrations/20260825100001_validate_pending_operations_reconciliation_residual.sql new file mode 100644 index 00000000..1f4e5108 --- /dev/null +++ b/supabase/migrations/20260825100001_validate_pending_operations_reconciliation_residual.sql @@ -0,0 +1,6 @@ +-- Validate the operation type CHECK re-added in 20260825100000. +-- 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 043eb89f..2b676409 100644 --- a/types/index.ts +++ b/types/index.ts @@ -2494,6 +2494,8 @@ export type PendingOperationType = | 'reconciliation_unmatch' // Sign-off "avstämt t.o.m. " on one account (account_reconciliations row). | 'reconciliation_signoff' + // Book the remainder of a bank selection as a fee/interest/rounding verifikat and link it. + | 'reconciliation_residual' // 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'