From 76b8d5c100a4171231b52def3ee355babfd3e105 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 17 Aug 2026 22:21:53 +0200 Subject: [PATCH] fix(pending): show the staged kontering and bank currency on the bulk_book_transactions approval card (#1648) The /pending card (and the chat ApprovalCard, same OperationPreview dispatch) for bulk_book_transactions rendered only aggregates: tx_count, tx_date, tx_sum, direction, mode. The staged journal lines sat unused in params.new_entry.lines even though the executor's RPC posts them verbatim, so the human approving an AI-staged samlingsverifikat could not see which accounts were debited or credited: "-720, 2 tx, expense" is compatible with both a correct booking and a wrong one. - Staging now writes preview_data.lines (account_number, chart or BAS account_name, debit/credit, line text) and entry_description, using the same account-name lookup as gnubok_create_voucher, plus the bank rows' currency. Nothing beyond what create_voucher already exposes; still no per-tx descriptions or counterparty identifiers. - New BulkBookPreview renders those lines with the create_voucher table and totals, and shows the bank sum in the rows' own currency. - CategorizePreview labels the source bank amount with its currency when it is not SEK, next to the (always SEK) journal lines: a 2 500 USD receipt booked as 24 292,50 kr read as a wrong SEK figure to an approver who saw only one of the two numbers. Reported via gnubok_feedback 2026-07-13 and 2026-07-14 ("the human-in- the-loop control is the safety mechanism, and it is currently blind"). Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- .../pending-operations/OperationPreview.tsx | 77 +++++++++++++++++++ .../__tests__/dimension-tools.test.ts | 35 ++++++++- extensions/general/mcp-server/server.ts | 54 +++++++++++-- 3 files changed, 159 insertions(+), 7 deletions(-) diff --git a/components/pending-operations/OperationPreview.tsx b/components/pending-operations/OperationPreview.tsx index bd483bb8..a4e1625e 100644 --- a/components/pending-operations/OperationPreview.tsx +++ b/components/pending-operations/OperationPreview.tsx @@ -46,9 +46,21 @@ function CategorizePreview({ data }: { data: Record }) { const vatLines = (data.vat_lines as Array<{ account_number: string; debit_amount: number; credit_amount: number; description: string }>) || [] if (lines.length > 0) { + // Journal lines are always SEK (BFL 5 kap 2 §). When the bank row itself + // is in another currency, say so next to the lines: a 2 500 USD receipt + // booked as 24 292,50 kr read as a wrong SEK figure to an approver who + // only saw one of the two numbers. + const txCurrency = (data.currency as string) || 'SEK' + const txAmount = typeof data.amount === 'number' && Number.isFinite(data.amount) ? data.amount : null return (

Verifikat

+ {txCurrency !== 'SEK' && txAmount !== null && ( +
+ Banktransaktion + {formatCurrency(txAmount, txCurrency)} +
+ )} {lines.map((line, i) => { const debitAmt = typeof line.debit_amount === 'number' ? line.debit_amount : 0 const creditAmt = typeof line.credit_amount === 'number' ? line.credit_amount : 0 @@ -291,6 +303,69 @@ function VoucherPreview({ data }: { data: Record }) { ) } +function BulkBookPreview({ data }: { data: Record }) { + // Samlingsverifikat over N bank rows. The staged kontering IS what the RPC + // posts on approval, so it is the load-bearing part of this card; the + // aggregates alone ("-720, 2 tx, expense") cannot tell a right booking from + // a wrong one. Journal lines are SEK; the bank sum is shown in the rows' + // own currency so a foreign batch is never misread as SEK. + const lines = (data.lines as VoucherLine[]) || [] + const txCount = typeof data.tx_count === 'number' ? data.tx_count : null + const txSum = typeof data.tx_sum === 'number' && Number.isFinite(data.tx_sum) ? data.tx_sum : null + const currency = (data.currency as string) || 'SEK' + const linkExisting = data.mode === 'link_existing' + const totalDebit = lines.reduce((s, l) => s + (l.debit_amount > 0 ? l.debit_amount : 0), 0) + const totalCredit = lines.reduce((s, l) => s + (l.credit_amount > 0 ? l.credit_amount : 0), 0) + + return ( +
+
+ Datum + {String(data.tx_date ?? '')} + Transaktioner + + {txCount ?? '-'} + {txSum !== null ? ` · ${formatCurrency(txSum, currency)}` : ''} + + Åtgärd + {linkExisting ? 'Länka till befintligt verifikat' : 'Ny samlingsverifikation'} + {data.entry_description ? ( + <> + Beskrivning + {String(data.entry_description)} + + ) : null} +
+ {lines.length > 0 && ( +
+
+ Konto + Text + Debet + Kredit +
+ +
+ + Summa + + {formatCurrency(totalDebit)} + + + {formatCurrency(totalCredit)} + +
+
+ )} + {linkExisting && lines.length === 0 && ( +

+ Transaktionerna kopplas till ett redan bokfört verifikat; ingen ny kontering skapas. +

+ )} +
+ ) +} + function CorrectEntryPreview({ data }: { data: Record }) { const original = (data.original as { voucher?: string @@ -451,6 +526,8 @@ export function OperationPreview({ op }: { op: OperationPreviewInput }) { return case 'create_voucher': return + case 'bulk_book_transactions': + return case 'correct_entry': return case 'attach_document_to_transaction': diff --git a/extensions/general/mcp-server/__tests__/dimension-tools.test.ts b/extensions/general/mcp-server/__tests__/dimension-tools.test.ts index 9edc50a4..d43f0792 100644 --- a/extensions/general/mcp-server/__tests__/dimension-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/dimension-tools.test.ts @@ -854,6 +854,14 @@ describe('gnubok_bulk_book_transactions: dimensions bag', () => { data: [{ id: 'tx-1', amount: -400, currency: 'SEK', date: '2026-05-12', journal_entry_id: null }], error: null, }) + // chart_of_accounts name lookup for the preview kontering + enqueue({ + data: [ + { account_number: '4010', account_name: 'Inköp material och varor' }, + { account_number: '1930', account_name: 'Företagskonto' }, + ], + error: null, + }) // resolvePeriodStatusForDate: 2 layers enqueue({ data: null, error: null }) enqueue({ data: null, error: null }) @@ -877,11 +885,36 @@ describe('gnubok_bulk_book_transactions: dimensions bag', () => { supabase as never, )) as { staged: boolean - preview: { dimension_resolutions?: Array> } + preview: { + dimension_resolutions?: Array> + lines?: Array> + currency?: string + entry_description?: string + } } expect(result.staged).toBe(true) + // The approval card renders the staged kontering: the exact lines the + // RPC will post, named, plus the bank rows' currency. Aggregates alone + // ("-400, 1 tx, expense") cannot tell a right booking from a wrong one. + expect(result.preview.currency).toBe('SEK') + expect(result.preview.entry_description).toBe('Samlingsverifikation material') + expect(result.preview.lines).toEqual([ + expect.objectContaining({ + account_number: '4010', + account_name: 'Inköp material och varor', + debit_amount: 400, + credit_amount: 0, + }), + expect.objectContaining({ + account_number: '1930', + account_name: 'Företagskonto', + debit_amount: 0, + credit_amount: 400, + }), + ]) + // Contract: per-line `new_entry.lines[].dimensions` carries the MERGED // (line-over-default) resolved bags; the top-level default is dropped: // the executor's RPC reads per-line dims only. diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index e4882927..792e37ab 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -8775,6 +8775,43 @@ export const tools: McpTool[] = [ periodCheckDate = (je.entry_date as string) > txDate ? (je.entry_date as string) : txDate } + // The staged kontering, named for the approver. Same shape and account- + // name lookup as gnubok_create_voucher's preview: the approval card is + // the human-in-the-loop control on an irreversible BFL posting, and a + // card that shows "-720, 2 tx, expense" without the debit/credit lines + // is compatible with both a correct booking and a wrong one. The + // executor's RPC posts new_entry.lines verbatim, so this preview is + // exactly what gets committed. Nothing beyond what create_voucher + // already exposes: BAS account + name, amounts, and the agent-authored + // line text; still no per-tx descriptions or counterparty identifiers. + let previewLines: Array> | null = null + if (stagedNewEntry) { + const stagedLines = stagedNewEntry.lines as Array> + const accountNumbers = [...new Set(stagedLines.map((l) => String(l.account_number)))] + const { data: accountRows } = await supabase + .from('chart_of_accounts') + .select('account_number, account_name') + .eq('company_id', companyId) + .in('account_number', accountNumbers) + const accountNames = new Map() + for (const a of accountRows || []) { + accountNames.set(String(a.account_number), (a.account_name as string) ?? '') + } + previewLines = stagedLines.map((l) => { + const accountNumber = String(l.account_number) + return { + account_number: accountNumber, + account_name: + accountNames.get(accountNumber) ?? + getBASReference(accountNumber)?.account_name ?? + null, + debit_amount: Number(l.debit_amount) || 0, + credit_amount: Number(l.credit_amount) || 0, + line_description: (l.line_description as string | undefined) ?? null, + } + }) + } + return stagePendingOperation(supabase, companyId, userId, 'bulk_book_transactions', existingJeId ? `Länka ${txIds.length} transaktioner till verifikat (${txDate})` @@ -8784,18 +8821,23 @@ export const tools: McpTool[] = [ existing_journal_entry_id: existingJeId, new_entry: stagedNewEntry, }, - // GDPR Art.25: preview_data carries only aggregate counts + the - // shared date/direction: no per-tx descriptions, no per-line - // descriptions, no counterparty IDs. The user-facing approval - // dialog reconstructs detail from the tx_ids list at render time - // rather than persisting denormalized PII here. Same privacy-by- - // design rationale as gnubok_link_transaction_to_journal_entry. + // GDPR Art.25: preview_data carries aggregate counts, the shared + // date/direction/currency, and the staged kontering (see previewLines + // above): no per-tx descriptions, no counterparty IDs. Same privacy- + // by-design rationale as gnubok_link_transaction_to_journal_entry. { tx_count: txIds.length, tx_date: txDate, tx_sum: txSum, + currency: txs[0]!.currency ?? 'SEK', direction, mode: existingJeId ? 'link_existing' : 'create_new', + ...(previewLines + ? { + entry_description: (stagedNewEntry as Record).description ?? null, + lines: previewLines, + } + : {}), // Echoed for every non-exact dimension resolution (resolve-don't- // select) so the agent can verify what a name attached to. ...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),