fix(balansrapport): adjust balance report to reflect correct sign con… (#375)

* 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
This commit is contained in:
Mattsson
2026-04-28 14:58:27 +02:00
committed by GitHub
parent d1267c37af
commit 2f49106f49
4 changed files with 81 additions and 27 deletions
+3 -3
View File
@@ -1073,8 +1073,8 @@ function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Ingående balans</th>
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Utgående balans</th>
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Förändring</th>
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Utgående balans</th>
</tr>
</thead>
<tbody>
@@ -1096,8 +1096,8 @@ function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string
</td>
<td className="px-4 py-1.5">{row.account_name}</td>
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">{formatAmount(row.ib)}</td>
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(row.ub)}</td>
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">{formatAmount(row.period_change)}</td>
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(row.ub)}</td>
</tr>
))}
<tr className="border-b font-medium">
@@ -1105,10 +1105,10 @@ function BalansrapportView({ periodId, onNavigateToAccount }: { periodId: string
Summa
</td>
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">{formatAmount(group.subtotal_ib)}</td>
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(group.subtotal_ub)}</td>
<td className="px-4 py-1.5 text-right tabular-nums text-muted-foreground">
{formatAmount(group.subtotal_ub - group.subtotal_ib)}
</td>
<td className="px-4 py-1.5 text-right tabular-nums">{formatAmount(group.subtotal_ub)}</td>
</tr>
</React.Fragment>
))}
+60 -7
View File
@@ -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({
+15 -14
View File
@@ -18,10 +18,11 @@ const CLASS_LABELS: Record<number, string> = {
* 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 {
@@ -385,8 +385,8 @@ export function BalansrapportPDF({ report, company, generatedAt }: Balansrapport
<Text style={[styles.tableHeaderText, styles.colAccount]}>Konto</Text>
<Text style={[styles.tableHeaderText, styles.colName]}>Kontonamn</Text>
<Text style={[styles.tableHeaderText, styles.colAmountMuted]}>Ingående</Text>
<Text style={[styles.tableHeaderText, styles.colAmount]}>Utgående</Text>
<Text style={[styles.tableHeaderText, styles.colAmountMuted]}>Förändring</Text>
<Text style={[styles.tableHeaderText, styles.colAmount]}>Utgående</Text>
</View>
{report.groups.map((group) => (
@@ -399,17 +399,17 @@ export function BalansrapportPDF({ report, company, generatedAt }: Balansrapport
<Text style={styles.colAccount}>{row.account_number}</Text>
<Text style={styles.colName}>{row.account_name}</Text>
<Text style={styles.colAmountMuted}>{formatAmount(row.ib)}</Text>
<Text style={styles.colAmount}>{formatAmount(row.ub)}</Text>
<Text style={styles.colAmountMuted}>{formatAmount(row.period_change)}</Text>
<Text style={styles.colAmount}>{formatAmount(row.ub)}</Text>
</View>
))}
<View style={styles.subtotalRow} wrap={false}>
<Text style={styles.subtotalLabel}>Summa</Text>
<Text style={[styles.subtotalAmount, { color: '#666' }]}>{formatAmount(group.subtotal_ib)}</Text>
<Text style={styles.subtotalAmount}>{formatAmount(group.subtotal_ub)}</Text>
<Text style={[styles.subtotalAmount, { color: '#666' }]}>
{formatAmount(group.subtotal_ub - group.subtotal_ib)}
</Text>
<Text style={styles.subtotalAmount}>{formatAmount(group.subtotal_ub)}</Text>
</View>
</View>
))}