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:
@@ -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 på 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 på 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>
|
||||
)
|
||||
}
|
||||
@@ -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 på 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 på 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 (R1–R11)
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 (80–87) → "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')
|
||||
}
|
||||
@@ -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.',
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user