From f98ffee1454aec79c35603eac90792419a09bb2c Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sun, 17 May 2026 15:38:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(bokslut):=20Phase=208=20=E2=80=94=20make?= =?UTF-8?q?=20=C3=A5rsredovisning=20Bolagsverket-fileable=20(#511)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bokslut): Phase 8 — make årsredovisning Bolagsverket-fileable Closes most of the deferred items from PR #509 review. The ÅR PDF is now an honest draft a user can take to Bolagsverket: it includes the fastställe- intyg page ÅRL 8 kap 3 § requires, the K2-mandatory aktiekapital note, and the narrative edits actually survive a refresh. Narrative persistence (replaces the round-1 URL-query-param carry) - New table arsredovisning_narratives (UNIQUE per fiscal_period_id, length caps matching the API schema, RLS + updated_at trigger). - narrative-service.ts: getNarrative / upsertNarrative. - /api/.../arsredovisning/narrative GET + POST. POST does an explicit period-ownership pre-check before the upsert. - buildArsredovisningData loads persisted narrative as override layer (caller-supplied overrides → persisted → boilerplate). - ÅR page replaces the URL-query-param hack with a Spara button + saved indicator. The PDF download URL is plain again — no narrative content in access logs, browser history, or CDN logs. - PDF route stops parsing description/events/disposition query params. Also closes the GDPR Art.25(1) finding the bot flagged in PR #509. Fastställelseintyg PDF page - New 7th page in ArsredovisningPDF after Underskrifter. Carries the ÅRL 8 kap 3 § attestation text + the resultatdisposition + a signature slot. - Without this page Bolagsverket rejects the filing — flagged in the round-2 Swedish review on PR #509. K2 aktiekapital note + framework guard - buildK2Noter now takes entityType. Note 1 only claims K2 when the company is an AB; non-AB gets a generic principles statement so we don't falsely assert a framework. Future K3 election will flip this branch when it lands. - New aktiekapital note (required K2 note for AB per BFNAR 2016:10 ch.18). Reads aktiekapital / antal_aktier / kvotvärde from company_settings; emits a "saknas — komplettera under Inställningar" placeholder when missing. Manual "Mark as signed" PATCH + UI button - New PATCH /signatures/[signatureId] — flips pending → signed (manual / paper flow) or pending → declined. Real BankID wiring is Phase 9 and will use the same markSignatureSigned helper with the BankID callback as the trigger. - ÅR page renders a "Markera som signerad" button on every pending row. Small cleanups all flagged in PR #509 reviews - AccrualProposal.reverses_on type: '' → null. The future accrual-reversal cron will filter `reverses_on IS NOT NULL`; an empty string would silently match. - ArsredovisningData.company.sate → city. The typo carried into the type in earlier phases; renaming now before any external consumer takes a dependency. - signer_name CHECK length 200 at storage layer (matches the API .max(200) added in PR #509 round-2 — GDPR Art.25.2 belt-and-braces). - Soliditet equity filter now has a code comment explaining the K2 vs K3 branch the bot wanted documented for the future K3 migration. Explicit follow-ups (each merits its own focused PR): - Real BankID signing — needs provider choice + polling + QR. Phase 9. - Accrual reversal cron — auto-flip 17xx/29xx accruals on Jan 1 of next FY. - Medelantal anställda proper annual average — needs salary-run aggregation. - Vacation avgifter age-tier split (10.21 % for 67+) — needs upstream vacation-liability report to expose age. - K2 noter expansion (lån till närstående, eventualförpliktelser detail). Verification - 94 unit tests pass (bokslut + MCP subsets) - Zero typecheck errors on any touched file - Zero lint errors on any touched file - Migration 20260517140000 applied to remote Supabase via MCP Co-Authored-By: Claude Opus 4.7 (1M context) * fix(bokslut): address PR #511 round-1 — 3 P1s + 3 real concerns 3 P1s from Greptile (all real bugs): - entityType default reintroduced the K2 false-assertion. build-data.ts defaulted `entity_type ?? 'aktiebolag'`, which means every unconfigured company would still claim K2 in Note 1 — exactly the false-assertion the framework guard was added to prevent. Now defaults to 'unknown' and the guard treats that as not-K2. New warning surfaces in the data so the UI can prompt the user to fill in företagsform. - Signatures PATCH ignored the URL fiscal-period id. The route destructured `id` from params but never used it as a filter, so PATCH /periods/A/ signatures/SIG_FROM_B succeeded silently — broken REST contract + IDOR across periods. Rewrote the handler to do a single UPDATE with all four filters: id, company_id, fiscal_period_id, status='pending'. Missing row returns 409 SIGNATURE_INVALID_TRANSITION instead of silent 200. - Signatures state-machine guard was missing. Without status='pending' in the WHERE clause, an already-signed signature could be flipped back to declined (or vice-versa). Now part of the consolidated UPDATE above. 3 real concerns: - Narrative GET lacked ownership pre-check. POST already had it; mirroring on GET so a valid JWT for company A can't probe / enumerate company B's period IDs through the narrative endpoint. - Narrative POST lacked period-lock check. BFL 5 kap 5 § makes räkenskapsinformation immutable after filing — editing the förvaltningsberättelse on a closed/locked period now returns PERIOD_LOCKED. - Aktiekapital placeholder text would land in Bolagsverket-filed PDF body. When aktiekapital fields are missing, the note now omits entirely and a warning surfaces in the ArsredovisningData.warnings array — the UI flags it pre-download with a "Innan inlämning till Bolagsverket" list. Same surface picks up the entityType=unknown and entityType=non-AB warnings. Plus 2 schema improvements from Swedish review: - AGM date persistence. Fastställelseintyg date was a literal "____" blank, defeating the point of a generated PDF. New agm_date column on arsredovisning_narratives + UI date input + PDF now renders the saved date. When missing, the warning surface flags it. - Composite UNIQUE constraint on (company_id, fiscal_period_id) instead of just fiscal_period_id. UUIDs don't collide across tenants in practice but the constraint should match the tenant boundary so a logic error in onConflict resolution can't write to another company's row. Migration 20260517160000 drops the old constraint and adds the composite. Verification - 94 unit tests pass - Zero typecheck errors on any touched file - Zero lint errors on any touched file - Migration 20260517160000 applied to remote Supabase via MCP Co-Authored-By: Claude Opus 4.7 (1M context) * fix(bokslut): address PR #511 round-2 — 5 real concerns + BFL/GDPR conflict 5 real concerns from the round-1 bot re-eval: - Narrative SELECT * leaked user_id to the frontend. getNarrative and upsertNarrative now project an explicit NARRATIVE_API_COLUMNS list (id, company_id, fiscal_period_id, narrative fields, agm_date, updated_at). user_id and created_at stay server-side. NarrativeRow type updated to match. Closes Art.25.2 + 2× A.8.3. - agm_date validated only as YYYY-MM-DD regex. '2024-13-99' passed Zod and surfaced as a Postgres 500 instead of a 400. Added a refine() that parses with new Date() and confirms ISO round-trip equality, so invalid calendar dates return a clean structured-error. - agm_date had no range check. ÅRL 8:3 → 7:10 §§ requires the AGM to be held after period end and within 6 months for privat AB; build-data warnings now flag agm_date <= period_end (impossible) and agm_date > period_end + 6 months (deadline). Warning surface in the UI already picks these up from the existing list. - Fastställelseintyg signer label "Styrelseledamot / VD" conflated legally distinct roles per ÅRL 8:3 → 6:6-7 §§ — a VD without board membership cannot sign. Label is now "Styrelseledamot (närvarande vid stämman)" and the body text references the AGM's resolution ("stämmobeslutet") rather than the board's proposal — the AGM votes, and it is the vote that must be certified. - Aktiekapital warning suppressed for entityType='unknown'. The maybeAb branch in buildK2Noter now fires for both 'aktiebolag' and 'unknown' so an unconfigured company that's actually an AB still gets prompted to fill in aktiekapital before filing. Note body stays omitted when fields are missing; only the warning surfaces. BFL × GDPR conflict (new migration 20260517180000): - Both arsredovisning_narratives and arsredovisning_signature_requests had user_id with ON DELETE CASCADE → auth.users. BFL 7 kap 1 § requires räkenskapsinformation to be retained for 7 years; GDPR Art.17 erasure or membership revocation would silently delete filed årsredovisning narrative + BankID signature evidence. BFL wins for filed financial records — user_id is now nullable with ON DELETE SET NULL on both tables. The company FK keeps its CASCADE (company deletion takes its räkenskapsinformation with it; that's a separate workflow). Deliberately not chasing on this round: - ISO A.8.12 historical PDF query-param logs — process item for the risk register, not code (the leak path is closed in this PR's first commit). - "Collapse the two narrative migrations" — both already shipped to remote and merged; the interim window is in the past. - "user_id on row vs separate audit log" — architectural debate; tracked but out of scope for this PR. - Multi-signer fastställelseintyg + DB-level period-lock trigger — bigger scope, each merits a focused follow-up. Verification - 89 unit tests pass - Zero typecheck errors on any touched file - Zero lint errors on any touched file - Migration 20260517180000 applied to remote Supabase via MCP Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../year-end/arsredovisning/page.tsx | 201 +++++++++++++++--- .../fiscal-periods/[id]/accruals/route.ts | 2 +- .../[id]/arsredovisning/narrative/route.ts | 91 ++++++++ .../[id]/arsredovisning/pdf/route.ts | 20 +- .../signatures/[signatureId]/route.ts | 67 ++++++ lib/bokslut/accruals/accrual-detector.ts | 7 +- lib/bokslut/accruals/types.ts | 12 +- .../arsredovisning/arsredovisning-pdf.tsx | 55 ++++- lib/bokslut/arsredovisning/build-data.ts | 136 +++++++++++- .../arsredovisning/narrative-service.ts | 89 ++++++++ lib/bokslut/arsredovisning/types.ts | 13 +- ...260517140000_arsredovisning_narratives.sql | 52 +++++ ...arrative_agm_date_and_composite_unique.sql | 26 +++ ...0000_narrative_signature_user_set_null.sql | 40 ++++ 14 files changed, 749 insertions(+), 62 deletions(-) create mode 100644 app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts create mode 100644 app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/signatures/[signatureId]/route.ts create mode 100644 lib/bokslut/arsredovisning/narrative-service.ts create mode 100644 supabase/migrations/20260517140000_arsredovisning_narratives.sql create mode 100644 supabase/migrations/20260517160000_narrative_agm_date_and_composite_unique.sql create mode 100644 supabase/migrations/20260517180000_narrative_signature_user_set_null.sql 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';