fix: trial balance 1000-row limit and rename /nyckeltal to /kpi (#79)

* fix: trial balance silently truncated at 1000 entries, rename /nyckeltal to /kpi

Trial balance bug:
- The old implementation fetched journal entry IDs (capped at 1000 by
  Supabase default limit), then queried lines via .in(entryIds) which
  also hit URL length limits with large arrays of UUIDs.
- SIE imports create thousands of entries → KPIs showed zero.
- Replaced with a single joined query (journal_entry_lines → journal_entries)
  using fetchAllRows() pagination. No row limit, no URL length issue.
- Removed the non-existent generate_trial_balance RPC call.

Page rename:
- /nyckeltal → /kpi (CLAUDE.md: all code in English)
- Nav label stays "Nyckeltal" (user-facing Swedish UI)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: select filtered columns in joined query, rename component, add redirect

- Select user_id/fiscal_period_id/status from journal_entries!inner()
  so PostgREST applies embedded filters reliably (defense in depth)
- Rename NyckeltalPage → KpiPage per English code convention
- Add permanent /nyckeltal → /kpi redirect for existing bookmarks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-03-21 17:21:34 +01:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 53d1807e8f
commit 8cce21983f
5 changed files with 86 additions and 218 deletions
@@ -8,7 +8,7 @@ import { KPIOperationalGrid } from '@/components/kpi/KPIOperationalGrid'
import { KPITrendChart } from '@/components/kpi/KPITrendChart'
import type { FiscalPeriod, KPIReport } from '@/types'
export default function NyckeltalPage() {
export default function KpiPage() {
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [selectedPeriod, setSelectedPeriod] = useState('')
const [report, setReport] = useState<KPIReport | null>(null)
+1 -1
View File
@@ -55,7 +55,7 @@ interface NavItem {
// All nav items for sidebar and mobile drawer
const navItems: NavItem[] = [
{ href: '/', label: 'Översikt', icon: LayoutDashboard, group: 'main' },
{ href: '/nyckeltal', label: 'Nyckeltal', icon: TrendingUp, group: 'main' },
{ href: '/kpi', label: 'Nyckeltal', icon: TrendingUp, group: 'main' },
{ href: '/deadlines', label: 'Deadlines', icon: Calendar, group: 'main' },
{ href: '/invoices', label: 'Fakturor', icon: Receipt, group: 'finans' },
{ href: '/customers', label: 'Kunder', icon: Users, group: 'finans' },
+50 -147
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
// ============================================================
// Mock — sequential result queue with rpc support
// Mock — sequential result queue (no RPC — direct joined queries)
// ============================================================
let resultIdx: number
@@ -20,7 +20,6 @@ function makeBuilder() {
function makeClient() {
return {
from: vi.fn().mockImplementation(() => makeBuilder()),
rpc: vi.fn().mockImplementation(async () => results[resultIdx++] ?? { data: null, error: null }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any
}
@@ -37,100 +36,9 @@ beforeEach(() => {
})
describe('generateTrialBalance', () => {
it('returns rows directly from RPC when successful', async () => {
it('returns empty report when no lines exist', async () => {
results = [
// 0: rpc('generate_trial_balance')
{
data: [
{
account_number: '1930',
account_name: 'Företagskonto',
account_class: 1,
opening_debit: 0,
opening_credit: 0,
period_debit: 5000,
period_credit: 0,
closing_debit: 5000,
closing_credit: 0,
},
{
account_number: '3001',
account_name: 'Försäljning 25%',
account_class: 3,
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 5000,
closing_debit: 0,
closing_credit: 5000,
},
],
error: null,
},
]
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
expect(result.rows).toHaveLength(2)
expect(result.totalDebit).toBe(5000)
expect(result.totalCredit).toBe(5000)
expect(result.isBalanced).toBe(true)
})
it('falls back to manual aggregation when RPC returns error', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'function not found' } },
// 1: manual — journal_entries
{
data: [{ id: 'e1' }],
error: null,
},
// 2: manual — journal_entry_lines
{
data: [
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 1000 },
],
error: null,
},
// 3: manual — chart_of_accounts
{
data: [
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
{ account_number: '3001', account_name: 'Försäljning 25%', account_class: 3 },
],
error: null,
},
]
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
expect(result.rows).toHaveLength(2)
expect(result.totalDebit).toBe(1000)
expect(result.totalCredit).toBe(1000)
expect(result.isBalanced).toBe(true)
})
it('falls back to manual aggregation when RPC returns null data', async () => {
results = [
// 0: rpc succeeds but data is null
{ data: null, error: null },
// 1: manual — journal_entries (empty)
{ data: [], error: null },
]
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
expect(result.rows).toEqual([])
expect(result.isBalanced).toBe(true)
})
it('returns empty report when no entries exist (manual path)', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: manual — journal_entries empty
// 0: journal_entry_lines (joined, page 1 — empty)
{ data: [], error: null },
]
@@ -144,11 +52,7 @@ describe('generateTrialBalance', () => {
it('aggregates lines by account and sorts by account_number', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries
{ data: [{ id: 'e1' }, { id: 'e2' }], error: null },
// 2: journal_entry_lines — multiple lines per account
// 0: journal_entry_lines (joined, page 1)
{
data: [
{ account_number: '3001', debit_amount: 0, credit_amount: 500 },
@@ -158,7 +62,7 @@ describe('generateTrialBalance', () => {
],
error: null,
},
// 3: chart_of_accounts
// 1: chart_of_accounts (page 1)
{
data: [
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
@@ -184,18 +88,14 @@ describe('generateTrialBalance', () => {
it('falls back to "Konto {number}" when account not in chart_of_accounts', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries
{ data: [{ id: 'e1' }], error: null },
// 2: journal_entry_lines
// 0: journal_entry_lines
{
data: [
{ account_number: '9999', debit_amount: 100, credit_amount: 0 },
],
error: null,
},
// 3: chart_of_accounts — empty
// 1: chart_of_accounts — empty
{ data: [], error: null },
]
@@ -206,18 +106,14 @@ describe('generateTrialBalance', () => {
it('derives account_class from first digit when account not in chart', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries
{ data: [{ id: 'e1' }], error: null },
// 2: journal_entry_lines
// 0: journal_entry_lines
{
data: [
{ account_number: '5410', debit_amount: 200, credit_amount: 0 },
],
error: null,
},
// 3: chart_of_accounts — empty
// 1: chart_of_accounts — empty
{ data: [], error: null },
]
@@ -228,11 +124,7 @@ describe('generateTrialBalance', () => {
it('uses Math.round for monetary precision', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries
{ data: [{ id: 'e1' }], error: null },
// 2: journal_entry_lines — values that cause floating point issues
// 0: journal_entry_lines — values that cause floating point issues
{
data: [
{ account_number: '1930', debit_amount: 33.33, credit_amount: 0 },
@@ -242,7 +134,7 @@ describe('generateTrialBalance', () => {
],
error: null,
},
// 3: chart_of_accounts
// 1: chart_of_accounts
{
data: [
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
@@ -262,31 +154,19 @@ describe('generateTrialBalance', () => {
it('detects unbalanced entries (isBalanced=false)', async () => {
results = [
// 0: rpc succeeds with unbalanced data
// 0: journal_entry_lines
{
data: [
{
account_number: '1930',
account_name: 'Bank',
account_class: 1,
opening_debit: 0,
opening_credit: 0,
period_debit: 1000,
period_credit: 0,
closing_debit: 1000,
closing_credit: 0,
},
{
account_number: '3001',
account_name: 'Revenue',
account_class: 3,
opening_debit: 0,
opening_credit: 0,
period_debit: 0,
period_credit: 999,
closing_debit: 0,
closing_credit: 999,
},
{ account_number: '1930', debit_amount: 1000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 999 },
],
error: null,
},
// 1: chart_of_accounts
{
data: [
{ account_number: '1930', account_name: 'Bank', account_class: 1 },
{ account_number: '3001', account_name: 'Revenue', account_class: 3 },
],
error: null,
},
@@ -299,17 +179,40 @@ describe('generateTrialBalance', () => {
expect(result.isBalanced).toBe(false)
})
it('returns empty when entries query errors (manual path)', async () => {
it('throws when lines query errors', async () => {
results = [
// 0: rpc fails
{ data: null, error: { message: 'error' } },
// 1: journal_entries query errors
// 0: journal_entry_lines query errors
{ data: null, error: { message: 'DB error' } },
]
await expect(generateTrialBalance(supabase, 'user-1', 'period-1')).rejects.toThrow('DB error')
})
it('handles balanced two-account entry', async () => {
results = [
// 0: journal_entry_lines
{
data: [
{ account_number: '1930', debit_amount: 5000, credit_amount: 0 },
{ account_number: '3001', debit_amount: 0, credit_amount: 5000 },
],
error: null,
},
// 1: chart_of_accounts
{
data: [
{ account_number: '1930', account_name: 'Företagskonto', account_class: 1 },
{ account_number: '3001', account_name: 'Försäljning 25%', account_class: 3 },
],
error: null,
},
]
const result = await generateTrialBalance(supabase, 'user-1', 'period-1')
expect(result.rows).toEqual([])
expect(result.rows).toHaveLength(2)
expect(result.totalDebit).toBe(5000)
expect(result.totalCredit).toBe(5000)
expect(result.isBalanced).toBe(true)
})
})
+25 -69
View File
@@ -3,10 +3,11 @@ import { fetchAllRows } from '@/lib/supabase/fetch-all'
import type { TrialBalanceRow } from '@/types'
/**
* Generate trial balance (Saldobalans) for a fiscal period
* Generate trial balance (Saldobalans) for a fiscal period.
*
* Aggregates all posted journal entry lines grouped by account number,
* filtered by fiscal period. Verifies total debits = total credits.
* Uses a single joined query (journal_entry_lines → journal_entries)
* with pagination to handle any number of entries. Avoids the broken
* .in(entryIds) pattern that silently truncated at 1000 rows.
*/
export async function generateTrialBalance(
supabase: SupabaseClient,
@@ -19,72 +20,31 @@ export async function generateTrialBalance(
isBalanced: boolean
}> {
// Get all posted journal entry lines for this period, grouped by account
const { data, error } = await supabase.rpc('generate_trial_balance', {
p_user_id: userId,
p_fiscal_period_id: fiscalPeriodId,
})
// Single joined query — no entry ID array, no URL length limit
const lines = await fetchAllRows<{
account_number: string
debit_amount: number
credit_amount: number
}>(({ from, to }) =>
supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount, journal_entries!inner(user_id, fiscal_period_id, status)')
.eq('journal_entries.user_id', userId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.in('journal_entries.status', ['posted', 'reversed'])
.range(from, to)
)
if (error) {
// Fallback: manual aggregation via SQL
return generateTrialBalanceManual(supabase, userId, fiscalPeriodId)
}
if (data) {
const rows = data as TrialBalanceRow[]
const totalDebit = rows.reduce((sum, r) => sum + r.closing_debit, 0)
const totalCredit = rows.reduce((sum, r) => sum + r.closing_credit, 0)
return {
rows,
totalDebit: Math.round(totalDebit * 100) / 100,
totalCredit: Math.round(totalCredit * 100) / 100,
isBalanced: Math.abs(totalDebit - totalCredit) < 0.01,
}
}
return generateTrialBalanceManual(supabase, userId, fiscalPeriodId)
}
/**
* Manual trial balance generation using direct queries
*/
async function generateTrialBalanceManual(
supabase: SupabaseClient,
userId: string,
fiscalPeriodId: string
): Promise<{
rows: TrialBalanceRow[]
totalDebit: number
totalCredit: number
isBalanced: boolean
}> {
// Get all journal entry lines for posted entries in this period
const { data: entries, error: entriesError } = await supabase
.from('journal_entries')
.select('id')
.eq('user_id', userId)
.eq('fiscal_period_id', fiscalPeriodId)
.in('status', ['posted', 'reversed'])
if (entriesError || !entries || entries.length === 0) {
return { rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true }
}
const entryIds = entries.map((e) => e.id)
const { data: lines, error: linesError } = await supabase
.from('journal_entry_lines')
.select('account_number, debit_amount, credit_amount')
.in('journal_entry_id', entryIds)
if (linesError || !lines) {
if (lines.length === 0) {
return { rows: [], totalDebit: 0, totalCredit: 0, isBalanced: true }
}
// Get account names
const accounts = await fetchAllRows<{ account_number: string; account_name: string; account_class: number }>(({ from, to }) =>
const accounts = await fetchAllRows<{
account_number: string
account_name: string
account_class: number
}>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class')
@@ -101,10 +61,7 @@ async function generateTrialBalanceManual(
}
// Aggregate by account
const balances = new Map<
string,
{ debit: number; credit: number }
>()
const balances = new Map<string, { debit: number; credit: number }>()
for (const line of lines) {
const existing = balances.get(line.account_number) || { debit: 0, credit: 0 }
@@ -134,7 +91,6 @@ async function generateTrialBalanceManual(
})
}
// Sort by account number
rows.sort((a, b) => a.account_number.localeCompare(b.account_number))
const totalDebit = Math.round(rows.reduce((sum, r) => sum + r.closing_debit, 0) * 100) / 100
+9
View File
@@ -18,6 +18,15 @@ const cspDirectives = [
const nextConfig: NextConfig = {
output: 'standalone',
async redirects() {
return [
{
source: '/nyckeltal',
destination: '/kpi',
permanent: true,
},
]
},
async headers() {
return [
{