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:
co-authored by
Claude Fable 5
parent
764348e99c
commit
86c924a6e9
@@ -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({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user