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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user