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:
co-authored by
Claude Opus 4.6
parent
53d1807e8f
commit
8cce21983f
@@ -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)
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,6 +18,15 @@ const cspDirectives = [
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
source: '/nyckeltal',
|
||||
destination: '/kpi',
|
||||
permanent: true,
|
||||
},
|
||||
]
|
||||
},
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user