diff --git a/lib/bookkeeping/__tests__/bas-ef-equity-accounts.test.ts b/lib/bookkeeping/__tests__/bas-ef-equity-accounts.test.ts new file mode 100644 index 00000000..2c5f1a2a --- /dev/null +++ b/lib/bookkeeping/__tests__/bas-ef-equity-accounts.test.ts @@ -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() + }) +}) diff --git a/lib/bookkeeping/bas-data/class-2-equity-liabilities.ts b/lib/bookkeeping/bas-data/class-2-equity-liabilities.ts index 6aee5103..67698048 100644 --- a/lib/bookkeeping/bas-data/class-2-equity-liabilities.ts +++ b/lib/bookkeeping/bas-data/class-2-equity-liabilities.ts @@ -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', diff --git a/lib/packs/__tests__/port-is-lossless.test.ts b/lib/packs/__tests__/port-is-lossless.test.ts index 357f8e21..0265e103 100644 --- a/lib/packs/__tests__/port-is-lossless.test.ts +++ b/lib/packs/__tests__/port-is-lossless.test.ts @@ -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 = { + 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) diff --git a/packs/loneutbetalning.yaml b/packs/loneutbetalning.yaml index d3d46605..e6ea0e6d 100644 --- a/packs/loneutbetalning.yaml +++ b/packs/loneutbetalning.yaml @@ -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 diff --git a/packs/periodiseringsfond-aterforing-ab.yaml b/packs/periodiseringsfond-aterforing-ab.yaml index e0ad7196..86ebed94 100644 --- a/packs/periodiseringsfond-aterforing-ab.yaml +++ b/packs/periodiseringsfond-aterforing-ab.yaml @@ -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 diff --git a/packs/periodiseringsfond-avsattning-ab.yaml b/packs/periodiseringsfond-avsattning-ab.yaml index 4a353b77..ad57bce1 100644 --- a/packs/periodiseringsfond-avsattning-ab.yaml +++ b/packs/periodiseringsfond-avsattning-ab.yaml @@ -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 diff --git a/scripts/validate-packs.ts b/scripts/validate-packs.ts index 0b68164b..0dc9d643 100644 --- a/scripts/validate-packs.ts +++ b/scripts/validate-packs.ts @@ -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 = { - 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 {