From 90e4f668c90154d68f008bb4d6237fbcc092c3d5 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:13:39 +0200 Subject: [PATCH] fix(bookkeeping): tiebreak same-date vouchers in the date-sort direction (#1032) The verifikat list RPC ordered entry_date in the requested direction but always tiebroke voucher_series/voucher_number ascending, so under the default date-descending view every multi-voucher day read the wrong way (A10, A11, A12 inside a descending list). The RPC now flips the tiebreaker with p_sort_date, and the route's direct-query fallback gains the matching voucher_series tiebreak so both paths agree. Fixes #972 Co-authored-by: Claude Fable 5 --- .../__tests__/list-filters.pg.test.ts | 40 ++++- app/api/bookkeeping/journal-entries/route.ts | 3 + ...0_journal_entries_list_same_date_order.sql | 137 ++++++++++++++++++ 3 files changed, 177 insertions(+), 3 deletions(-) create mode 100644 supabase/migrations/20260716140000_journal_entries_list_same_date_order.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 b64f2142..fd3d2452 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 @@ -26,6 +26,7 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters', voucherNumber: number description: string voucherSeries?: string + entryDate?: string reversesId?: string correctionOfId?: string withLines?: boolean @@ -35,7 +36,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,$11,'2026-06-01',$6,$7,$8,$9,$10)`, + VALUES ($1,$2,$3,$4,$5,$11,$12,$6,$7,$8,$9,$10)`, [ id, p.userId, @@ -48,6 +49,7 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters', p.reversesId ?? null, p.correctionOfId ?? null, p.voucherSeries ?? 'A', + p.entryDate ?? '2026-06-01', ], ) if (p.withLines) await insertBalancedLines(id) @@ -63,15 +65,16 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters', collapse?: boolean series?: string | null limit?: number + sortDate?: 'asc' | 'desc' } = {}, ) { const { rows } = await getPool().query<{ - entry: { id: string; voucher_series: string } + entry: { id: string; voucher_series: string; voucher_number: number } total_count: string }>( `SELECT entry, total_count FROM list_fiscal_period_entries_with_related( - $1, $2, true, $3, NULL, NULL, 'desc', $6, 0, $4, $5, $7)`, + $1, $2, true, $3, NULL, NULL, $8, $6, 0, $4, $5, $7)`, [ companyId, periodId, @@ -80,6 +83,7 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters', opts.collapse ?? false, opts.limit ?? 100, opts.series ?? null, + opts.sortDate ?? 'desc', ], ) return rows @@ -149,4 +153,34 @@ describe('list_fiscal_period_entries_with_related: draft + correction filters', expect(firstPage).toHaveLength(2) expect(Number(firstPage[0]!.total_count)).toBe(3) }) + + it('tiebreaks same-date vouchers in the date-sort direction (#972)', async () => { + const { userId, companyId, fiscalPeriodId } = await seedCompany() + + // The exact scenario from #972: several days carry more than one voucher. + const spec: Array<[number, string]> = [ + [4, '2026-06-01'], + [5, '2026-06-07'], + [6, '2026-06-07'], + [7, '2026-06-07'], + [8, '2026-06-08'], + [9, '2026-06-09'], + [10, '2026-06-10'], + [11, '2026-06-10'], + [12, '2026-06-10'], + [13, '2026-06-11'], + ] + for (const [n, entryDate] of spec) { + await insertEntry({ userId, companyId, fiscalPeriodId, status: 'posted', sourceType: 'manual', voucherNumber: n, entryDate, withLines: true, description: `A${n}` }) + } + + // Date-descending (the default list view): same-date vouchers must also + // descend, so the whole column reads 13..4 with no zig-zag inside a day. + const desc = await callRpc(companyId, fiscalPeriodId, { sortDate: 'desc' }) + expect(desc.map((r) => r.entry.voucher_number)).toEqual([13, 12, 11, 10, 9, 8, 7, 6, 5, 4]) + + // Date-ascending: fully chronological registration order. + const asc = await callRpc(companyId, fiscalPeriodId, { sortDate: 'asc' }) + expect(asc.map((r) => r.entry.voucher_number)).toEqual([4, 5, 6, 7, 8, 9, 10, 11, 12, 13]) + }) }) diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts index 1c1dc0c6..7b884c26 100644 --- a/app/api/bookkeeping/journal-entries/route.ts +++ b/app/api/bookkeeping/journal-entries/route.ts @@ -109,8 +109,11 @@ export const GET = withRouteContext('bookkeeping.journal_entries.list', async (r .order('voucher_series', { ascending: voucherAscending }) .order('voucher_number', { ascending: voucherAscending }) } else if (sortDate === 'asc' || sortDate === 'desc' || sortBy === 'date_asc' || sortBy === 'date_desc') { + // Tiebreak same-date vouchers in the SAME direction as the date sort, and + // by series before number so the order matches the RPC path (#972). query = query .order('entry_date', { ascending: dateAscending }) + .order('voucher_series', { ascending: dateAscending }) .order('voucher_number', { ascending: dateAscending }) } else { query = query diff --git a/supabase/migrations/20260716140000_journal_entries_list_same_date_order.sql b/supabase/migrations/20260716140000_journal_entries_list_same_date_order.sql new file mode 100644 index 00000000..f92e5954 --- /dev/null +++ b/supabase/migrations/20260716140000_journal_entries_list_same_date_order.sql @@ -0,0 +1,137 @@ +-- Verifikationslista: make the same-date tiebreaker follow the date direction. +-- +-- The list RPC ordered by entry_date in the requested direction but always +-- tiebroke by voucher_series/voucher_number ASCENDING. Under the default +-- date-descending view, vouchers sharing a date therefore ran the "wrong way" +-- (A10, A11, A12 inside 2026-06-10 while the dates around them descend), so +-- the list looked shuffled around every multi-voucher day (#972). The route's +-- direct-query fallback already flips its tiebreaker with the date direction; +-- the RPC now does the same, so both paths agree. +-- +-- Same 12-arg signature as 20260629160000, so CREATE OR REPLACE keeps the +-- existing GRANTs. Only the two ORDER BY clauses change. + +CREATE OR REPLACE 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, + CASE WHEN p_sort_date = 'asc' THEN voucher_series END ASC, + CASE WHEN p_sort_date = 'desc' THEN voucher_series END DESC, + CASE WHEN p_sort_date = 'asc' THEN voucher_number END ASC, + CASE WHEN p_sort_date = 'desc' THEN voucher_number END DESC + 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, + CASE WHEN p_sort_date = 'asc' THEN p.voucher_series END ASC, + CASE WHEN p_sort_date = 'desc' THEN p.voucher_series END DESC, + CASE WHEN p_sort_date = 'asc' THEN p.voucher_number END ASC, + CASE WHEN p_sort_date = 'desc' THEN p.voucher_number END DESC; +$$; + +NOTIFY pgrst, 'reload schema';