1&xs9Y!!mMrzy3VY~KdAj#yg80{Ww5dIKcg-VN{2Y14fY
z{}<-4HSI3XFd<1S5tmyfv%4(sYsj@DhL&vn?|YZK!#l=c6Igsk1D3j69@g&mZuh5f
zm26iJN+D+y@p2nDrs*71c#40Ao3H8Xq66telQ_|1YIxnd5Uo<8BDsihFB#*D1C83C
zO_{vgKC&R@(cM}f$F>mRoZ)$u=UR5ykCtr9+OAj-|EI=^s|b6$)(;r4tXp(#ZAV-w__Z*NRNq?+
zcna&ptK9};*_o^~N=+}cVGR}1M1Y@ctZP?spplZ
z6m&|otGt38;b`2{|gUh
Bj*tKV
literal 0
HcmV?d00001
diff --git a/lib/reconciliation/schemas.ts b/lib/reconciliation/schemas.ts
index bbacbd75..9b8828ed 100644
--- a/lib/reconciliation/schemas.ts
+++ b/lib/reconciliation/schemas.ts
@@ -80,6 +80,24 @@ export const ReconciliationSignoffSchema = z.object({
})
export type ReconciliationSignoff = z.infer
+/** One underlag file attached to an account's balansdag (account_reconciliation_attachments). */
+export const ReconciliationAttachmentSchema = z.object({
+ id: z.string(),
+ account_key: AccountKeySchema,
+ through_date: z.string(),
+ file_name: z.string(),
+ mime_type: z.string(),
+ size_bytes: z.number().int(),
+ sha256: z.string(),
+ note: z.string().nullable(),
+ uploaded_by: z.string(),
+ uploaded_at: z.string(),
+ removed_at: z.string().nullable(),
+ removed_by: z.string().nullable(),
+ removed_reason: z.string().nullable(),
+})
+export type ReconciliationAttachment = z.infer
+
export const ReconciliationAccountSchema = z.object({
account_key: AccountKeySchema,
kind: ReconciliationKindSchema,
diff --git a/lib/reports/__tests__/full-archive-export.test.ts b/lib/reports/__tests__/full-archive-export.test.ts
index 3d961327..e00df793 100644
--- a/lib/reports/__tests__/full-archive-export.test.ts
+++ b/lib/reports/__tests__/full-archive-export.test.ts
@@ -51,6 +51,11 @@ vi.mock('../journal-register', () => ({
}),
}))
+// The bilagor step reads account_reconciliation_attachments through the
+// store; an empty list keeps the queued-mock order of these tests intact.
+vi.mock('@/lib/reconciliation/attachments-store', () => ({
+ listAttachmentRowsInRange: vi.fn().mockResolvedValue([]),
+}))
vi.mock('../vat-declaration', () => ({
calculateVatDeclaration: vi.fn().mockResolvedValue({
period: { type: 'yearly', year: 2024, period: 1, start: '2024-01-01', end: '2024-12-31' },
diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts
index 01691312..99924017 100644
--- a/lib/reports/full-archive-export.ts
+++ b/lib/reports/full-archive-export.ts
@@ -9,6 +9,7 @@ import { generateJournalRegister } from './journal-register'
import { calculateVatDeclaration } from './vat-declaration'
import { getAuditLog } from '@/lib/core/audit/audit-service'
import { downloadDocumentObject } from '@/lib/core/documents/document-service'
+import { listAttachmentRowsInRange } from '@/lib/reconciliation/attachments-store'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { getBranding } from '@/lib/branding/service'
import {
@@ -171,6 +172,7 @@ export async function generateFullArchive(
if (options.include_documents !== false) {
await writeDocuments(zip, supabase, companyId, periods, options.scope)
+ await writeReconciliationAttachments(zip, supabase, companyId, periods)
}
if (options.scope === 'all') {
@@ -554,6 +556,94 @@ async function writeDocuments(
dokument.file('manifest.json', JSON.stringify(manifest, null, 2))
}
+interface ReconciliationAttachmentManifestEntry {
+ attachment_id: string
+ account_key: string
+ through_date: string
+ file_name: string
+ storage_path: string
+ sha256: string
+ mime_type: string
+ size_bytes: number
+ note: string | null
+ uploaded_at: string
+ removed_at: string | null
+ removed_reason: string | null
+ zip_path: string | null
+ status: 'downloaded' | 'removed' | 'error'
+ error?: string
+}
+
+/**
+ * The underlag behind the reconciliation sign-offs (bokslutsbilagor): every
+ * file attached to a balansdag inside the archived periods, laid out as
+ * `bilagor///_`, plus a manifest
+ * with the content hashes. Removed files are listed (with their stamp) but
+ * not copied: the manifest is the record that they were attached and then
+ * withdrawn. A failed read lands in the manifest rather than aborting the
+ * archive, like writeDocuments.
+ */
+async function writeReconciliationAttachments(
+ zip: JSZip,
+ supabase: SupabaseClient,
+ companyId: string,
+ periods: FiscalPeriodRow[]
+): Promise {
+ const manifest: ReconciliationAttachmentManifestEntry[] = []
+ const usedPaths = new Set()
+ const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
+ const from = sorted[0].period_start
+ const to = sorted[sorted.length - 1].period_end
+
+ try {
+ const rows = await listAttachmentRowsInRange(supabase, companyId, from, to, { includeRemoved: true })
+ for (const row of rows) {
+ const period = sorted.find((p) => row.through_date >= p.period_start && row.through_date <= p.period_end)
+ const base = {
+ attachment_id: row.id,
+ account_key: row.account_key,
+ through_date: row.through_date,
+ file_name: row.file_name,
+ storage_path: row.storage_path,
+ sha256: row.sha256,
+ mime_type: row.mime_type,
+ size_bytes: row.size_bytes,
+ note: row.note,
+ uploaded_at: row.uploaded_at,
+ removed_at: row.removed_at,
+ removed_reason: row.removed_reason,
+ }
+ if (row.removed_at) {
+ manifest.push({ ...base, zip_path: null, status: 'removed' })
+ continue
+ }
+ if (!period) continue
+ let zipPath = `bilagor/${periodLabel(period)}/${row.account_key.replace(':', '_')}/${row.through_date}_${row.file_name}`
+ if (usedPaths.has(zipPath)) {
+ const dot = zipPath.lastIndexOf('.')
+ const suffix = `_${row.id.slice(0, 8)}`
+ zipPath = dot > zipPath.lastIndexOf('/') ? `${zipPath.slice(0, dot)}${suffix}${zipPath.slice(dot)}` : `${zipPath}${suffix}`
+ }
+ usedPaths.add(zipPath)
+ try {
+ const { data, error } = await supabase.storage.from(row.storage_bucket).download(row.storage_path)
+ if (error || !data) {
+ manifest.push({ ...base, zip_path: null, status: 'error', error: error?.message || 'Download returned no data' })
+ continue
+ }
+ zip.file(zipPath, await data.arrayBuffer())
+ manifest.push({ ...base, zip_path: zipPath, status: 'downloaded' })
+ } catch (err) {
+ manifest.push({ ...base, zip_path: null, status: 'error', error: err instanceof Error ? err.message : 'Unknown error' })
+ }
+ }
+ } catch {
+ // Attachment listing failed: the archive still carries everything else.
+ }
+
+ zip.folder('bilagor')!.file('manifest.json', JSON.stringify(manifest, null, 2))
+}
+
/**
* PostgREST returns a many-to-one embedded resource as either an object or an
* array depending on schema introspection (FK is unique vs not). Normalize.
@@ -988,6 +1078,9 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [
// through which date with the numbers as they stood, plus reopen stamps.
// Part of the avstämningsdokumentation an auditor asks for; kept.
{ name: 'account_reconciliations', file: 'account_reconciliations.json', orderBy: 'signed_at' },
+ // The bokslut checklist per räkenskapsår (which closing steps were done,
+ // by whom, when): the konsult's documented bokslutsarbete (Reko 760); kept.
+ { name: 'bokslut_checklist_items', file: 'bokslut_checklist_items.json', orderBy: 'updated_at', pageKey: 'item_key' },
{ name: 'journal_entry_no_doc_required', file: 'journal_entry_no_doc_required.json', pageKey: 'journal_entry_id' },
{ name: 'rot_rut_payout_requests', file: 'rot_rut_payout_requests.json', orderBy: 'created_at' },
// No `denormalize`: rot_rut_payout_requests has no currency column either.
@@ -1017,6 +1110,7 @@ export const ARCHIVE_COVERED_ELSEWHERE_TABLES: Record = {
voucher_sequences: 'revision/systemdokumentation.json (verifikationsserier)',
audit_log: 'revision/behandlingshistorik.json',
document_attachments: 'dokument/ + dokument/manifest.json',
+ account_reconciliation_attachments: 'bilagor/ + bilagor/manifest.json',
sie_imports: 'sie/imports.json + sie/original/',
sie_account_mappings: 'sie/account_mappings.json',
}
diff --git a/messages/en.json b/messages/en.json
index 50135573..1aa72028 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -8056,5 +8056,40 @@
"settings_toggle_help": "Shows the driving log in the menu. There you log business trips and book tax-free mileage allowance at the statutory rate.",
"settings_save_failed_title": "Could not save the setting",
"settings_open_page": "Open the driving log"
+ },
+ "reconciliation_underlag": {
+ "heading": "Supporting documents",
+ "heading_dated": "Supporting documents as of {date}",
+ "attach": "Attach document",
+ "empty": "No supporting documents for this date. Attach the bank statement, engagement letter or other specification the account was reconciled against.",
+ "remove": "Remove",
+ "attached": "{name} attached",
+ "removed": "{name} removed. The file stays in the archive with a note about the removal.",
+ "upload_failed": "Could not attach the document",
+ "remove_failed": "Could not remove the document",
+ "too_large_title": "The file is too large"
+ },
+ "bokslut_checklist": {
+ "heading": "Closing checklist",
+ "progress": "{done} of {total} done",
+ "group_avstamning": "Reconciliations",
+ "group_periodisering": "Accruals",
+ "group_vardering": "Valuation",
+ "group_dispositioner": "Appropriations and tax",
+ "group_kontroll": "Controls",
+ "group_rapportering": "Reporting",
+ "auto_chip": "computed",
+ "done_at": "done {date}",
+ "open": "Open",
+ "reopen": "Reopen",
+ "not_applicable": "Not applicable",
+ "use_auto": "Let the system decide",
+ "save_failed": "Could not save the checklist"
+ },
+ "fiscal_year_gaps": {
+ "title": "{count, plural, one {One fiscal year is missing} other {# fiscal years are missing}}",
+ "gap": "{from} to {to} (between {after} and {before})",
+ "hint": "Import the SIE file for that year, or create the fiscal year manually, so balances roll forward.",
+ "manage": "Manage fiscal years"
}
}
diff --git a/messages/sv.json b/messages/sv.json
index 99f075e3..18ea6433 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -8056,5 +8056,40 @@
"settings_toggle_help": "Visar körjournalen i menyn. Där loggar du tjänsteresor och bokför milersättning skattefritt enligt schablon.",
"settings_save_failed_title": "Kunde inte spara inställningen",
"settings_open_page": "Öppna körjournalen"
+ },
+ "reconciliation_underlag": {
+ "heading": "Underlag",
+ "heading_dated": "Underlag per {date}",
+ "attach": "Bifoga underlag",
+ "empty": "Inget underlag bifogat för det här datumet. Bifoga kontoutdrag, engagemangsbesked eller annan specifikation som kontot stämts av mot.",
+ "remove": "Ta bort",
+ "attached": "{name} bifogad",
+ "removed": "{name} borttagen. Filen finns kvar i arkivet med en notering om borttagningen.",
+ "upload_failed": "Kunde inte bifoga underlaget",
+ "remove_failed": "Kunde inte ta bort underlaget",
+ "too_large_title": "Filen är för stor"
+ },
+ "bokslut_checklist": {
+ "heading": "Bokslutschecklista",
+ "progress": "{done} av {total} klara",
+ "group_avstamning": "Avstämningar",
+ "group_periodisering": "Periodiseringar",
+ "group_vardering": "Värdering",
+ "group_dispositioner": "Dispositioner och skatt",
+ "group_kontroll": "Kontroller",
+ "group_rapportering": "Rapportering",
+ "auto_chip": "beräknas",
+ "done_at": "klart {date}",
+ "open": "Öppna",
+ "reopen": "Öppna igen",
+ "not_applicable": "Ej tillämpligt",
+ "use_auto": "Låt systemet bedöma",
+ "save_failed": "Kunde inte spara checklistan"
+ },
+ "fiscal_year_gaps": {
+ "title": "{count, plural, one {Ett räkenskapsår saknas} other {# räkenskapsår saknas}}",
+ "gap": "{from} till {to} (mellan {after} och {before})",
+ "hint": "Importera SIE-filen för det året, eller skapa räkenskapsåret manuellt, så att balanserna rullar fram.",
+ "manage": "Hantera räkenskapsår"
}
}
diff --git a/supabase/migrations/20260824200000_account_reconciliation_attachments.sql b/supabase/migrations/20260824200000_account_reconciliation_attachments.sql
new file mode 100644
index 00000000..9215eff5
--- /dev/null
+++ b/supabase/migrations/20260824200000_account_reconciliation_attachments.sql
@@ -0,0 +1,145 @@
+-- Underlag for account reconciliation: the bank statement, engagemangsbesked,
+-- reskontralista, inventering or any other document a balance account was
+-- reconciled against, attached to (company, account_key, through_date), the
+-- same scope a sign-off (account_reconciliations) attests. A file can be
+-- attached before the sign-off exists (attach the statement, then sign) and
+-- stays with the balansdag afterwards; together they are the bokslutsbilaga
+-- Reko 140/760/765 ask a redovisningskonsult to keep per balanspost.
+--
+-- Räkenskapsinformation once it backs a bokslut (BFL 7 kap.), so rows are
+-- never deleted: a wrongly attached file gets a removal stamp (removed_at/by/
+-- reason), the storage object stays, and the pärm export lists it as removed.
+-- Every column but the removal stamp is frozen by trigger.
+
+CREATE TABLE IF NOT EXISTS public.account_reconciliation_attachments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
+ account_key TEXT NOT NULL
+ CHECK (account_key ~ '^(bank:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|skattekonto|manual:[0-9]{4})$'),
+ -- The balansdag the file documents (inclusive), matching account_reconciliations.through_date.
+ through_date DATE NOT NULL,
+ file_name TEXT NOT NULL CHECK (length(file_name) BETWEEN 1 AND 255),
+ mime_type TEXT NOT NULL,
+ size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0),
+ storage_bucket TEXT NOT NULL,
+ storage_path TEXT NOT NULL,
+ -- Content hash, so the pärm and the full archive can prove the file is the one that was attached.
+ sha256 TEXT NOT NULL CHECK (sha256 ~ '^[0-9a-f]{64}$'),
+ -- What the file is ("Kontoutdrag december", "Engagemangsbesked 2026-12-31").
+ note TEXT,
+ uploaded_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
+ uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ removed_at TIMESTAMPTZ,
+ removed_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
+ removed_reason TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CONSTRAINT account_reconciliation_attachments_removal_pair
+ CHECK ((removed_at IS NULL) = (removed_by IS NULL)),
+ CONSTRAINT account_reconciliation_attachments_storage_path_unique UNIQUE (storage_bucket, storage_path)
+);
+
+COMMENT ON TABLE public.account_reconciliation_attachments IS
+ 'Underlag attached to a reconciliation balansdag (account_key + through_date): the bokslutsbilaga files. Append-only; removal stamps instead of deleting (BFL 7 kap.).';
+
+-- "Files for this account and balansdag" is the read on every status page and in the pärm.
+CREATE INDEX IF NOT EXISTS idx_account_reconciliation_attachments_scope
+ ON public.account_reconciliation_attachments (company_id, account_key, through_date)
+ WHERE removed_at IS NULL;
+
+-- The pärm lists every balansdag of a fiscal period in one query.
+CREATE INDEX IF NOT EXISTS idx_account_reconciliation_attachments_company_date
+ ON public.account_reconciliation_attachments (company_id, through_date);
+
+-- Only the removal stamp may change after insert; everything else is the record.
+CREATE OR REPLACE FUNCTION public.account_reconciliation_attachments_freeze()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SET search_path = ''
+AS $$
+BEGIN
+ IF NEW.company_id IS DISTINCT FROM OLD.company_id
+ OR NEW.account_key IS DISTINCT FROM OLD.account_key
+ OR NEW.through_date IS DISTINCT FROM OLD.through_date
+ OR NEW.file_name IS DISTINCT FROM OLD.file_name
+ OR NEW.mime_type IS DISTINCT FROM OLD.mime_type
+ OR NEW.size_bytes IS DISTINCT FROM OLD.size_bytes
+ OR NEW.storage_bucket IS DISTINCT FROM OLD.storage_bucket
+ OR NEW.storage_path IS DISTINCT FROM OLD.storage_path
+ OR NEW.sha256 IS DISTINCT FROM OLD.sha256
+ OR NEW.note IS DISTINCT FROM OLD.note
+ OR NEW.uploaded_by IS DISTINCT FROM OLD.uploaded_by
+ OR NEW.uploaded_at IS DISTINCT FROM OLD.uploaded_at
+ OR NEW.created_at IS DISTINCT FROM OLD.created_at THEN
+ RAISE EXCEPTION 'account_reconciliation_attachments rows are append-only; only the removal stamp may change'
+ USING ERRCODE = 'integrity_constraint_violation';
+ END IF;
+ IF OLD.removed_at IS NOT NULL AND (
+ NEW.removed_at IS DISTINCT FROM OLD.removed_at
+ OR NEW.removed_by IS DISTINCT FROM OLD.removed_by
+ OR NEW.removed_reason IS DISTINCT FROM OLD.removed_reason) THEN
+ RAISE EXCEPTION 'a removed attachment cannot be restored or re-stamped'
+ USING ERRCODE = 'integrity_constraint_violation';
+ END IF;
+ RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS trg_account_reconciliation_attachments_freeze ON public.account_reconciliation_attachments;
+CREATE TRIGGER trg_account_reconciliation_attachments_freeze
+ BEFORE UPDATE ON public.account_reconciliation_attachments
+ FOR EACH ROW EXECUTE FUNCTION public.account_reconciliation_attachments_freeze();
+
+CREATE OR REPLACE FUNCTION public.account_reconciliation_attachments_no_delete()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SET search_path = ''
+AS $$
+BEGIN
+ RAISE EXCEPTION 'account_reconciliation_attachments rows are never deleted (BFL 7 kap.); stamp removed_at instead'
+ USING ERRCODE = 'integrity_constraint_violation';
+END;
+$$;
+
+DROP TRIGGER IF EXISTS trg_account_reconciliation_attachments_no_delete ON public.account_reconciliation_attachments;
+CREATE TRIGGER trg_account_reconciliation_attachments_no_delete
+ BEFORE DELETE ON public.account_reconciliation_attachments
+ FOR EACH ROW EXECUTE FUNCTION public.account_reconciliation_attachments_no_delete();
+
+ALTER TABLE public.account_reconciliation_attachments ENABLE ROW LEVEL SECURITY;
+
+-- Every member of the company sees the underlag; owners, admins and members
+-- attach and remove (viewers look but do not touch). requireWrite on the
+-- routes is the first layer; this is defense in depth.
+DROP POLICY IF EXISTS "account_reconciliation_attachments_select" ON public.account_reconciliation_attachments;
+CREATE POLICY "account_reconciliation_attachments_select" ON public.account_reconciliation_attachments
+ FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
+
+DROP POLICY IF EXISTS "account_reconciliation_attachments_insert" ON public.account_reconciliation_attachments;
+CREATE POLICY "account_reconciliation_attachments_insert" ON public.account_reconciliation_attachments
+ FOR INSERT WITH CHECK (
+ uploaded_by = auth.uid()
+ AND company_id IN (
+ SELECT cm.company_id FROM public.company_members cm
+ WHERE cm.user_id = auth.uid()
+ AND cm.role IN ('owner', 'admin', 'member')
+ )
+ );
+
+DROP POLICY IF EXISTS "account_reconciliation_attachments_update" ON public.account_reconciliation_attachments;
+CREATE POLICY "account_reconciliation_attachments_update" ON public.account_reconciliation_attachments
+ FOR UPDATE USING (
+ company_id IN (
+ SELECT cm.company_id FROM public.company_members cm
+ WHERE cm.user_id = auth.uid()
+ AND cm.role IN ('owner', 'admin', 'member')
+ )
+ )
+ WITH CHECK (
+ company_id IN (
+ SELECT cm.company_id FROM public.company_members cm
+ WHERE cm.user_id = auth.uid()
+ AND cm.role IN ('owner', 'admin', 'member')
+ )
+ );
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260824210000_bokslut_checklist_items.sql b/supabase/migrations/20260824210000_bokslut_checklist_items.sql
new file mode 100644
index 00000000..d8386cd6
--- /dev/null
+++ b/supabase/migrations/20260824210000_bokslut_checklist_items.sql
@@ -0,0 +1,74 @@
+-- The bokslut checklist per räkenskapsår: which closing steps are done, by
+-- whom and when, with a note. Reko 140/760 want the konsult's bokslutsarbete
+-- documented step by step; the wizard's own steps were client state that
+-- vanished on reload, so nothing recorded that the inventory was counted or
+-- the doubtful receivables reviewed.
+--
+-- The item catalogue lives in code (lib/bokslut/checklist.ts): the row is the
+-- state of one catalogue item for one period. Items the system can evaluate
+-- itself (drafts left, trial balance, sign-offs through balansdagen) are
+-- computed live; a row only overrides them (e.g. marking a step not
+-- applicable) or records the manual ones. Mutable by design: a step can be
+-- unticked when a late verifikat reopens it. The trail of who last touched a
+-- row is kept on the row; the archive dumps the table as documentation.
+
+CREATE TABLE IF NOT EXISTS public.bokslut_checklist_items (
+ company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
+ fiscal_period_id UUID NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE CASCADE,
+ -- Catalogue key (lib/bokslut/checklist.ts); constrained by shape so a typo cannot create a phantom step.
+ item_key TEXT NOT NULL CHECK (item_key ~ '^[a-z0-9_]{1,64}$'),
+ state TEXT NOT NULL CHECK (state IN ('open', 'done', 'not_applicable')),
+ note TEXT CHECK (note IS NULL OR length(note) <= 2000),
+ -- Who marked it done / not applicable and when; cleared when reopened.
+ done_by UUID REFERENCES auth.users(id) ON DELETE SET NULL,
+ done_at TIMESTAMPTZ,
+ updated_by UUID NOT NULL REFERENCES auth.users(id) ON DELETE RESTRICT,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (company_id, fiscal_period_id, item_key),
+ CONSTRAINT bokslut_checklist_items_done_pair
+ CHECK ((state = 'open' AND done_at IS NULL) OR (state <> 'open' AND done_at IS NOT NULL))
+);
+
+COMMENT ON TABLE public.bokslut_checklist_items IS
+ 'State of one bokslut checklist item (lib/bokslut/checklist.ts) for one fiscal period: open / done / not_applicable with note and who/when.';
+
+ALTER TABLE public.bokslut_checklist_items ENABLE ROW LEVEL SECURITY;
+
+-- Members read the checklist; owners, admins and members tick it as
+-- themselves (updated_by = auth.uid()); viewers look but do not touch. No
+-- DELETE policy: a step is reopened, never erased.
+DROP POLICY IF EXISTS "bokslut_checklist_items_select" ON public.bokslut_checklist_items;
+CREATE POLICY "bokslut_checklist_items_select" ON public.bokslut_checklist_items
+ FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
+
+DROP POLICY IF EXISTS "bokslut_checklist_items_insert" ON public.bokslut_checklist_items;
+CREATE POLICY "bokslut_checklist_items_insert" ON public.bokslut_checklist_items
+ FOR INSERT WITH CHECK (
+ updated_by = auth.uid()
+ AND company_id IN (
+ SELECT cm.company_id FROM public.company_members cm
+ WHERE cm.user_id = auth.uid()
+ AND cm.role IN ('owner', 'admin', 'member')
+ )
+ );
+
+DROP POLICY IF EXISTS "bokslut_checklist_items_update" ON public.bokslut_checklist_items;
+CREATE POLICY "bokslut_checklist_items_update" ON public.bokslut_checklist_items
+ FOR UPDATE USING (
+ company_id IN (
+ SELECT cm.company_id FROM public.company_members cm
+ WHERE cm.user_id = auth.uid()
+ AND cm.role IN ('owner', 'admin', 'member')
+ )
+ )
+ WITH CHECK (
+ updated_by = auth.uid()
+ AND company_id IN (
+ SELECT cm.company_id FROM public.company_members cm
+ WHERE cm.user_id = auth.uid()
+ AND cm.role IN ('owner', 'admin', 'member')
+ )
+ );
+
+NOTIFY pgrst, 'reload schema';
diff --git a/tests/pg/account-reconciliation-attachments.pg.test.ts b/tests/pg/account-reconciliation-attachments.pg.test.ts
new file mode 100644
index 00000000..d0079b1a
--- /dev/null
+++ b/tests/pg/account-reconciliation-attachments.pg.test.ts
@@ -0,0 +1,188 @@
+import { randomUUID } from 'node:crypto'
+import { describe, it, expect } from 'vitest'
+import { getPool, withUserContext } from './setup'
+import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
+
+// pg-real coverage for 20260824200000_account_reconciliation_attachments:
+// RLS (members read, owner/admin/member attach as themselves, viewers
+// read-only, no DELETE policy), the append-only freeze trigger (only the
+// removal stamp may change, and only once), the no-delete trigger, and the
+// account_key / sha256 CHECKs.
+
+const SHA = 'ab'.repeat(32)
+
+async function insertAttachment(
+ companyId: string,
+ uploadedBy: string,
+ overrides: { accountKey?: string; throughDate?: string; storagePath?: string } = {},
+): Promise {
+ const id = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.account_reconciliation_attachments
+ (id, company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
+ VALUES ($1, $2, $3, $4, 'kontoutdrag.pdf', 'application/pdf', 1234, 'documents', $5, $6, $7)`,
+ [
+ id,
+ companyId,
+ overrides.accountKey ?? 'manual:2350',
+ overrides.throughDate ?? '2026-12-31',
+ overrides.storagePath ?? `documents/${companyId}/reconciliation/manual_2350/2026-12-31/${id}_kontoutdrag.pdf`,
+ SHA,
+ uploadedBy,
+ ],
+ )
+ return id
+}
+
+describe('account_reconciliation_attachments RLS', () => {
+ it('lets company members read, strangers see nothing', async () => {
+ const { userId, companyId } = await seedCompany()
+ const rowId = await insertAttachment(companyId, userId)
+ const stranger = await insertAuthUser()
+
+ const ownerView = await withUserContext(userId, (client) =>
+ client.query<{ id: string }>(`SELECT id FROM public.account_reconciliation_attachments WHERE id = $1`, [rowId]),
+ )
+ expect(ownerView.rows).toHaveLength(1)
+
+ const strangerView = await withUserContext(stranger, (client) =>
+ client.query<{ id: string }>(`SELECT id FROM public.account_reconciliation_attachments WHERE id = $1`, [rowId]),
+ )
+ expect(strangerView.rows).toHaveLength(0)
+ })
+
+ it('lets viewers read but not attach', async () => {
+ const { userId, companyId } = await seedCompany()
+ const rowId = await insertAttachment(companyId, userId)
+ const viewer = await insertAuthUser()
+ await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
+
+ const viewerRead = await withUserContext(viewer, (client) =>
+ client.query<{ id: string }>(`SELECT id FROM public.account_reconciliation_attachments WHERE id = $1`, [rowId]),
+ )
+ expect(viewerRead.rows).toHaveLength(1)
+
+ await expect(
+ withUserContext(viewer, (client) =>
+ client.query(
+ `INSERT INTO public.account_reconciliation_attachments
+ (company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
+ VALUES ($1, 'manual:2350', '2026-12-31', 'x.pdf', 'application/pdf', 1, 'documents', $2, $3, $4)`,
+ [companyId, `documents/${companyId}/reconciliation/manual_2350/2026-12-31/${randomUUID()}_x.pdf`, SHA, viewer],
+ ),
+ ),
+ ).rejects.toThrow(/row-level security/i)
+ })
+
+ it('lets members attach as themselves but not as someone else', async () => {
+ const { userId: owner, companyId } = await seedCompany()
+ const member = await insertAuthUser()
+ await insertCompanyMember({ companyId, userId: member, role: 'member' })
+
+ const inserted = await withUserContext(member, (client) =>
+ client.query<{ id: string }>(
+ `INSERT INTO public.account_reconciliation_attachments
+ (company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
+ VALUES ($1, 'skattekonto', '2026-12-31', 'x.pdf', 'application/pdf', 1, 'documents', $2, $3, $4) RETURNING id`,
+ [companyId, `documents/${companyId}/reconciliation/skattekonto/2026-12-31/${randomUUID()}_x.pdf`, SHA, member],
+ ),
+ )
+ expect(inserted.rows).toHaveLength(1)
+
+ await expect(
+ withUserContext(member, (client) =>
+ client.query(
+ `INSERT INTO public.account_reconciliation_attachments
+ (company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
+ VALUES ($1, 'skattekonto', '2026-12-31', 'x.pdf', 'application/pdf', 1, 'documents', $2, $3, $4)`,
+ [companyId, `documents/${companyId}/reconciliation/skattekonto/2026-12-31/${randomUUID()}_y.pdf`, SHA, owner],
+ ),
+ ),
+ ).rejects.toThrow(/row-level security/i)
+ })
+
+ it('has no DELETE policy and a no-delete trigger', async () => {
+ const { userId, companyId } = await seedCompany()
+ const rowId = await insertAttachment(companyId, userId)
+
+ // RLS: the statement runs but touches nothing.
+ const asMember = await withUserContext(userId, (client) =>
+ client.query(`DELETE FROM public.account_reconciliation_attachments WHERE id = $1`, [rowId]),
+ )
+ expect(asMember.rowCount).toBe(0)
+
+ // Even the superuser cannot: BFL 7 kap. retention is enforced by trigger.
+ await expect(
+ getPool().query(`DELETE FROM public.account_reconciliation_attachments WHERE id = $1`, [rowId]),
+ ).rejects.toThrow(/never deleted/i)
+ })
+})
+
+describe('account_reconciliation_attachments append-only', () => {
+ it('lets a member stamp removal once, and freezes everything else', async () => {
+ const { userId, companyId } = await seedCompany()
+ const rowId = await insertAttachment(companyId, userId)
+
+ await expect(
+ withUserContext(userId, (client) =>
+ client.query(`UPDATE public.account_reconciliation_attachments SET note = 'ändrad' WHERE id = $1`, [rowId]),
+ ),
+ ).rejects.toThrow(/append-only/i)
+
+ await expect(
+ withUserContext(userId, (client) =>
+ client.query(`UPDATE public.account_reconciliation_attachments SET storage_path = 'documents/x' WHERE id = $1`, [rowId]),
+ ),
+ ).rejects.toThrow(/append-only/i)
+
+ const stamped = await withUserContext(userId, (client) =>
+ client.query<{ removed_at: string }>(
+ `UPDATE public.account_reconciliation_attachments
+ SET removed_at = NOW(), removed_by = $2, removed_reason = 'fel fil'
+ WHERE id = $1 RETURNING removed_at`,
+ [rowId, userId],
+ ),
+ )
+ expect(stamped.rows).toHaveLength(1)
+
+ // withUserContext rolls back; stamp for real (superuser) to test finality.
+ await getPool().query(
+ `UPDATE public.account_reconciliation_attachments
+ SET removed_at = NOW(), removed_by = $2, removed_reason = 'fel fil'
+ WHERE id = $1`,
+ [rowId, userId],
+ )
+
+ // The stamp itself is final: no restore, no re-stamp.
+ await expect(
+ getPool().query(
+ `UPDATE public.account_reconciliation_attachments SET removed_at = NULL, removed_by = NULL, removed_reason = NULL WHERE id = $1`,
+ [rowId],
+ ),
+ ).rejects.toThrow(/cannot be restored/i)
+ })
+
+ it('rejects a malformed account_key, a bad hash, and a half removal stamp', async () => {
+ const { userId, companyId } = await seedCompany()
+ await expect(insertAttachment(companyId, userId, { accountKey: '1930' })).rejects.toThrow(/account_key/i)
+ await expect(
+ getPool().query(
+ `INSERT INTO public.account_reconciliation_attachments
+ (company_id, account_key, through_date, file_name, mime_type, size_bytes, storage_bucket, storage_path, sha256, uploaded_by)
+ VALUES ($1, 'skattekonto', '2026-12-31', 'x.pdf', 'application/pdf', 1, 'documents', $2, 'nothex', $3)`,
+ [companyId, `documents/${companyId}/reconciliation/skattekonto/2026-12-31/${randomUUID()}_x.pdf`, userId],
+ ),
+ ).rejects.toThrow(/sha256/i)
+ const rowId = await insertAttachment(companyId, userId)
+ await expect(
+ getPool().query(`UPDATE public.account_reconciliation_attachments SET removed_at = NOW() WHERE id = $1`, [rowId]),
+ ).rejects.toThrow(/removal_pair/i)
+ })
+
+ it('refuses the same storage object twice', async () => {
+ const { userId, companyId } = await seedCompany()
+ const path = `documents/${companyId}/reconciliation/skattekonto/2026-12-31/${randomUUID()}_same.pdf`
+ await insertAttachment(companyId, userId, { accountKey: 'skattekonto', storagePath: path })
+ await expect(insertAttachment(companyId, userId, { accountKey: 'skattekonto', storagePath: path })).rejects.toThrow(/storage_path_unique|duplicate key/i)
+ })
+})
diff --git a/tests/pg/bokslut-checklist-items.pg.test.ts b/tests/pg/bokslut-checklist-items.pg.test.ts
new file mode 100644
index 00000000..c040f953
--- /dev/null
+++ b/tests/pg/bokslut-checklist-items.pg.test.ts
@@ -0,0 +1,113 @@
+import { describe, it, expect } from 'vitest'
+import { getPool, withUserContext } from './setup'
+import { seedCompany, insertAuthUser, insertCompanyMember } from './fixtures'
+
+// pg-real coverage for 20260824210000_bokslut_checklist_items: RLS (members
+// read, owner/admin/member write as themselves, viewers read-only, no
+// DELETE policy), the item_key and state CHECKs, the done pair CHECK and the
+// composite primary key (one row per item and period).
+
+async function tick(
+ companyId: string,
+ periodId: string,
+ userId: string,
+ overrides: { itemKey?: string; state?: string; doneAt?: string | null } = {},
+): Promise {
+ const state = overrides.state ?? 'done'
+ const doneAt = overrides.doneAt === undefined ? (state === 'open' ? null : new Date().toISOString()) : overrides.doneAt
+ await getPool().query(
+ `INSERT INTO public.bokslut_checklist_items
+ (company_id, fiscal_period_id, item_key, state, done_by, done_at, updated_by)
+ VALUES ($1, $2, $3, $4, $5, $6, $5)`,
+ [companyId, periodId, overrides.itemKey ?? 'inventory_valued', state, userId, doneAt],
+ )
+}
+
+describe('bokslut_checklist_items RLS', () => {
+ it('lets company members read, strangers see nothing', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+ await tick(companyId, fiscalPeriodId, userId)
+ const stranger = await insertAuthUser()
+
+ const ownerView = await withUserContext(userId, (client) =>
+ client.query(`SELECT item_key FROM public.bokslut_checklist_items WHERE fiscal_period_id = $1`, [fiscalPeriodId]),
+ )
+ expect(ownerView.rows).toHaveLength(1)
+
+ const strangerView = await withUserContext(stranger, (client) =>
+ client.query(`SELECT item_key FROM public.bokslut_checklist_items WHERE fiscal_period_id = $1`, [fiscalPeriodId]),
+ )
+ expect(strangerView.rows).toHaveLength(0)
+ })
+
+ it('lets viewers read but not tick', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+ await tick(companyId, fiscalPeriodId, userId)
+ const viewer = await insertAuthUser()
+ await insertCompanyMember({ companyId, userId: viewer, role: 'viewer' })
+
+ const read = await withUserContext(viewer, (client) =>
+ client.query(`SELECT item_key FROM public.bokslut_checklist_items WHERE fiscal_period_id = $1`, [fiscalPeriodId]),
+ )
+ expect(read.rows).toHaveLength(1)
+
+ await expect(
+ withUserContext(viewer, (client) =>
+ client.query(
+ `INSERT INTO public.bokslut_checklist_items (company_id, fiscal_period_id, item_key, state, done_by, done_at, updated_by)
+ VALUES ($1, $2, 'accruals_posted', 'done', $3, NOW(), $3)`,
+ [companyId, fiscalPeriodId, viewer],
+ ),
+ ),
+ ).rejects.toThrow(/row-level security/i)
+ })
+
+ it('lets members tick as themselves, upsert their own rows, but not sign as someone else', async () => {
+ const { userId: owner, companyId, fiscalPeriodId } = await seedCompany()
+ const member = await insertAuthUser()
+ await insertCompanyMember({ companyId, userId: member, role: 'member' })
+
+ const inserted = await withUserContext(member, (client) =>
+ client.query(
+ `INSERT INTO public.bokslut_checklist_items (company_id, fiscal_period_id, item_key, state, done_by, done_at, updated_by)
+ VALUES ($1, $2, 'accruals_posted', 'done', $3, NOW(), $3)
+ ON CONFLICT (company_id, fiscal_period_id, item_key)
+ DO UPDATE SET state = EXCLUDED.state, done_by = EXCLUDED.done_by, done_at = EXCLUDED.done_at, updated_by = EXCLUDED.updated_by, updated_at = NOW()
+ RETURNING state`,
+ [companyId, fiscalPeriodId, member],
+ ),
+ )
+ expect(inserted.rows[0].state).toBe('done')
+
+ await expect(
+ withUserContext(member, (client) =>
+ client.query(
+ `INSERT INTO public.bokslut_checklist_items (company_id, fiscal_period_id, item_key, state, done_by, done_at, updated_by)
+ VALUES ($1, $2, 'tax_provision', 'done', $3, NOW(), $3)`,
+ [companyId, fiscalPeriodId, owner],
+ ),
+ ),
+ ).rejects.toThrow(/row-level security/i)
+ })
+
+ it('has no DELETE policy: a member delete touches nothing', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+ await tick(companyId, fiscalPeriodId, userId)
+ const res = await withUserContext(userId, (client) =>
+ client.query(`DELETE FROM public.bokslut_checklist_items WHERE fiscal_period_id = $1`, [fiscalPeriodId]),
+ )
+ expect(res.rowCount).toBe(0)
+ })
+})
+
+describe('bokslut_checklist_items constraints', () => {
+ it('rejects a malformed key, an unknown state, an open row with done_at, a done row without it, and a duplicate', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+ await expect(tick(companyId, fiscalPeriodId, userId, { itemKey: 'Not Valid' })).rejects.toThrow(/item_key/i)
+ await expect(tick(companyId, fiscalPeriodId, userId, { state: 'maybe' })).rejects.toThrow(/state/i)
+ await expect(tick(companyId, fiscalPeriodId, userId, { state: 'open', doneAt: new Date().toISOString() })).rejects.toThrow(/done_pair/i)
+ await expect(tick(companyId, fiscalPeriodId, userId, { state: 'done', doneAt: null })).rejects.toThrow(/done_pair/i)
+ await tick(companyId, fiscalPeriodId, userId, { itemKey: 'no_drafts', state: 'not_applicable' })
+ await expect(tick(companyId, fiscalPeriodId, userId, { itemKey: 'no_drafts' })).rejects.toThrow(/duplicate key/i)
+ })
+})