From df34cae9bfc18c8db4e6cbcb4babaf4254902268 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Mon, 3 Aug 2026 18:05:54 +0200 Subject: [PATCH] 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) * 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) --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/core-build.yml | 10 + .../fixtures/seeded-system-templates.json | 630 ++++++++++++++++++ lib/packs/__tests__/port-is-lossless.test.ts | 100 +++ lib/packs/__tests__/schema.test.ts | 121 ++++ lib/packs/load.ts | 139 ++++ lib/packs/schema.ts | 138 ++++ package-lock.json | 11 +- package.json | 3 + packs/README.md | 97 +++ packs/aktieagarlan-aterbetalning.yaml | 21 + packs/aktieagarlan-insattning.yaml | 21 + .../arbetsgivaravgifter-via-skattekonto.yaml | 21 + packs/arbetsgivaravgifter.yaml | 21 + packs/bankavgift.yaml | 21 + packs/beraknad-bolagsskatt.yaml | 21 + packs/eget-insattning.yaml | 21 + packs/eget-uttag.yaml | 21 + packs/forsaljning-eu-tjanster-b2b.yaml | 21 + packs/forsaljning-export-utanfor-eu.yaml | 21 + packs/inkop-eu-tjanster-omvand-moms-25.yaml | 31 + packs/inkop-eu-varor-omvand-moms-25.yaml | 31 + packs/insattning-skattekonto.yaml | 21 + packs/loneutbetalning.yaml | 31 + packs/momsbetalning-via-skattekonto.yaml | 21 + packs/momsredovisning-nettning.yaml | 26 + packs/overavskrivning-inventarier.yaml | 21 + packs/periodiseringsfond-aterforing-ab.yaml | 21 + packs/periodiseringsfond-avsattning-ab.yaml | 21 + packs/preliminar-f-skatt-ab.yaml | 21 + packs/preliminar-f-skatt-ef.yaml | 21 + packs/ranteintakt.yaml | 21 + packs/rantekostnad.yaml | 21 + packs/representation-avdragsgill-25-moms.yaml | 26 + packs/skatteaterbaring.yaml | 21 + packs/utdelning-till-aktieagare.yaml | 21 + scripts/validate-packs.ts | 214 ++++++ 36 files changed, 2047 insertions(+), 2 deletions(-) create mode 100644 lib/packs/__tests__/fixtures/seeded-system-templates.json create mode 100644 lib/packs/__tests__/port-is-lossless.test.ts create mode 100644 lib/packs/__tests__/schema.test.ts create mode 100644 lib/packs/load.ts create mode 100644 lib/packs/schema.ts create mode 100644 packs/README.md create mode 100644 packs/aktieagarlan-aterbetalning.yaml create mode 100644 packs/aktieagarlan-insattning.yaml create mode 100644 packs/arbetsgivaravgifter-via-skattekonto.yaml create mode 100644 packs/arbetsgivaravgifter.yaml create mode 100644 packs/bankavgift.yaml create mode 100644 packs/beraknad-bolagsskatt.yaml create mode 100644 packs/eget-insattning.yaml create mode 100644 packs/eget-uttag.yaml create mode 100644 packs/forsaljning-eu-tjanster-b2b.yaml create mode 100644 packs/forsaljning-export-utanfor-eu.yaml create mode 100644 packs/inkop-eu-tjanster-omvand-moms-25.yaml create mode 100644 packs/inkop-eu-varor-omvand-moms-25.yaml create mode 100644 packs/insattning-skattekonto.yaml create mode 100644 packs/loneutbetalning.yaml create mode 100644 packs/momsbetalning-via-skattekonto.yaml create mode 100644 packs/momsredovisning-nettning.yaml create mode 100644 packs/overavskrivning-inventarier.yaml create mode 100644 packs/periodiseringsfond-aterforing-ab.yaml create mode 100644 packs/periodiseringsfond-avsattning-ab.yaml create mode 100644 packs/preliminar-f-skatt-ab.yaml create mode 100644 packs/preliminar-f-skatt-ef.yaml create mode 100644 packs/ranteintakt.yaml create mode 100644 packs/rantekostnad.yaml create mode 100644 packs/representation-avdragsgill-25-moms.yaml create mode 100644 packs/skatteaterbaring.yaml create mode 100644 packs/utdelning-till-aktieagare.yaml create mode 100644 scripts/validate-packs.ts diff --git a/.github/workflows/core-build.yml b/.github/workflows/core-build.yml index 7e5199fa..821d1094 100644 --- a/.github/workflows/core-build.yml +++ b/.github/workflows/core-build.yml @@ -58,6 +58,16 @@ jobs: # down as the A1 (route auth) and D1 (rounding) migrations land. run: npm run check:guards + - name: Validate konteringspaket catalogue + # The system booking templates are data files under packs/ rather than + # rows frozen inside a migration. This gate is what makes that safe: it + # checks the schema, that every BAS account actually exists in the 2026 + # chart, and that each pack BALANCES when applied through the real + # applyTemplate(). PR #1321 shipped seeded reference data that + # contradicted the engine; that class of bug is structurally valid and + # only a semantic check catches it. + run: npm run validate:packs + - name: Check no core imports from extensions run: | VIOLATIONS=$(grep -r "from '@/extensions/" lib/ app/api/ components/ --include="*.ts" --include="*.tsx" \ diff --git a/lib/packs/__tests__/fixtures/seeded-system-templates.json b/lib/packs/__tests__/fixtures/seeded-system-templates.json new file mode 100644 index 00000000..e30c6666 --- /dev/null +++ b/lib/packs/__tests__/fixtures/seeded-system-templates.json @@ -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" + } + ] + } +] diff --git a/lib/packs/__tests__/port-is-lossless.test.ts b/lib/packs/__tests__/port-is-lossless.test.ts new file mode 100644 index 00000000..357f8e21 --- /dev/null +++ b/lib/packs/__tests__/port-is-lossless.test.ts @@ -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> +} + +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> +}): 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)) + }) +}) diff --git a/lib/packs/__tests__/schema.test.ts b/lib/packs/__tests__/schema.test.ts new file mode 100644 index 00000000..62dae6b6 --- /dev/null +++ b/lib/packs/__tests__/schema.test.ts @@ -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) + } + }) +}) diff --git a/lib/packs/load.ts b/lib/packs/load.ts new file mode 100644 index 00000000..f926112b --- /dev/null +++ b/lib/packs/load.ts @@ -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> +} { + 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 = { + 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 + }), + } +} diff --git a/lib/packs/schema.ts b/lib/packs/schema.ts new file mode 100644 index 00000000..f0db6389 --- /dev/null +++ b/lib/packs/schema.ts @@ -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 +export type PackLine = z.infer +export type PackMeta = z.infer diff --git a/package-lock.json b/package-lock.json index 892a3a94..a813e254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "framer-motion": "^12.42.2", "fuse.js": "^7.4.2", "ics": "^3.12.0", + "js-yaml": "4.1.1", "jszip": "^3.10.1", "lucide-react": "^1.24.0", "mailparser": "^3.9.14", @@ -66,6 +67,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/js-yaml": "4.0.9", "@types/mailparser": "^3.4.6", "@types/node": "^20", "@types/pg": "^8.20.0", @@ -7634,6 +7636,13 @@ "@types/unist": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -8616,7 +8625,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -12008,7 +12016,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index fb793512..a16228df 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "start": "next start", "lint": "eslint", "check:guards": "node scripts/checks/no-new-antipatterns.mjs", + "validate:packs": "npx tsx scripts/validate-packs.ts", "check:lint": "node scripts/checks/no-new-lint-errors.mjs", "test": "vitest run --project unit", "test:pg": "vitest run --project pg-real" @@ -51,6 +52,7 @@ "framer-motion": "^12.42.2", "fuse.js": "^7.4.2", "ics": "^3.12.0", + "js-yaml": "4.1.1", "jszip": "^3.10.1", "lucide-react": "^1.24.0", "mailparser": "^3.9.14", @@ -80,6 +82,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@types/js-yaml": "4.0.9", "@types/mailparser": "^3.4.6", "@types/node": "^20", "@types/pg": "^8.20.0", diff --git a/packs/README.md b/packs/README.md new file mode 100644 index 00000000..83214f90 --- /dev/null +++ b/packs/README.md @@ -0,0 +1,97 @@ +# Konteringspaket + +Reusable bookkeeping patterns, as data. One YAML file per pattern. + +These are the templates a user picks in the app when booking something common: +representation, EU-handel, periodiseringsfond, löneutbetalning. They used to be +rows frozen inside a database migration. They are files now, so correcting one +is a one-line edit and a green CI run instead of a new migration. + +## Anatomy + +```yaml +meta: + slug: representation-avdragsgill-25-moms # filename must match, this is the public key + order: 13 # display order, unique across the catalogue + name: 'Representation (avdragsgill, 25% moms)' + category: representation # eu_trade | tax_account | private_transfer | + # salary | representation | year_end | vat | + # financial | other + entity_type: all # all | enskild_firma | aktiebolag + description: >- + Extern representation med avdragsgill moms. Max 300 kr/person exkl. moms. +lines: + - account: '6072' # BAS account, ALWAYS quoted (it is a string) + label: 'Representation avdragsgill' + side: debit + type: business + ratio: 0.8 + - account: '2641' + label: 'Ingående moms' + side: debit + type: vat + vat_rate: 0.25 + - account: '1930' + label: 'Företagskonto' + side: credit + type: settlement + ratio: 1.0 +``` + +## The three line types + +The user types one total amount. The type decides how each line's amount is +derived from it (`applyTemplate()` in `lib/bookkeeping/template-library.ts`): + +| Type | Amount | Carries | +|---|---|---| +| `vat` | `total * vat_rate / (1 + vat_rate)` | `vat_rate`, never `ratio` | +| `business` | `total * ratio` | `ratio`, never `vat_rate` | +| `settlement` | `total * ratio` | `ratio`, never `vat_rate` | + +`settlement` is the money leg (the bank account, the reskontra). `business` is +the cost or revenue. Putting a `ratio` on a `vat` line silently computes the +wrong amount, so the schema rejects it rather than trusting you to remember. + +## Rules the CI gate enforces + +Run `npm run validate:packs` before pushing. It checks: + +1. The schema, including the `vat_rate` / `ratio` split above. +2. Filename equals `meta.slug`. +3. `meta.slug` and `meta.order` are unique across the catalogue. +4. **Every account exists in the BAS 2026 chart.** A pack may only reference + standard accounts, because a non-standard one cannot be seeded into a + company's chart and the template will fail to apply. +5. **The pack balances** at five probe amounts, applied through the real + `applyTemplate()`. Debits must equal credits or the verifikat cannot post. +6. Both a debit and a credit line are present. + +## Account numbers are strings + +`account: '1930'`, never `account: 1930`. YAML would read the unquoted form as +a number, and a BAS account is an identifier, not a quantity. The schema +rejects it, but quote it anyway so the file reads correctly. + +## Swedish stays Swedish + +`name`, `description` and `legal_note` are user-facing Swedish and are not +translated, in either locale. They are statutory content, per +`.claude/rules/i18n.md`. + +## Known-broken templates + +Four packs ported out of the original migration have pre-existing problems +(an unbalanced salary template, and accounts that no longer exist in BAS 2026). +They are listed in `KNOWN_BROKEN` in `scripts/validate-packs.ts` with the reason +for each. They are quarantined, not accepted: the list may only shrink, and +fixing one means deleting its entry. Each needs a Swedish accounting decision +rather than a code change, which is why they were not fixed during the port. + +## Adding a pack + +1. Copy the closest existing file, rename it to your slug. +2. Set `meta.order` to one past the current highest. +3. Run `npm run validate:packs`. +4. New user-facing strings go in the YAML, not in `messages/*.json`: a pack + carries its own Swedish. diff --git a/packs/aktieagarlan-aterbetalning.yaml b/packs/aktieagarlan-aterbetalning.yaml new file mode 100644 index 00000000..3b6eb7fe --- /dev/null +++ b/packs/aktieagarlan-aterbetalning.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: aktieagarlan-aterbetalning + order: 9 + name: 'Aktieägarlån — återbetalning' + category: private_transfer + entity_type: aktiebolag + description: >- + Bolaget betalar tillbaka lån till ägaren. +lines: + - account: '2893' + label: 'Skuld till aktieägare' + side: debit + type: business + ratio: 1.0 + - account: '1930' + label: 'Företagskonto' + side: credit + type: settlement + ratio: 1.0 diff --git a/packs/aktieagarlan-insattning.yaml b/packs/aktieagarlan-insattning.yaml new file mode 100644 index 00000000..7a464225 --- /dev/null +++ b/packs/aktieagarlan-insattning.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: aktieagarlan-insattning + order: 8 + name: 'Aktieägarlån — insättning' + category: private_transfer + entity_type: aktiebolag + description: >- + Ägaren sätter in pengar som lån till bolaget. +lines: + - account: '1930' + label: 'Företagskonto' + side: debit + type: settlement + ratio: 1.0 + - account: '2893' + label: 'Skuld till aktieägare' + side: credit + type: business + ratio: 1.0 diff --git a/packs/arbetsgivaravgifter-via-skattekonto.yaml b/packs/arbetsgivaravgifter-via-skattekonto.yaml new file mode 100644 index 00000000..d34bbd94 --- /dev/null +++ b/packs/arbetsgivaravgifter-via-skattekonto.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: arbetsgivaravgifter-via-skattekonto + order: 16 + name: 'Arbetsgivaravgifter via skattekonto' + category: tax_account + entity_type: all + description: >- + Arbetsgivaravgifter som dras från skattekontot. +lines: + - account: '2731' + label: 'Avräkning sociala avgifter' + side: debit + type: business + ratio: 1.0 + - account: '1630' + label: 'Skattekonto' + side: credit + type: settlement + ratio: 1.0 diff --git a/packs/arbetsgivaravgifter.yaml b/packs/arbetsgivaravgifter.yaml new file mode 100644 index 00000000..35feed9e --- /dev/null +++ b/packs/arbetsgivaravgifter.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: arbetsgivaravgifter + order: 14 + name: 'Arbetsgivaravgifter' + category: salary + entity_type: aktiebolag + description: >- + Bokföring av arbetsgivaravgifter (31,42% av bruttolön). +lines: + - account: '7510' + label: 'Arbetsgivaravgifter' + side: debit + type: business + ratio: 1.0 + - account: '2731' + label: 'Avräkning sociala avgifter' + side: credit + type: business + ratio: 1.0 diff --git a/packs/bankavgift.yaml b/packs/bankavgift.yaml new file mode 100644 index 00000000..d66475bd --- /dev/null +++ b/packs/bankavgift.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: bankavgift + order: 5 + name: 'Bankavgift' + category: financial + entity_type: all + description: >- + Månadsavgift eller transaktionsavgift från banken. +lines: + - account: '6570' + label: 'Bankkostnader' + side: debit + type: business + ratio: 1.0 + - account: '1930' + label: 'Företagskonto' + side: credit + type: settlement + ratio: 1.0 diff --git a/packs/beraknad-bolagsskatt.yaml b/packs/beraknad-bolagsskatt.yaml new file mode 100644 index 00000000..430af14b --- /dev/null +++ b/packs/beraknad-bolagsskatt.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: beraknad-bolagsskatt + order: 23 + name: 'Beräknad bolagsskatt' + category: year_end + entity_type: aktiebolag + description: >- + Bokföring av beräknad inkomstskatt vid bokslut. +lines: + - account: '8910' + label: 'Skatt på årets resultat' + side: debit + type: business + ratio: 1.0 + - account: '2512' + label: 'Beräknad inkomstskatt' + side: credit + type: business + ratio: 1.0 diff --git a/packs/eget-insattning.yaml b/packs/eget-insattning.yaml new file mode 100644 index 00000000..964a978d --- /dev/null +++ b/packs/eget-insattning.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: eget-insattning + order: 10 + name: 'Eget insättning' + category: private_transfer + entity_type: enskild_firma + description: >- + Privat insättning till företagskontot (enskild firma). +lines: + - account: '1930' + label: 'Företagskonto' + side: debit + type: settlement + ratio: 1.0 + - account: '2018' + label: 'Egna insättningar' + side: credit + type: business + ratio: 1.0 diff --git a/packs/eget-uttag.yaml b/packs/eget-uttag.yaml new file mode 100644 index 00000000..1adf3f48 --- /dev/null +++ b/packs/eget-uttag.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: eget-uttag + order: 11 + name: 'Eget uttag' + category: private_transfer + entity_type: enskild_firma + description: >- + Privat uttag från företagskontot (enskild firma). +lines: + - account: '2013' + label: 'Egna uttag' + side: debit + type: business + ratio: 1.0 + - account: '1930' + label: 'Företagskonto' + side: credit + type: settlement + ratio: 1.0 diff --git a/packs/forsaljning-eu-tjanster-b2b.yaml b/packs/forsaljning-eu-tjanster-b2b.yaml new file mode 100644 index 00000000..22741197 --- /dev/null +++ b/packs/forsaljning-eu-tjanster-b2b.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: forsaljning-eu-tjanster-b2b + order: 1 + name: 'Försäljning EU-tjänster (B2B)' + category: eu_trade + entity_type: all + description: >- + Tjänsteförsäljning till annat EU-land (B2B). Ingen moms — kunden redovisar omvänd skattskyldighet. +lines: + - account: '1510' + label: 'Kundfordringar' + side: debit + type: settlement + ratio: 1.0 + - account: '3308' + label: 'Försäljning tjänster EU' + side: credit + type: business + ratio: 1.0 diff --git a/packs/forsaljning-export-utanfor-eu.yaml b/packs/forsaljning-export-utanfor-eu.yaml new file mode 100644 index 00000000..b288c955 --- /dev/null +++ b/packs/forsaljning-export-utanfor-eu.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: forsaljning-export-utanfor-eu + order: 2 + name: 'Försäljning export (utanför EU)' + category: eu_trade + entity_type: all + description: >- + Försäljning till land utanför EU. Momsfritt. +lines: + - account: '1510' + label: 'Kundfordringar' + side: debit + type: settlement + ratio: 1.0 + - account: '3305' + label: 'Försäljning export' + side: credit + type: business + ratio: 1.0 diff --git a/packs/inkop-eu-tjanster-omvand-moms-25.yaml b/packs/inkop-eu-tjanster-omvand-moms-25.yaml new file mode 100644 index 00000000..33980c00 --- /dev/null +++ b/packs/inkop-eu-tjanster-omvand-moms-25.yaml @@ -0,0 +1,31 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: inkop-eu-tjanster-omvand-moms-25 + order: 3 + name: 'Inköp EU-tjänster, omvänd moms 25%' + category: eu_trade + entity_type: all + description: >- + Köp av tjänster från annat EU-land. Omvänd skattskyldighet — du redovisar både utgående och ingående moms. +lines: + - account: '6540' + label: 'IT-tjänster' + side: debit + type: business + ratio: 1.0 + - account: '2614' + label: 'Utgående moms omvänd skattskyldighet 25%' + side: credit + type: vat + vat_rate: 0.25 + - account: '2645' + label: 'Beräknad ingående moms 25%' + side: debit + type: vat + vat_rate: 0.25 + - account: '1930' + label: 'Företagskonto' + side: credit + type: settlement + ratio: 1.0 diff --git a/packs/inkop-eu-varor-omvand-moms-25.yaml b/packs/inkop-eu-varor-omvand-moms-25.yaml new file mode 100644 index 00000000..b076b7a7 --- /dev/null +++ b/packs/inkop-eu-varor-omvand-moms-25.yaml @@ -0,0 +1,31 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: inkop-eu-varor-omvand-moms-25 + order: 4 + name: 'Inköp EU-varor, omvänd moms 25%' + category: eu_trade + entity_type: all + description: >- + Köp av varor från annat EU-land. Omvänd skattskyldighet — du redovisar både utgående och ingående moms. +lines: + - account: '4010' + label: 'Varuinköp' + side: debit + type: business + ratio: 1.0 + - account: '2614' + label: 'Utgående moms omvänd skattskyldighet 25%' + side: credit + type: vat + vat_rate: 0.25 + - account: '2645' + label: 'Beräknad ingående moms 25%' + side: debit + type: vat + vat_rate: 0.25 + - account: '1930' + label: 'Företagskonto' + side: credit + type: settlement + ratio: 1.0 diff --git a/packs/insattning-skattekonto.yaml b/packs/insattning-skattekonto.yaml new file mode 100644 index 00000000..8c49d97c --- /dev/null +++ b/packs/insattning-skattekonto.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: insattning-skattekonto + order: 17 + name: 'Insättning skattekonto' + category: tax_account + entity_type: all + description: >- + Betalning från företagskonto till skattekontot hos Skatteverket. +lines: + - account: '1630' + label: 'Skattekonto' + side: debit + type: business + ratio: 1.0 + - account: '1930' + label: 'Företagskonto' + side: credit + type: settlement + ratio: 1.0 diff --git a/packs/loneutbetalning.yaml b/packs/loneutbetalning.yaml new file mode 100644 index 00000000..d3d46605 --- /dev/null +++ b/packs/loneutbetalning.yaml @@ -0,0 +1,31 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: loneutbetalning + order: 15 + name: 'Löneutbetalning' + category: salary + entity_type: aktiebolag + description: >- + Utbetalning av nettolön till anställd. +lines: + - account: '2710' + label: 'Personalskatt' + side: debit + type: business + ratio: 0.3 + - account: '2920' + label: 'Upplupna semesterlöner' + side: debit + type: business + ratio: 0.12 + - account: '7010' + label: 'Löner' + side: debit + type: business + ratio: 1.0 + - account: '1930' + label: 'Företagskonto' + side: credit + type: settlement + ratio: 1.0 diff --git a/packs/momsbetalning-via-skattekonto.yaml b/packs/momsbetalning-via-skattekonto.yaml new file mode 100644 index 00000000..49dd690e --- /dev/null +++ b/packs/momsbetalning-via-skattekonto.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: momsbetalning-via-skattekonto + order: 18 + name: 'Momsbetalning via skattekonto' + category: tax_account + entity_type: all + description: >- + Moms som dras från skattekontot efter momsdeklaration. +lines: + - account: '2650' + label: 'Redovisningskonto moms' + side: debit + type: business + ratio: 1.0 + - account: '1630' + label: 'Skattekonto' + side: credit + type: settlement + ratio: 1.0 diff --git a/packs/momsredovisning-nettning.yaml b/packs/momsredovisning-nettning.yaml new file mode 100644 index 00000000..edb16884 --- /dev/null +++ b/packs/momsredovisning-nettning.yaml @@ -0,0 +1,26 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: momsredovisning-nettning + order: 22 + name: 'Momsredovisning (nettning)' + category: vat + entity_type: all + description: >- + Nettning av momskonton vid momsdeklaration. Justera konton och belopp efter din deklaration. +lines: + - account: '2611' + label: 'Utgående moms 25%' + side: debit + type: business + ratio: 0.5 + - account: '2641' + label: 'Ingående moms' + side: credit + type: business + ratio: 0.3 + - account: '2650' + label: 'Redovisningskonto moms' + side: credit + type: business + ratio: 0.2 diff --git a/packs/overavskrivning-inventarier.yaml b/packs/overavskrivning-inventarier.yaml new file mode 100644 index 00000000..1af35d27 --- /dev/null +++ b/packs/overavskrivning-inventarier.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: overavskrivning-inventarier + order: 26 + name: 'Överavskrivning inventarier' + category: year_end + entity_type: aktiebolag + description: >- + Bokföring av överavskrivning (skillnad räkenskapsenlig vs planenlig). +lines: + - account: '8850' + label: 'Förändring överavskrivning' + side: debit + type: business + ratio: 1.0 + - account: '2150' + label: 'Ackumulerade överavskrivningar' + side: credit + type: business + ratio: 1.0 diff --git a/packs/periodiseringsfond-aterforing-ab.yaml b/packs/periodiseringsfond-aterforing-ab.yaml new file mode 100644 index 00000000..e0ad7196 --- /dev/null +++ b/packs/periodiseringsfond-aterforing-ab.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: periodiseringsfond-aterforing-ab + order: 25 + name: 'Periodiseringsfond återföring (AB)' + category: year_end + entity_type: aktiebolag + description: >- + Återföring av periodiseringsfond (senast efter 6 år). +lines: + - account: '2113' + label: 'Periodiseringsfond' + side: debit + type: business + ratio: 1.0 + - account: '8819' + label: 'Återföring periodiseringsfond' + side: credit + type: business + ratio: 1.0 diff --git a/packs/periodiseringsfond-avsattning-ab.yaml b/packs/periodiseringsfond-avsattning-ab.yaml new file mode 100644 index 00000000..4a353b77 --- /dev/null +++ b/packs/periodiseringsfond-avsattning-ab.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: periodiseringsfond-avsattning-ab + order: 24 + name: 'Periodiseringsfond avsättning (AB)' + category: year_end + entity_type: aktiebolag + description: >- + Avsättning till periodiseringsfond vid bokslut. Max 25% av överskottet. +lines: + - account: '8811' + label: 'Avsättning periodiseringsfond' + side: debit + type: business + ratio: 1.0 + - account: '2113' + label: 'Periodiseringsfond' + side: credit + type: business + ratio: 1.0 diff --git a/packs/preliminar-f-skatt-ab.yaml b/packs/preliminar-f-skatt-ab.yaml new file mode 100644 index 00000000..e1463e09 --- /dev/null +++ b/packs/preliminar-f-skatt-ab.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: preliminar-f-skatt-ab + order: 19 + name: 'Preliminär F-skatt (AB)' + category: tax_account + entity_type: aktiebolag + description: >- + Betalning av preliminär bolagsskatt från skattekontot. +lines: + - account: '2518' + label: 'Betald F-skatt' + side: debit + type: business + ratio: 1.0 + - account: '1630' + label: 'Skattekonto' + side: credit + type: settlement + ratio: 1.0 diff --git a/packs/preliminar-f-skatt-ef.yaml b/packs/preliminar-f-skatt-ef.yaml new file mode 100644 index 00000000..c026391a --- /dev/null +++ b/packs/preliminar-f-skatt-ef.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: preliminar-f-skatt-ef + order: 20 + name: 'Preliminär F-skatt (EF)' + category: tax_account + entity_type: enskild_firma + description: >- + Betalning av preliminär F-skatt från skattekontot (enskild firma). +lines: + - account: '2012' + label: 'Egna skatter' + side: debit + type: business + ratio: 1.0 + - account: '1630' + label: 'Skattekonto' + side: credit + type: settlement + ratio: 1.0 diff --git a/packs/ranteintakt.yaml b/packs/ranteintakt.yaml new file mode 100644 index 00000000..0d2ee4a5 --- /dev/null +++ b/packs/ranteintakt.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: ranteintakt + order: 6 + name: 'Ränteintäkt' + category: financial + entity_type: all + description: >- + Ränta från sparkonto eller bank. +lines: + - account: '1930' + label: 'Företagskonto' + side: debit + type: settlement + ratio: 1.0 + - account: '8311' + label: 'Ränteintäkter' + side: credit + type: business + ratio: 1.0 diff --git a/packs/rantekostnad.yaml b/packs/rantekostnad.yaml new file mode 100644 index 00000000..45696230 --- /dev/null +++ b/packs/rantekostnad.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: rantekostnad + order: 7 + name: 'Räntekostnad' + category: financial + entity_type: all + description: >- + Ränta på lån eller kredit. +lines: + - account: '8410' + label: 'Räntekostnader' + side: debit + type: business + ratio: 1.0 + - account: '1930' + label: 'Företagskonto' + side: credit + type: settlement + ratio: 1.0 diff --git a/packs/representation-avdragsgill-25-moms.yaml b/packs/representation-avdragsgill-25-moms.yaml new file mode 100644 index 00000000..cdcd6e97 --- /dev/null +++ b/packs/representation-avdragsgill-25-moms.yaml @@ -0,0 +1,26 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: representation-avdragsgill-25-moms + order: 13 + name: 'Representation (avdragsgill, 25% moms)' + category: representation + entity_type: all + description: >- + Extern representation med avdragsgill moms. Max 300 kr/person exkl. moms. +lines: + - account: '6072' + label: 'Representation avdragsgill' + side: debit + type: business + ratio: 0.8 + - account: '2641' + label: 'Ingående moms' + side: debit + type: vat + vat_rate: 0.25 + - account: '1930' + label: 'Företagskonto' + side: credit + type: settlement + ratio: 1.0 diff --git a/packs/skatteaterbaring.yaml b/packs/skatteaterbaring.yaml new file mode 100644 index 00000000..3e12b716 --- /dev/null +++ b/packs/skatteaterbaring.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: skatteaterbaring + order: 21 + name: 'Skatteåterbäring' + category: tax_account + entity_type: all + description: >- + Återbetalning från skattekontot till företagskonto. +lines: + - account: '1930' + label: 'Företagskonto' + side: debit + type: settlement + ratio: 1.0 + - account: '1630' + label: 'Skattekonto' + side: credit + type: business + ratio: 1.0 diff --git a/packs/utdelning-till-aktieagare.yaml b/packs/utdelning-till-aktieagare.yaml new file mode 100644 index 00000000..f0fc354f --- /dev/null +++ b/packs/utdelning-till-aktieagare.yaml @@ -0,0 +1,21 @@ +# Konteringspaket. Schema: lib/packs/schema.ts +# Validated by `npm run validate:packs` (CI gate). +meta: + slug: utdelning-till-aktieagare + order: 12 + name: 'Utdelning till aktieägare' + category: private_transfer + entity_type: aktiebolag + description: >- + Utbetalning av beslutad utdelning till aktieägare. +lines: + - account: '2898' + label: 'Outtagen utdelning' + side: debit + type: business + ratio: 1.0 + - account: '1930' + label: 'Företagskonto' + side: credit + type: settlement + ratio: 1.0 diff --git a/scripts/validate-packs.ts b/scripts/validate-packs.ts new file mode 100644 index 00000000..0b68164b --- /dev/null +++ b/scripts/validate-packs.ts @@ -0,0 +1,214 @@ +#!/usr/bin/env npx tsx +/** + * CI gate for the konteringspaket catalogue. + * + * Schema validation alone is not enough. The failure this whole format exists + * to prevent (PR #1321: seeded reference data that contradicted what the engine + * actually books) passes any structural check: the JSON was well-formed, the + * account numbers were four digits, and the values were still wrong. So the + * gate also asserts the things that make a pack *correct*: + * + * 1. Schema (lib/packs/schema.ts), including the vat_rate / ratio split. + * 2. Filename equals meta.slug: the slug is the public lookup key. + * 3. Slugs and meta.order are unique. Order is the single source of truth for + * display order in both the gallery and the docs, so a duplicate makes the + * two surfaces disagree non-deterministically. + * 4. Every account exists in the BAS 2026 reference chart. This is the #1321 + * check. + * 5. The pack BALANCES when applied, using the real applyTemplate() rather + * than a reimplementation, so the validator tests what the product does. + * 6. Debit and credit are both present: a template posting only one side can + * never produce a legal verifikat. + * + * Usage: + * npx tsx scripts/validate-packs.ts # validate (CI) + * npx tsx scripts/validate-packs.ts --json # machine-readable summary + */ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { loadPacks, sortPacks, type LoadedPack } from '../lib/packs/load' +import { applyTemplate } from '../lib/bookkeeping/template-library' +import { getBASReference } from '../lib/bookkeeping/bas-reference' +import type { BookingTemplateLibraryLine } from '../types' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +/** Amounts a pack is test-applied at. Deliberately awkward so rounding shows up. */ +const PROBE_AMOUNTS = [100, 1000, 1234.56, 99.99, 3333.33] + +/** + * Pre-existing breakage in the 26 templates ported out of migration + * 20260413160000, quarantined so the format port stays lossless. + * + * These are NOT accepted as correct. They are recorded, visible, and bounded: + * a quarantined pack's findings are reported as warnings instead of failures, + * a NEW finding on any pack still fails the build, and fixing one requires + * deleting its entry here (the validator fails if a quarantined pack turns out + * to be clean, so the list can only shrink). + * + * They are not fixed in this PR on purpose. Each is a Swedish accounting + * content change to a user-facing template, which is a domain decision that + * deserves its own review rather than riding along inside a file-format change. + */ +const KNOWN_BROKEN: Record = { + loneutbetalning: + 'Does not balance: debits total 1.42x the amount (2710 @0.3 + 2920 @0.12 + 7010 @1.0) ' + + 'against a single 1.0 credit, so applying it can never produce a postable verifikat. ' + + 'Per the swedish-payroll skill the correct shape is Debit 7010 gross, Credit 2710 tax, ' + + 'Credit 1930 net, and the 2920 semesterlöneskuld line belongs to a separate accrual entry. ' + + 'Fixing it changes what the template posts: needs a domain sign-off.', + 'periodiseringsfond-avsattning-ab': + 'References account 2113, which is not in BAS 2026 and is not seeded into any company chart, ' + + 'so the template cannot resolve. BAS 2026 has 2110 Periodiseringsfonder. Remapping it is a ' + + 'domain decision (the 211x accounts are year-tagged).', + 'periodiseringsfond-aterforing-ab': + 'Same 2113 problem as periodiseringsfond-avsattning-ab.', + 'preliminar-f-skatt-ef': + 'References account 2012, which is not in BAS 2026 and is not seeded into any company chart. ' + + 'The neighbouring egna-uttag accounts that do exist are 2011/2013/2017/2018. Picking the right ' + + 'one is a domain decision.', +} + +interface Failure { + file: string + message: string +} + +function checkAccountsExist(p: LoadedPack, fail: (m: string) => void): void { + for (const line of p.pack.lines) { + if (!getBASReference(line.account)) { + fail( + `account ${line.account} ("${line.label}") is not in the BAS 2026 reference chart. ` + + `A pack may only reference standard accounts.`, + ) + } + } +} + +function checkBothSidesPresent(p: LoadedPack, fail: (m: string) => void): void { + const sides = new Set(p.pack.lines.map((l) => l.side)) + if (!sides.has('debit') || !sides.has('credit')) { + fail(`has only ${[...sides].join('/')} lines: a verifikat needs both a debit and a credit side`) + } +} + +function checkBalances(p: LoadedPack, fail: (m: string) => void): void { + for (const amount of PROBE_AMOUNTS) { + const lines = applyTemplate(p.pack.lines as unknown as BookingTemplateLibraryLine[], amount) + let debit = 0 + let credit = 0 + for (const l of lines) { + debit += l.debit_amount ? Number(l.debit_amount) : 0 + credit += l.credit_amount ? Number(l.credit_amount) : 0 + } + // Compare in öre to avoid float noise on the sum itself. + const debitOre = Math.round(debit * 100) + const creditOre = Math.round(credit * 100) + if (debitOre !== creditOre) { + fail( + `does not balance at ${amount} kr: debit ${(debitOre / 100).toFixed(2)} vs credit ` + + `${(creditOre / 100).toFixed(2)} (difference ${((debitOre - creditOre) / 100).toFixed(2)})`, + ) + return + } + if (debitOre === 0) { + fail(`applies to zero at ${amount} kr: every ratio is 0, so the template posts nothing`) + return + } + } +} + +function main(): void { + const asJson = process.argv.includes('--json') + const { packs, errors } = loadPacks(ROOT) + const failures: Failure[] = errors.map((e) => ({ file: e.file, message: e.message })) + const quarantined: Failure[] = [] + /** Quarantined slugs that produced no finding: their entry is now stale. */ + const cleanButQuarantined = new Set(Object.keys(KNOWN_BROKEN)) + + // Cross-file uniqueness. + const bySlug = new Map() + const byOrder = new Map() + + for (const p of packs) { + const isQuarantined = p.pack.meta.slug in KNOWN_BROKEN + // Structural problems always fail, even for a quarantined pack: the + // quarantine covers accounting content, not a malformed file. + const fail = (m: string) => failures.push({ file: p.file, message: m }) + // Semantic problems (BAS membership, balance) are downgraded for a + // quarantined pack and recorded instead. + const semanticFail = (m: string) => { + if (isQuarantined) { + cleanButQuarantined.delete(p.pack.meta.slug) + quarantined.push({ file: p.file, message: m }) + } else { + failures.push({ file: p.file, message: m }) + } + } + + if (p.fileSlug !== p.pack.meta.slug) { + fail(`filename is "${p.fileSlug}.yaml" but meta.slug is "${p.pack.meta.slug}": they must match`) + } + bySlug.set(p.pack.meta.slug, [...(bySlug.get(p.pack.meta.slug) ?? []), p.file]) + byOrder.set(p.pack.meta.order, [...(byOrder.get(p.pack.meta.order) ?? []), p.file]) + + checkAccountsExist(p, semanticFail) + checkBothSidesPresent(p, semanticFail) + checkBalances(p, semanticFail) + } + + for (const [slug, files] of bySlug) { + if (files.length > 1) { + failures.push({ file: files.join(', '), message: `duplicate meta.slug "${slug}"` }) + } + } + for (const [order, files] of byOrder) { + if (files.length > 1) { + failures.push({ + file: files.join(', '), + message: + `duplicate meta.order ${order}. Order is the single source of truth for display order ` + + `in both the gallery and the docs; a duplicate makes them disagree.`, + }) + } + } + + // A quarantined pack that no longer produces a finding must be released, or + // the list silently grows stale and stops meaning anything. + for (const slug of cleanButQuarantined) { + if (packs.some((p) => p.pack.meta.slug === slug)) { + failures.push({ + file: `packs/${slug}.yaml`, + message: + `is in KNOWN_BROKEN but now validates cleanly. Delete its entry from ` + + `scripts/validate-packs.ts: the quarantine list may only shrink.`, + }) + } + } + + if (asJson) { + console.log(JSON.stringify({ packs: packs.length, failures, quarantined }, null, 2)) + process.exit(failures.length ? 1 : 0) + } + + if (quarantined.length) { + console.warn(`\n! ${quarantined.length} known pre-existing problem(s), quarantined (see KNOWN_BROKEN):`) + for (const q of quarantined) console.warn(` ${q.file}\n ${q.message}`) + } + + if (failures.length) { + console.error(`\n✗ Pack validation failed: ${failures.length} problem(s)\n`) + for (const f of failures) console.error(` ${f.file}\n ${f.message}`) + console.error('\n → schema and rationale: lib/packs/schema.ts') + process.exit(1) + } + + const ordered = sortPacks(packs) + console.log( + `\n✓ Packs valid: ${packs.length} pack(s), orders ${ordered[0]?.pack.meta.order}-${ + ordered[ordered.length - 1]?.pack.meta.order + }, all balance at ${PROBE_AMOUNTS.length} probe amounts (${quarantined.length} quarantined).`, + ) +} + +main()