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>
168 lines
7.4 KiB
TypeScript
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))
|
|
})
|
|
})
|