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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
9ebb2e518f
commit
1e6e19afe9
@@ -1192,3 +1192,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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] 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] 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-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.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const statusMock = vi.fn()
|
|||||||
const itemsMock = vi.fn()
|
const itemsMock = vi.fn()
|
||||||
const matchMock = vi.fn()
|
const matchMock = vi.fn()
|
||||||
const signoffMock = vi.fn()
|
const signoffMock = vi.fn()
|
||||||
|
const residualMock = vi.fn()
|
||||||
|
|
||||||
vi.mock('@/lib/reconciliation/service', () => ({
|
vi.mock('@/lib/reconciliation/service', () => ({
|
||||||
getAccountStatus: (...args: unknown[]) => statusMock(...args),
|
getAccountStatus: (...args: unknown[]) => statusMock(...args),
|
||||||
@@ -28,6 +29,10 @@ vi.mock('@/lib/reconciliation/actions', () => ({
|
|||||||
vi.mock('@/lib/reconciliation/signoff', () => ({
|
vi.mock('@/lib/reconciliation/signoff', () => ({
|
||||||
signOffAccount: (...args: unknown[]) => signoffMock(...args),
|
signOffAccount: (...args: unknown[]) => signoffMock(...args),
|
||||||
}))
|
}))
|
||||||
|
vi.mock('@/lib/reconciliation/residual', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('@/lib/reconciliation/residual')>('@/lib/reconciliation/residual')
|
||||||
|
return { ...actual, bookResidualAndLink: (...args: unknown[]) => residualMock(...args) }
|
||||||
|
})
|
||||||
|
|
||||||
import { tools, isDefaultCatalogTool, deriveToolMeta } from '../server'
|
import { tools, isDefaultCatalogTool, deriveToolMeta } from '../server'
|
||||||
|
|
||||||
@@ -225,3 +230,83 @@ describe('gnubok_reconcile_signoff', () => {
|
|||||||
expect(signoffMock).not.toHaveBeenCalled()
|
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<string, unknown>
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [
|
|||||||
// staged link (bank accounts and skattekonto alike).
|
// staged link (bank accounts and skattekonto alike).
|
||||||
'gnubok_list_reconciliation_items',
|
'gnubok_list_reconciliation_items',
|
||||||
'gnubok_reconcile_match',
|
'gnubok_reconcile_match',
|
||||||
|
'gnubok_reconcile_residual',
|
||||||
'gnubok_reconcile_signoff',
|
'gnubok_reconcile_signoff',
|
||||||
'gnubok_list_voucher_gaps',
|
'gnubok_list_voucher_gaps',
|
||||||
'gnubok_explain_voucher_gap',
|
'gnubok_explain_voucher_gap',
|
||||||
@@ -84,6 +85,9 @@ export const RECOMMENDED_WORKFLOW_LOADOUTS: readonly WorkflowLoadout[] = [
|
|||||||
'gnubok_list_reconciliation_items',
|
'gnubok_list_reconciliation_items',
|
||||||
'gnubok_reconcile_match',
|
'gnubok_reconcile_match',
|
||||||
'gnubok_reconcile_unmatch',
|
'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
|
// Rows with no counterpart: book them (bank side) or link to the
|
||||||
// verifikat that already holds the affärshändelse.
|
// verifikat that already holds the affärshändelse.
|
||||||
'gnubok_categorize_transaction',
|
'gnubok_categorize_transaction',
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ import { getAccountStatus } from '@/lib/reconciliation/service'
|
|||||||
import { listAccountItems } from '@/lib/reconciliation/items'
|
import { listAccountItems } from '@/lib/reconciliation/items'
|
||||||
import { matchPairs } from '@/lib/reconciliation/actions'
|
import { matchPairs } from '@/lib/reconciliation/actions'
|
||||||
import { signOffAccount } from '@/lib/reconciliation/signoff'
|
import { signOffAccount } from '@/lib/reconciliation/signoff'
|
||||||
|
import { bookResidualAndLink, RESIDUAL_MAX_AMOUNT } from '@/lib/reconciliation/residual'
|
||||||
import { parseAccountKey, type ReconciliationItemBucket } from '@/lib/reconciliation/schemas'
|
import { parseAccountKey, type ReconciliationItemBucket } from '@/lib/reconciliation/schemas'
|
||||||
import { decryptPersonnummer, maskEmployeeForResponse, maskPersonnummer } from '@/lib/salary/personnummer'
|
import { decryptPersonnummer, maskEmployeeForResponse, maskPersonnummer } from '@/lib/salary/personnummer'
|
||||||
import {
|
import {
|
||||||
@@ -1388,6 +1389,7 @@ const TOOL_PREFLIGHT_MAP: Record<string, string> = {
|
|||||||
gnubok_book_salary_run: 'gnubok_get_salary_run',
|
gnubok_book_salary_run: 'gnubok_get_salary_run',
|
||||||
gnubok_reconcile_match: 'gnubok_get_reconciliation_status',
|
gnubok_reconcile_match: 'gnubok_get_reconciliation_status',
|
||||||
gnubok_reconcile_signoff: '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:<cash_account_id>" (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',
|
name: 'gnubok_list_cash_accounts',
|
||||||
title: 'List Cash Accounts',
|
title: 'List Cash Accounts',
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ Per account: outside vs ledger, what was linked, what the user still has to book
|
|||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Links and sign-offs never touch the ledger; booking does, and always stages.
|
- 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\`.
|
- 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.
|
- A skattekonto sign-off date cannot pass the saldo snapshot; ask for a fetch.
|
||||||
|
|
||||||
## Tools used
|
## Tools used
|
||||||
|
|
||||||
- \`gnubok_get_reconciliation_status\`, \`gnubok_list_reconciliation_items\` (read)
|
- \`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_categorize_transaction\`, \`gnubok_link_transaction_to_journal_entry\` (bank-side booking)
|
||||||
- \`gnubok_approve_pending_operation\` (when the user approves in chat)
|
- \`gnubok_approve_pending_operation\` (when the user approves in chat)
|
||||||
- Resource: \`Accounted://reconciliation/summary\`
|
- Resource: \`Accounted://reconciliation/summary\`
|
||||||
|
|||||||
@@ -194,6 +194,8 @@ export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
|||||||
gnubok_reconcile_match: 'reconciliation:write',
|
gnubok_reconcile_match: 'reconciliation:write',
|
||||||
gnubok_reconcile_unmatch: 'reconciliation:write',
|
gnubok_reconcile_unmatch: 'reconciliation:write',
|
||||||
gnubok_reconcile_signoff: 'reconciliation:signoff',
|
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_transactions: 'transactions:write',
|
||||||
gnubok_bulk_book_inbox_items: 'transactions:write',
|
gnubok_bulk_book_inbox_items: 'transactions:write',
|
||||||
gnubok_auto_match_period: 'transactions:write',
|
gnubok_auto_match_period: 'transactions:write',
|
||||||
|
|||||||
@@ -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<typeof import('@/lib/reconciliation/residual')>('@/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>): 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { roundOre } from '@/lib/money'
|
import { roundOre } from '@/lib/money'
|
||||||
import { matchPairs, unmatchLink } from '@/lib/reconciliation/actions'
|
import { matchPairs, unmatchLink } from '@/lib/reconciliation/actions'
|
||||||
import { signOffAccount } from '@/lib/reconciliation/signoff'
|
import { signOffAccount } from '@/lib/reconciliation/signoff'
|
||||||
|
import { bookResidualAndLink, ReconciliationResidualError } from '@/lib/reconciliation/residual'
|
||||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||||
import { validateVatNumber } from '@/lib/vat/vies-client'
|
import { validateVatNumber } from '@/lib/vat/vies-client'
|
||||||
import {
|
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<string, unknown>
|
||||||
|
): Promise<ExecutorResult> {
|
||||||
|
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(
|
async function commitLinkTransactionJournalEntry(
|
||||||
supabase: SupabaseClient,
|
supabase: SupabaseClient,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -6429,6 +6488,9 @@ async function commitPendingOperationInner(
|
|||||||
case 'reconciliation_signoff':
|
case 'reconciliation_signoff':
|
||||||
result = await commitReconciliationSignoff(supabase, userId, companyId, pendingOp.params)
|
result = await commitReconciliationSignoff(supabase, userId, companyId, pendingOp.params)
|
||||||
break
|
break
|
||||||
|
case 'reconciliation_residual':
|
||||||
|
result = await commitReconciliationResidual(supabase, userId, companyId, pendingOp.params)
|
||||||
|
break
|
||||||
case 'submit_vat_declaration':
|
case 'submit_vat_declaration':
|
||||||
result = await commitSubmitVatDeclaration(supabase, userId, companyId, pendingOp.params)
|
result = await commitSubmitVatDeclaration(supabase, userId, companyId, pendingOp.params)
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -210,6 +210,11 @@ export const OPERATION_RISK_TIERS: Record<string, RiskLevel> = {
|
|||||||
// Sign-off writes the attestation row others rely on (overview, Hem, auditor)
|
// Sign-off writes the attestation row others rely on (overview, Hem, auditor)
|
||||||
// but nothing in the ledger, and reopen undoes it: 'medium'.
|
// but nothing in the ledger, and reopen undoes it: 'medium'.
|
||||||
reconciliation_signoff: '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) ───────────────────────────────────────────
|
// ── Körjournal (mileage) ───────────────────────────────────────────
|
||||||
// A trip row is pure travel documentation: no booking impact until a
|
// A trip row is pure travel documentation: no booking impact until a
|
||||||
|
|||||||
@@ -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;
|
||||||
+6
@@ -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;
|
||||||
@@ -2494,6 +2494,8 @@ export type PendingOperationType =
|
|||||||
| 'reconciliation_unmatch'
|
| 'reconciliation_unmatch'
|
||||||
// Sign-off "avstämt t.o.m. <datum>" on one account (account_reconciliations row).
|
// Sign-off "avstämt t.o.m. <datum>" on one account (account_reconciliations row).
|
||||||
| 'reconciliation_signoff'
|
| '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"
|
// PR5: Skatteverket filing via MCP. Commit = "send for BankID signing"
|
||||||
// (returns a signing link); the user's signature in the browser files it.
|
// (returns a signing link); the user's signature in the browser files it.
|
||||||
| 'submit_vat_declaration'
|
| 'submit_vat_declaration'
|
||||||
|
|||||||
Reference in New Issue
Block a user