* 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>
108 lines
3.6 KiB
TypeScript
108 lines
3.6 KiB
TypeScript
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
|
||
}
|