feat(reports): bokslutsbilagor, the pärm per räkenskapsår (Reko bilagor, PR 4) (#1874)
* 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> 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 <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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
c62321988b
commit
9ce1ebc65f
@@ -1197,3 +1197,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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/<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.
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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 +
|
||||
|
||||
@@ -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={
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Link href="/reports/bokslutsbilagor" className={cn(QUIET_LINK_CLASS, 'mr-2')}>
|
||||
{tParm('open_parm')}
|
||||
</Link>
|
||||
<SegmentedControl
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { AlertCircle, FolderArchive } from 'lucide-react'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { EmptyState } from '@/components/ui/empty-state'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import { ReportExportMenu } from '@/components/reports/ReportExportMenu'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { BokslutsbilagorReport } from '@/lib/reports/bokslutsbilagor-types'
|
||||
|
||||
/**
|
||||
* The bokslutsbilagor pärm on screen: one row per balance account as of the
|
||||
* balansdag (booked, per underlag, difference, sign-off, files) with the PDF
|
||||
* export; each row links into the Avstämning page where the work is done.
|
||||
*/
|
||||
interface BokslutsbilagorViewProps {
|
||||
periodId: string
|
||||
}
|
||||
|
||||
export function BokslutsbilagorView({ periodId }: BokslutsbilagorViewProps) {
|
||||
const t = useTranslations('bokslutsbilagor')
|
||||
const locale = useLocale()
|
||||
const [report, setReport] = useState<BokslutsbilagorReport | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="space-y-3" aria-busy>
|
||||
<Skeleton className="h-4 w-1/3" />
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-destructive">
|
||||
<AlertCircle className="mx-auto mb-2 h-6 w-6" />
|
||||
{error}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
if (!report || report.accounts.length === 0) {
|
||||
return <EmptyState icon={FolderArchive} title={t('empty_title')} description={t('empty_desc')} actionLabel={t('open_reconciliation')} actionHref="/reconciliation" />
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-sm text-muted-foreground tabular-nums">
|
||||
{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 })}
|
||||
</p>
|
||||
<ReportExportMenu items={[{ format: 'pdf', href: `/api/reports/bokslutsbilagor?period_id=${encodeURIComponent(periodId)}&format=pdf` }]} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[820px] text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={cn(TH_CLASS, 'w-[240px]')}>{t('col_account')}</th>
|
||||
<th className={cn(TH_CLASS, 'text-right')}>{t('col_booked')}</th>
|
||||
<th className={cn(TH_CLASS, 'text-right')}>{t('col_external')}</th>
|
||||
<th className={cn(TH_CLASS, 'text-right')}>{t('col_difference')}</th>
|
||||
<th className={TH_CLASS}>{t('col_signoff')}</th>
|
||||
<th className={cn(TH_CLASS, 'text-right w-[70px]')}>{t('col_files')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.accounts.map((a) => {
|
||||
const files = a.attachments.filter((f) => !f.removed_at).length
|
||||
return (
|
||||
<tr key={a.account_key} className="border-b border-border/60 last:border-b-0 align-top">
|
||||
<td className={cn(TD_CLASS, 'max-w-0')}>
|
||||
<Link href={`/reconciliation?account=${encodeURIComponent(a.account_key)}`} className={QUIET_LINK_CLASS} data-ph-mask>
|
||||
<span className="tabular-nums">{a.account_number}</span> {a.name}
|
||||
</Link>
|
||||
<div className="truncate text-[11.5px] text-muted-foreground">{externalLabel(a)}</div>
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')} data-ph-mask>
|
||||
{money(a.closing_balance)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')} data-ph-mask>
|
||||
{money(a.external_balance)}
|
||||
</td>
|
||||
<td
|
||||
className={cn(
|
||||
TD_CLASS,
|
||||
'whitespace-nowrap text-right tabular-nums',
|
||||
a.difference != null && Math.abs(a.difference) >= 0.005 && 'text-warning',
|
||||
)}
|
||||
data-ph-mask
|
||||
>
|
||||
{money(a.difference)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'text-[12.5px]')}>
|
||||
{a.signoff ? (
|
||||
<span className={cn(!a.signoff.on_balansdag && 'text-warning')} data-ph-mask>
|
||||
{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 })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('unsigned')}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'text-right tabular-nums')} data-ph-mask>
|
||||
{files}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 <SupplierLedgerView periodId={periodId} />
|
||||
case 'behandlingshistorik':
|
||||
return <BehandlingshistorikView periodId={periodId} dateRange={dateRange} />
|
||||
case 'bokslutsbilagor':
|
||||
return <BokslutsbilagorView key={periodId} periodId={periodId} />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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<typeof import('@/lib/reconciliation/manual-reconciliation')>('@/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<typeof import('@/lib/reconciliation/signoff-store')>('@/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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -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([]),
|
||||
}))
|
||||
|
||||
@@ -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<BilagaChecklistItem['state'], string> = {
|
||||
done: '[x] Klart',
|
||||
not_applicable: '[-] Ej tillämpl.',
|
||||
open: '[ ] Öppet',
|
||||
}
|
||||
|
||||
const GROUP_LABEL: Record<string, string> = {
|
||||
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 (
|
||||
<View style={styles.row} wrap={false}>
|
||||
<Text style={styles.checkState}>{STATE_LABEL[item.state]}</Text>
|
||||
<Text style={styles.checkLabel}>
|
||||
{pdfText(item.label_sv)}
|
||||
{item.note ? ` (${pdfText(item.note)})` : ''}
|
||||
</Text>
|
||||
<Text style={styles.checkWho}>{pdfText(who)}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={styles.bilaga}>
|
||||
<View style={styles.bilagaHead} wrap={false}>
|
||||
<Text style={styles.bilagaTitle}>
|
||||
{account.account_number} {pdfText(account.name)}
|
||||
</Text>
|
||||
<Text style={styles.bilagaKey}>{account.account_key}</Text>
|
||||
</View>
|
||||
<View style={styles.numbers} wrap={false}>
|
||||
<View style={styles.numCell}>
|
||||
<Text style={styles.numLabel}>Ingående balans</Text>
|
||||
<Text style={styles.numValue}>{amount(account.opening_balance)}</Text>
|
||||
</View>
|
||||
<View style={styles.numCell}>
|
||||
<Text style={styles.numLabel}>Förändring</Text>
|
||||
<Text style={styles.numValue}>{amount(account.movement)}</Text>
|
||||
</View>
|
||||
<View style={styles.numCell}>
|
||||
<Text style={styles.numLabel}>Utgående balans</Text>
|
||||
<Text style={styles.numValue}>{amount(account.closing_balance)}</Text>
|
||||
</View>
|
||||
<View style={styles.numCell}>
|
||||
<Text style={styles.numLabel}>Enligt underlag</Text>
|
||||
<Text style={styles.numValue}>{amount(account.external_balance)}</Text>
|
||||
</View>
|
||||
<View style={styles.numCell}>
|
||||
<Text style={styles.numLabel}>Differens</Text>
|
||||
<Text style={styles.numValue}>{amount(account.difference)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.lineMuted} wrap={false}>
|
||||
Underlag: {pdfText(account.external_label_sv)}
|
||||
</Text>
|
||||
<Text style={styles.line} wrap={false}>
|
||||
{pdfText(signLine)}
|
||||
</Text>
|
||||
{active.length === 0 ? (
|
||||
<Text style={styles.lineMuted} wrap={false}>
|
||||
Inga bifogade filer.
|
||||
</Text>
|
||||
) : (
|
||||
active.map((a) => (
|
||||
<View key={a.id} style={styles.fileRow} wrap={false}>
|
||||
<Text style={styles.fileName}>
|
||||
{pdfText(a.file_name)}
|
||||
{a.note ? ` (${pdfText(a.note)})` : ''}
|
||||
{a.through_date !== balansdag ? ` per ${a.through_date}` : ''}
|
||||
</Text>
|
||||
<Text style={styles.fileMeta}>
|
||||
sha256 {a.sha256.slice(0, 16)} · {formatStockholmTimestamp(a.uploaded_at)}
|
||||
</Text>
|
||||
</View>
|
||||
))
|
||||
)}
|
||||
{removed.map((a) => (
|
||||
<View key={a.id} style={styles.fileRow} wrap={false}>
|
||||
<Text style={[styles.fileName, { color: MUTED }]}>
|
||||
Borttagen: {pdfText(a.file_name)}
|
||||
{a.removed_reason ? ` (${pdfText(a.removed_reason)})` : ''}
|
||||
</Text>
|
||||
<Text style={styles.fileMeta}>{a.removed_at ? formatStockholmTimestamp(a.removed_at) : ''}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Document title={`Bokslutsbilagor ${report.period.name}`} author={report.company.name} subject="Bokslutsbilagor per balansdagen">
|
||||
<Page size="A4" style={styles.page}>
|
||||
<View style={styles.header} fixed>
|
||||
<View style={styles.titleBlock}>
|
||||
<Text style={styles.title}>Bokslutsbilagor</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{report.period.name} ({report.period.start} till {report.period.end}) · Balansdag {report.period.end}
|
||||
</Text>
|
||||
<Text style={styles.legal}>Avstämning och dokumentation per balanspost · Tider i Europe/Stockholm</Text>
|
||||
</View>
|
||||
<View style={styles.companyInfo}>
|
||||
{report.company.name ? <Text style={styles.companyName}>{pdfText(report.company.name)}</Text> : null}
|
||||
{report.company.org_number ? <Text style={styles.companyMeta}>Org.nr: {formatOrgNumber(report.company.org_number)}</Text> : null}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.meta}>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Genererad</Text>
|
||||
<Text style={styles.metaValue}>{generated}</Text>
|
||||
</View>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Programversion</Text>
|
||||
<Text style={styles.metaValue}>{report.app_version ?? 'okänd'}</Text>
|
||||
</View>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Balanskonton</Text>
|
||||
<Text style={styles.metaValue}>
|
||||
{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)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.metaItem}>
|
||||
<Text style={styles.metaLabel}>Bifogade filer</Text>
|
||||
<Text style={styles.metaValue}>{String(report.summary.attachments)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionHeading}>Bokslutschecklista</Text>
|
||||
<Text style={styles.sectionNote}>
|
||||
{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.
|
||||
</Text>
|
||||
{groups.map((group) => (
|
||||
<View key={group}>
|
||||
<Text style={[styles.lineMuted, { marginTop: 4 }]}>{GROUP_LABEL[group] ?? group}</Text>
|
||||
{report.checklist.items.filter((i) => i.group === group).map((item) => <ChecklistRow key={item.key} item={item} />)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
<Text style={styles.sectionHeading}>Bilagor per balanskonto</Text>
|
||||
<Text style={styles.sectionNote}>
|
||||
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.
|
||||
</Text>
|
||||
{report.accounts.length === 0 ? (
|
||||
<Text style={styles.empty}>Inga balanskonton med saldo eller rörelse i perioden.</Text>
|
||||
) : (
|
||||
report.accounts.map((account) => <Bilaga key={account.account_key} account={account} balansdag={report.period.end} />)
|
||||
)}
|
||||
|
||||
<View style={styles.footer} fixed>
|
||||
<Text style={styles.footerText}>
|
||||
{pdfText(report.company.name)}
|
||||
{report.company.org_number ? ` · ${formatOrgNumber(report.company.org_number)}` : ''}
|
||||
{' · Bokslutsbilagor '}
|
||||
{report.period.name}
|
||||
</Text>
|
||||
<Text style={styles.footerText} render={({ pageNumber, totalPages }) => `Genererad ${generated} · Sida ${pageNumber} av ${totalPages}`} />
|
||||
</View>
|
||||
</Page>
|
||||
</Document>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<Map<string, string>>
|
||||
appVersion?: string | null
|
||||
}
|
||||
|
||||
interface PeriodRow {
|
||||
id: string
|
||||
name: string
|
||||
period_start: string
|
||||
period_end: string
|
||||
}
|
||||
|
||||
const KIND_ORDER: Record<ReconciliationAccount['kind'], number> = { 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<BokslutsbilagorReport | null> {
|
||||
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<string, ReconciliationSignoff | null>()),
|
||||
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<string, AttachmentRow[]>()
|
||||
for (const row of attachmentRows) {
|
||||
attachmentsByKey.set(row.account_key, [...(attachmentsByKey.get(row.account_key) ?? []), row])
|
||||
}
|
||||
|
||||
const userIds = new Set<string>()
|
||||
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<string, string>()
|
||||
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<Map<string, ReconciliationSignoff>> {
|
||||
const out = new Map<string, ReconciliationSignoff>()
|
||||
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<typeof mapSignoffRow>[0][]) {
|
||||
const mapped = mapSignoffRow(row)
|
||||
out.set(mapped.account_key, mapped)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user