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
@@ -390,4 +390,63 @@ describe('commitEntry — mandatory dimension enforcement (PR10)', () => {
expect(queriedTables()).not.toContain('journal_entry_lines')
})
it('exempts system source types — an untagged SIE-import entry commits despite a required rule', async () => {
const { supabase } = buildSupabase({
...BASE_TABLES,
account_dimension_rules: { data: [requiredRule] },
journal_entry_lines: {
data: [
// Post-cutover line fetch carries the parent source_type via join.
{ account_number: '4010', dimensions: {}, journal_entries: { source_type: 'import' } },
{ account_number: '1930', dimensions: {}, journal_entries: { source_type: 'import' } },
],
},
})
const entry = await commitEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
expect(entry.id).toBe('entry-1')
expect(supabase.rpc).toHaveBeenCalledWith(
'commit_journal_entry',
expect.objectContaining({ p_entry_id: 'entry-1' })
)
})
it('still enforces on operational source types carried by the join', async () => {
const { supabase } = buildSupabase({
...BASE_TABLES,
account_dimension_rules: { data: [requiredRule] },
journal_entry_lines: {
data: [
{ account_number: '4010', dimensions: {}, journal_entries: { source_type: 'manual' } },
],
},
})
await expect(
commitEntry(supabase as never, 'company-1', 'user-1', 'entry-1')
).rejects.toBeInstanceOf(MandatoryDimensionMissingError)
})
})
describe('createDraftEntry — system-source exemption (PR10)', () => {
it('never applies default/fixed rules onto an opening-balance entry (no injection into derived history)', async () => {
const { supabase, inserts, queriedTables } = buildSupabase({
...BASE_TABLES,
account_dimension_rules: {
data: [makeRuleRow({ rule_type: 'fixed', dimension_values: { code: 'PLOCK' } })],
},
})
const input = { ...makeInput(), source_type: 'opening_balance' as const }
const entry = await createDraftEntry(supabase as never, 'company-1', 'user-1', input)
expect(entry.id).toBe('entry-1')
// Exempt source: the rules table is never even consulted…
expect(queriedTables()).not.toContain('account_dimension_rules')
// …and the inserted bags stay exactly as the import provided them.
const lineRows = inserts.journal_entry_lines[0] as Array<Record<string, unknown>>
expect(lineRows[0].dimensions).toEqual({})
})
})
+34
View File
@@ -174,3 +174,37 @@ export function assertMandatoryDimensions(
throw new MandatoryDimensionMissingError([...violations.values()])
}
}
/**
* Source types EXEMPT from dimension rules — system-generated and
* correction-instrument entries where policy must never bite:
*
* - historical/derived data (SIE import, opening balances) must land
* verbatim — injecting defaults or refusing untagged history would
* falsify the record (BFL 5 kap)
* - year-end and revaluation are system bokslut mechanics; a rule on a
* result account must not be able to block closing the year
* - storno/correction/credit notes are HOW history gets fixed — blocking
* them on entries that pre-date a rule would make old mistakes
* permanent (same argument as the commitEntry bypass for reversals)
* - accrual dissolutions replay a schedule created before the rule
*
* Operational sources (manual, bank_transaction, invoice_*, supplier_*
* registrations/payments, salary_payment) stay enforced — those are the
* new business events the policy exists for.
*/
export const DIMENSION_RULE_EXEMPT_SOURCE_TYPES: ReadonlySet<string> = new Set([
'opening_balance',
'import',
'year_end',
'storno',
'correction',
'credit_note',
'supplier_credit_note',
'currency_revaluation',
'system',
])
export function isDimensionRuleExemptSource(sourceType: string | null | undefined): boolean {
return sourceType != null && DIMENSION_RULE_EXEMPT_SOURCE_TYPES.has(sourceType)
}
+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)
}
}
}
@@ -54,14 +54,26 @@ const SOURCE_SIE = [
'#DIM 1 "Kostnadsställe"',
'#DIM 6 "Projekt"',
'#UNDERDIM 2 "Kostnadsbärare" 1',
// Custom dimension + custom child — exactly what PR10's "Ny dimension"
// (SIE 20+, optional parent) produces. Proves the round-trip covers
// user-created dims, not just the reserved 1/2/6 set.
'#DIM 20 "Avdelning"',
'#UNDERDIM 25 "Team" 20',
'#OBJEKT 1 "KS01" "Butiken"',
'#OBJEKT 2 "KB1" "Bärare ett"',
'#OBJEKT 6 "P001" "Villa Almgren"',
'#OBJEKT 20 "SYD" "Avdelning Syd"',
'#OBJEKT 25 "T1" "Team ett"',
'#VER A 1 20260115 "Hyra januari"',
'{',
'#TRANS 5010 {1 "KS01" 2 "KB1" 6 "P001"} 15000.00',
'#TRANS 1930 {} -15000.00',
'}',
'#VER A 3 20260117 "Avdelningskostnad"',
'{',
'#TRANS 5010 {20 "SYD" 25 "T1"} 800.00',
'#TRANS 1930 {} -800.00',
'}',
'#VER A 2 20260116 "Odeklarerat projekt"',
'{',
// P002 is referenced but never declared via #OBJEKT — import synthesizes