feat(packs): konteringspaket as validated data files (phase 2a) (#1386)

* feat(packs): konteringspaket as validated data files, ported losslessly

The 26 system booking templates lived inside migration 20260413160000. Under
the never-modify-a-shipped-migration rule that froze them: correcting a wrong
BAS account or a Swedish typo needed a whole new migration, and nothing checked
that a seeded account existed in the chart or that a template balanced. #1321
was exactly that failure with seeded chart names.

They are now one YAML file per pattern under packs/, with a Zod contract and a
CI gate. A correction becomes a one-line edit plus a green run.

The port is proven lossless, not asserted. The test fixture was read out of a
Postgres with all 548 migrations applied, so it is the exact JSONB production
holds; lib/packs/__tests__/port-is-lossless.test.ts asserts the YAML reproduces
it by value. Phase 2b can swap the seeded rows for the loader as a no-op.

The gate checks what makes a pack CORRECT, not just well-formed, because #1321
was structurally valid and still wrong: every account must exist in BAS 2026,
and every pack must balance at five probe amounts through the real
applyTemplate() rather than a reimplementation. Account numbers validate through
lib/invariants, so a pack cannot disagree with the API or the SIE importer about
what an account number is.

Doing that immediately found four pre-existing breakages in the shipped
templates:

  loneutbetalning                    debits total 1.42x the amount against a
                                     1.0 credit: it can never post
  periodiseringsfond-avsattning-ab   account 2113 is not in BAS 2026 and is not
  periodiseringsfond-aterforing-ab   seeded into any company chart
  preliminar-f-skatt-ef              account 2012, same problem

These are quarantined in KNOWN_BROKEN, not fixed and not hidden: a quarantined
pack's findings are warnings, any NEW finding fails the build, and the validator
fails if a quarantined pack turns out to be clean, so the list may only shrink.
Each is a Swedish accounting content change to a user-facing template, which
deserves its own review rather than riding along inside a file-format change.

Five shipped descriptions contain em dashes, preserved verbatim and pinned by a
test: a lossless port must not silently rewrite user-visible strings.

js-yaml is promoted from a transitive dependency to a declared one (MIT, already
in node_modules), so the catalogue does not depend on it by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(deps): regenerate package-lock.json with npm 10 to match CI

`npm ci` failed on every job with "Missing: @swc/helpers@0.5.23 from lock
file". The lockfile was written by local npm 11.6.0; CI runs npm 10.8.2 on
node 20, and npm 11 emits a tree npm 10 reads as out of sync.

Regenerated with `npx npm@10 install --package-lock-only`, which cuts the diff
from a sprawling rewrite down to the three entries this branch actually adds
(js-yaml, @types/js-yaml, and the @swc/helpers entry npm 11 had dropped).
Verified with `npx npm@10 ci --dry-run`.

This is the documented gotcha for this repo: regenerate lockfiles with
npx npm@10, never with a local npm 11.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-03 18:05:54 +02:00
committed by GitHub
co-authored by Claude Opus 5 Jakob Wennberg
parent c1888fcd5e
commit df34cae9bf
36 changed files with 2047 additions and 2 deletions
@@ -0,0 +1,630 @@
[
{
"name": "Försäljning EU-tjänster (B2B)",
"description": "Tjänsteförsäljning till annat EU-land (B2B). Ingen moms — kunden redovisar omvänd skattskyldighet.",
"category": "eu_trade",
"entity_type": "all",
"lines": [
{
"side": "debit",
"type": "settlement",
"label": "Kundfordringar",
"ratio": 1.0,
"account": "1510"
},
{
"side": "credit",
"type": "business",
"label": "Försäljning tjänster EU",
"ratio": 1.0,
"account": "3308"
}
]
},
{
"name": "Försäljning export (utanför EU)",
"description": "Försäljning till land utanför EU. Momsfritt.",
"category": "eu_trade",
"entity_type": "all",
"lines": [
{
"side": "debit",
"type": "settlement",
"label": "Kundfordringar",
"ratio": 1.0,
"account": "1510"
},
{
"side": "credit",
"type": "business",
"label": "Försäljning export",
"ratio": 1.0,
"account": "3305"
}
]
},
{
"name": "Inköp EU-tjänster, omvänd moms 25%",
"description": "Köp av tjänster från annat EU-land. Omvänd skattskyldighet — du redovisar både utgående och ingående moms.",
"category": "eu_trade",
"entity_type": "all",
"lines": [
{
"side": "debit",
"type": "business",
"label": "IT-tjänster",
"ratio": 1.0,
"account": "6540"
},
{
"side": "credit",
"type": "vat",
"label": "Utgående moms omvänd skattskyldighet 25%",
"account": "2614",
"vat_rate": 0.25
},
{
"side": "debit",
"type": "vat",
"label": "Beräknad ingående moms 25%",
"account": "2645",
"vat_rate": 0.25
},
{
"side": "credit",
"type": "settlement",
"label": "Företagskonto",
"ratio": 1.0,
"account": "1930"
}
]
},
{
"name": "Inköp EU-varor, omvänd moms 25%",
"description": "Köp av varor från annat EU-land. Omvänd skattskyldighet — du redovisar både utgående och ingående moms.",
"category": "eu_trade",
"entity_type": "all",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Varuinköp",
"ratio": 1.0,
"account": "4010"
},
{
"side": "credit",
"type": "vat",
"label": "Utgående moms omvänd skattskyldighet 25%",
"account": "2614",
"vat_rate": 0.25
},
{
"side": "debit",
"type": "vat",
"label": "Beräknad ingående moms 25%",
"account": "2645",
"vat_rate": 0.25
},
{
"side": "credit",
"type": "settlement",
"label": "Företagskonto",
"ratio": 1.0,
"account": "1930"
}
]
},
{
"name": "Bankavgift",
"description": "Månadsavgift eller transaktionsavgift från banken.",
"category": "financial",
"entity_type": "all",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Bankkostnader",
"ratio": 1.0,
"account": "6570"
},
{
"side": "credit",
"type": "settlement",
"label": "Företagskonto",
"ratio": 1.0,
"account": "1930"
}
]
},
{
"name": "Ränteintäkt",
"description": "Ränta från sparkonto eller bank.",
"category": "financial",
"entity_type": "all",
"lines": [
{
"side": "debit",
"type": "settlement",
"label": "Företagskonto",
"ratio": 1.0,
"account": "1930"
},
{
"side": "credit",
"type": "business",
"label": "Ränteintäkter",
"ratio": 1.0,
"account": "8311"
}
]
},
{
"name": "Räntekostnad",
"description": "Ränta på lån eller kredit.",
"category": "financial",
"entity_type": "all",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Räntekostnader",
"ratio": 1.0,
"account": "8410"
},
{
"side": "credit",
"type": "settlement",
"label": "Företagskonto",
"ratio": 1.0,
"account": "1930"
}
]
},
{
"name": "Aktieägarlån — insättning",
"description": "Ägaren sätter in pengar som lån till bolaget.",
"category": "private_transfer",
"entity_type": "aktiebolag",
"lines": [
{
"side": "debit",
"type": "settlement",
"label": "Företagskonto",
"ratio": 1.0,
"account": "1930"
},
{
"side": "credit",
"type": "business",
"label": "Skuld till aktieägare",
"ratio": 1.0,
"account": "2893"
}
]
},
{
"name": "Aktieägarlån — återbetalning",
"description": "Bolaget betalar tillbaka lån till ägaren.",
"category": "private_transfer",
"entity_type": "aktiebolag",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Skuld till aktieägare",
"ratio": 1.0,
"account": "2893"
},
{
"side": "credit",
"type": "settlement",
"label": "Företagskonto",
"ratio": 1.0,
"account": "1930"
}
]
},
{
"name": "Eget insättning",
"description": "Privat insättning till företagskontot (enskild firma).",
"category": "private_transfer",
"entity_type": "enskild_firma",
"lines": [
{
"side": "debit",
"type": "settlement",
"label": "Företagskonto",
"ratio": 1.0,
"account": "1930"
},
{
"side": "credit",
"type": "business",
"label": "Egna insättningar",
"ratio": 1.0,
"account": "2018"
}
]
},
{
"name": "Eget uttag",
"description": "Privat uttag från företagskontot (enskild firma).",
"category": "private_transfer",
"entity_type": "enskild_firma",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Egna uttag",
"ratio": 1.0,
"account": "2013"
},
{
"side": "credit",
"type": "settlement",
"label": "Företagskonto",
"ratio": 1.0,
"account": "1930"
}
]
},
{
"name": "Utdelning till aktieägare",
"description": "Utbetalning av beslutad utdelning till aktieägare.",
"category": "private_transfer",
"entity_type": "aktiebolag",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Outtagen utdelning",
"ratio": 1.0,
"account": "2898"
},
{
"side": "credit",
"type": "settlement",
"label": "Företagskonto",
"ratio": 1.0,
"account": "1930"
}
]
},
{
"name": "Representation (avdragsgill, 25% moms)",
"description": "Extern representation med avdragsgill moms. Max 300 kr/person exkl. moms.",
"category": "representation",
"entity_type": "all",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Representation avdragsgill",
"ratio": 0.8,
"account": "6072"
},
{
"side": "debit",
"type": "vat",
"label": "Ingående moms",
"account": "2641",
"vat_rate": 0.25
},
{
"side": "credit",
"type": "settlement",
"label": "Företagskonto",
"ratio": 1.0,
"account": "1930"
}
]
},
{
"name": "Arbetsgivaravgifter",
"description": "Bokföring av arbetsgivaravgifter (31,42% av bruttolön).",
"category": "salary",
"entity_type": "aktiebolag",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Arbetsgivaravgifter",
"ratio": 1.0,
"account": "7510"
},
{
"side": "credit",
"type": "business",
"label": "Avräkning sociala avgifter",
"ratio": 1.0,
"account": "2731"
}
]
},
{
"name": "Löneutbetalning",
"description": "Utbetalning av nettolön till anställd.",
"category": "salary",
"entity_type": "aktiebolag",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Personalskatt",
"ratio": 0.3,
"account": "2710"
},
{
"side": "debit",
"type": "business",
"label": "Upplupna semesterlöner",
"ratio": 0.12,
"account": "2920"
},
{
"side": "debit",
"type": "business",
"label": "Löner",
"ratio": 1.0,
"account": "7010"
},
{
"side": "credit",
"type": "settlement",
"label": "Företagskonto",
"ratio": 1.0,
"account": "1930"
}
]
},
{
"name": "Arbetsgivaravgifter via skattekonto",
"description": "Arbetsgivaravgifter som dras från skattekontot.",
"category": "tax_account",
"entity_type": "all",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Avräkning sociala avgifter",
"ratio": 1.0,
"account": "2731"
},
{
"side": "credit",
"type": "settlement",
"label": "Skattekonto",
"ratio": 1.0,
"account": "1630"
}
]
},
{
"name": "Insättning skattekonto",
"description": "Betalning från företagskonto till skattekontot hos Skatteverket.",
"category": "tax_account",
"entity_type": "all",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Skattekonto",
"ratio": 1.0,
"account": "1630"
},
{
"side": "credit",
"type": "settlement",
"label": "Företagskonto",
"ratio": 1.0,
"account": "1930"
}
]
},
{
"name": "Momsbetalning via skattekonto",
"description": "Moms som dras från skattekontot efter momsdeklaration.",
"category": "tax_account",
"entity_type": "all",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Redovisningskonto moms",
"ratio": 1.0,
"account": "2650"
},
{
"side": "credit",
"type": "settlement",
"label": "Skattekonto",
"ratio": 1.0,
"account": "1630"
}
]
},
{
"name": "Preliminär F-skatt (AB)",
"description": "Betalning av preliminär bolagsskatt från skattekontot.",
"category": "tax_account",
"entity_type": "aktiebolag",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Betald F-skatt",
"ratio": 1.0,
"account": "2518"
},
{
"side": "credit",
"type": "settlement",
"label": "Skattekonto",
"ratio": 1.0,
"account": "1630"
}
]
},
{
"name": "Preliminär F-skatt (EF)",
"description": "Betalning av preliminär F-skatt från skattekontot (enskild firma).",
"category": "tax_account",
"entity_type": "enskild_firma",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Egna skatter",
"ratio": 1.0,
"account": "2012"
},
{
"side": "credit",
"type": "settlement",
"label": "Skattekonto",
"ratio": 1.0,
"account": "1630"
}
]
},
{
"name": "Skatteåterbäring",
"description": "Återbetalning från skattekontot till företagskonto.",
"category": "tax_account",
"entity_type": "all",
"lines": [
{
"side": "debit",
"type": "settlement",
"label": "Företagskonto",
"ratio": 1.0,
"account": "1930"
},
{
"side": "credit",
"type": "business",
"label": "Skattekonto",
"ratio": 1.0,
"account": "1630"
}
]
},
{
"name": "Momsredovisning (nettning)",
"description": "Nettning av momskonton vid momsdeklaration. Justera konton och belopp efter din deklaration.",
"category": "vat",
"entity_type": "all",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Utgående moms 25%",
"ratio": 0.5,
"account": "2611"
},
{
"side": "credit",
"type": "business",
"label": "Ingående moms",
"ratio": 0.3,
"account": "2641"
},
{
"side": "credit",
"type": "business",
"label": "Redovisningskonto moms",
"ratio": 0.2,
"account": "2650"
}
]
},
{
"name": "Beräknad bolagsskatt",
"description": "Bokföring av beräknad inkomstskatt vid bokslut.",
"category": "year_end",
"entity_type": "aktiebolag",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Skatt på årets resultat",
"ratio": 1.0,
"account": "8910"
},
{
"side": "credit",
"type": "business",
"label": "Beräknad inkomstskatt",
"ratio": 1.0,
"account": "2512"
}
]
},
{
"name": "Periodiseringsfond avsättning (AB)",
"description": "Avsättning till periodiseringsfond vid bokslut. Max 25% av överskottet.",
"category": "year_end",
"entity_type": "aktiebolag",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Avsättning periodiseringsfond",
"ratio": 1.0,
"account": "8811"
},
{
"side": "credit",
"type": "business",
"label": "Periodiseringsfond",
"ratio": 1.0,
"account": "2113"
}
]
},
{
"name": "Periodiseringsfond återföring (AB)",
"description": "Återföring av periodiseringsfond (senast efter 6 år).",
"category": "year_end",
"entity_type": "aktiebolag",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Periodiseringsfond",
"ratio": 1.0,
"account": "2113"
},
{
"side": "credit",
"type": "business",
"label": "Återföring periodiseringsfond",
"ratio": 1.0,
"account": "8819"
}
]
},
{
"name": "Överavskrivning inventarier",
"description": "Bokföring av överavskrivning (skillnad räkenskapsenlig vs planenlig).",
"category": "year_end",
"entity_type": "aktiebolag",
"lines": [
{
"side": "debit",
"type": "business",
"label": "Förändring överavskrivning",
"ratio": 1.0,
"account": "8850"
},
{
"side": "credit",
"type": "business",
"label": "Ackumulerade överavskrivningar",
"ratio": 1.0,
"account": "2150"
}
]
}
]
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest'
import path from 'node:path'
import { loadPacks, packToLibraryRow, sortPacks } from '@/lib/packs/load'
import seeded from './fixtures/seeded-system-templates.json'
/**
* The port out of migration 20260413160000 must be LOSSLESS.
*
* The fixture is not hand-written: it was read out of a Postgres that had all
* 548 migrations applied, so it is exactly the JSONB the database holds today.
* If `packs/*.yaml` reproduces it byte for byte, then swapping the seeded rows
* for the pack files (phase 2b) is a no-op for every existing company.
*
* This is the test that makes the format change safe to ship. If it fails, the
* catalogue has drifted from production and the loader must not be switched on.
*/
interface SeededTemplate {
name: string
description: string
category: string
entity_type: string
lines: Array<Record<string, unknown>>
}
const ROOT = path.resolve(__dirname, '../../..')
/** Compare by value: jsonb does not preserve key order, so neither do we. */
function canonical(t: {
name: string
description: string
category: string
entity_type: string
lines: Array<Record<string, unknown>>
}): string {
return JSON.stringify({
name: t.name,
description: t.description,
category: t.category,
entity_type: t.entity_type,
lines: t.lines.map((l) =>
Object.fromEntries(Object.entries(l).sort(([a], [b]) => a.localeCompare(b))),
),
})
}
describe('pack catalogue is a lossless port of the seeded system templates', () => {
const { packs, errors } = loadPacks(ROOT)
it('every pack file parses and passes the schema', () => {
expect(errors, `pack load errors:\n${errors.map((e) => `${e.file}: ${e.message}`).join('\n')}`).toEqual([])
expect(packs.length).toBeGreaterThan(0)
})
it('reproduces exactly the templates the migration seeds', () => {
const fromPacks = packs.map((p) => canonical(packToLibraryRow(p.pack))).sort()
const fromDb = (seeded as SeededTemplate[]).map(canonical).sort()
expect(fromPacks).toHaveLength(fromDb.length)
expect(fromPacks).toEqual(fromDb)
})
it('covers all 26 seeded templates, none added and none dropped', () => {
expect(packs).toHaveLength((seeded as SeededTemplate[]).length)
expect(packs).toHaveLength(26)
})
it('preserves shipped Swedish text verbatim, em dashes included', () => {
// Five seeded descriptions/names contain an em dash. The repo style rule
// forbids writing new ones, but a lossless port must not silently rewrite
// user-visible strings: changing them is a content decision, not a format
// one. This test pins that so a future cleanup is deliberate.
const packText = packs.map((p) => `${p.pack.meta.name} ${p.pack.meta.description}`).join('\n')
const dbText = (seeded as SeededTemplate[]).map((t) => `${t.name} ${t.description}`).join('\n')
const countEmDash = (s: string) => (s.match(/—/g) ?? []).length
expect(countEmDash(packText)).toBe(countEmDash(dbText))
expect(countEmDash(packText)).toBeGreaterThan(0)
})
})
describe('catalogue invariants', () => {
const { packs } = loadPacks(ROOT)
it('has a unique slug per pack, matching its filename', () => {
const slugs = packs.map((p) => p.pack.meta.slug)
expect(new Set(slugs).size).toBe(slugs.length)
for (const p of packs) expect(p.fileSlug).toBe(p.pack.meta.slug)
})
it('has a unique meta.order, so gallery and docs can never disagree', () => {
const orders = packs.map((p) => p.pack.meta.order)
expect(new Set(orders).size).toBe(orders.length)
})
it('sorts deterministically by meta.order', () => {
const ordered = sortPacks(packs).map((p) => p.pack.meta.order)
expect(ordered).toEqual([...ordered].sort((a, b) => a - b))
})
})
+121
View File
@@ -0,0 +1,121 @@
import { describe, it, expect } from 'vitest'
import { PackSchema, PackLineSchema, PACK_SLUG_RE } from '@/lib/packs/schema'
const validLine = {
account: '5010',
label: 'Lokalhyra',
side: 'debit' as const,
type: 'business' as const,
ratio: 1.0,
}
const validPack = {
meta: {
slug: 'lokalhyra',
order: 1,
name: 'Lokalhyra',
description: 'Månadshyra för kontorslokal.',
category: 'other' as const,
entity_type: 'all' as const,
},
lines: [validLine, { ...validLine, account: '1930', label: 'Företagskonto', side: 'credit' as const, type: 'settlement' as const }],
}
describe('pack schema', () => {
it('accepts a well-formed pack', () => {
expect(PackSchema.safeParse(validPack).success).toBe(true)
})
it('rejects an unknown top-level key so typos surface instead of being ignored', () => {
const r = PackSchema.safeParse({ ...validPack, calculators: ['pm_moms'] })
expect(r.success).toBe(false)
})
it('rejects an unknown line key', () => {
const r = PackSchema.safeParse({
...validPack,
lines: [{ ...validLine, deductibility: 'full' }, validPack.lines[1]],
})
expect(r.success).toBe(false)
})
it('requires at least two lines: one cannot balance', () => {
expect(PackSchema.safeParse({ ...validPack, lines: [validLine] }).success).toBe(false)
})
})
describe('account numbers go through the shared invariant', () => {
it('rejects a non-four-digit account', () => {
for (const bad of ['501', '50100', 'abcd', '']) {
expect(PackLineSchema.safeParse({ ...validLine, account: bad }).success, bad).toBe(false)
}
})
it('rejects a numeric account: BAS numbers are strings', () => {
expect(PackLineSchema.safeParse({ ...validLine, account: 5010 as never }).success).toBe(false)
})
})
describe('the vat_rate / ratio split', () => {
// applyTemplate() computes a vat line from vat_rate and everything else from
// ratio. Mixing them silently produces the wrong amount, so the schema
// refuses rather than trusting convention.
it('requires vat_rate on a vat line', () => {
const r = PackLineSchema.safeParse({ account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat' })
expect(r.success).toBe(false)
})
it('rejects ratio on a vat line', () => {
const r = PackLineSchema.safeParse({
account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25, ratio: 1.0,
})
expect(r.success).toBe(false)
})
it('requires ratio on business and settlement lines', () => {
for (const type of ['business', 'settlement'] as const) {
const r = PackLineSchema.safeParse({ account: '5010', label: 'X', side: 'debit', type })
expect(r.success, type).toBe(false)
}
})
it('rejects vat_rate on a business line', () => {
const r = PackLineSchema.safeParse({ ...validLine, vat_rate: 0.25 })
expect(r.success).toBe(false)
})
it('accepts a correct vat line', () => {
const r = PackLineSchema.safeParse({
account: '2641', label: 'Ingående moms', side: 'debit', type: 'vat', vat_rate: 0.25,
})
expect(r.success).toBe(true)
})
})
describe('slug rule', () => {
it('accepts lowercase kebab-case', () => {
for (const ok of ['lokalhyra', 'eu-tjanster-b2b', 'moms-25']) {
expect(PACK_SLUG_RE.test(ok), ok).toBe(true)
}
})
it('rejects anything that would break a URL or a lookup', () => {
for (const bad of ['Lokalhyra', 'lokal_hyra', 'lokal hyra', '-lokal', 'lokal-', 'lokal--hyra', 'lokalhyrå']) {
expect(PACK_SLUG_RE.test(bad), bad).toBe(false)
}
})
it('is enforced by the schema', () => {
const r = PackSchema.safeParse({ ...validPack, meta: { ...validPack.meta, slug: 'Not A Slug' } })
expect(r.success).toBe(false)
})
})
describe('meta.order', () => {
it('must be a positive integer', () => {
for (const bad of [0, -1, 1.5]) {
const r = PackSchema.safeParse({ ...validPack, meta: { ...validPack.meta, order: bad } })
expect(r.success, String(bad)).toBe(false)
}
})
})
+139
View File
@@ -0,0 +1,139 @@
import fs from 'node:fs'
import path from 'node:path'
import yaml from 'js-yaml'
import { PackSchema, type Pack } from './schema'
/**
* Reading packs off disk.
*
* Node-only: uses `fs`, so this must never be imported from a client component
* or from an edge path. The runtime consumers (the system-template loader, the
* validator, the docs export) are all server-side or build-time.
*/
/** Repo-root-relative home of the pack catalogue. */
export const PACKS_DIR = 'packs'
export interface LoadedPack {
/** Filename without extension. Must equal `pack.meta.slug`. */
fileSlug: string
/** Repo-relative path, for error messages. */
file: string
pack: Pack
}
export interface PackLoadError {
file: string
message: string
}
export interface PackLoadResult {
packs: LoadedPack[]
errors: PackLoadError[]
}
function packsDirAbs(root: string): string {
return path.join(root, PACKS_DIR)
}
/** List pack files (`*.yaml`) in the catalogue, sorted by filename. */
export function listPackFiles(root: string = process.cwd()): string[] {
const dir = packsDirAbs(root)
if (!fs.existsSync(dir)) return []
return fs
.readdirSync(dir)
.filter((f) => f.endsWith('.yaml'))
.sort()
.map((f) => path.join(dir, f))
}
/**
* Load and schema-validate every pack.
*
* Collects errors rather than throwing on the first one: a validator that stops
* at the first bad file makes fixing a batch a game of whack-a-mole. Structural
* validation only. Cross-file rules (unique slug and order) and semantic rules
* (accounts exist in BAS, the template balances) live in
* `scripts/validate-packs.ts`, because they need the BAS chart and are a CI
* gate rather than a runtime concern.
*/
export function loadPacks(root: string = process.cwd()): PackLoadResult {
const packs: LoadedPack[] = []
const errors: PackLoadError[] = []
for (const abs of listPackFiles(root)) {
const file = path.relative(root, abs).split(path.sep).join('/')
const fileSlug = path.basename(abs, '.yaml')
let raw: unknown
try {
raw = yaml.load(fs.readFileSync(abs, 'utf8'))
} catch (err) {
errors.push({ file, message: `YAML parse failed: ${(err as Error).message}` })
continue
}
const parsed = PackSchema.safeParse(raw)
if (!parsed.success) {
for (const issue of parsed.error.issues) {
const where = issue.path.length ? issue.path.join('.') : '(root)'
errors.push({ file, message: `${where}: ${issue.message}` })
}
continue
}
packs.push({ fileSlug, file, pack: parsed.data })
}
return { packs, errors }
}
/**
* Packs in display order.
*
* `meta.order` is the single source of truth: the in-app gallery and the docs
* site both sort on it so they can never disagree. Ties fall back to slug only
* so the sort is deterministic; the validator rejects duplicate orders, so a
* tie means the catalogue is already invalid.
*/
export function sortPacks(packs: LoadedPack[]): LoadedPack[] {
return [...packs].sort(
(a, b) => a.pack.meta.order - b.pack.meta.order || a.pack.meta.slug.localeCompare(b.pack.meta.slug),
)
}
/**
* Shape a pack into the row the `booking_template_library` table stores.
*
* This is the bridge between the data files and the database: the system
* templates are seeded from packs rather than from a frozen migration.
*/
export function packToLibraryRow(pack: Pack): {
name: string
description: string
category: string
entity_type: string
is_system: true
lines: Array<Record<string, unknown>>
} {
return {
name: pack.meta.name,
description: pack.meta.description,
category: pack.meta.category,
entity_type: pack.meta.entity_type,
is_system: true,
// Key order matches the schema declaration, not the seeded JSONB: jsonb
// does not preserve key order anyway, so equality is compared by value.
lines: pack.lines.map((l) => {
const row: Record<string, unknown> = {
account: l.account,
label: l.label,
side: l.side,
type: l.type,
}
if (l.ratio !== undefined) row.ratio = l.ratio
if (l.vat_rate !== undefined) row.vat_rate = l.vat_rate
return row
}),
}
}
+138
View File
@@ -0,0 +1,138 @@
import { z } from 'zod'
import { accountNumberSchema } from '@/lib/invariants/zod'
/**
* The konteringspaket (pack) contract.
*
* ## What a pack is
*
* One reusable bookkeeping pattern, as data: the accounts it touches, which
* side each lands on, and how a total amount is split across them. A pack is
* pure data forever. It never carries executable code or DDL, which is the
* lock recorded in `dev_docs/niche_factory.md` and what makes a pack safe to
* accept from an author who is not us.
*
* ## Why the catalogue moved out of a migration
*
* The 26 system templates were seeded inside
* `supabase/migrations/20260413160000_booking_template_library.sql`. Under the
* never-modify-a-shipped-migration rule that froze them: correcting a wrong BAS
* account or a Swedish typo needed a whole new migration, and nothing checked
* that a seeded account existed in the chart or that a template balanced. PR
* #1321 was exactly that failure with seeded chart names. As data files with a
* validator, a correction is a one-line edit plus a green CI run.
*
* ## The line model
*
* `applyTemplate()` in `lib/bookkeeping/template-library.ts` turns a total
* amount into lines, and the three types are not decorative:
*
* - `vat`: amount is `total * vat_rate / (1 + vat_rate)`, so it carries
* `vat_rate` and never `ratio`.
* - `business` and `settlement`: amount is `total * ratio`, so they carry
* `ratio` and never `vat_rate`.
*
* That split is enforced below rather than left as a convention, because a
* `vat` line with a `ratio` silently computes the wrong amount.
*/
/** Categories a pack can be filed under. Mirrors the `booking_template_library.category` CHECK. */
export const PACK_CATEGORIES = [
'eu_trade',
'tax_account',
'private_transfer',
'salary',
'representation',
'year_end',
'vat',
'financial',
'other',
] as const
/** Which entity types a pack applies to. Mirrors the `entity_type` CHECK. */
export const PACK_ENTITY_TYPES = ['all', 'enskild_firma', 'aktiebolag'] as const
/** Line roles. Drives the amount maths in `applyTemplate()`. */
export const PACK_LINE_TYPES = ['business', 'vat', 'settlement'] as const
/**
* Slug: lowercase kebab-case. This is the **public lookup key**, used as the
* filename, in the docs URL, and by the assistant to name a pack. Renaming one
* breaks every reference, so treat it as an identifier, not a label.
*/
export const PACK_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
export const PackLineSchema = z
.object({
// From lib/invariants: the same BAS account rule the API, the MCP surface
// and the SIE importer use. A pack cannot disagree with the rest of the app
// about what an account number is.
account: accountNumberSchema,
label: z.string().min(1).max(200),
side: z.enum(['debit', 'credit']),
type: z.enum(PACK_LINE_TYPES),
ratio: z.number().min(0).max(10).optional(),
vat_rate: z.number().min(0).max(1).optional(),
})
.strict()
.superRefine((line, ctx) => {
if (line.type === 'vat') {
if (line.vat_rate === undefined) {
ctx.addIssue({ code: 'custom', message: 'a vat line must carry vat_rate', path: ['vat_rate'] })
}
if (line.ratio !== undefined) {
ctx.addIssue({
code: 'custom',
message: 'a vat line must not carry ratio: its amount comes from vat_rate',
path: ['ratio'],
})
}
return
}
if (line.ratio === undefined) {
ctx.addIssue({ code: 'custom', message: `a ${line.type} line must carry ratio`, path: ['ratio'] })
}
if (line.vat_rate !== undefined) {
ctx.addIssue({
code: 'custom',
message: `a ${line.type} line must not carry vat_rate`,
path: ['vat_rate'],
})
}
})
export const PackMetaSchema = z
.object({
slug: z.string().regex(PACK_SLUG_RE, 'slug must be lowercase kebab-case'),
/**
* Single source of truth for display order, unique across the catalogue.
* The in-app gallery and the docs site both sort on it, so the two surfaces
* cannot disagree. Renumber deliberately.
*/
order: z.number().int().positive(),
name: z.string().min(1).max(200),
description: z.string().max(2000).default(''),
/**
* Optional Swedish note on the statutory rule behind the pattern, e.g. the
* 300 kr per person cap on representation VAT. Rendered next to the pack so
* a user knows *when* the template applies, not just what it posts.
* Stays Swedish in both locales: it is statutory content, per
* `.claude/rules/i18n.md`.
*/
legal_note: z.string().max(2000).optional(),
category: z.enum(PACK_CATEGORIES),
entity_type: z.enum(PACK_ENTITY_TYPES).default('all'),
})
.strict()
export const PackSchema = z
.object({
meta: PackMetaSchema,
// Two lines is the minimum that can balance.
lines: z.array(PackLineSchema).min(2).max(50),
})
.strict()
export type Pack = z.infer<typeof PackSchema>
export type PackLine = z.infer<typeof PackLineSchema>
export type PackMeta = z.infer<typeof PackMetaSchema>