fix(bokslut): årsredovisning for a klarmarkerad year, bokslutsbilagor under Bokslut, bilagor reminder (#1875)

* 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz

* fix(nav): admit the bokslutsbilagor label key

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-25 10:08:46 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 9ce1ebc65f
commit 3dce94d39e
9 changed files with 172 additions and 10 deletions
+1
View File
@@ -1198,3 +1198,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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/<company>/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.
+3
View File
@@ -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' },
@@ -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<boolean>(() => {
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 && (
<Fragment>
{fed.length > 0 && (
<li
aria-hidden
className="mt-3 px-3 pb-1 text-[10.5px] font-semibold uppercase tracking-[0.08em] text-muted-foreground"
<li className={cn(fed.length > 0 && 'mt-3')}>
<button
type="button"
onClick={toggleManual}
aria-expanded={showManual}
aria-controls="recon-rail-manual"
className="flex w-full items-center gap-1.5 rounded-lg px-3 py-1.5 text-left text-[10.5px] font-semibold uppercase tracking-[0.08em] text-muted-foreground hover:bg-muted/60"
>
{t('rail_group_manual')}
</li>
{showManual ? (
<ChevronDown className="h-3 w-3 shrink-0" aria-hidden="true" />
) : (
<ChevronRight className="h-3 w-3 shrink-0" aria-hidden="true" />
)}
<span className="min-w-0 flex-1 truncate">{t('rail_group_manual')}</span>
<span className="shrink-0 normal-case tracking-normal tabular-nums" data-ph-mask>
{manualUnsigned > 0 ? t('rail_group_manual_unsigned', { count: manualUnsigned, total: manual.length }) : String(manual.length)}
</span>
</button>
</li>
{showManual && (
<Fragment>
<li id="recon-rail-manual" className="sr-only" aria-hidden />
{manual.map(renderRow)}
</Fragment>
)}
{manual.map(renderRow)}
</Fragment>
)}
</ul>
@@ -16,6 +16,10 @@ vi.mock('@/lib/reports/ar-reconciliation', () => ({
generateARReconciliation: vi.fn(),
}))
const listAccountsMock = vi.fn()
vi.mock('@/lib/reconciliation/service', () => ({
listReconciliationAccounts: (...args: unknown[]) => listAccountsMock(...args),
}))
vi.mock('@/lib/reports/supplier-reconciliation', () => ({
generateReconciliation: vi.fn(),
}))
@@ -412,3 +416,50 @@ describe('buildBokslutReadinessReport', () => {
expect(vi.mocked(generateAPReconciliation)).toHaveBeenCalled()
})
})
describe('bokslutsbilagor reminder', () => {
beforeEach(() => {
listAccountsMock.mockReset()
})
it('counts balance accounts not signed off per balansdagen and links to the pärm', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
listAccountsMock.mockResolvedValue([
{ account_key: 'bank:1', kind: 'bank', superseded_by: null, signed_off_through: '2025-12-31' },
{ account_key: 'bank:2', kind: 'bank', superseded_by: 'bank:1', signed_off_through: null },
{ account_key: 'manual:1510', kind: 'manual', superseded_by: null, signed_off_through: '2025-11-30' },
{ account_key: 'manual:2350', kind: 'manual', superseded_by: null, signed_off_through: null },
])
const report = await buildBokslutReadinessReport(
makeSupabase({ period: { data: PERIOD, error: null }, settings: { data: { entity_type: 'aktiebolag' }, error: null } }),
'company-1',
'user-1',
'fp-1',
)
const reminder = report.reminders.find((r) => r.code === 'bilagor_unsigned')
expect(reminder).toMatchObject({ severity: 'warning', href: '/reports/bokslutsbilagor' })
expect(reminder?.message).toMatch(/2 av 3 balanskonton/)
expect(listAccountsMock).toHaveBeenCalledWith(expect.anything(), 'company-1', expect.objectContaining({ withStatus: false, windowTo: PERIOD.period_end }))
})
it('says so when every account is signed, and stays silent when the read fails', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
listAccountsMock.mockResolvedValue([{ account_key: 'bank:1', kind: 'bank', superseded_by: null, signed_off_through: '2025-12-31' }])
const done = await buildBokslutReadinessReport(
makeSupabase({ period: { data: PERIOD, error: null }, settings: { data: { entity_type: 'aktiebolag' }, error: null } }),
'company-1',
'user-1',
'fp-1',
)
expect(done.reminders.find((r) => r.code === 'bilagor_unsigned')).toMatchObject({ severity: 'info' })
listAccountsMock.mockRejectedValue(new Error('down'))
const failed = await buildBokslutReadinessReport(
makeSupabase({ period: { data: PERIOD, error: null }, settings: { data: { entity_type: 'aktiebolag' }, error: null } }),
'company-1',
'user-1',
'fp-1',
)
expect(failed.reminders.find((r) => r.code === 'bilagor_unsigned')).toBeUndefined()
})
})
+27
View File
@@ -7,6 +7,7 @@ import { generateReconciliation as generateAPReconciliation } from '@/lib/report
import { computeEfDeclarationPreview } from '@/lib/bokslut/enskild-firma/ef-declaration-preview'
import { createLogger } from '@/lib/logger'
import { describeFiscalYearGap, findFiscalYearGaps, type PeriodLike } from '@/lib/bookkeeping/fiscal-year-gaps'
import { listReconciliationAccounts } from '@/lib/reconciliation/service'
import type { YearEndBlocker, YearEndValidation } from '@/types'
const log = createLogger('bokslut-readiness')
@@ -234,6 +235,32 @@ export async function buildBokslutReadinessReport(
'Periodiseringar (förutbetalda kostnader 17xx, upplupna kostnader 29xx) bokas manuellt. Tänk på att vända dem 1 januari nästa år.',
})
// Bokslutsbilagor: every balance account signed off per balansdagen is what
// Reko 760/765 asks for. Advisory: a failed read costs only this reminder.
try {
const accounts = await listReconciliationAccounts(supabase, companyId, {
today: period.period_end,
windowFrom: period.period_start,
windowTo: period.period_end,
withStatus: false,
})
const live = accounts.filter((a) => !a.superseded_by)
const unsigned = live.filter((a) => !a.signed_off_through || a.signed_off_through < period.period_end)
if (live.length > 0) {
reminders.push({
code: 'bilagor_unsigned',
severity: unsigned.length > 0 ? 'warning' : 'info',
message:
unsigned.length > 0
? `${unsigned.length} av ${live.length} balanskonton är inte signerade per balansdagen ${period.period_end}. Bokslutsbilagorna samlar avstämning, underlag och signering per konto.`
: `Alla ${live.length} balanskonton är signerade per balansdagen ${period.period_end}. Bokslutsbilagorna kan skrivas ut.`,
href: '/reports/bokslutsbilagor',
})
}
} catch (err) {
log.warn('bilagor reminder failed', { companyId, fiscalPeriodId, error: err instanceof Error ? err.message : String(err) })
}
if (entityType === 'enskild_firma') {
// Pre-compute the EF declaration so the wizard's overview reflects what
// the user will see when they reach the dispositions step. Egenavgifter,
@@ -618,6 +618,33 @@ describe('generateTrialBalance', () => {
expect(tables).not.toContain('journal_entry_lines')
})
it('does not fail closed for a period klarmarkerad in a previous system (closed_externally)', async () => {
mockResults = {
fiscal_periods: [
{
data: {
period_start: '2024-01-01',
period_end: '2024-12-31',
opening_balance_entry_id: null,
closing_entry_id: null,
is_closed: true,
closed_externally: true,
},
error: null,
},
],
journal_entries: [{ data: [], error: null }],
journal_entry_lines: [{ data: [], error: null }],
chart_of_accounts: [{ data: [], error: null }],
}
// The closing verifikat lives in the old software: nothing to strip, the
// booked balances are the statutory pre-closing balances.
await expect(
generateTrialBalance(supabase, 'company-1', 'period-1', { closingEntry: 'exclude-final' }),
).resolves.toMatchObject({ isBalanced: true })
})
it('keeps year-end adjustments for an open period without a final closing entry', async () => {
mockResults = {
fiscal_periods: [
+6 -2
View File
@@ -96,7 +96,7 @@ export async function generateTrialBalance(
const [periodResult, yearEndIdRows, accounts] = await Promise.all([
supabase
.from('fiscal_periods')
.select('period_start, period_end, opening_balance_entry_id, closing_entry_id, is_closed')
.select('period_start, period_end, opening_balance_entry_id, closing_entry_id, is_closed, closed_externally')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
@@ -132,11 +132,15 @@ export async function generateTrialBalance(
// Statutory annual reports must exclude only the linked final closing entry:
// tax, depreciation, and appropriations also use source_type year_end. A
// closed period without the link is ambiguous, so fail instead of silently
// understating the statutory report.
// understating the statutory report. A period klarmarkerad as closed in a
// previous system (closed_externally) is the one unambiguous case: its
// closing verifikat never existed in these books, so there is nothing to
// strip and the balances as booked are the pre-closing balances.
if (
excludeFinalOnly
&& period?.is_closed === true
&& !period.closing_entry_id
&& period.closed_externally !== true
) {
throw new Error(
'Closed fiscal period is missing closing_entry_id; statutory pre-closing balances cannot be generated safely',
+2
View File
@@ -111,6 +111,7 @@
"skattekonto": "Tax account",
"periodiseringar": "Accruals",
"year_end": "Annual accounts",
"bokslutsbilagor": "Closing binder",
"annual_report": "Annual report",
"income_declaration": "Income tax return",
"pending": "Pending",
@@ -7930,6 +7931,7 @@
"toast_marked_ib": "The voucher was marked as opening balance",
"toast_moved": "The transaction was moved to {account}",
"rail_group_manual": "Other balance sheet accounts",
"rail_group_manual_unsigned": "{count} of {total} not signed off",
"rail_never_signed": "not signed off",
"state_unsigned": "Not signed off",
"tile_external_manual": "Balance per supporting documents",
+2
View File
@@ -111,6 +111,7 @@
"skattekonto": "Skattekonto",
"periodiseringar": "Periodiseringar",
"year_end": "Årsbokslut",
"bokslutsbilagor": "Bokslutsbilagor",
"annual_report": "Årsredovisning",
"income_declaration": "Inkomstdeklaration",
"pending": "Väntande",
@@ -7930,6 +7931,7 @@
"toast_marked_ib": "Verifikatet markerades som ingående balans",
"toast_moved": "Transaktionen flyttades till {account}",
"rail_group_manual": "Övriga balanskonton",
"rail_group_manual_unsigned": "{count} av {total} ej avstämda",
"rail_never_signed": "inte avstämt",
"state_unsigned": "Inte avstämt",
"tile_external_manual": "Saldo enligt underlag",