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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
15a1c96292
commit
76b8d5c100
@@ -46,9 +46,21 @@ function CategorizePreview({ data }: { data: Record<string, unknown> }) {
|
||||
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 (
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="text-xs text-muted-foreground mb-1">Verifikat</p>
|
||||
{txCurrency !== 'SEK' && txAmount !== null && (
|
||||
<div className="flex justify-between gap-4 text-xs text-muted-foreground mb-1">
|
||||
<span>Banktransaktion</span>
|
||||
<span className="tabular-nums shrink-0">{formatCurrency(txAmount, txCurrency)}</span>
|
||||
</div>
|
||||
)}
|
||||
{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<string, unknown> }) {
|
||||
)
|
||||
}
|
||||
|
||||
function BulkBookPreview({ data }: { data: Record<string, unknown> }) {
|
||||
// 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 (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||
<span className="text-muted-foreground">Datum</span>
|
||||
<span className="font-mono">{String(data.tx_date ?? '')}</span>
|
||||
<span className="text-muted-foreground">Transaktioner</span>
|
||||
<span className="font-mono tabular-nums">
|
||||
{txCount ?? '-'}
|
||||
{txSum !== null ? ` · ${formatCurrency(txSum, currency)}` : ''}
|
||||
</span>
|
||||
<span className="text-muted-foreground">Åtgärd</span>
|
||||
<span>{linkExisting ? 'Länka till befintligt verifikat' : 'Ny samlingsverifikation'}</span>
|
||||
{data.entry_description ? (
|
||||
<>
|
||||
<span className="text-muted-foreground">Beskrivning</span>
|
||||
<span className="truncate">{String(data.entry_description)}</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{lines.length > 0 && (
|
||||
<div>
|
||||
<div className="grid grid-cols-[auto_1fr_auto_auto] gap-x-3 text-[11px] uppercase tracking-wider text-muted-foreground pb-1">
|
||||
<span>Konto</span>
|
||||
<span>Text</span>
|
||||
<span className="text-right w-24">Debet</span>
|
||||
<span className="text-right w-24">Kredit</span>
|
||||
</div>
|
||||
<VoucherLinesTable lines={lines} />
|
||||
<div className="border-t pt-2 grid grid-cols-[auto_1fr_auto_auto] gap-x-3 text-xs">
|
||||
<span></span>
|
||||
<span className="text-muted-foreground">Summa</span>
|
||||
<span className="font-mono tabular-nums text-right w-24 font-medium">
|
||||
{formatCurrency(totalDebit)}
|
||||
</span>
|
||||
<span className="font-mono tabular-nums text-right w-24 font-medium">
|
||||
{formatCurrency(totalCredit)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{linkExisting && lines.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Transaktionerna kopplas till ett redan bokfört verifikat; ingen ny kontering skapas.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CorrectEntryPreview({ data }: { data: Record<string, unknown> }) {
|
||||
const original = (data.original as {
|
||||
voucher?: string
|
||||
@@ -451,6 +526,8 @@ export function OperationPreview({ op }: { op: OperationPreviewInput }) {
|
||||
return <CreateTransactionPreview data={op.preview_data} />
|
||||
case 'create_voucher':
|
||||
return <VoucherPreview data={op.preview_data} />
|
||||
case 'bulk_book_transactions':
|
||||
return <BulkBookPreview data={op.preview_data} />
|
||||
case 'correct_entry':
|
||||
return <CorrectEntryPreview data={op.preview_data} />
|
||||
case 'attach_document_to_transaction':
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user