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:
@@ -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';
|
||||
Reference in New Issue
Block a user