From 9ce1ebc65fc49236d3216f7c0ab0ba6340da0862 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Tue, 25 Aug 2026 09:35:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(reports):=20bokslutsbilagor,=20the=20p?= =?UTF-8?q?=C3=A4rm=20per=20r=C3=A4kenskaps=C3=A5r=20(Reko=20bilagor,=20PR?= =?UTF-8?q?=204)=20(#1874)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(reports): bokslutsbilagor, the pärm per räkenskapsår (Reko bilagor, PR 4) One bilaga per balance account as of the balansdag: IB, movement and UB from the trial balance, what it was reconciled against, the difference, the sign-off with who, when and note, and every attached file with its SHA-256; the closing checklist as the first page. JSON and PDF through /api/reports/bokslutsbilagor, in the reports library and on the Avstämning page, and written into every period folder of the full archive. Built from the attested rows, never by recomputing live status. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(reports): load the pärm renderer on demand in the full archive so PDF stubs elsewhere keep working Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(reconciliation): neutral rail dot for a manual account that is merely not attested yet An unsigned manual account without a system specification has nothing to compare against, so an amber dot read as a problem on every balance account of a freshly migrated company. Neutral until it is signed or a specification differs. 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 + .../bokslutsbilagor/__tests__/route.test.ts | 99 +++++++ app/api/reports/bokslutsbilagor/route.ts | 62 ++++ .../reconciliation/ReconciliationRail.tsx | 10 +- .../ReconciliationWorkspace.tsx | 7 + components/reports/BokslutsbilagorView.tsx | 163 +++++++++++ components/reports/FocusedReport.tsx | 6 + .../bokslutsbilagor-pdf-template.test.ts | 80 ++++++ lib/reports/__tests__/bokslutsbilagor.test.ts | 163 +++++++++++ .../__tests__/full-archive-export.test.ts | 5 + lib/reports/bokslutsbilagor-pdf-template.tsx | 266 ++++++++++++++++++ lib/reports/bokslutsbilagor-types.ts | 87 ++++++ lib/reports/bokslutsbilagor.ts | 221 +++++++++++++++ lib/reports/catalog.ts | 15 + lib/reports/full-archive-export.ts | 33 +++ messages/en.json | 20 ++ messages/sv.json | 20 ++ 17 files changed, 1257 insertions(+), 1 deletion(-) create mode 100644 app/api/reports/bokslutsbilagor/__tests__/route.test.ts create mode 100644 app/api/reports/bokslutsbilagor/route.ts create mode 100644 components/reports/BokslutsbilagorView.tsx create mode 100644 lib/reports/__tests__/bokslutsbilagor-pdf-template.test.ts create mode 100644 lib/reports/__tests__/bokslutsbilagor.test.ts create mode 100644 lib/reports/bokslutsbilagor-pdf-template.tsx create mode 100644 lib/reports/bokslutsbilagor-types.ts create mode 100644 lib/reports/bokslutsbilagor.ts diff --git a/DECISIONS.md b/DECISIONS.md index 742ce043..79b5d3a0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1197,3 +1197,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-24] Manual reconciliation adapter (Reko bilagor, PR 1) computes the ledger side per fiscal period via generateTrialBalance (IB + movement through the balansdag), never as an all-history sumAccountBalance: year-end posts an opening_balance verifikat that re-books every balance account in the new year, so an all-history sum counts a closed year twice. Reskontra/semesterskuld specifications are "per idag" (open items now), labeled so in the bridge; a per-date reskontra is a follow-up. A typed external_balance is accepted only on manual accounts without a system specification (EXTERNAL_BALANCE_NOT_ALLOWED elsewhere): letting a stated number override the bank, Skatteverket or the reskontra would hide the very difference the sign-off exists to record. [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. diff --git a/app/api/reports/bokslutsbilagor/__tests__/route.test.ts b/app/api/reports/bokslutsbilagor/__tests__/route.test.ts new file mode 100644 index 00000000..12a5f7a6 --- /dev/null +++ b/app/api/reports/bokslutsbilagor/__tests__/route.test.ts @@ -0,0 +1,99 @@ +/** + * Tests for GET /api/reports/bokslutsbilagor (cookie session, withRouteContext). + * The generator and the PDF renderer are mocked; the wrapper and the query + * validation are real. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextResponse } from 'next/server' +import { createQueuedMockSupabase, createMockRequest, parseJsonResponse } from '@/tests/helpers' + +const { supabase, reset } = createQueuedMockSupabase() + +const requireAuthMock = vi.fn() +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) +vi.mock('@/lib/company/context', () => ({ + getActiveCompanyId: vi.fn().mockResolvedValue('company-1'), + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/supabase/server', () => ({ + createServiceClient: () => ({ from: vi.fn() }), +})) +const generateMock = vi.fn() +vi.mock('@/lib/reports/bokslutsbilagor', () => ({ + generateBokslutsbilagor: (...args: unknown[]) => generateMock(...args), +})) +const renderMock = vi.fn() +vi.mock('@react-pdf/renderer', () => ({ + renderToBuffer: (...args: unknown[]) => renderMock(...args), +})) +vi.mock('@/lib/reports/bokslutsbilagor-pdf-template', () => ({ + BokslutsbilagorPDF: () => null, +})) +vi.mock('@/lib/reports/behandlingshistorik', () => ({ + resolveUserLabelsFromProfiles: vi.fn().mockResolvedValue(new Map()), +})) + +import { GET } from '../route' + +const PERIOD_ID = '11111111-1111-4111-8111-111111111111' +const REPORT = { + company: { name: 'Väla Redovisning AB', org_number: '5592383508' }, + period: { id: PERIOD_ID, name: 'Räkenskapsår 2026', start: '2026-01-01', end: '2026-12-31' }, + generated_at: '2027-01-15T10:00:00Z', + app_version: null, + checklist: { items: [], summary: { total: 0, done: 0, not_applicable: 0, open: 0 } }, + accounts: [], + summary: { accounts: 0, signed_on_balansdag: 0, signed_other_date: 0, unsigned: 0, attachments: 0 }, +} + +describe('GET /api/reports/bokslutsbilagor', () => { + beforeEach(() => { + vi.clearAllMocks() + reset() + requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase }) + generateMock.mockResolvedValue(REPORT) + renderMock.mockResolvedValue(Buffer.from('%PDF-1.4 stub')) + }) + + it('401 without a session', async () => { + requireAuthMock.mockResolvedValue({ error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) }) + const res = await GET(createMockRequest(`http://localhost/api/reports/bokslutsbilagor?period_id=${PERIOD_ID}`)) + expect(res.status).toBe(401) + }) + + it('400s a missing or malformed period_id and an unknown format', async () => { + expect((await GET(createMockRequest('http://localhost/api/reports/bokslutsbilagor'))).status).toBe(400) + expect((await GET(createMockRequest('http://localhost/api/reports/bokslutsbilagor?period_id=nope'))).status).toBe(400) + expect((await GET(createMockRequest(`http://localhost/api/reports/bokslutsbilagor?period_id=${PERIOD_ID}&format=xlsx`))).status).toBe(400) + expect(generateMock).not.toHaveBeenCalled() + }) + + it('returns the JSON report for the user, private and uncached', async () => { + const res = await GET(createMockRequest(`http://localhost/api/reports/bokslutsbilagor?period_id=${PERIOD_ID}`)) + expect(res.status).toBe(200) + expect(res.headers.get('Cache-Control')).toMatch(/no-store/) + const { body } = await parseJsonResponse<{ data: { period: { id: string } } }>(res) + expect(body.data.period.id).toBe(PERIOD_ID) + expect(generateMock).toHaveBeenCalledWith(supabase, 'company-1', PERIOD_ID, expect.objectContaining({ userId: 'user-1' })) + }) + + it('renders the PDF with a dated filename', async () => { + const res = await GET(createMockRequest(`http://localhost/api/reports/bokslutsbilagor?period_id=${PERIOD_ID}&format=pdf`)) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('application/pdf') + expect(res.headers.get('Content-Disposition')).toMatch(/bokslutsbilagor-.*-20261231\.pdf/) + expect(renderMock).toHaveBeenCalledTimes(1) + }) + + it('404s an unknown period and 500s a generator failure without leaking the message', async () => { + generateMock.mockResolvedValue(null) + expect((await GET(createMockRequest(`http://localhost/api/reports/bokslutsbilagor?period_id=${PERIOD_ID}`))).status).toBe(404) + generateMock.mockRejectedValue(new Error('relation account_reconciliations does not exist')) + const failed = await GET(createMockRequest(`http://localhost/api/reports/bokslutsbilagor?period_id=${PERIOD_ID}`)) + expect(failed.status).toBe(500) + expect(JSON.stringify(await parseJsonResponse(failed))).not.toMatch(/relation/) + }) +}) diff --git a/app/api/reports/bokslutsbilagor/route.ts b/app/api/reports/bokslutsbilagor/route.ts new file mode 100644 index 00000000..7078e3b3 --- /dev/null +++ b/app/api/reports/bokslutsbilagor/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from 'next/server' +import { z } from 'zod' +import { renderToBuffer } from '@react-pdf/renderer' +import { withRouteContext } from '@/lib/api/with-route-context' +import { validateQuery } from '@/lib/api/validate' +import { contentDisposition } from '@/lib/api/content-disposition' +import { privateNoStore } from '@/lib/api/private-no-store' +import { errorResponseFromCode } from '@/lib/errors/get-structured-error' +import { createServiceClient } from '@/lib/supabase/server' +import { generateBokslutsbilagor } from '@/lib/reports/bokslutsbilagor' +import { BokslutsbilagorPDF } from '@/lib/reports/bokslutsbilagor-pdf-template' +import { resolveUserLabelsFromProfiles } from '@/lib/reports/behandlingshistorik' +import { currentAppVersion } from '@/lib/reports/app-version' +import { slugifyCompanyName } from '@/lib/reports/xlsx-export' + +const BokslutsbilagorQuerySchema = z.object({ + period_id: z.string().uuid(), + format: z.enum(['json', 'pdf']).default('json'), +}) + +/** + * GET /api/reports/bokslutsbilagor?period_id=&format=json|pdf + * + * The bokslutsbilagor pärm for one räkenskapsår: every balance account as of + * the balansdag with balances, specification or stated balance, sign-off, + * underlag files with hashes, and the checklist. Read-only; signer and + * uploader labels resolve through a service-role lookup on `profiles` + * restricted to the ids in the result, like behandlingshistorik. + */ +export const GET = withRouteContext('report.bokslutsbilagor', async (request, ctx) => { + const { supabase, user, companyId, log, requestId } = ctx + const query = validateQuery(request, BokslutsbilagorQuerySchema, { log, operation: 'report.bokslutsbilagor' }) + if (!query.success) return query.response + const { period_id: periodId, format } = query.data + + try { + const serviceClient = createServiceClient() + const report = await generateBokslutsbilagor(supabase, companyId, periodId, { + userId: user.id, + resolveUserLabels: (ids) => resolveUserLabelsFromProfiles(serviceClient, ids), + appVersion: currentAppVersion(), + }) + if (!report) return errorResponseFromCode('FISCAL_PERIOD_NOT_FOUND', log, { requestId }) + + if (format === 'json') { + return privateNoStore(NextResponse.json({ data: report })) + } + const pdf = await renderToBuffer(BokslutsbilagorPDF({ report })) + const filename = `bokslutsbilagor-${slugifyCompanyName(report.company.name)}-${report.period.end.replace(/-/g, '')}.pdf` + return new NextResponse(new Uint8Array(pdf), { + headers: { + 'Content-Type': 'application/pdf', + 'Content-Disposition': contentDisposition('attachment', filename), + 'Cache-Control': 'private, no-store', + }, + }) + } catch (err) { + // Raw message stays server-side: it can carry table names / SQL. + log.error('bokslutsbilagor generation failed', err as Error, { periodId }) + return errorResponseFromCode('REPORT_GENERATION_FAILED', log, { requestId }) + } +}) diff --git a/components/reconciliation/ReconciliationRail.tsx b/components/reconciliation/ReconciliationRail.tsx index 83dbf02d..980d3c16 100644 --- a/components/reconciliation/ReconciliationRail.tsx +++ b/components/reconciliation/ReconciliationRail.tsx @@ -81,9 +81,17 @@ export function ReconciliationRail({ accounts, selectedKey, onSelect }: Reconcil return synced ? t('rail_synced', { date: formatDate(synced) }) : t('rail_never_synced') } + // A manual account with nothing to compare against is not a problem, just + // not attested yet: neutral until it is signed or a specification differs. + const dotState = (account: ReconciliationAccount): keyof typeof DOT_CLASS => { + const state = account.status?.state ?? 'unknown' + if (account.kind === 'manual' && state === 'open' && account.status?.unexplained_difference == null) return 'unknown' + return state + } + const renderRow = (account: ReconciliationAccount) => { const selected = account.account_key === selectedKey - const state = account.status?.state ?? 'unknown' + const state = dotState(account) const open = account.status ? account.status.open_counts.proposed + account.status.open_counts.unmatched_external + diff --git a/components/reconciliation/ReconciliationWorkspace.tsx b/components/reconciliation/ReconciliationWorkspace.tsx index 21cbec3f..229e3e66 100644 --- a/components/reconciliation/ReconciliationWorkspace.tsx +++ b/components/reconciliation/ReconciliationWorkspace.tsx @@ -1,9 +1,12 @@ 'use client' import { useCallback, useEffect, useMemo, useState } from 'react' +import Link from 'next/link' import { usePathname, useRouter, useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' import { Scale } from 'lucide-react' +import { QUIET_LINK_CLASS } from '@/components/ui/dry-table' +import { cn } from '@/lib/utils' import { PageHeader } from '@/components/ui/page-header' import { HelpPopover } from '@/components/ui/help-popover' import { EmptyState } from '@/components/ui/empty-state' @@ -41,6 +44,7 @@ interface ReconciliationWorkspaceProps { export function ReconciliationWorkspace({ initialPeriods, initialCompanyId }: ReconciliationWorkspaceProps) { const t = useTranslations('reconciliation') + const tParm = useTranslations('bokslutsbilagor') const router = useRouter() const pathname = usePathname() const searchParams = useSearchParams() @@ -120,6 +124,9 @@ export function ReconciliationWorkspace({ initialPeriods, initialCompanyId }: Re } action={
+ + {tParm('open_parm')} + (null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + // Keyed on periodId by the caller: a new period is a fresh mount, so the + // initial loading state is the reset. + useEffect(() => { + let cancelled = false + fetch(`/api/reports/bokslutsbilagor?period_id=${encodeURIComponent(periodId)}`) + .then(async (res) => { + const json = await res.json().catch(() => ({})) + if (cancelled) return + if (!res.ok) { + setError(getErrorMessage(json, { statusCode: res.status })) + return + } + setReport(json.data as BokslutsbilagorReport) + }) + .catch(() => { + if (!cancelled) setError(t('load_failed')) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + return () => { + cancelled = true + } + }, [periodId, t]) + + if (loading) { + return ( +
+ + +
+ ) + } + if (error) { + return ( + + + + {error} + + + ) + } + if (!report || report.accounts.length === 0) { + return + } + + const money = (n: number | null) => (n == null ? '-' : formatCurrency(n, 'SEK')) + const externalLabel = (a: BokslutsbilagorReport['accounts'][number]) => (locale === 'en' ? a.external_label_en : a.external_label_sv) + + return ( +
+
+

+ {t('summary', { + accounts: report.summary.accounts, + signed: report.summary.signed_on_balansdag, + files: report.summary.attachments, + date: formatDate(report.period.end), + })} + {' · '} + {t('checklist_summary', { done: report.checklist.summary.done + report.checklist.summary.not_applicable, total: report.checklist.summary.total })} +

+ +
+ + + +
+ + + + + + + + + + + + + {report.accounts.map((a) => { + const files = a.attachments.filter((f) => !f.removed_at).length + return ( + + + + + + + + + ) + })} + +
{t('col_account')}{t('col_booked')}{t('col_external')}{t('col_difference')}{t('col_signoff')}{t('col_files')}
+ + {a.account_number} {a.name} + +
{externalLabel(a)}
+
+ {money(a.closing_balance)} + + {money(a.external_balance)} + = 0.005 && 'text-warning', + )} + data-ph-mask + > + {money(a.difference)} + + {a.signoff ? ( + + {a.signoff.on_balansdag + ? t('signed', { who: a.signoff.signed_by_label, when: formatDate(a.signoff.signed_at) }) + : t('signed_other_date', { date: formatDate(a.signoff.through_date), who: a.signoff.signed_by_label })} + + ) : ( + {t('unsigned')} + )} + + {files} +
+
+
+
+
+ ) +} diff --git a/components/reports/FocusedReport.tsx b/components/reports/FocusedReport.tsx index 7255804e..e4865ba6 100644 --- a/components/reports/FocusedReport.tsx +++ b/components/reports/FocusedReport.tsx @@ -55,6 +55,10 @@ const BehandlingshistorikView = dynamic(() => import('./BehandlingshistorikView').then((module) => ({ default: module.BehandlingshistorikView })), { loading: ReportViewLoading }, ) +const BokslutsbilagorView = dynamic(() => + import('./BokslutsbilagorView').then((module) => ({ default: module.BokslutsbilagorView })), + { loading: ReportViewLoading }, +) /** * The focused single-report experience at /reports/[slug]. Carries one report: @@ -240,6 +244,8 @@ function FocusedView({ return case 'behandlingshistorik': return + case 'bokslutsbilagor': + return default: return null } diff --git a/lib/reports/__tests__/bokslutsbilagor-pdf-template.test.ts b/lib/reports/__tests__/bokslutsbilagor-pdf-template.test.ts new file mode 100644 index 00000000..a5e32214 --- /dev/null +++ b/lib/reports/__tests__/bokslutsbilagor-pdf-template.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import { renderToBuffer } from '@react-pdf/renderer' +import { BokslutsbilagorPDF } from '../bokslutsbilagor-pdf-template' +import type { BokslutsbilagorReport } from '../bokslutsbilagor-types' + +function report(): BokslutsbilagorReport { + const account = (n: number): BokslutsbilagorReport['accounts'][number] => ({ + account_key: `manual:${2300 + n}`, + kind: 'manual', + account_number: String(2300 + n), + name: `Skuld ${n} med ett ganska långt namn för radbrytning`, + opening_balance: -100000 - n, + movement: 5000, + closing_balance: -95000 - n, + external_label_sv: 'Saldo enligt underlag (angivet vid signering)', + external_label_en: 'Balance per supporting documents (stated at sign-off)', + external_balance: -95000 - n, + difference: 0, + signoff: { + id: `s${n}`, + through_date: '2026-12-31', + on_balansdag: n % 2 === 0, + external_balance: -95000 - n, + ledger_balance: -95000 - n, + unexplained_difference: 0, + note: n % 3 === 0 ? 'Enligt engagemangsbesked → kontrollerat' : null, + signed_by: 'u1', + signed_by_label: 'yasemin@example.se', + signed_at: '2027-01-10T08:00:00Z', + }, + attachments: [ + { + id: `a${n}`, + through_date: '2026-12-31', + file_name: `engagemangsbesked-${n}.pdf`, + mime_type: 'application/pdf', + size_bytes: 1234, + sha256: 'ab'.repeat(32), + note: null, + uploaded_by_label: 'yasemin@example.se', + uploaded_at: '2027-01-09T08:00:00Z', + removed_at: n === 1 ? '2027-01-09T09:00:00Z' : null, + removed_reason: n === 1 ? 'fel fil' : null, + }, + ], + }) + return { + company: { name: 'Väla Redovisning AB', org_number: '5592383508' }, + period: { id: 'fy-2026', name: 'Räkenskapsår 2026', start: '2026-01-01', end: '2026-12-31' }, + generated_at: '2027-01-15T10:00:00Z', + app_version: '1.2.3', + checklist: { + items: [ + { key: 'bank_signed', group: 'avstamning', label_sv: 'Bankkonton avstämda', label_en: 'Bank reconciled', state: 'done', done_at: null, done_by_label: null, note: null }, + { key: 'inventory_valued', group: 'vardering', label_sv: 'Varulager inventerat', label_en: 'Inventory counted', state: 'not_applicable', done_at: '2027-01-06T08:00:00Z', done_by_label: 'yasemin@example.se', note: 'Inget lager' }, + ], + summary: { total: 2, done: 1, not_applicable: 1, open: 0 }, + }, + accounts: Array.from({ length: 40 }, (_, i) => account(i)), + summary: { accounts: 40, signed_on_balansdag: 20, signed_other_date: 20, unsigned: 0, attachments: 39 }, + } +} + +describe('BokslutsbilagorPDF', () => { + it('renders a multi-page pärm without deadlocking and with WinAnsi-safe text', async () => { + const pdf = await renderToBuffer(BokslutsbilagorPDF({ report: report() })) + expect(pdf.length).toBeGreaterThan(5000) + expect(pdf.subarray(0, 5).toString()).toBe('%PDF-') + // More than one page: the fixed header/footer repeat and no `break` was needed. + expect((pdf.toString('latin1').match(/\/Type\s*\/Page[^s]/g) ?? []).length).toBeGreaterThan(1) + }, 30000) + + it('renders the empty pärm', async () => { + const r = report() + r.accounts = [] + r.summary = { accounts: 0, signed_on_balansdag: 0, signed_other_date: 0, unsigned: 0, attachments: 0 } + const pdf = await renderToBuffer(BokslutsbilagorPDF({ report: r })) + expect(pdf.subarray(0, 5).toString()).toBe('%PDF-') + }, 30000) +}) diff --git a/lib/reports/__tests__/bokslutsbilagor.test.ts b/lib/reports/__tests__/bokslutsbilagor.test.ts new file mode 100644 index 00000000..be5b9b32 --- /dev/null +++ b/lib/reports/__tests__/bokslutsbilagor.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createQueuedMockSupabase } from '@/tests/helpers' + +const listAccountsMock = vi.fn() +const snapshotMock = vi.fn() +const specMock = vi.fn() +const latestSignoffsMock = vi.fn() +const attachmentsMock = vi.fn() +const checklistMock = vi.fn() + +vi.mock('@/lib/reconciliation/service', () => ({ + listReconciliationAccounts: (...args: unknown[]) => listAccountsMock(...args), +})) +vi.mock('@/lib/reconciliation/manual-reconciliation', async () => { + const actual = await vi.importActual('@/lib/reconciliation/manual-reconciliation') + return { + ...actual, + loadBalanceSheetSnapshot: (...args: unknown[]) => snapshotMock(...args), + loadSpecificationAmounts: (...args: unknown[]) => specMock(...args), + } +}) +vi.mock('@/lib/reconciliation/signoff-store', async () => { + const actual = await vi.importActual('@/lib/reconciliation/signoff-store') + return { ...actual, getLatestSignoffs: (...args: unknown[]) => latestSignoffsMock(...args) } +}) +vi.mock('@/lib/reconciliation/attachments-store', () => ({ + listAttachmentRowsInRange: (...args: unknown[]) => attachmentsMock(...args), +})) +vi.mock('@/lib/bokslut/checklist', () => ({ + buildBokslutChecklist: (...args: unknown[]) => checklistMock(...args), +})) + +import { generateBokslutsbilagor } from '../bokslutsbilagor' + +const COMPANY = 'company-1' +const PERIOD = { id: 'fy-2026', name: 'Räkenskapsår 2026', period_start: '2026-01-01', period_end: '2026-12-31' } + +function account(overrides: Record) { + return { + account_key: 'manual:2350', + kind: 'manual', + account_number: '2350', + name: 'Banklån', + currency: 'SEK', + logo_url: null, + source: { type: 'manual', synced_at: null, stale: false }, + status: null, + superseded_by: null, + signed_off_through: null, + ...overrides, + } +} + +function signoffRow(overrides: Record) { + return { + id: 's1', + account_key: 'manual:2350', + through_date: '2026-12-31', + external_balance: -250000, + ledger_balance: -250000, + unexplained_difference: 0, + note: 'Enligt engagemangsbesked', + signed_by: 'u1', + signed_at: '2027-01-10T08:00:00Z', + reopened_at: null, + reopened_by: null, + reopen_reason: null, + ...overrides, + } +} + +beforeEach(() => { + vi.clearAllMocks() + for (const m of [listAccountsMock, snapshotMock, specMock, latestSignoffsMock, attachmentsMock, checklistMock]) m.mockReset() + listAccountsMock.mockResolvedValue([ + account({ account_key: 'bank:11111111-1111-4111-8111-111111111111', kind: 'bank', account_number: '1930', name: 'Företagskonto' }), + account({ account_key: 'manual:1510', account_number: '1510', name: 'Kundfordringar' }), + account({}), + ]) + snapshotMock.mockResolvedValue({ + period: PERIOD, + as_of: '2026-12-31', + rows: new Map([ + ['1930', { account_number: '1930', account_name: 'Företagskonto', opening_balance: 50000, movement: 12000, closing_balance: 62000 }], + ['1510', { account_number: '1510', account_name: 'Kundfordringar', opening_balance: 8000, movement: 4000, closing_balance: 12000 }], + ['2350', { account_number: '2350', account_name: 'Banklån', opening_balance: -260000, movement: 10000, closing_balance: -250000 }], + ]), + }) + specMock.mockResolvedValue(new Map([['1510', { amount: 11500, unconverted_fx_count: 0 }]])) + latestSignoffsMock.mockResolvedValue(new Map([['bank:11111111-1111-4111-8111-111111111111', signoffRow({ id: 's-bank', account_key: 'bank:11111111-1111-4111-8111-111111111111', through_date: '2026-11-30', external_balance: 61000, ledger_balance: 61000, note: null, signed_by: 'u2' })]])) + attachmentsMock.mockResolvedValue([ + { id: 'a1', account_key: 'manual:2350', through_date: '2026-12-31', file_name: 'engagemangsbesked.pdf', mime_type: 'application/pdf', size_bytes: 100, storage_bucket: 'documents', storage_path: 'x', sha256: 'ab'.repeat(32), note: null, uploaded_by: 'u1', uploaded_at: '2027-01-09T08:00:00Z', removed_at: null, removed_by: null, removed_reason: null }, + { id: 'a2', account_key: 'manual:2350', through_date: '2026-12-31', file_name: 'fel.pdf', mime_type: 'application/pdf', size_bytes: 100, storage_bucket: 'documents', storage_path: 'y', sha256: 'cd'.repeat(32), note: null, uploaded_by: 'u1', uploaded_at: '2027-01-09T08:00:00Z', removed_at: '2027-01-09T09:00:00Z', removed_by: 'u1', removed_reason: 'fel fil' }, + ]) + checklistMock.mockResolvedValue({ + period: PERIOD, + items: [{ key: 'inventory_valued', group: 'vardering', label_sv: 'Varulager', label_en: 'Inventory', auto: false, auto_state: null, stored_state: 'done', effective_state: 'done', note: null, done_by: 'u1', done_at: '2027-01-06T08:00:00Z' }], + summary: { total: 1, done: 1, not_applicable: 0, open: 0 }, + }) +}) + +describe('generateBokslutsbilagor', () => { + it('builds one bilaga per account from the snapshot, the sign-offs, the specification and the files', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: PERIOD }) // fiscal period + enqueue({ data: { name: 'Väla Redovisning AB', org_number: '5592383508' } }) // company + enqueue({ data: [signoffRow({})] }) // sign-offs on the balansdag + + const report = await generateBokslutsbilagor(supabase as never, COMPANY, 'fy-2026', { + userId: 'u1', + appVersion: '1.2.3', + resolveUserLabels: async (ids) => new Map(ids.map((id) => [id, `${id}@example.se`])), + }) + expect(report).not.toBeNull() + expect(report!.company).toEqual({ name: 'Väla Redovisning AB', org_number: '5592383508' }) + expect(report!.accounts.map((a) => a.account_number)).toEqual(['1930', '1510', '2350']) + + const [bank, ar, loan] = report!.accounts + // Bank: latest sign-off is not the balansdag, so it is flagged; the outside balance is unknown for the balansdag. + expect(bank).toMatchObject({ closing_balance: 62000, external_balance: null, difference: null }) + expect(bank.signoff).toMatchObject({ on_balansdag: false, through_date: '2026-11-30', signed_by_label: 'u2@example.se' }) + // AR: system specification, difference against the booked balance. + expect(ar).toMatchObject({ external_balance: 11500, closing_balance: 12000, difference: 500, external_label_sv: 'Kundreskontra, öppna fakturor' }) + expect(ar.signoff).toBeNull() + // Loan: stated balance from the balansdag sign-off, one active file, one removed. + expect(loan).toMatchObject({ opening_balance: -260000, movement: 10000, closing_balance: -250000, external_balance: -250000, difference: 0 }) + expect(loan.signoff).toMatchObject({ on_balansdag: true, note: 'Enligt engagemangsbesked', signed_by_label: 'u1@example.se' }) + expect(loan.attachments.map((a) => [a.file_name, a.removed_at != null])).toEqual([['engagemangsbesked.pdf', false], ['fel.pdf', true]]) + + expect(report!.summary).toEqual({ accounts: 3, signed_on_balansdag: 1, signed_other_date: 1, unsigned: 1, attachments: 1 }) + expect(report!.checklist.items[0]).toMatchObject({ key: 'inventory_valued', state: 'done', done_by_label: 'u1@example.se' }) + expect(report!.app_version).toBe('1.2.3') + expect(checklistMock).toHaveBeenCalledWith(supabase, COMPANY, 'u1', 'fy-2026', {}) + expect(listAccountsMock).toHaveBeenCalledWith(supabase, COMPANY, { today: '2026-12-31', windowFrom: '2026-01-01', windowTo: '2026-12-31' }) + }) + + it('skips the readiness-derived checklist items without a user, and returns null for a foreign period', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: PERIOD }) + enqueue({ data: { name: 'X', org_number: null } }) + enqueue({ data: [] }) + await generateBokslutsbilagor(supabase as never, COMPANY, 'fy-2026') + expect(checklistMock).toHaveBeenCalledWith(supabase, COMPANY, '', 'fy-2026', { readiness: null }) + + enqueue({ data: null }) + enqueue({ data: null }) + expect(await generateBokslutsbilagor(supabase as never, COMPANY, 'nope')).toBeNull() + }) + + it('survives a failed snapshot, sign-off or attachment read', async () => { + const { supabase, enqueue } = createQueuedMockSupabase() + enqueue({ data: PERIOD }) + enqueue({ data: { name: 'X', org_number: null } }) + enqueue({ data: null, error: { message: 'boom' } }) + snapshotMock.mockRejectedValue(new Error('tb down')) + attachmentsMock.mockRejectedValue(new Error('storage down')) + latestSignoffsMock.mockRejectedValue(new Error('signoffs down')) + const report = await generateBokslutsbilagor(supabase as never, COMPANY, 'fy-2026', { userId: 'u1' }) + expect(report!.accounts).toHaveLength(3) + expect(report!.accounts.every((a) => a.closing_balance === null && a.signoff === null && a.attachments.length === 0)).toBe(true) + expect(specMock).not.toHaveBeenCalled() + }) +}) diff --git a/lib/reports/__tests__/full-archive-export.test.ts b/lib/reports/__tests__/full-archive-export.test.ts index e00df793..f066bbc2 100644 --- a/lib/reports/__tests__/full-archive-export.test.ts +++ b/lib/reports/__tests__/full-archive-export.test.ts @@ -53,6 +53,11 @@ vi.mock('../journal-register', () => ({ // The bilagor step reads account_reconciliation_attachments through the // store; an empty list keeps the queued-mock order of these tests intact. +// The pärm step runs the reconciliation and checklist readers; a null report +// keeps the queued-mock order of these tests intact. +vi.mock('../bokslutsbilagor', () => ({ + generateBokslutsbilagor: vi.fn().mockResolvedValue(null), +})) vi.mock('@/lib/reconciliation/attachments-store', () => ({ listAttachmentRowsInRange: vi.fn().mockResolvedValue([]), })) diff --git a/lib/reports/bokslutsbilagor-pdf-template.tsx b/lib/reports/bokslutsbilagor-pdf-template.tsx new file mode 100644 index 00000000..be3532eb --- /dev/null +++ b/lib/reports/bokslutsbilagor-pdf-template.tsx @@ -0,0 +1,266 @@ +import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer' +import type { BilagaAccount, BilagaChecklistItem, BokslutsbilagorReport } from '@/lib/reports/bokslutsbilagor-types' +import { formatStockholmTimestamp } from '@/lib/reports/behandlingshistorik' + +/** + * Bokslutsbilagor as a printable pärm: the checklist first, then one bilaga + * per balance account with the balances, what it was reconciled against, the + * sign-off and the underlag files with their hashes. Same layout rules as the + * other report PDFs: bundled Helvetica/Courier (no Font.register), header and + * footer `fixed`, every row `wrap={false}`, and no `break` props (they + * deadlock multi-page renders in @react-pdf/renderer 4). + */ + +const INK = '#1a1a1a' +const MUTED = '#666' +const HAIRLINE = '#d4d4d4' + +const styles = StyleSheet.create({ + page: { paddingTop: 36, paddingHorizontal: 40, paddingBottom: 54, fontSize: 8.5, fontFamily: 'Helvetica', color: INK }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + marginBottom: 10, + paddingBottom: 10, + borderBottomWidth: 1, + borderBottomColor: HAIRLINE, + }, + titleBlock: { flex: 1 }, + title: { fontSize: 18, fontWeight: 'bold', marginBottom: 3 }, + subtitle: { fontSize: 9.5, color: '#333', marginBottom: 2 }, + legal: { fontSize: 8, color: MUTED }, + companyInfo: { textAlign: 'right' }, + companyName: { fontSize: 10, fontWeight: 'bold', marginBottom: 2 }, + companyMeta: { fontSize: 8.5, color: MUTED }, + meta: { flexDirection: 'row', flexWrap: 'wrap', marginBottom: 8 }, + metaItem: { width: '25%', paddingRight: 10, marginBottom: 4 }, + metaLabel: { fontSize: 7, color: MUTED, textTransform: 'uppercase', letterSpacing: 0.4 }, + metaValue: { fontSize: 9 }, + sectionHeading: { fontSize: 10.5, fontWeight: 'bold', marginTop: 12, marginBottom: 4, paddingBottom: 3, borderBottomWidth: 0.5, borderBottomColor: '#888' }, + sectionNote: { fontSize: 8, color: MUTED, marginBottom: 4 }, + row: { flexDirection: 'row', paddingVertical: 2.5, borderBottomWidth: 0.5, borderBottomColor: '#ececec' }, + checkState: { width: 70, fontFamily: 'Courier', fontSize: 7.5 }, + checkLabel: { flex: 1, paddingRight: 8 }, + checkWho: { width: 150, fontSize: 7.5, color: MUTED }, + bilaga: { marginTop: 8, paddingTop: 6, borderTopWidth: 0.5, borderTopColor: '#bbb' }, + bilagaHead: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 3 }, + bilagaTitle: { fontSize: 9.5, fontWeight: 'bold' }, + bilagaKey: { fontFamily: 'Courier', fontSize: 7.5, color: MUTED }, + numbers: { flexDirection: 'row', marginBottom: 3 }, + numCell: { width: '20%', paddingRight: 8 }, + numLabel: { fontSize: 6.8, color: MUTED, textTransform: 'uppercase', letterSpacing: 0.3 }, + numValue: { fontFamily: 'Courier', fontSize: 8.5 }, + line: { fontSize: 8, color: '#333', marginBottom: 1.5 }, + lineMuted: { fontSize: 7.5, color: MUTED, marginBottom: 1.5 }, + fileRow: { flexDirection: 'row', paddingVertical: 1.5 }, + fileName: { flex: 1, fontSize: 7.8, paddingRight: 6 }, + fileMeta: { width: 200, fontFamily: 'Courier', fontSize: 6.8, color: MUTED }, + empty: { fontSize: 8.5, color: MUTED, fontStyle: 'italic', paddingVertical: 6 }, + footer: { position: 'absolute', bottom: 22, left: 40, right: 40, borderTopWidth: 0.5, borderTopColor: HAIRLINE, paddingTop: 5, flexDirection: 'row', justifyContent: 'space-between' }, + footerText: { fontSize: 7.5, color: '#888' }, +}) + +/** WinAnsi only: arrows, true minus and narrow spaces would drop silently. */ +function pdfText(value: string): string { + return value.replace(/→/g, '->').replace(/−/g, '-').replace(/[   ]/g, ' ') +} + +const NUMBER = new Intl.NumberFormat('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + +function amount(n: number | null): string { + return n == null ? '-' : pdfText(NUMBER.format(n)) +} + +function formatOrgNumber(orgNumber: string): string { + const cleaned = orgNumber.replace(/\D/g, '') + return cleaned.length === 10 ? `${cleaned.slice(0, 6)}-${cleaned.slice(6)}` : orgNumber +} + +const STATE_LABEL: Record = { + done: '[x] Klart', + not_applicable: '[-] Ej tillämpl.', + open: '[ ] Öppet', +} + +const GROUP_LABEL: Record = { + avstamning: 'Avstämningar', + periodisering: 'Periodiseringar', + vardering: 'Värdering', + dispositioner: 'Dispositioner och skatt', + kontroll: 'Kontroller', + rapportering: 'Rapportering', +} + +function ChecklistRow({ item }: { item: BilagaChecklistItem }) { + const who = item.done_at ? `${item.done_by_label ?? ''} ${formatStockholmTimestamp(item.done_at)}`.trim() : '' + return ( + + {STATE_LABEL[item.state]} + + {pdfText(item.label_sv)} + {item.note ? ` (${pdfText(item.note)})` : ''} + + {pdfText(who)} + + ) +} + +function Bilaga({ account, balansdag }: { account: BilagaAccount; balansdag: string }) { + const s = account.signoff + const signLine = !s + ? 'Ej signerad.' + : s.on_balansdag + ? `Signerad per ${s.through_date} av ${s.signed_by_label} (${formatStockholmTimestamp(s.signed_at)})${s.note ? `: ${s.note}` : ''}` + : `Senast signerad t.o.m. ${s.through_date} av ${s.signed_by_label} (${formatStockholmTimestamp(s.signed_at)}), inte per balansdagen ${balansdag}${s.note ? `: ${s.note}` : ''}` + const active = account.attachments.filter((a) => !a.removed_at) + const removed = account.attachments.filter((a) => a.removed_at) + return ( + + + + {account.account_number} {pdfText(account.name)} + + {account.account_key} + + + + Ingående balans + {amount(account.opening_balance)} + + + Förändring + {amount(account.movement)} + + + Utgående balans + {amount(account.closing_balance)} + + + Enligt underlag + {amount(account.external_balance)} + + + Differens + {amount(account.difference)} + + + + Underlag: {pdfText(account.external_label_sv)} + + + {pdfText(signLine)} + + {active.length === 0 ? ( + + Inga bifogade filer. + + ) : ( + active.map((a) => ( + + + {pdfText(a.file_name)} + {a.note ? ` (${pdfText(a.note)})` : ''} + {a.through_date !== balansdag ? ` per ${a.through_date}` : ''} + + + sha256 {a.sha256.slice(0, 16)} · {formatStockholmTimestamp(a.uploaded_at)} + + + )) + )} + {removed.map((a) => ( + + + Borttagen: {pdfText(a.file_name)} + {a.removed_reason ? ` (${pdfText(a.removed_reason)})` : ''} + + {a.removed_at ? formatStockholmTimestamp(a.removed_at) : ''} + + ))} + + ) +} + +export interface BokslutsbilagorPDFProps { + report: BokslutsbilagorReport +} + +export function BokslutsbilagorPDF({ report }: BokslutsbilagorPDFProps) { + const generated = formatStockholmTimestamp(report.generated_at) + const groups = [...new Set(report.checklist.items.map((i) => i.group))] + return ( + + + + + Bokslutsbilagor + + {report.period.name} ({report.period.start} till {report.period.end}) · Balansdag {report.period.end} + + Avstämning och dokumentation per balanspost · Tider i Europe/Stockholm + + + {report.company.name ? {pdfText(report.company.name)} : null} + {report.company.org_number ? Org.nr: {formatOrgNumber(report.company.org_number)} : null} + + + + + + Genererad + {generated} + + + Programversion + {report.app_version ?? 'okänd'} + + + Balanskonton + + {String(report.summary.accounts)} · signerade per balansdagen {String(report.summary.signed_on_balansdag)} · annan dag{' '} + {String(report.summary.signed_other_date)} · osignerade {String(report.summary.unsigned)} + + + + Bifogade filer + {String(report.summary.attachments)} + + + + Bokslutschecklista + + {report.checklist.summary.done} klara, {report.checklist.summary.not_applicable} ej tillämpliga, {report.checklist.summary.open} öppna av{' '} + {report.checklist.summary.total}. Steg som systemet bedömer själv visas som de stod när pärmen genererades. + + {groups.map((group) => ( + + {GROUP_LABEL[group] ?? group} + {report.checklist.items.filter((i) => i.group === group).map((item) => )} + + ))} + + Bilagor per balanskonto + + Bokfört enligt saldobalansen per balansdagen. Enligt underlag: reskontra eller beräkning där systemet har en, annars det saldo som angavs + eller hämtades vid signeringen. Filer identifieras med SHA-256; borttagna filer finns kvar i arkivet. + + {report.accounts.length === 0 ? ( + Inga balanskonton med saldo eller rörelse i perioden. + ) : ( + report.accounts.map((account) => ) + )} + + + + {pdfText(report.company.name)} + {report.company.org_number ? ` · ${formatOrgNumber(report.company.org_number)}` : ''} + {' · Bokslutsbilagor '} + {report.period.name} + + `Genererad ${generated} · Sida ${pageNumber} av ${totalPages}`} /> + + + + ) +} diff --git a/lib/reports/bokslutsbilagor-types.ts b/lib/reports/bokslutsbilagor-types.ts new file mode 100644 index 00000000..3fb4eb1a --- /dev/null +++ b/lib/reports/bokslutsbilagor-types.ts @@ -0,0 +1,87 @@ +/** + * Bokslutsbilagor: the pärm a redovisningskonsult keeps per räkenskapsår + * (Reko 140/760/765). One bilaga per balance account as of the balansdag: + * the booked balance and how it moved, what it was reconciled against, who + * signed it and when, and the underlag files with their content hashes. The + * checklist rides along as the first page. Types live apart from the + * generator so the report view does not pull the PDF renderer into the + * client bundle. + */ + +import type { ChecklistState } from '@/lib/bokslut/checklist' + +export interface BilagaSignoff { + id: string + through_date: string + /** True when through_date is the balansdag; false when the latest sign-off ends earlier or later. */ + on_balansdag: boolean + external_balance: number | null + ledger_balance: number | null + unexplained_difference: number | null + note: string | null + signed_by: string + signed_by_label: string + signed_at: string +} + +export interface BilagaAttachment { + id: string + through_date: string + file_name: string + mime_type: string + size_bytes: number + sha256: string + note: string | null + uploaded_by_label: string + uploaded_at: string + removed_at: string | null + removed_reason: string | null +} + +export interface BilagaAccount { + account_key: string + kind: 'bank' | 'skattekonto' | 'manual' + account_number: string + name: string + /** From the trial balance through the balansdag; null when the account has no row in the period. */ + opening_balance: number | null + movement: number | null + closing_balance: number | null + /** What the account was reconciled against: system specification, stated balance, or the feed's balance at sign-off. */ + external_label_sv: string + external_label_en: string + external_balance: number | null + difference: number | null + signoff: BilagaSignoff | null + attachments: BilagaAttachment[] +} + +export interface BilagaChecklistItem { + key: string + group: string + label_sv: string + label_en: string + state: ChecklistState + done_at: string | null + done_by_label: string | null + note: string | null +} + +export interface BokslutsbilagorReport { + company: { name: string; org_number: string | null } + period: { id: string; name: string; start: string; end: string } + generated_at: string + app_version: string | null + checklist: { + items: BilagaChecklistItem[] + summary: { total: number; done: number; not_applicable: number; open: number } + } + accounts: BilagaAccount[] + summary: { + accounts: number + signed_on_balansdag: number + signed_other_date: number + unsigned: number + attachments: number + } +} diff --git a/lib/reports/bokslutsbilagor.ts b/lib/reports/bokslutsbilagor.ts new file mode 100644 index 00000000..5ead64f1 --- /dev/null +++ b/lib/reports/bokslutsbilagor.ts @@ -0,0 +1,221 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { createLogger } from '@/lib/logger' +import { roundOre } from '@/lib/money' +import { listReconciliationAccounts } from '@/lib/reconciliation/service' +import { + loadBalanceSheetSnapshot, + loadSpecificationAmounts, + SPECIFICATION_PROVIDERS, + type BalanceSheetSnapshot, +} from '@/lib/reconciliation/manual-reconciliation' +import { getLatestSignoffs, mapSignoffRow } from '@/lib/reconciliation/signoff-store' +import { listAttachmentRowsInRange, type AttachmentRow } from '@/lib/reconciliation/attachments-store' +import type { ReconciliationAccount, ReconciliationSignoff } from '@/lib/reconciliation/schemas' +import { buildBokslutChecklist } from '@/lib/bokslut/checklist' +import type { BilagaAccount, BilagaAttachment, BilagaSignoff, BokslutsbilagorReport } from './bokslutsbilagor-types' + +const log = createLogger('reports/bokslutsbilagor') + +export interface BokslutsbilagorOptions { + /** The acting user; without one the checklist skips the readiness-derived items (archive runs have no user). */ + userId?: string | null + /** Resolves auth user ids to display labels (email / name); defaults to the id itself. */ + resolveUserLabels?: (ids: string[]) => Promise> + appVersion?: string | null +} + +interface PeriodRow { + id: string + name: string + period_start: string + period_end: string +} + +const KIND_ORDER: Record = { bank: 0, skattekonto: 1, manual: 2 } + +function externalLabels(account: ReconciliationAccount, hasSpecification: boolean): { sv: string; en: string } { + if (account.kind === 'bank') return { sv: 'Banken (saldo vid signering)', en: 'The bank (balance at sign-off)' } + if (account.kind === 'skattekonto') return { sv: 'Skatteverket (saldo vid signering)', en: 'Skatteverket (balance at sign-off)' } + if (hasSpecification) { + const p = SPECIFICATION_PROVIDERS[account.account_number] + return { sv: p.label_sv, en: p.label_en } + } + return { sv: 'Saldo enligt underlag (angivet vid signering)', en: 'Balance per supporting documents (stated at sign-off)' } +} + +/** + * The pärm for one räkenskapsår. Null when the period is not this company's. + * One trial-balance read, one reconciliation list, one sign-off read per + * flavour, one attachment read: no per-account status recomputation. + */ +export async function generateBokslutsbilagor( + supabase: SupabaseClient, + companyId: string, + periodId: string, + options: BokslutsbilagorOptions = {}, +): Promise { + const [{ data: periodData, error: periodError }, { data: companyData }] = await Promise.all([ + supabase.from('fiscal_periods').select('id, name, period_start, period_end').eq('id', periodId).eq('company_id', companyId).maybeSingle(), + supabase.from('companies').select('name, org_number').eq('id', companyId).maybeSingle(), + ]) + if (periodError) throw new Error(`Kunde inte hämta räkenskapsår: ${periodError.message}`) + const period = periodData as PeriodRow | null + if (!period) return null + const company = (companyData as { name: string | null; org_number: string | null } | null) ?? { name: null, org_number: null } + const balansdag = period.period_end + + const [accounts, snapshot, balansdagSignoffs, latestSignoffs, attachmentRows, checklist] = await Promise.all([ + listReconciliationAccounts(supabase, companyId, { today: balansdag, windowFrom: period.period_start, windowTo: balansdag }), + loadBalanceSheetSnapshot(supabase, companyId, balansdag).catch((err): BalanceSheetSnapshot | null => { + log.warn('balance snapshot failed', { companyId, periodId, error: String(err) }) + return null + }), + signoffsOn(supabase, companyId, balansdag), + getLatestSignoffs(supabase, companyId).catch(() => new Map()), + listAttachmentRowsInRange(supabase, companyId, period.period_start, balansdag, { includeRemoved: true }).catch((err): AttachmentRow[] => { + log.warn('attachment read failed', { companyId, periodId, error: String(err) }) + return [] + }), + buildBokslutChecklist(supabase, companyId, options.userId ?? '', periodId, options.userId ? {} : { readiness: null }), + ]) + + const specifications = snapshot + ? await loadSpecificationAmounts( + supabase, + companyId, + snapshot, + new Set(accounts.filter((a) => a.kind === 'manual' && SPECIFICATION_PROVIDERS[a.account_number]).map((a) => a.account_number)), + ) + : new Map() + + const attachmentsByKey = new Map() + for (const row of attachmentRows) { + attachmentsByKey.set(row.account_key, [...(attachmentsByKey.get(row.account_key) ?? []), row]) + } + + const userIds = new Set() + for (const s of balansdagSignoffs.values()) userIds.add(s.signed_by) + for (const s of latestSignoffs.values()) if (s) userIds.add(s.signed_by) + for (const a of attachmentRows) userIds.add(a.uploaded_by) + for (const i of checklist?.items ?? []) if (i.done_by) userIds.add(i.done_by) + let labels = new Map() + if (options.resolveUserLabels && userIds.size > 0) { + try { + labels = await options.resolveUserLabels([...userIds]) + } catch (err) { + log.warn('user label resolution failed', { companyId, error: String(err) }) + } + } + const label = (id: string) => labels.get(id) ?? id + + const bilagor: BilagaAccount[] = [...accounts] + .sort((a, b) => KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || a.account_number.localeCompare(b.account_number)) + .map((account): BilagaAccount => { + const row = snapshot?.rows.get(account.account_number) ?? null + const spec = account.kind === 'manual' ? specifications.get(account.account_number) : undefined + const onDay = balansdagSignoffs.get(account.account_key) ?? null + const latest = latestSignoffs.get(account.account_key) ?? null + const chosen = onDay ?? latest + const signoff: BilagaSignoff | null = chosen + ? { + id: chosen.id, + through_date: chosen.through_date, + on_balansdag: chosen.through_date === balansdag, + external_balance: chosen.external_balance, + ledger_balance: chosen.ledger_balance, + unexplained_difference: chosen.unexplained_difference, + note: chosen.note, + signed_by: chosen.signed_by, + signed_by_label: label(chosen.signed_by), + signed_at: chosen.signed_at, + } + : null + // The outside side: the system specification for the reskontra accounts, + // else what was recorded at the balansdag sign-off (the feed's balance, + // or the balance the signer stated). + const external = spec ? spec.amount : onDay ? onDay.external_balance : null + const closing = row ? row.closing_balance : (onDay?.ledger_balance ?? null) + const difference = external != null && closing != null ? roundOre(closing - external) : null + const labelsFor = externalLabels(account, Boolean(spec)) + return { + account_key: account.account_key, + kind: account.kind, + account_number: account.account_number, + name: account.name, + opening_balance: row?.opening_balance ?? null, + movement: row?.movement ?? null, + closing_balance: closing, + external_label_sv: labelsFor.sv, + external_label_en: labelsFor.en, + external_balance: external, + difference, + signoff, + attachments: (attachmentsByKey.get(account.account_key) ?? []).map( + (a): BilagaAttachment => ({ + id: a.id, + through_date: a.through_date, + file_name: a.file_name, + mime_type: a.mime_type, + size_bytes: a.size_bytes, + sha256: a.sha256, + note: a.note, + uploaded_by_label: label(a.uploaded_by), + uploaded_at: a.uploaded_at, + removed_at: a.removed_at, + removed_reason: a.removed_reason, + }), + ), + } + }) + + const signedOnDay = bilagor.filter((b) => b.signoff?.on_balansdag).length + const signedOther = bilagor.filter((b) => b.signoff && !b.signoff.on_balansdag).length + + return { + company: { name: company.name ?? '', org_number: company.org_number ?? null }, + period: { id: period.id, name: period.name, start: period.period_start, end: period.period_end }, + generated_at: new Date().toISOString(), + app_version: options.appVersion ?? null, + checklist: { + items: (checklist?.items ?? []).map((i) => ({ + key: i.key, + group: i.group, + label_sv: i.label_sv, + label_en: i.label_en, + state: i.effective_state, + done_at: i.done_at, + done_by_label: i.done_by ? label(i.done_by) : null, + note: i.note, + })), + summary: checklist?.summary ?? { total: 0, done: 0, not_applicable: 0, open: 0 }, + }, + accounts: bilagor, + summary: { + accounts: bilagor.length, + signed_on_balansdag: signedOnDay, + signed_other_date: signedOther, + unsigned: bilagor.length - signedOnDay - signedOther, + attachments: attachmentRows.filter((a) => !a.removed_at).length, + }, + } +} + +/** Active sign-offs whose through_date is exactly the balansdag, keyed by account. */ +async function signoffsOn(supabase: SupabaseClient, companyId: string, throughDate: string): Promise> { + const out = new Map() + const { data, error } = await supabase + .from('account_reconciliations') + .select('id, account_key, through_date, external_balance, ledger_balance, unexplained_difference, note, signed_by, signed_at, reopened_at, reopened_by, reopen_reason') + .eq('company_id', companyId) + .eq('through_date', throughDate) + .is('reopened_at', null) + if (error) { + log.warn('balansdag sign-off read failed', { companyId, throughDate, error: error.message }) + return out + } + for (const row of (data ?? []) as Parameters[0][]) { + const mapped = mapSignoffRow(row) + out.set(mapped.account_key, mapped) + } + return out +} diff --git a/lib/reports/catalog.ts b/lib/reports/catalog.ts index 430cea68..68ca6710 100644 --- a/lib/reports/catalog.ts +++ b/lib/reports/catalog.ts @@ -336,6 +336,21 @@ export const REPORT_CATALOG: ReportDescriptor[] = [ searchTerms: 'behandlingshistorik audit trail audit log händelselogg ändringslogg logg historik vem gjorde vad processing history revision systemdokumentation', }, + { + // Bokslutsbilagor (Reko 140/760/765): the pärm per räkenskapsår, one + // bilaga per balance account as of the balansdag with balances, the + // specification or stated balance, the sign-off and the underlag files. + // Whole period only: a bilaga is per balansdag, not per date range. + slug: 'bokslutsbilagor', + labelKey: 'name_bokslutsbilagor', + descKey: 'desc_bokslutsbilagor', + category: 'export', + params: 'fiscal', + exports: ['pdf'], + libraryOnly: true, + searchTerms: + 'bokslutsbilagor bilagor bilaga bokslutspärm pärm avstämning avstämningar underlag signering reko balanskonton specifikation kontoutdrag engagemangsbesked checklista', + }, ] /** Reports that take a fiscal period + optional date sub-range. */ diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index 99924017..26162e14 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -10,6 +10,7 @@ import { calculateVatDeclaration } from './vat-declaration' import { getAuditLog } from '@/lib/core/audit/audit-service' import { downloadDocumentObject } from '@/lib/core/documents/document-service' import { listAttachmentRowsInRange } from '@/lib/reconciliation/attachments-store' +import { generateBokslutsbilagor } from './bokslutsbilagor' import { fetchAllRows } from '@/lib/supabase/fetch-all' import { getBranding } from '@/lib/branding/service' import { @@ -153,6 +154,7 @@ export async function generateFullArchive( const reports = await generatePeriodReports(supabase, companyId, period) const periodFolder = rapporterFolder.folder(periodLabel(period))! writeReports(periodFolder, reports) + await writeBokslutsbilagor(periodFolder, supabase, companyId, period.id) }) ) } @@ -168,6 +170,7 @@ export async function generateFullArchive( const reports = await generatePeriodReports(supabase, companyId, period) const rapporter = zip.folder('rapporter')! writeReports(rapporter, reports) + await writeBokslutsbilagor(rapporter, supabase, companyId, period.id) } if (options.include_documents !== false) { @@ -556,6 +559,36 @@ async function writeDocuments( dokument.file('manifest.json', JSON.stringify(manifest, null, 2)) } +/** + * The bokslutsbilagor pärm for one period, as JSON and PDF next to the other + * reports. Archive runs have no acting user, so the checklist's + * readiness-derived items are left as stored. Best-effort like the reports: + * a failure is logged into the folder rather than aborting the archive. + */ +async function writeBokslutsbilagor( + folder: JSZip, + supabase: SupabaseClient, + companyId: string, + periodId: string +): Promise { + try { + const report = await generateBokslutsbilagor(supabase, companyId, periodId, { appVersion: currentAppVersion() }) + if (!report) return + folder.file('bokslutsbilagor.json', JSON.stringify(report, null, 2)) + // The renderer and the template load on demand: the template registers + // styles at import time, and this module is imported far more widely + // than the pärm is rendered (tests stub @react-pdf/renderer partially). + const [{ BokslutsbilagorPDF }, { renderToBuffer }] = await Promise.all([ + import('./bokslutsbilagor-pdf-template'), + import('@react-pdf/renderer'), + ]) + const pdf = await renderToBuffer(BokslutsbilagorPDF({ report })) + folder.file('bokslutsbilagor.pdf', new Uint8Array(pdf)) + } catch (err) { + folder.file('bokslutsbilagor.error.txt', err instanceof Error ? err.message : 'Unknown error') + } +} + interface ReconciliationAttachmentManifestEntry { attachment_id: string account_key: string diff --git a/messages/en.json b/messages/en.json index 1aa72028..88709b75 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6732,6 +6732,8 @@ "name_grundbok": "Journal register", "name_behandlingshistorik": "Processing history", "desc_behandlingshistorik": "Who did what and when: posted vouchers, corrections and changes to the system (BFL 5 kap. 11 §)", + "name_bokslutsbilagor": "Closing binder", + "desc_bokslutsbilagor": "The binder per fiscal year: every balance sheet account as of the balance sheet date with booked balance, supporting documents, sign-off and attached files", "bh_summary": "{count, plural, =1 {1 event} other {# events}}", "bh_range_to": "to", "bh_version": "Software version", @@ -8091,5 +8093,23 @@ "gap": "{from} to {to} (between {after} and {before})", "hint": "Import the SIE file for that year, or create the fiscal year manually, so balances roll forward.", "manage": "Manage fiscal years" + }, + "bokslutsbilagor": { + "open_parm": "Closing binder", + "load_failed": "Could not load the closing binder.", + "empty_title": "No balance sheet accounts yet", + "empty_desc": "The binder fills up once the fiscal year has balances or movements on balance sheet accounts. Reconcile and sign them on Reconciliation.", + "open_reconciliation": "Open Reconciliation", + "summary": "{accounts} balance sheet accounts as of {date}, {signed} signed off on the balance sheet date, {files} attached files", + "checklist_summary": "checklist {done} of {total}", + "col_account": "Account", + "col_booked": "Booked", + "col_external": "Per documents", + "col_difference": "Difference", + "col_signoff": "Sign-off", + "col_files": "Files", + "signed": "{who}, {when}", + "signed_other_date": "through {date} ({who}), not the balance sheet date", + "unsigned": "Not signed off" } } diff --git a/messages/sv.json b/messages/sv.json index 18ea6433..4a8405d2 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -6732,6 +6732,8 @@ "name_grundbok": "Grundbok", "name_behandlingshistorik": "Behandlingshistorik", "desc_behandlingshistorik": "Vem gjorde vad och när: bokförda verifikationer, rättelser och ändringar i systemet (BFL 5 kap. 11 §)", + "name_bokslutsbilagor": "Bokslutsbilagor", + "desc_bokslutsbilagor": "Pärmen per räkenskapsår: varje balanskonto per balansdagen med bokfört saldo, underlag, signering och bifogade filer", "bh_summary": "{count, plural, =1 {1 händelse} other {# händelser}}", "bh_range_to": "till", "bh_version": "Programversion", @@ -8091,5 +8093,23 @@ "gap": "{from} till {to} (mellan {after} och {before})", "hint": "Importera SIE-filen för det året, eller skapa räkenskapsåret manuellt, så att balanserna rullar fram.", "manage": "Hantera räkenskapsår" + }, + "bokslutsbilagor": { + "open_parm": "Bokslutsbilagor", + "load_failed": "Kunde inte hämta bokslutsbilagorna.", + "empty_title": "Inga balanskonton ännu", + "empty_desc": "Pärmen fylls när räkenskapsåret har saldon eller rörelser på balanskonton. Stäm av och signera dem på Avstämning.", + "open_reconciliation": "Öppna Avstämning", + "summary": "{accounts} balanskonton per {date}, {signed} signerade per balansdagen, {files} bifogade filer", + "checklist_summary": "checklista {done} av {total}", + "col_account": "Konto", + "col_booked": "Bokfört", + "col_external": "Enligt underlag", + "col_difference": "Differens", + "col_signoff": "Signering", + "col_files": "Filer", + "signed": "{who}, {when}", + "signed_other_date": "t.o.m. {date} ({who}), inte balansdagen", + "unsigned": "Ej signerad" } }