Files
accounted/lib/packs/__tests__/port-is-lossless.test.ts
T
MattssonandClaude Fable 5 e113e9c099 fix(vat,documents): EU reverse-charge packs feed ruta 20/21; daily reanchor cron for floating supplier-invoice underlag (#2095)
Two user reports (Anders, 2026-08-25 + 2026-08-29):

1. The seeded standardmallar "Inkop EU-varor/-tjanster, omvand moms 25%"
   booked the cost on 4010/6540, which no momsdeklaration ruta reads, so the
   fiktiv moms filled ruta 30/48 while ruta 20/21 (inkopsvarde) stayed 0;
   Skatteverket rejects that (FK004, ML 13 kap). The packs now book directly
   on the basis accounts 4515/4535 (ACCOUNT_RUTA -> ruta 20/21); the
   transaction-picker path already skips its own basis emission for basis
   debit accounts, so no double counting. Regression test pins every
   reverse-charge pack to a 44xx/45xx business debit. Prod rows update via
   the existing pack sync cron (upsert on pack_slug).

2. A kontantmetod payment verifikat stayed "Underlag saknas" although the
   invoice PDF was attached and eligible on every static condition: the
   inline anchorSupplierInvoiceDocument silently did nothing (prod case
   2026-08-28, verified in audit_log: no document_attachments update between
   the payment booking and the user's manual re-upload). The helper now
   verifies the guarded update actually matched a row instead of claiming
   success on zero rows, logs its silent bail branches, and a new daily cron
   (/api/documents/reanchor/cron) re-runs the anchor for any floating
   retained document with a posted verifikat, replacing the pattern of
   one-off repair migrations (20260727180000, 20260824150000). The sweep
   names the FK in its embed and is idempotent; locked/closed periods are
   skipped as before.


Claude-Session: https://claude.ai/code/session_01Jj6Rg1ViyFRej55gbxLVgj

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:16:07 +02:00

192 lines
8.7 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.',
},
'preliminar-f-skatt-ef': {
seededName: 'Preliminär F-skatt (EF)',
reason:
'Seeded version debited 2012 "Avräkning för skatter och avgifter". The primary-source ' +
'check in #1409 (bas.se BAS 2026 v2) shows official BAS has no 2012: the EF equity block ' +
'is 2010/2011/2013/2017/2018/2019, and 2012 is a Visma/Bokio/BL program convention. Owner ' +
'taxes paid by the firm are an eget uttag, so the template now debits 2013 Övriga egna ' +
'uttag; migration 20260810120000 retargets the seeded rows the same way.',
},
'inkop-eu-varor-omvand-moms-25': {
seededName: 'Inköp EU-varor, omvänd moms 25%',
reason:
'Seeded version booked the cost on 4010 Varuinköp, which no momsdeklaration ruta reads: ' +
'the fiktiv moms filled ruta 30/48 while ruta 20 (inköpsvärdet) stayed 0, which Skatteverket ' +
'rejects (felkod FK004, ML 13 kap kräver både underlag och moms). Now books on 4515 Inköp ' +
'varor från annat EU-land 25%, the ACCOUNT_RUTA basis account for ruta 20. User report ' +
'(Anders, 2026-08-25).',
},
'inkop-eu-tjanster-omvand-moms-25': {
seededName: 'Inköp EU-tjänster, omvänd moms 25%',
reason:
'Same ruta gap as inkop-eu-varor-omvand-moms-25: cost sat on 6540 IT-tjänster, so ruta 21 ' +
'stayed 0. Now books on 4535 Inköp tjänster från annat EU-land 25% (ruta 21).',
},
'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))
})
})