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>
122 lines
4.0 KiB
TypeScript
122 lines
4.0 KiB
TypeScript
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)
|
|
}
|
|
})
|
|
})
|