From 8cce21983f2855d62efc2e2552fdeccb6c43c50a Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Sat, 21 Mar 2026 17:21:34 +0100 Subject: [PATCH] fix: trial balance 1000-row limit and rename /nyckeltal to /kpi (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- app/(dashboard)/{nyckeltal => kpi}/page.tsx | 2 +- components/dashboard/DashboardNav.tsx | 2 +- lib/reports/__tests__/trial-balance.test.ts | 197 +++++--------------- lib/reports/trial-balance.ts | 94 +++------- next.config.ts | 9 + 5 files changed, 86 insertions(+), 218 deletions(-) rename app/(dashboard)/{nyckeltal => kpi}/page.tsx (99%) diff --git a/app/(dashboard)/nyckeltal/page.tsx b/app/(dashboard)/kpi/page.tsx similarity index 99% rename from app/(dashboard)/nyckeltal/page.tsx rename to app/(dashboard)/kpi/page.tsx index d21d7183..70d49f86 100644 --- a/app/(dashboard)/nyckeltal/page.tsx +++ b/app/(dashboard)/kpi/page.tsx @@ -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([]) const [selectedPeriod, setSelectedPeriod] = useState('') const [report, setReport] = useState(null) diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index fafad8c3..e73d5611 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -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' }, diff --git a/lib/reports/__tests__/trial-balance.test.ts b/lib/reports/__tests__/trial-balance.test.ts index 7dd9beac..4eef71c3 100644 --- a/lib/reports/__tests__/trial-balance.test.ts +++ b/lib/reports/__tests__/trial-balance.test.ts @@ -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) }) }) diff --git a/lib/reports/trial-balance.ts b/lib/reports/trial-balance.ts index dcb5e5e3..38fdd9ae 100644 --- a/lib/reports/trial-balance.ts +++ b/lib/reports/trial-balance.ts @@ -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() 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 diff --git a/next.config.ts b/next.config.ts index 5bd14816..5d31618c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -18,6 +18,15 @@ const cspDirectives = [ const nextConfig: NextConfig = { output: 'standalone', + async redirects() { + return [ + { + source: '/nyckeltal', + destination: '/kpi', + permanent: true, + }, + ] + }, async headers() { return [ {