Files
accounted/lib/reports/balansrapport.ts
T
Jakob WennbergandClaude Opus 4.7 4822649c26 feat(reports): split operational Resultatrapport/Balansrapport from formal Räkning views (#363)
* feat(reports): add Resultatrapport and Balansrapport (operational reports)

Per user feedback (Anders Gengård): Swedish accounting practice (BFL 6 kap,
ÅRL Bilaga 1-3) distinguishes operational reports (Resultatrapport /
Balansrapport, used during the year for reconciliation, account-level
detail with numbers) from formal statements (Resultaträkning /
Balansräkning, part of årsbokslut/årsredovisning, ÅRL uppställningsform,
no account numbers). Until now gnubok only had a hybrid version under
"Bokslut" that did neither well.

This adds the operational pair as their own reports under a new "Löpande
rapporter" section on the Reports page. Resultaträkning and Balansräkning
under "Bokslut" are kept untouched (their yellow ÅRL 2:7 § draft
disclaimer stays — it's appropriate there). Saldobalans moves into the
new operational section.

Both new generators reuse generateTrialBalance — Balansrapport filters to
classes 1-2 with IB/UB/förändring; Resultatrapport filters to classes 3-8,
calls trial balance for the previous period (via fiscal_periods.previous_period_id)
and joins per account so the user sees current vs prior side-by-side.
Account 8999 is excluded the same way generateIncomeStatement excludes it.

13 new unit tests cover grouping, prior-period join, account-class
exclusions, zero-row filtering, and the missing-period fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(reports): show Balanscheck on Balansrapport

Addresses the most material PR review finding (raised by both the Swedish
compliance bot and Greptile): BalansrapportReport returned total_assets_ub
and total_equity_liabilities_ub but the UI never displayed them, so the
user could not verify that books balance.

generateBalansrapport now also returns:
  - beraknat_resultat = total_assets - total_eq_liab (Fortnox/Visma
    convention: residual on the balance side; equals current-year P&L
    during a running year, drops to 0 once year-end closing posts
    8999 → 2099)
  - is_balanced from the underlying trial balance — that's the meaningful
    integrity check (a missing IB row or continuity break shows up as an
    imbalanced TB)

UI gets a Balanscheck card showing the three totals plus a Balanserar /
Balanserar ej verdict.

Other PR review items (Föregående header polish, inline subtotal diff
rounding, class-8 filter scope, 2099 caveat, terminology disclaimer) are
non-blocking and deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(reports): correct BAS class labels and add bokslut caveat

Addresses three findings from the Swedish compliance bot's review of the
prior commit:

  - Class 6 label dropped the informal '(forts.)' marker — '6 Övriga
    externa kostnader' is the BAS-correct heading.
  - Balansrapport class 2 label expanded to 'Eget kapital, obeskattade
    reserver, avsättningar och skulder' to match ÅRL Bilaga 1. The old
    label hid 21xx (periodiseringsfond, överavskrivningar) and 22xx
    (avsättningar) which matter for AB users.
  - Beräknat resultat row in the Balanscheck card now reads 'Beräknat
    resultat (ej bokslutsjusterat)' so the residual is not misread as
    a confirmed profit figure pre-closing.

Skipped the bot's 8910/8999 finding: 8910 is 'Skatt på årets resultat'
(regular tax expense), not a closing account; 8999 is the only BAS
closing account, so the existing exclusion is correct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:16:17 +02:00

108 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from './trial-balance'
import type {
BalansrapportReport,
BalansrapportRow,
BalansrapportGroup,
} from '@/types'
const CLASS_LABELS: Record<number, string> = {
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<BalansrapportReport> {
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
}