From e85eac317ce01c2fb97f6109dedb43a4da152dee Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:44:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(arsredovisning):=20manual=20override=20for?= =?UTF-8?q?=20medelantal=20anst=C3=A4llda=20(Not=202)=20(#2420)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(arsredovisning): manual override for medelantal anstallda (Not 2) Why the problem occurred: the ÅRL 5:20 § note was derived only from the employees table, and most aktiebolag that book salary never create a Löner employee record (hand-booked salary, SIE import, migrated history). In prod 148 of 195 aktiebolag with posted 70xx-73xx lines have no employees rows, so their note reads "inga anställda". The reporting company had one row created the same day with a start date halfway through a July-June year: 181/365 = 0.5 FTE rounds to 0. What was removed or simplified: nothing removed. One resolver (resolveMedelantalAnstallda: override, else FTE average) now feeds the K2 note, the K3 note and the iXBRL fact, so no reader can pick a different number. The override sits on arsredovisning_narratives next to the other ÅRL 5 kap. disclosures and rides the existing narrative GET/POST route, service and page save. Why this and not the proposed one: the request asked support to "enable override of Not 2" as free text. A whole number keeps the statutory sentence intact and the iXBRL MedelantaletAnstallda fact taggable; free text would allow a non-compliant note. Rounding 0.5 up globally was rejected (changes every company's note silently, does nothing for companies with no employees rows), as was backdating the hire date (fixes one company, misstates the fact). The iXBRL input also reads the previous period's override so the jämförelseår column shows what last year's document showed. Migration 20260908130127 is additive (nullable INTEGER with a CHECK) and is applied and tracked on staging. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GxiKQke7sY6mrAK9KX4hbD * fix(arsredovisning): size-threshold metrics use the medelantal override too Skeptic refutation on 442fa1bc5: reportMetrics in model.ts still read the FTE average from the employees table, so the ÅRL 1:3 § större- företag test and the K2 relief thresholds could disagree with the figure Not 2 and the iXBRL fact disclose. A SIE-migrated aktiebolag with no employees rows and an override of 60 both years, balansomslutning over 40 MSEK, would have validated as K2-eligible while its own document said 60 employees. Fix: the metrics resolve the employee figure the same way the note does (current period override from report.disclosures, previous period via getMedelantalOverride on that period's narrative row). previous_period on ArsredovisningData now carries the period id so the lookup needs no extra fiscal_periods read. The iXBRL employees-error fallback keeps the previous period's override instead of blanking the jämförelseår. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GxiKQke7sY6mrAK9KX4hbD --------- Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 1 + .../year-end/arsredovisning/page.tsx | 43 ++++++++++ .../narrative/__tests__/route.test.ts | 28 +++++++ .../[id]/arsredovisning/narrative/route.ts | 5 ++ .../__tests__/arsredovisning-k3-pdf.test.ts | 2 + .../__tests__/arsredovisning-k3.test.ts | 45 +++++++++-- .../__tests__/arsredovisning-pdf-sign.test.ts | 1 + .../__tests__/model-metrics.test.ts | 80 +++++++++++++++++++ lib/bokslut/arsredovisning/build-data.ts | 16 +++- lib/bokslut/arsredovisning/model.ts | 31 +++++-- .../arsredovisning/narrative-service.ts | 31 ++++++- lib/bokslut/arsredovisning/types.ts | 5 ++ lib/bokslut/ixbrl/build-input.ts | 42 +++++++--- lib/salary/__tests__/medelantal.test.ts | 38 ++++++++- lib/salary/medelantal.ts | 18 +++++ ...127_arsredovisning_medelantal_override.sql | 26 ++++++ 16 files changed, 382 insertions(+), 30 deletions(-) create mode 100644 lib/bokslut/arsredovisning/__tests__/model-metrics.test.ts create mode 100644 supabase/migrations/20260908130127_arsredovisning_medelantal_override.sql diff --git a/DECISIONS.md b/DECISIONS.md index e07f6788..0ca44a34 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1662,4 +1662,5 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-08] #2391 skeptic pass: orgNumberKey only strips hyphens and spaces and only unprefixes 12-digit values behind 16/18/19/20. Reason: 26 prod supplier rows hold a VAT number (orgnr + 01, prefixes 55/52/87) in org_number, and 'last 10 of any 12 digits' would have rewritten them to another company's identity; letters stay because BE0123456789 is not the Swedish 0123456789. The matcher scans live suppliers only (archived_at IS NULL), the list and v1 search compare without separators, the CSV import and the provider migration orchestrator key and write through the same rule. [2026-09-08] correctEntry re-points the original entry's transaction_voucher_links rows to the corrected entry (lib/core/bookkeeping/storno-service.ts relinkTransactionsToEntry) instead of deleting them as issue #2364 proposed. Why: for a samlingsverifikat (bulk-book N>1) the junction is the row's only anchor, so deleting it would push rows the corrected verifikat still explains back into Att bokföra; the pointer column already follows the correction and the junction now follows it the same way, so every reader (is_transaction_booked, fetchJunctionLinkedTxIds, the bulk_book RPC) sees one live anchor. Rejected: a relink_entry_anchors RPC moving pointer and junction atomically (a migration plus pg test for a path that is already best-effort across five other statements; revisit if a partial failure ever shows up in the surfaced transactionRelinkError). Prod repair (planned, runs after merge on the founder's go; completion gets its own dated entry): the 7 stale links (3 companies) all sit on rows whose pointer names a posted entry (4 on a correction chain, 3 from a June 2026 samlingsverifikat storno that predates the junction cleanup and were re-booked 1:1); they will be re-pointed to the pointer's entry, the same rule the fix applies, rather than deleted. [2026-09-08] delete_last_voucher returns a correction's bank anchors (transactions.journal_entry_id and transaction_voucher_links rows) to correction_of_id before the row is deleted (migration 20260908095907). Why: the #2364 skeptic showed that once the junction follows the correction, the two-step undo (delete the correction, then the storno) cascaded the links away and restored an original that explains bank rows nobody points at, so the rows surfaced as bookable again; before, the links had stayed on the original by accident. Chosen over releasing the rows (the restored original would still explain them, same trap) and over a TS pre-step in the DELETE route (not atomic with the RPC's own guards: a refused delete would leave anchors on a reversed entry). A duplicate of a link the original already holds is dropped, not re-pointed (UNIQUE (transaction_id, journal_entry_id)). +[2026-09-08] Medelantal anställda (Not 2, ÅRL 5:20 §) gets a whole-number override on arsredovisning_narratives (migration 20260908130127) instead of the free-text note override the support request asked for. Why: the number keeps the statutory sentence and the iXBRL MedelantaletAnstallda fact correct; free text would let a non-compliant note through and could not be tagged. One resolver (lib/salary/medelantal.ts resolveMedelantalAnstallda: override, else FTE average over employees) feeds the K2 and K3 note builders and the iXBRL input, which also reads the previous period's override so the jämförelseår column shows the same figure the previous year's document did. Rejected: rounding 0.5 up globally (silently changes every company's note and does nothing for the 148 of 195 aktiebolag with salary but no employees rows); asking the user to backdate employment_start (fixes one company, misstates the hire date). [2026-09-08] Issue #2413 BAS 2026 kontogrupp 12: kept 1249/1259/1269 in the catalog renamed after their free heads and dropped only 1241/1242/1251/1261, instead of removing all seven retired sub-accounts and moving the asset module's vehicle/computer defaults to BAS 2026 (1226/1224 on 1229): the asset module's DEFAULT_ACCOUNTS_BY_CATEGORY still books vehicles on 1240/1249 and computers on 1250/1259 (31 live assets in prod, guard test requires the triple in BAS_REFERENCE), so dropping the contra accounts would have forced a depreciation-default change into a label fix; that change is the founder's call and lives in #2414. The prod backfill renames only the exact catalog literal next to a free-labelled head, so old-BAS imports (1240 Bilar + 1249 Ack. avskr. bilar) and user renames stay untouched. diff --git a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx index 7bb07fab..02922404 100644 --- a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx @@ -79,6 +79,9 @@ export default function ArsredovisningPage() { const [savedParentOrgNr, setSavedParentOrgNr] = useState('') const [parentCity, setParentCity] = useState('') const [savedParentCity, setSavedParentCity] = useState('') + // ÅRL 5:20 §: manual medelantal anställda. Empty = computed from Löner. + const [medelantalOverride, setMedelantalOverride] = useState('') + const [savedMedelantalOverride, setSavedMedelantalOverride] = useState('') const [longTermDebtConfirmed, setLongTermDebtConfirmed] = useState(false) const [savedLongTermDebtConfirmed, setSavedLongTermDebtConfirmed] = useState(false) const [securitiesPledgedConfirmed, setSecuritiesPledgedConfirmed] = useState(false) @@ -167,6 +170,10 @@ export default function ArsredovisningPage() { setSavedParentOrgNr(d.disclosures.parent_company_org_number ?? '') setParentCity(d.disclosures.parent_company_city ?? '') setSavedParentCity(d.disclosures.parent_company_city ?? '') + const medel = d.disclosures.medelantal_anstallda_override + const medelStr = medel != null ? String(medel) : '' + setMedelantalOverride(medelStr) + setSavedMedelantalOverride(medelStr) setLongTermDebtConfirmed(d.disclosures.confirmations.long_term_debt_over_five_years) setSavedLongTermDebtConfirmed(d.disclosures.confirmations.long_term_debt_over_five_years) setSecuritiesPledgedConfirmed(d.disclosures.confirmations.securities_pledged) @@ -202,6 +209,7 @@ export default function ArsredovisningPage() { parentName !== savedParentName || parentOrgNr !== savedParentOrgNr || parentCity !== savedParentCity || + medelantalOverride !== savedMedelantalOverride || longTermDebtConfirmed !== savedLongTermDebtConfirmed || securitiesPledgedConfirmed !== savedSecuritiesPledgedConfirmed || contingentLiabilitiesConfirmed !== savedContingentLiabilitiesConfirmed || @@ -226,6 +234,21 @@ export default function ArsredovisningPage() { } longTermDebtParsed = parsed } + // Medelantal anställda: empty clears the override (note falls back to + // the FTE average from Löner); otherwise a whole number of employees. + let medelantalParsed: number | null = null + if (medelantalOverride.trim()) { + const parsed = Number(medelantalOverride.trim()) + if (!Number.isInteger(parsed) || parsed < 0) { + toast({ + title: 'Ogiltigt antal', + description: 'Medelantal anställda måste vara ett heltal, noll eller större (eller lämnas tomt).', + variant: 'destructive', + }) + return + } + medelantalParsed = parsed + } let proposedDividendParsed = 0 if (proposedDividend.trim()) { const parsed = Number(proposedDividend.replace(/\s/g, '').replace(',', '.')) @@ -263,6 +286,7 @@ export default function ArsredovisningPage() { parent_company_name: parentName.trim() || null, parent_company_org_number: parentOrgNr.trim() || null, parent_company_city: parentCity.trim() || null, + medelantal_anstallda_override: medelantalParsed, long_term_debt_over_five_years_confirmed: longTermDebtConfirmed, securities_pledged_confirmed: securitiesPledgedConfirmed, contingent_liabilities_confirmed: contingentLiabilitiesConfirmed, @@ -292,6 +316,7 @@ export default function ArsredovisningPage() { setSavedParentName(parentName) setSavedParentOrgNr(parentOrgNr) setSavedParentCity(parentCity) + setSavedMedelantalOverride(medelantalOverride) setSavedLongTermDebtConfirmed(longTermDebtConfirmed) setSavedSecuritiesPledgedConfirmed(securitiesPledgedConfirmed) setSavedContingentLiabilitiesConfirmed(contingentLiabilitiesConfirmed) @@ -323,6 +348,7 @@ export default function ArsredovisningPage() { parentName, parentOrgNr, parentCity, + medelantalOverride, longTermDebtConfirmed, securitiesPledgedConfirmed, contingentLiabilitiesConfirmed, @@ -719,6 +745,23 @@ export default function ArsredovisningPage() { fält visas som "Inga." i PDF:en.

+
+ + setMedelantalOverride(e.target.value)} + placeholder="Beräknas från Löner" + className="max-w-[220px] tabular-nums" + /> +

+ ÅRL 5:20 §. Lämna tomt för att använda antalet anställda från Löner. + Ägare som tar ut lön räknas som anställd; fyll i om lönen bokförts utan + anställd i Löner. +

+