fix(packs): repair the four broken system templates the validator found (#1388)
Phase 2a quarantined four defects rather than guessing at Swedish accounting. Each is now resolved against a domain source. KNOWN_BROKEN is empty. Löneutbetalning could never post. It debited 2710 @0.3 + 2920 @0.12 + 7010 @1.0 against a single 1.0 credit, totalling 1.42x the amount, so the balance trigger would reject every entry built from it. Rebuilt per the swedish-payroll skill: Debit 7010 gross, Credit 2710 tax, Credit 1930 net. The 2920 semesterlöneskuld line is gone because vacation accrual is its own verifikat (7290/2920), and a legal_note now says the 30% split is schablon and must be adjusted to the actual skatteavdrag. Periodiseringsfond avsättning/återföring referenced account 2113. Per swedish-year-end-closing the year-tagged block is 2120-2129 (2126 = tax year 2026), so 2113 was the fund for tax year 2013: long since reversed and absent from BAS 2026. Both now use 2110 Periodiseringsfonder, which does not rot annually, with a legal_note pointing at the year-tagged accounts for a company that tracks funds per year. Preliminär F-skatt (EF) turned out to be RIGHT, and the reference was wrong. Account 2012 "Avräkning för skatter och avgifter" was simply missing from lib/bookkeeping/bas-data (the file jumps 2011 -> 2013), while the swedish-year-end-closing skill uses it in two places as an enskild firma equity sub-account. That is not cosmetic: account-backfill.ts only seeds accounts present in BAS_REFERENCE, so any entry touching 2012 failed with AccountsNotInChartError. Added it with the equity SRU code its siblings share, and a description separating it from 1630, which carries a confusingly similar name on the asset side. The port test now distinguishes deliberate divergence from accidental drift: a pack not listed in INTENTIONAL_DIVERGENCES must still match the seeded JSONB exactly, and a listed pack must actually differ, so neither an unnoticed edit nor a stale entry can survive. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Opus 5
parent
8299ee9fb4
commit
5b9605d8e9
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
|
||||
/**
|
||||
* The enskild firma equity block must be complete.
|
||||
*
|
||||
* 2012 was missing from the reference, which is not cosmetic:
|
||||
* `lib/bookkeeping/account-backfill.ts` only seeds accounts that appear in
|
||||
* BAS_REFERENCE, so an account absent from it can never be added to a company's
|
||||
* chart on demand. Any entry touching 2012 failed with AccountsNotInChartError,
|
||||
* which is exactly what the "Preliminär F-skatt (EF)" template did.
|
||||
*
|
||||
* The gap was found by the pack validator asserting that every account a
|
||||
* template references exists in BAS 2026.
|
||||
*/
|
||||
describe('enskild firma equity accounts (20xx)', () => {
|
||||
it('has no hole in the 2010-2013 run', () => {
|
||||
for (const account of ['2010', '2011', '2012', '2013']) {
|
||||
expect(getBASReference(account), `${account} missing from BAS reference`).toBeDefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('2012 is the owner-tax equity account, distinct from the 1630 skattekonto asset', () => {
|
||||
const equity = getBASReference('2012')
|
||||
const skattekonto = getBASReference('1630')
|
||||
|
||||
expect(equity?.account_type).toBe('equity')
|
||||
expect(equity?.normal_balance).toBe('debit')
|
||||
// Both are called "avräkning", which is precisely why they get confused.
|
||||
expect(skattekonto?.account_type).toBe('asset')
|
||||
expect(equity?.account_number).not.toBe(skattekonto?.account_number)
|
||||
})
|
||||
|
||||
it('shares the equity SRU code with its siblings, since they all net into 2010', () => {
|
||||
const siblings = ['2011', '2012', '2013', '2018'].map((a) => getBASReference(a)?.sru_code)
|
||||
expect(new Set(siblings).size).toBe(1)
|
||||
expect(siblings[0]).toBe(getBASReference('2010')?.sru_code)
|
||||
})
|
||||
})
|
||||
|
||||
describe('periodiseringsfond accounts', () => {
|
||||
it('offers the generic account plus the year-tagged block', () => {
|
||||
expect(getBASReference('2110')?.account_name).toContain('Periodiseringsfond')
|
||||
// 2120-2129 are year-tagged (2126 = tax year 2026).
|
||||
expect(getBASReference('2126')?.account_name).toContain('2026')
|
||||
})
|
||||
|
||||
it('does not carry 2113: the pre-2020 year-tagged funds are long reversed', () => {
|
||||
// The seeded "Periodiseringsfond" templates referenced 2113 (tax year 2013),
|
||||
// so they could never resolve. Pinning this prevents a well-meaning
|
||||
// "fix" that re-adds an obsolete account instead of correcting the template.
|
||||
expect(getBASReference('2113')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,21 @@ export const CLASS_2_ACCOUNTS: BASReferenceAccount[] = [
|
||||
sru_code: '7221',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '2012',
|
||||
account_name: 'Avräkning för skatter och avgifter',
|
||||
account_class: 2,
|
||||
account_group: '20',
|
||||
account_type: 'equity',
|
||||
normal_balance: 'debit',
|
||||
description:
|
||||
'Enskild firma: ägarens egna skatter och avgifter (t.ex. preliminär F-skatt) som betalas ' +
|
||||
'av företaget. Ett eget uttag, inte en företagskostnad. Nollas mot 2010 Eget kapital vid ' +
|
||||
'nytt räkenskapsår. Ska inte förväxlas med 1630 Avräkning för skatter och avgifter ' +
|
||||
'(skattekonto), som är tillgångssidans saldo mot Skatteverket.',
|
||||
sru_code: '7221',
|
||||
k2_excluded: false,
|
||||
},
|
||||
{
|
||||
account_number: '2013',
|
||||
account_name: 'Övriga egna uttag',
|
||||
|
||||
@@ -4,17 +4,39 @@ import { loadPacks, packToLibraryRow, sortPacks } from '@/lib/packs/load'
|
||||
import seeded from './fixtures/seeded-system-templates.json'
|
||||
|
||||
/**
|
||||
* The port out of migration 20260413160000 must be LOSSLESS.
|
||||
* 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.
|
||||
* If `packs/*.yaml` reproduces it byte for byte, then swapping the seeded rows
|
||||
* for the pack files (phase 2b) is a no-op for every existing company.
|
||||
* 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.
|
||||
*
|
||||
* This is the test that makes the format change safe to ship. If it fails, the
|
||||
* catalogue has drifted from production and the loader must not be switched on.
|
||||
* 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.
|
||||
*/
|
||||
const INTENTIONAL_DIVERGENCES: Record<string, string> = {
|
||||
loneutbetalning:
|
||||
'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':
|
||||
'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': 'Same 2113 fix as periodiseringsfond-avsattning-ab.',
|
||||
}
|
||||
|
||||
interface SeededTemplate {
|
||||
name: string
|
||||
description: string
|
||||
@@ -52,14 +74,39 @@ describe('pack catalogue is a lossless port of the seeded system templates', ()
|
||||
expect(packs.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('reproduces exactly the templates the migration seeds', () => {
|
||||
const fromPacks = packs.map((p) => canonical(packToLibraryRow(p.pack))).sort()
|
||||
const fromDb = (seeded as SeededTemplate[]).map(canonical).sort()
|
||||
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(
|
||||
packs
|
||||
.filter((p) => p.pack.meta.slug in INTENTIONAL_DIVERGENCES)
|
||||
.map((p) => p.pack.meta.name),
|
||||
)
|
||||
|
||||
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 of Object.keys(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(pack!.pack.meta.name)
|
||||
expect(seededForm, `no seeded template named "${pack!.pack.meta.name}"`).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)
|
||||
|
||||
+13
-12
@@ -7,25 +7,26 @@ meta:
|
||||
category: salary
|
||||
entity_type: aktiebolag
|
||||
description: >-
|
||||
Utbetalning av nettolön till anställd.
|
||||
Utbetalning av lön till anställd. Ange bruttolönen som belopp.
|
||||
legal_note: >-
|
||||
Bruttolön debiteras 7010, avdragen personalskatt krediteras 2710 och
|
||||
nettolönen krediteras 1930. Skatteandelen här är schablonmässiga 30 %:
|
||||
justera raderna efter det faktiska skatteavdraget enligt skattetabell.
|
||||
Arbetsgivaravgifter (7510/2730) och semesterlöneskuld (7290/2920) bokas
|
||||
som egna verifikat, inte i den här mallen.
|
||||
lines:
|
||||
- account: '2710'
|
||||
label: 'Personalskatt'
|
||||
side: debit
|
||||
type: business
|
||||
ratio: 0.3
|
||||
- account: '2920'
|
||||
label: 'Upplupna semesterlöner'
|
||||
side: debit
|
||||
type: business
|
||||
ratio: 0.12
|
||||
- account: '7010'
|
||||
label: 'Löner'
|
||||
side: debit
|
||||
type: business
|
||||
ratio: 1.0
|
||||
- account: '2710'
|
||||
label: 'Personalskatt'
|
||||
side: credit
|
||||
type: business
|
||||
ratio: 0.3
|
||||
- account: '1930'
|
||||
label: 'Företagskonto'
|
||||
side: credit
|
||||
type: settlement
|
||||
ratio: 1.0
|
||||
ratio: 0.7
|
||||
|
||||
@@ -8,9 +8,12 @@ meta:
|
||||
entity_type: aktiebolag
|
||||
description: >-
|
||||
Återföring av periodiseringsfond (senast efter 6 år).
|
||||
legal_note: >-
|
||||
Återföring sker från den äldsta fonden först. Bokas på 2110 Periodiseringsfonder,
|
||||
eller på det årsmärkta kontot (2120-2129) om fonderna följs per år.
|
||||
lines:
|
||||
- account: '2113'
|
||||
label: 'Periodiseringsfond'
|
||||
- account: '2110'
|
||||
label: 'Periodiseringsfonder'
|
||||
side: debit
|
||||
type: business
|
||||
ratio: 1.0
|
||||
|
||||
@@ -8,14 +8,18 @@ meta:
|
||||
entity_type: aktiebolag
|
||||
description: >-
|
||||
Avsättning till periodiseringsfond vid bokslut. Max 25% av överskottet.
|
||||
legal_note: >-
|
||||
Bokas på 2110 Periodiseringsfonder. Ett bolag som följer varje års fond separat bokar
|
||||
i stället på det årsmärkta kontot (2120-2129, där 2126 = beskattningsår 2026).
|
||||
Max 25 % av överskottet för aktiebolag. Återförs senast efter sex år.
|
||||
lines:
|
||||
- account: '8811'
|
||||
label: 'Avsättning periodiseringsfond'
|
||||
side: debit
|
||||
type: business
|
||||
ratio: 1.0
|
||||
- account: '2113'
|
||||
label: 'Periodiseringsfond'
|
||||
- account: '2110'
|
||||
label: 'Periodiseringsfonder'
|
||||
side: credit
|
||||
type: business
|
||||
ratio: 1.0
|
||||
|
||||
@@ -51,22 +51,11 @@ const PROBE_AMOUNTS = [100, 1000, 1234.56, 99.99, 3333.33]
|
||||
* deserves its own review rather than riding along inside a file-format change.
|
||||
*/
|
||||
const KNOWN_BROKEN: Record<string, string> = {
|
||||
loneutbetalning:
|
||||
'Does not balance: debits total 1.42x the amount (2710 @0.3 + 2920 @0.12 + 7010 @1.0) ' +
|
||||
'against a single 1.0 credit, so applying it can never produce a postable verifikat. ' +
|
||||
'Per the swedish-payroll skill the correct shape is Debit 7010 gross, Credit 2710 tax, ' +
|
||||
'Credit 1930 net, and the 2920 semesterlöneskuld line belongs to a separate accrual entry. ' +
|
||||
'Fixing it changes what the template posts: needs a domain sign-off.',
|
||||
'periodiseringsfond-avsattning-ab':
|
||||
'References account 2113, which is not in BAS 2026 and is not seeded into any company chart, ' +
|
||||
'so the template cannot resolve. BAS 2026 has 2110 Periodiseringsfonder. Remapping it is a ' +
|
||||
'domain decision (the 211x accounts are year-tagged).',
|
||||
'periodiseringsfond-aterforing-ab':
|
||||
'Same 2113 problem as periodiseringsfond-avsattning-ab.',
|
||||
'preliminar-f-skatt-ef':
|
||||
'References account 2012, which is not in BAS 2026 and is not seeded into any company chart. ' +
|
||||
'The neighbouring egna-uttag accounts that do exist are 2011/2013/2017/2018. Picking the right ' +
|
||||
'one is a domain decision.',
|
||||
// Empty, and that is the point: the four templates ported out of migration
|
||||
// 20260413160000 with real defects (an unbalanced salary template, and
|
||||
// accounts that could not resolve) were fixed rather than accepted. The list
|
||||
// may only shrink; the validator fails if an entry here validates cleanly, so
|
||||
// a stale quarantine cannot linger.
|
||||
}
|
||||
|
||||
interface Failure {
|
||||
|
||||
Reference in New Issue
Block a user