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:
@@ -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" \
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Generated
+9
-2
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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<string, string> = {
|
||||
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<string, string[]>()
|
||||
const byOrder = new Map<number, string[]>()
|
||||
|
||||
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()
|
||||
Reference in New Issue
Block a user