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:
co-authored by
Claude Sonnet 4.6
parent
5e9aa52dea
commit
6b4bf63fec
+133
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user