feat(reconciliation,bokslut): underlag on a balansdag + persisted closing checklist (Reko bilagor, PR 2 + PR 3) (#1873)
* feat(reconciliation): underlag on a balansdag, the files behind a sign-off (Reko bilagor, PR 2) A konsult attaches the kontoutdrag, engagemangsbesked or reskontralista an account was reconciled against to (account_key, through_date), before or after the sign-off, from every account body on the Avstämning page. Rows live in account_reconciliation_attachments (append-only, removal stamp by trigger, RLS like account_reconciliations), bytes in the documents bucket under the company prefix so its RLS applies unchanged, and the full archive copies them into bilagor/ with a hash manifest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(reconciliation): literal selects and payload in the attachments store so the phantom-column scanner can read them Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) (#1867) * feat(bokslut): persisted closing checklist and missing-fiscal-year warning (Reko bilagor, PR 3) The bokslut checklist is a catalogue in code with one state row per period (bokslut_checklist_items): the steps the system can judge (sign-offs through balansdagen, reskontra tie-outs, drafts, voucher gaps, trial balance) are computed live and a stored row only overrides them; the manual steps are the konsult's ticks, with who and when. It sits on the wizard's Kontroll step and is dumped into the full archive. A hole between fiscal years (one-file SIE migrations) is now named on the bokslut readiness screen and on the import result screen, where the next file is one click away. Non-adjacent period links are #1849's fix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz * fix(bokslut): count unexplained voucher gaps, literal select and payload for the checklist store Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RvFveUpbdPBXdm7f5FEYoz --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
ebbdf96b74
commit
c62321988b
@@ -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';
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user