df34cae9bf
* 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>
215 lines
8.9 KiB
TypeScript
215 lines
8.9 KiB
TypeScript
#!/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()
|