Files
accounted/lib/reports/resultatrapport.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

167 lines
5.4 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 {
ResultatrapportReport,
ResultatrapportRow,
ResultatrapportGroup,
TrialBalanceRow,
} from '@/types'
const CLASS_LABELS: Record<number, string> = {
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 38 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<ResultatrapportReport> {
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<string, TrialBalanceRow>()
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 47) 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<string, TrialBalanceRow>
): ResultatrapportGroup[] {
const accountIndex = new Map<string, { name: string; class: number }>()
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<string, TrialBalanceRow>()
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
}