diff --git a/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts b/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts index 98175b34..4308a57c 100644 --- a/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts +++ b/lib/bookkeeping/__tests__/engine-dimension-validation.test.ts @@ -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> + expect(lineRows[0].dimensions).toEqual({}) + }) }) diff --git a/lib/bookkeeping/dimension-rules.ts b/lib/bookkeeping/dimension-rules.ts index 65101ac0..9e8ea62c 100644 --- a/lib/bookkeeping/dimension-rules.ts +++ b/lib/bookkeeping/dimension-rules.ts @@ -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 = 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) +} diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts index c8e2641a..5bd3d883 100644 --- a/lib/bookkeeping/engine.ts +++ b/lib/bookkeeping/engine.ts @@ -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 }>, - rules - ) + const typedLines = ruleLines as unknown as Array<{ + account_number: string + dimensions: Record + 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) + } } } diff --git a/lib/import/__tests__/sie-dimensions-roundtrip.test.ts b/lib/import/__tests__/sie-dimensions-roundtrip.test.ts index 488629ab..ea720774 100644 --- a/lib/import/__tests__/sie-dimensions-roundtrip.test.ts +++ b/lib/import/__tests__/sie-dimensions-roundtrip.test.ts @@ -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