From 663e1232a4a95057099aa17054892dd336f6506a Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:47:42 +0200 Subject: [PATCH] fix(bookkeeping): push grundbok series filter into the RPC so pagination total is honest (#798) (#823) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verifikationslista filtered by voucher series in the API route, AFTER the list_fiscal_period_entries_with_related RPC had already paged — recomputing `count = entries.length` from the current page only. That clobbered the RPC's window-function total_count, so whenever a user filtered the grundbok by series the paginator's total collapsed to the matching subset of one page and 'next page' disabled early — the list appeared to stop after ~one page. - New migration adds p_series to the RPC and applies the filter inside the matching CTE, so COUNT(*) OVER () reflects the filtered set. DROP+CREATE (a new param changes the function identity); p_series DEFAULT NULL keeps existing callers unaffected. Re-GRANTs EXECUTE to authenticated to match the original definition, since DROP+CREATE drops prior grants. - The route passes p_series and drops the post-filter recompute; entries and count now come straight from the RPC. - Extends list-filters.pg.test.ts: series=B over a 2A/3B period returns the 3 B entries with total_count=3, and a short page (limit 2) still reports total 3 — the regression that broke pagination. Note: hosted DB does not auto-apply migrations on merge — apply 20260629160000 to prod after merge. Co-authored-by: Claude Opus 4.8 (1M context) --- .../__tests__/list-filters.pg.test.ts | 66 +++++++- app/api/bookkeeping/journal-entries/route.ts | 17 +- ...000_journal_entries_list_series_filter.sql | 149 ++++++++++++++++++ 3 files changed, 213 insertions(+), 19 deletions(-) create mode 100644 supabase/migrations/20260629160000_journal_entries_list_series_filter.sql diff --git a/app/api/bookkeeping/journal-entries/__tests__/list-filters.pg.test.ts b/app/api/bookkeeping/journal-entries/__tests__/list-filters.pg.test.ts index 504b872a..159ec8c0 100644 --- a/app/api/bookkeeping/journal-entries/__tests__/list-filters.pg.test.ts +++ b/app/api/bookkeeping/journal-entries/__tests__/list-filters.pg.test.ts @@ -3,11 +3,15 @@ import { describe, expect, it } from 'vitest' import { getPool } from '@/tests/pg/setup' import { seedCompany, insertBalancedLines } from '@/tests/pg/fixtures' -// Covers the p_exclude_draft / p_collapse_corrections params added to -// list_fiscal_period_entries_with_related (migration 20260621130500). +// Covers the p_exclude_draft / p_collapse_corrections / p_series params on +// list_fiscal_period_entries_with_related (migrations 20260621130500 + +// 20260629160000). // - exclude_draft: drafts kept off the committed list (own "Utkast" surface). // - collapse_corrections: a correction group renders as ONE row — the live // correction; the storno and the reversed original it replaced are hidden. +// - series: voucher-series filter; pushed into the RPC so total_count reflects +// the filtered set (the route used to post-filter and recompute count from +// one page, breaking pagination — #798). // total_count must stay in lockstep with the filtered set so pagination holds. describe('list_fiscal_period_entries_with_related: draft + correction filters', () => { // Insert a journal_entry directly so we can set the storno/correction link @@ -21,6 +25,7 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters', sourceType: string voucherNumber: number description: string + voucherSeries?: string reversesId?: string correctionOfId?: string withLines?: boolean @@ -30,7 +35,7 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters', `INSERT INTO public.journal_entries (id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series, entry_date, description, source_type, status, reverses_id, correction_of_id) - VALUES ($1,$2,$3,$4,$5,'A','2026-06-01',$6,$7,$8,$9,$10)`, + VALUES ($1,$2,$3,$4,$5,$11,'2026-06-01',$6,$7,$8,$9,$10)`, [ id, p.userId, @@ -42,6 +47,7 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters', p.status, p.reversesId ?? null, p.correctionOfId ?? null, + p.voucherSeries ?? 'A', ], ) if (p.withLines) await insertBalancedLines(id) @@ -51,13 +57,30 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters', async function callRpc( companyId: string, periodId: string, - opts: { status?: string | null; excludeDraft?: boolean; collapse?: boolean } = {}, + opts: { + status?: string | null + excludeDraft?: boolean + collapse?: boolean + series?: string | null + limit?: number + } = {}, ) { - const { rows } = await getPool().query<{ entry: { id: string }; total_count: string }>( + const { rows } = await getPool().query<{ + entry: { id: string; voucher_series: string } + total_count: string + }>( `SELECT entry, total_count FROM list_fiscal_period_entries_with_related( - $1, $2, true, $3, NULL, NULL, 'desc', 100, 0, $4, $5)`, - [companyId, periodId, opts.status ?? null, opts.excludeDraft ?? false, opts.collapse ?? false], + $1, $2, true, $3, NULL, NULL, 'desc', $6, 0, $4, $5, $7)`, + [ + companyId, + periodId, + opts.status ?? null, + opts.excludeDraft ?? false, + opts.collapse ?? false, + opts.limit ?? 100, + opts.series ?? null, + ], ) return rows } @@ -97,4 +120,33 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters', const rows = await callRpc(companyId, fiscalPeriodId, { status: 'draft', excludeDraft: true }) expect(rows.map((r) => r.entry.id)).toEqual([draft]) }) + + it('filters by voucher series with a total_count over the filtered set (#798)', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + + // 2 entries in series A, 3 in series B. + for (const n of [1, 2]) { + await insertEntry({ userId, companyId, fiscalPeriodId, status: 'posted', sourceType: 'manual', voucherNumber: n, voucherSeries: 'A', withLines: true, description: `A${n}` }) + } + for (const n of [1, 2, 3]) { + await insertEntry({ userId, companyId, fiscalPeriodId, status: 'posted', sourceType: 'manual', voucherNumber: n, voucherSeries: 'B', withLines: true, description: `B${n}` }) + } + + // No series filter: all 5. + const all = await callRpc(companyId, fiscalPeriodId, {}) + expect(Number(all[0]!.total_count)).toBe(5) + + // series=B returns only the 3 B entries, and total_count is the filtered 3. + const seriesB = await callRpc(companyId, fiscalPeriodId, { series: 'B' }) + expect(seriesB).toHaveLength(3) + expect(seriesB.every((r) => r.entry.voucher_series === 'B')).toBe(true) + expect(Number(seriesB[0]!.total_count)).toBe(3) + + // The #798 regression: with a page smaller than the filtered set, the page + // truncates but total_count still reports the full filtered count (3) — the + // paginator can reach every B entry instead of stopping after one page. + const firstPage = await callRpc(companyId, fiscalPeriodId, { series: 'B', limit: 2 }) + expect(firstPage).toHaveLength(2) + expect(Number(firstPage[0]!.total_count)).toBe(3) + }) }) diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index a0295fa8..36e3398c 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -88,6 +88,9 @@ export async function GET(request: Request) { p_offset: offset, p_exclude_draft: excludeDraft, p_collapse_corrections: collapseCorrections, + // Series filter lives in the RPC now (#798): filtering here after the RPC + // paged would recompute count from one page only, breaking pagination. + p_series: seriesFilter, }) if (error) { @@ -95,18 +98,8 @@ export async function GET(request: Request) { } const rows = data ?? [] - let entries = rows.map((r: { entry: unknown }) => r.entry) as Array<{ voucher_series?: string }> - let count = rows.length > 0 ? Number((rows[0] as { total_count: number | string }).total_count) : 0 - - // The list_fiscal_period_entries_with_related RPC doesn't accept a series - // filter, so post-filter here. Recompute count from the filtered set so - // the paginator stays consistent; consequence: when a series filter is - // applied, the cross-period follow-up surfacing is still on but the - // visible total drops to the matching subset. - if (seriesFilter) { - entries = entries.filter((e) => (e?.voucher_series ?? 'A') === seriesFilter) - count = entries.length - } + const entries = rows.map((r: { entry: unknown }) => r.entry) + const count = rows.length > 0 ? Number((rows[0] as { total_count: number | string }).total_count) : 0 return NextResponse.json({ data: entries, count }) } diff --git a/supabase/migrations/20260629160000_journal_entries_list_series_filter.sql b/supabase/migrations/20260629160000_journal_entries_list_series_filter.sql new file mode 100644 index 00000000..75f20209 --- /dev/null +++ b/supabase/migrations/20260629160000_journal_entries_list_series_filter.sql @@ -0,0 +1,149 @@ +-- Verifikationslista: push the voucher-series filter into the RPC. +-- +-- The grundbok view filtered by voucher series in the API route, AFTER the RPC +-- returned a page — recomputing `count = entries.length` from the current page +-- only (app/api/bookkeeping/journal-entries/route.ts). That clobbered the RPC's +-- window-function `total_count`, so whenever a user filtered by series the +-- paginator's total collapsed to the matching subset of one page and "next +-- page" disabled early — the list appeared to stop after ~one page (#798). +-- +-- Fix: add p_series so the filter lives inside the `matching` CTE and +-- `COUNT(*) OVER ()` is computed on the filtered set. The route then drops its +-- post-filter recompute and trusts the RPC's total_count. +-- +-- Adding a parameter changes the function identity, so DROP the current 11-arg +-- signature first (CREATE OR REPLACE cannot add params) — otherwise PostgREST +-- sees two overloads. p_series defaults to NULL, so existing callers are +-- unaffected. + +DROP FUNCTION IF EXISTS public.list_fiscal_period_entries_with_related( + uuid, uuid, boolean, text, date, date, text, int, int, boolean, boolean +); + +CREATE FUNCTION public.list_fiscal_period_entries_with_related( + p_company_id uuid, + p_period_id uuid, + p_include_related boolean DEFAULT true, + p_status text DEFAULT NULL, + p_date_from date DEFAULT NULL, + p_date_to date DEFAULT NULL, + p_sort_date text DEFAULT 'desc', + p_limit int DEFAULT 50, + p_offset int DEFAULT 0, + p_exclude_draft boolean DEFAULT false, + p_collapse_corrections boolean DEFAULT false, + p_series text DEFAULT NULL +) +RETURNS TABLE ( + entry jsonb, + total_count bigint +) +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path = public, pg_temp +AS $$ + WITH period AS ( + SELECT period_start, period_end + FROM public.fiscal_periods + WHERE id = p_period_id AND company_id = p_company_id + ), + matching AS ( + SELECT je.* + FROM public.journal_entries je + CROSS JOIN period p + WHERE je.company_id = p_company_id + AND ( + je.fiscal_period_id = p_period_id + OR ( + p_include_related + AND je.source_type IN ('invoice_paid','invoice_cash_payment','credit_note') + AND EXISTS ( + SELECT 1 FROM public.invoices i + WHERE i.id = je.source_id + AND i.company_id = p_company_id + AND i.invoice_date BETWEEN p.period_start AND p.period_end + ) + ) + OR ( + p_include_related + AND je.source_type IN ('supplier_invoice_paid','supplier_invoice_cash_payment','supplier_credit_note') + AND EXISTS ( + SELECT 1 FROM public.supplier_invoices si + WHERE si.id = je.source_id + AND si.company_id = p_company_id + AND si.invoice_date BETWEEN p.period_start AND p.period_end + ) + ) + ) + AND (p_status IS NULL OR je.status = p_status) + -- Hide cancelled by default; show them only when caller asks explicitly. + AND (je.status <> 'cancelled' OR p_status = 'cancelled') + -- Voucher-series filter (single letter A–Z). Inside the CTE so total_count + -- below reflects the filtered set — this is the #798 fix. + AND (p_series IS NULL OR je.voucher_series = p_series) + -- Drafts live on their own surface; exclude them only on the committed + -- list. Ignored when the caller asked for an explicit status (so a + -- status='draft' request is never self-cancelled) — mirrors the route's + -- direct-query path. + AND (NOT p_exclude_draft OR p_status IS NOT NULL OR je.status <> 'draft') + -- Collapse correction groups to the live correction: drop the storno and + -- the reversed original that a posted correction replaced. + AND ( + NOT p_collapse_corrections + OR ( + je.source_type <> 'storno' + AND NOT EXISTS ( + SELECT 1 FROM public.journal_entries c + WHERE c.company_id = p_company_id + AND c.source_type = 'correction' + AND c.status = 'posted' + AND c.correction_of_id = je.id + ) + ) + ) + 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) + ), + matching_with_total AS ( + SELECT m.*, COUNT(*) OVER () AS total + FROM matching m + ), + paged AS ( + SELECT * + FROM matching_with_total + ORDER BY + CASE WHEN p_sort_date = 'asc' THEN entry_date END ASC NULLS LAST, + CASE WHEN p_sort_date = 'desc' THEN entry_date END DESC NULLS LAST, + voucher_series, + voucher_number + LIMIT p_limit OFFSET p_offset + ) + SELECT + (to_jsonb(p.*) - 'total') + || jsonb_build_object( + 'lines', COALESCE( + (SELECT jsonb_agg(to_jsonb(l.*) ORDER BY l.sort_order) + FROM public.journal_entry_lines l + WHERE l.journal_entry_id = p.id), + '[]'::jsonb + ), + 'out_of_period', (p.fiscal_period_id IS DISTINCT FROM p_period_id) + ) AS entry, + p.total AS total_count + FROM paged p + ORDER BY + CASE WHEN p_sort_date = 'asc' THEN p.entry_date END ASC NULLS LAST, + CASE WHEN p_sort_date = 'desc' THEN p.entry_date END DESC NULLS LAST, + p.voucher_series, + p.voucher_number; +$$; + +-- DROP+CREATE drops any prior GRANTs, so re-grant EXECUTE to authenticated to +-- match the original definition (20260421120000). (Supabase default privileges +-- also cover this, but be explicit so the grundbok can't lose access.) +GRANT EXECUTE ON FUNCTION public.list_fiscal_period_entries_with_related( + uuid, uuid, boolean, text, date, date, text, int, int, boolean, boolean, text +) TO authenticated; + +NOTIFY pgrst, 'reload schema';