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
@@ -279,6 +279,46 @@ describe('PATCH /api/pending-operations/[id]', () => {
)
})
it('re-derives edited previews against the linked cash account', async () => {
enqueue({
data: {
id: 'op-1',
company_id: 'company-1',
operation_type: 'categorize_transaction',
status: 'pending',
params: { transaction_id: 'tx-1', category: 'expense_other', vat_treatment: null },
preview_data: { debit_account: '6990', credit_account: '1930', amount: 500 },
title: 'Kategorisera: X',
},
})
enqueue({
data: {
id: 'tx-1',
company_id: 'company-1',
amount: -500,
currency: 'SEK',
cash_account_id: 'cash-1',
},
})
enqueue({ data: { entity_type: 'aktiebolag' } })
enqueue({ data: { ledger_account: '1931' } })
enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } })
const res = await PATCH(
createMockRequest('/api/pending-operations/op-1', {
method: 'PATCH',
body: { category: 'expense_software' },
}),
createMockRouteParams({ id: 'op-1' }),
)
expect(res.status).toBe(200)
expect(buildLinesMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'tx-1', cash_account_id: 'cash-1' }),
expect.objectContaining({ debit_account: '5410', credit_account: '1931' }),
)
})
it('preserves a staged vat_amount override when the new treatment still carries VAT', async () => {
enqueue({
data: {
+22 -1
View File
@@ -3,6 +3,8 @@ import { z } from 'zod'
import { ensureInitialized } from '@/lib/init'
import { withRouteContext } from '@/lib/api/with-route-context'
import { buildMappingResultFromCategory, getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping'
import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine'
import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account'
import { buildTransactionEntryLines } from '@/lib/bookkeeping/transaction-entries'
import { getVatRate } from '@/lib/bookkeeping/vat-entries'
import type { EntityType, Transaction, TransactionCategory, VatTreatment } from '@/types'
@@ -49,7 +51,7 @@ const PatchSchema = z
export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
'pending_operation.update',
async (request, { supabase, companyId }, { params }) => {
async (request, { supabase, companyId, log }, { params }) => {
const { id } = await params
let body: z.infer<typeof PatchSchema>
@@ -168,6 +170,25 @@ export const PATCH = withRouteContext<{ params: Promise<{ id: string }> }>(
)
}
try {
const settlementAccount = await resolveSettlementAccount(
supabase,
companyId,
(tx as Transaction).cash_account_id,
log,
)
mapping = applySettlementAccount(mapping, settlementAccount)
} catch (err) {
log.error('pending-operation edit: settlement account lookup failed', err as Error, {
operationId: id,
transactionId: txId,
})
return NextResponse.json(
{ error: getUserErrorMessage(err) },
{ status: 500 },
)
}
if (!mapping.debit_account || !mapping.credit_account) {
return NextResponse.json(
{ error: `Inget kontomappning för kategorin "${newCategory}" (${entityType}).` },
@@ -119,6 +119,124 @@ beforeEach(() => {
})
describe('POST batch-categorize', () => {
it('uses the linked cash account in validation and the posted mapping', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: {
data: {
id: TX_A,
company_id: COMPANY_ID,
date: '2026-05-12',
amount: -349.5,
currency: 'SEK',
merchant_name: 'ICA',
cash_account_id: 'cash-1',
journal_entry_id: null,
},
error: null,
},
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
cash_accounts: { data: { ledger_account: '1931' }, error: null },
fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null },
}).supabase,
)
const res = await POST(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`,
{
items: [
{ transaction_id: TX_A, categorization: { is_business: true, category: 'expense_office' } },
],
},
),
batchParams(),
)
expect(res.status).toBe(200)
expect(findMissingAccountsMock).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
expect.arrayContaining(['1931']),
)
expect(createTxJE).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
'user-1',
expect.objectContaining({ id: TX_A, cash_account_id: 'cash-1' }),
expect.objectContaining({ credit_account: '1931' }),
)
})
it('isolates a settlement lookup failure to its item and continues the batch', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
company_members: { data: { company_id: COMPANY_ID, role: 'owner' }, error: null },
transactions: [
{
data: {
id: TX_A,
company_id: COMPANY_ID,
date: '2026-05-12',
amount: -100,
currency: 'SEK',
cash_account_id: 'cash-broken',
journal_entry_id: null,
},
error: null,
},
{
data: {
id: TX_B,
company_id: COMPANY_ID,
date: '2026-05-13',
amount: -200,
currency: 'SEK',
cash_account_id: 'cash-ok',
journal_entry_id: null,
},
error: null,
},
],
company_settings: { data: { entity_type: 'enskild_firma' }, error: null },
cash_accounts: [
{ data: null, error: { message: 'temporary lookup failure' } },
{ data: { ledger_account: '1931' }, error: null },
],
fiscal_periods: { data: { id: 'period-1', is_closed: false, locked_at: null }, error: null },
}).supabase,
)
const res = await POST(
makeRequest(
`https://x.test/api/v1/companies/${COMPANY_ID}/transactions/batch-categorize`,
{
items: [
{ transaction_id: TX_A, categorization: { is_business: true, category: 'expense_office' } },
{ transaction_id: TX_B, categorization: { is_business: true, category: 'expense_office' } },
],
},
),
batchParams(),
)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.data.results[0].ok).toBe(false)
expect(body.data.results[0].error.code).toBe('INTERNAL_ERROR')
expect(body.data.results[1].ok).toBe(true)
expect(body.data.summary).toEqual({ total: 2, succeeded: 1, failed: 1 })
expect(createTxJE).toHaveBeenCalledTimes(1)
expect(createTxJE).toHaveBeenCalledWith(
expect.anything(),
COMPANY_ID,
'user-1',
expect.objectContaining({ id: TX_B }),
expect.objectContaining({ credit_account: '1931' }),
)
})
it('returns per-item ACCOUNTS_NOT_IN_CHART for items whose mapping references inactive accounts; clean items still succeed', async () => {
mockServiceClient.mockReturnValue(
makeFlexibleSupabase({
@@ -19,6 +19,8 @@ import { checkPeriodLock } from '@/lib/api/v1/check-period-lock'
import { CategorizeTransactionSchema } from '@/lib/api/schemas'
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 {
getTemplateById,
buildMappingResultFromTemplate,
@@ -187,6 +189,29 @@ async function categorizeOne(
input.vat_treatment,
)
}
try {
const settlementAccount = await resolveSettlementAccount(
supabase,
companyId,
transaction.cash_account_id,
log,
)
mappingResult = applySettlementAccount(mappingResult, settlementAccount)
} catch (err) {
log.error('batch-categorize: settlement account lookup failed', err as Error, {
request_index: index,
transactionId,
})
return {
ok: false,
request_index: index,
transaction_id: transactionId,
error: {
code: 'INTERNAL_ERROR',
message: isBookkeepingError(err) ? getErrorMessage(err, { context: 'transaction' }) : getErrorMessage(err),
},
}
}
// Dimensions: an explicitly supplied bag tags the business lines of the
// generated verifikat (bank/VAT legs stay untagged).
if (input.dimensions && Object.keys(input.dimensions).length > 0) {