Files
accounted/supabase/migrations/20260723180000_kpi_report_aggregates_rpc.sql
T
MattssonandClaude Fable 5 288915c152 Fix/fdb fr usrs (#1125)
* fix(invoices): return attachment filename in delivery history summaries

The 20260723003000 hardening dropped attachment_filename from
list_invoice_delivery_summaries, so the delivery history UI always fell
back to the generic "faktura.pdf" label. Recreate the RPC with the
filename included: it is derived from company name, customer name,
invoice number, and date, all already visible to every company member,
so the minimization boundary is unchanged. Addresses stay masked and
message content, BCC, and checksums stay server-side.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(reconciliation): surface own-account transfer legs in match-to-voucher by default

The second (incoming) leg of a transfer between two of the company's own
bank accounts was hidden in the 'Matcha mot befintlig verifikation' dialog
because the voucher counted as 'already matched' once its outgoing leg was
linked, even though the incoming account's line had no settling transaction.
Users read the empty default list as 'the app won't let me link this'.

get_account_gl_lines_for_matching now counts links per settlement account:
a transaction provably on another cash account no longer marks the voucher
as matched for the requested account, so the unsettled transfer leg surfaces
by default (and auto-selects on an exact match). Same-account N:1 stays
behind the 'Visa aven matchade verifikationer' opt-in, and transactions
without a resolvable cash account conservatively keep counting everywhere.
get_unlinked_gl_lines is deliberately untouched (feeds auto-reconcile).

Companion guard: mark_entry_as_opening_balance now refuses entries with
linked bank transactions, since half-settled transfer vouchers became
reachable in the reconciliation view's unmatched table where 'Mark som IB'
renders; re-tagging one would strand its transaction against a movement-
excluded entry. getReconciliationStatus counts unmatched GL lines with the
account-scoped RPC so the status card agrees with the table.

Fixes #1026

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* perf(api): cut prod p95 latency via local JWT auth, single-RT company resolution, and report aggregate RPCs

Baseline 2026-07-23 (487 prod samples): p50 160ms, p95 480ms, 13% of
requests over 300ms. Target: p95 under 300ms.

- requireAuth: verify JWTs locally via getClaims (ES256/JWKS) instead of
  a second network getUser per request; getUser fallback keeps HS256
  self-hosted and existing test mocks working; middleware still
  revocation-checks every /api request
- resolve_active_company RPC (20260723161000): one round trip replaces
  2-3 queries in getActiveCompanyId and middleware; PGRST202/42501 fall
  back to the legacy query path
- arsredovisning build-data: ~33 sequential round trips down to ~7,
  output byte-identical (snapshot-proven)
- currency rate route: stop bypassing the exchange_rates cache (missing
  supabase arg caused an external Riksbanken call on every request)
- document.get: parallelize row fetch, signed URL and audit event
- list_company_accounts RPC (20260723170000): accounts list in one round
  trip instead of paging past PostgREST's 1000-row cap
- vat-declaration route: drop a dead sequential company_settings query
- get_kpi_report_aggregates RPC (20260723180000): KPI report's three
  full-period line scans collapsed into one aggregate call; dimension-
  filtered path unchanged
- lint: fix 9 baseline errors, downgrade 4 react-hooks compiler rules to
  warn, zero the eslint baseline ratchet

All four gates green: lint 0 errors, 9163 tests, check:guards, build.
Migrations applied idempotently to staging only; prod receives them via
Supabase branching on merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(review): resolve PR review findings across auth, VAT declaration, and IB retag

- requireAuth getClaims fast path: pin iss (project URL) and aud
  ('authenticated'), log every fallback to getUser (ASVS V9.1 finding)
- remove the ignored accountingMethod parameter from calculateVatDeclaration
  and the dead company_settings.accounting_method reads in xlsx/pdf/eskd
  routes; v1 API keeps accepting the query param but documents it as a no-op
- close the mark_entry_as_opening_balance TOCTOU race with a transactions
  trigger (20260723190000, FOR KEY SHARE on journal_entries) + pg tests;
  applied to staging and smoke-verified both directions
- re-add the 42501 tenant guard to branch-local migration 20260723160000
  (function body had silently reverted to the pre-20260619130100 definition)
- document the buildK3Noter tbFullRows full-TB contract (uppskjuten skatt
  opening balance per BFNAR 2012:1 ch.29)
- add KPI VAT-liability test covering reduced-rate output accounts 2621/2631

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(db): use NULL-safe caller_is_company_member in opening-balance retag guard

The re-added tenant guard carried the pre-20260703180000 raw
NOT IN (SELECT user_company_ids()) pattern, which the
null-safe-tenant-guards ratchet blocks. Staging re-synced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:16:55 +02:00

191 lines
7.4 KiB
PL/PgSQL

-- RPC: get_kpi_report_aggregates: one-round-trip aggregation for the KPI
-- report (app/api/reports/kpi).
--
-- WHY THIS MIGRATION EXISTS
-- -------------------------
-- The report.kpi handler used to scan every journal line of the fiscal
-- period THREE times through PostgREST and aggregate in JS:
-- 1. generateTrialBalance (unfiltered) for balance-side KPIs,
-- 2. generateIncomeStatement, which re-runs generateTrialBalance with
-- excludeYearEndClosing,
-- 3. generateMonthlyBreakdown with its own paginated line fetch.
-- Each scan pays sequential cross-region round trips (Vercel iad1 to
-- Supabase eu-north-1) per 1000-row page. One SQL pass now returns all
-- three aggregate shapes; the JS side only merges opening balances and
-- formats rows, so the payload is O(accounts + months), not O(lines).
--
-- SECTION SEMANTICS (each mirrors an existing TypeScript fetch; the
-- pg-real test pins them):
-- tb per-account debit/credit sums over the period's posted
-- and reversed entries, excluding the opening-balance
-- entry (p_ob_entry_id) so its values are not double
-- counted against the IB. Mirrors the period-lines fetch
-- in lib/reports/trial-balance.ts (no entry_date filter:
-- fiscal_period_id already bounds activity).
-- tb_ex_year_end same, additionally excluding every source_type
-- 'year_end' entry plus stornos/corrections of REVERSED
-- year-end entries. ye_reversed is COMPANY-WIDE (no
-- period filter), mirroring trial-balance.ts wave-1.
-- source_type is NOT NULL, so IS DISTINCT FROM matches
-- the PostgREST .neq() exactly.
-- ob per-account sums of the opening-balance entry's lines.
-- NO status filter, mirroring getOpeningBalances
-- (lib/reports/opening-balances.ts) which only checks
-- id + company_id. Empty when p_ob_entry_id IS NULL.
-- monthly income/expenses per calendar month over POSTED entries
-- only (lib/reports/monthly-breakdown.ts): class 3 =
-- income (credit-debit), classes 4-7 = expenses
-- (debit-credit), class 8 split PER LINE by sign of
-- credit-debit, and 8999 excluded entirely (the closing
-- account would cancel the income-vs-expense signal).
-- The OB entry and year_end entries are NOT excluded
-- here: the JS scan never excluded them either.
--
-- SECURITY INVOKER: journal_entries/journal_entry_lines RLS is
-- company-scoped via user_company_ids(), so the caller's own membership
-- bounds what is aggregated; a non-member calling with a foreign company
-- id gets empty sections, not an error. Service-role callers rely on the
-- explicit p_company_id filter.
--
-- pg-test: tests/pg/kpi-report-aggregates-rpc.pg.test.ts
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
JOIN period_entries e
ON e.id = l.journal_entry_id
AND e.status = 'posted'
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';