diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index ce65688f..c4b5d57f 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -23,6 +23,8 @@ import type { TrialBalanceRow, IncomeStatementReport, BalanceSheetReport, + ResultatrapportReport, + BalansrapportReport, VatDeclaration, VatPeriodType, } from '@/types' @@ -39,6 +41,8 @@ interface DrillDownStep { } const TAB_LABELS: Record = { + 'resultatrapport': 'Resultatrapport', + 'balansrapport': 'Balansrapport', 'trial-balance': 'Saldobalans', 'income-statement': 'Resultaträkning', 'balance-sheet': 'Balansräkning', @@ -47,7 +51,7 @@ const TAB_LABELS: Record = { export default function ReportsPage() { const [selectedPeriod, setSelectedPeriod] = useState('') - const [activeTab, setActiveTab] = useState('trial-balance') + const [activeTab, setActiveTab] = useState('resultatrapport') const [isLoadingInit, setIsLoadingInit] = useState(true) const { company } = useCompany() @@ -170,8 +174,12 @@ export default function ReportsPage() { onChange={(e) => handleTabChange(e.target.value)} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" > - + + + + + @@ -193,14 +201,29 @@ export default function ReportsPage() { {/* Desktop: inline grouped tab navigation */} -
+
+ {/* Löpande rapporter */} +
+ Löpande rapporter + + + Resultatrapport + + + Balansrapport + + + Saldobalans + + +
+ +
+ {/* Bokslut */}
Bokslut - - Saldobalans - Resultaträkning @@ -266,6 +289,12 @@ export default function ReportsPage() {
+ + + + + + @@ -827,6 +856,278 @@ function BalanceSheetView({ periodId, onNavigateToAccount }: { periodId: string; ) } +function ResultatrapportView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + setLoading(true) + setError(null) + fetch(`/api/reports/resultatrapport?period_id=${periodId}`) + .then((res) => res.json()) + .then((result) => { + if (result.error) { + setError(result.error) + } else { + setData(result.data) + } + setLoading(false) + }) + .catch(() => { + setError('Kunde inte hämta resultatrapport') + setLoading(false) + }) + }, [periodId]) + + if (loading) { + return ( + + + Laddar resultatrapport... + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data || data.groups.length === 0) { + return ( + + + Inga bokförda intäkter eller kostnader i denna period. + + + ) + } + + const hasPrior = data.prior_period !== null + + return ( +
+ + +
+ + + + + + + + + + + {data.groups.map((group) => ( + + + + + {group.rows.map((row) => ( + onNavigateToAccount(row.account_number)} + > + + + + + + ))} + + + + + + + ))} + +
KontoKontonamnInnevarandeFöregående
+ {group.class_label} +
+ + {row.account_name}{formatAmount(row.current_period)} + {hasPrior ? formatAmount(row.prior_period) : '—'} +
+ Summa + {formatAmount(group.subtotal_current)} + {hasPrior ? formatAmount(group.subtotal_prior) : '—'} +
+
+
+
+ + + +
+ Beräknat resultat + = 0 ? 'text-success' : 'text-destructive'}`}> + {formatAmount(data.net_result_current)} kr + + + {hasPrior ? `${formatAmount(data.net_result_prior)} kr` : '—'} + +
+
+
+
+ ) +} + +function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string; onNavigateToAccount: (account: string) => void }) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + setLoading(true) + setError(null) + fetch(`/api/reports/balansrapport?period_id=${periodId}`) + .then((res) => res.json()) + .then((result) => { + if (result.error) { + setError(result.error) + } else { + setData(result.data) + } + setLoading(false) + }) + .catch(() => { + setError('Kunde inte hämta balansrapport') + setLoading(false) + }) + }, [periodId]) + + if (loading) { + return ( + + + Laddar balansrapport... + + + ) + } + + if (error) { + return ( + + + + {error} + + + ) + } + + if (!data || data.groups.length === 0) { + return ( + + + Inga balansposter i denna period. + + + ) + } + + return ( +
+ + +
+ + + + + + + + + + + + {data.groups.map((group) => ( + + + + + {group.rows.map((row) => ( + onNavigateToAccount(row.account_number)} + > + + + + + + + ))} + + + + + + + + ))} + +
KontoKontonamnIngående balansUtgående balansFörändring
+ {group.class_label} +
+ + {row.account_name}{formatAmount(row.ib)}{formatAmount(row.ub)}{formatAmount(row.period_change)}
+ Summa + {formatAmount(group.subtotal_ib)}{formatAmount(group.subtotal_ub)} + {formatAmount(group.subtotal_ub - group.subtotal_ib)} +
+
+
+
+ + + +
+ Summa tillgångar + {formatAmount(data.total_assets_ub)} kr +
+
+ Summa eget kapital, reserver, avsättningar och skulder + {formatAmount(data.total_equity_liabilities_ub)} kr +
+
+ Beräknat resultat (ej bokslutsjusterat) + {formatAmount(data.beraknat_resultat)} kr +
+
+ Balanscheck + {data.is_balanced ? ( + + Balanserar + + ) : ( + + Balanserar ej + + )} +
+
+
+
+ ) +} + function ReportSectionTable({ sections, negate, diff --git a/app/api/reports/balansrapport/route.ts b/app/api/reports/balansrapport/route.ts new file mode 100644 index 00000000..5c3688ce --- /dev/null +++ b/app/api/reports/balansrapport/route.ts @@ -0,0 +1,32 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { generateBalansrapport } from '@/lib/reports/balansrapport' +import { requireCompanyId } from '@/lib/company/context' + +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const companyId = await requireCompanyId(supabase, user.id) + + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') + + if (!periodId) { + return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) + } + + try { + const result = await generateBalansrapport(supabase, companyId, periodId) + return NextResponse.json({ data: result }) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to generate balansrapport' }, + { status: 500 } + ) + } +} diff --git a/app/api/reports/resultatrapport/route.ts b/app/api/reports/resultatrapport/route.ts new file mode 100644 index 00000000..f7163c60 --- /dev/null +++ b/app/api/reports/resultatrapport/route.ts @@ -0,0 +1,32 @@ +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import { generateResultatrapport } from '@/lib/reports/resultatrapport' +import { requireCompanyId } from '@/lib/company/context' + +export async function GET(request: Request) { + const supabase = await createClient() + const { data: { user } } = await supabase.auth.getUser() + + if (!user) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const companyId = await requireCompanyId(supabase, user.id) + + const { searchParams } = new URL(request.url) + const periodId = searchParams.get('period_id') + + if (!periodId) { + return NextResponse.json({ error: 'period_id is required' }, { status: 400 }) + } + + try { + const result = await generateResultatrapport(supabase, companyId, periodId) + return NextResponse.json({ data: result }) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Failed to generate resultatrapport' }, + { status: 500 } + ) + } +} diff --git a/lib/reports/__tests__/balansrapport.test.ts b/lib/reports/__tests__/balansrapport.test.ts new file mode 100644 index 00000000..70d0204b --- /dev/null +++ b/lib/reports/__tests__/balansrapport.test.ts @@ -0,0 +1,291 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../trial-balance', () => ({ + generateTrialBalance: vi.fn(), +})) + +import { generateBalansrapport } from '../balansrapport' +import { generateTrialBalance } from '../trial-balance' +import { createQueuedMockSupabase } from '@/tests/helpers' +import type { TrialBalanceRow } from '@/types' + +const mockTrialBalance = vi.mocked(generateTrialBalance) + +beforeEach(() => { + vi.clearAllMocks() +}) + +function makeRow(overrides: Partial): TrialBalanceRow { + return { + account_number: '1930', + account_name: 'Bank', + account_class: 1, + opening_debit: 0, + opening_credit: 0, + period_debit: 0, + period_credit: 0, + closing_debit: 0, + closing_credit: 0, + ...overrides, + } +} + +function tb(rows: TrialBalanceRow[]) { + const totalDebit = rows.reduce((s, r) => s + r.closing_debit, 0) + const totalCredit = rows.reduce((s, r) => s + 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, + } +} + +describe('generateBalansrapport', () => { + it('groups balance accounts into class 1 (assets) and class 2 (equity & liabilities)', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31' }, + error: null, + }) + + mockTrialBalance.mockResolvedValueOnce( + tb([ + makeRow({ + account_number: '1930', + account_name: 'Bank', + account_class: 1, + opening_debit: 50000, + opening_credit: 0, + closing_debit: 75000, + closing_credit: 0, + }), + makeRow({ + account_number: '1510', + account_name: 'Kundfordringar', + account_class: 1, + opening_debit: 10000, + opening_credit: 0, + closing_debit: 12500, + closing_credit: 0, + }), + makeRow({ + account_number: '2440', + account_name: 'Lev.skulder', + account_class: 2, + opening_credit: 8000, + opening_debit: 0, + closing_credit: 15000, + closing_debit: 0, + }), + makeRow({ + account_number: '2099', + account_name: 'Årets resultat', + account_class: 2, + opening_credit: 52000, + opening_debit: 0, + closing_credit: 72500, + closing_debit: 0, + }), + ]) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateBalansrapport(q.supabase as any, 'company-1', 'period-1') + + expect(report.groups).toHaveLength(2) + expect(report.groups[0].class).toBe(1) + expect(report.groups[1].class).toBe(2) + + // Assets sorted by account number + const assets = report.groups[0] + expect(assets.rows.map((r) => r.account_number)).toEqual(['1510', '1930']) + expect(assets.rows[1]).toEqual({ + account_number: '1930', + account_name: 'Bank', + ib: 50000, + ub: 75000, + period_change: 25000, + }) + expect(assets.subtotal_ib).toBe(60000) + expect(assets.subtotal_ub).toBe(87500) + + // Equity & liabilities — credit-positive + const equity = report.groups[1] + expect(equity.rows[0]).toEqual({ + account_number: '2099', + account_name: 'Årets resultat', + ib: 52000, + ub: 72500, + period_change: 20500, + }) + expect(equity.subtotal_ub).toBe(87500) + + expect(report.total_assets_ub).toBe(87500) + expect(report.total_equity_liabilities_ub).toBe(87500) + // 2099 already absorbs prior+current result, residual is 0 + expect(report.beraknat_resultat).toBe(0) + expect(report.is_balanced).toBe(true) + }) + + it('beräknat resultat equals total_assets - total_eq_liab during running year', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31' }, + error: null, + }) + + // Mid-year, before any 2099 update: assets 80 000, liabs 30 000. + // P&L (3001 - 5010) = 50 000 sits in P&L accounts and equals the residual. + mockTrialBalance.mockResolvedValueOnce( + tb([ + makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 80000 }), + makeRow({ account_number: '2440', account_name: 'Lev.skuld', account_class: 2, closing_credit: 30000 }), + makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 70000 }), + makeRow({ account_number: '5010', account_name: 'Rent', account_class: 5, closing_debit: 20000 }), + ]) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateBalansrapport(q.supabase as any, 'company-1', 'period-1') + + expect(report.total_assets_ub).toBe(80000) + expect(report.total_equity_liabilities_ub).toBe(30000) + expect(report.beraknat_resultat).toBe(50000) + // Trial balance still balances — double-entry guarantees this. + expect(report.is_balanced).toBe(true) + }) + + it('is_balanced reflects trial balance balance state', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31' }, + error: null, + }) + + // Manually construct an unbalanced trial balance (in practice the DB + // trigger prevents this, but a continuity break or missing IB row would + // surface here). + mockTrialBalance.mockResolvedValueOnce({ + rows: [ + makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 80000 }), + makeRow({ account_number: '2440', account_name: 'Lev.skuld', account_class: 2, closing_credit: 70000 }), + ], + totalDebit: 80000, + totalCredit: 70000, + isBalanced: false, + }) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateBalansrapport(q.supabase as any, 'company-1', 'period-1') + + expect(report.is_balanced).toBe(false) + }) + + it('ignores P&L accounts (class 3-8)', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31' }, + error: null, + }) + + mockTrialBalance.mockResolvedValueOnce( + tb([ + makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 10000 }), + makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 50000 }), + makeRow({ account_number: '5010', account_name: 'Rent', account_class: 5, closing_debit: 8000 }), + makeRow({ account_number: '8410', account_name: 'Räntekostnad', account_class: 8, closing_debit: 100 }), + ]) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateBalansrapport(q.supabase as any, 'company-1', 'period-1') + + expect(report.groups).toHaveLength(1) + expect(report.groups[0].class).toBe(1) + expect(report.groups[0].rows.map((r) => r.account_number)).toEqual(['1930']) + }) + + it('drops accounts where both IB and UB are zero', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31' }, + error: null, + }) + + mockTrialBalance.mockResolvedValueOnce( + tb([ + makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 10000 }), + makeRow({ account_number: '1940', account_name: 'Inactive', account_class: 1 }), + ]) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateBalansrapport(q.supabase as any, 'company-1', 'period-1') + + expect(report.groups[0].rows).toHaveLength(1) + expect(report.groups[0].rows[0].account_number).toBe('1930') + }) + + it('handles accounts that closed during the period (UB=0, IB>0)', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31' }, + error: null, + }) + + mockTrialBalance.mockResolvedValueOnce( + tb([ + makeRow({ + account_number: '1510', + account_name: 'Kundfordran (betald)', + account_class: 1, + opening_debit: 10000, + period_credit: 10000, + closing_debit: 10000, + closing_credit: 10000, + }), + ]) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateBalansrapport(q.supabase as any, 'company-1', 'period-1') + + expect(report.groups[0].rows[0]).toEqual({ + account_number: '1510', + account_name: 'Kundfordran (betald)', + ib: 10000, + ub: 0, + period_change: -10000, + }) + }) + + it('throws when fiscal period not found', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ data: null, error: null }) + + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + generateBalansrapport(q.supabase as any, 'company-1', 'missing') + ).rejects.toThrow('Fiscal period not found') + }) + + it('returns empty groups when there are no balance accounts at all', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31' }, + error: null, + }) + + mockTrialBalance.mockResolvedValueOnce(tb([])) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateBalansrapport(q.supabase as any, 'company-1', 'period-1') + + expect(report.groups).toEqual([]) + expect(report.total_assets_ub).toBe(0) + expect(report.total_equity_liabilities_ub).toBe(0) + expect(report.beraknat_resultat).toBe(0) + expect(report.is_balanced).toBe(true) + }) +}) diff --git a/lib/reports/__tests__/resultatrapport.test.ts b/lib/reports/__tests__/resultatrapport.test.ts new file mode 100644 index 00000000..6153cf4e --- /dev/null +++ b/lib/reports/__tests__/resultatrapport.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../trial-balance', () => ({ + generateTrialBalance: vi.fn(), +})) + +import { generateResultatrapport } from '../resultatrapport' +import { generateTrialBalance } from '../trial-balance' +import { createQueuedMockSupabase } from '@/tests/helpers' +import type { TrialBalanceRow } from '@/types' + +const mockTrialBalance = vi.mocked(generateTrialBalance) + +beforeEach(() => { + vi.clearAllMocks() +}) + +function makeRow(overrides: Partial): TrialBalanceRow { + return { + account_number: '3001', + account_name: 'Test', + account_class: 3, + opening_debit: 0, + opening_credit: 0, + period_debit: 0, + period_credit: 0, + closing_debit: 0, + closing_credit: 0, + ...overrides, + } +} + +function tb(rows: TrialBalanceRow[]) { + const totalDebit = rows.reduce((s, r) => s + r.closing_debit, 0) + const totalCredit = rows.reduce((s, r) => s + 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, + } +} + +describe('generateResultatrapport', () => { + it('groups P&L accounts by class with current and prior period values', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null }, + error: null, + }) + + mockTrialBalance.mockResolvedValueOnce( + tb([ + makeRow({ account_number: '3001', account_name: 'Försäljning 25%', account_class: 3, closing_credit: 100000 }), + makeRow({ account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 30000 }), + makeRow({ account_number: '7210', account_name: 'Löner', account_class: 7, closing_debit: 50000 }), + ]) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1') + + expect(report.groups).toHaveLength(3) + expect(report.groups.map((g) => g.class)).toEqual([3, 5, 7]) + expect(report.groups[0].rows[0]).toEqual({ + account_number: '3001', + account_name: 'Försäljning 25%', + current_period: 100000, + prior_period: 0, + }) + // Expense rows shown as negative (credit - debit) + expect(report.groups[1].rows[0].current_period).toBe(-30000) + expect(report.groups[2].rows[0].current_period).toBe(-50000) + + // Net result = revenue - expenses = 100000 - 30000 - 50000 = 20000 + expect(report.net_result_current).toBe(20000) + expect(report.net_result_prior).toBe(0) + expect(report.prior_period).toBeNull() + }) + + it('joins prior-period values onto current accounts', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: 'period-0' }, + error: null, + }) + q.enqueue({ + data: { period_start: '2025-01-01', period_end: '2025-12-31' }, + error: null, + }) + + mockTrialBalance + .mockResolvedValueOnce( + tb([ + makeRow({ account_number: '3001', account_name: 'Försäljning', account_class: 3, closing_credit: 200000 }), + makeRow({ account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 60000 }), + ]) + ) + .mockResolvedValueOnce( + tb([ + makeRow({ account_number: '3001', account_name: 'Försäljning', account_class: 3, closing_credit: 150000 }), + makeRow({ account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 45000 }), + ]) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1') + + const revenueRow = report.groups[0].rows[0] + expect(revenueRow.current_period).toBe(200000) + expect(revenueRow.prior_period).toBe(150000) + + const expenseRow = report.groups[1].rows[0] + expect(expenseRow.current_period).toBe(-60000) + expect(expenseRow.prior_period).toBe(-45000) + + expect(report.net_result_current).toBe(140000) + expect(report.net_result_prior).toBe(105000) + expect(report.prior_period).toEqual({ start: '2025-01-01', end: '2025-12-31' }) + }) + + it('includes accounts that exist only in prior period (with current=0)', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: 'period-0' }, + error: null, + }) + q.enqueue({ + data: { period_start: '2025-01-01', period_end: '2025-12-31' }, + error: null, + }) + + mockTrialBalance + .mockResolvedValueOnce( + tb([ + makeRow({ account_number: '3001', account_name: 'Försäljning', account_class: 3, closing_credit: 100000 }), + ]) + ) + .mockResolvedValueOnce( + tb([ + makeRow({ account_number: '3001', account_name: 'Försäljning', account_class: 3, closing_credit: 80000 }), + // Account discontinued this year + makeRow({ account_number: '3002', account_name: 'Gammal intäkt', account_class: 3, closing_credit: 5000 }), + ]) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1') + + const class3 = report.groups.find((g) => g.class === 3)! + expect(class3.rows).toHaveLength(2) + const discontinued = class3.rows.find((r) => r.account_number === '3002')! + expect(discontinued.current_period).toBe(0) + expect(discontinued.prior_period).toBe(5000) + }) + + it('excludes account 8999 (year-end closing account)', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null }, + error: null, + }) + + mockTrialBalance.mockResolvedValueOnce( + tb([ + makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 100000 }), + makeRow({ account_number: '8999', account_name: 'Årets resultat', account_class: 8, closing_debit: 100000 }), + ]) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1') + + const class8 = report.groups.find((g) => g.class === 8) + expect(class8).toBeUndefined() + expect(report.net_result_current).toBe(100000) + }) + + it('ignores balance accounts (class 1-2)', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null }, + error: null, + }) + + mockTrialBalance.mockResolvedValueOnce( + tb([ + makeRow({ account_number: '1930', account_name: 'Bank', account_class: 1, closing_debit: 50000 }), + makeRow({ account_number: '2440', account_name: 'Lev.skuld', account_class: 2, closing_credit: 10000 }), + makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 40000 }), + ]) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1') + + expect(report.groups).toHaveLength(1) + expect(report.groups[0].class).toBe(3) + }) + + it('drops rows where both current and prior are zero', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ + data: { period_start: '2026-01-01', period_end: '2026-12-31', previous_period_id: null }, + error: null, + }) + + mockTrialBalance.mockResolvedValueOnce( + tb([ + makeRow({ account_number: '3001', account_name: 'Revenue', account_class: 3, closing_credit: 50000 }), + makeRow({ account_number: '3002', account_name: 'Tom rad', account_class: 3, closing_credit: 0 }), + ]) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateResultatrapport(q.supabase as any, 'company-1', 'period-1') + + expect(report.groups[0].rows).toHaveLength(1) + expect(report.groups[0].rows[0].account_number).toBe('3001') + }) + + it('throws when fiscal period not found', async () => { + const q = createQueuedMockSupabase() + q.enqueue({ data: null, error: null }) + + await expect( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + generateResultatrapport(q.supabase as any, 'company-1', 'missing') + ).rejects.toThrow('Fiscal period not found') + }) +}) diff --git a/lib/reports/balansrapport.ts b/lib/reports/balansrapport.ts new file mode 100644 index 00000000..74770db8 --- /dev/null +++ b/lib/reports/balansrapport.ts @@ -0,0 +1,107 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { generateTrialBalance } from './trial-balance' +import type { + BalansrapportReport, + BalansrapportRow, + BalansrapportGroup, +} from '@/types' + +const CLASS_LABELS: Record = { + 1: '1 Tillgångar', + 2: '2 Eget kapital, obeskattade reserver, avsättningar och skulder', +} + +/** + * Balansrapport — operational balance report. + * + * Lists every account in classes 1–2 with IB, period change, and UB. + * Unlike Balansräkning (formal, ÅRL Bilaga 1), this keeps account numbers + * and is meant for ongoing reconciliation, not for årsbokslut/årsredovisning. + * + * Sign convention: assets (class 1) shown debit-positive (debit - credit), + * equity & liabilities (class 2) shown credit-positive (credit - debit). + * That's the normal balance for each side and matches how Fortnox/Visma + * present a Balansrapport. + */ +export async function generateBalansrapport( + supabase: SupabaseClient, + companyId: string, + fiscalPeriodId: string +): Promise { + const { data: period } = await supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .single() + + if (!period) { + throw new Error('Fiscal period not found') + } + + const trialBalance = await generateTrialBalance(supabase, companyId, fiscalPeriodId) + const balanceRows = trialBalance.rows.filter((r) => r.account_class === 1 || r.account_class === 2) + + const groups: BalansrapportGroup[] = [] + for (const klass of [1, 2] as const) { + const groupRows = balanceRows + .filter((r) => r.account_class === klass) + .sort((a, b) => a.account_number.localeCompare(b.account_number)) + + const rows: BalansrapportRow[] = [] + let subtotalIb = 0 + let subtotalUb = 0 + for (const r of groupRows) { + const ib = signedAmount(r.opening_debit, r.opening_credit, klass) + const ub = signedAmount(r.closing_debit, r.closing_credit, klass) + const change = round2(ub - ib) + if (Math.abs(ib) < 0.005 && Math.abs(ub) < 0.005) continue + rows.push({ + account_number: r.account_number, + account_name: r.account_name, + ib: round2(ib), + ub: round2(ub), + period_change: change, + }) + subtotalIb += ib + subtotalUb += ub + } + + if (rows.length === 0) continue + + groups.push({ + class: klass, + class_label: CLASS_LABELS[klass], + rows, + subtotal_ib: round2(subtotalIb), + subtotal_ub: round2(subtotalUb), + }) + } + + const totalAssetsUb = groups.find((g) => g.class === 1)?.subtotal_ub ?? 0 + const totalEquityLiabilitiesUb = groups.find((g) => g.class === 2)?.subtotal_ub ?? 0 + + // Beräknat resultat (Fortnox/Visma convention): the residual on the balance + // side. During a running year, current-year profit lives in P&L accounts + // and 2099 still holds the prior year's accumulated result, so the residual + // equals current-year P&L net result. After year-end closing posts result + // into 2099, residual is 0 and total_assets == total_eq_liab. + const beraknatResultat = round2(totalAssetsUb - totalEquityLiabilitiesUb) + + return { + groups, + total_assets_ub: totalAssetsUb, + total_equity_liabilities_ub: totalEquityLiabilitiesUb, + beraknat_resultat: beraknatResultat, + is_balanced: trialBalance.isBalanced, + period: { start: period.period_start, end: period.period_end }, + } +} + +function signedAmount(debit: number, credit: number, klass: number): number { + return klass === 1 ? debit - credit : credit - debit +} + +function round2(n: number): number { + return Math.round(n * 100) / 100 +} diff --git a/lib/reports/resultatrapport.ts b/lib/reports/resultatrapport.ts new file mode 100644 index 00000000..05f76749 --- /dev/null +++ b/lib/reports/resultatrapport.ts @@ -0,0 +1,166 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { generateTrialBalance } from './trial-balance' +import type { + ResultatrapportReport, + ResultatrapportRow, + ResultatrapportGroup, + TrialBalanceRow, +} from '@/types' + +const CLASS_LABELS: Record = { + 3: '3 Rörelsens inkomster/intäkter', + 4: '4 Material- och varukostnader', + 5: '5 Övriga externa kostnader', + 6: '6 Övriga externa kostnader', + 7: '7 Personalkostnader', + 8: '8 Finansiella poster och bokslutsdispositioner', +} + +/** + * Resultatrapport — operational P&L report. + * + * Lists every account in classes 3–8 with current-period and prior-period + * values side by side. Unlike Resultaträkning (formal, ÅRL Bilaga 2), this + * keeps account numbers and is meant for ongoing reconciliation, not for + * årsbokslut/årsredovisning. + * + * Account 8999 is excluded — it's the year-end closing account that moves + * årets resultat into equity (2099). Including its balance would double-count + * the result. Same exclusion as generateIncomeStatement. + */ +export async function generateResultatrapport( + supabase: SupabaseClient, + companyId: string, + fiscalPeriodId: string +): Promise { + const { data: period } = await supabase + .from('fiscal_periods') + .select('period_start, period_end, previous_period_id') + .eq('id', fiscalPeriodId) + .eq('company_id', companyId) + .single() + + if (!period) { + throw new Error('Fiscal period not found') + } + + const currentTb = await generateTrialBalance(supabase, companyId, fiscalPeriodId) + const currentRows = filterPnl(currentTb.rows) + + let priorRows: TrialBalanceRow[] = [] + let priorPeriodInfo: { start: string; end: string } | null = null + if (period.previous_period_id) { + const { data: prior } = await supabase + .from('fiscal_periods') + .select('period_start, period_end') + .eq('id', period.previous_period_id) + .eq('company_id', companyId) + .single() + + if (prior) { + const priorTb = await generateTrialBalance(supabase, companyId, period.previous_period_id) + priorRows = filterPnl(priorTb.rows) + priorPeriodInfo = { start: prior.period_start, end: prior.period_end } + } + } + + const priorByAccount = new Map() + for (const r of priorRows) priorByAccount.set(r.account_number, r) + + const groups = buildGroups(currentRows, priorByAccount) + + const netResultCurrent = sumNet(currentRows) + const netResultPrior = sumNet(priorRows) + + return { + groups, + net_result_current: round2(netResultCurrent), + net_result_prior: round2(netResultPrior), + period: { start: period.period_start, end: period.period_end }, + prior_period: priorPeriodInfo, + } +} + +function filterPnl(rows: TrialBalanceRow[]): TrialBalanceRow[] { + return rows.filter( + (r) => + r.account_class >= 3 && + r.account_class <= 8 && + r.account_number !== '8999' + ) +} + +/** + * Sign convention: revenue (class 3) has credit normal balance, expenses + * (class 4–7) have debit. We render every line as `credit - debit` so that + * revenue is positive, expenses are negative, and a positive net result + * means profit. This matches how Fortnox and Visma present a Resultatrapport. + */ +function signedAmount(row: TrialBalanceRow): number { + return row.closing_credit - row.closing_debit +} + +function sumNet(rows: TrialBalanceRow[]): number { + return rows.reduce((sum, r) => sum + signedAmount(r), 0) +} + +function buildGroups( + currentRows: TrialBalanceRow[], + priorByAccount: Map +): ResultatrapportGroup[] { + const accountIndex = new Map() + for (const r of currentRows) { + accountIndex.set(r.account_number, { name: r.account_name, class: r.account_class }) + } + for (const r of priorByAccount.values()) { + if (!accountIndex.has(r.account_number)) { + accountIndex.set(r.account_number, { name: r.account_name, class: r.account_class }) + } + } + + const currentByAccount = new Map() + for (const r of currentRows) currentByAccount.set(r.account_number, r) + + const groups: ResultatrapportGroup[] = [] + for (const klass of [3, 4, 5, 6, 7, 8] as const) { + const accountsInClass = [...accountIndex.entries()] + .filter(([, info]) => info.class === klass) + .map(([account_number, info]) => ({ account_number, name: info.name })) + .sort((a, b) => a.account_number.localeCompare(b.account_number)) + + const rows: ResultatrapportRow[] = [] + let subtotalCurrent = 0 + let subtotalPrior = 0 + for (const { account_number, name } of accountsInClass) { + const cur = currentByAccount.get(account_number) + const pr = priorByAccount.get(account_number) + const currentAmount = cur ? signedAmount(cur) : 0 + const priorAmount = pr ? signedAmount(pr) : 0 + if (Math.abs(currentAmount) < 0.005 && Math.abs(priorAmount) < 0.005) continue + rows.push({ + account_number, + account_name: name, + current_period: round2(currentAmount), + prior_period: round2(priorAmount), + }) + subtotalCurrent += currentAmount + subtotalPrior += priorAmount + } + + if (rows.length === 0) continue + + groups.push({ + class: klass, + class_label: CLASS_LABELS[klass], + rows, + subtotal_current: round2(subtotalCurrent), + subtotal_prior: round2(subtotalPrior), + }) + } + + return groups +} + +function round2(n: number): number { + return Math.round(n * 100) / 100 +} diff --git a/types/index.ts b/types/index.ts index d71edec2..7e7a38ed 100644 --- a/types/index.ts +++ b/types/index.ts @@ -1217,6 +1217,54 @@ export interface BalanceSheetReport { period: { start: string; end: string } } +export interface ResultatrapportRow { + account_number: string + account_name: string + current_period: number + prior_period: number +} + +export interface ResultatrapportGroup { + class: number + class_label: string + rows: ResultatrapportRow[] + subtotal_current: number + subtotal_prior: number +} + +export interface ResultatrapportReport { + groups: ResultatrapportGroup[] + net_result_current: number + net_result_prior: number + period: { start: string; end: string } + prior_period: { start: string; end: string } | null +} + +export interface BalansrapportRow { + account_number: string + account_name: string + ib: number + ub: number + period_change: number +} + +export interface BalansrapportGroup { + class: number + class_label: string + rows: BalansrapportRow[] + subtotal_ib: number + subtotal_ub: number +} + +export interface BalansrapportReport { + groups: BalansrapportGroup[] + total_assets_ub: number + total_equity_liabilities_ub: number + beraknat_resultat: number + is_balanced: boolean + period: { start: string; end: string } +} + export interface SIEExportOptions { fiscal_period_id: string company_name: string