Files
accounted/lib/packs/__tests__/port-is-lossless.test.ts
T
a3d8fe733c fix(packs): representation template named itself avdragsgill while booking 6072 (#1396)
Raised by the Swedish compliance bot on #1395. The template called itself
"Representation (avdragsgill, 25% moms)" and labelled its cost line
"Representation avdragsgill", but booked to 6072, which BAS 2026 defines as
Representation, EJ avdragsgill. The swedish-vat skill lists that exact
confusion under Representation errors: "Not separating avdragsgill/ej
avdragsgill on correct accounts (6071 vs 6072)".

The account was right and the words were wrong. Meal representation stopped
being income-tax deductible in 2017, so 6072 is where the cost belongs; only
enklare fortaring (max 60 kr per person) is still deductible, on 6071. Renamed
to "Representation, maltid (ej avdragsgill)" and relabelled to match.

VAT moves from 25% to the 12% restaurang-och-catering rate our own static
representation_external template in booking-templates.ts already used, so the
two template systems stop contradicting each other, with the paired net ratio
1/1.12. A legal_note now carries the 300 kr per person VAT cap, which this
format cannot compute because a template sees only a total and never a
participant count, plus the reminder that the rate follows the underlag rather
than the template.

Correcting the record on one point: the seeded ratio 0.8 was NOT a phantom 80%
deductibility rule, as the bot suggested and as I first repeated. 1/1.25 = 0.8,
so it was the net-of-VAT fraction pairing with the 25% rate, and the arithmetic
balanced. Only the naming and the missing cap were wrong. The ratio is now
commented in the file so the next reader does not make the same misreading.

Slug deliberately unchanged though it still reads "avdragsgill": a slug is an
identifier, not a label (packs/README.md). Renaming it would retire this
template and insert a new one, breaking every company's booking_template_usage
history.

The drift guard caught the rename, which exposed that the test joined packs to
seeded rows by NAME. That is the same fragility that made pack_slug the sync's
key, so INTENTIONAL_DIVERGENCES now records each entry's seededName explicitly.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 22:32:12 +02:00

168 lines
7.4 KiB
TypeScript

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 pack catalogue must not drift from the seeded templates by ACCIDENT.
*
* 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.
* The port was lossless when it landed, which is what makes phase 2b (swapping
* the seeded rows for the loader) a no-op for existing companies.
*
* Templates have since been fixed on purpose. Each deliberate change is listed
* in INTENTIONAL_DIVERGENCES with its reason, and the list is policed from both
* sides: a pack NOT listed must still match the seed exactly, and a pack that
* IS listed must actually differ. So an unnoticed edit fails the build, and a
* stale entry cannot linger after a template is reverted.
*/
/**
* Packs that deliberately no longer match what migration 20260413160000 seeds.
* Every entry is a defect found by the pack validator and fixed with a domain
* source cited in the commit.
*/
interface Divergence {
/**
* The name this template carried in the seed. Needed because a fix may rename
* it, and the seeded fixture predates pack_slug so name is the only join key
* available there. Exactly the fragility that made pack_slug the sync's key.
*/
seededName: string
reason: string
}
const INTENTIONAL_DIVERGENCES: Record<string, Divergence> = {
loneutbetalning: {
seededName: 'Löneutbetalning',
reason:
'Seeded version debited 2710 @0.3 + 2920 @0.12 + 7010 @1.0 against a single 1.0 credit, ' +
'so it totalled 1.42x the amount and could never post. Rebuilt per the swedish-payroll ' +
'skill: Debit 7010 gross, Credit 2710 tax, Credit 1930 net. The 2920 semesterlöneskuld ' +
'line moved out because vacation accrual is its own verifikat (7290/2920).',
},
'periodiseringsfond-avsattning-ab': {
seededName: 'Periodiseringsfond avsättning (AB)',
reason:
'Seeded version used account 2113, i.e. the fund for tax year 2013 under the pre-2020 ' +
'year-tagged block. Those funds had to be reversed years ago and the account is not in ' +
'BAS 2026, so the template could not resolve. Now uses 2110 Periodiseringsfonder, which ' +
'does not rot annually; the legal_note points at the year-tagged 2120-2129 alternative.',
},
'periodiseringsfond-aterforing-ab': {
seededName: 'Periodiseringsfond återföring (AB)',
reason: 'Same 2113 fix as periodiseringsfond-avsattning-ab.',
},
'representation-avdragsgill-25-moms': {
seededName: 'Representation (avdragsgill, 25% moms)',
reason:
'Seeded version booked to 6072 (Representation, EJ avdragsgill) while naming and labelling itself "avdragsgill", which is the exact confusion the swedish-vat skill lists under Representation errors. The account was right and the words were wrong: meal representation stopped being income-tax deductible in 2017. Renamed to match, VAT moved from 25% to the 12% restaurang rate our own static representation_external template already used (with the net ratio 1/1.12 that pairs with it), and a legal_note added for the 300 kr per person VAT cap, which this format cannot compute because it has no participant count. Slug deliberately unchanged: it is an identifier, not a label.',
},
}
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 the seeded templates exactly, except where we deliberately fixed one', () => {
const unchanged = packs.filter((p) => !(p.pack.meta.slug in INTENTIONAL_DIVERGENCES))
const changedNames = new Set(Object.values(INTENTIONAL_DIVERGENCES).map((d) => d.seededName))
const fromPacks = unchanged.map((p) => canonical(packToLibraryRow(p.pack))).sort()
const fromDb = (seeded as SeededTemplate[])
.filter((t) => !changedNames.has(t.name))
.map(canonical)
.sort()
expect(fromPacks).toHaveLength(fromDb.length)
expect(fromPacks).toEqual(fromDb)
})
it('every declared divergence actually diverges, so the list cannot go stale', () => {
const byName = new Map((seeded as SeededTemplate[]).map((t) => [t.name, canonical(t)]))
for (const [slug, divergence] of Object.entries(INTENTIONAL_DIVERGENCES)) {
const pack = packs.find((p) => p.pack.meta.slug === slug)
expect(pack, `${slug} is in INTENTIONAL_DIVERGENCES but no such pack exists`).toBeDefined()
const seededForm = byName.get(divergence.seededName)
expect(seededForm, `no seeded template named "${divergence.seededName}"`).toBeDefined()
expect(
canonical(packToLibraryRow(pack!.pack)),
`${slug} is listed as diverging but matches the seed: remove its entry`,
).not.toBe(seededForm)
}
})
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))
})
})