fix(reports): count reversed originals in the KPI monthly breakdown (#2201) (#2243)

The year total (tb / tb_ex_year_end) aggregates posted AND reversed
entries, so a same-year storno nets to 0. The monthly section, both the
get_kpi_report_aggregates RPC and the dimension-filtered JS fallback in
lib/reports/monthly-breakdown.ts, was posted-only: it dropped the reversed
original but kept the storno (itself posted). 10 000 kr on 3041 in March,
reversed in April, gave March 0 kr, April -10 000 kr, year 0 kr, and PR
#2198 made the per-month figures visible enough to add up.

Migration 20260903160000 replaces the RPC with the monthly join on
tb_ex_year_end's entry set verbatim (no extra status predicate); the JS
fallback filters status in ('posted','reversed') the same way. The pg-real
pin ("in tb, not in monthly") is flipped and a storno case asserts
sum(months) = net result. Everything else in the function is byte-identical
to 20260730090000.

Verified: pg-real suite against a rebuilt local supabase/postgres with every
migration applied (9 tests), unit suite, lint.

Closes #2201


Claude-Session: https://claude.ai/code/session_01VnConrmMCxJRQ5kfiPPWyy

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-03 18:21:00 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 3918ff6620
commit d69de86b71
4 changed files with 225 additions and 13 deletions
@@ -0,0 +1,162 @@
-- get_kpi_report_aggregates: let a same-year storno cancel inside the monthly
-- breakdown the way it already does in the year total.
--
-- The 'monthly' section joined tb_ex_ye_entries with an extra
-- `e.status = 'posted'` predicate. The year total (tb / tb_ex_year_end) has no
-- such predicate: it aggregates posted AND reversed entries, so a P&L entry
-- that was reversed by storno nets to 0 across the original and the storno.
-- Monthly dropped the reversed ORIGINAL but kept the storno (itself posted),
-- so the months no longer summed to Nettoresultat: 10 000 kr on 3041 in
-- March, reversed in April, gave March 0 kr and April -10 000 kr while the
-- year said 0 kr (issue #2201). PR #2198 put the exact per-month figures on
-- the Nyckeltal page, which made the gap visible to anyone adding them up.
--
-- Fix: the monthly section uses tb_ex_year_end's entry set verbatim (posted +
-- reversed, minus the undone year-end chain). lib/reports/monthly-breakdown.ts
-- (the dimension-filtered JS fallback serving the same chart) changes in the
-- same PR so the two paths keep agreeing. Everything else in the function is
-- byte-identical to 20260730090000.
CREATE OR REPLACE FUNCTION public.get_kpi_report_aggregates(
p_company_id uuid,
p_fiscal_period_id uuid,
p_ob_entry_id uuid DEFAULT NULL
)
RETURNS jsonb
LANGUAGE sql
STABLE
SECURITY INVOKER
SET search_path TO 'public'
AS $$
WITH period_entries AS (
SELECT id, entry_date, status, source_type, reverses_id, correction_of_id
FROM public.journal_entries
WHERE company_id = p_company_id
AND fiscal_period_id = p_fiscal_period_id
AND status IN ('posted', 'reversed')
),
tb_entries AS (
SELECT * FROM period_entries
WHERE p_ob_entry_id IS NULL OR id <> p_ob_entry_id
),
ye_reversed AS (
-- Company-wide (no period filter), mirroring the wave-1 fetch in
-- lib/reports/trial-balance.ts: a storno in this period can reverse a
-- year-end entry from another period.
SELECT id
FROM public.journal_entries
WHERE company_id = p_company_id
AND source_type = 'year_end'
AND status = 'reversed'
),
tb_ex_ye_entries AS (
SELECT * FROM tb_entries
WHERE source_type IS DISTINCT FROM 'year_end'
AND (reverses_id IS NULL
OR reverses_id NOT IN (SELECT id FROM ye_reversed))
AND (correction_of_id IS NULL
OR correction_of_id NOT IN (SELECT id FROM ye_reversed))
)
SELECT jsonb_build_object(
'tb', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'account_number', t.account_number,
'debit', t.debit,
'credit', t.credit
) ORDER BY t.account_number)
FROM (
SELECT l.account_number,
sum(l.debit_amount)::float8 AS debit,
sum(l.credit_amount)::float8 AS credit
FROM public.journal_entry_lines l
JOIN tb_entries e ON e.id = l.journal_entry_id
GROUP BY l.account_number
) t
), '[]'::jsonb),
'tb_ex_year_end', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'account_number', t.account_number,
'debit', t.debit,
'credit', t.credit
) ORDER BY t.account_number)
FROM (
SELECT l.account_number,
sum(l.debit_amount)::float8 AS debit,
sum(l.credit_amount)::float8 AS credit
FROM public.journal_entry_lines l
JOIN tb_ex_ye_entries e ON e.id = l.journal_entry_id
GROUP BY l.account_number
) t
), '[]'::jsonb),
'ob', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'account_number', t.account_number,
'debit', t.debit,
'credit', t.credit
) ORDER BY t.account_number)
FROM (
-- No status filter: getOpeningBalances only checks id + company_id.
SELECT l.account_number,
sum(l.debit_amount)::float8 AS debit,
sum(l.credit_amount)::float8 AS credit
FROM public.journal_entry_lines l
JOIN public.journal_entries e
ON e.id = l.journal_entry_id
AND e.id = p_ob_entry_id
AND e.company_id = p_company_id
GROUP BY l.account_number
) t
), '[]'::jsonb),
'monthly', COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'year', m.year,
'month', m.month,
'income', m.income,
'expenses', m.expenses
) ORDER BY m.year, m.month)
FROM (
SELECT EXTRACT(YEAR FROM e.entry_date)::int AS year,
EXTRACT(MONTH FROM e.entry_date)::int AS month,
(
COALESCE(sum(CASE
WHEN l.account_number ~ '^3'
THEN l.credit_amount - l.debit_amount
END), 0)
+ COALESCE(sum(CASE
WHEN l.account_number ~ '^8'
AND l.account_number <> '8999'
AND (l.credit_amount - l.debit_amount) >= 0
THEN l.credit_amount - l.debit_amount
END), 0)
)::float8 AS income,
(
COALESCE(sum(CASE
WHEN l.account_number ~ '^[4-7]'
THEN l.debit_amount - l.credit_amount
END), 0)
+ COALESCE(sum(CASE
WHEN l.account_number ~ '^8'
AND l.account_number <> '8999'
AND (l.credit_amount - l.debit_amount) < 0
THEN l.debit_amount - l.credit_amount
END), 0)
)::float8 AS expenses
FROM public.journal_entry_lines l
-- The same entry set as tb_ex_year_end, with NO extra status predicate:
-- posted AND reversed originals, minus the undone year-end chain. A
-- reversed original and its storno then cancel within the year exactly
-- as they do in the year total, so sum(months) = Nettoresultat. Matches
-- lib/reports/monthly-breakdown.ts.
JOIN tb_ex_ye_entries e
ON e.id = l.journal_entry_id
WHERE l.account_number ~ '^[3-8]'
GROUP BY 1, 2
) m
), '[]'::jsonb)
)
$$;
REVOKE ALL ON FUNCTION public.get_kpi_report_aggregates(uuid, uuid, uuid) FROM PUBLIC, anon;
GRANT EXECUTE ON FUNCTION public.get_kpi_report_aggregates(uuid, uuid, uuid) TO authenticated, service_role;
NOTIFY pgrst, 'reload schema';