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.
This commit is contained in:
Mattsson
2026-05-19 16:10:18 +02:00
committed by GitHub
parent e211ab31be
commit 8a6ce7093e
42 changed files with 3420 additions and 206 deletions
@@ -0,0 +1,132 @@
-- 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';
@@ -0,0 +1,150 @@
-- Migration: cash_accounts — first-class entity for routable cash accounts
--
-- Why this exists: bank_connections.accounts_data is a JSONB array storing
-- PSD2 accounts plus their BAS ledger account mapping. That shape is fine for
-- ingest but doesn't support:
-- - per-account reconciliation (today's get_unlinked_1930_lines hardcodes 1930)
-- - a stable foreign key from transactions to "the account that produced this row"
-- - cash-on-hand / Stripe-clearing / BG-PG entries that aren't backed by PSD2
-- - a primary-SEK designation so the skattekonto guesser stops hardcoding 1930
--
-- cash_accounts promotes the routing primitive to its own table. JSONB
-- accounts_data remains the source for PSD2 sync metadata + UI display in this
-- PR; cash_accounts is the canonical source for routing decisions. A follow-up
-- migration after 30 days of stable operation will drop accounts_data.
--
-- Unique constraint is (company_id, ledger_account) per user decision. This
-- enforces today's "one currency per BAS account" assumption (1930=SEK,
-- 1932=EUR, 1933=USD, 1934=GBP). If a future Wise/Revolut multi-currency wallet
-- customer appears, a follow-up migration would split the constraint to add
-- currency as a third key column.
--
-- Partial unique index on (company_id) WHERE is_primary = true gives us
-- at most one primary per company — used by skattekonto-booking's
-- __PRIMARY_SEK__ sentinel resolver and by future multi-currency wallets.
CREATE TABLE public.cash_accounts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
-- bank_connection FK is nullable so manual / cash-on-hand / SIE-imported
-- accounts can live without a PSD2 connection.
bank_connection_id UUID REFERENCES public.bank_connections(id) ON DELETE SET NULL,
external_uid TEXT, -- PSD2 StoredAccount.uid
iban TEXT,
bg_pg TEXT, -- Bankgiro / Plusgiro
name TEXT,
currency TEXT NOT NULL CHECK (length(currency) = 3),
-- BAS account number (string, not int — '1930' not 1930).
ledger_account TEXT NOT NULL CHECK (length(ledger_account) >= 4),
balance NUMERIC,
balance_updated_at TIMESTAMPTZ,
enabled BOOLEAN NOT NULL DEFAULT true,
is_primary BOOLEAN NOT NULL DEFAULT false,
source TEXT NOT NULL DEFAULT 'enable_banking'
CHECK (source IN ('enable_banking', 'manual', 'sie_import')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (company_id, ledger_account),
-- For PSD2-backed accounts, (bank_connection_id, external_uid) uniquely
-- identifies the account inside that connection — guards against duplicate
-- upserts during sync.
UNIQUE (company_id, bank_connection_id, external_uid)
);
CREATE INDEX idx_cash_accounts_company ON public.cash_accounts (company_id);
CREATE INDEX idx_cash_accounts_iban
ON public.cash_accounts (company_id, iban)
WHERE iban IS NOT NULL;
-- At most one primary per company. Partial unique index — the standard pattern
-- for "exactly one true row per group" (mirrors sie_imports active_partial_unique).
CREATE UNIQUE INDEX idx_cash_accounts_one_primary_per_company
ON public.cash_accounts (company_id)
WHERE is_primary = true;
ALTER TABLE public.cash_accounts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "cash_accounts_select" ON public.cash_accounts
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "cash_accounts_insert" ON public.cash_accounts
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "cash_accounts_update" ON public.cash_accounts
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "cash_accounts_delete" ON public.cash_accounts
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE TRIGGER cash_accounts_updated_at
BEFORE UPDATE ON public.cash_accounts
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- ============================================================
-- Backfill from bank_connections.accounts_data
-- ============================================================
--
-- For each enabled StoredAccount in accounts_data, insert a cash_accounts row.
-- Currency defaults to SEK; ledger_account defaults to 1930. ON CONFLICT DO
-- NOTHING means re-running the migration is safe — and the (company_id,
-- ledger_account) conflict naturally collapses duplicates that share a BAS code
-- (older data sometimes had two SEK accounts both pointing to 1930; we keep
-- the first one and the user can re-map later via the AccountPicker).
INSERT INTO public.cash_accounts (
company_id, bank_connection_id, external_uid, iban, name,
currency, ledger_account, balance, balance_updated_at, enabled, source
)
SELECT
bc.company_id,
bc.id,
elem->>'uid',
elem->>'iban',
elem->>'name',
COALESCE(UPPER(elem->>'currency'), 'SEK'),
COALESCE(elem->>'ledger_account', '1930'),
NULLIF(elem->>'balance', '')::NUMERIC,
NULLIF(elem->>'balance_updated_at', '')::TIMESTAMPTZ,
COALESCE((elem->>'enabled')::BOOLEAN, true),
'enable_banking'
FROM public.bank_connections bc,
LATERAL jsonb_array_elements(COALESCE(bc.accounts_data, '[]'::jsonb)) AS elem
WHERE bc.company_id IS NOT NULL
AND elem->>'uid' IS NOT NULL
ON CONFLICT (company_id, ledger_account) DO NOTHING;
-- First SEK row per company becomes primary. Falls back to the oldest cash
-- account of any currency if no SEK row exists. The skattekonto resolver and
-- transfer-pairing will use this row when the __PRIMARY_SEK__ sentinel is hit.
WITH first_sek AS (
SELECT DISTINCT ON (company_id) id
FROM public.cash_accounts
WHERE currency = 'SEK'
ORDER BY company_id, created_at, id
)
UPDATE public.cash_accounts ca
SET is_primary = true
WHERE ca.id IN (SELECT id FROM first_sek);
-- Companies with no SEK account: pick any account as primary so the sentinel
-- always resolves. Edge case (manual cash-on-hand only, etc.).
WITH primary_missing AS (
SELECT c.id AS company_id
FROM public.companies c
WHERE NOT EXISTS (
SELECT 1 FROM public.cash_accounts ca
WHERE ca.company_id = c.id AND ca.is_primary = true
)
AND EXISTS (
SELECT 1 FROM public.cash_accounts ca
WHERE ca.company_id = c.id
)
), first_any AS (
SELECT DISTINCT ON (ca.company_id) ca.id
FROM public.cash_accounts ca
JOIN primary_missing pm ON pm.company_id = ca.company_id
ORDER BY ca.company_id, ca.created_at, ca.id
)
UPDATE public.cash_accounts ca
SET is_primary = true
WHERE ca.id IN (SELECT id FROM first_any);
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,72 @@
-- Migration: parametrise the unmatched-GL-lines RPC so reconciliation works on
-- any settlement account, not just 1930.
--
-- Why: until now `get_unlinked_1930_lines` hardcoded the bank-side BAS account.
-- EUR/USD customers can't self-reconcile their 1932/1933 accounts and the
-- skattekonto, payment-provider clearing, and BG/PG flows have no path. Now that
-- cash_accounts exists, the RPC accepts an account number parameter and the UI
-- can offer an account selector populated from that table.
--
-- The old function is dropped (no backwards-compatibility shim): the two
-- in-repo callers (lib/reconciliation/bank-reconciliation.ts and
-- app/api/reconciliation/bank/unmatched-entries/route.ts) are updated in the
-- same PR. Anyone calling the RPC over the REST surface must switch to the new
-- name + parameter at the same time.
DROP FUNCTION IF EXISTS public.get_unlinked_1930_lines(uuid, date, date);
CREATE FUNCTION public.get_unlinked_gl_lines(
p_company_id UUID,
p_account_number TEXT DEFAULT '1930',
p_date_from DATE DEFAULT NULL,
p_date_to DATE DEFAULT NULL
)
RETURNS TABLE (
line_id UUID,
journal_entry_id UUID,
debit_amount NUMERIC,
credit_amount NUMERIC,
line_description TEXT,
entry_date DATE,
voucher_number INT,
voucher_series TEXT,
entry_description TEXT,
source_type TEXT
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
SELECT
jel.id AS line_id,
je.id AS journal_entry_id,
jel.debit_amount,
jel.credit_amount,
jel.line_description,
je.entry_date,
je.voucher_number,
je.voucher_series,
je.description AS entry_description,
je.source_type
FROM public.journal_entry_lines jel
JOIN public.journal_entries je ON je.id = jel.journal_entry_id
WHERE jel.account_number = p_account_number
AND je.company_id = p_company_id
AND je.status = 'posted'
-- IB lines never have a counterpart in the bank feed — the bank statement
-- starts at IB and accumulates from there. Keep them excluded from the
-- unmatched set so reconciliation doesn't surface a phantom voucher.
AND je.source_type IS DISTINCT FROM 'opening_balance'
AND (p_date_from IS NULL OR je.entry_date >= p_date_from)
AND (p_date_to IS NULL OR je.entry_date <= p_date_to)
AND NOT EXISTS (
SELECT 1
FROM public.transactions t
WHERE t.journal_entry_id = je.id
AND t.company_id = p_company_id
)
ORDER BY je.entry_date, je.voucher_number;
$$;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,23 @@
-- Migration: capture counterparty IBAN on transactions for transfer-pairing.
--
-- Why: PSD2 returns the creditor (outflow) or debtor (inflow) account IBAN on
-- every booked transaction, but gnubok hasn't persisted it. Without an IBAN
-- column, intra-account transfers ("move money from SEK 1930 to EUR 1932")
-- can't be auto-detected and end up double-categorized.
--
-- The new column is nullable: SIE imports, manual entries, and pre-existing
-- rows have no IBAN. counterparty_account stays as a fallback for Bankgiro /
-- Plusgiro identifiers when IBAN is absent (Swedish domestic transfers).
--
-- The partial index supports the own-account detector's primary lookup:
-- SELECT ... FROM transactions WHERE company_id = ? AND counterparty_iban = ?
ALTER TABLE public.transactions
ADD COLUMN IF NOT EXISTS counterparty_iban TEXT,
ADD COLUMN IF NOT EXISTS counterparty_account TEXT;
CREATE INDEX IF NOT EXISTS idx_transactions_counterparty_iban
ON public.transactions (company_id, counterparty_iban)
WHERE counterparty_iban IS NOT NULL;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,103 @@
-- Migration: seed default 1930 SEK cash_account for every company
--
-- Why this exists: the cash_accounts backfill in 20260519110000_cash_accounts.sql
-- only seeded rows from existing bank_connections. Companies that haven't
-- connected a bank via PSD2 yet, or that do their books from SIE imports + manual
-- entries only, end up with zero cash_accounts rows. Combined with the
-- ownership check on /api/reconciliation/bank/{run,unmatched-entries,status},
-- that means reconciliation became unreachable for those companies the moment we
-- removed the '1930' bypass.
--
-- Two paths covered:
-- 1. Backfill — insert (company_id, '1930', 'SEK', is_primary=true if no other
-- primary exists) for every company missing a 1930 row. Idempotent via
-- the (company_id, ledger_account) unique constraint.
-- 2. Forward-fill — extend public.create_company_with_owner() to seed the
-- same default row at company creation. Source 'manual' so it's clear
-- this isn't a PSD2-backed account; the user can re-map or remove it via
-- AccountPicker once a PSD2 connection is established.
--
-- Compliance: removes the last hidden bypass in the reconciliation routes that
-- the PR review (ASVS V8.2.1, ISO 27001:2022 A.8.3, SOC 2 CC6.6) flagged as
-- inconsistent authorization across cash accounts.
-- 1. Backfill: every company without a 1930 row gets one.
INSERT INTO public.cash_accounts (
company_id, ledger_account, currency, name, enabled, is_primary, source
)
SELECT
c.id,
'1930',
'SEK',
'Företagskonto (SEK)',
true,
-- Only flag as primary if no other primary exists for this company.
NOT EXISTS (
SELECT 1 FROM public.cash_accounts ca2
WHERE ca2.company_id = c.id AND ca2.is_primary = true
),
'manual'
FROM public.companies c
WHERE NOT EXISTS (
SELECT 1 FROM public.cash_accounts ca
WHERE ca.company_id = c.id AND ca.ledger_account = '1930'
)
ON CONFLICT (company_id, ledger_account) DO NOTHING;
-- 2. Forward-fill: bake the seed into company creation so the reconciliation
-- ownership check is always satisfiable.
CREATE OR REPLACE FUNCTION public.create_company_with_owner(
p_name text,
p_entity_type text,
p_set_active boolean DEFAULT true
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_user_id uuid;
v_company_id uuid;
BEGIN
v_user_id := auth.uid();
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
IF p_entity_type NOT IN ('enskild_firma', 'aktiebolag') THEN
RAISE EXCEPTION 'Invalid entity_type: %', p_entity_type;
END IF;
INSERT INTO public.companies (name, entity_type, created_by)
VALUES (p_name, p_entity_type, v_user_id)
RETURNING id INTO v_company_id;
INSERT INTO public.company_members (company_id, user_id, role)
VALUES (v_company_id, v_user_id, 'owner');
-- Seed default 1930 SEK cash account so reconciliation routes work before
-- any PSD2 connection is established. is_primary so the __PRIMARY_SEK__
-- sentinel in skattekonto-booking resolves on day one.
INSERT INTO public.cash_accounts (
company_id, ledger_account, currency, name, enabled, is_primary, source
)
VALUES (
v_company_id, '1930', 'SEK', 'Företagskonto (SEK)', true, true, 'manual'
)
ON CONFLICT (company_id, ledger_account) DO NOTHING;
IF p_set_active THEN
INSERT INTO public.user_preferences (user_id, active_company_id)
VALUES (v_user_id, v_company_id)
ON CONFLICT (user_id)
DO UPDATE SET active_company_id = EXCLUDED.active_company_id;
END IF;
RETURN v_company_id;
END;
$$;
GRANT EXECUTE ON FUNCTION public.create_company_with_owner(text, text, boolean) TO authenticated;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,56 @@
-- Migration: atomic set_cash_account_primary RPC
--
-- Why this exists: lib/cash-accounts/service.ts#setPrimary() previously did two
-- separate UPDATEs (clear old primary, then set new primary). Between the two
-- round-trips no row carries is_primary = true. If skattekonto-booking runs
-- in that window and encounters a rule whose counter_account is '__PRIMARY_SEK__',
-- getPrimary() returns null and the booking either falls back to '1930'
-- (potentially wrong) or fails outright.
--
-- This RPC wraps both updates in a single BEGIN ... COMMIT so the
-- intermediate "no primary" state is invisible to concurrent readers.
-- SECURITY INVOKER so RLS still scopes which rows the caller may touch.
--
-- Compliance: addresses the P1 "Non-atomic setPrimary" review finding.
CREATE OR REPLACE FUNCTION public.set_cash_account_primary(
p_company_id uuid,
p_cash_account_id uuid
)
RETURNS void
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path = public
AS $$
BEGIN
-- Guard: refuse to set primary on an account that doesn't exist or that
-- belongs to a different company. RLS covers the second case but we want a
-- clean error rather than a no-op silent UPDATE.
IF NOT EXISTS (
SELECT 1 FROM public.cash_accounts
WHERE id = p_cash_account_id AND company_id = p_company_id
) THEN
RAISE EXCEPTION 'cash_account not found for company';
END IF;
-- Both updates execute in the same statement-level transaction. The partial
-- unique index idx_cash_accounts_one_primary_per_company is deferred to
-- transaction commit only if explicitly deferred; UPDATE order matters and
-- the index is non-deferrable today. Postgres still evaluates uniqueness
-- at statement boundaries within the function body, so we clear first.
UPDATE public.cash_accounts
SET is_primary = false
WHERE company_id = p_company_id
AND is_primary = true
AND id <> p_cash_account_id;
UPDATE public.cash_accounts
SET is_primary = true
WHERE company_id = p_company_id
AND id = p_cash_account_id;
END;
$$;
GRANT EXECUTE ON FUNCTION public.set_cash_account_primary(uuid, uuid) TO authenticated;
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,29 @@
-- Migration: fix arbetsgivaravgifter mapping 2731 -> 2730
--
-- Why this exists: the initial seed in 20260519100000_skattekonto_rules.sql
-- routed the AGI / arbetsgivaravgifter / sociala avgifter SKV pattern to 2731
-- (Avräkning för arbetsgivaravgifter / accrued liability). The Swedish payroll
-- skill (BAS-praxis) is unambiguous:
-- * 7510 Lagstadgade sociala avgifter (kostnad) → debited monthly together
-- with 2730 Lagstadgade sociala avgifter (skuld) on the credit side.
-- * 2730 is the redovisningskonto (the running clearing account) that gets
-- debited when SKV draws the amount from the skattekonto.
-- * 2731 is the period-end accrual posting target (interimsskuld), used
-- for the year-end semesterlöneskuld / accrued-but-not-paid leg.
--
-- Routing the AGI payment-clearing leg to 2731 leaves a permanent unexplained
-- balance on 2730 after each AGI month closes because the accrual leg never
-- gets cleared. Per swedish-payroll the correct counter-account for the
-- skattekonto AGI debit is 2730.
--
-- Compliance: addresses the Swedish accounting compliance review finding
-- "Arbetsgivaravgifter mapped to 2731, not 2730".
UPDATE public.skattekonto_rules
SET counter_account = '2730',
updated_at = now()
WHERE company_id IS NULL
AND counter_account = '2731'
AND pattern = 'arbetsgivaravgift,sociala avgifter,agi';
NOTIFY pgrst, 'reload schema';