fix(bookkeeping): push grundbok series filter into the RPC so pagination total is honest (#798) (#823)

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) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-29 21:47:42 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 4872c0f242
commit 663e1232a4
3 changed files with 213 additions and 19 deletions
@@ -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)
})
})
+5 -12
View File
@@ -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 })
}