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) {
+104
View File
@@ -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.
@@ -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 } : {}),
@@ -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.
+10 -1
View File
@@ -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
@@ -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;