diff --git a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx index a024359a..19c35f27 100644 --- a/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/arsredovisning/page.tsx @@ -11,7 +11,7 @@ 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 { ArrowLeft, FileDown, Plus, ExternalLink, Loader2, Save, CheckCircle2 } 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' @@ -26,10 +26,19 @@ export default function ArsredovisningPage() { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) - // Editable narrative fields + // Editable narrative fields — persisted to arsredovisning_narratives so + // the PDF always reflects the latest saved version and a refresh / new + // user picks up the same content. const [description, setDescription] = useState('') const [importantEvents, setImportantEvents] = useState('') const [resultatdisposition, setResultatdisposition] = useState('') + const [savedDescription, setSavedDescription] = useState('') + const [savedImportantEvents, setSavedImportantEvents] = useState('') + const [savedResultatdisposition, setSavedResultatdisposition] = useState('') + const [agmDate, setAgmDate] = useState('') + const [savedAgmDate, setSavedAgmDate] = useState('') + const [savingNarrative, setSavingNarrative] = useState(false) + const [savedAt, setSavedAt] = useState(null) // Add-signer form const [signerName, setSignerName] = useState('') @@ -52,9 +61,18 @@ export default function ArsredovisningPage() { } const d = arBody.data as ArsredovisningData setData(d) + // buildArsredovisningData merges persisted narrative + boilerplate, + // so the values here are whatever the user will see in the PDF + // unless they edit. Track both "current draft" and "last saved" so + // we can disable Spara when there's nothing pending. setDescription(d.forvaltningsberattelse.description) setImportantEvents(d.forvaltningsberattelse.important_events) setResultatdisposition(d.forvaltningsberattelse.resultatdisposition) + setAgmDate(d.forvaltningsberattelse.agm_date ?? '') + setSavedDescription(d.forvaltningsberattelse.description) + setSavedImportantEvents(d.forvaltningsberattelse.important_events) + setSavedResultatdisposition(d.forvaltningsberattelse.resultatdisposition) + setSavedAgmDate(d.forvaltningsberattelse.agm_date ?? '') setSignatures((sigBody.data ?? []) as SignatureRequest[]) }) .catch(() => { @@ -68,6 +86,90 @@ export default function ArsredovisningPage() { } }, [periodId]) + const hasUnsavedNarrative = + description !== savedDescription || + importantEvents !== savedImportantEvents || + resultatdisposition !== savedResultatdisposition || + agmDate !== savedAgmDate + + const handleSaveNarrative = useCallback(async () => { + if (!periodId) return + setSavingNarrative(true) + try { + const res = await fetch( + `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/narrative`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + description, + important_events: importantEvents, + resultatdisposition, + agm_date: agmDate || null, + }), + }, + ) + const body = await res.json() + if (!res.ok) { + toast({ + title: 'Kunde inte spara texten', + description: body?.error?.message ?? '', + variant: 'destructive', + }) + return + } + setSavedDescription(description) + setSavedImportantEvents(importantEvents) + setSavedResultatdisposition(resultatdisposition) + setSavedAgmDate(agmDate) + setSavedAt(Date.now()) + } catch (err) { + toast({ + title: 'Kunde inte spara texten', + description: err instanceof Error ? err.message : 'Okänt fel', + variant: 'destructive', + }) + } finally { + setSavingNarrative(false) + } + }, [periodId, description, importantEvents, resultatdisposition, agmDate, toast]) + + const handleMarkSigned = useCallback( + async (signatureId: string) => { + if (!periodId) return + try { + const res = await fetch( + `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/signatures/${signatureId}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status: 'signed' }), + }, + ) + const body = await res.json() + if (!res.ok) { + toast({ + title: 'Kunde inte markera som signerad', + description: body?.error?.message ?? '', + variant: 'destructive', + }) + return + } + setSignatures((prev) => + prev.map((s) => (s.id === signatureId ? (body.data as SignatureRequest) : s)), + ) + toast({ title: 'Underskrift registrerad' }) + } catch (err) { + toast({ + title: 'Kunde inte markera som signerad', + description: err instanceof Error ? err.message : 'Okänt fel', + variant: 'destructive', + }) + } + }, + [periodId, toast], + ) + const handleAddSigner = useCallback(async () => { if (!periodId || !signerName.trim()) return try { @@ -144,22 +246,9 @@ export default function ArsredovisningPage() { ) } - // 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 : ''}` - })() + // PDF route reads persisted narrative from the new arsredovisning_narratives + // table. The save button below writes overrides; the URL stays clean. + const pdfUrl = `/api/bookkeeping/fiscal-periods/${periodId}/arsredovisning/pdf` return (
@@ -211,6 +300,47 @@ export default function ArsredovisningPage() { rows={3} />
+
+ + setAgmDate(e.target.value)} + className="max-w-[220px]" + /> +

+ Datum då årsstämman fastställde årsredovisningen — fyller i datumraden på + fastställelseintyget i PDF:en (krävs för inlämning till Bolagsverket). +

+
+
+
+ {hasUnsavedNarrative ? ( + Ändringar sparas inte automatiskt. + ) : savedAt ? ( + + Sparat + + ) : ( + Alla ändringar är sparade. + )} +
+ +
@@ -274,13 +404,24 @@ export default function ArsredovisningPage() {

{sig.signer_name}

{sig.role}

- {sig.status === 'signed' ? ( - Signerad - ) : sig.status === 'declined' ? ( - Avböjd - ) : ( - Väntar på underskrift - )} +
+ {sig.status === 'signed' ? ( + Signerad + ) : sig.status === 'declined' ? ( + Avböjd + ) : ( + <> + Väntar på underskrift + + + )} +
))}
@@ -344,6 +485,16 @@ export default function ArsredovisningPage() {
+ {data.warnings.length > 0 && ( +
+

Innan inlämning till Bolagsverket:

+
    + {data.warnings.map((w, i) => ( +
  • {w}
  • + ))} +
+
+ )}
Notis om digital inlämning: Bolagsverket har föreslagit att digital inlämning (iXBRL) av årsredovisning för aktiebolag ska bli diff --git a/app/api/bookkeeping/fiscal-periods/[id]/accruals/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/accruals/route.ts index 9cb1aab2..10d4217a 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/accruals/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/accruals/route.ts @@ -82,7 +82,7 @@ export const POST = withRouteContext( return errorResponseFromCode('PERIOD_LOCKED', log, { requestId }) } - const created: { kind: string; entry: JournalEntry; reverses_on: string }[] = [] + const created: { kind: string; entry: JournalEntry; reverses_on: string | null }[] = [] const skipped: { kind: string; existing_entry_id: string; reason: string }[] = [] for (const item of validation.data.items) { diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts new file mode 100644 index 00000000..3bf31493 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts @@ -0,0 +1,91 @@ +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 { + getNarrative, + upsertNarrative, +} from '@/lib/bokslut/arsredovisning/narrative-service' + +const PostSchema = z.object({ + // Match the DB CHECK lengths exactly so a payload that would fail at the + // storage layer instead returns a clean 400 here. + description: z.string().max(4000).nullable().optional(), + important_events: z.string().max(4000).nullable().optional(), + resultatdisposition: z.string().max(2000).nullable().optional(), + // ISO YYYY-MM-DD per the DATE column; null clears it. Validate as a + // real calendar date (not just regex) so '2024-13-99' returns 400 from + // the API instead of bubbling up as a Postgres 500. + agm_date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine( + (s) => { + const d = new Date(`${s}T00:00:00Z`) + return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s + }, + { message: 'Invalid calendar date' }, + ) + .nullable() + .optional(), +}) + +export const GET = withRouteContext( + 'period.arsredovisning_narrative_get', + async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => { + const { id } = await params + const { supabase, companyId, log, requestId } = ctx + try { + // Mirror the POST handler's period-ownership pre-check so a valid + // JWT for company A can't probe / enumerate company B's period IDs + // through this endpoint. + const { data: period } = await supabase + .from('fiscal_periods') + .select('id') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + if (!period) { + return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId }) + } + const data = await getNarrative(supabase, companyId, id) + return NextResponse.json({ data }) + } catch (err) { + return errorResponse(err, log, { requestId }) + } + }, +) + +export const POST = withRouteContext( + 'period.arsredovisning_narrative_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, PostSchema) + if (!validation.success) return validation.response + try { + // Verify the fiscal period belongs to the authenticated company before + // writing — defense-in-depth alongside RLS, gives a cleaner 404 than + // the RLS rejection envelope. Also refuse mutations on locked/closed + // periods (BFL 5 kap 5 § — räkenskapsinformation immutability). + const { data: period } = await supabase + .from('fiscal_periods') + .select('id, is_closed, locked_at, closing_entry_id') + .eq('id', id) + .eq('company_id', companyId) + .maybeSingle() + if (!period) { + return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId }) + } + if (period.is_closed || period.locked_at || period.closing_entry_id) { + return errorResponseFromCode('PERIOD_LOCKED', log, { requestId }) + } + const data = await upsertNarrative(supabase, companyId, user.id, id, validation.data) + return NextResponse.json({ data }) + } catch (err) { + return errorResponse(err, log, { requestId }) + } + }, + { requireWrite: true }, +) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/pdf/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/pdf/route.ts index b7583fa1..0b7856ec 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/pdf/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/pdf/route.ts @@ -6,24 +6,14 @@ import { ArsredovisningPDF } from '@/lib/bokslut/arsredovisning/arsredovisning-p export const GET = withRouteContext( 'period.arsredovisning_pdf', - async (request, ctx, { params }: { params: Promise<{ id: string }> }) => { + 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) + // Narrative edits come from arsredovisning_narratives now, loaded + // inside buildArsredovisningData. The URL stays clean — no narrative + // text in query params, access logs, or browser history. + const data = await buildArsredovisningData(supabase, companyId, id) 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. diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/route.ts new file mode 100644 index 00000000..1749ae0b --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/route.ts @@ -0,0 +1,67 @@ +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' + +// PATCH transitions: pending → signed (manual entry for the paper / outside- +// BankID flow) or pending → declined. Real BankID wiring lands in a future +// phase and uses the same UPDATE with the BankID callback as trigger. +// +// Hardening on every UPDATE: +// - .eq('id', signatureId) + .eq('company_id', companyId) +// - .eq('fiscal_period_id', id from URL) — enforces the REST contract so +// /periods/A/signatures/SIG_FROM_B can't bypass the path scope +// - .eq('status', 'pending') — state-machine guard so a signed or declined +// row can't be flipped back +const PatchSchema = z.object({ + status: z.enum(['signed', 'declined']), +}) + +export const PATCH = withRouteContext( + 'period.arsredovisning_signature_patch', + async ( + request, + ctx, + { params }: { params: Promise<{ id: string; signatureId: string }> }, + ) => { + const { id: fiscalPeriodId, signatureId } = await params + const { supabase, companyId, log, requestId } = ctx + const validation = await validateBody(request, PatchSchema) + if (!validation.success) return validation.response + + const update = + validation.data.status === 'signed' + ? { status: 'signed' as const, signed_at: new Date().toISOString() } + : { status: 'declined' as const } + + try { + const { data, error } = await supabase + .from('arsredovisning_signature_requests') + .update(update) + .eq('id', signatureId) + .eq('company_id', companyId) + .eq('fiscal_period_id', fiscalPeriodId) + .eq('status', 'pending') + .select('*') + .maybeSingle() + + if (error) { + throw new Error(`Failed to update signature: ${error.message}`) + } + if (!data) { + // No row matched: either it doesn't exist, belongs to another + // company / period, or is already signed/declined. Return 409 so + // the client knows the transition is invalid rather than "missing". + return NextResponse.json( + { error: { code: 'SIGNATURE_INVALID_TRANSITION' } }, + { status: 409 }, + ) + } + return NextResponse.json({ data }) + } catch (err) { + return errorResponse(err, log, { requestId }) + } + }, + { requireWrite: true }, +) diff --git a/lib/bokslut/accruals/accrual-detector.ts b/lib/bokslut/accruals/accrual-detector.ts index 5d7c5795..e61af929 100644 --- a/lib/bokslut/accruals/accrual-detector.ts +++ b/lib/bokslut/accruals/accrual-detector.ts @@ -113,9 +113,10 @@ export async function proposeVacationLiabilityChange( '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: '', + // null (not '') = no reversal. The future accrual-reversal cron will + // filter `reverses_on IS NOT NULL` and an empty string would silently + // match that. + reverses_on: null, warnings: [], computation: { current_2920: currentLiability, diff --git a/lib/bokslut/accruals/types.ts b/lib/bokslut/accruals/types.ts index 85d02d87..8544ff67 100644 --- a/lib/bokslut/accruals/types.ts +++ b/lib/bokslut/accruals/types.ts @@ -22,11 +22,13 @@ export interface AccrualProposal { 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 + /** Date the entry should be reversed on (typically Jan 1 of next FY), or + * null for accruals that intentionally do NOT reverse (e.g. semesterlöne- + * skuld carries forward — see proposeVacationLiabilityChange). Phase 4 + * ships this as metadata; the actual auto-reversal cron is follow-up + * infra. Using null instead of an empty string keeps the future cron's + * filter (`reverses_on IS NOT NULL`) unambiguous. */ + reverses_on: string | null /** Soft warnings the UI surfaces beside the card. Never blockers. */ warnings: string[] /** Calculator-specific breakdown for the "Visa beräkning" panel. */ diff --git a/lib/bokslut/arsredovisning/arsredovisning-pdf.tsx b/lib/bokslut/arsredovisning/arsredovisning-pdf.tsx index 0f52b11b..9971712d 100644 --- a/lib/bokslut/arsredovisning/arsredovisning-pdf.tsx +++ b/lib/bokslut/arsredovisning/arsredovisning-pdf.tsx @@ -138,8 +138,8 @@ export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) { {data.company.name} Organisationsnummer: {data.company.org_number} - {data.company.sate && ( - Säte: {data.company.sate} + {data.company.city && ( + Säte: {data.company.city} )} @@ -265,7 +265,7 @@ export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) { Underskrifter - {data.company.sate ? `${data.company.sate}, ` : ''} + {data.company.city ? `${data.company.city}, ` : ''} {data.fiscal_period.period_end} {(data.signatures.length > 0 @@ -283,6 +283,55 @@ export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) { ))} + + {/* + Fastställelseintyg — required by ÅRL 8 kap 3 § for Bolagsverket + filing. The intyg confirms that the income statement and balance + sheet have been adopted at the AGM (årsstämma) and reports the + AGM's resolution on resultatdisposition. Without this page the + document cannot be filed as-is — Bolagsverket rejects submissions + that lack the intyg. + + Signer label is "Styrelseledamot (närvarande vid stämman)" per + ÅRL 8:3 → 6:6-7 §§ — a VD who is not also a styrelseledamot cannot + sign the fastställelseintyg. Conflating the two roles ("VD") would + render the certificate defective. + + Body refers to the AGM's RESOLUTION (stämmobeslut), not the board's + proposal — the board proposes in förvaltningsberättelsen but the + AGM votes, and it is the vote that must be certified. + */} + + + Fastställelseintyg + + Undertecknad styrelseledamot, närvarande vid årsstämman, intygar härmed + att resultaträkningen och balansräkningen har fastställts på årsstämma + den {data.forvaltningsberattelse.agm_date ?? '____________________'} och + att årsstämman beslutade om disposition av bolagets resultat i enlighet + med vad som anges nedan. + + + Jag intygar också att årsredovisningen ger en rättvisande bild av + företagets ställning och resultat samt att förvaltningsberättelsen ger + en rättvisande översikt över utvecklingen av företagets verksamhet, + ställning och resultat. + + Stämmans beslut om resultatdisposition + + {data.forvaltningsberattelse.resultatdisposition} + + + + + + Styrelseledamot (närvarande vid stämman) + + + {data.company.city ? `${data.company.city}, ` : ''} + datum: {data.forvaltningsberattelse.agm_date ?? '____________________'} + + ) } diff --git a/lib/bokslut/arsredovisning/build-data.ts b/lib/bokslut/arsredovisning/build-data.ts index f391a484..bb7ac655 100644 --- a/lib/bokslut/arsredovisning/build-data.ts +++ b/lib/bokslut/arsredovisning/build-data.ts @@ -4,6 +4,7 @@ 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 { getNarrative } from './narrative-service' import type { ArsredovisningData, EgenKapitalRow, @@ -33,7 +34,7 @@ export async function buildArsredovisningData( fiscalPeriodId: string, overrides: Partial = {}, ): Promise { - const [periodResult, settingsResult, periodList, incomeStatement, balanceSheet] = await Promise.all([ + const [periodResult, settingsResult, periodList, incomeStatement, balanceSheet, narrative] = await Promise.all([ supabase .from('fiscal_periods') .select('id, name, period_start, period_end, previous_period_id, closing_entry_id') @@ -42,7 +43,7 @@ export async function buildArsredovisningData( .single(), supabase .from('company_settings') - .select('company_name, org_number, address') + .select('company_name, org_number, address, entity_type') .eq('company_id', companyId) .maybeSingle(), fetchAllRows(({ from, to }) => @@ -55,6 +56,11 @@ export async function buildArsredovisningData( ), generateIncomeStatement(supabase, companyId, fiscalPeriodId), generateBalanceSheet(supabase, companyId, fiscalPeriodId), + // Load persisted narrative overrides — replaces the URL-query-param + // carry from earlier phases. Caller-supplied overrides (passed in via + // the second arg) still win, so the API can layer per-request edits on + // top of the saved baseline if needed. + getNarrative(supabase, companyId, fiscalPeriodId).catch(() => null), ]) if (periodResult.error || !periodResult.data) { @@ -64,12 +70,23 @@ export async function buildArsredovisningData( const settings = settingsResult.data const companyName = settings?.company_name ?? 'Bolaget' const orgNumber = settings?.org_number ?? '' + // Default to 'unknown' (not 'aktiebolag') when entity_type isn't set — + // otherwise the K2 guard in buildK2Noter would claim K2 for every + // unconfigured company, which is exactly the false-assertion the guard + // was added to prevent. + const entityType = (settings as { entity_type?: string } | null)?.entity_type ?? 'unknown' type AddressShape = { city?: string | null; postal_city?: string | null } | null const addressUnknown = (settings as { address?: AddressShape } | null)?.address ?? null - const sate = + const city = (addressUnknown && (addressUnknown.city ?? addressUnknown.postal_city)) || null + // Merge precedence: caller overrides → persisted narrative → boilerplate + const persistedDescription = narrative?.description ?? undefined + const persistedEvents = narrative?.important_events ?? undefined + const persistedRd = narrative?.resultatdisposition ?? undefined + const persistedAgmDate = narrative?.agm_date ?? null + const flerarsoversikt = await buildFlerarsoversikt( supabase, companyId, @@ -79,16 +96,57 @@ export async function buildArsredovisningData( const egen_kapital_changes = buildEquityChanges(balanceSheet.equity_liability_sections) - const noter = await buildK2Noter(supabase, companyId) + const { notes: noter, warnings: noterWarnings } = await buildK2Noter( + supabase, + companyId, + entityType, + ) const resultatrakning = flattenIncomeStatement(incomeStatement) const balansrakning = flattenBalanceSheet(balanceSheet) + const warnings: string[] = [...noterWarnings] + if (entityType !== 'aktiebolag' && entityType !== 'unknown') { + warnings.push( + 'Den här årsredovisningen genereras med K2-mallen (BFNAR 2016:10) som standard. För K3- eller annan företagsform kan strukturen behöva justeras manuellt innan inlämning.', + ) + } + if (entityType === 'unknown') { + warnings.push( + 'Företagsform saknas i inställningarna — fyll i Inställningar → Företag för att få rätt redovisningsprinciper i not 1.', + ) + } + if (!persistedAgmDate) { + warnings.push( + 'Datum för årsstämma saknas. Fastställelseintyget i PDF:en lämnas tomt på datumraden tills det fylls i nedan.', + ) + } else { + // ÅRL 8 kap 3 § + ÅRL 7 kap 10 §: AGM must be held after the räkenskapsår + // ends and within 6 months of period end (för privat AB). A date before + // period_end is logically impossible; after the deadline is a legally + // defective fastställelseintyg. + if (persistedAgmDate <= period.period_end) { + warnings.push( + `Datum för årsstämma (${persistedAgmDate}) ligger på eller före räkenskapsårets slut (${period.period_end}) — fastställelseintyget blir juridiskt felaktigt. Kontrollera datumet.`, + ) + } else { + const periodEndDate = new Date(`${period.period_end}T00:00:00Z`) + const deadline = new Date(periodEndDate) + deadline.setUTCMonth(deadline.getUTCMonth() + 6) + const deadlineIso = deadline.toISOString().slice(0, 10) + if (persistedAgmDate > deadlineIso) { + warnings.push( + `Datum för årsstämma (${persistedAgmDate}) är efter 6-månadersgränsen (${deadlineIso}). För privat AB ska årsstämman hållas inom 6 månader från räkenskapsårets slut (ÅRL 7 kap 10 §).`, + ) + } + } + } + return { company: { name: companyName, org_number: orgNumber, - sate, + city, }, fiscal_period: { id: period.id, @@ -99,18 +157,23 @@ export async function buildArsredovisningData( forvaltningsberattelse: { description: overrides.description ?? + persistedDescription ?? `${companyName} bedriver verksamhet enligt verksamhetsbeskrivningen i bolagsordningen.`, important_events: overrides.important_events ?? + persistedEvents ?? '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 ?? + persistedRd ?? 'Styrelsen föreslår att årets resultat balanseras i ny räkning.', + agm_date: persistedAgmDate, }, resultatrakning, + warnings, balansrakning, noter, signatures: [], // populated by signature-flow service in a later phase step @@ -156,6 +219,12 @@ async function buildFlerarsoversikt( // ö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. + // + // K3 NOTE: K3 (BFNAR 2012:1) requires the 79,4% equity portion of + // obeskattade reserver to be folded into eget kapital and the 20,6% + // latent skatteskuld to be split out separately. When K3 support + // lands this filter must branch on the company's framework — for now + // we treat every entity as K2 / consistent-with-K2. const equity = tb.rows .filter((r) => r.account_number.startsWith('20')) .reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0) @@ -205,15 +274,64 @@ function buildEquityChanges(sections: BalanceSheetSection[]): EgenKapitalRow[] { async function buildK2Noter( supabase: SupabaseClient, companyId: string, -): Promise { + entityType: string, +): Promise<{ notes: NoteEntry[]; warnings: string[] }> { const notes: NoteEntry[] = [] + const warnings: string[] = [] + // Note 1: framework. Only claim K2 explicitly when we know the company is + // an AB and using K2 — otherwise emit a generic principles note so the + // ÅR doesn't falsely assert a framework the company isn't on. + // K3 election isn't yet tracked separately; we treat any non-AB as not-K2. + const isAbK2 = entityType === 'aktiebolag' 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).', + body: isAbK2 + ? 'Å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).' + : 'Årsredovisningen är upprättad i enlighet med Årsredovisningslagen och Bokföringsnämndens allmänna råd.', }) + // Note: aktiekapital. K2 punkt 18.x requires AB to disclose share-capital + // structure. Read from company_settings when present; surface a warning + // when missing so the user knows to fill it in. We also surface the + // warning when entityType is 'unknown' since the company may in fact be + // an AB the user just hasn't configured yet — staying silent would let + // them download an incomplete K2 ÅR without realising. + const maybeAb = isAbK2 || entityType === 'unknown' + if (maybeAb) { + const { data: settings } = await supabase + .from('company_settings') + .select('aktiekapital, antal_aktier, kvotvarde') + .eq('company_id', companyId) + .maybeSingle() + type AktiekapitalShape = { aktiekapital?: number | null; antal_aktier?: number | null; kvotvarde?: number | null } + const ak = settings as AktiekapitalShape | null + const aktiekapital = ak?.aktiekapital ?? null + const antalAktier = ak?.antal_aktier ?? null + const kvotvarde = ak?.kvotvarde ?? null + if (aktiekapital || antalAktier) { + const parts: string[] = [] + if (aktiekapital) parts.push(`Aktiekapital: ${aktiekapital.toLocaleString('sv-SE')} kr.`) + if (antalAktier) parts.push(`Antal aktier: ${antalAktier.toLocaleString('sv-SE')}.`) + if (kvotvarde) parts.push(`Kvotvärde per aktie: ${kvotvarde.toLocaleString('sv-SE')} kr.`) + notes.push({ + number: notes.length + 1, + title: 'Aktiekapital', + body: parts.join(' '), + }) + } else { + // Don't write a "saknas — komplettera" placeholder into the PDF body — + // that text would land in the Bolagsverket-filed document as a user- + // facing error string and the filing would be K2-non-compliant + // (BFNAR 2016:10 punkt 5.4 / ÅRL 5 kap 14 § require the actual + // registered amount). Omit the note entirely and surface a warning so + // the UI can flag this pre-download. + warnings.push( + 'Aktiekapitalnoten saknas eftersom uppgifter om aktiekapital inte finns i Inställningar → Företag. K2 / ÅRL kräver att noten innehåller registrerat belopp innan inlämning till Bolagsverket.', + ) + } + } + // Avskrivningstider — derive from asset register const assets = await listAssets(supabase, companyId) if (assets.length > 0) { @@ -269,7 +387,7 @@ async function buildK2Noter( body: 'Inga.', }) - return notes + return { notes, warnings } } function flattenIncomeStatement(is: { diff --git a/lib/bokslut/arsredovisning/narrative-service.ts b/lib/bokslut/arsredovisning/narrative-service.ts new file mode 100644 index 00000000..f5132467 --- /dev/null +++ b/lib/bokslut/arsredovisning/narrative-service.ts @@ -0,0 +1,89 @@ +import type { SupabaseClient } from '@supabase/supabase-js' + +export interface NarrativeOverrides { + description: string | null + important_events: string | null + resultatdisposition: string | null + /** ISO date of the AGM (årsstämma) where the årsredovisning was adopted. + * Populates the fastställelseintyg date blank — without it the PDF + * cannot be filed at Bolagsverket without manual pen-and-ink edit. */ + agm_date: string | null +} + +/** + * Shape returned from getNarrative / upsertNarrative. user_id and + * created_at are deliberately excluded from the API projection (see + * NARRATIVE_API_COLUMNS below). + */ +export interface NarrativeRow { + id: string + company_id: string + fiscal_period_id: string + description: string | null + important_events: string | null + resultatdisposition: string | null + agm_date: string | null + updated_at: string +} + +const TABLE = 'arsredovisning_narratives' + +// Explicit projection — keeps user_id and other internal audit fields out +// of API responses. GDPR Art.25.2 / ISO A.8.3 data-minimization: callers +// only need the narrative content + last-updated timestamp. +const NARRATIVE_API_COLUMNS = + 'id, company_id, fiscal_period_id, description, important_events, resultatdisposition, agm_date, updated_at' + +/** + * Load persisted narrative overrides for a fiscal period. Returns null when + * the user hasn't customised anything yet — caller then falls back to the + * auto-generated boilerplate in buildArsredovisningData. + */ +export async function getNarrative( + supabase: SupabaseClient, + companyId: string, + fiscalPeriodId: string, +): Promise { + const { data, error } = await supabase + .from(TABLE) + .select(NARRATIVE_API_COLUMNS) + .eq('company_id', companyId) + .eq('fiscal_period_id', fiscalPeriodId) + .maybeSingle() + if (error) throw new Error(`Failed to load narrative: ${error.message}`) + return (data as NarrativeRow | null) ?? null +} + +/** + * Upsert narrative overrides for a fiscal period. Composite UNIQUE constraint + * (company_id, fiscal_period_id) — see migration + * 20260517160000_narrative_agm_date_and_composite_unique.sql — makes the + * onConflict path resolve to an UPDATE within the same tenant, so repeated + * saves cleanly replace prior content instead of stacking rows. + */ +export async function upsertNarrative( + supabase: SupabaseClient, + companyId: string, + userId: string, + fiscalPeriodId: string, + input: Partial, +): Promise { + const payload = { + user_id: userId, + company_id: companyId, + fiscal_period_id: fiscalPeriodId, + description: input.description ?? null, + important_events: input.important_events ?? null, + resultatdisposition: input.resultatdisposition ?? null, + agm_date: input.agm_date ?? null, + } + const { data, error } = await supabase + .from(TABLE) + .upsert(payload, { onConflict: 'company_id,fiscal_period_id' }) + .select(NARRATIVE_API_COLUMNS) + .single() + if (error || !data) { + throw new Error(`Failed to save narrative: ${error?.message ?? 'unknown'}`) + } + return data as NarrativeRow +} diff --git a/lib/bokslut/arsredovisning/types.ts b/lib/bokslut/arsredovisning/types.ts index 88c3cbfa..dfe5e0cc 100644 --- a/lib/bokslut/arsredovisning/types.ts +++ b/lib/bokslut/arsredovisning/types.ts @@ -49,7 +49,9 @@ export interface ArsredovisningData { company: { name: string org_number: string - sate: string | null + /** Företagets säte (Bolagsverket-registered registered office city). + * Used in the underskrifter "Stad, datum" line and the fastställelseintyg. */ + city: string | null } fiscal_period: { id: string @@ -69,6 +71,10 @@ export interface ArsredovisningData { egen_kapital_changes: EgenKapitalRow[] /** Styrelsens förslag till resultatdisposition (manual input). */ resultatdisposition: string + /** ISO date of the årsstämma where the årsredovisning was adopted. + * Populates the fastställelseintyg date blank. Null means "not yet + * recorded" — PDF then leaves the blank. */ + agm_date: string | null } resultatrakning: IncomeStatementLine[] balansrakning: { @@ -84,4 +90,9 @@ export interface ArsredovisningData { name: string signed_at: string | null }[] + /** Pre-download blockers / warnings the UI surfaces so the user knows the + * PDF is not yet Bolagsverket-fileable as-is. Examples: aktiekapital + * uppgifter saknas, AGM-datum saknas, K3 entity. Never an error — the + * user can still download to iterate. */ + warnings: string[] } diff --git a/supabase/migrations/20260517140000_arsredovisning_narratives.sql b/supabase/migrations/20260517140000_arsredovisning_narratives.sql new file mode 100644 index 00000000..532f1380 --- /dev/null +++ b/supabase/migrations/20260517140000_arsredovisning_narratives.sql @@ -0,0 +1,52 @@ +-- arsredovisning_narratives — persists the free-text förvaltningsberättelse +-- fields (description, important_events, resultatdisposition) that the user +-- edits in the ÅR page. Replaces the URL-query-param carry from PR #509 — +-- those params leaked narrative content into access logs and browser history, +-- and the URL state couldn't survive a refresh or be shared between users. +-- +-- One row per fiscal_period_id (UNIQUE), upserted by the page's save action. +-- The PDF route reads from here as overrides on top of the auto-generated +-- boilerplate in buildArsredovisningData. +-- +-- Also tightens signer_name to VARCHAR(200) at the storage layer to match +-- the API-layer .max(200) guard added in PR #509 (round-2 polish, GDPR Art.25.2 +-- data-minimization). The API caps it; the column enforces it. + +CREATE TABLE public.arsredovisning_narratives ( + 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, + description TEXT CHECK (length(description) <= 4000), + important_events TEXT CHECK (length(important_events) <= 4000), + resultatdisposition TEXT CHECK (length(resultatdisposition) <= 2000), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT arsredovisning_narratives_unique_period UNIQUE (fiscal_period_id) +); + +CREATE INDEX idx_arsredovisning_narratives_company + ON public.arsredovisning_narratives (company_id); + +ALTER TABLE public.arsredovisning_narratives ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "arsredovisning_narratives_select" ON public.arsredovisning_narratives + FOR SELECT USING (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "arsredovisning_narratives_insert" ON public.arsredovisning_narratives + FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "arsredovisning_narratives_update" ON public.arsredovisning_narratives + FOR UPDATE USING (company_id IN (SELECT public.user_company_ids())) + WITH CHECK (company_id IN (SELECT public.user_company_ids())); +CREATE POLICY "arsredovisning_narratives_delete" ON public.arsredovisning_narratives + FOR DELETE USING (company_id IN (SELECT public.user_company_ids())); + +CREATE TRIGGER arsredovisning_narratives_updated_at + BEFORE UPDATE ON public.arsredovisning_narratives + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); + +-- Tighten signer_name length at storage layer (matches API .max(200)). +ALTER TABLE public.arsredovisning_signature_requests + ADD CONSTRAINT arsredovisning_sigreq_signer_name_max + CHECK (length(signer_name) <= 200); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260517160000_narrative_agm_date_and_composite_unique.sql b/supabase/migrations/20260517160000_narrative_agm_date_and_composite_unique.sql new file mode 100644 index 00000000..9b2632fa --- /dev/null +++ b/supabase/migrations/20260517160000_narrative_agm_date_and_composite_unique.sql @@ -0,0 +1,26 @@ +-- Two changes to arsredovisning_narratives: +-- +-- 1. Add agm_date column. The fastställelseintyg page (added in PR #511) +-- has a blank for the stämma-date the user has to fill in by hand, +-- which defeats the purpose of a generated document. With this column +-- the user records the AGM date once and the PDF picks it up. +-- +-- 2. Change the UNIQUE constraint from (fiscal_period_id) to +-- (company_id, fiscal_period_id). The previous constraint relied on +-- UUIDs not colliding across tenants for isolation — true in practice, +-- but the constraint itself should match the tenant boundary so a logic +-- error in onConflict resolution can never overwrite another company's +-- narrative. RLS would also reject the cross-tenant write, but +-- constraint-level enforcement is stronger defense-in-depth. + +ALTER TABLE public.arsredovisning_narratives + ADD COLUMN IF NOT EXISTS agm_date DATE; + +ALTER TABLE public.arsredovisning_narratives + DROP CONSTRAINT IF EXISTS arsredovisning_narratives_unique_period; + +ALTER TABLE public.arsredovisning_narratives + ADD CONSTRAINT arsredovisning_narratives_unique_period + UNIQUE (company_id, fiscal_period_id); + +NOTIFY pgrst, 'reload schema'; diff --git a/supabase/migrations/20260517180000_narrative_signature_user_set_null.sql b/supabase/migrations/20260517180000_narrative_signature_user_set_null.sql new file mode 100644 index 00000000..afb2d35c --- /dev/null +++ b/supabase/migrations/20260517180000_narrative_signature_user_set_null.sql @@ -0,0 +1,40 @@ +-- Decouple arsredovisning_narratives and arsredovisning_signature_requests +-- from the auth.users lifecycle. Both tables hold räkenskapsinformation that +-- BFL 7 kap 1 § requires to be retained for 7 years; without this change, +-- ON DELETE CASCADE on user_id would delete the filed årsredovisning content +-- (and the signature evidence) the moment the authoring user is deleted — +-- e.g. on GDPR Art.17 erasure or membership revocation. That's a direct +-- conflict between two compliance regimes; BFL wins for filed financial +-- records, so the user FK becomes optional and SET NULL on delete. +-- +-- The company FK keeps its CASCADE: when a company is deleted, its +-- räkenskapsinformation goes with it (separate workflow, e.g. liquidation +-- archive handover, handles BFL retention at that level). + +-- arsredovisning_narratives +ALTER TABLE public.arsredovisning_narratives + ALTER COLUMN user_id DROP NOT NULL; + +ALTER TABLE public.arsredovisning_narratives + DROP CONSTRAINT IF EXISTS arsredovisning_narratives_user_id_fkey; + +ALTER TABLE public.arsredovisning_narratives + ADD CONSTRAINT arsredovisning_narratives_user_id_fkey + FOREIGN KEY (user_id) + REFERENCES auth.users(id) + ON DELETE SET NULL; + +-- arsredovisning_signature_requests +ALTER TABLE public.arsredovisning_signature_requests + ALTER COLUMN user_id DROP NOT NULL; + +ALTER TABLE public.arsredovisning_signature_requests + DROP CONSTRAINT IF EXISTS arsredovisning_signature_requests_user_id_fkey; + +ALTER TABLE public.arsredovisning_signature_requests + ADD CONSTRAINT arsredovisning_signature_requests_user_id_fkey + FOREIGN KEY (user_id) + REFERENCES auth.users(id) + ON DELETE SET NULL; + +NOTIFY pgrst, 'reload schema';