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
@@ -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: {
|
||||
|
||||
@@ -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}).` },
|
||||
|
||||
+118
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user