From 6b4bf63feccccb2c420893f8ba8c08c0f3c43845 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Flod=C3=A9n?= Date: Mon, 29 Jun 2026 22:14:10 +0200 Subject: [PATCH] fix(reports): sort trial balance source lines by date then voucher_number (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .order({ foreignTable }) in Supabase/PostgREST sorts the embedded resource's rows, not the parent result set. Journal entry lines in the trial balance drill-down were therefore returned in database insertion order rather than chronological order. Sort in JavaScript after fetching — mirroring the approach in generateGeneralLedger — to guarantee entry_date ASC, voucher_number ASC ordering regardless of what the database returns. Signed-off-by: Jonas Flodén Co-authored-by: Claude Sonnet 4.6 --- .../sources/__tests__/route.test.ts | 133 ++++++++++++++++++ .../account/[accountNumber]/sources/route.ts | 31 ++-- 2 files changed, 151 insertions(+), 13 deletions(-) diff --git a/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts b/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts index 48b45cc1..69dc56fe 100644 --- a/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts +++ b/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts @@ -160,4 +160,137 @@ describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => expect(body.data.lines[1].credit).toBe(700) expect(body.data.next_cursor).toBeNull() }) + + it('sorts lines by entry_date ASC then voucher_number ASC regardless of DB return order', async () => { + // DB returns rows in reverse date order (latest first) — the route must + // sort them, not rely on the database order. + const linesData = [ + { + debit_amount: 500, + credit_amount: 0, + journal_entry_id: 'je-latest', + journal_entries: { + id: 'je-latest', + voucher_number: 15, + voucher_series: 'A', + entry_date: '2026-05-10', + description: 'Latest', + status: 'posted', + company_id: 'company-1', + fiscal_period_id: 'period-1', + }, + }, + { + debit_amount: 200, + credit_amount: 0, + journal_entry_id: 'je-earliest', + journal_entries: { + id: 'je-earliest', + voucher_number: 3, + voucher_series: 'A', + entry_date: '2026-05-01', + description: 'Earliest', + status: 'posted', + company_id: 'company-1', + fiscal_period_id: 'period-1', + }, + }, + { + debit_amount: 0, + credit_amount: 100, + journal_entry_id: 'je-middle', + journal_entries: { + id: 'je-middle', + voucher_number: 9, + voucher_series: 'A', + entry_date: '2026-05-05', + description: 'Middle', + status: 'posted', + company_id: 'company-1', + fiscal_period_id: 'period-1', + }, + }, + ] + + mockCreateClient.mockResolvedValue( + buildSupabase( + { id: 'user-1' }, + { account_number: '1930', account_name: 'Företagskonto' }, + { data: linesData, error: null } + ) as never + ) + + const req = createMockRequest( + '/api/reports/trial-balance/account/1930/sources', + { searchParams: { fiscal_period_id: 'period-1' } } + ) + const res = await GET(req, createMockRouteParams({ accountNumber: '1930' })) + expect(res.status).toBe(200) + + const body = (await res.json()) as { + data: { lines: Array<{ journal_entry_id: string; date: string; voucher_number: number }> } + } + + expect(body.data.lines).toHaveLength(3) + expect(body.data.lines[0].journal_entry_id).toBe('je-earliest') // 2026-05-01, #3 + expect(body.data.lines[1].journal_entry_id).toBe('je-middle') // 2026-05-05, #9 + expect(body.data.lines[2].journal_entry_id).toBe('je-latest') // 2026-05-10, #15 + }) + + it('sorts lines with same date by voucher_number ASC', async () => { + const linesData = [ + { + debit_amount: 100, + credit_amount: 0, + journal_entry_id: 'je-high', + journal_entries: { + id: 'je-high', + voucher_number: 20, + voucher_series: 'A', + entry_date: '2026-06-01', + description: 'High voucher', + status: 'posted', + company_id: 'company-1', + fiscal_period_id: 'period-1', + }, + }, + { + debit_amount: 50, + credit_amount: 0, + journal_entry_id: 'je-low', + journal_entries: { + id: 'je-low', + voucher_number: 5, + voucher_series: 'A', + entry_date: '2026-06-01', + description: 'Low voucher', + status: 'posted', + company_id: 'company-1', + fiscal_period_id: 'period-1', + }, + }, + ] + + mockCreateClient.mockResolvedValue( + buildSupabase( + { id: 'user-1' }, + { account_number: '1930', account_name: 'Företagskonto' }, + { data: linesData, error: null } + ) as never + ) + + const req = createMockRequest( + '/api/reports/trial-balance/account/1930/sources', + { searchParams: { fiscal_period_id: 'period-1' } } + ) + const res = await GET(req, createMockRouteParams({ accountNumber: '1930' })) + expect(res.status).toBe(200) + + const body = (await res.json()) as { + data: { lines: Array<{ journal_entry_id: string }> } + } + + expect(body.data.lines[0].journal_entry_id).toBe('je-low') // voucher 5 first + expect(body.data.lines[1].journal_entry_id).toBe('je-high') // voucher 20 second + }) }) diff --git a/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts b/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts index ba261117..07a07ce9 100644 --- a/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts +++ b/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts @@ -79,8 +79,6 @@ export async function GET( .eq('journal_entries.company_id', companyId) .eq('journal_entries.fiscal_period_id', fiscalPeriodId) .in('journal_entries.status', ['posted', 'reversed']) - .order('entry_date', { foreignTable: 'journal_entries', ascending: true }) - .order('voucher_number', { foreignTable: 'journal_entries', ascending: true }) .limit(PAGE_LIMIT + 1) if (cursor) { @@ -107,17 +105,24 @@ export async function GET( // eslint-disable-next-line @typescript-eslint/no-explicit-any const rows = (data || []) as any[] - const lines: ReportSourceLine[] = rows - .slice(0, PAGE_LIMIT) - .map((row) => ({ - journal_entry_id: row.journal_entries.id, - voucher_number: row.journal_entries.voucher_number, - voucher_series: row.journal_entries.voucher_series || 'A', - date: row.journal_entries.entry_date, - description: row.journal_entries.description || '', - debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100, - credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100, - })) + // Map all rows then sort in JS (date ASC, voucher_number ASC). + // .order({ foreignTable }) in Supabase sorts the embedded resource's rows, + // not the parent result set, so we cannot rely on DB ordering here. + // This mirrors the sort in generateGeneralLedger. + const allMapped: ReportSourceLine[] = rows.map((row) => ({ + journal_entry_id: row.journal_entries.id, + voucher_number: row.journal_entries.voucher_number, + voucher_series: row.journal_entries.voucher_series || 'A', + date: row.journal_entries.entry_date, + description: row.journal_entries.description || '', + debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100, + credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100, + })) + allMapped.sort((a, b) => { + const dateComp = a.date.localeCompare(b.date) + return dateComp !== 0 ? dateComp : a.voucher_number - b.voucher_number + }) + const lines = allMapped.slice(0, PAGE_LIMIT) // If we got more than PAGE_LIMIT rows back, the next cursor points at the // last delivered row so the next call resumes from after it.