fix(reports): sort trial balance source lines by date then voucher_number (#763)

.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 <jonas@floden.nu>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jonas Flodén
2026-06-29 22:14:10 +02:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 5e9aa52dea
commit 6b4bf63fec
2 changed files with 151 additions and 13 deletions
@@ -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
})
})
@@ -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.