fix(bookkeeping): resolve settlement account in all categorization flows and ship mis-booking audit (#1383)

Completes the #985/#986/#987 caller sweep: categorize-core, v1
batch-categorize, pending-operation edits and the MCP categorize path now
resolve the settlement leg from the transaction's cash account instead of
inheriting a hardcoded or stale account. Extends the correct_entry preview
with currency, tax and dimension line metadata so staged corrections
preserve full line fidelity. Adds a read-only audit query and a runbook for
reviewing and correcting historical mis-bookings via staged storno with
explicit approval; no automated bulk mutation.

Fixes #1001

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-03 18:45:38 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent e40dc64485
commit 8299ee9fb4
11 changed files with 726 additions and 4 deletions
@@ -722,6 +722,41 @@ describe('gnubok_create_invoice: dimensions bag', () => {
})
describe('gnubok_categorize_transaction: dimensions bag', () => {
it('shows the linked cash account in the staged preview', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const tx = makeTransaction({
id: 'tx-1',
amount: -500,
cash_account_id: 'cash-1',
})
enqueue({ data: tx, error: null })
enqueue({ data: { entity_type: 'enskild_firma', fiscal_year_start_month: 1 }, error: null })
enqueue({ data: { ledger_account: '1931' }, error: null })
enqueue({ data: tx, error: null })
enqueue({ data: null, error: null })
enqueue({ data: null, error: null })
enqueue({ data: { id: 'op-cat-settlement' }, error: null })
const result = (await categorizeTransaction.execute(
{ transaction_id: 'tx-1', category: 'expense_office', allow_duplicate: true },
'company-1',
'user-1',
supabase as never,
)) as {
staged: boolean
preview: {
credit_account: string
lines: Array<{ account_number: string; credit_amount: number }>
}
}
expect(result.staged).toBe(true)
expect(result.preview.credit_account).toBe('1931')
expect(result.preview.lines).toContainEqual(
expect.objectContaining({ account_number: '1931', credit_amount: 500 }),
)
})
it('resolves the bag and stages it as params.dimensions with the echo in the preview', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
const inserts = captureInserts(supabase)
@@ -481,6 +481,110 @@ describe('gnubok_correct_entry: registration', () => {
),
).rejects.toThrow(/not balanced/i)
})
it('shows preserved currency, tax, and dimension metadata in the correction preview', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { dimensions_enabled: false }, error: null })
enqueue({
data: {
id: '11111111-1111-4111-8111-111111111111',
status: 'posted',
entry_date: '2026-05-12',
description: 'Foreign purchase',
voucher_number: 12,
voucher_series: 'A',
fiscal_period_id: 'fp-1',
fiscal_periods: { name: '2026', is_closed: false, locked_at: null },
lines: [
{
account_number: '5420',
debit_amount: 1000,
credit_amount: 0,
line_description: 'Software',
currency: 'EUR',
amount_in_currency: 90,
exchange_rate: 11.111111,
tax_code: 'EU_SERVICE',
dimensions: { '6': 'P001' },
cost_center: null,
project: 'P001',
},
{
account_number: '1930',
debit_amount: 0,
credit_amount: 1000,
line_description: 'Settlement',
currency: 'EUR',
amount_in_currency: 90,
exchange_rate: 11.111111,
tax_code: null,
dimensions: {},
cost_center: null,
project: null,
},
],
},
error: null,
})
enqueue({ data: { bookkeeping_locked_through: null }, error: null })
enqueue({ data: { id: 'fp-1', is_closed: false, locked_at: null }, error: null })
enqueue({ data: { id: 'op-correct-1' }, error: null })
const replacementLines = [
{
account_number: '5420',
debit_amount: 1000,
credit_amount: 0,
line_description: 'Software',
currency: 'EUR',
amount_in_currency: 90,
exchange_rate: 11.111111,
tax_code: 'EU_SERVICE',
dimensions: { '6': 'P001' },
},
{
account_number: '1931',
debit_amount: 0,
credit_amount: 1000,
line_description: 'Settlement',
currency: 'EUR',
amount_in_currency: 90,
exchange_rate: 11.111111,
dimensions: {},
},
]
const result = (await correctEntry.execute(
{
entry_id: '11111111-1111-4111-8111-111111111111',
lines: replacementLines,
},
'company-1',
'user-1',
supabase as never,
)) as {
preview: {
original: { lines: Array<Record<string, unknown>> }
correction: { lines: Array<Record<string, unknown>> }
}
}
expect(result.preview.original.lines[0]).toMatchObject({
currency: 'EUR',
amount_in_currency: 90,
exchange_rate: 11.111111,
tax_code: 'EU_SERVICE',
dimensions: { '6': 'P001' },
project: 'P001',
})
expect(result.preview.correction.lines[0]).toMatchObject({
currency: 'EUR',
amount_in_currency: 90,
exchange_rate: 11.111111,
tax_code: 'EU_SERVICE',
dimensions: { '6': 'P001' },
})
})
})
describe('gnubok_reverse_journal_entry: staging gates', () => {
+33 -2
View File
@@ -18,6 +18,8 @@ import { createLogger } from '@/lib/logger'
import { roundOre, sumOre } from '@/lib/money'
import type { SupabaseClient } from '@supabase/supabase-js'
import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping'
import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { buildTransactionEntryLines, createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries'
import { upsertCounterpartyTemplate, findCounterpartyTemplatesBatch, formatCounterpartyName } from '@/lib/bookkeeping/counterparty-templates'
import { formatVoucherLabel, hasLiveJournalEntryLink } from '@/lib/transactions/link-journal-entry'
@@ -930,7 +932,7 @@ async function categorizeTransactionCore(
const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma'
// Build mapping
const mappingResult = buildMappingResultFromCategory(
let mappingResult = buildMappingResultFromCategory(
category,
transaction as Transaction,
isBusiness,
@@ -938,6 +940,13 @@ async function categorizeTransactionCore(
vatTreatment,
vatAmount
)
const settlementAccount = await resolveSettlementAccount(
supabase,
companyId,
transaction.cash_account_id,
log,
)
mappingResult = applySettlementAccount(mappingResult, settlementAccount)
if (!mappingResult.debit_account || !mappingResult.credit_account) {
throw new Error(
@@ -14167,13 +14176,21 @@ export const tools: McpTool[] = [
debit_amount: number | string
credit_amount: number | string
line_description: string | null
currency: string | null
amount_in_currency: number | string | null
exchange_rate: number | string | null
tax_code: string | null
dimensions: Record<string, string> | null
cost_center: string | null
project: string | null
}> | null
}
const { data, error: origErr } = await supabase
.from('journal_entries')
.select(
'id, status, entry_date, description, voucher_number, voucher_series, fiscal_period_id, ' +
'fiscal_periods!journal_entries_fiscal_period_id_fkey!inner(name, is_closed, locked_at), lines:journal_entry_lines(account_number, debit_amount, credit_amount, line_description)'
'fiscal_periods!journal_entries_fiscal_period_id_fkey!inner(name, is_closed, locked_at), ' +
'lines:journal_entry_lines(account_number, debit_amount, credit_amount, line_description, currency, amount_in_currency, exchange_rate, tax_code, dimensions, cost_center, project)'
)
.eq('id', entryId)
.eq('company_id', companyId)
@@ -14221,6 +14238,14 @@ export const tools: McpTool[] = [
debit_amount: Number(l.debit_amount),
credit_amount: Number(l.credit_amount),
line_description: l.line_description,
currency: l.currency,
amount_in_currency:
l.amount_in_currency != null ? Number(l.amount_in_currency) : null,
exchange_rate: l.exchange_rate != null ? Number(l.exchange_rate) : null,
tax_code: l.tax_code,
dimensions: l.dimensions,
cost_center: l.cost_center,
project: l.project,
})),
},
correction: {
@@ -14232,7 +14257,13 @@ export const tools: McpTool[] = [
debit_amount: l.debit_amount,
credit_amount: l.credit_amount,
line_description: l.line_description ?? null,
currency: l.currency ?? null,
amount_in_currency: l.amount_in_currency ?? null,
exchange_rate: l.exchange_rate ?? null,
tax_code: l.tax_code ?? null,
dimensions: l.dimensions ?? null,
cost_center: l.cost_center ?? null,
project: l.project ?? null,
})),
},
...(dimensionResolutions.length > 0 ? { dimension_resolutions: dimensionResolutions } : {}),