From 2f49106f496d12a6e58c64822d669501c97dd1b4 Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:58:27 +0200 Subject: [PATCH] =?UTF-8?q?fix(balansrapport):=20adjust=20balance=20report?= =?UTF-8?q?=20to=20reflect=20correct=20sign=20con=E2=80=A6=20(#375)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(balansrapport): adjust balance report to reflect correct sign conventions and update PDF template * fix(balansrapport): enforce strict negative balance convention for equity rows in balance report tests --- app/(dashboard)/reports/page.tsx | 6 +- lib/reports/__tests__/balansrapport.test.ts | 67 +++++++++++++++++-- lib/reports/balansrapport.ts | 29 ++++---- .../operational-report-pdf-template.tsx | 6 +- 4 files changed, 81 insertions(+), 27 deletions(-) diff --git a/app/(dashboard)/reports/page.tsx b/app/(dashboard)/reports/page.tsx index a11f3f95..71c621da 100644 --- a/app/(dashboard)/reports/page.tsx +++ b/app/(dashboard)/reports/page.tsx @@ -1073,8 +1073,8 @@ function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string Konto Kontonamn Ingående balans - Utgående balans Förändring + Utgående balans @@ -1096,8 +1096,8 @@ function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string {row.account_name} {formatAmount(row.ib)} - {formatAmount(row.ub)} {formatAmount(row.period_change)} + {formatAmount(row.ub)} ))} @@ -1105,10 +1105,10 @@ function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string Summa {formatAmount(group.subtotal_ib)} - {formatAmount(group.subtotal_ub)} {formatAmount(group.subtotal_ub - group.subtotal_ib)} + {formatAmount(group.subtotal_ub)} ))} diff --git a/lib/reports/__tests__/balansrapport.test.ts b/lib/reports/__tests__/balansrapport.test.ts index 70d0204b..a34e195e 100644 --- a/lib/reports/__tests__/balansrapport.test.ts +++ b/lib/reports/__tests__/balansrapport.test.ts @@ -110,19 +110,19 @@ describe('generateBalansrapport', () => { expect(assets.subtotal_ib).toBe(60000) expect(assets.subtotal_ub).toBe(87500) - // Equity & liabilities — credit-positive + // Equity & liabilities — debit-negative (Fortnox/Visma convention) const equity = report.groups[1] expect(equity.rows[0]).toEqual({ account_number: '2099', account_name: 'Årets resultat', - ib: 52000, - ub: 72500, - period_change: 20500, + ib: -52000, + ub: -72500, + period_change: -20500, }) - expect(equity.subtotal_ub).toBe(87500) + expect(equity.subtotal_ub).toBe(-87500) expect(report.total_assets_ub).toBe(87500) - expect(report.total_equity_liabilities_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) @@ -150,12 +150,65 @@ describe('generateBalansrapport', () => { 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.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('renders class 2 rows with negative sign (god redovisningssed convention)', async () => { + // Regression test: every Swedish accounting tool (Fortnox, Visma, Bokio, + // Briox, BL) renders class 2 debit-negative on Balansrapport so that + // assets + eq_liab = beräknat resultat. Pin this convention. + 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: 100000, + closing_debit: 120000, + }), + makeRow({ + account_number: '2440', + account_name: 'Lev.skulder', + account_class: 2, + opening_credit: 40000, + closing_credit: 50000, + }), + makeRow({ + account_number: '2350', + account_name: 'Banklån', + account_class: 2, + opening_credit: 30000, + closing_credit: 25000, + }), + ]) + ) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const report = await generateBalansrapport(q.supabase as any, 'company-1', 'period-1') + + const equity = report.groups.find((g) => g.class === 2)! + // Strict < 0: every fixture row has a nonzero credit balance, so the + // convention requires every row to be strictly negative. + expect(equity.rows.every((r) => r.ib < 0)).toBe(true) + expect(equity.rows.every((r) => r.ub < 0)).toBe(true) + expect(equity.subtotal_ib).toBeLessThan(0) + expect(equity.subtotal_ub).toBeLessThan(0) + expect(report.total_equity_liabilities_ub).toBeLessThan(0) + // Sum of both sides equals beräknat resultat (here: profit residual) + expect(report.total_assets_ub + report.total_equity_liabilities_ub).toBe( + report.beraknat_resultat + ) + }) + it('is_balanced reflects trial balance balance state', async () => { const q = createQueuedMockSupabase() q.enqueue({ diff --git a/lib/reports/balansrapport.ts b/lib/reports/balansrapport.ts index 74770db8..da0d9a4d 100644 --- a/lib/reports/balansrapport.ts +++ b/lib/reports/balansrapport.ts @@ -18,10 +18,11 @@ const CLASS_LABELS: Record = { * 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. + * Sign convention: every row is shown debit-positive (debit - credit). Class 1 + * accounts (debit balance) render positive; class 2 accounts (credit balance) + * render negative. This matches Fortnox/Visma/Bokio and lets the user verify + * the balance by adding rows: total_assets_ub + total_equity_liabilities_ub + * = beraknat_resultat (the running-year P&L residual, 0 after year-end close). */ export async function generateBalansrapport( supabase: SupabaseClient, @@ -52,8 +53,8 @@ export async function generateBalansrapport( 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 ib = signedAmount(r.opening_debit, r.opening_credit) + const ub = signedAmount(r.closing_debit, r.closing_credit) const change = round2(ub - ib) if (Math.abs(ib) < 0.005 && Math.abs(ub) < 0.005) continue rows.push({ @@ -81,12 +82,12 @@ export async function generateBalansrapport( 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) + // Beräknat resultat: the residual on the balance side. With both classes in + // debit-positive sign, assets are positive and eq_liab is negative; their sum + // equals the running-year P&L residual. Trial balance guarantees + // sum_all(debit - credit) = 0, so sum_balance = -sum_pl = revenues - costs. + // After year-end close posts the result into 2099, the residual is 0. + const beraknatResultat = round2(totalAssetsUb + totalEquityLiabilitiesUb) return { groups, @@ -98,8 +99,8 @@ export async function generateBalansrapport( } } -function signedAmount(debit: number, credit: number, klass: number): number { - return klass === 1 ? debit - credit : credit - debit +function signedAmount(debit: number, credit: number): number { + return debit - credit } function round2(n: number): number { diff --git a/lib/reports/operational-report-pdf-template.tsx b/lib/reports/operational-report-pdf-template.tsx index 0de6090b..bae10e72 100644 --- a/lib/reports/operational-report-pdf-template.tsx +++ b/lib/reports/operational-report-pdf-template.tsx @@ -385,8 +385,8 @@ export function BalansrapportPDF({ report, company, generatedAt }: Balansrapport Konto Kontonamn Ingående - Utgående Förändring + Utgående {report.groups.map((group) => ( @@ -399,17 +399,17 @@ export function BalansrapportPDF({ report, company, generatedAt }: Balansrapport {row.account_number} {row.account_name} {formatAmount(row.ib)} - {formatAmount(row.ub)} {formatAmount(row.period_change)} + {formatAmount(row.ub)} ))} Summa {formatAmount(group.subtotal_ib)} - {formatAmount(group.subtotal_ub)} {formatAmount(group.subtotal_ub - group.subtotal_ib)} + {formatAmount(group.subtotal_ub)} ))}