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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user