Files
accounted/supabase/migrations/20260519100000_skattekonto_rules.sql
T
Mattsson 8a6ce7093e feat: implement skattekonto drift detection and alerting (#525)
* feat: implement skattekonto drift detection and alerting

- Add skattekonto drift computation logic to compare Skatteverket's saldo with GL 1630 sum.
- Implement alerting mechanism for significant drift changes, with throttling to prevent alert spamming.
- Introduce database functions to sum GL 1630 entries and list unbooked skattekonto rows.

feat: create own account transfer detection

- Develop logic to detect transfers between a company's own cash accounts based on counterparty IBAN.
- Implement tests to validate detection logic under various scenarios, including matching and non-matching IBANs.

feat: establish cash accounts as a first-class entity

- Create cash_accounts table to manage routable cash accounts, replacing ad-hoc JSONB structures.
- Implement functions for listing, upserting, and managing cash accounts, including primary account designation.

feat: enhance GL line reconciliation functionality

- Modify get_unlinked_1930_lines RPC to accept any account number for reconciliation, improving flexibility for different currencies.
- Update related functions to ensure compatibility with the new cash_accounts structure.

feat: capture counterparty IBAN in transactions

- Add counterparty_iban column to transactions table to facilitate intra-account transfer detection.
- Create index for efficient lookups based on counterparty IBAN.

* feat: Enhance cash account handling and reconciliation processes

- Updated reconciliation routes to enforce cash account validation for all account numbers, including '1930'.
- Improved error handling for unknown cash accounts in reconciliation status and unmatched entries routes.
- Changed CashAccountSelector to use sessionStorage instead of localStorage for better data privacy.
- Fixed mapping for employer payroll taxes to route to the correct account (2730 instead of 2731).
- Added safety checks for company IDs in the guessCounterAccount function to prevent injection vulnerabilities.
- Introduced atomic RPC for setting primary cash accounts to avoid intermediate states during updates.
- Seeded default cash accounts for new companies to ensure reconciliation routes are accessible from day one.
- Updated email notifications for drift detection to avoid exposing sensitive financial data.
- Enhanced bank reconciliation logic to handle multi-currency transactions correctly.
- Renamed and updated tests to reflect changes in the underlying RPCs and ensure accurate coverage.
- Migrated existing cash account rules to correct mappings in compliance with Swedish accounting standards.
2026-05-19 16:10:18 +02:00

133 lines
6.4 KiB
SQL

-- Migration: skattekonto_rules — extensible counter-account rules for skattekontot
--
-- Why this exists: skattekonto-booking previously hardcoded an 8-entry array of
-- (substring → counter-account) rules in TypeScript. Each new SKV transaktionstext
-- pattern (omprövning, skattetillägg, förseningsavgift, ...) required a code change.
-- This table lets users (and migrations) add rules without redeploys, mirroring the
-- Fortnox "Regelverk" model.
--
-- Sign convention is handled by the caller (skattekonto-booking.ts): a positive
-- belopp_skatteverket debits 1630 and credits counter_account; a negative belopp
-- does the reverse. Rules only resolve the counter-account.
--
-- System seeds (company_id IS NULL): read-only to all companies; cannot be mutated
-- via RLS. Per-company rules override system seeds via lower numeric priority.
--
-- Special sentinel: counter_account = '__PRIMARY_SEK__' resolves at runtime via
-- the cash_accounts.is_primary lookup. Used so the inbetalning / utbetalning rules
-- don't have to assume 1930 is the bank account for every company.
--
-- Account 8314 (Skattefria ränteintäkter) is used for intäktsränta instead of 8313
-- (Ränteintäkter från bankgiro etc., taxable) because skattekontoräntan is skattefri
-- per IL 8 kap 7 §.
--
-- Account 6992 (Övriga externa kostnader, ej avdragsgilla) catches skattetillägg
-- and förseningsavgift — both are non-deductible penalties on SKV charges.
--
-- "Omprövning" deliberately has NO system rule: it's a re-assessment, not a
-- penalty. The underlying tax (moms, F-skatt, AGI) is what changes — the existing
-- moms/preliminärskatt/AGI rules cover those cases. Routing "omprövning" to 6992
-- by keyword alone would mis-book a moms re-assessment as a non-deductible cost.
--
-- Anstånd is intentionally NOT given a rule. Anstånd is an SKV-side deferral
-- (the saldo changes but no underlying tax is restated), so the GL doesn't move.
-- The resolver returns null and the booking flow surfaces NO_COUNTER_ACCOUNT for
-- the user to handle manually if a rare anstånd-across-closed-period case appears.
CREATE TABLE public.skattekonto_rules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
-- NULL = system default seed, readable by every company.
company_id UUID REFERENCES public.companies(id) ON DELETE CASCADE,
-- Lower priority wins. Per-company rules typically use 1-49 to override system
-- seeds (10-30).
priority INTEGER NOT NULL,
-- Comma-separated lowercase substrings; ANY match wins.
pattern TEXT NOT NULL CHECK (length(pattern) > 0),
amount_min NUMERIC,
amount_max NUMERIC,
company_type TEXT NOT NULL DEFAULT 'all'
CHECK (company_type IN ('aktiebolag','enskild_firma','all')),
-- BAS counter-account, or the literal sentinel '__PRIMARY_SEK__'.
counter_account TEXT NOT NULL CHECK (length(counter_account) > 0),
-- Override for enskild_firma when the same rule needs a different account for EF
-- (e.g. preliminärskatt: AB → 2510, EF → 2012). NULL means use counter_account.
counter_account_ef TEXT,
label TEXT,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_skattekonto_rules_lookup
ON public.skattekonto_rules (company_id, priority)
WHERE active = true;
ALTER TABLE public.skattekonto_rules ENABLE ROW LEVEL SECURITY;
-- System seeds are visible to all authenticated users; per-company rules only to
-- members of that company.
CREATE POLICY "skattekonto_rules_select" ON public.skattekonto_rules
FOR SELECT USING (
company_id IS NULL
OR company_id IN (SELECT public.user_company_ids())
);
-- Writes are scoped to companies the user belongs to. System seeds (company_id IS NULL)
-- cannot be mutated via RLS — the WITH CHECK forces a non-NULL company_id.
CREATE POLICY "skattekonto_rules_insert" ON public.skattekonto_rules
FOR INSERT WITH CHECK (
company_id IS NOT NULL
AND company_id IN (SELECT public.user_company_ids())
);
CREATE POLICY "skattekonto_rules_update" ON public.skattekonto_rules
FOR UPDATE USING (
company_id IS NOT NULL
AND company_id IN (SELECT public.user_company_ids())
)
WITH CHECK (
company_id IS NOT NULL
AND company_id IN (SELECT public.user_company_ids())
);
CREATE POLICY "skattekonto_rules_delete" ON public.skattekonto_rules
FOR DELETE USING (
company_id IS NOT NULL
AND company_id IN (SELECT public.user_company_ids())
);
CREATE TRIGGER skattekonto_rules_updated_at
BEFORE UPDATE ON public.skattekonto_rules
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- ============================================================
-- System seeds
-- ============================================================
--
-- counter_account = '__PRIMARY_SEK__' on the in-/utbetalning rules so the resolver
-- looks up cash_accounts.is_primary = true for SEK rather than assuming 1930.
INSERT INTO public.skattekonto_rules (
company_id, priority, pattern, company_type, counter_account, counter_account_ef, label
) VALUES
(NULL, 10, 'inbetalning bokförd,inbetalning,överföring från bank', 'all',
'__PRIMARY_SEK__', NULL, 'Inbetalning till skattekonto'),
(NULL, 10, 'utbetalning,återbetalning', 'all',
'__PRIMARY_SEK__', NULL, 'Utbetalning från skattekonto'),
(NULL, 20, 'debiterad preliminärskatt,preliminärskatt,f-skatt,fskatt', 'all',
'2510', '2012', 'Preliminär skatt'),
(NULL, 20, 'arbetsgivaravgift,sociala avgifter,agi', 'all',
'2731', NULL, 'Arbetsgivaravgifter'),
(NULL, 20, 'avdragen skatt,personalskatt,a-skatt', 'all',
'2710', NULL, 'Avdragen skatt anställda'),
(NULL, 20, 'mervärdesskatt,moms,momsdeklaration', 'all',
'2650', NULL, 'Redovisningskonto för moms'),
(NULL, 25, 'skattetillägg,förseningsavgift', 'all',
'6992', NULL, 'Ej avdragsgilla skatteavgifter'),
(NULL, 30, 'kostnadsränta', 'all',
'8423', NULL, 'Kostnadsränta skattekonto'),
(NULL, 30, 'intäktsränta', 'all',
'8314', NULL, 'Intäktsränta skattekonto');
NOTIFY pgrst, 'reload schema';