feat(bokslut): Phase 8 — make årsredovisning Bolagsverket-fileable (#511)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-17 15:38:36 +02:00
committed by GitHub
parent fd4274787c
commit f98ffee145
14 changed files with 749 additions and 62 deletions
@@ -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<string | null>(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<number | null>(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 (
<div className="space-y-8">
@@ -211,6 +300,47 @@ export default function ArsredovisningPage() {
rows={3}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ar-agm-date">Datum för årsstämma</Label>
<Input
id="ar-agm-date"
type="date"
value={agmDate}
onChange={(e) => setAgmDate(e.target.value)}
className="max-w-[220px]"
/>
<p className="text-xs text-muted-foreground">
Datum årsstämman fastställde årsredovisningen fyller i datumraden
fastställelseintyget i PDF:en (krävs för inlämning till Bolagsverket).
</p>
</div>
<div className="flex items-center justify-between pt-2">
<div className="text-xs text-muted-foreground">
{hasUnsavedNarrative ? (
<span>Ändringar sparas inte automatiskt.</span>
) : savedAt ? (
<span className="inline-flex items-center gap-1 text-success">
<CheckCircle2 className="h-3.5 w-3.5" /> Sparat
</span>
) : (
<span>Alla ändringar är sparade.</span>
)}
</div>
<Button
onClick={handleSaveNarrative}
disabled={savingNarrative || !hasUnsavedNarrative}
>
{savingNarrative ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sparar
</>
) : (
<>
<Save className="mr-2 h-4 w-4" /> Spara texten
</>
)}
</Button>
</div>
</CardContent>
</Card>
@@ -274,13 +404,24 @@ export default function ArsredovisningPage() {
<p className="text-sm font-medium">{sig.signer_name}</p>
<p className="text-xs text-muted-foreground">{sig.role}</p>
</div>
{sig.status === 'signed' ? (
<Badge variant="success">Signerad</Badge>
) : sig.status === 'declined' ? (
<Badge variant="destructive">Avböjd</Badge>
) : (
<Badge variant="outline">Väntar underskrift</Badge>
)}
<div className="flex items-center gap-2">
{sig.status === 'signed' ? (
<Badge variant="success">Signerad</Badge>
) : sig.status === 'declined' ? (
<Badge variant="destructive">Avböjd</Badge>
) : (
<>
<Badge variant="outline">Väntar underskrift</Badge>
<Button
size="sm"
variant="outline"
onClick={() => void handleMarkSigned(sig.id)}
>
Markera som signerad
</Button>
</>
)}
</div>
</div>
))}
<div className="flex flex-wrap gap-2 items-end pt-2">
@@ -344,6 +485,16 @@ export default function ArsredovisningPage() {
</Link>
</Button>
</div>
{data.warnings.length > 0 && (
<div className="rounded-md border border-warning/40 bg-warning/5 p-3 text-xs text-warning-foreground space-y-1">
<p className="font-medium">Innan inlämning till Bolagsverket:</p>
<ul className="list-disc pl-5 space-y-1">
{data.warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
</div>
)}
<div className="rounded-md border border-warning/40 bg-warning/5 p-3 text-xs text-warning-foreground">
<strong>Notis om digital inlämning:</strong> Bolagsverket har föreslagit att
digital inlämning (iXBRL) av årsredovisning för aktiebolag ska bli
@@ -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) {
@@ -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 },
)
@@ -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.
@@ -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 },
)
+4 -3
View File
@@ -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,
+7 -5
View File
@@ -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. */
@@ -138,8 +138,8 @@ export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) {
</Text>
<Text style={styles.paragraph}>{data.company.name}</Text>
<Text style={styles.paragraph}>Organisationsnummer: {data.company.org_number}</Text>
{data.company.sate && (
<Text style={styles.paragraph}>Säte: {data.company.sate}</Text>
{data.company.city && (
<Text style={styles.paragraph}>Säte: {data.company.city}</Text>
)}
</View>
</Page>
@@ -265,7 +265,7 @@ export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) {
<PageChrome data={data} pageLabel="Underskrifter" />
<Text style={styles.sectionTitle}>Underskrifter</Text>
<Text style={styles.paragraph}>
{data.company.sate ? `${data.company.sate}, ` : ''}
{data.company.city ? `${data.company.city}, ` : ''}
{data.fiscal_period.period_end}
</Text>
{(data.signatures.length > 0
@@ -283,6 +283,55 @@ export function ArsredovisningPDF({ data }: { data: ArsredovisningData }) {
</View>
))}
</Page>
{/*
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.
*/}
<Page size="A4" style={styles.page}>
<PageChrome data={data} pageLabel="Fastställelseintyg" />
<Text style={styles.sectionTitle}>Fastställelseintyg</Text>
<Text style={styles.paragraph}>
Undertecknad styrelseledamot, närvarande vid årsstämman, intygar härmed
att resultaträkningen och balansräkningen har fastställts årsstämma
den {data.forvaltningsberattelse.agm_date ?? '____________________'} och
att årsstämman beslutade om disposition av bolagets resultat i enlighet
med vad som anges nedan.
</Text>
<Text style={styles.paragraph}>
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.
</Text>
<Text style={styles.sectionTitle}>Stämmans beslut om resultatdisposition</Text>
<Text style={styles.paragraph}>
{data.forvaltningsberattelse.resultatdisposition}
</Text>
<View style={styles.signatureLine}>
<View style={styles.signatureSlot}>
<Text> </Text>
</View>
<Text style={{ width: 240 }}>Styrelseledamot (närvarande vid stämman)</Text>
</View>
<Text style={[styles.paragraph, { marginTop: 30, fontSize: 9, color: '#666' }]}>
{data.company.city ? `${data.company.city}, ` : ''}
datum: {data.forvaltningsberattelse.agm_date ?? '____________________'}
</Text>
</Page>
</Document>
)
}
+127 -9
View File
@@ -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<ArsredovisningData['forvaltningsberattelse']> = {},
): Promise<ArsredovisningData> {
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<NoteEntry[]> {
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: {
@@ -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<NarrativeRow | null> {
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<NarrativeOverrides>,
): Promise<NarrativeRow> {
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
}
+12 -1
View File
@@ -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[]
}
@@ -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';
@@ -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';
@@ -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';