* 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>
167 lines
5.4 KiB
TypeScript
167 lines
5.4 KiB
TypeScript
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 3–8 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 4–7) 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
|
||
}
|