From 8299ee9fb4c63aced88faf1cd61de7a3e9a78d8c Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:45:38 +0200 Subject: [PATCH] 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 --- .../[id]/__tests__/route.test.ts | 40 ++++ app/api/pending-operations/[id]/route.ts | 23 +- .../batch-categorize/__tests__/route.test.ts | 118 +++++++++++ .../transactions/batch-categorize/route.ts | 25 +++ docs/SETTLEMENT_ACCOUNT_REMEDIATION.md | 104 +++++++++ .../__tests__/dimension-tools.test.ts | 35 ++++ .../__tests__/voucher-tools.test.ts | 104 +++++++++ extensions/general/mcp-server/server.ts | 35 +++- .../__tests__/categorize-core.bulk.test.ts | 37 ++++ lib/transactions/categorize-core.ts | 11 +- .../audit-settlement-account-mismatches.sql | 198 ++++++++++++++++++ 11 files changed, 726 insertions(+), 4 deletions(-) create mode 100644 docs/SETTLEMENT_ACCOUNT_REMEDIATION.md create mode 100644 scripts/audit-settlement-account-mismatches.sql diff --git a/app/api/pending-operations/[id]/__tests__/route.test.ts b/app/api/pending-operations/[id]/__tests__/route.test.ts index 3dafdb35..81eb48fd 100644 --- a/app/api/pending-operations/[id]/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/__tests__/route.test.ts @@ -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: { diff --git a/app/api/pending-operations/[id]/route.ts b/app/api/pending-operations/[id]/route.ts index 4b69d09c..a2beae8c 100644 --- a/app/api/pending-operations/[id]/route.ts +++ b/app/api/pending-operations/[id]/route.ts @@ -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 @@ -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}).` }, diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts index 6dff39f1..d75dc9ed 100644 --- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/__tests__/route.test.ts @@ -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({ diff --git a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts index 50b9b0d0..4b56f3eb 100644 --- a/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts +++ b/app/api/v1/companies/[companyId]/transactions/batch-categorize/route.ts @@ -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) { diff --git a/docs/SETTLEMENT_ACCOUNT_REMEDIATION.md b/docs/SETTLEMENT_ACCOUNT_REMEDIATION.md new file mode 100644 index 00000000..9ca15f89 --- /dev/null +++ b/docs/SETTLEMENT_ACCOUNT_REMEDIATION.md @@ -0,0 +1,104 @@ +# Settlement account remediation + +Use this runbook to review journal entries that may have been posted against a +different settlement account than the cash account linked to their source bank +transaction. It covers historical entries created before and around the fixes +for issues #985, #986, and #987. + +The audit is intentionally broader than a list of confirmed errors. A current +`cash_account_id` does not prove that the transaction had that link when the +entry was posted. Never correct an entry from query output alone. + +## Invariants + +- Never edit or delete a posted journal entry. +- Correct a confirmed error with storno plus a replacement entry through + `gnubok_correct_entry`. +- Keep every replacement line balanced and preserve the original line metadata. +- Do not write directly to `journal_entries` or `journal_entry_lines`. +- Do not correct a locked or closed period without a separately reviewed and + explicitly approved unlock or reopening workflow. +- Never run a production correction without explicit approval for the exact + company, vouchers, and replacement lines. + +## Detect candidates + +Run [`scripts/audit-settlement-account-mismatches.sql`](../scripts/audit-settlement-account-mismatches.sql) +against the intended database. The query is read-only and returns current +posted entries whose exact directional settlement leg differs from +`cash_accounts.ledger_account`. + +Treat `review_priority` only as an ordering aid: + +- `high_review_priority_hardcoded_1930_signature` matches the known historical + failure shape, but still needs evidence review. +- `manual_review_payment_aware_correction_required` identifies a payment flow with a + mismatching account. Detection is supported, but the generic correction + procedure below is not. Stop and use a payment-aware correction path. +- Every `manual_review_*` result may be a later cash-account link, a manual + reconciliation, or another legitimate accounting shape. + +## Review each candidate + +1. Confirm the transaction belongs to the reported cash account using the + original bank feed or statement and the bank connection metadata. +2. Confirm that the cash-account link existed when the journal entry was + posted. `transaction_updated_at` close to `committed_at` is supporting + evidence, not proof. +3. Fetch the current journal entry and all lines. Stop if the entry was already + reversed, corrected, or linked manually after posting. +4. Confirm there is exactly one settlement leg and that its direction and SEK + amount match the bank transaction. +5. Confirm the only required accounting change is replacing the observed + settlement account with `expected_settlement_account`. +6. Check `effective_lock_status`. Both the fiscal period's `is_closed` and + `locked_at` fields and the company-wide `bookkeeping_locked_through` date + are authoritative lock layers. Any result other than `open` is a hard stop + for the ordinary correction flow. If VAT has already been filed, determine + whether an omprövning is required before reopening anything. +7. Stop when `booking_flow` is `customer_invoice_payment` or + `supplier_invoice_payment`. The generic correction service does not relink + all invoice-payment references from the reversed entry. A payment-aware, + tested correction procedure is required for those candidates. + +Keep the reviewed candidate set, evidence, proposed replacement lines, and +reviewer identity together as the correction record. + +## Stage the correction + +For a confirmed candidate in an open and unlocked period: + +1. Re-run the audit query immediately before staging and retain its + `original_lines` value. It includes currency amounts, exchange rates, tax + codes, dimensions, cost centers, and projects that the ordinary journal + query does not return. +2. Re-fetch the entry with `gnubok_query_journal` to confirm that its status, + voucher, amounts, and visible lines still match the fresh audit result. +3. Copy every object from `original_lines` into the `lines` input for + `gnubok_correct_entry`. +4. On the one confirmed settlement line, replace only `account_number` with + `expected_settlement_account`. +5. Preserve debit and credit amounts, line descriptions, currency metadata, + tax codes, dimensions, cost centers, and projects exactly as recorded. +6. Verify that total debits equal total credits and both totals are positive. +7. Stage `gnubok_correct_entry` using the voucher reference or freshly fetched + entry UUID. Review its original and correction previews line by line. + The preview must show the preserved currency, tax, and dimension metadata. +8. Approve the staged operation with `gnubok_approve_pending_operation` only + after the exact operation has explicit authorization. High-risk approval + requires `confirmed: true`. + +Do not use `gnubok_reverse_journal_entry` by itself for this case. The business +event remains valid; only its settlement account is being corrected. + +## Verify after approval + +1. Confirm the original entry is retained with status `reversed`. +2. Confirm a posted storno and a posted corrected entry were created in the + intended fiscal period. +3. Confirm the bank transaction now links to the posted corrected entry. +4. Confirm the corrected settlement leg uses the expected account and amount. +5. Run `gnubok_get_general_ledger` for both the observed and expected accounts. +6. Re-run the audit query. The corrected transaction must no longer appear. +7. Record the new voucher references and verification evidence with the + reviewed candidate set. diff --git a/extensions/general/mcp-server/__tests__/dimension-tools.test.ts b/extensions/general/mcp-server/__tests__/dimension-tools.test.ts index 52f559f6..e569569a 100644 --- a/extensions/general/mcp-server/__tests__/dimension-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/dimension-tools.test.ts @@ -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) diff --git a/extensions/general/mcp-server/__tests__/voucher-tools.test.ts b/extensions/general/mcp-server/__tests__/voucher-tools.test.ts index a44159d8..1cfa2d8b 100644 --- a/extensions/general/mcp-server/__tests__/voucher-tools.test.ts +++ b/extensions/general/mcp-server/__tests__/voucher-tools.test.ts @@ -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> } + correction: { lines: Array> } + } + } + + 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', () => { diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 6d0480e2..2f5c9b7b 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -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 | 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 } : {}), diff --git a/lib/transactions/__tests__/categorize-core.bulk.test.ts b/lib/transactions/__tests__/categorize-core.bulk.test.ts index ab69f231..64ff00f7 100644 --- a/lib/transactions/__tests__/categorize-core.bulk.test.ts +++ b/lib/transactions/__tests__/categorize-core.bulk.test.ts @@ -247,6 +247,43 @@ describe('bulkBookMatchedInboxItems: booking', () => { ) }) + it('books against the linked cash account instead of the 1930 mapping default', async () => { + const supabase = queuedSupabase([ + { data: { id: 'i1', matched_transaction_id: 'tx-1', created_journal_entry_id: null, created_supplier_invoice_id: null } }, + { + data: { + id: 'tx-1', + date: '2026-06-01', + amount: -700.28, + currency: 'SEK', + cash_account_id: 'cash-1', + journal_entry_id: null, + }, + }, + { data: { entity_type: 'aktiebolag', fiscal_year_start_month: 1 } }, + { data: { ledger_account: '1931' } }, + { data: [{ id: 'fp-1' }] }, + { error: null }, + { data: [] }, + ]) + + const { booked, skipped } = await bulkBookMatchedInboxItems(supabase, 'u1', 'c1', { + item_ids: ['i1'], + category: 'expense_software', + }) + + expect(skipped).toEqual([]) + expect(booked).toHaveLength(1) + expect(mockCreateJE).toHaveBeenCalledWith( + expect.anything(), + 'c1', + 'u1', + expect.objectContaining({ id: 'tx-1', cash_account_id: 'cash-1' }), + expect.objectContaining({ debit_account: '5420', credit_account: '1931' }), + undefined, + ) + }) + it('forwards the shared dimensions bag onto every booked mapping result', async () => { // Fresh mapping object per call: the core mutates it in place, and a // shared fixture would leak dimensions across tests. diff --git a/lib/transactions/categorize-core.ts b/lib/transactions/categorize-core.ts index fd25f092..a1b0549f 100644 --- a/lib/transactions/categorize-core.ts +++ b/lib/transactions/categorize-core.ts @@ -24,6 +24,8 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { eventBus } from '@/lib/events' import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' +import { applySettlementAccount } from '@/lib/bookkeeping/mapping-engine' +import { resolveSettlementAccount } from '@/lib/bookkeeping/settlement-account' import { createTransactionJournalEntry } from '@/lib/bookkeeping/transaction-entries' import { upsertCounterpartyTemplate } from '@/lib/bookkeeping/counterparty-templates' import { isBookkeepingError } from '@/lib/bookkeeping/errors' @@ -329,9 +331,16 @@ export async function categorizeMatchedTransaction( const entityType: EntityType = (settings?.entity_type as EntityType) || 'enskild_firma' const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1 - const mappingResult = buildMappingResultFromCategory( + let mappingResult = buildMappingResultFromCategory( category, transaction as Transaction, isBusiness, entityType, vatTreatment, vatAmount ) + const settlementAccount = await resolveSettlementAccount( + supabase, + companyId, + transaction.cash_account_id, + log, + ) + mappingResult = applySettlementAccount(mappingResult, settlementAccount) // Dimensions PR7: tag the business lines of the generated verifikat. if (dimensions && Object.keys(dimensions).length > 0) { mappingResult.dimensions = dimensions diff --git a/scripts/audit-settlement-account-mismatches.sql b/scripts/audit-settlement-account-mismatches.sql new file mode 100644 index 00000000..3ada0f47 --- /dev/null +++ b/scripts/audit-settlement-account-mismatches.sql @@ -0,0 +1,198 @@ +-- Read-only audit for journal entries whose settlement leg may disagree with +-- the cash account linked to the source bank transaction. +-- +-- This query is deliberately diagnostic. A current cash_account_id does not +-- prove that the same link existed when the entry was posted. Every result +-- therefore requires review against the bank feed, the original journal entry, +-- and the transaction history before any correction is staged. +-- +-- The query performs no writes, creates no temporary objects, and only returns +-- current posted entries. Reversed originals disappear once their transaction +-- is linked to the posted correction. + +with transaction_context as ( + select + t.company_id, + t.id as transaction_id, + t.date as transaction_date, + t.amount as transaction_amount, + coalesce(t.currency, 'SEK') as transaction_currency, + t.amount_sek, + t.exchange_rate, + t.cash_account_id, + t.import_source, + t.created_at as transaction_created_at, + t.updated_at as transaction_updated_at, + je.id as journal_entry_id, + je.entry_date, + je.committed_at, + je.voucher_series, + je.voucher_number, + je.fiscal_period_id, + ca.ledger_account as expected_settlement_account, + ca.created_at as cash_account_created_at, + fp.name as fiscal_period_name, + fp.is_closed as fiscal_period_closed, + fp.locked_at as fiscal_period_locked_at, + cs.bookkeeping_locked_through, + round( + abs( + case + when coalesce(t.currency, 'SEK') = 'SEK' then t.amount + when t.amount_sek is not null then t.amount_sek + when t.exchange_rate is not null then t.amount * t.exchange_rate + else null + end + ), + 2 + ) as settlement_amount_sek, + exists ( + select 1 + from public.invoice_payments ip + where ip.transaction_id = t.id + and ip.journal_entry_id = je.id + ) as is_customer_invoice_payment, + exists ( + select 1 + from public.supplier_invoice_payments sip + where sip.transaction_id = t.id + and sip.journal_entry_id = je.id + ) as is_supplier_invoice_payment + from public.transactions t + join public.journal_entries je + on je.id = t.journal_entry_id + and je.company_id = t.company_id + and je.status = 'posted' + join public.cash_accounts ca + on ca.id = t.cash_account_id + and ca.company_id = t.company_id + join public.fiscal_periods fp + on fp.id = je.fiscal_period_id + and fp.company_id = t.company_id + left join public.company_settings cs + on cs.company_id = t.company_id +), directional_exact_lines as ( + select + tc.*, + jel.id as observed_line_id, + jel.account_number as observed_settlement_account, + jel.debit_amount as observed_debit_amount, + jel.credit_amount as observed_credit_amount, + count(*) over (partition by tc.transaction_id, tc.journal_entry_id) as exact_directional_line_count + from transaction_context tc + join public.journal_entry_lines jel + on jel.journal_entry_id = tc.journal_entry_id + and case + when tc.transaction_amount < 0 then + round(jel.credit_amount, 2) = tc.settlement_amount_sek + and round(jel.debit_amount, 2) = 0 + else + round(jel.debit_amount, 2) = tc.settlement_amount_sek + and round(jel.credit_amount, 2) = 0 + end + where tc.settlement_amount_sek is not null +), candidates as ( + select dl.* + from directional_exact_lines dl + where dl.observed_settlement_account <> dl.expected_settlement_account + and not exists ( + select 1 + from public.journal_entry_lines expected + where expected.journal_entry_id = dl.journal_entry_id + and expected.account_number = dl.expected_settlement_account + and case + when dl.transaction_amount < 0 then + round(expected.credit_amount, 2) = dl.settlement_amount_sek + and round(expected.debit_amount, 2) = 0 + else + round(expected.debit_amount, 2) = dl.settlement_amount_sek + and round(expected.credit_amount, 2) = 0 + end + ) +) +select + c.company_id, + c.transaction_id, + c.journal_entry_id, + c.observed_line_id, + concat(c.voucher_series, '-', c.voucher_number) as voucher, + c.transaction_date, + c.entry_date, + c.committed_at, + c.transaction_amount, + c.transaction_currency, + c.settlement_amount_sek, + c.expected_settlement_account, + c.observed_settlement_account, + c.observed_debit_amount, + c.observed_credit_amount, + c.exact_directional_line_count, + c.cash_account_id, + c.import_source, + c.transaction_created_at, + c.transaction_updated_at, + c.cash_account_created_at, + c.fiscal_period_id, + c.fiscal_period_name, + c.fiscal_period_closed, + c.fiscal_period_locked_at, + c.bookkeeping_locked_through, + case + when c.fiscal_period_closed then 'closed' + when c.fiscal_period_locked_at is not null then 'period_locked' + when c.bookkeeping_locked_through is not null + and c.entry_date <= c.bookkeeping_locked_through + then 'company_lock_date' + else 'open' + end as effective_lock_status, + ( + select jsonb_agg( + jsonb_build_object( + 'account_number', original.account_number, + 'debit_amount', original.debit_amount, + 'credit_amount', original.credit_amount, + 'line_description', original.line_description, + 'currency', original.currency, + 'amount_in_currency', original.amount_in_currency, + 'exchange_rate', original.exchange_rate, + 'tax_code', original.tax_code, + 'dimensions', original.dimensions, + 'cost_center', original.cost_center, + 'project', original.project + ) + order by original.sort_order, original.id + ) + from public.journal_entry_lines original + where original.journal_entry_id = c.journal_entry_id + ) as original_lines, + case + when c.is_customer_invoice_payment then 'customer_invoice_payment' + when c.is_supplier_invoice_payment then 'supplier_invoice_payment' + else 'categorization_or_manual_link' + end as booking_flow, + case + when c.exact_directional_line_count <> 1 then 'manual_review_multiple_exact_lines' + when c.is_customer_invoice_payment or c.is_supplier_invoice_payment + then 'manual_review_payment_aware_correction_required' + when c.cash_account_created_at > c.committed_at then 'manual_review_cash_account_created_later' + when c.expected_settlement_account <> '1930' + and c.observed_settlement_account = '1930' + and abs(extract(epoch from (c.transaction_updated_at - c.committed_at))) <= 60 + then 'high_review_priority_hardcoded_1930_signature' + else 'manual_review_link_history_required' + end as review_priority +from candidates c +order by + case + when c.expected_settlement_account <> '1930' + and c.observed_settlement_account = '1930' + and c.cash_account_created_at <= c.committed_at + and abs(extract(epoch from (c.transaction_updated_at - c.committed_at))) <= 60 + then 1 + when c.is_customer_invoice_payment or c.is_supplier_invoice_payment then 2 + else 3 + end, + c.company_id, + c.entry_date, + c.voucher_series, + c.voucher_number;