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>
140 lines
4.1 KiB
TypeScript
140 lines
4.1 KiB
TypeScript
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
|
|
}),
|
|
}
|
|
}
|