feat: remove AI extensions, restructure settings, and add atomic voucher commits (#157)

Remove AI-dependent extensions (ai-chat, ai-categorization, receipt-ocr,
invoice-inbox) and their infrastructure (lib/ai/*, ai-consent, LangChain/
Anthropic/OpenAI deps) to simplify core and reduce bundle size.

Restructure monolithic settings page into dedicated sub-pages (company,
bookkeeping, invoicing, tax, banking, api, account, team, templates) with
shared layout and sidebar navigation.

Add atomic commit_journal_entry RPC so voucher number increment and status
update happen in a single transaction — prevents burned numbers on constraint
failures. Add continuity check report and voucher gap explanation tracking.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-01 17:08:00 +02:00
committed by GitHub
co-authored by Claude Opus 4.6
parent e89f2c402d
commit d0b3f21bde
172 changed files with 3621 additions and 20538 deletions
@@ -0,0 +1,40 @@
-- Settings restructure: add bookkeeping lock, PDF/invoice settings
-- Phase 2: Bookkeeping settings
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS bookkeeping_locked_through date,
ADD COLUMN IF NOT EXISTS auto_lock_period_days integer;
-- Phase 3: Invoice/PDF settings
ALTER TABLE public.company_settings
ADD COLUMN IF NOT EXISTS ore_rounding boolean DEFAULT true,
ADD COLUMN IF NOT EXISTS invoice_show_ocr boolean DEFAULT true,
ADD COLUMN IF NOT EXISTS invoice_show_bankgiro boolean DEFAULT true,
ADD COLUMN IF NOT EXISTS invoice_show_plusgiro boolean DEFAULT true,
ADD COLUMN IF NOT EXISTS invoice_late_fee_text text,
ADD COLUMN IF NOT EXISTS invoice_credit_terms_text text;
-- Enforce company-wide bookkeeping lock date on journal entries
CREATE OR REPLACE FUNCTION public.enforce_company_lock_date()
RETURNS TRIGGER AS $$
DECLARE
lock_date date;
BEGIN
SELECT bookkeeping_locked_through INTO lock_date
FROM public.company_settings
WHERE company_id = NEW.company_id;
IF lock_date IS NOT NULL AND NEW.entry_date <= lock_date THEN
RAISE EXCEPTION 'Bokföringen är låst t.o.m. %. Kan inte skapa verifikation med datum %.',
lock_date, NEW.entry_date;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply trigger only on insert and update of entry_date
DROP TRIGGER IF EXISTS trg_enforce_company_lock_date ON public.journal_entries;
CREATE TRIGGER trg_enforce_company_lock_date
BEFORE INSERT OR UPDATE OF entry_date ON public.journal_entries
FOR EACH ROW
EXECUTE FUNCTION public.enforce_company_lock_date();
@@ -0,0 +1,4 @@
-- Add continuity_verified column to fiscal_periods
-- NULL = not checked, true = IB/UB match verified, false = discrepancy detected
ALTER TABLE public.fiscal_periods
ADD COLUMN continuity_verified boolean;
@@ -0,0 +1,6 @@
-- Partial unique index on journal_entries to prevent duplicate voucher numbers.
-- Drafts use voucher_number = 0 (integer NOT NULL), so WHERE > 0 excludes them.
-- This enforces BFL 5:7 at the database level — application logic alone is insufficient.
CREATE UNIQUE INDEX IF NOT EXISTS uq_journal_entries_voucher_number
ON public.journal_entries (company_id, fiscal_period_id, voucher_series, voucher_number)
WHERE voucher_number > 0;
@@ -0,0 +1,55 @@
-- Voucher gap explanations table (BFNAR 2013:2 punkt 5.8)
-- Stores documented reasons for missing verifikationsnummer.
-- Unexplained gaps block year-end closing.
CREATE TABLE public.voucher_gap_explanations (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
fiscal_period_id uuid NOT NULL REFERENCES public.fiscal_periods(id) ON DELETE CASCADE,
voucher_series text NOT NULL DEFAULT 'A',
gap_start integer NOT NULL,
gap_end integer NOT NULL,
explanation text NOT NULL CHECK (char_length(explanation) <= 500),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (company_id, fiscal_period_id, voucher_series, gap_start, gap_end)
);
ALTER TABLE public.voucher_gap_explanations ENABLE ROW LEVEL SECURITY;
-- SELECT: any company member can read explanations
CREATE POLICY "voucher_gap_explanations_select"
ON public.voucher_gap_explanations FOR SELECT
USING (company_id IN (SELECT public.user_company_ids()));
-- INSERT: only owner/admin can create explanations (bokföringsansvarig)
CREATE POLICY "voucher_gap_explanations_insert"
ON public.voucher_gap_explanations FOR INSERT
WITH CHECK (
company_id IN (SELECT public.user_company_ids())
AND EXISTS (
SELECT 1 FROM public.team_members tm
JOIN public.companies c ON c.team_id = tm.team_id
WHERE c.id = company_id
AND tm.user_id = auth.uid()
AND tm.role IN ('owner', 'admin')
)
);
-- UPDATE: only owner/admin can update explanations
CREATE POLICY "voucher_gap_explanations_update"
ON public.voucher_gap_explanations FOR UPDATE
USING (
company_id IN (SELECT public.user_company_ids())
AND EXISTS (
SELECT 1 FROM public.team_members tm
JOIN public.companies c ON c.team_id = tm.team_id
WHERE c.id = company_id
AND tm.user_id = auth.uid()
AND tm.role IN ('owner', 'admin')
)
);
CREATE TRIGGER set_updated_at BEFORE UPDATE ON public.voucher_gap_explanations
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
@@ -0,0 +1,54 @@
-- Atomic journal entry commit: assigns voucher number and posts entry in one transaction.
-- Prevents burned voucher numbers from failed commits (the counter increment rolls back
-- if the status update or balance trigger fails).
--
-- Only used for the draft→posted transition (commitEntry). reverseEntry and storno
-- use a different flow (INSERT with voucher number) and still call next_voucher_number.
CREATE OR REPLACE FUNCTION public.commit_journal_entry(
p_company_id uuid,
p_entry_id uuid
)
RETURNS TABLE (voucher_number integer)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_next integer;
v_fiscal_period_id uuid;
v_series text;
BEGIN
-- Fetch and lock the draft entry
SELECT je.fiscal_period_id, COALESCE(je.voucher_series, 'A')
INTO v_fiscal_period_id, v_series
FROM public.journal_entries je
WHERE je.id = p_entry_id
AND je.company_id = p_company_id
AND je.status = 'draft'
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Draft journal entry not found: %', p_entry_id;
END IF;
-- Increment voucher sequence (atomic via INSERT ON CONFLICT)
INSERT INTO public.voucher_sequences (company_id, user_id, fiscal_period_id, voucher_series, last_number)
VALUES (p_company_id, auth.uid(), v_fiscal_period_id, v_series, 1)
ON CONFLICT (company_id, fiscal_period_id, voucher_series)
DO UPDATE SET
last_number = public.voucher_sequences.last_number + 1,
updated_at = now()
RETURNING last_number INTO v_next;
-- Update entry to posted with the assigned voucher number.
-- If the balance trigger (check_balance_on_post) rejects the UPDATE,
-- the entire transaction rolls back — including the sequence increment.
-- No burned number, no gap.
UPDATE public.journal_entries
SET voucher_number = v_next,
status = 'posted'
WHERE id = p_entry_id
AND company_id = p_company_id;
RETURN QUERY SELECT v_next;
END;
$$;