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:
co-authored by
Claude Fable 5
parent
e40dc64485
commit
8299ee9fb4
@@ -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', () => {
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user