fix(dimensions): exempt system source types from account dimension rules (#888)

SIE import books three system entries through the engine (opening
balances, IB resynk, the omföring adjustment for excluded vouchers) —
after PR10 those passed through the rules layer, so a required rule
could block an import and default/fixed rules could inject dimensions
into derived historical entries. Year-end, currency revaluation and
credit instruments had the same exposure.

Policy now governs NEW business events only: source types in
DIMENSION_RULE_EXEMPT_SOURCE_TYPES (opening_balance, import, year_end,
storno, correction, credit_note, supplier_credit_note,
currency_revaluation, system) skip both the draft-time apply and the
commit-time assert — imported history lands verbatim (BFL 5 kap),
bokslut can never be blocked by a dimension rule, and crediting an
entry that pre-dates a rule always works. Operational sources (manual,
bank_transaction, invoice_*, supplier_* registrations/payments,
salary_payment) stay enforced.

The SIE round-trip test now also covers a PR10-created custom dimension
(#DIM 20) with a custom child (#UNDERDIM 25 ... 20) and a tagged line —
proving user-created dims survive export → parse structurally.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-03 17:17:16 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 764348e99c
commit 86c924a6e9
4 changed files with 130 additions and 9 deletions
+25 -9
View File
@@ -22,6 +22,7 @@ import {
applyDimensionRules,
assertMandatoryDimensions,
fetchActiveDimensionRules,
isDimensionRuleExemptSource,
} from '@/lib/bookkeeping/dimension-rules'
import { backfillStandardBASAccounts } from '@/lib/bookkeeping/account-backfill'
import { syncInvoiceStatusFromPaymentEntry, isPaymentSourceType } from '@/lib/bookkeeping/payment-sync'
@@ -235,8 +236,11 @@ export async function createDraftEntry(
// Account dimension rules (dimensions PR10): apply 'default'/'fixed'
// values onto the line bags before validation + insert. Zero rules —
// every company by default — returns the input untouched; a failed rule
// fetch fails open like the soft validation below.
const rules = await fetchActiveDimensionRules(supabase, companyId)
// fetch fails open like the soft validation below. System-generated and
// correction sources are exempt — policy governs new business events,
// never imported history or bokslut mechanics.
const ruleExempt = isDimensionRuleExemptSource(input.source_type)
const rules = ruleExempt ? [] : await fetchActiveDimensionRules(supabase, companyId)
if (rules === null) {
log.warn('dimension rule fetch failed — defaults/fixed skipped (fail-open)', { companyId })
}
@@ -424,8 +428,13 @@ export async function updateDraftEntry(
// Same soft dimension validation as createDraftEntry — before any write, so
// a rejection leaves both the header and the existing lines untouched.
// Account dimension rules (PR10) apply first — same as create.
const rules = await fetchActiveDimensionRules(supabase, companyId)
// Account dimension rules (PR10) apply first — same as create. Gate on
// the STORED source_type (updates preserve it; the input's copy is not
// authoritative here).
const ruleExempt = isDimensionRuleExemptSource(
(existing as { source_type?: string }).source_type
)
const rules = ruleExempt ? [] : await fetchActiveDimensionRules(supabase, companyId)
if (rules === null) {
log.warn('dimension rule fetch failed — defaults/fixed skipped (fail-open)', { companyId })
}
@@ -567,7 +576,7 @@ export async function commitEntry(
} else if (rules.some((r) => r.rule_type === 'required')) {
const { data: ruleLines, error: ruleLinesError } = await supabase
.from('journal_entry_lines')
.select('account_number, dimensions')
.select('account_number, dimensions, journal_entries!inner(source_type)')
.eq('journal_entry_id', entryId)
if (ruleLinesError || !ruleLines) {
log.warn('line fetch for mandatory dimension check failed — enforcement skipped (fail-open)', {
@@ -575,10 +584,17 @@ export async function commitEntry(
entityId: entryId,
})
} else {
assertMandatoryDimensions(
ruleLines as Array<{ account_number: string; dimensions: Record<string, string> }>,
rules
)
const typedLines = ruleLines as unknown as Array<{
account_number: string
dimensions: Record<string, string>
journal_entries: { source_type: string }
}>
// System/correction sources are exempt — see
// DIMENSION_RULE_EXEMPT_SOURCE_TYPES (imported history, bokslut
// mechanics and credit instruments must never be blocked by policy).
if (!isDimensionRuleExemptSource(typedLines[0]?.journal_entries?.source_type)) {
assertMandatoryDimensions(typedLines, rules)
}
}
}