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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-17 22:21:53 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 15a1c96292
commit 76b8d5c100
3 changed files with 159 additions and 7 deletions
@@ -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<Record<string, unknown>> }
preview: {
dimension_resolutions?: Array<Record<string, unknown>>
lines?: Array<Record<string, unknown>>
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.
+48 -6
View File
@@ -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<Record<string, unknown>> | null = null
if (stagedNewEntry) {
const stagedLines = stagedNewEntry.lines as Array<Record<string, unknown>>
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<string, string>()
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<string, unknown>).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 } : {}),