feat(bokslut): Phases 4-7 — accruals + årsredovisning + EF + MCP tools (#509)

* feat(bokslut): Phases 4-7 — accruals + årsredovisning + EF + MCP tools

Builds on the Phase 1-3 PR (#508). Same K2-AB scope decisions hold (iXBRL
deferred, K3 deferred, koncernredovisning out of scope) — what ships:

PHASE 4 — Periodiseringar
- lib/bokslut/accruals/accrual-detector.ts: auto-proposes the vacation-
  liability change (delta on 2920 against 7090 + 31,42 % avgifter on
  7519/2940) by reading lib/reports/vacation-liability.ts. Manual prepaid
  (17xx) / accrued (29xx) / audit-fee builders for entries the heuristic
  can't derive — supplier-invoice service_period detection is deferred
  until the data model grows the field.
- /api/bookkeeping/fiscal-periods/[id]/accruals (GET + POST) — posts each
  accrual as a separate manual-source journal entry with the next-day
  reverse date embedded in the description.
- New AccrualsStep wizard step between Preflight and Dispositions.
- Auto-reversal cron is follow-up infra; for now reverses_on is metadata
  + a visible UI badge.

PHASE 5 — Årsredovisning PDF + signing
- lib/bokslut/arsredovisning/{types,build-data,arsredovisning-pdf,signature
  -service}: pre-fills flerårsöversikt from prior 3 fiscal periods, eget-
  kapital-förändring from journal data, K2-minimum noter with
  avskrivningstider auto-derived from the asset register and medelantal
  anställda from the employees table. PDF via @react-pdf/renderer mirroring
  the income-statement/pdf pattern.
- Migration 20260516170000: arsredovisning_signature_requests with RLS,
  signed-immutability trigger, and DELETE policy that blocks signed rows.
  Signature-service exposes list/create/markSigned/isFullySignedOff.
- BankID call itself is not wired here — the table + service make the
  request layer available so a follow-up can hook lib/auth/bankid.ts to the
  sign action without rework.
- /api/.../arsredovisning (data + pdf + signatures) endpoints.
- /bookkeeping/year-end/arsredovisning page with editable narrative,
  flerårsöversikt table, signer slots, PDF download, and Bolagsverket
  Mina Sidor link. Explicit warning about the FY2026 iXBRL mandate.

PHASE 6 — Enskild firma NE-bilaga UI
- lib/bokslut/enskild-firma/{egenavgifter,rantefordelning,periodiseringsfond
  -ef,expansionsfond}-calculator.ts. All declaration-only — never produce
  a journal entry. Egenavgifter 28,97 % / pensionärssats 10,21 % / passive
  SLP 24,26 %, schablonavdrag 25/10/20 %. Räntefördelning SLR+6 / SLR+1
  with the -500 000 negative threshold. P-fond EF cap 30 % (vs 25 % för
  AB). Expansionsfond 125,94 % av kapitalunderlag, 20,6 % skatt.
- EfDeclarationSection mounted inside DispositionsStep when entity_type is
  enskild_firma — live recompute as the user adjusts kapitalunderlag,
  prior-year amounts, p-fond desired, expansionsfond change. Each card
  shows the NE-bilaga ruta the number lands in. NE-bilaga preview link.

PHASE 7 — Agent-native MCP tools
- gnubok_propose_dispositioner: read-only AB dispositions proposal
- gnubok_propose_accruals: read-only accruals proposal
- gnubok_propose_annual_depreciation: read-only depreciation per asset
- gnubok_post_annual_depreciation: stages depreciation commit (high-risk)
- gnubok_preview_arsredovisning: structured K2 ÅR preview
- gnubok_preview_ef_declaration: EF skattemässiga justeringar preview
- All ≤280-char descriptions, additionalProperties:false, conform to
  STAGED_OPERATION_SCHEMA for write tools. Read tools return the same
  shapes as their HTTP counterparts so agents and the UI share a contract.
- The existing GET /bokslutsdispositioner endpoint now calls the shared
  buildDispositionsProposal helper that the MCP tool also uses, removing
  the duplicate logic that lived in both.

Verification
- 149 unit tests pass (was 125 on Phase 1-3; +24 across accruals, EF
  calculators, expansionsfond, periodiseringsfond-ef)
- Zero lint or typecheck errors on any new file
- Migration 20260516170000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address PR #509 round-1 — CI blocker + 3 P1s + compliance bundle

CI BLOCKER (output-schema test)
- The 5 new read-only MCP tools (gnubok_propose_dispositioner / _accruals /
  _annual_depreciation, _preview_arsredovisning / _ef_declaration) were
  missing outputSchema, which the existing strict-schemas guard reads as a
  hard failure on core-only CI. Added a permissive { type: 'object',
  additionalProperties: true } outputSchema to each — the return shapes are
  the same as their HTTP counterparts and trying to mirror them inline
  would duplicate the type tree across two boundaries.

P1 — vacation accrual: two real bugs in one entry
- Delta was anchored on the OPENING balance of 2920, so any mid-year
  postings (partial accruals, reversals) were ignored. Now anchors on the
  current closing balance via tb.rows.find(2920).closing_credit -
  closing_debit. Updated the computation field label to current_2920.
- More importantly: 2920 is a balance-sheet carry-forward (semesterlöneskuld
  persists until the actual vacation is paid). The original implementation
  set reverses_on to Jan 1 of the next year, which would zero the liability
  on day 1 of the new year — a known Swedish bookkeeping error. The
  vacation proposal now ships with reverses_on = '' to suppress the
  reversal badge, the API route emits a "Bokslutsjustering" description
  instead of "Periodisering (vänds …)", and AccrualsStep renders "Rullas
  vidare (ingen vändning)" so the user knows the liability carries forward.

P1 — signature_requests immutability gap (security)
- The existing trigger only guarded role / signer_name / signed_at /
  status on signed rows, leaving bankid_signature_data,
  signer_personnummer_encrypted, and signer_personnummer_hash mutable. An
  UPDATE on a signed row could silently alter the BankID proof. New
  migration 20260517090000 replaces the trigger function to cover the full
  audit-critical column set (plus fiscal_period_id and company_id as
  belt-and-braces).

P1 — narrative edits never reached the PDF
- The /bookkeeping/year-end/arsredovisning page let the user edit
  description / important_events / resultatdisposition but the download
  link pointed at a plain GET that regenerated boilerplate. Wired the PDF
  endpoint to accept description / events / disposition as query params
  (length-capped) and the page now constructs the download URL with the
  current narrative state — only fields the user actually changed are
  included, keeping the URL short for the unchanged-defaults case.

Compliance quick wins
- Added period_lock check to gnubok_post_annual_depreciation MCP tool
  (matches the existing accruals POST guard).
- Added explicit fiscal-period ownership pre-check to the signatures POST
  route (RLS would reject anyway; the route layer just makes the 404
  envelope cleaner).
- Replaced free-text role on the signatures schema with an enum allowing
  only Styrelseledamot / Styrelseordförande / VD / Verkställande direktör.
- Added Cache-Control: no-store + Pragma: no-cache to the ÅR PDF response
  so the document (officer names + financials = personal data) isn't
  cached by any intermediary.
- Sanitized period_end in the PDF Content-Disposition header to dodge
  header-injection via stray chars (defensive — period_end is a date, but
  the cost is one regex).
- Softened the iXBRL warning text on the ÅR page: digital filing is
  proposed by Bolagsverket but not yet enacted; PDF is still valid today.

False positives I'm intentionally not chasing on this round
- Greptile P2 `sate` typo in the address city field — the rename would
  touch the type and every consumer; defer.
- Greptile P2 "computation field label" — already addressed as part of
  the P1 vacation fix above.
- Compliance V2.2 "silent skip" in accruals POST — the silent skip is in
  the UI's empty-row filter, not in the server route. Server validation
  already returns 400 via Zod for any invalid item.

Verification
- 154 unit tests pass (was 149; +5 from re-running the MCP strict-schemas
  + output-schema suites that now include the new tools)
- Zero typecheck errors on any touched file
- Zero lint errors on any touched file
- Migration 20260517090000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address PR #509 round-2 — 3 real bugs + 5-item polish

3 real bugs from the round-1 Swedish review + Compliance Swarm re-eval:

- Soliditet inflated by obeskattade reserver. build-data.ts filtered equity
  with startsWith('20') || startsWith('21') — but 21xx (periodiseringsfonder,
  överavskrivningar) are partially deferred tax, not eget kapital. Splitting
  them out per K2 / ÅRL. Filter is now startsWith('20') only.

- Resultaträkning omitted bokslutsdispositioner + skatt rows. K2 RR must
  include 88xx (dispositioner) and 89xx (skatt) before "Årets resultat" per
  ÅRL 3:2 — without them, the printed RR doesn't reconcile to BS 2099 and
  the document is non-compliant for any AB that posted bolagsskatt or
  periodiseringsfond. flattenIncomeStatement now splits the financial
  sections on title (Bokslutsdispositioner / Skatter och årets resultat)
  and emits the K2-required intermediate subtotals: "Resultat efter
  finansiella poster" → dispositioner → "Resultat före skatt" → skatt →
  "Årets resultat".

- Accruals POST had no idempotency. Re-running the wizard (or a retried
  POST after a flaky network) would create duplicate accrual entries that
  distort both the balance sheet and trial balance. New
  findExistingAccrualEntry helper queries the period for an existing
  posted entry whose description matches the kind's stable prefix (or for
  manual prepaid/accrued, the user-supplied description). Duplicates land
  in a new `skipped` array in the response with a reference to the
  existing entry id, rather than producing a second posting.

5-item compliance polish:

- signer_name on the signatures schema now has .max(200) per GDPR Art.25.2
  data-minimization. Swedish personal names are well under that — the
  bound is a defense against an unbounded-string injection.

- Audit-fee accrual: 6420 is BAS-specific to lagstadgad revision. Bokslut
  fees for a non-revisionspliktigt bolag (liability_account = 2991) now
  debit 6590 (övriga externa tjänster) instead — Skatteverket may query a
  6420 debit when there is no revisor i bolaget.

- Räntefördelning ne_ruta label: was 'R30 / INK1 kapital' (confusing —
  INK1 is a separate form). Positive now reads 'R30 (avdrag i
  näringsverksamhet)' with the INK1 T4 cross-reference moved to the
  description; negative reads 'R30 (tillägg till resultat)'.

- gnubok_post_annual_depreciation MCP tool now checks the caller's
  company_members role and throws on viewer. RLS would reject the
  underlying INSERT anyway; failing fast here produces a cleaner error
  than the cascaded RLS rejection. Mirrors the HTTP route's
  { requireWrite: true } guard.

- Signature DELETE policy now blocks both 'signed' AND 'declined' rows. A
  declined signature is auditable evidence (board member refused) and is
  material under ABL 8 kap. New migration 20260517100000.

Bot-flagged items I'm deliberately not chasing on this round:
- V8.2.1 × 2 cross-tenant findings on ÅR routes — same false-positive
  class I've responded to repeatedly: buildArsredovisningData internally
  filters by company_id, bot can't see past the route handler.
- V4.5 / V2.2 MCP arg Zod redundancy — MCP server's central handler
  validates against each tool's inputSchema.
- Narrative-in-URL GDPR concerns — proper fix is POST + body or
  server-side persistence; tracked as follow-up.
- `sate` typo (Greptile P2) — type-wide rename, deferred.
- Vacation avgifter age-tier rate split — inherited from upstream
  generateVacationLiability which doesn't expose age; needs upstream
  work.
- Medelantal anställda proper monthly average — needs salary-run
  aggregation across the year, follow-up.
- K2 noter aktiekapital + fastställelseintyg blocks — real K2 gaps,
  tracked as follow-up.

Verification
- 94 tests pass (subset for bokslut + MCP suites; full Phase 1-7 suite
  unchanged in scope)
- Zero typecheck errors on any touched file
- Zero new lint errors on any touched file (the 2 server.ts warnings are
  pre-existing)
- Migration 20260517100000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-17 14:50:48 +02:00
committed by GitHub
parent 05078c9d8e
commit fd4274787c
32 changed files with 3829 additions and 98 deletions
@@ -0,0 +1,358 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { PageHeader } from '@/components/ui/page-header'
import { ArrowLeft, FileDown, Plus, ExternalLink } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import type { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types'
import type { SignatureRequest } from '@/lib/bokslut/arsredovisning/signature-service'
export default function ArsredovisningPage() {
const searchParams = useSearchParams()
const periodId = searchParams.get('period')
const { toast } = useToast()
const [data, setData] = useState<ArsredovisningData | null>(null)
const [signatures, setSignatures] = useState<SignatureRequest[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Editable narrative fields
const [description, setDescription] = useState('')
const [importantEvents, setImportantEvents] = useState('')
const [resultatdisposition, setResultatdisposition] = useState('')
// Add-signer form
const [signerName, setSignerName] = useState('')
const [signerRole, setSignerRole] = useState('Styrelseledamot')
useEffect(() => {
if (!periodId) return
let cancelled = false
Promise.all([
fetch(`/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning`).then((r) => r.json()),
fetch(`/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/signatures`).then((r) =>
r.json(),
),
])
.then(([arBody, sigBody]) => {
if (cancelled) return
if (arBody?.error) {
setError(arBody.error.message ?? 'Kunde inte hämta årsredovisning')
return
}
const d = arBody.data as ArsredovisningData
setData(d)
setDescription(d.forvaltningsberattelse.description)
setImportantEvents(d.forvaltningsberattelse.important_events)
setResultatdisposition(d.forvaltningsberattelse.resultatdisposition)
setSignatures((sigBody.data ?? []) as SignatureRequest[])
})
.catch(() => {
if (!cancelled) setError('Kunde inte hämta årsredovisning')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [periodId])
const handleAddSigner = useCallback(async () => {
if (!periodId || !signerName.trim()) return
try {
const res = await fetch(
`/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/signatures`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: signerRole, signer_name: signerName.trim() }),
},
)
const body = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte lägga till undertecknare',
description: body?.error?.message ?? '',
variant: 'destructive',
})
return
}
setSignatures((prev) => [...prev, body.data as SignatureRequest])
setSignerName('')
toast({ title: 'Undertecknare tillagd', description: `${signerRole}: ${signerName}` })
} catch (err) {
toast({
title: 'Kunde inte lägga till undertecknare',
description: err instanceof Error ? err.message : 'Okänt fel',
variant: 'destructive',
})
}
}, [periodId, signerName, signerRole, toast])
if (!periodId) {
return (
<div className="space-y-8">
<PageHeader title="Årsredovisning" />
<Card>
<CardContent className="p-6 text-muted-foreground">
Saknar periodparameter. Öppna sidan från bokslutet via{' '}
<Link href="/bookkeeping/year-end" className="text-primary hover:underline">
/bookkeeping/year-end
</Link>
.
</CardContent>
</Card>
</div>
)
}
if (loading) {
return (
<div className="space-y-8">
<PageHeader title="Årsredovisning" />
<Card>
<CardContent className="p-6 space-y-3">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
</div>
)
}
if (error || !data) {
return (
<div className="space-y-8">
<PageHeader title="Årsredovisning" />
<Card>
<CardContent className="p-6 text-destructive">
{error ?? 'Kunde inte hämta data'}
</CardContent>
</Card>
</div>
)
}
// Carry the narrative edits into the PDF URL so the download reflects
// exactly what the user typed. A future enhancement will persist
// overrides server-side; URL params get us through the merge while
// keeping the "click to download" UX.
const pdfUrl = (() => {
const qs = new URLSearchParams()
if (description !== data.forvaltningsberattelse.description) qs.set('description', description)
if (importantEvents !== data.forvaltningsberattelse.important_events) {
qs.set('events', importantEvents)
}
if (resultatdisposition !== data.forvaltningsberattelse.resultatdisposition) {
qs.set('disposition', resultatdisposition)
}
const query = qs.toString()
return `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/pdf${query ? '?' + query : ''}`
})()
return (
<div className="space-y-8">
<PageHeader
title={`Årsredovisning ${data.fiscal_period.name}`}
description={`${data.company.name} · ${data.company.org_number}`}
action={
<Button variant="outline" asChild>
<Link href={`/bookkeeping/year-end?period=${periodId}`}>
<ArrowLeft className="mr-2 h-4 w-4" /> Tillbaka till bokslut
</Link>
</Button>
}
/>
<Card>
<CardHeader>
<CardTitle className="text-base">Förvaltningsberättelse narrativ</CardTitle>
<p className="text-sm text-muted-foreground">
Texten nedan visas i PDF:en. Förändringar är lokala till denna sida tills
vidare; en framtida version kommer att spara dem mellan sessioner.
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ar-description">Verksamhetsbeskrivning</Label>
<Textarea
id="ar-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ar-events">Väsentliga händelser</Label>
<Textarea
id="ar-events"
value={importantEvents}
onChange={(e) => setImportantEvents(e.target.value)}
rows={4}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ar-rd">Resultatdisposition</Label>
<Textarea
id="ar-rd"
value={resultatdisposition}
onChange={(e) => setResultatdisposition(e.target.value)}
rows={3}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Flerårsöversikt</CardTitle>
</CardHeader>
<CardContent>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase tracking-wider text-muted-foreground border-b border-border">
<th className="py-2">År</th>
<th className="py-2 text-right">Nettoomsättning</th>
<th className="py-2 text-right">Resultat efter fin.</th>
<th className="py-2 text-right">Soliditet</th>
</tr>
</thead>
<tbody>
{data.forvaltningsberattelse.flerarsoversikt.map((row) => (
<tr key={row.year} className="border-b border-border last:border-b-0">
<td className="py-2">{row.year}</td>
<td className="py-2 text-right tabular-nums">
{row.net_revenue.toLocaleString('sv-SE')}
</td>
<td className="py-2 text-right tabular-nums">
{row.result_after_financial.toLocaleString('sv-SE')}
</td>
<td className="py-2 text-right tabular-nums">
{row.soliditet_pct === null
? '—'
: `${row.soliditet_pct.toFixed(1)} %`}
</td>
</tr>
))}
</tbody>
</table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Underskrifter</CardTitle>
<p className="text-sm text-muted-foreground">
Lägg till varje styrelseledamot + VD som ska skriva under. BankID-signering
kommer i en kommande version för nu visas slottar och status här, och
själva underskriften görs pappret.
</p>
</CardHeader>
<CardContent className="space-y-4">
{signatures.length === 0 && (
<p className="text-sm text-muted-foreground italic">
Inga undertecknare tillagda än.
</p>
)}
{signatures.map((sig) => (
<div
key={sig.id}
className="flex items-center justify-between border-b border-border last:border-b-0 pb-3 last:pb-0"
>
<div>
<p className="text-sm font-medium">{sig.signer_name}</p>
<p className="text-xs text-muted-foreground">{sig.role}</p>
</div>
{sig.status === 'signed' ? (
<Badge variant="success">Signerad</Badge>
) : sig.status === 'declined' ? (
<Badge variant="destructive">Avböjd</Badge>
) : (
<Badge variant="outline">Väntar underskrift</Badge>
)}
</div>
))}
<div className="flex flex-wrap gap-2 items-end pt-2">
<div className="space-y-1">
<Label htmlFor="signer-role" className="text-xs">
Roll
</Label>
<select
id="signer-role"
className="border border-border rounded-md h-9 text-sm px-2 bg-background"
value={signerRole}
onChange={(e) => setSignerRole(e.target.value)}
>
<option>Styrelseledamot</option>
<option>Styrelseordförande</option>
<option>VD</option>
<option>Verkställande direktör</option>
</select>
</div>
<div className="space-y-1 flex-1 min-w-[200px]">
<Label htmlFor="signer-name" className="text-xs">
Namn
</Label>
<Input
id="signer-name"
value={signerName}
onChange={(e) => setSignerName(e.target.value)}
placeholder="t.ex. Anna Andersson"
className="h-9"
/>
</div>
<Button onClick={handleAddSigner} disabled={!signerName.trim()}>
<Plus className="mr-1 h-4 w-4" /> Lägg till
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Ladda ner & lämna in</CardTitle>
</CardHeader>
<CardContent className="space-y-4 text-sm">
<p className="text-muted-foreground">
Ladda ner PDF-utkastet, granska, skriv ut och låt undertecknarna signera
fastställelseintyget. Ladda sedan upp PDF:en till Bolagsverkets e-tjänst.
</p>
<div className="flex flex-wrap gap-3">
<Button asChild>
<Link href={pdfUrl} target="_blank" rel="noopener noreferrer">
<FileDown className="mr-2 h-4 w-4" /> Ladda ner PDF (utkast)
</Link>
</Button>
<Button variant="outline" asChild>
<Link
href="https://www.bolagsverket.se/foretag/aktiebolag/arsredovisning/lamna-in-arsredovisning"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" /> Bolagsverket Mina Sidor
</Link>
</Button>
</div>
<div className="rounded-md border border-warning/40 bg-warning/5 p-3 text-xs text-warning-foreground">
<strong>Notis om digital inlämning:</strong> Bolagsverket har föreslagit att
digital inlämning (iXBRL) av årsredovisning för aktiebolag ska bli
obligatorisk beslut och ikraftträdande är ännu inte fastställda. Idag är
PDF-inlämning fortfarande godkänd. Gnubok stödjer för närvarande endast
PDF-utkast; iXBRL-generering är planerad till en kommande version.
</div>
</CardContent>
</Card>
</div>
)
}
+13 -3
View File
@@ -23,15 +23,17 @@ import type { FiscalPeriod, YearEndPreview, YearEndResult } from '@/types'
import type { BokslutReadinessReport } from '@/lib/bokslut/readiness-aggregator'
import { PreflightStep } from '@/components/bookkeeping/year-end/PreflightStep'
import { DispositionsStep } from '@/components/bookkeeping/year-end/DispositionsStep'
import { AccrualsStep } from '@/components/bookkeeping/year-end/AccrualsStep'
import { PreviewStep } from '@/components/bookkeeping/year-end/PreviewStep'
import { ExecuteStep } from '@/components/bookkeeping/year-end/ExecuteStep'
import { ResultStep } from '@/components/bookkeeping/year-end/ResultStep'
type Step = 'preflight' | 'dispositions' | 'preview' | 'execute' | 'result'
type Step = 'preflight' | 'accruals' | 'dispositions' | 'preview' | 'execute' | 'result'
const STEP_ORDER: Step[] = ['preflight', 'dispositions', 'preview', 'execute', 'result']
const STEP_ORDER: Step[] = ['preflight', 'accruals', 'dispositions', 'preview', 'execute', 'result']
const STEP_LABELS: Record<Step, string> = {
preflight: 'Kontroll',
accruals: 'Periodiseringar',
dispositions: 'Dispositioner',
preview: 'Förhandsgranska',
execute: 'Verkställ',
@@ -286,6 +288,14 @@ export default function YearEndPage() {
report={report}
isLoading={reportLoading}
error={reportError}
onContinue={() => setStep('accruals')}
/>
)}
{showWizard && step === 'accruals' && selectedPeriodId && (
<AccrualsStep
periodId={selectedPeriodId}
onBack={() => setStep('preflight')}
onContinue={() => setStep('dispositions')}
/>
)}
@@ -293,7 +303,7 @@ export default function YearEndPage() {
{showWizard && step === 'dispositions' && selectedPeriodId && (
<DispositionsStep
periodId={selectedPeriodId}
onBack={() => setStep('preflight')}
onBack={() => setStep('accruals')}
onContinue={goToPreview}
/>
)}
@@ -0,0 +1,217 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import {
buildAccrualsProposal,
proposeAuditFee,
proposeManualAccrued,
proposeManualPrepaid,
proposeVacationLiabilityChange,
} from '@/lib/bokslut/accruals/accrual-detector'
import type { AccrualProposal } from '@/lib/bokslut/accruals/types'
import type { JournalEntry } from '@/types'
export const GET = withRouteContext(
'period.accruals_preview',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
const proposal = await buildAccrualsProposal(supabase, companyId, id)
return NextResponse.json({ data: proposal })
} catch (err) {
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
return errorResponse(err, log, { requestId })
}
},
)
const PostItemSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('vacation_liability_change') }),
z.object({
kind: z.literal('audit_fee'),
amount: z.number().positive(),
liability_account: z.enum(['2991', '2992']).optional(),
}),
z.object({
kind: z.literal('manual_prepaid_expense'),
amount: z.number().positive(),
expense_account: z.string().regex(/^\d{4}$/),
prepaid_account: z.string().regex(/^17\d{2}$/),
description: z.string().min(1),
}),
z.object({
kind: z.literal('manual_accrued_expense'),
amount: z.number().positive(),
expense_account: z.string().regex(/^\d{4}$/),
accrued_account: z.string().regex(/^29\d{2}$/),
description: z.string().min(1),
}),
])
const PostBodySchema = z.object({
items: z.array(PostItemSchema).min(1),
})
export const POST = withRouteContext(
'period.accruals_post',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, PostBodySchema)
if (!validation.success) return validation.response
try {
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, name, period_end, is_closed, locked_at, closing_entry_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
if (period.is_closed || period.closing_entry_id || period.locked_at) {
return errorResponseFromCode('PERIOD_LOCKED', log, { requestId })
}
const created: { kind: string; entry: JournalEntry; reverses_on: string }[] = []
const skipped: { kind: string; existing_entry_id: string; reason: string }[] = []
for (const item of validation.data.items) {
// Idempotency: refuse to post a duplicate accrual of the same kind for
// the same period. Without this, re-running the wizard (or a retried
// request after a flaky network) would create duplicate entries that
// distort both the balance sheet and the trial balance.
const existingId = await findExistingAccrualEntry(supabase, companyId, id, item)
if (existingId) {
skipped.push({
kind: item.kind,
existing_entry_id: existingId,
reason: 'already_posted',
})
continue
}
let proposal: AccrualProposal | null = null
switch (item.kind) {
case 'vacation_liability_change':
proposal = await proposeVacationLiabilityChange(supabase, companyId, id, {
closingDate: period.period_end,
})
break
case 'audit_fee':
proposal = proposeAuditFee({
amount: item.amount,
closingDate: period.period_end,
liabilityAccount: item.liability_account,
})
break
case 'manual_prepaid_expense':
proposal = proposeManualPrepaid({
amount: item.amount,
expenseAccount: item.expense_account,
prepaidAccount: item.prepaid_account,
description: item.description,
closingDate: period.period_end,
})
break
case 'manual_accrued_expense':
proposal = proposeManualAccrued({
amount: item.amount,
expenseAccount: item.expense_account,
accruedAccount: item.accrued_account,
description: item.description,
closingDate: period.period_end,
})
break
}
if (!proposal) continue
// Mark the entry's description with the reversal date so future
// bookkeepers (and a future cron) can spot the periodisering. The
// accrual_reversals cron is follow-up infra — once it lands, the
// entry's source_type or a metadata column can drive the auto-flip.
//
// Vacation-liability adjustments deliberately have empty reverses_on
// since 2920 carries forward — emit a different description in that
// case so future readers don't expect a Jan 1 reversal.
const description = proposal.reverses_on
? `Periodisering: ${proposal.label} (vänds ${proposal.reverses_on})`
: `Bokslutsjustering: ${proposal.label}`
const entry = await createJournalEntry(supabase, companyId, user.id, {
fiscal_period_id: id,
entry_date: period.period_end,
description,
source_type: 'manual',
voucher_series: 'A',
lines: proposal.lines,
})
created.push({ kind: item.kind, entry, reverses_on: proposal.reverses_on })
}
return NextResponse.json({ data: { created, skipped } })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
{ requireWrite: true },
)
type PostItem = z.infer<typeof PostItemSchema>
/**
* Find a previously-posted accrual journal entry for the same kind in the
* same period — used by the POST handler to enforce idempotency. Matches
* on the description prefix that each calculator emits (see
* accrual-detector.ts and the POST handler's description construction).
*
* For manual prepaid/accrued the dedup key includes the user-supplied
* description, so different items with different descriptions don't collide.
*/
async function findExistingAccrualEntry(
supabase: Awaited<ReturnType<typeof import('@/lib/supabase/server')['createClient']>>,
companyId: string,
fiscalPeriodId: string,
item: PostItem,
): Promise<string | null> {
let pattern: string
switch (item.kind) {
case 'vacation_liability_change':
pattern = '%semesterlöneskuld%'
break
case 'audit_fee': {
const account = item.liability_account ?? '2992'
pattern = account === '2991' ? '%arvode för bokslut%' : '%arvode för revision%'
break
}
case 'manual_prepaid_expense':
pattern = `Periodisering: Förutbetald kostnad: ${escapeLike(item.description)}%`
break
case 'manual_accrued_expense':
pattern = `Periodisering: Upplupen kostnad: ${escapeLike(item.description)}%`
break
}
const { data } = await supabase
.from('journal_entries')
.select('id')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.eq('status', 'posted')
.ilike('description', pattern)
.limit(1)
return (data?.[0]?.id as string | undefined) ?? null
}
function escapeLike(s: string): string {
return s.replace(/[\\%_]/g, (c) => `\\${c}`)
}
@@ -0,0 +1,52 @@
import { renderToBuffer } from '@react-pdf/renderer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
import { ArsredovisningPDF } from '@/lib/bokslut/arsredovisning/arsredovisning-pdf'
export const GET = withRouteContext(
'period.arsredovisning_pdf',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
// Accept the editable narrative fields as query params so the
// /bookkeeping/year-end/arsredovisning page's edits actually reach the
// PDF. Persisting overrides to a table is a deferred enhancement;
// for now the URL is the carrier so the "download" button reflects
// whatever the user just typed. Length-capped to keep the URL from
// ballooning past CDN / browser limits.
const url = new URL(request.url)
const cap = (s: string | null, n: number) => (s ? s.slice(0, n) : undefined)
const overrides = {
description: cap(url.searchParams.get('description'), 4_000),
important_events: cap(url.searchParams.get('events'), 4_000),
resultatdisposition: cap(url.searchParams.get('disposition'), 2_000),
}
const data = await buildArsredovisningData(supabase, companyId, id, overrides)
const pdfBuffer = await renderToBuffer(ArsredovisningPDF({ data }))
// "-utkast" suffix mirrors the existing PDF routes; the file becomes
// "fastställd" only after the signature flow records all signatures.
// Sanitize the dynamic segment so a stray quote / newline in the date
// (defensive — unlikely to ever happen) can't break the header.
const safePeriodEnd = data.fiscal_period.period_end.replace(/[^\w.-]/g, '_')
const filename = `arsredovisning-${safePeriodEnd}-utkast.pdf`
return new Response(new Uint8Array(pdfBuffer), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${filename}"`,
// ÅR contains company financials + officer names — don't let any
// intermediary cache the document.
'Cache-Control': 'private, no-store, no-cache, must-revalidate',
'Pragma': 'no-cache',
},
})
} catch (err) {
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
return errorResponse(err, log, { requestId })
}
},
)
@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
export const GET = withRouteContext(
'period.arsredovisning_data',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
const data = await buildArsredovisningData(supabase, companyId, id)
return NextResponse.json({ data })
} catch (err) {
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
return errorResponse(err, log, { requestId })
}
},
)
@@ -0,0 +1,62 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
import {
createSignatureRequest,
listSignatureRequests,
} from '@/lib/bokslut/arsredovisning/signature-service'
// Roles are constrained to the underskrifter set ÅRL allows — keeps the API
// from accepting arbitrary "Administrator" / "CEO" strings that the UI
// dropdown doesn't expose. Name capped at 200 chars per GDPR data-min
// (Art.25.2) — Swedish names are well under that; bound is a defense.
const CreateSchema = z.object({
role: z.enum(['Styrelseledamot', 'Styrelseordförande', 'VD', 'Verkställande direktör']),
signer_name: z.string().min(1).max(200),
})
export const GET = withRouteContext(
'period.arsredovisning_signatures_list',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
const data = await listSignatureRequests(supabase, companyId, id)
return NextResponse.json({ data })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
)
export const POST = withRouteContext(
'period.arsredovisning_signatures_create',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, CreateSchema)
if (!validation.success) return validation.response
try {
// Defense-in-depth: confirm the fiscal period belongs to the
// authenticated company before writing. RLS would catch a cross-tenant
// insert anyway, but rejecting at the route layer gives a cleaner
// 404 + avoids the noisy RLS error in the structured-error envelope.
const { data: period } = await supabase
.from('fiscal_periods')
.select('id')
.eq('id', id)
.eq('company_id', companyId)
.maybeSingle()
if (!period) {
return NextResponse.json({ error: { code: 'PERIOD_NOT_FOUND' } }, { status: 404 })
}
const data = await createSignatureRequest(supabase, companyId, user.id, id, validation.data)
return NextResponse.json({ data })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
{ requireWrite: true },
)
@@ -13,7 +13,8 @@ import {
} from '@/lib/bokslut/reserves/periodiseringsfond-service'
import { proposeOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-service'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import type { DispositionsProposal, ProposedDisposition } from '@/lib/bokslut/types'
import { buildDispositionsProposal } from '@/lib/bokslut/dispositions-proposal-builder'
import type { ProposedDisposition } from '@/lib/bokslut/types'
import type { JournalEntry } from '@/types'
/**
@@ -57,91 +58,13 @@ export const GET = withRouteContext(
const opLog = log.child({ periodId: id })
try {
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
const data = await buildDispositionsProposal(supabase, companyId, id)
return NextResponse.json({ data })
} catch (err) {
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
}
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
const entityType = (settings?.entity_type ??
'aktiebolag') as DispositionsProposal['entityType']
// EF doesn't book dispositioner — these are handled in NE/INK1. Return
// an empty proposal so the wizard can skip the step.
if (entityType !== 'aktiebolag') {
const incomeStatement = await generateIncomeStatement(supabase, companyId, id)
return NextResponse.json({
data: {
entityType,
fiscalPeriod: period,
netResultBefore: incomeStatement.net_result,
proposals: [],
} satisfies DispositionsProposal,
})
}
const fiscalYear = parseInt(period.period_end.slice(0, 4), 10)
const incomeStatement = await generateIncomeStatement(supabase, companyId, id)
const resultBeforeTax = incomeStatement.net_result
const proposals: ProposedDisposition[] = []
// 1. Periodiseringsfond återföring (mandatory for ≥6-year-old fonder)
const existingFonder = await listExistingPeriodiseringsfonder(
supabase,
companyId,
period.period_end,
)
const ateforing = proposeAteforing(existingFonder, {
schablonintaktRate: DEFAULT_SCHABLONINTAKT_RATE,
})
proposals.push(...ateforing.proposals)
// 2. Överavskrivningar — no default; user must enter desired amount
// (Phase 3 will compute from asset register).
// 3. Periodiseringsfond avsättning — default to max (25 % of result
// before tax adjusted for återföring/schablonintäkt).
const taxableBeforeAvsattning =
resultBeforeTax +
ateforing.proposals.reduce((sum, p) => sum + p.amount, 0) +
ateforing.schablonintaktAmount
const avsattning = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: taxableBeforeAvsattning,
fiscalYear,
})
if (avsattning) proposals.push(avsattning)
// 4. Särskild löneskatt — auto-computed from posted pension costs
const slp = await calculateSarskildLoneskatt(supabase, companyId, id)
if (slp) proposals.push(slp)
// 5. Bolagsskatt — taxable result after p-fond avsättning + schablonintäkt
const bolagsskatt = await calculateBolagsskatt(supabase, companyId, id, {
manualAdjustments: {
schablonintaktPeriodiseringsfond: ateforing.schablonintaktAmount,
},
})
if (bolagsskatt) proposals.push(bolagsskatt)
return NextResponse.json({
data: {
entityType,
fiscalPeriod: period,
netResultBefore: resultBeforeTax,
proposals,
} satisfies DispositionsProposal,
})
} catch (err) {
opLog.error('bokslutsdispositioner preview failed', err as Error)
return errorResponse(err, opLog, { requestId })
}
@@ -0,0 +1,389 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ArrowRight, Loader2, Plus, Trash2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import type { AccrualsProposal } from '@/lib/bokslut/accruals/types'
interface AccrualsStepProps {
periodId: string
onBack: () => void
onContinue: () => void
}
interface AutoState {
vacation: { accept: boolean }
}
interface ManualEntry {
id: string
kind: 'audit_fee' | 'manual_prepaid_expense' | 'manual_accrued_expense'
amount: string
description: string
expenseAccount: string
prepaidAccount: string
accruedAccount: string
liabilityAccount: '2991' | '2992'
}
function makeId() {
return Math.random().toString(36).slice(2, 10)
}
export function AccrualsStep({ periodId, onBack, onContinue }: AccrualsStepProps) {
const { toast } = useToast()
const [proposal, setProposal] = useState<AccrualsProposal | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [auto, setAuto] = useState<AutoState>({ vacation: { accept: true } })
const [manual, setManual] = useState<ManualEntry[]>([])
const [posting, setPosting] = useState(false)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
fetch(`/api/bookkeeping/fiscal-periods/${periodId}/accruals`)
.then(async (res) => {
const body = await res.json()
if (cancelled) return
if (!res.ok) {
setError(body?.error?.message ?? 'Kunde inte ladda periodiseringar')
return
}
setProposal(body.data as AccrualsProposal)
})
.catch(() => {
if (!cancelled) setError('Kunde inte ladda periodiseringar')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [periodId])
const addManual = useCallback((kind: ManualEntry['kind']) => {
setManual((prev) => [
...prev,
{
id: makeId(),
kind,
amount: '',
description: '',
expenseAccount: kind === 'audit_fee' ? '6420' : '',
prepaidAccount: '',
accruedAccount: '',
liabilityAccount: '2992',
},
])
}, [])
const removeManual = useCallback((id: string) => {
setManual((prev) => prev.filter((m) => m.id !== id))
}, [])
const updateManual = useCallback((id: string, patch: Partial<ManualEntry>) => {
setManual((prev) => prev.map((m) => (m.id === id ? { ...m, ...patch } : m)))
}, [])
const handleCommit = useCallback(async () => {
if (!proposal) return
setPosting(true)
try {
const items: unknown[] = []
if (proposal.proposals.find((p) => p.kind === 'vacation_liability_change') && auto.vacation.accept) {
items.push({ kind: 'vacation_liability_change' })
}
for (const m of manual) {
const amount = parseFloat(m.amount)
if (!Number.isFinite(amount) || amount <= 0) continue
if (m.kind === 'audit_fee') {
items.push({ kind: 'audit_fee', amount, liability_account: m.liabilityAccount })
} else if (m.kind === 'manual_prepaid_expense') {
if (!m.expenseAccount || !m.prepaidAccount || !m.description) continue
items.push({
kind: 'manual_prepaid_expense',
amount,
expense_account: m.expenseAccount,
prepaid_account: m.prepaidAccount,
description: m.description,
})
} else if (m.kind === 'manual_accrued_expense') {
if (!m.expenseAccount || !m.accruedAccount || !m.description) continue
items.push({
kind: 'manual_accrued_expense',
amount,
expense_account: m.expenseAccount,
accrued_account: m.accruedAccount,
description: m.description,
})
}
}
if (items.length === 0) {
onContinue()
return
}
const res = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/accruals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
})
const body = await res.json()
if (!res.ok) {
setError(body?.error?.message ?? 'Kunde inte bokföra periodiseringarna')
return
}
const created = body.data?.created?.length ?? 0
toast({
title: `${created} periodisering${created === 1 ? '' : 'ar'} bokförd${
created === 1 ? '' : 'a'
}`,
description: 'Vänd dem första dagen i nästa räkenskapsår.',
})
onContinue()
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel')
} finally {
setPosting(false)
}
}, [proposal, auto, manual, periodId, onContinue, toast])
if (loading) {
return (
<Card>
<CardContent className="p-6 space-y-2">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
)
}
if (error && !proposal) {
return (
<Card>
<CardContent className="p-6 text-destructive">{error}</CardContent>
</Card>
)
}
if (!proposal) return null
const vacation = proposal.proposals.find((p) => p.kind === 'vacation_liability_change')
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Periodiseringar</CardTitle>
<p className="text-sm text-muted-foreground">
Förutbetalda kostnader (17xx) och upplupna kostnader (29xx). Posteringarna
ska vändas första dagen av nästa räkenskapsår datumet visas per
verifikation. Automatisk omvändning är planerad till en kommande version.
</p>
</CardHeader>
</Card>
{vacation && (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<CardTitle className="text-base">{vacation.label}</CardTitle>
<p className="text-sm text-muted-foreground mt-1">{vacation.description}</p>
{vacation.reverses_on ? (
<Badge variant="outline" className="mt-2">
Vänds {vacation.reverses_on}
</Badge>
) : (
<Badge variant="outline" className="mt-2">
Rullas vidare (ingen vändning)
</Badge>
)}
</div>
<p className="font-display text-2xl tabular-nums shrink-0">
{formatCurrency(vacation.amount)}
</p>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
<Checkbox
id="accept-vacation"
checked={auto.vacation.accept}
onCheckedChange={(c) => setAuto({ vacation: { accept: Boolean(c) } })}
/>
<Label htmlFor="accept-vacation" className="text-sm cursor-pointer select-none">
Boka denna justering
</Label>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Manuella periodiseringar</CardTitle>
<p className="text-sm text-muted-foreground">
Lägg till revisionsarvode, hyra som löper över årsskiftet, förutbetalda
försäkringar m.m.
</p>
</CardHeader>
<CardContent className="space-y-4">
{manual.length === 0 && (
<p className="text-sm text-muted-foreground italic">Inga manuella periodiseringar tillagda än.</p>
)}
{manual.map((m) => (
<ManualEntryEditor
key={m.id}
entry={m}
onChange={(patch) => updateManual(m.id, patch)}
onRemove={() => removeManual(m.id)}
/>
))}
<div className="flex flex-wrap gap-2 pt-2">
<Button variant="outline" size="sm" onClick={() => addManual('audit_fee')}>
<Plus className="mr-1 h-3.5 w-3.5" /> Revisions-/bokslutsarvode
</Button>
<Button variant="outline" size="sm" onClick={() => addManual('manual_prepaid_expense')}>
<Plus className="mr-1 h-3.5 w-3.5" /> Förutbetald kostnad
</Button>
<Button variant="outline" size="sm" onClick={() => addManual('manual_accrued_expense')}>
<Plus className="mr-1 h-3.5 w-3.5" /> Upplupen kostnad
</Button>
</div>
</CardContent>
</Card>
{error && (
<Card>
<CardContent className="p-4 text-sm text-destructive">{error}</CardContent>
</Card>
)}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack} disabled={posting}>
Tillbaka
</Button>
<Button onClick={handleCommit} disabled={posting}>
{posting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Bokför
</>
) : (
<>
Fortsätt <ArrowRight className="ml-1 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
)
}
function ManualEntryEditor({
entry,
onChange,
onRemove,
}: {
entry: ManualEntry
onChange: (patch: Partial<ManualEntry>) => void
onRemove: () => void
}) {
return (
<div className="rounded-md border border-border p-3 space-y-3">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">
{entry.kind === 'audit_fee' && 'Revisions-/bokslutsarvode'}
{entry.kind === 'manual_prepaid_expense' && 'Förutbetald kostnad'}
{entry.kind === 'manual_accrued_expense' && 'Upplupen kostnad'}
</p>
<Button variant="ghost" size="sm" onClick={onRemove} className="h-7 px-2">
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Belopp (kr)</Label>
<Input
type="number"
step="1"
min="0"
value={entry.amount}
onChange={(e) => onChange({ amount: e.target.value })}
className="tabular-nums h-8"
/>
</div>
{entry.kind === 'audit_fee' && (
<div className="space-y-1">
<Label className="text-xs">Konto</Label>
<select
className="border border-border rounded-md h-8 text-sm px-2 w-full bg-background"
value={entry.liabilityAccount}
onChange={(e) =>
onChange({ liabilityAccount: e.target.value as '2991' | '2992' })
}
>
<option value="2992">2992 Revision</option>
<option value="2991">2991 Bokslut</option>
</select>
</div>
)}
{entry.kind !== 'audit_fee' && (
<>
<div className="space-y-1">
<Label className="text-xs">Kostnadskonto</Label>
<Input
value={entry.expenseAccount}
onChange={(e) => onChange({ expenseAccount: e.target.value })}
placeholder="t.ex. 6310"
className="tabular-nums h-8"
/>
</div>
{entry.kind === 'manual_prepaid_expense' && (
<div className="space-y-1">
<Label className="text-xs">17xx-konto</Label>
<Input
value={entry.prepaidAccount}
onChange={(e) => onChange({ prepaidAccount: e.target.value })}
placeholder="t.ex. 1730"
className="tabular-nums h-8"
/>
</div>
)}
{entry.kind === 'manual_accrued_expense' && (
<div className="space-y-1">
<Label className="text-xs">29xx-konto</Label>
<Input
value={entry.accruedAccount}
onChange={(e) => onChange({ accruedAccount: e.target.value })}
placeholder="t.ex. 2990"
className="tabular-nums h-8"
/>
</div>
)}
<div className="space-y-1 col-span-2">
<Label className="text-xs">Beskrivning</Label>
<Input
value={entry.description}
onChange={(e) => onChange({ description: e.target.value })}
placeholder="t.ex. Försäkring 2026"
className="h-8"
/>
</div>
</>
)}
</div>
</div>
)
}
@@ -12,6 +12,7 @@ import { ArrowRight, AlertTriangle, Loader2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { DepreciationPanel } from './DepreciationPanel'
import { EfDeclarationSection } from './EfDeclarationSection'
import type {
DispositionsProposal,
ProposedDisposition,
@@ -145,20 +146,18 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
if (!proposal) return null
// EF: only depreciation applies (no AB-only dispositioner)
// EF: depreciation can apply (skattemässig hanteras separat); replace the
// AB-only dispositioner with a NE-bilaga declaration section.
if (proposal.entityType !== 'aktiebolag') {
const fiscalYear = parseInt(proposal.fiscalPeriod.period_end.slice(0, 4), 10)
return (
<div className="space-y-6">
<DepreciationPanel periodId={periodId} onPosted={() => void loadProposals()} />
<Card>
<CardHeader>
<CardTitle className="text-base">Inga bokslutsdispositioner</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
För enskild firma bokförs varken bolagsskatt, periodiseringsfond eller överavskrivningar.
De räknas i NE-bilagan när du deklarerar.
</CardContent>
</Card>
<EfDeclarationSection
fiscalPeriodId={periodId}
bookedSurplus={proposal.netResultBefore}
fiscalYear={fiscalYear}
/>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>Tillbaka</Button>
<Button onClick={onContinue}>
@@ -0,0 +1,238 @@
'use client'
import { useMemo, useState } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { FileDown, Info } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { calculateEgenavgifter, type EgenavgiftCategory } from '@/lib/bokslut/enskild-firma/egenavgifter-calculator'
import { calculateRantefordelning } from '@/lib/bokslut/enskild-firma/rantefordelning-calculator'
import { proposeEfPfondAvsattning } from '@/lib/bokslut/enskild-firma/periodiseringsfond-ef'
import { calculateExpansionsfondChange } from '@/lib/bokslut/enskild-firma/expansionsfond-calculator'
import type { EfDeclarationItem } from '@/lib/bokslut/enskild-firma/types'
interface EfDeclarationSectionProps {
fiscalPeriodId: string
/** Bokfört resultat (income statement net_result) — used as the default
* surplus base for the calculators. */
bookedSurplus: number
/** Closing year of the fiscal period (for periodiseringsfond cohort). */
fiscalYear: number
}
/**
* Read-only EF declaration computation. All values are skattemässiga
* justeringar that are filed in NE-bilaga / INK1 — never booked. The card
* runs calculators in the browser as the user adjusts inputs and shows the
* NE-bilaga ruta where each number lands.
*/
export function EfDeclarationSection({
fiscalPeriodId,
bookedSurplus,
fiscalYear,
}: EfDeclarationSectionProps) {
const [category, setCategory] = useState<EgenavgiftCategory>('full')
const [priorSchablon, setPriorSchablon] = useState('')
const [priorActual, setPriorActual] = useState('')
const [kapitalunderlag, setKapitalunderlag] = useState('')
const [pfondDesired, setPfondDesired] = useState('')
const [expansionsfondBalance, setExpansionsfondBalance] = useState('')
const [expansionsfondChange, setExpansionsfondChange] = useState('')
const items: EfDeclarationItem[] = useMemo(() => {
const list: EfDeclarationItem[] = []
const eg = calculateEgenavgifter({
surplusBeforeEgenavgifter: bookedSurplus,
category,
priorYearSchablonavdrag: parseFloat(priorSchablon) || 0,
priorYearActualCharged: parseFloat(priorActual) || 0,
})
list.push(eg)
const kap = parseFloat(kapitalunderlag) || 0
const r = calculateRantefordelning({ kapitalunderlag: kap })
if (r) list.push(r)
const surplusAfterEg = bookedSurplus - eg.amount
const pfond = proposeEfPfondAvsattning({
surplus: surplusAfterEg,
fiscalYear,
desiredAmount: pfondDesired === '' ? undefined : parseFloat(pfondDesired),
})
if (pfond) list.push(pfond)
const expChange = parseFloat(expansionsfondChange) || 0
if (expChange !== 0) {
const exp = calculateExpansionsfondChange({
kapitalunderlag: kap,
existingBalance: parseFloat(expansionsfondBalance) || 0,
desiredChange: expChange,
})
if (exp) list.push(exp)
}
return list
}, [
bookedSurplus,
category,
priorSchablon,
priorActual,
kapitalunderlag,
pfondDesired,
expansionsfondBalance,
expansionsfondChange,
fiscalYear,
])
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Skattemässiga justeringar NE-bilaga</CardTitle>
<p className="text-sm text-muted-foreground">
För enskild firma bokförs inte skatt, egenavgifter, fonder eller
räntefördelning. Beräkningarna nedan visar vad du fyller i NE-bilagan
när du deklarerar.
</p>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label className="text-xs">Egenavgifter kategori</Label>
<select
className="border border-border rounded-md h-9 text-sm px-2 w-full bg-background"
value={category}
onChange={(e) => setCategory(e.target.value as EgenavgiftCategory)}
>
<option value="full">Aktiv, full sats (28,97 %)</option>
<option value="pensioner">Pensionär (10,21 %)</option>
<option value="passive">Passiv (SLP 24,26 %)</option>
</select>
</div>
<div className="space-y-1">
<Label className="text-xs">Kapitalunderlag (vid IB)</Label>
<Input
type="number"
step="1"
value={kapitalunderlag}
onChange={(e) => setKapitalunderlag(e.target.value)}
placeholder="0"
className="tabular-nums h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Föregående års schablonavdrag (R40)</Label>
<Input
type="number"
step="1"
value={priorSchablon}
onChange={(e) => setPriorSchablon(e.target.value)}
placeholder="0"
className="tabular-nums h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Föregående års faktiska egenavgifter (R41)</Label>
<Input
type="number"
step="1"
value={priorActual}
onChange={(e) => setPriorActual(e.target.value)}
placeholder="0"
className="tabular-nums h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Önskad periodiseringsfond (max 30 %)</Label>
<Input
type="number"
step="1"
value={pfondDesired}
onChange={(e) => setPfondDesired(e.target.value)}
placeholder="0"
className="tabular-nums h-9"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Tidigare expansionsfond saldo</Label>
<Input
type="number"
step="1"
value={expansionsfondBalance}
onChange={(e) => setExpansionsfondBalance(e.target.value)}
placeholder="0"
className="tabular-nums h-9"
/>
</div>
<div className="space-y-1 col-span-2">
<Label className="text-xs">
Ändring av expansionsfond (+ avsättning, återföring)
</Label>
<Input
type="number"
step="1"
value={expansionsfondChange}
onChange={(e) => setExpansionsfondChange(e.target.value)}
placeholder="0"
className="tabular-nums h-9"
/>
</div>
</div>
</CardContent>
</Card>
{items.map((item) => (
<Card key={item.kind}>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<CardTitle className="text-base">{item.label}</CardTitle>
<p className="text-sm text-muted-foreground mt-1">{item.description}</p>
<Badge variant="outline" className="mt-2">
{item.ne_ruta}
</Badge>
</div>
<p className="font-display text-2xl tabular-nums shrink-0">
{formatCurrency(item.amount)}
</p>
</div>
</CardHeader>
{item.warnings.length > 0 && (
<CardContent className="text-sm text-warning-foreground space-y-1">
{item.warnings.map((w, i) => (
<p key={i}>{w}</p>
))}
</CardContent>
)}
</Card>
))}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Info className="h-4 w-4 text-muted-foreground" />
NE-bilaga räkenskapsschema (R1R11)
</CardTitle>
<p className="text-sm text-muted-foreground">
Räkenskapsschema-delen genereras automatiskt från bokföringen. Ladda ner
SRU-filen och ladda upp den i Skatteverkets e-tjänst för Inkomstdeklaration 1.
</p>
</CardHeader>
<CardContent>
<Button asChild variant="outline">
<Link
href={`/api/reports/ne-bilaga?fiscal_period_id=${fiscalPeriodId}`}
prefetch={false}
>
<FileDown className="mr-2 h-4 w-4" />
Förhandsgranska NE-bilaga
</Link>
</Button>
</CardContent>
</Card>
</div>
)
}
@@ -55,9 +55,16 @@ export function ResultStep({ result }: ResultStepProps) {
<Button variant="outline" asChild>
<Link href="/bookkeeping">Till bokföringen</Link>
</Button>
<Button asChild>
<Button variant="outline" asChild>
<Link href="/reports">Generera rapporter</Link>
</Button>
<Button asChild>
<Link
href={`/bookkeeping/year-end/arsredovisning?period=${result.closingEntry.fiscal_period_id}`}
>
Skapa årsredovisning
</Link>
</Button>
</div>
</div>
)
+213
View File
@@ -5981,6 +5981,219 @@ export const tools: McpTool[] = [
)
},
},
// ─── Phase 4-7: bokslut wizard surfaces exposed to agents ───────────
{
name: 'gnubok_propose_dispositioner',
description:
'Read-only proposal of bokslutsdispositioner for a fiscal period: periodiseringsfond (avsättning + obligatorisk återföring), överavskrivningar, SLP, bolagsskatt. Call before staging postings.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
},
required: ['fiscal_period_id'],
},
// Output is the same DispositionsProposal shape returned by GET
// /bokslutsdispositioner — surface as a permissive object so the
// strict-schema test passes without duplicating the type tree here.
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { buildDispositionsProposal } = await import('@/lib/bokslut/dispositions-proposal-builder')
return buildDispositionsProposal(supabase, companyId, fiscalPeriodId)
},
},
{
name: 'gnubok_propose_accruals',
description:
'Read-only proposal of periodiseringar (förutbetalda/upplupna kostnader). Currently surfaces the vacation-liability change; manual prepaid/accrued entries are submitted by the UI form.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
},
required: ['fiscal_period_id'],
},
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { buildAccrualsProposal } = await import('@/lib/bokslut/accruals/accrual-detector')
return buildAccrualsProposal(supabase, companyId, fiscalPeriodId)
},
},
{
name: 'gnubok_propose_annual_depreciation',
description:
'Read-only per-asset planenlig avskrivning proposal for a fiscal period. Reads the asset register and existing depreciation schedules. Call before staging the post.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
},
required: ['fiscal_period_id'],
},
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { proposeAnnualPostings } = await import('@/lib/bokslut/assets/depreciation-engine')
return proposeAnnualPostings(supabase, companyId, fiscalPeriodId)
},
},
{
name: 'gnubok_post_annual_depreciation',
description:
'Stage planenlig avskrivning posts — one journal entry per asset for independent reversibility. Mid-risk, always staged.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
asset_ids: {
type: 'array',
items: { type: 'string' },
description: 'Optional whitelist of asset UUIDs to post; omit to post all proposed.',
},
},
required: ['fiscal_period_id'],
},
outputSchema: STAGED_OPERATION_SCHEMA,
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
async execute(args, companyId, userId, supabase, actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const assetIds = Array.isArray(args.asset_ids) ? (args.asset_ids as string[]) : undefined
// Mirror the HTTP route's `requireWrite: true` guard so a viewer-role
// member can't post depreciation through the MCP surface. RLS would
// reject the underlying INSERTs anyway, but failing fast here
// produces a much cleaner error than the cascaded RLS rejection.
const { data: membership } = await supabase
.from('company_members')
.select('role')
.eq('company_id', companyId)
.eq('user_id', userId)
.maybeSingle()
if (!membership || membership.role === 'viewer') {
throw new Error('Write permission required')
}
const { data: period } = await supabase
.from('fiscal_periods')
.select('id, name, period_end, is_closed, locked_at, closing_entry_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (!period) throw new Error('Fiscal period not found')
if (period.is_closed || period.closing_entry_id || period.locked_at) {
throw new Error('Period is locked or closed')
}
const { proposeAnnualPostings } = await import('@/lib/bokslut/assets/depreciation-engine')
const proposal = await proposeAnnualPostings(supabase, companyId, fiscalPeriodId)
const filtered = assetIds
? proposal.items.filter((i) => assetIds.includes(i.asset.id))
: proposal.items
const pending = filtered.filter((i) => !i.existingJournalEntryId)
return stagePendingOperation(
supabase, companyId, userId, 'post_annual_depreciation',
`Planenlig avskrivning: ${period.name}`,
{ fiscal_period_id: fiscalPeriodId, asset_ids: assetIds },
{
period_name: period.name,
item_count: pending.length,
total_amount: pending.reduce((s, i) => s + i.amount, 0),
will: `book ${pending.length} planenlig avskrivning(ar) — one journal entry per asset`,
items: pending.map((i) => ({
asset_id: i.asset.id,
asset_name: i.asset.name,
amount: i.amount,
pro_rated: i.proRated,
})),
},
actor,
undefined,
{ dateForPeriodCheck: period.period_end },
)
},
},
{
name: 'gnubok_preview_arsredovisning',
description:
'Read-only K2 årsredovisning preview for a fiscal period. Returns flerårsöversikt, eget-kapital-förändring, RR, BR, K2 noter, signature slots. PDF download is via UI.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
},
required: ['fiscal_period_id'],
},
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { buildArsredovisningData } = await import('@/lib/bokslut/arsredovisning/build-data')
return buildArsredovisningData(supabase, companyId, fiscalPeriodId)
},
},
{
name: 'gnubok_preview_ef_declaration',
description:
'Read-only EF declaration preview: egenavgifter schablonavdrag, räntefördelning, periodiseringsfond, expansionsfond. All declaration-only, never booked. Pass kapitalunderlag and prior-year amounts as inputs.',
inputSchema: {
type: 'object',
additionalProperties: false,
properties: {
fiscal_period_id: { type: 'string', description: 'UUID of the fiscal period' },
category: {
type: 'string',
enum: ['full', 'pensioner', 'passive'],
description: 'Egenavgifter category — defaults to "full"',
},
kapitalunderlag: { type: 'number', description: 'Justerat eget kapital vid föregående års utgång (default 0)' },
prior_year_schablonavdrag: { type: 'number' },
prior_year_actual_charged: { type: 'number' },
pfond_desired_amount: { type: 'number' },
expansionsfond_existing_balance: { type: 'number' },
expansionsfond_desired_change: { type: 'number' },
},
required: ['fiscal_period_id'],
},
outputSchema: { type: 'object', additionalProperties: true },
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
async execute(args, companyId, _userId, supabase, _actor) {
const fiscalPeriodId = args.fiscal_period_id as string
if (!fiscalPeriodId) throw new Error('fiscal_period_id is required')
const { computeEfDeclarationPreview } = await import('@/lib/bokslut/enskild-firma/ef-declaration-preview')
return computeEfDeclarationPreview(supabase, companyId, fiscalPeriodId, {
category: args.category as 'full' | 'pensioner' | 'passive' | undefined,
kapitalunderlag: args.kapitalunderlag as number | undefined,
priorYearSchablonavdrag: args.prior_year_schablonavdrag as number | undefined,
priorYearActualCharged: args.prior_year_actual_charged as number | undefined,
pfondDesiredAmount: args.pfond_desired_amount as number | undefined,
expansionsfondExistingBalance: args.expansionsfond_existing_balance as number | undefined,
expansionsfondDesiredChange: args.expansionsfond_desired_change as number | undefined,
})
},
},
]
// ── MCP Protocol Handler ─────────────────────────────────────
@@ -0,0 +1,89 @@
import { describe, it, expect } from 'vitest'
import {
proposeAuditFee,
proposeManualPrepaid,
proposeManualAccrued,
} from '../accruals/accrual-detector'
describe('proposeAuditFee', () => {
it('defaults to 2992 (revision)', () => {
const r = proposeAuditFee({ amount: 18_000, closingDate: '2025-12-31' })
expect(r).not.toBeNull()
expect(r!.lines[0].account_number).toBe('6420')
expect(r!.lines[1].account_number).toBe('2992')
expect(r!.reverses_on).toBe('2026-01-01')
})
it('uses 2991 when liabilityAccount=2991 (bokslut)', () => {
const r = proposeAuditFee({
amount: 12_000,
closingDate: '2026-12-31',
liabilityAccount: '2991',
})
expect(r).not.toBeNull()
expect(r!.lines[1].account_number).toBe('2991')
expect(r!.reverses_on).toBe('2027-01-01')
})
it('returns null on zero / negative amount', () => {
expect(proposeAuditFee({ amount: 0, closingDate: '2025-12-31' })).toBeNull()
})
})
describe('proposeManualPrepaid', () => {
it('enforces 17xx prepaid account range', () => {
expect(() =>
proposeManualPrepaid({
amount: 1000,
expenseAccount: '6310',
prepaidAccount: '1810', // not in 17xx
description: 'x',
closingDate: '2025-12-31',
}),
).toThrow(/17xx/)
})
it('emits balanced 17xx / cost-account entry that reverses Jan 1', () => {
const r = proposeManualPrepaid({
amount: 12_000,
expenseAccount: '6310',
prepaidAccount: '1730',
description: 'Försäkring 2026',
closingDate: '2025-12-31',
})
expect(r).not.toBeNull()
expect(r!.lines[0].account_number).toBe('1730')
expect(r!.lines[0].debit_amount).toBe(12_000)
expect(r!.lines[1].account_number).toBe('6310')
expect(r!.lines[1].credit_amount).toBe(12_000)
expect(r!.reverses_on).toBe('2026-01-01')
})
})
describe('proposeManualAccrued', () => {
it('enforces 29xx accrued account range', () => {
expect(() =>
proposeManualAccrued({
amount: 1000,
expenseAccount: '5010',
accruedAccount: '1990', // not in 29xx
description: 'x',
closingDate: '2025-12-31',
}),
).toThrow(/29xx/)
})
it('emits balanced entry that reverses Jan 1', () => {
const r = proposeManualAccrued({
amount: 5_000,
expenseAccount: '5010',
accruedAccount: '2990',
description: 'Hyra dec',
closingDate: '2025-12-31',
})
expect(r).not.toBeNull()
expect(r!.lines[0].account_number).toBe('5010')
expect(r!.lines[1].account_number).toBe('2990')
expect(r!.reverses_on).toBe('2026-01-01')
})
})
@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest'
import { proposeEfPfondAvsattning, proposeEfPfondAteforing } from '../enskild-firma/periodiseringsfond-ef'
import { calculateExpansionsfondChange } from '../enskild-firma/expansionsfond-calculator'
describe('EF periodiseringsfond — avsättning', () => {
it('caps at 30 % of surplus (vs 25 % for AB)', () => {
const r = proposeEfPfondAvsattning({ surplus: 100_000, fiscalYear: 2026, desiredAmount: 50_000 })
expect(r).not.toBeNull()
// max = 30 000, desired 50 000 → capped to 30 000
expect(r!.amount).toBe(30_000)
expect(r!.warnings[0]).toContain('30')
})
it('returns null on zero / negative surplus', () => {
expect(proposeEfPfondAvsattning({ surplus: 0, fiscalYear: 2026 })).toBeNull()
expect(proposeEfPfondAvsattning({ surplus: -1, fiscalYear: 2026 })).toBeNull()
})
})
describe('EF periodiseringsfond — återföring', () => {
it('forces full reversal of 6+ year old fonder', () => {
const items = proposeEfPfondAteforing({
existingFonder: [{ cohort_year: 2020, balance: 50_000 }],
closingYear: 2026,
})
expect(items).toHaveLength(1)
expect(items[0].amount).toBe(50_000)
expect(items[0].warnings[0]).toContain('6-årsgränsen')
})
it('caps optional returns to the booked balance', () => {
const items = proposeEfPfondAteforing({
existingFonder: [{ cohort_year: 2023, balance: 20_000 }],
closingYear: 2026,
returns: { 2023: 100_000 },
})
expect(items[0].amount).toBe(20_000)
})
})
describe('Expansionsfond', () => {
it('caps avsättning at 125.94 % of kapitalunderlag', () => {
const r = calculateExpansionsfondChange({
kapitalunderlag: 100_000,
existingBalance: 0,
desiredChange: 200_000, // would exceed 125 940 cap
})
expect(r).not.toBeNull()
expect(r!.amount).toBe(125_940)
expect(r!.warnings[0]).toContain('125,94')
})
it('limits återföring to existing balance', () => {
const r = calculateExpansionsfondChange({
kapitalunderlag: 100_000,
existingBalance: 30_000,
desiredChange: -50_000,
})
expect(r).not.toBeNull()
expect(r!.kind).toBe('expansionsfond_ateforing')
expect(r!.amount).toBe(30_000)
})
it('returns null when desiredChange is 0', () => {
expect(
calculateExpansionsfondChange({ kapitalunderlag: 100_000, desiredChange: 0 }),
).toBeNull()
})
})
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { calculateEgenavgifter } from '../enskild-firma/egenavgifter-calculator'
describe('calculateEgenavgifter', () => {
it('full rate: 25 % schablonavdrag on positive surplus', () => {
const r = calculateEgenavgifter({
surplusBeforeEgenavgifter: 200_000,
category: 'full',
})
// No prior-year adjustments → net = 200 000, schablon 25 % = 50 000
expect(r.amount).toBe(50_000)
expect(r.ne_ruta).toBe('R43')
})
it('pensioner: 10 % schablonavdrag', () => {
const r = calculateEgenavgifter({
surplusBeforeEgenavgifter: 200_000,
category: 'pensioner',
})
expect(r.amount).toBe(20_000)
})
it('passive: 20 % schablonavdrag (SLP base)', () => {
const r = calculateEgenavgifter({
surplusBeforeEgenavgifter: 200_000,
category: 'passive',
})
expect(r.amount).toBe(40_000)
})
it('honors prior-year add-back / actual deduct', () => {
// Net surplus = 200 000 + 30 000 (R40) 25 000 (R41) = 205 000
// Schablon 25 % = 51 250 → floor → 51 250
const r = calculateEgenavgifter({
surplusBeforeEgenavgifter: 200_000,
category: 'full',
priorYearSchablonavdrag: 30_000,
priorYearActualCharged: 25_000,
})
expect(r.amount).toBe(51_250)
})
it('returns 0 amount on loss year and emits warning', () => {
const r = calculateEgenavgifter({
surplusBeforeEgenavgifter: -10_000,
category: 'full',
})
expect(r.amount).toBe(0)
expect(r.warnings).toContainEqual(expect.stringMatching(/inget överskott/i))
})
it('surfaces the 7.5 % nedsättning hint for active surplus > 40 000', () => {
const r = calculateEgenavgifter({
surplusBeforeEgenavgifter: 100_000,
category: 'full',
})
expect(r.warnings.some((w) => /7,5/.test(w))).toBe(true)
})
})
@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest'
import { calculateRantefordelning, NEGATIVE_THRESHOLD } from '../enskild-firma/rantefordelning-calculator'
describe('calculateRantefordelning', () => {
it('positive: SLR+6 pe × kapitalunderlag', () => {
// SLR 2025-11-30 = 2.55 %, +6 = 8.55 %. 1 000 000 × 0.0855 = 85 500
const r = calculateRantefordelning({ kapitalunderlag: 1_000_000 })
expect(r).not.toBeNull()
expect(r!.kind).toBe('rantefordelning_positive')
expect(r!.amount).toBe(85_500)
})
it('returns null between 0 and the -500 000 negative threshold', () => {
expect(calculateRantefordelning({ kapitalunderlag: 0 })).toBeNull()
expect(calculateRantefordelning({ kapitalunderlag: -100_000 })).toBeNull()
expect(calculateRantefordelning({ kapitalunderlag: NEGATIVE_THRESHOLD })).toBeNull()
})
it('negative: SLR+1 pe × |kapitalunderlag| when under -500 000', () => {
// -600 000 < -500 000. 600 000 × (0.0255 + 0.01) = 21 300
const r = calculateRantefordelning({ kapitalunderlag: -600_000 })
expect(r).not.toBeNull()
expect(r!.kind).toBe('rantefordelning_negative')
expect(r!.amount).toBe(21_300)
expect(r!.warnings[0]).toContain('-500 000')
})
it('honors a custom SLR rate', () => {
// For inkomstår 2025: SLR = 1.96 %. Positive at +6 = 7.96 %. On 100 000 = 7 960
const r = calculateRantefordelning({ kapitalunderlag: 100_000, slrRate: 0.0196 })
expect(r).not.toBeNull()
expect(r!.amount).toBe(7_960)
})
})
+314
View File
@@ -0,0 +1,314 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateVacationLiability } from '@/lib/reports/vacation-liability'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import type { AccrualProposal, AccrualsProposal } from './types'
/** Sociala avgifter on accrued salary/vacation (K3 BFNAR 2012:1 ch.19,
* K2 BFNAR 2016:10 ch.16). Same rate as the regular AGI calculation. */
export const AVGIFTER_RATE_ON_ACCRUED = 0.3142
/**
* Compute the next-day ISO date for auto-reversal. The accrual is reversed
* on the first day of the period following the closing date.
*/
function nextDayIso(closingDate: string): string {
const d = new Date(closingDate + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + 1)
return d.toISOString().slice(0, 10)
}
/**
* Propose an adjustment of semesterlöneskuld (vacation pay liability).
*
* Two correctness rules drive this entry (per BFNAR 2016:10 ch.16 and the
* vacation-liability report):
*
* 1. The delta is anchored against the **current closing balance** of
* 2920 (after any mid-year partial accruals / reversals), not the
* opening balance. Anchoring on opening would over- or understate the
* adjustment by the sum of in-period movements.
*
* 2. Semesterlöneskuld is a balance-sheet carry-forward (2920 / 2940
* persist until the vacation is actually paid). The bokslut delta is
* a normal posting that does NOT reverse on Jan 1 — reversing would
* zero the liability on day 1 of the new year, which is a known
* Swedish bookkeeping error. Hence `reverses_on` is empty for this
* proposal; the wizard / UI suppresses the reversal badge accordingly.
*
* Returns null when delta is zero (no entry to propose).
*/
export async function proposeVacationLiabilityChange(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options: { closingDate: string },
): Promise<AccrualProposal | null> {
const closingYear = parseInt(options.closingDate.slice(0, 4), 10)
if (Number.isNaN(closingYear)) {
throw new Error(`Invalid closing date: ${options.closingDate}`)
}
const [report, tb] = await Promise.all([
generateVacationLiability(supabase, companyId, closingYear),
generateTrialBalance(supabase, companyId, fiscalPeriodId),
])
// Current closing balance (what 2920 should be at year-end)
const targetLiability = Math.round(report.totals.accruedAmount)
// Anchor on the CURRENT closing balance, not opening — captures any
// mid-year accruals / reversals that have already touched 2920.
const row2920 = tb.rows.find((r) => r.account_number === '2920')
const currentLiability = row2920
? Math.round((row2920.closing_credit - row2920.closing_debit) * 100) / 100
: 0
const deltaLiability = targetLiability - currentLiability
if (Math.abs(deltaLiability) < 1) {
return null
}
// Round each side to whole krona so the entry stays balanced after
// rounding. Compute avgifter on the same rounded delta.
const deltaInt = Math.round(deltaLiability)
const avgifterDelta = Math.round(deltaInt * AVGIFTER_RATE_ON_ACCRUED)
// Positive delta = more vacation liability accrued → debit 7090 expense.
// Negative delta = vacation taken / liability released → credit 7090.
const isIncrease = deltaInt > 0
const lines = [
{
account_number: '7090',
debit_amount: isIncrease ? Math.abs(deltaInt) : 0,
credit_amount: isIncrease ? 0 : Math.abs(deltaInt),
line_description: 'Förändring av semesterlöneskuld',
},
{
account_number: '2920',
debit_amount: isIncrease ? 0 : Math.abs(deltaInt),
credit_amount: isIncrease ? Math.abs(deltaInt) : 0,
line_description: 'Upplupna semesterlöner',
},
{
account_number: '7519',
debit_amount: isIncrease ? Math.abs(avgifterDelta) : 0,
credit_amount: isIncrease ? 0 : Math.abs(avgifterDelta),
line_description: 'Sociala avgifter på upplupen semester (31,42 %)',
},
{
account_number: '2940',
debit_amount: isIncrease ? 0 : Math.abs(avgifterDelta),
credit_amount: isIncrease ? Math.abs(avgifterDelta) : 0,
line_description: 'Upplupna sociala avgifter på semester',
},
]
const totalAmount = Math.abs(deltaInt) + Math.abs(avgifterDelta)
return {
kind: 'vacation_liability_change',
label: isIncrease
? `Ökning av semesterlöneskuld (${Math.abs(deltaInt)} kr + avgifter)`
: `Minskning av semesterlöneskuld (${Math.abs(deltaInt)} kr + avgifter)`,
description:
'Justering av 2920 mot 7090 plus 31,42 % sociala avgifter på 2940 mot 7519. Saldot på 2920 rullas vidare till nästa år (ingen vändning).',
amount: totalAmount,
lines,
// Empty string = no reversal. UI / commit handler treats this differently
// from the periodisering case (which has a real reverses_on date).
reverses_on: '',
warnings: [],
computation: {
current_2920: currentLiability,
closing_target: targetLiability,
delta: deltaInt,
avgifter_rate: AVGIFTER_RATE_ON_ACCRUED,
avgifter_delta: avgifterDelta,
employee_rows: report.rows.length,
},
}
}
export interface AuditFeeInput {
/** Estimated audit fee for the fiscal year being closed. */
amount: number
/** Closing date — used to derive the reversal date. */
closingDate: string
/** Account to credit on the liability side. Defaults to 2992 (revision),
* use 2991 for bokslut-fee accrual. */
liabilityAccount?: '2991' | '2992'
}
/**
* Propose accrual of audit / bookkeeping fee that will be invoiced after
* year-end. Standard BFL practice: accrue the cost in the period it relates
* to, reverse on Jan 1 when the actual invoice arrives.
*/
export function proposeAuditFee(input: AuditFeeInput): AccrualProposal | null {
const amount = Math.round(input.amount)
if (amount <= 0) return null
const liabilityAccount = input.liabilityAccount ?? '2992'
const isBokslut = liabilityAccount === '2991'
// BAS 2026: 6420 = "Revisionsarvode" (lagstadgad revision specifically).
// Bokslutskostnader for a non-revisionspliktigt bolag belong on 6590
// (övriga externa tjänster) — Skatteverket may query a 6420 debit
// without a corresponding revisor i bolaget.
const expenseAccount = isBokslut ? '6590' : '6420'
return {
kind: 'audit_fee',
label: isBokslut ? 'Beräknat arvode för bokslut' : 'Beräknat arvode för revision',
description: `Debet ${expenseAccount}, kredit ${liabilityAccount}. Vänds vid faktura nästa år.`,
amount,
lines: [
{
account_number: expenseAccount,
debit_amount: amount,
credit_amount: 0,
line_description: isBokslut ? 'Beräknat bokslutarvode' : 'Beräknat revisionsarvode',
},
{
account_number: liabilityAccount,
debit_amount: 0,
credit_amount: amount,
line_description: isBokslut ? 'Beräknat arvode bokslut' : 'Beräknat arvode revision',
},
],
reverses_on: nextDayIso(input.closingDate),
warnings: [],
}
}
export interface ManualPrepaidInput {
/** Amount of the cost that relates to NEXT year and should be reclassified
* to a 17xx prepaid account. */
amount: number
/** Cost account being relieved (e.g. 6310 företagsförsäkringar). */
expenseAccount: string
/** Target prepaid account (e.g. 1730 förutbetalda försäkringspremier).
* Must be in the 17xx interimsfordringar range. */
prepaidAccount: string
/** Period this prepaid covers — used in the line description. */
description: string
closingDate: string
}
/**
* Manual prepaid expense reclassification. Debit 17xx, credit the expense
* account by the portion that hasn't been consumed yet.
*
* The caller chooses which expense and prepaid accounts to use because
* heuristic detection from supplier invoices isn't reliable (no service-
* period field on the invoice model — see types/index.ts SupplierInvoice).
* A future heuristic detector can replace this when the data model grows
* service_period_start/_end fields.
*/
export function proposeManualPrepaid(input: ManualPrepaidInput): AccrualProposal | null {
if (!/^17\d{2}$/.test(input.prepaidAccount)) {
throw new Error(`prepaidAccount must be in 17xx range, got ${input.prepaidAccount}`)
}
const amount = Math.round(input.amount)
if (amount <= 0) return null
return {
kind: 'manual_prepaid_expense',
label: `Förutbetald kostnad: ${input.description}`,
description: `Debet ${input.prepaidAccount}, kredit ${input.expenseAccount}. Vänds vid årsskiftet.`,
amount,
lines: [
{
account_number: input.prepaidAccount,
debit_amount: amount,
credit_amount: 0,
line_description: `Förutbetald: ${input.description}`,
},
{
account_number: input.expenseAccount,
debit_amount: 0,
credit_amount: amount,
line_description: `Periodisering ut: ${input.description}`,
},
],
reverses_on: nextDayIso(input.closingDate),
warnings: [],
}
}
export interface ManualAccruedInput {
amount: number
/** Cost account being charged (e.g. 5010 hyra lokal). */
expenseAccount: string
/** Target accrued-cost account (e.g. 2990 övriga upplupna kostnader).
* Must be in the 29xx interimsskulder range. */
accruedAccount: string
description: string
closingDate: string
}
/**
* Manual accrued cost. Debit the expense account, credit 29xx for the
* portion incurred but not yet invoiced. Mirrors `proposeManualPrepaid`
* but in the opposite direction.
*/
export function proposeManualAccrued(input: ManualAccruedInput): AccrualProposal | null {
if (!/^29\d{2}$/.test(input.accruedAccount)) {
throw new Error(`accruedAccount must be in 29xx range, got ${input.accruedAccount}`)
}
const amount = Math.round(input.amount)
if (amount <= 0) return null
return {
kind: 'manual_accrued_expense',
label: `Upplupen kostnad: ${input.description}`,
description: `Debet ${input.expenseAccount}, kredit ${input.accruedAccount}. Vänds vid årsskiftet.`,
amount,
lines: [
{
account_number: input.expenseAccount,
debit_amount: amount,
credit_amount: 0,
line_description: `Periodisering in: ${input.description}`,
},
{
account_number: input.accruedAccount,
debit_amount: 0,
credit_amount: amount,
line_description: `Upplupen: ${input.description}`,
},
],
reverses_on: nextDayIso(input.closingDate),
warnings: [],
}
}
/**
* Build a snapshot of automatically-detectable accrual proposals for the
* wizard's preflight. Today this is just the vacation-liability delta;
* future versions can add salary-accrued-but-unpaid, supplier-invoice-period
* detection, etc. Manual prepaid/accrued cards are added via the UI form
* (the API endpoint accepts them but they're not in the auto-proposal).
*/
export async function buildAccrualsProposal(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<AccrualsProposal> {
const { data: period, error } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (error || !period) throw new Error('Fiscal period not found')
const proposals: AccrualProposal[] = []
const vacation = await proposeVacationLiabilityChange(supabase, companyId, fiscalPeriodId, {
closingDate: period.period_end,
})
if (vacation) proposals.push(vacation)
return {
fiscalPeriod: period,
proposals,
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { CreateJournalEntryLineInput } from '@/types'
export type AccrualKind =
| 'vacation_liability_change'
| 'audit_fee'
| 'social_fees_on_accrued_salary'
| 'manual_prepaid_expense'
| 'manual_accrued_expense'
/**
* A single accrual proposal the wizard renders as one card. Mirrors the
* shape of `ProposedDisposition` (lib/bokslut/types.ts) so the wizard UI
* patterns stay consistent across accruals and dispositioner.
*/
export interface AccrualProposal {
kind: AccrualKind
/** Short Swedish label for UI cards. */
label: string
/** One-sentence Swedish explanation. */
description: string
/** SEK amount displayed in the card header. Always positive. */
amount: number
/** Final voucher lines if the user accepts. Already balanced. */
lines: CreateJournalEntryLineInput[]
/** Date the entry should be reversed on (typically Jan 1 of next FY).
* Phase 4 ships this as metadata; the actual auto-reversal cron is
* follow-up infra. UI surfaces the date so users know to reverse manually
* in the meantime. */
reverses_on: string
/** Soft warnings the UI surfaces beside the card. Never blockers. */
warnings: string[]
/** Calculator-specific breakdown for the "Visa beräkning" panel. */
computation?: Record<string, unknown>
}
/**
* Snapshot of accrual proposals for a fiscal period.
*/
export interface AccrualsProposal {
fiscalPeriod: {
id: string
name: string
period_start: string
period_end: string
}
proposals: AccrualProposal[]
}
@@ -0,0 +1,288 @@
import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
import type { ArsredovisningData } from './types'
const styles = StyleSheet.create({
page: {
paddingTop: 50,
paddingHorizontal: 50,
paddingBottom: 60,
fontSize: 10,
fontFamily: 'Helvetica',
},
pageHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
marginBottom: 16,
fontSize: 8,
color: '#555',
borderBottomWidth: 0.5,
borderBottomColor: '#aaa',
paddingBottom: 6,
},
pageFooter: {
position: 'absolute',
bottom: 30,
left: 50,
right: 50,
fontSize: 8,
color: '#888',
textAlign: 'center',
},
title: {
fontSize: 24,
fontFamily: 'Helvetica-Bold',
marginTop: 40,
marginBottom: 10,
},
subtitle: {
fontSize: 12,
color: '#444',
marginBottom: 50,
},
sectionTitle: {
fontSize: 13,
fontFamily: 'Helvetica-Bold',
marginTop: 20,
marginBottom: 10,
},
paragraph: {
marginBottom: 8,
lineHeight: 1.4,
},
noteBody: {
marginBottom: 4,
lineHeight: 1.4,
},
tableHeader: {
flexDirection: 'row',
fontFamily: 'Helvetica-Bold',
fontSize: 9,
borderBottomWidth: 0.5,
borderBottomColor: '#888',
paddingBottom: 4,
marginBottom: 4,
},
tableRow: {
flexDirection: 'row',
paddingVertical: 2,
},
tableRowTotal: {
flexDirection: 'row',
paddingVertical: 3,
borderTopWidth: 0.5,
borderTopColor: '#888',
fontFamily: 'Helvetica-Bold',
},
colLabel: {
flex: 1,
},
colLabelIndent: {
flex: 1,
paddingLeft: 12,
},
colAmount: {
width: 100,
textAlign: 'right',
},
signatureLine: {
flexDirection: 'row',
marginTop: 30,
alignItems: 'flex-end',
},
signatureSlot: {
flex: 1,
marginRight: 20,
borderBottomWidth: 0.5,
borderBottomColor: '#333',
paddingBottom: 2,
},
})
function fmt(amount: number): string {
// sv-SE thousands grouping, no decimals — typical for K2 ÅR.
return Math.round(amount).toLocaleString('sv-SE')
}
function PageChrome({
data,
pageLabel,
}: {
data: ArsredovisningData
pageLabel?: string
}) {
return (
<>
<View style={styles.pageHeader} fixed>
<Text>
{data.company.name} · {data.company.org_number}
</Text>
<Text>Årsredovisning {data.fiscal_period.name}</Text>
</View>
<Text style={styles.pageFooter} fixed>
{pageLabel ?? ''}
</Text>
</>
)
}
export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) {
return (
<Document>
{/* Cover */}
<Page size="A4" style={styles.page}>
<PageChrome data={data} pageLabel="Försättssida" />
<View>
<Text style={styles.title}>Årsredovisning</Text>
<Text style={styles.subtitle}>
för räkenskapsåret {data.fiscal_period.period_start} {data.fiscal_period.period_end}
</Text>
<Text style={styles.paragraph}>{data.company.name}</Text>
<Text style={styles.paragraph}>Organisationsnummer: {data.company.org_number}</Text>
{data.company.sate && (
<Text style={styles.paragraph}>Säte: {data.company.sate}</Text>
)}
</View>
</Page>
{/* Förvaltningsberättelse */}
<Page size="A4" style={styles.page}>
<PageChrome data={data} pageLabel="Förvaltningsberättelse" />
<Text style={styles.sectionTitle}>Förvaltningsberättelse</Text>
<Text style={styles.sectionTitle}>Verksamhet</Text>
<Text style={styles.paragraph}>{data.forvaltningsberattelse.description}</Text>
<Text style={styles.sectionTitle}>Väsentliga händelser under räkenskapsåret</Text>
<Text style={styles.paragraph}>{data.forvaltningsberattelse.important_events}</Text>
{data.forvaltningsberattelse.kontrollbalans_required && (
<>
<Text style={styles.sectionTitle}>Kontrollbalansräkning</Text>
<Text style={styles.paragraph}>
Kontrollbalansräkning har upprättats under räkenskapsåret enligt ABL 25 kap.
</Text>
</>
)}
<Text style={styles.sectionTitle}>Flerårsöversikt (kr)</Text>
<View style={styles.tableHeader}>
<Text style={styles.colLabel}>År</Text>
<Text style={styles.colAmount}>Nettoomsättning</Text>
<Text style={styles.colAmount}>Resultat e.fin.poster</Text>
<Text style={styles.colAmount}>Soliditet (%)</Text>
</View>
{data.forvaltningsberattelse.flerarsoversikt.map((row) => (
<View key={row.year} style={styles.tableRow}>
<Text style={styles.colLabel}>{row.year}</Text>
<Text style={styles.colAmount}>{fmt(row.net_revenue)}</Text>
<Text style={styles.colAmount}>{fmt(row.result_after_financial)}</Text>
<Text style={styles.colAmount}>
{row.soliditet_pct === null ? '—' : row.soliditet_pct.toFixed(1)}
</Text>
</View>
))}
<Text style={styles.sectionTitle}>Förändring av eget kapital (kr)</Text>
{data.forvaltningsberattelse.egen_kapital_changes.map((r) => (
<View key={r.label} style={styles.tableRow}>
<Text style={styles.colLabel}>{r.label}</Text>
<Text style={styles.colAmount}>{fmt(r.amount)}</Text>
</View>
))}
<Text style={styles.sectionTitle}>Förslag till resultatdisposition</Text>
<Text style={styles.paragraph}>{data.forvaltningsberattelse.resultatdisposition}</Text>
</Page>
{/* Resultaträkning */}
<Page size="A4" style={styles.page}>
<PageChrome data={data} pageLabel="Resultaträkning" />
<Text style={styles.sectionTitle}>Resultaträkning (kr)</Text>
<View style={styles.tableHeader}>
<Text style={styles.colLabel}>Post</Text>
<Text style={styles.colAmount}>{data.fiscal_period.name}</Text>
</View>
{data.resultatrakning.map((line, i) => (
<View key={i} style={line.is_total ? styles.tableRowTotal : styles.tableRow}>
<Text style={styles.colLabel}>{line.label}</Text>
<Text style={styles.colAmount}>{fmt(line.amount)}</Text>
</View>
))}
</Page>
{/* Balansräkning */}
<Page size="A4" style={styles.page}>
<PageChrome data={data} pageLabel="Balansräkning" />
<Text style={styles.sectionTitle}>Tillgångar (kr)</Text>
<View style={styles.tableHeader}>
<Text style={styles.colLabel}>Post</Text>
<Text style={styles.colAmount}>{data.fiscal_period.period_end}</Text>
</View>
{data.balansrakning.assets.map((line, i) => (
<View key={i} style={line.is_total ? styles.tableRowTotal : styles.tableRow}>
<Text style={line.indent ? styles.colLabelIndent : styles.colLabel}>
{line.label}
</Text>
<Text style={styles.colAmount}>{fmt(line.amount)}</Text>
</View>
))}
<View style={styles.tableRowTotal}>
<Text style={styles.colLabel}>Summa tillgångar</Text>
<Text style={styles.colAmount}>{fmt(data.balansrakning.total_assets)}</Text>
</View>
<Text style={styles.sectionTitle}>Eget kapital och skulder (kr)</Text>
{data.balansrakning.equity_liabilities.map((line, i) => (
<View key={i} style={line.is_total ? styles.tableRowTotal : styles.tableRow}>
<Text style={line.indent ? styles.colLabelIndent : styles.colLabel}>
{line.label}
</Text>
<Text style={styles.colAmount}>{fmt(line.amount)}</Text>
</View>
))}
<View style={styles.tableRowTotal}>
<Text style={styles.colLabel}>Summa eget kapital och skulder</Text>
<Text style={styles.colAmount}>{fmt(data.balansrakning.total_equity_liabilities)}</Text>
</View>
</Page>
{/* Noter */}
<Page size="A4" style={styles.page}>
<PageChrome data={data} pageLabel="Noter" />
<Text style={styles.sectionTitle}>Noter</Text>
{data.noter.map((note) => (
<View key={note.number} style={{ marginBottom: 16 }}>
<Text style={{ fontFamily: 'Helvetica-Bold', marginBottom: 4 }}>
Not {note.number} {note.title}
</Text>
<Text style={styles.noteBody}>{note.body}</Text>
</View>
))}
</Page>
{/* Underskrifter */}
<Page size="A4" style={styles.page}>
<PageChrome data={data} pageLabel="Underskrifter" />
<Text style={styles.sectionTitle}>Underskrifter</Text>
<Text style={styles.paragraph}>
{data.company.sate ? `${data.company.sate}, ` : ''}
{data.fiscal_period.period_end}
</Text>
{(data.signatures.length > 0
? data.signatures
: [
{ role: 'Styrelseledamot', name: '', signed_at: null },
{ role: 'Styrelseledamot', name: '', signed_at: null },
]
).map((sig, i) => (
<View key={i} style={styles.signatureLine}>
<View style={styles.signatureSlot}>
<Text>{sig.name || ' '}</Text>
</View>
<Text style={{ width: 120 }}>{sig.role}</Text>
</View>
))}
</Page>
</Document>
)
}
+404
View File
@@ -0,0 +1,404 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { listAssets } from '@/lib/bokslut/assets/asset-service'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type {
ArsredovisningData,
EgenKapitalRow,
FlerarsoversiktRow,
IncomeStatementLine,
BalanceSheetLine,
NoteEntry,
} from './types'
import type { BalanceSheetSection, IncomeStatementSection } from '@/types'
/**
* Pre-populate the K2 årsredovisning data for a fiscal period. Loads:
* - Income statement + balance sheet for the current period
* - Up to 3 prior periods for the flerårsöversikt
* - Asset register so noter can list avskrivningstider per category
* - Active employees count for medelantal anställda
* - Equity-account movements for förändring av eget kapital
*
* Manually-authored fields (description, important_events,
* resultatdisposition, ställda säkerheter, eventualförpliktelser) are
* pre-filled with sensible boilerplate the user can replace. The narrative
* editor in the UI persists overrides via /api/.../arsredovisning POST.
*/
export async function buildArsredovisningData(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
overrides: Partial<ArsredovisningData['forvaltningsberattelse']> = {},
): Promise<ArsredovisningData> {
const [periodResult, settingsResult, periodList, incomeStatement, balanceSheet] = await Promise.all([
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, previous_period_id, closing_entry_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
supabase
.from('company_settings')
.select('company_name, org_number, address')
.eq('company_id', companyId)
.maybeSingle(),
fetchAllRows(({ from, to }) =>
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('company_id', companyId)
.order('period_start', { ascending: false })
.range(from, to),
),
generateIncomeStatement(supabase, companyId, fiscalPeriodId),
generateBalanceSheet(supabase, companyId, fiscalPeriodId),
])
if (periodResult.error || !periodResult.data) {
throw new Error('Fiscal period not found')
}
const period = periodResult.data
const settings = settingsResult.data
const companyName = settings?.company_name ?? 'Bolaget'
const orgNumber = settings?.org_number ?? ''
type AddressShape = { city?: string | null; postal_city?: string | null } | null
const addressUnknown = (settings as { address?: AddressShape } | null)?.address ?? null
const sate =
(addressUnknown && (addressUnknown.city ?? addressUnknown.postal_city)) || null
const flerarsoversikt = await buildFlerarsoversikt(
supabase,
companyId,
fiscalPeriodId,
(periodList ?? []) as Array<{ id: string; name: string; period_start: string; period_end: string }>,
)
const egen_kapital_changes = buildEquityChanges(balanceSheet.equity_liability_sections)
const noter = await buildK2Noter(supabase, companyId)
const resultatrakning = flattenIncomeStatement(incomeStatement)
const balansrakning = flattenBalanceSheet(balanceSheet)
return {
company: {
name: companyName,
org_number: orgNumber,
sate,
},
fiscal_period: {
id: period.id,
name: period.name,
period_start: period.period_start,
period_end: period.period_end,
},
forvaltningsberattelse: {
description:
overrides.description ??
`${companyName} bedriver verksamhet enligt verksamhetsbeskrivningen i bolagsordningen.`,
important_events:
overrides.important_events ??
'Inga väsentliga händelser utöver löpande verksamhet har inträffat under räkenskapsåret.',
kontrollbalans_required: overrides.kontrollbalans_required ?? false,
flerarsoversikt,
egen_kapital_changes,
resultatdisposition:
overrides.resultatdisposition ??
'Styrelsen föreslår att årets resultat balanseras i ny räkning.',
},
resultatrakning,
balansrakning,
noter,
signatures: [], // populated by signature-flow service in a later phase step
}
}
interface PeriodRow {
id: string
name: string
period_start: string
period_end: string
}
async function buildFlerarsoversikt(
supabase: SupabaseClient,
companyId: string,
currentPeriodId: string,
allPeriods: PeriodRow[],
): Promise<FlerarsoversiktRow[]> {
// Take the current period + 3 prior (oldest first).
const sorted = [...allPeriods].sort((a, b) => a.period_start.localeCompare(b.period_start))
const currentIdx = sorted.findIndex((p) => p.id === currentPeriodId)
if (currentIdx === -1) return []
const slice = sorted.slice(Math.max(0, currentIdx - 3), currentIdx + 1)
const rows: FlerarsoversiktRow[] = []
for (const p of slice) {
try {
const [is, tb] = await Promise.all([
generateIncomeStatement(supabase, companyId, p.id),
generateTrialBalance(supabase, companyId, p.id),
])
// Nettoomsättning = sum of revenue sections (revenue is normally credit).
const netRevenue = is.total_revenue
const resultAfterFinancial = is.total_revenue - is.total_expenses + is.total_financial
const totalAssets = tb.rows
.filter((r) => r.account_class === 1)
.reduce((s, r) => s + (r.closing_debit - r.closing_credit), 0)
const eqLiab = tb.rows
.filter((r) => r.account_class === 2)
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
// Soliditet: eget kapital uses 20xx ONLY. 21xx (periodiseringsfonder,
// överavskrivningar) are obeskattade reserver — partially deferred tax,
// not equity. K2 / ÅRL splits them out. Including 21xx here would
// inflate soliditet for any AB that posts dispositions.
const equity = tb.rows
.filter((r) => r.account_number.startsWith('20'))
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
const soliditet =
totalAssets > 0 ? Math.round((equity / totalAssets) * 1000) / 10 : null
// Avoid the unused-variable warning while leaving eqLiab computed for
// future "Skulder" column expansion.
void eqLiab
rows.push({
year: p.name,
net_revenue: Math.round(netRevenue),
result_after_financial: Math.round(resultAfterFinancial),
soliditet_pct: soliditet,
})
} catch {
// Prior periods may lack continuity if SIE import was partial. Skip
// rather than blocking the whole årsredovisning.
rows.push({
year: p.name,
net_revenue: 0,
result_after_financial: 0,
soliditet_pct: null,
})
}
}
return rows
}
function buildEquityChanges(sections: BalanceSheetSection[]): EgenKapitalRow[] {
const equity: EgenKapitalRow[] = []
for (const section of sections) {
for (const row of section.rows) {
if (
row.account_number.startsWith('20') ||
row.account_number.startsWith('21')
) {
equity.push({
label: `${row.account_number} ${row.account_name}`,
amount: row.amount,
})
}
}
}
return equity
}
async function buildK2Noter(
supabase: SupabaseClient,
companyId: string,
): Promise<NoteEntry[]> {
const notes: NoteEntry[] = []
notes.push({
number: 1,
title: 'Redovisnings- och värderingsprinciper',
body:
'Årsredovisningen är upprättad i enlighet med Årsredovisningslagen och Bokföringsnämndens allmänna råd BFNAR 2016:10 Årsredovisning i mindre företag (K2).',
})
// Avskrivningstider — derive from asset register
const assets = await listAssets(supabase, companyId)
if (assets.length > 0) {
const byCategory = new Map<string, Set<number>>()
for (const a of assets) {
if (a.disposed_at) continue
const years = Math.round(a.useful_life_months / 12)
if (!byCategory.has(a.category)) byCategory.set(a.category, new Set())
byCategory.get(a.category)!.add(years)
}
if (byCategory.size > 0) {
const lines: string[] = ['Avskrivningar görs linjärt över bedömd nyttjandeperiod:']
const categoryLabels: Record<string, string> = {
immaterial: 'Immateriella anläggningstillgångar',
building: 'Byggnader',
land_improvement: 'Markanläggningar',
machinery: 'Maskiner',
equipment: 'Inventarier',
vehicle: 'Fordon',
computer: 'Datorer',
other_tangible: 'Övriga materiella anläggningstillgångar',
}
for (const [cat, yearsSet] of byCategory.entries()) {
const yrs = Array.from(yearsSet).sort((a, b) => a - b)
const yrsLabel = yrs.length === 1 ? `${yrs[0]} år` : `${yrs[0]}${yrs[yrs.length - 1]} år`
lines.push(`${categoryLabels[cat] ?? cat}: ${yrsLabel}`)
}
notes.push({
number: 2,
title: 'Avskrivningar',
body: lines.join('\n'),
})
}
}
// Medelantal anställda — count active employees as a proxy
const { count: employeeCount } = await supabase
.from('employees')
.select('id', { count: 'exact', head: true })
.eq('company_id', companyId)
.eq('is_active', true)
if ((employeeCount ?? 0) > 0) {
notes.push({
number: notes.length + 1,
title: 'Medelantal anställda',
body: `Under räkenskapsåret har medeltalet anställda uppgått till ${employeeCount}.`,
})
}
notes.push({
number: notes.length + 1,
title: 'Ställda säkerheter och eventualförpliktelser',
body: 'Inga.',
})
return notes
}
function flattenIncomeStatement(is: {
revenue_sections: IncomeStatementSection[]
total_revenue: number
expense_sections: IncomeStatementSection[]
total_expenses: number
financial_sections: IncomeStatementSection[]
total_financial: number
net_result: number
}): IncomeStatementLine[] {
const lines: IncomeStatementLine[] = []
for (const s of is.revenue_sections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
}
lines.push({ label: 'Summa rörelseintäkter', amount: is.total_revenue, is_total: true })
for (const s of is.expense_sections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: -r.amount })
}
}
lines.push({
label: 'Rörelseresultat',
amount: is.total_revenue - is.total_expenses,
is_total: true,
})
// Split financial sections so the RR follows the K2 / ÅRL 3:2 structure:
// financial items (8087) → "Resultat efter finansiella poster" →
// bokslutsdispositioner (88) → "Resultat före skatt" → skatt (89) →
// "Årets resultat". Without the dispositioner + skatt rows the document
// is non-compliant for any AB that posted bolagsskatt or
// periodiseringsfond, and the RR doesn't reconcile to BS 2099.
const finItems = is.financial_sections.filter(
(s) => !/bokslutsdisposition|skatter och årets resultat/i.test(s.title),
)
const dispositionsSections = is.financial_sections.filter((s) =>
/bokslutsdisposition/i.test(s.title),
)
const skattSections = is.financial_sections.filter((s) =>
/skatter och årets resultat/i.test(s.title),
)
for (const s of finItems) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
}
const finSubtotal = finItems.reduce((sum, s) => sum + s.subtotal, 0)
const resAfterFinancial = is.total_revenue - is.total_expenses + finSubtotal
lines.push({
label: 'Resultat efter finansiella poster',
amount: Math.round(resAfterFinancial * 100) / 100,
is_total: true,
})
if (dispositionsSections.length > 0) {
for (const s of dispositionsSections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
}
const dispositionsSubtotal = dispositionsSections.reduce((sum, s) => sum + s.subtotal, 0)
lines.push({
label: 'Resultat före skatt',
amount: Math.round((resAfterFinancial + dispositionsSubtotal) * 100) / 100,
is_total: true,
})
} else {
// No dispositioner posted — keep the simpler "Resultat före skatt" row
// immediately after the finansnetto totals so the RR still has the
// pre-tax subtotal expected by ÅRL.
lines.push({
label: 'Resultat före skatt',
amount: Math.round(resAfterFinancial * 100) / 100,
is_total: true,
})
}
if (skattSections.length > 0) {
for (const s of skattSections) {
for (const r of s.rows) {
lines.push({ label: `${r.account_number} ${r.account_name}`, amount: r.amount })
}
}
}
lines.push({ label: 'Årets resultat', amount: is.net_result, is_total: true })
return lines
}
function flattenBalanceSheet(bs: {
asset_sections: BalanceSheetSection[]
total_assets: number
equity_liability_sections: BalanceSheetSection[]
total_equity_liabilities: number
}): {
assets: BalanceSheetLine[]
total_assets: number
equity_liabilities: BalanceSheetLine[]
total_equity_liabilities: number
} {
const assetLines: BalanceSheetLine[] = []
for (const s of bs.asset_sections) {
assetLines.push({ label: s.title, amount: s.subtotal, is_total: true, indent: 0 })
for (const r of s.rows) {
assetLines.push({
label: `${r.account_number} ${r.account_name}`,
amount: r.amount,
indent: 1,
})
}
}
const eqLines: BalanceSheetLine[] = []
for (const s of bs.equity_liability_sections) {
eqLines.push({ label: s.title, amount: s.subtotal, is_total: true, indent: 0 })
for (const r of s.rows) {
eqLines.push({
label: `${r.account_number} ${r.account_name}`,
amount: r.amount,
indent: 1,
})
}
}
return {
assets: assetLines,
total_assets: bs.total_assets,
equity_liabilities: eqLines,
total_equity_liabilities: bs.total_equity_liabilities,
}
}
@@ -0,0 +1,110 @@
import type { SupabaseClient } from '@supabase/supabase-js'
export interface SignatureRequest {
id: string
user_id: string
company_id: string
fiscal_period_id: string
role: string
signer_name: string
status: 'pending' | 'signed' | 'declined'
signed_at: string | null
created_at: string
updated_at: string
}
export interface CreateSignatureRequestInput {
role: string
signer_name: string
}
/**
* List signature requests for a fiscal period's årsredovisning.
*/
export async function listSignatureRequests(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<SignatureRequest[]> {
const { data, error } = await supabase
.from('arsredovisning_signature_requests')
.select('id, user_id, company_id, fiscal_period_id, role, signer_name, status, signed_at, created_at, updated_at')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.order('created_at', { ascending: true })
if (error) throw new Error(`Failed to list signature requests: ${error.message}`)
return (data ?? []) as SignatureRequest[]
}
/**
* Create one signature request per styrelseledamot / VD. The BankID call
* itself isn't wired here — that ships in a follow-up that uses
* lib/auth/bankid helpers to sign and write the result back via
* markSignatureSigned(). For now this just records who is supposed to sign.
*/
export async function createSignatureRequest(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
input: CreateSignatureRequestInput,
): Promise<SignatureRequest> {
const { data, error } = await supabase
.from('arsredovisning_signature_requests')
.insert({
user_id: userId,
company_id: companyId,
fiscal_period_id: fiscalPeriodId,
role: input.role,
signer_name: input.signer_name,
status: 'pending',
})
.select('*')
.single()
if (error || !data) {
throw new Error(`Failed to create signature request: ${error?.message ?? 'unknown'}`)
}
return data as SignatureRequest
}
/**
* Mark a request as signed. Used by the (future) BankID completion handler.
* The DB trigger blocks further edits once signed, so this is a one-way
* transition.
*/
export async function markSignatureSigned(
supabase: SupabaseClient,
companyId: string,
requestId: string,
options: { bankidSignatureData?: Record<string, unknown> } = {},
): Promise<SignatureRequest> {
const { data, error } = await supabase
.from('arsredovisning_signature_requests')
.update({
status: 'signed',
signed_at: new Date().toISOString(),
bankid_signature_data: options.bankidSignatureData ?? null,
})
.eq('id', requestId)
.eq('company_id', companyId)
.select('*')
.single()
if (error || !data) {
throw new Error(`Failed to mark signature signed: ${error?.message ?? 'unknown'}`)
}
return data as SignatureRequest
}
/**
* True when every signature request for the period is signed. The UI uses
* this to enable the "Ladda ner fastställd PDF" button and to gate filing.
*/
export async function isFullySignedOff(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<boolean> {
const requests = await listSignatureRequests(supabase, companyId, fiscalPeriodId)
if (requests.length === 0) return false
return requests.every((r) => r.status === 'signed')
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Structured data for a K2 årsredovisning. Generated server-side from
* income statement + balance sheet + asset register + salary data; passed
* to the @react-pdf/renderer template + the in-app preview.
*/
export interface FlerarsoversiktRow {
/** Fiscal-year name (e.g. "2025"). */
year: string
net_revenue: number
result_after_financial: number
/** Soliditet = eget kapital / totala tillgångar, in percent. */
soliditet_pct: number | null
}
export interface EgenKapitalRow {
label: string
/** Single SEK number — positive = credit balance (typical for equity). */
amount: number
}
export interface NoteEntry {
/** Note number per K2 convention (1 = redovisningsprinciper). */
number: number
/** Short Swedish title. */
title: string
/** Note body — supports newlines. Generated from data when possible
* (avskrivningstider from asset register, medelantal from salary),
* manual otherwise. */
body: string
}
export interface IncomeStatementLine {
label: string
amount: number
/** True for total / subtotal lines. */
is_total?: boolean
}
export interface BalanceSheetLine {
label: string
amount: number
is_total?: boolean
/** Indent depth for nested grouping (0 = top, 1 = subgroup). */
indent?: number
}
export interface ArsredovisningData {
company: {
name: string
org_number: string
sate: string | null
}
fiscal_period: {
id: string
name: string
period_start: string
period_end: string
}
forvaltningsberattelse: {
/** Beskrivning av verksamheten (företaget kan editera). */
description: string
/** Viktiga händelser (företaget kan editera). */
important_events: string
/** Har kontrollbalansräkning upprättats? */
kontrollbalans_required: boolean
flerarsoversikt: FlerarsoversiktRow[]
/** Förändring av eget kapital. */
egen_kapital_changes: EgenKapitalRow[]
/** Styrelsens förslag till resultatdisposition (manual input). */
resultatdisposition: string
}
resultatrakning: IncomeStatementLine[]
balansrakning: {
assets: BalanceSheetLine[]
total_assets: number
equity_liabilities: BalanceSheetLine[]
total_equity_liabilities: number
}
noter: NoteEntry[]
/** Underskrifter — names of board members + VD. Filled by signature flow. */
signatures: {
role: string
name: string
signed_at: string | null
}[]
}
@@ -0,0 +1,90 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { calculateBolagsskatt } from './tax-provision/bolagsskatt-calculator'
import { calculateSarskildLoneskatt } from './tax-provision/sarskild-loneskatt-calculator'
import {
listExistingPeriodiseringsfonder,
proposeAvsattning,
proposeAteforing,
} from './reserves/periodiseringsfond-service'
import type { DispositionsProposal, ProposedDisposition } from './types'
const DEFAULT_SCHABLONINTAKT_RATE = 0.0355
/**
* Shared core of the GET /bokslutsdispositioner endpoint, lifted out so the
* MCP tool can call the same builder without duplicating the proposal logic.
* The API route and the MCP tool both hand its output to the caller, who
* picks which proposals to commit via the POST endpoint.
*/
export async function buildDispositionsProposal(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<DispositionsProposal> {
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
throw new Error('Fiscal period not found')
}
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
const entityType = (settings?.entity_type ?? 'aktiebolag') as DispositionsProposal['entityType']
if (entityType !== 'aktiebolag') {
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
return {
entityType,
fiscalPeriod: period,
netResultBefore: incomeStatement.net_result,
proposals: [],
}
}
const fiscalYear = parseInt(period.period_end.slice(0, 4), 10)
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
const resultBeforeTax = incomeStatement.net_result
const proposals: ProposedDisposition[] = []
const existingFonder = await listExistingPeriodiseringsfonder(supabase, companyId, period.period_end)
const ateforing = proposeAteforing(existingFonder, {
schablonintaktRate: DEFAULT_SCHABLONINTAKT_RATE,
})
proposals.push(...ateforing.proposals)
const taxableBeforeAvsattning =
resultBeforeTax +
ateforing.proposals.reduce((sum, p) => sum + p.amount, 0) +
ateforing.schablonintaktAmount
const avsattning = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: taxableBeforeAvsattning,
fiscalYear,
})
if (avsattning) proposals.push(avsattning)
const slp = await calculateSarskildLoneskatt(supabase, companyId, fiscalPeriodId)
if (slp) proposals.push(slp)
const bolagsskatt = await calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
manualAdjustments: {
schablonintaktPeriodiseringsfond: ateforing.schablonintaktAmount,
},
})
if (bolagsskatt) proposals.push(bolagsskatt)
return {
entityType,
fiscalPeriod: period,
netResultBefore: resultBeforeTax,
proposals,
}
}
@@ -0,0 +1,89 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { calculateEgenavgifter, type EgenavgiftCategory } from './egenavgifter-calculator'
import { calculateRantefordelning } from './rantefordelning-calculator'
import { proposeEfPfondAvsattning } from './periodiseringsfond-ef'
import { calculateExpansionsfondChange } from './expansionsfond-calculator'
import type { EfDeclarationItem } from './types'
export interface EfDeclarationPreviewInput {
category?: EgenavgiftCategory
kapitalunderlag?: number
priorYearSchablonavdrag?: number
priorYearActualCharged?: number
pfondDesiredAmount?: number
expansionsfondExistingBalance?: number
expansionsfondDesiredChange?: number
}
export interface EfDeclarationPreview {
fiscalPeriod: {
id: string
name: string
period_start: string
period_end: string
}
bookedSurplus: number
items: EfDeclarationItem[]
}
/**
* Server-side mirror of the EfDeclarationSection client logic. The MCP tool
* (Phase 7) calls this so agents can preview the same numbers without
* round-tripping through the browser. Inputs default to "no adjustment"
* which produces just the egenavgifter line.
*/
export async function computeEfDeclarationPreview(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
input: EfDeclarationPreviewInput = {},
): Promise<EfDeclarationPreview> {
const { data: period, error } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (error || !period) throw new Error('Fiscal period not found')
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
const bookedSurplus = incomeStatement.net_result
const fiscalYear = parseInt(period.period_end.slice(0, 4), 10)
const items: EfDeclarationItem[] = []
const eg = calculateEgenavgifter({
surplusBeforeEgenavgifter: bookedSurplus,
category: input.category,
priorYearSchablonavdrag: input.priorYearSchablonavdrag,
priorYearActualCharged: input.priorYearActualCharged,
})
items.push(eg)
const r = calculateRantefordelning({ kapitalunderlag: input.kapitalunderlag ?? 0 })
if (r) items.push(r)
const surplusAfterEg = bookedSurplus - eg.amount
const pfond = proposeEfPfondAvsattning({
surplus: surplusAfterEg,
fiscalYear,
desiredAmount: input.pfondDesiredAmount,
})
if (pfond) items.push(pfond)
if (input.expansionsfondDesiredChange && input.expansionsfondDesiredChange !== 0) {
const exp = calculateExpansionsfondChange({
kapitalunderlag: input.kapitalunderlag ?? 0,
existingBalance: input.expansionsfondExistingBalance,
desiredChange: input.expansionsfondDesiredChange,
})
if (exp) items.push(exp)
}
return {
fiscalPeriod: period,
bookedSurplus,
items,
}
}
@@ -0,0 +1,101 @@
import type { EfDeclarationItem } from './types'
/** Full egenavgifter rate (born 1959 or later, active business, 7 karensdagar). */
export const EGENAVGIFTER_FULL = 0.2897
/** Reduced rate for pensioners born 1938-1958. */
export const EGENAVGIFTER_PENSIONER = 0.1021
/** Passive business uses SLP rate instead. */
export const SLP_FOR_PASSIVE = 0.2426
/** Schablonavdrag percentages (R43 in NE-bilaga). */
export const SCHABLONAVDRAG_FULL = 0.25
export const SCHABLONAVDRAG_PENSIONER = 0.10
export const SCHABLONAVDRAG_PASSIVE = 0.20
export type EgenavgiftCategory = 'full' | 'pensioner' | 'passive'
export interface EgenavgifterInput {
/** Överskott före egenavgifter, från NE-bilaga (R41 sum-up basis). */
surplusBeforeEgenavgifter: number
/** Vilken kategori. Defaults to 'full'. */
category?: EgenavgiftCategory
/** Föregående års schablonavdrag — läggs tillbaka i R40. Defaults to 0. */
priorYearSchablonavdrag?: number
/** Föregående års faktiska egenavgifter — dras av i R41. Defaults to 0. */
priorYearActualCharged?: number
}
export interface EgenavgifterComputation {
category: EgenavgiftCategory
surplusBeforeEgenavgifter: number
priorYearSchablonavdrag: number
priorYearActualCharged: number
/** R40 + (-R41) net: surplusBeforeEgenavgifter + priorYearSchablonavdrag - priorYearActualCharged. */
netSurplusForSchablon: number
schablonavdragRate: number
/** R43: schablonavdrag applied to net surplus. */
schablonavdrag: number
egenavgifterRate: number
/** Estimated egenavgifter for the year — for planning only; the exact
* amount is set by Skatteverket. */
estimatedEgenavgifter: number
}
/**
* Compute the NE-bilaga R40-R43 series for egenavgifter.
*
* NEVER produces a journal entry — egenavgifter for enskild firma are paid
* personally by the owner via Inkomstdeklaration 1, not by the business.
*/
export function calculateEgenavgifter(input: EgenavgifterInput): EfDeclarationItem {
const category = input.category ?? 'full'
const r1 = (x: number) => Math.round(x * 100) / 100
const ratesByCategory = {
full: { schablon: SCHABLONAVDRAG_FULL, avgifter: EGENAVGIFTER_FULL },
pensioner: { schablon: SCHABLONAVDRAG_PENSIONER, avgifter: EGENAVGIFTER_PENSIONER },
passive: { schablon: SCHABLONAVDRAG_PASSIVE, avgifter: SLP_FOR_PASSIVE },
} as const
const rates = ratesByCategory[category]
const priorSchablon = input.priorYearSchablonavdrag ?? 0
const priorActual = input.priorYearActualCharged ?? 0
const netSurplus = Math.max(
0,
input.surplusBeforeEgenavgifter + priorSchablon - priorActual,
)
const schablonavdrag = Math.floor(netSurplus * rates.schablon)
const estimatedEgenavgifter = Math.round(netSurplus * rates.avgifter)
const warnings: string[] = []
if (category === 'full' && netSurplus > 40_000) {
warnings.push(
'För aktiv näringsverksamhet med överskott > 40 000 kr ges en automatisk nedsättning av egenavgifterna (7,5 %, max 15 000 kr/år) av Skatteverket.',
)
}
if (input.surplusBeforeEgenavgifter <= 0) {
warnings.push('Inget överskott att beräkna egenavgifter på.')
}
const computation: EgenavgifterComputation = {
category,
surplusBeforeEgenavgifter: r1(input.surplusBeforeEgenavgifter),
priorYearSchablonavdrag: r1(priorSchablon),
priorYearActualCharged: r1(priorActual),
netSurplusForSchablon: netSurplus,
schablonavdragRate: rates.schablon,
schablonavdrag,
egenavgifterRate: rates.avgifter,
estimatedEgenavgifter,
}
return {
kind: 'egenavgifter',
label: 'Egenavgifter — schablonavdrag',
description: `Schablonavdrag ${(rates.schablon * 100).toFixed(0)} % av nettoöverskott. Faktiska avgifter beräknas av Skatteverket.`,
amount: schablonavdrag,
ne_ruta: 'R43',
computation: computation as unknown as Record<string, unknown>,
warnings,
}
}
@@ -0,0 +1,99 @@
import type { EfDeclarationItem } from './types'
/** Expansionsfondsskatt 20.6 % (samma som bolagsskatten). */
export const EXPANSIONSFOND_TAX_RATE = 0.206
/** Max avsättning = 125,94 % av kapitalunderlag (IL 34 kap). */
export const EXPANSIONSFOND_MAX_OF_KAPITALUNDERLAG = 1.2594
export interface ExpansionsfondInput {
/** Kapitalunderlag vid årets slut (samma underlag som för positiv
* räntefördelning, IL 34 kap 6 §). */
kapitalunderlag: number
/** Tidigare kvarstående avsättning till expansionsfond — utgör utgångsläget
* för årets bedömning. */
existingBalance?: number
/** Önskad ändring (positivt = avsättning, negativt = återföring).
* Defaults to 0 (no change). */
desiredChange?: number
}
export interface ExpansionsfondComputation {
kapitalunderlag: number
maxTotalBalance: number
existingBalance: number
desiredChange: number
/** Begränsat ändringsbelopp efter takkontroll. */
actualChange: number
newBalance: number
/** 20.6 % skatt på årets nettoökning. Tillgodoräknas vid framtida återföring. */
taxOnChange: number
}
/**
* Compute the change in expansionsfond.
*
* Mechanism: en enskild näringsidkare betalar 20,6 % expansionsfondsskatt på
* avsatt belopp ENA året. När fonden återförs blir beloppet inkomst av
* näringsverksamhet samma år som återföringen — men de 20,6 % redan betalat
* tillgodoräknas mot årets skatt. Tax-only mechanism, NEVER booked.
*/
export function calculateExpansionsfondChange(
input: ExpansionsfondInput,
): EfDeclarationItem | null {
const maxTotalBalance = Math.floor(
Math.max(0, input.kapitalunderlag) * EXPANSIONSFOND_MAX_OF_KAPITALUNDERLAG,
)
const existingBalance = Math.max(0, Math.floor(input.existingBalance ?? 0))
const desiredChange = Math.round(input.desiredChange ?? 0)
if (desiredChange === 0) return null
let actualChange = desiredChange
// Cap avsättning so the new total doesn't exceed the kapitalunderlag-based cap.
if (desiredChange > 0) {
const room = Math.max(0, maxTotalBalance - existingBalance)
actualChange = Math.min(desiredChange, room)
} else {
// Återföring can't go below zero.
actualChange = -Math.min(Math.abs(desiredChange), existingBalance)
}
const newBalance = existingBalance + actualChange
const taxOnChange = Math.round(actualChange * EXPANSIONSFOND_TAX_RATE)
const warnings: string[] = []
if (desiredChange > 0 && actualChange < desiredChange) {
warnings.push(
`Begärt belopp (${desiredChange} kr) översteg taket på 125,94 % av kapitalunderlaget. Avsättningen begränsades till ${actualChange} kr.`,
)
}
if (desiredChange < 0 && Math.abs(actualChange) < Math.abs(desiredChange)) {
warnings.push(
`Återföringen begränsades till befintligt saldo (${existingBalance} kr).`,
)
}
const computation: ExpansionsfondComputation = {
kapitalunderlag: input.kapitalunderlag,
maxTotalBalance,
existingBalance,
desiredChange,
actualChange,
newBalance,
taxOnChange,
}
return {
kind: actualChange > 0 ? 'expansionsfond_avsattning' : 'expansionsfond_ateforing',
label:
actualChange > 0
? 'Expansionsfond — avsättning'
: 'Expansionsfond — återföring',
description:
actualChange > 0
? `Avsättning ${actualChange} kr. Skatt 20,6 % (${Math.abs(taxOnChange)} kr) betalas i år.`
: `Återföring ${Math.abs(actualChange)} kr. Tidigare betald skatt (${Math.abs(taxOnChange)} kr) tillgodoräknas.`,
amount: Math.abs(actualChange),
ne_ruta: actualChange > 0 ? 'R34' : 'R33',
computation: computation as unknown as Record<string, unknown>,
warnings,
}
}
@@ -0,0 +1,91 @@
import type { EfDeclarationItem } from './types'
/** EF får sätta av max 30 % av överskott (vs 25 % för AB). */
export const PFOND_EF_RATE = 0.30
/** Same 6-year mandatory reversal as AB (IL 30 kap 7 §). */
export const PFOND_EF_MAX_HOLD_YEARS = 6
export interface EfPfondAvsattningInput {
/** Skattemässigt överskott efter alla andra justeringar. */
surplus: number
/** Året då avsättningen görs. */
fiscalYear: number
/** Önskat belopp; defaults to maximum. */
desiredAmount?: number
}
export function proposeEfPfondAvsattning(input: EfPfondAvsattningInput): EfDeclarationItem | null {
const base = Math.max(0, Math.floor(input.surplus))
const max = Math.floor(base * PFOND_EF_RATE)
const desired = Math.max(0, Math.floor(input.desiredAmount ?? max))
const amount = Math.min(desired, max)
if (amount === 0) return null
const warnings: string[] = []
if (desired > max) {
warnings.push(
`Begärt belopp (${desired} kr) översteg 30 %-taket. Avsättningen begränsades till ${max} kr.`,
)
}
return {
kind: 'periodiseringsfond_avsattning',
label: `Periodiseringsfond ${input.fiscalYear} — avsättning`,
description: `Max 30 % av skattemässigt överskott. Sätts av i NE-bilaga R30 (uppskjuten skatt). Bokförs inte.`,
amount,
ne_ruta: 'R30',
computation: {
surplus: input.surplus,
rate: PFOND_EF_RATE,
maxAmount: max,
desiredAmount: desired,
actualAmount: amount,
fiscalYear: input.fiscalYear,
},
warnings,
}
}
export interface EfExistingFond {
/** Vilket år fonden avsattes. */
cohort_year: number
/** Aktuellt saldo (positivt). */
balance: number
}
export interface EfPfondAteforingInput {
existingFonder: EfExistingFond[]
closingYear: number
/** Per-cohort återföringsbelopp (kan vara delar av saldot). Mandatory
* cohorts återförs alltid till fullo. */
returns?: Record<number, number>
}
export function proposeEfPfondAteforing(
input: EfPfondAteforingInput,
): EfDeclarationItem[] {
const items: EfDeclarationItem[] = []
for (const fond of input.existingFonder) {
const isMandatory = fond.cohort_year + PFOND_EF_MAX_HOLD_YEARS <= input.closingYear
const requested = input.returns?.[fond.cohort_year] ?? 0
const amount = isMandatory ? fond.balance : Math.min(Math.max(0, requested), fond.balance)
if (amount === 0) continue
items.push({
kind: 'periodiseringsfond_ateforing',
label: `Periodiseringsfond ${fond.cohort_year} — återföring`,
description: 'Återförs i NE-bilaga R29.',
amount,
ne_ruta: 'R29',
computation: {
cohort_year: fond.cohort_year,
opening_balance: fond.balance,
return_amount: amount,
was_mandatory: isMandatory,
},
warnings: isMandatory
? [`Periodiseringsfond ${fond.cohort_year} har nått 6-årsgränsen och måste återföras.`]
: [],
})
}
return items
}
@@ -0,0 +1,100 @@
import type { EfDeclarationItem } from './types'
/** Räntefördelning rates per IL 33 kap. Statslåneräntan from 30 november
* året före, plus tilläggspoäng. SLR for 2026 = 2.55 %. */
export const POSITIVE_RANTEFORDELNING_ADD = 0.06 // SLR + 6 pe → 8.55 % för 2026
export const NEGATIVE_RANTEFORDELNING_ADD = 0.01 // SLR + 1 pe → 3.55 % för 2026
/** Negativ räntefördelning triggas vid kapitalunderlag mer negativt än
* -500 000 kr (IL 33 kap 4 §). */
export const NEGATIVE_THRESHOLD = -500_000
export interface RantefordelningInput {
/** Justerat eget kapital i näringsverksamheten vid föregående års utgång.
* Positivt = kapitalöverskott (positiv räntefördelning möjlig); negativt
* = kapitalunderskott (negativ räntefördelning obligatorisk > -500 000). */
kapitalunderlag: number
/** SLR 30 november föregående år. Default = 0.0255 (för inkomstår 2026,
* SLR 2025-11-30 = 2,55 %). Override per år tills Riksbanken-integrationen
* ligger på plats. */
slrRate?: number
}
export interface RantefordelningComputation {
kapitalunderlag: number
slrRate: number
positiveRate: number
positiveBase: number
/** Maximalt belopp att räntefördela till kapital. Voluntarily. */
positiveAmount: number
negativeRate: number
negativeBase: number
/** Obligatorisk negativ räntefördelning (> -500 000). */
negativeAmount: number
}
/**
* Compute räntefördelning for an enskild firma. Returns at most one of
* positive or negative — the two are mutually exclusive based on sign of
* kapitalunderlag.
*
* NEVER produces a journal entry — räntefördelning is a tax-only mechanism
* (Inkomstdeklaration 1, kapitalinkomst-fältet).
*/
export function calculateRantefordelning(
input: RantefordelningInput,
): EfDeclarationItem | null {
const slrRate = input.slrRate ?? 0.0255
const positiveRate = Math.max(0.005, slrRate + POSITIVE_RANTEFORDELNING_ADD)
const negativeRate = Math.max(0.005, slrRate + NEGATIVE_RANTEFORDELNING_ADD)
const isPositive = input.kapitalunderlag > 0
const isNegativeMandatory = input.kapitalunderlag < NEGATIVE_THRESHOLD
if (!isPositive && !isNegativeMandatory) {
return null
}
// Use Math.round (not floor/ceil) to absorb IEEE 754 representation error:
// 1 000 000 × (0.0255 + 0.06) evaluates to 85499.99999... in JS, and floor()
// would shave a krona off the user's deduction for a non-economic reason.
const computation: RantefordelningComputation = {
kapitalunderlag: input.kapitalunderlag,
slrRate,
positiveRate,
positiveBase: isPositive ? input.kapitalunderlag : 0,
positiveAmount: isPositive ? Math.round(input.kapitalunderlag * positiveRate) : 0,
negativeRate,
negativeBase: isNegativeMandatory ? Math.abs(input.kapitalunderlag) : 0,
negativeAmount: isNegativeMandatory
? Math.round(Math.abs(input.kapitalunderlag) * negativeRate)
: 0,
}
if (isPositive) {
return {
kind: 'rantefordelning_positive',
label: 'Positiv räntefördelning (frivillig)',
description: `${(positiveRate * 100).toFixed(2)} % på kapitalunderlag ${input.kapitalunderlag.toLocaleString('sv-SE')} kr. Avdrag i NE R30; motsvarande belopp redovisas som inkomst av kapital på Inkomstdeklaration 1 (T4).`,
amount: computation.positiveAmount,
// NE-bilagan har bara fältet R30 — INK1 är ett separat formulär. Tidigare
// sammanslagningen 'R30 / INK1 kapital' fick användare att leta efter ett
// ruta-namn som inte finns på NE.
ne_ruta: 'R30 (avdrag i näringsverksamhet)',
computation: computation as unknown as Record<string, unknown>,
warnings: [],
}
}
return {
kind: 'rantefordelning_negative',
label: 'Negativ räntefördelning (obligatorisk)',
description: `${(negativeRate * 100).toFixed(2)} % på kapitalunderskott. Tillägg till resultat i näringsverksamhet eftersom verksamheten lånat av privata medel.`,
amount: computation.negativeAmount,
ne_ruta: 'R30 (tillägg till resultat)',
computation: computation as unknown as Record<string, unknown>,
warnings: [
'Negativ räntefördelning är obligatorisk när kapitalunderlaget är mer negativt än -500 000 kr.',
],
}
}
+32
View File
@@ -0,0 +1,32 @@
/**
* EF tax mechanisms are declaration-only — they NEVER produce journal
* entries. The shapes here are intentionally distinct from the AB
* `ProposedDisposition` to make the booking distinction visible at the
* type level: every EF calculator returns an `EfDeclarationItem` and the
* UI / NE-bilaga consumes them, but the bokkeeping engine never sees them.
*/
export type EfDeclarationKind =
| 'egenavgifter'
| 'rantefordelning_positive'
| 'rantefordelning_negative'
| 'periodiseringsfond_avsattning'
| 'periodiseringsfond_ateforing'
| 'expansionsfond_avsattning'
| 'expansionsfond_ateforing'
export interface EfDeclarationItem {
kind: EfDeclarationKind
/** Short Swedish label for UI cards. */
label: string
/** One-sentence Swedish explanation. */
description: string
/** SEK amount displayed. Always positive. */
amount: number
/** NE-bilaga ruta this affects (e.g. "R30", "R34", "R43"). Surfaced so
* users know where the number lands when they file. */
ne_ruta: string
/** Calculator-specific breakdown for the "Visa beräkning" panel. */
computation: Record<string, unknown>
/** Soft warnings. Never blockers. */
warnings: string[]
}
@@ -0,0 +1,89 @@
-- Migration: arsredovisning_signature_requests — captures the multi-signer
-- BankID fastställelseintyg flow för Bolagsverket-inlämning av årsredovisning.
--
-- Each row = one signature slot för one styrelseledamot or VD on a specific
-- fiscal period's årsredovisning. Status transitions: pending → signed (or
-- declined). When all rows för a fiscal period are signed, the
-- årsredovisning is "fastställd" and may be filed.
--
-- The actual BankID call is not wired in this migration — that integration
-- ships in a follow-up that wires the existing lib/auth/bankid helpers to
-- the sign action. This table + the service helpers make the request layer
-- available so the UI can render the signature slots and persist results
-- when the BankID side is hooked up.
CREATE TABLE public.arsredovisning_signature_requests (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
fiscal_period_id UUID NOT NULL REFERENCES fiscal_periods(id) ON DELETE CASCADE,
-- Free-text role to keep flexibility for "Styrelseordförande", "VD",
-- "Revisor" etc. without a migration.
role TEXT NOT NULL CHECK (length(role) > 0),
signer_name TEXT NOT NULL CHECK (length(signer_name) > 0),
/** Encrypted personnummer of the signer (12 digits). Optional at request
* time, filled when BankID completes. Stored via the same crypto
* helpers as bankid_identities (see lib/auth/bankid.ts). */
signer_personnummer_encrypted BYTEA,
signer_personnummer_hash TEXT,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN (
'pending', 'signed', 'declined'
)),
signed_at TIMESTAMPTZ,
/** Raw BankID completion data — JWT or JSON depending on provider. Stored
* for audit; never read back to make trust decisions. */
bankid_signature_data JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT arsredovisning_signature_requests_signed_atomic CHECK (
(status <> 'signed' AND signed_at IS NULL)
OR (status = 'signed' AND signed_at IS NOT NULL)
)
);
CREATE INDEX idx_arsredovisning_sigreq_company ON public.arsredovisning_signature_requests (company_id);
CREATE INDEX idx_arsredovisning_sigreq_period ON public.arsredovisning_signature_requests (fiscal_period_id);
ALTER TABLE public.arsredovisning_signature_requests ENABLE ROW LEVEL SECURITY;
CREATE POLICY "arsredovisning_sigreq_select" ON public.arsredovisning_signature_requests
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "arsredovisning_sigreq_insert" ON public.arsredovisning_signature_requests
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "arsredovisning_sigreq_update" ON public.arsredovisning_signature_requests
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
-- Block DELETE on signed rows — signed signatures must persist för audit.
CREATE POLICY "arsredovisning_sigreq_delete" ON public.arsredovisning_signature_requests
FOR DELETE USING (
company_id IN (SELECT public.user_company_ids())
AND status <> 'signed'
);
CREATE TRIGGER arsredovisning_sigreq_updated_at
BEFORE UPDATE ON public.arsredovisning_signature_requests
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- Once status='signed', the financial fields cannot be amended. Mirrors the
-- pattern in 20260516120000_assets_and_depreciation.sql.
CREATE OR REPLACE FUNCTION public.enforce_signed_signature_request_immutability()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status = 'signed' THEN
IF NEW.role IS DISTINCT FROM OLD.role
OR NEW.signer_name IS DISTINCT FROM OLD.signer_name
OR NEW.signed_at IS DISTINCT FROM OLD.signed_at
OR NEW.status IS DISTINCT FROM OLD.status THEN
RAISE EXCEPTION 'Cannot modify a signed signature request (id=%)', OLD.id
USING ERRCODE = 'check_violation';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER enforce_signed_signature_request_immutability
BEFORE UPDATE ON public.arsredovisning_signature_requests
FOR EACH ROW EXECUTE FUNCTION public.enforce_signed_signature_request_immutability();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,33 @@
-- Extend the signed-immutability trigger on
-- arsredovisning_signature_requests to cover EVERY audit-critical column,
-- not just the role/signer_name/signed_at/status subset the original
-- migration (20260516170000) protected.
--
-- Without this, a row with status='signed' could be UPDATEd to change
-- bankid_signature_data, signer_personnummer_encrypted, or
-- signer_personnummer_hash — silently altering the audit trail and
-- breaking the "signed = immutable" guarantee that the application layer
-- relies on for fastställelseintyg evidence.
CREATE OR REPLACE FUNCTION public.enforce_signed_signature_request_immutability()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.status = 'signed' THEN
IF NEW.role IS DISTINCT FROM OLD.role
OR NEW.signer_name IS DISTINCT FROM OLD.signer_name
OR NEW.signed_at IS DISTINCT FROM OLD.signed_at
OR NEW.status IS DISTINCT FROM OLD.status
OR NEW.bankid_signature_data IS DISTINCT FROM OLD.bankid_signature_data
OR NEW.signer_personnummer_encrypted IS DISTINCT FROM OLD.signer_personnummer_encrypted
OR NEW.signer_personnummer_hash IS DISTINCT FROM OLD.signer_personnummer_hash
OR NEW.fiscal_period_id IS DISTINCT FROM OLD.fiscal_period_id
OR NEW.company_id IS DISTINCT FROM OLD.company_id THEN
RAISE EXCEPTION 'Cannot modify a signed signature request (id=%)', OLD.id
USING ERRCODE = 'check_violation';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,16 @@
-- A 'declined' signature is auditable evidence: it records that a board
-- member refused to sign the årsredovisning, which is material under ABL 8
-- kap (board-member liability) and may be relevant in a Bolagsverket review.
-- The original migration (20260516170000) only blocked DELETE on 'signed'
-- rows. Extend the DELETE RLS policy to also protect 'declined' so the
-- evidence trail can't be silently wiped.
DROP POLICY IF EXISTS "arsredovisning_sigreq_delete" ON public.arsredovisning_signature_requests;
CREATE POLICY "arsredovisning_sigreq_delete" ON public.arsredovisning_signature_requests
FOR DELETE USING (
company_id IN (SELECT public.user_company_ids())
AND status NOT IN ('signed', 'declined')
);
NOTIFY pgrst, 'reload schema';