Files
accounted/supabase/migrations/20260519110000_cash_accounts.sql
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

151 lines
6.8 KiB
SQL

-- 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';