feat(dimensions): PR1 substrate — SIE-native registry + dimensions JSONB on journal lines (#857)
* feat(dimensions): substrate — SIE-native registry + dimensions JSONB on journal lines (PR1)
Implements phase 1 of dev_docs/dimensions_implementation_plan.md:
- New company-native registry tables: dimensions (= SIE #DIM/#UNDERDIM,
seeded is_system 1=Kostnadsställe / 6=Projekt via ensure_company_dimensions,
nullable bare firm_id) and dimension_values (= #OBJEKT), full RLS incl.
DELETE, audit + updated_at triggers, guard triggers (system dims undeletable,
sie_dim_no immutable, values referenced by posted lines archive-not-delete).
- journal_entry_lines.dimensions jsonb NOT NULL DEFAULT '{}' as the single
source of truth ({sie_dim_no: object_code}), CHECK object-typed, GIN
(jsonb_path_ops) + partial expression indexes on dims 1/6. Inherits posted-
line immutability from the existing trigger with zero new triggers.
- Backfill: representation copy of legacy cost_center/project text into the
JSONB map (trigger-disabled, schema_sync precedent); legacy cost_centers/
projects registry rows copied into dimension_values; inactive placeholder
values for orphaned free-text codes.
- Dual-write: engine buildLineInserts + storno/correction/date-move now derive
cost_center/project mirrors from the map via lib/bookkeeping/dimension-resolver.ts
(normalizeLineDimensions / lineDimensionColumns); reversal copies dims.
- CreateJournalEntryLineInput + shared Zod line schema gain a dimensions bag
(cost_center/project stay as deprecated aliases); pending-ops voucher lines
coerce it.
- CI ratchet: direct-jel-insert check in no-new-antipatterns.mjs — inserts into
journal_entry_lines outside sanctioned writers fail CI.
- pg-real suite: registry RLS/guards/retention, ensure_company_dimensions
tenant guard, dims frozen on posted lines, CHECK enforcement (13 tests).
Non-breaking: companies without dimensions see zero change; no UI yet.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dimensions): address review findings — canonical keys, boundary-validated staged bags, migration guidance
- normalizeLineDimensions canonicalizes numeric keys ('01' -> '1') so
leading-zero keys can't split values or miss the cost_center/project mirrors
(PR Agent finding).
- New coerceDimensionsBag() in dimension-resolver is the single boundary
validator for untyped staged payloads, enforcing the same constraints as the
Zod line schema (string-only values, 1-40 chars, no SIE-framing chars,
canonical keys). pending-operations normalizeVoucherLines now uses it —
staged payloads can no longer bypass API-layer validation via numeric
coercion (compliance-swarm V2.2/V1.2.5/PI1.1, Swedish review finding 4).
- Migration backfill comment now spells out the exact conditions under which
the trigger-disable pattern is defensible (BFL 5:5 / BFNAR 2013:2) and what
a future reviewer must verify before reusing it (Swedish review finding 2).
- 10 new resolver tests incl. reversal-parity (empty bag + aliases ==
alias-only) proving the reverseEntry and storno paths normalize identically
(PR Agent finding 1).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(dimensions): round-2 review — shared Zod schema, transactional backfill, empty-string guard
- DimensionsBagSchema now lives in dimension-resolver as the single source of
truth; CreateJournalEntryLineSchema and coerceDimensionsBag both delegate to
it, so the API layer and the staged pending-operations path provably cannot
drift (compliance-swarm V2.2). coerceDimensionsBag switches to whole-bag
semantics: any invalid entry rejects the bag, exactly like the API schema.
- Migration backfill now runs DISABLE TRIGGER / UPDATE / ENABLE TRIGGER inside
one transaction — the ACCESS EXCLUSIVE lock from ALTER TABLE holds until
COMMIT, so no concurrent writer can slip an unguarded line write into the
window during a live apply (compliance-swarm V1.2, Swedish review finding 1).
- NULLIF guard: empty-string legacy mirrors can no longer mint {"n":""}
entries the resolver would interpret as "cleared" (PR Agent round-2 edge).
- COMMENT ON dimensions.resets_annually documenting the SIE4 #IB/#OIB
semantics the PR2+ export path must honour (Swedish review finding 2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f63d3e3100
commit
8cc2efb083
@@ -16,6 +16,12 @@
|
||||
* 2. naive-ore-round — `Math.round(x * 100) / 100`, which is subtly wrong on
|
||||
* exact-half values (see lib/money.ts `roundOre`). Tracked as a count.
|
||||
* The canonical rounding modules are excluded.
|
||||
* 3. direct-jel-insert — a file that inserts into `journal_entry_lines`
|
||||
* outside the sanctioned writers. During the dimensions dual-write window
|
||||
* every line writer must derive cost_center/project via
|
||||
* lineDimensionColumns() from the dimensions JSONB map
|
||||
* (lib/bookkeeping/dimension-resolver.ts) — a new direct insert site can
|
||||
* silently diverge the mirror columns. Tracked as a file-set.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/checks/no-new-antipatterns.mjs # check (CI)
|
||||
@@ -75,6 +81,37 @@ function findRawRouteAuth() {
|
||||
.sort()
|
||||
}
|
||||
|
||||
// Sanctioned journal_entry_lines insert sites. engine/storno write mirrors via
|
||||
// dimension-resolver; sie-import and sandbox seed write neither dims nor
|
||||
// mirrors (DB defaults keep them consistent).
|
||||
const JEL_INSERT_SANCTIONED = new Set([
|
||||
'lib/bookkeeping/engine.ts',
|
||||
'lib/core/bookkeeping/storno-service.ts',
|
||||
'lib/import/sie-import.ts',
|
||||
'app/api/sandbox/seed/route.ts',
|
||||
])
|
||||
// Matches an insert CHAINED on the lines table (`.from('journal_entry_lines').insert(`,
|
||||
// with optional whitespace/newlines in the chain) — select-only readers don't count.
|
||||
const JEL_INSERT_CHAIN_RE = /\.from\(\s*['"]journal_entry_lines['"]\s*\)\s*\.\s*(insert|upsert)\(/
|
||||
|
||||
/** Files that insert into journal_entry_lines outside the sanctioned writers. */
|
||||
function findDirectJelInserts() {
|
||||
const files = [
|
||||
...walk(path.join(ROOT, 'lib'), ['.ts', '.tsx']),
|
||||
...walk(path.join(ROOT, 'app'), ['.ts', '.tsx']),
|
||||
...walk(path.join(ROOT, 'extensions'), ['.ts', '.tsx']),
|
||||
]
|
||||
return files
|
||||
.filter((f) => {
|
||||
const r = rel(f)
|
||||
if (JEL_INSERT_SANCTIONED.has(r)) return false
|
||||
if (r.includes('__tests__/') || r.endsWith('.test.ts')) return false
|
||||
return JEL_INSERT_CHAIN_RE.test(fs.readFileSync(f, 'utf8'))
|
||||
})
|
||||
.map(rel)
|
||||
.sort()
|
||||
}
|
||||
|
||||
/** Count of naive Math.round(x*100)/100 occurrences (lines) across source. */
|
||||
function countNaiveRound() {
|
||||
const files = [
|
||||
@@ -96,6 +133,7 @@ function countNaiveRound() {
|
||||
const current = {
|
||||
rawRouteAuth: findRawRouteAuth(),
|
||||
naiveOreRound: countNaiveRound(),
|
||||
directJelInsert: findDirectJelInserts(),
|
||||
}
|
||||
|
||||
const isUpdate = process.argv.includes('--update')
|
||||
@@ -136,6 +174,22 @@ if (newAuthFiles.length) {
|
||||
console.error(' → wrap the route in withRouteContext (or call requireAuth) so MFA is enforced.')
|
||||
}
|
||||
|
||||
// 1b. direct-jel-insert: allowlist lives in this file (JEL_INSERT_SANCTIONED),
|
||||
// no baseline — any unsanctioned insert site is a hard failure.
|
||||
if (current.directJelInsert.length) {
|
||||
failed = true
|
||||
console.error(
|
||||
`\n✗ direct-jel-insert: ${current.directJelInsert.length} file(s) insert into journal_entry_lines ` +
|
||||
`outside the sanctioned writers:`,
|
||||
)
|
||||
current.directJelInsert.forEach((f) => console.error(` ${f}`))
|
||||
console.error(
|
||||
' → route line writes through lib/bookkeeping/engine.ts, or derive cost_center/project via\n' +
|
||||
' lineDimensionColumns() (lib/bookkeeping/dimension-resolver.ts) and add the file to\n' +
|
||||
' JEL_INSERT_SANCTIONED in this script with a justification.',
|
||||
)
|
||||
}
|
||||
|
||||
// 2. naive-ore-round: count may not increase.
|
||||
if (current.naiveOreRound > baseline.naiveOreRound.count) {
|
||||
failed = true
|
||||
@@ -160,5 +214,5 @@ if (failed) {
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(
|
||||
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}).`,
|
||||
`\n✓ Antipattern guard passed (raw-route-auth: ${current.rawRouteAuth.length}, naive-ore-round: ${current.naiveOreRound}, direct-jel-insert: 0).`,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user