When the worksheet selection (N bank rows vs one verifikat) misses by a few kronor, 'Bokför mellanskillnaden som Bankavgift / Räntekostnad / Ränteintäkt / Öresavrundning och koppla' books the remainder on 6570 / 8410 / 8310 / 3740 against the bank account, links the rows to the main verifikat and anchors the residual verifikat through transaction_voucher_links. Bank accounts only (Skatteverket posts ränta and avgifter as rows of their own), capped at 5 000 kr, direction-checked against the kind; links are made first and undone if the booking is refused. Dashboard + v1 doors (transactions:write, Idempotency-Key, dry run), API skill regenerated. The bridge now treats transaction_voucher_links as links on both sides: migration 20260824190000 re-creates get_unlinked_gl_lines and get_account_gl_lines_for_matching to count junction-linked verifikat as matched (pg-real test), and the TS engine + items do the same for the transactions. This also stops bulk-booked samlingsverifikat from polluting the open buckets. 'Koppla bort' drops the junction rows too. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
193 lines
7.4 KiB
PL/PgSQL
193 lines
7.4 KiB
PL/PgSQL
-- The bank reconciliation's ledger side treats a verifikat as "matched" only
|
|
-- when a transactions row points at it (transactions.journal_entry_id). A
|
|
-- verifikat settled through transaction_voucher_links (a samlingsverifikation
|
|
-- from bulk-book, or a residual booking on the Avstämning page) carries no
|
|
-- such pointer: its rows have journal_entry_id NULL and the link lives in the
|
|
-- junction. Both GL RPCs therefore listed those verifikat as "utan
|
|
-- banktransaktion" while the TS side listed their transactions as unmatched:
|
|
-- the bridge still netted to zero, but the open buckets were polluted and a
|
|
-- residual booking could never disappear from the worksheet.
|
|
--
|
|
-- This re-creates get_unlinked_gl_lines and get_account_gl_lines_for_matching
|
|
-- with the junction counted as a link, exactly as is_transaction_booked()
|
|
-- (20260529120000) already does for the inbox. Signatures, tenant guards and
|
|
-- grants are unchanged. The TS twin is fetchJunctionLinkedTxIds in
|
|
-- lib/reconciliation/bank-reconciliation.ts.
|
|
|
|
CREATE OR REPLACE 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'
|
|
AND je.source_type IS DISTINCT FROM 'opening_balance'
|
|
AND je.source_type IS DISTINCT FROM 'storno'
|
|
AND je.source_type IS DISTINCT FROM 'correction'
|
|
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
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM public.transaction_voucher_links l
|
|
WHERE l.journal_entry_id = je.id
|
|
AND l.company_id = p_company_id
|
|
)
|
|
-- Tenant guard: anon/authenticated may only read their own companies;
|
|
-- service_role and direct/superuser access (no JWT role) bypass.
|
|
AND (
|
|
coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '')
|
|
NOT IN ('anon', 'authenticated')
|
|
OR je.company_id IN (SELECT public.user_company_ids())
|
|
)
|
|
ORDER BY je.entry_date, je.voucher_number;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION public.get_account_gl_lines_for_matching(
|
|
p_company_id UUID,
|
|
p_account_number TEXT DEFAULT '1930',
|
|
p_date_from DATE DEFAULT NULL,
|
|
p_date_to DATE DEFAULT NULL,
|
|
p_include_matched BOOLEAN DEFAULT false
|
|
)
|
|
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,
|
|
linked_transaction_count INT
|
|
)
|
|
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,
|
|
-- Account-scoped: a transaction provably on ANOTHER cash account (its
|
|
-- cash_accounts row resolves to a different ledger_account) does not make
|
|
-- this voucher "matched" for p_account_number. A NULL / unresolvable cash
|
|
-- account keeps counting for every account (conservative legacy behavior).
|
|
-- Junction-linked transactions count exactly like pointer-linked ones.
|
|
(
|
|
(
|
|
SELECT count(*)
|
|
FROM public.transactions t
|
|
LEFT JOIN public.cash_accounts ca ON ca.id = t.cash_account_id
|
|
WHERE t.journal_entry_id = je.id
|
|
AND t.company_id = p_company_id
|
|
AND (ca.ledger_account IS NULL OR ca.ledger_account = p_account_number)
|
|
) + (
|
|
SELECT count(*)
|
|
FROM public.transaction_voucher_links l
|
|
JOIN public.transactions t ON t.id = l.transaction_id
|
|
LEFT JOIN public.cash_accounts ca ON ca.id = t.cash_account_id
|
|
WHERE l.journal_entry_id = je.id
|
|
AND l.company_id = p_company_id
|
|
AND t.journal_entry_id IS DISTINCT FROM je.id
|
|
AND (ca.ledger_account IS NULL OR ca.ledger_account = p_account_number)
|
|
)
|
|
)::int AS linked_transaction_count
|
|
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'
|
|
AND je.source_type IS DISTINCT FROM 'opening_balance'
|
|
AND je.source_type IS DISTINCT FROM 'storno'
|
|
AND je.source_type IS DISTINCT FROM 'correction'
|
|
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 (
|
|
p_include_matched
|
|
OR (
|
|
NOT EXISTS (
|
|
SELECT 1
|
|
FROM public.transactions t
|
|
LEFT JOIN public.cash_accounts ca ON ca.id = t.cash_account_id
|
|
WHERE t.journal_entry_id = je.id
|
|
AND t.company_id = p_company_id
|
|
AND (ca.ledger_account IS NULL OR ca.ledger_account = p_account_number)
|
|
)
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM public.transaction_voucher_links l
|
|
JOIN public.transactions t ON t.id = l.transaction_id
|
|
LEFT JOIN public.cash_accounts ca ON ca.id = t.cash_account_id
|
|
WHERE l.journal_entry_id = je.id
|
|
AND l.company_id = p_company_id
|
|
AND (ca.ledger_account IS NULL OR ca.ledger_account = p_account_number)
|
|
)
|
|
)
|
|
)
|
|
-- Tenant guard: anon/authenticated may only read their own companies;
|
|
-- service_role and direct/superuser access (no JWT role) bypass.
|
|
AND (
|
|
coalesce(nullif(current_setting('request.jwt.claims', true), '')::jsonb ->> 'role', '')
|
|
NOT IN ('anon', 'authenticated')
|
|
OR je.company_id IN (SELECT public.user_company_ids())
|
|
)
|
|
ORDER BY je.entry_date, je.voucher_number;
|
|
$$;
|
|
|
|
-- CREATE OR REPLACE preserves the ACL; re-assert least privilege so this
|
|
-- migration stands alone on a fresh replay (20260611130000).
|
|
REVOKE EXECUTE ON FUNCTION public.get_unlinked_gl_lines(uuid, text, date, date) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.get_unlinked_gl_lines(uuid, text, date, date) TO authenticated, service_role;
|
|
REVOKE EXECUTE ON FUNCTION public.get_account_gl_lines_for_matching(uuid, text, date, date, boolean) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.get_account_gl_lines_for_matching(uuid, text, date, date, boolean) TO authenticated, service_role;
|
|
|
|
NOTIFY pgrst, 'reload schema';
|