From 3dce94d39ea1a5760257c6f3996eb50b51c5b445 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 25 Aug 2026 10:08:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(bokslut):=20=C3=A5rsredovisning=20for=20a?= =?UTF-8?q?=20klarmarkerad=20year,=20bokslutsbilagor=20under=20Bokslut,=20?= =?UTF-8?q?bilagor=20reminder=20(#1875)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bokslut): årsredovisning for a klarmarkerad year, bokslutsbilagor under Bokslut in the menu, bilagor reminder on Kontroll A year closed in a previous system (Klarmarkera perioden) has no closing verifikat in these books by definition, so the statutory pre-closing guard in the trial balance has nothing to strip; it now lets that case through instead of failing the whole Årsredovisning page with a 500. Bokslutsbilagor now sits in the Bokslut fold of the menu (a byrå looks for the bilagor inside the bokslut module, not under Rapporter), and the wizard's Kontroll step says how many balance accounts are still unsigned per balansdagen with a link to the pärm. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(nav): admit the bokslutsbilagor label key Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * feat(reconciliation): fold the manual accounts in the rail, with count and unsigned hint Twenty-odd balance accounts pushed the bank rows out of view on a migrated company. The group opens when a manual account is selected and otherwise remembers the last choice per browser. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + components/dashboard/DashboardNav.tsx | 3 + .../reconciliation/ReconciliationRail.tsx | 61 ++++++++++++++++--- .../__tests__/readiness-aggregator.test.ts | 51 ++++++++++++++++ lib/bokslut/readiness-aggregator.ts | 27 ++++++++ lib/reports/__tests__/trial-balance.test.ts | 27 ++++++++ lib/reports/trial-balance.ts | 8 ++- messages/en.json | 2 + messages/sv.json | 2 + 9 files changed, 172 insertions(+), 10 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 79b5d3a0..3b829c5d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1198,3 +1198,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-24] Reconciliation underlag (Reko bilagor, PR 2) is its own table (account_reconciliation_attachments) scoped by (company, account_key, through_date), not extra columns on document_attachments: that table's link is a verifikat and its WORM version chain is about digitized receipts, while a bilaga belongs to a balansdag and may be attached before the sign-off exists. Files stay in the `documents` bucket under `documents//reconciliation/...` so the bucket's company-scoped RLS applies unchanged; removal is a stamp (never a delete, BFL 7 kap.) enforced by trigger; the full archive copies the files into `bilagor/` with a hash manifest. No v1 API endpoints in this PR on purpose: the concurrent reconciliation-residual work edits the v1 route loader, spec snapshot and scopes, and files cannot be uploaded by an agent anyway. [2026-08-24] Bokslut checklist (Reko bilagor, PR 3) keeps the item catalogue in code and only the per-period state in bokslut_checklist_items: steps the system can judge (drafts, voucher gaps, trial balance, sign-offs through balansdagen, reskontra tie-outs) are computed live every time and a stored row only overrides them, so the checklist never claims a state the ledger contradicts; manual steps (inventering, osäkra fordringar, dispositioner) are what the konsult ticks. Mutable on purpose (a late verifikat reopens a step), no DELETE policy. The missing-fiscal-year check is a pure helper reused by the readiness warnings and the SIE import result; the non-adjacent previous_period_id fix is #1849 and is not duplicated here. [2026-08-24] Bokslutsbilagor pärm (Reko bilagor, PR 4) is generated from the sign-off rows, the trial balance through balansdagen and the attachment rows, never by recomputing each account's live status: the bilaga documents what was attested (numbers as they stood at sign-off, who, when, note) plus the files with their SHA-256, which is what a kvalitetskontroll reads. Whole period only (a bilaga is per balansdag), PDF-only export, written into every period folder of the full archive as JSON + PDF; an archive run has no acting user, so the checklist's readiness-derived items are left as stored there. +[2026-08-25] A period klarmarkerad as closed in a previous system (closed_externally) no longer trips the trial balance's "closed without closing_entry_id" guard for statutory pre-closing balances: its closing verifikat never existed in these books, so the booked balances are the pre-closing balances and there is nothing to strip. The guard stays for periods our own engine closed, where a missing link is a real inconsistency. Found by Väla Redovisning: Klarmarkera + Årsredovisning = 500. diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index b83b70f4..b8bdcce7 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -45,6 +45,7 @@ import { PanelLeftClose, Library, BookCheck, + FolderArchive, ShoppingCart, Car, } from 'lucide-react' @@ -134,6 +135,7 @@ type NavLabelKey = | 'deadlines' | 'periodiseringar' | 'year_end' + | 'bokslutsbilagor' | 'annual_report' | 'income_declaration' | 'help' @@ -247,6 +249,7 @@ const navItems: NavItem[] = [ { href: '/deadlines', labelKey: 'deadlines', icon: CalendarClock, group: 'skatt' }, { href: '/bookkeeping/periodiseringar', labelKey: 'periodiseringar', icon: CalendarRange, group: 'skatt', fold: 'bokslut' }, { href: '/bookkeeping/year-end', labelKey: 'year_end', icon: FileCheck, group: 'skatt', fold: 'bokslut' }, + { href: '/reports/bokslutsbilagor', labelKey: 'bokslutsbilagor', icon: FolderArchive, group: 'skatt', fold: 'bokslut' }, { href: '/bookkeeping/year-end/arsredovisning', labelKey: 'annual_report', icon: ScrollText, group: 'skatt', fold: 'bokslut', entityOnly: 'aktiebolag' }, { href: '/reports/ink2-declaration', labelKey: 'income_declaration', icon: FileSpreadsheet, group: 'skatt', fold: 'bokslut', entityOnly: 'aktiebolag' }, { href: '/reports/ne-declaration', labelKey: 'income_declaration', icon: FileSpreadsheet, group: 'skatt', fold: 'bokslut', entityOnly: 'enskild_firma' }, diff --git a/components/reconciliation/ReconciliationRail.tsx b/components/reconciliation/ReconciliationRail.tsx index 980d3c16..5b566870 100644 --- a/components/reconciliation/ReconciliationRail.tsx +++ b/components/reconciliation/ReconciliationRail.tsx @@ -1,8 +1,9 @@ 'use client' -import { Fragment } from 'react' +import { Fragment, useState } from 'react' import Image from 'next/image' import { useTranslations } from 'next-intl' +import { ChevronDown, ChevronRight } from 'lucide-react' import { cn, formatDate } from '@/lib/utils' import type { ReconciliationAccount } from '@/lib/reconciliation/schemas' @@ -63,10 +64,38 @@ interface ReconciliationRailProps { onSelect: (accountKey: string) => void } +const MANUAL_OPEN_STORAGE_KEY = 'Accounted:recon-rail-manual-open' + export function ReconciliationRail({ accounts, selectedKey, onSelect }: ReconciliationRailProps) { const t = useTranslations('reconciliation') const fed = accounts.filter((a) => a.kind !== 'manual') const manual = accounts.filter((a) => a.kind === 'manual') + const selectedIsManual = selectedKey?.startsWith('manual:') ?? false + + // The manual group folds: a migrated company has twenty-odd balance + // accounts, and the bank rows must stay in view. It opens when a manual + // account is selected and otherwise remembers the last choice per browser. + const [manualOpen, setManualOpen] = useState(() => { + if (selectedIsManual) return true + try { + return window.localStorage.getItem(MANUAL_OPEN_STORAGE_KEY) === '1' + } catch { + return false + } + }) + // Derived, not synced: the selected row must stay visible. + const showManual = manualOpen || selectedIsManual + const toggleManual = () => { + setManualOpen((open) => { + try { + window.localStorage.setItem(MANUAL_OPEN_STORAGE_KEY, open ? '0' : '1') + } catch { + // Per-browser convenience only. + } + return !open + }) + } + const manualUnsigned = manual.filter((a) => a.status?.state !== 'reconciled').length const stateLabel = (account: ReconciliationAccount): string => { const state = account.status?.state ?? 'unknown' @@ -150,15 +179,31 @@ export function ReconciliationRail({ accounts, selectedKey, onSelect }: Reconcil {fed.map(renderRow)} {manual.length > 0 && ( - {fed.length > 0 && ( -
  • 0 && 'mt-3')}> +
  • + {showManual ? ( +