Files
accounted/lib/reports/resultatrapport.ts
T
MattssonandClaude Fable 5.1 9a8291f454 feat(reports): list a booked 8999 in Resultatrapport instead of hiding it (#2457)
* feat(reports): list a booked 8999 in Resultatrapport instead of hiding it

Resultatrapport and dimension-pnl filtered account 8999 out and printed a
computed result row, so a user who books or imports the omföring of årets
resultat by hand saw huvudboken and the account-level report disagree.

Why it occurred: the filter was copied from the formal Resultaträkning,
where it is right (ÅRL's uppställningsform has no 8999 line). In the
operational report it hid a real balance. Our own bokslut verifikat never
posts 8999 (it zeroes each P&L account straight against 2099), so the only
8999 balances that exist are manual or SIE-imported ones, exactly the case
the report suppressed.

What was removed: the exclusion itself, in both operational reports, so
they keep reconciling. The XLSX bottom row is renamed to "Beräknat resultat"
to match the UI and PDF. Beräknat resultat now reads zero after such an
omföring, the Fortnox/Visma resultatrapport convention.

Why this and not the proposed shape: the user asked about Resultaträkning,
which stays as is on purpose. The bigger version (Stage 2 of #1051, showing
the bokslut verifikat via exclude-final) would zero every row of a closed
year given our closing-entry shape and is a separate decision; recorded in
DECISIONS.md.

Fixes #2455

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131jmfXGzSdyjaQoGiCoo1t

* test(reports): pin the deliberate 8999 gap between Resultatrapport and Resultaträkning

The cross-surface agreement test claimed the two operational reports are
identical; after #2455 they differ by exactly a booked 8999 omföring, and
the fixture had no such row so the invariant went silently false. Pin the
gap explicitly, and note in DECISIONS.md that this supersedes the
2026-07-29 same-profit line.

Refs #2455

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131jmfXGzSdyjaQoGiCoo1t

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-09 13:57:38 +02:00

308 lines
12 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from './trial-balance'
import { getLatestPostedVouchers } from './latest-vouchers'
import type {
LatestVoucherPerSeries,
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 listed like any other class-8 account (#2455). It only
* carries a balance when the user books or imports the omföring of årets
* resultat by hand (our own bokslut verifikat zeroes each P&L account straight
* against 2099 and never touches 8999). Listing it makes "Beräknat resultat"
* read zero after that omföring, the Fortnox/Visma resultatrapport
* convention, and keeps this report in agreement with huvudboken. The formal
* Resultaträkning (generateIncomeStatement) still excludes 8999: ÅRL's
* uppställningsform has no such line, årets resultat is always computed there.
*/
export async function generateResultatrapport(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options?: {
fromDate?: string
toDate?: string
/** SIE dim → code filter ({"6":"P001"}). P&L-safe: see trial-balance.ts. */
dimensions?: Record<string, 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 effectiveFromDate = options?.fromDate ?? period.period_start
const effectiveToDate = options?.toDate ?? period.period_end
// Exclude year-end closing entries. Without this a closed year reads ZERO on
// every line: the resultatavslut posts the mirror image of each P&L account
// into 2099 inside the same period, so the period movements this report sums
// net out exactly.
//
// 'exclude-all-year-end', NOT 'exclude-final', so this report keeps showing
// the same profit as the formal Resultaträkning. Moving
// generateIncomeStatement to 'exclude-final' is Stage 2 of #1051 and
// deliberately deferred: see DECISIONS.md:632. When that lands, this call
// site moves with it.
const currentTb = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
closingEntry: 'exclude-all-year-end',
fromDate: options?.fromDate,
toDate: options?.toDate,
dimensions: options?.dimensions,
})
const currentRows = filterPnl(currentTb.rows)
// Prior-period comparison. Full period: the previous fiscal period.
// Narrowed range: the same window shifted one year back (#862), so
// "Hittills i år" compares against the same dates last year.
// Dimension filter: no comparison at all; project codes are time-limited
// under K2/K3 (registry start/end dates), so "this code last year" may be
// a different project entirely: drop the column rather than compare
// unrelated activity (#862 review).
let priorRows: TrialBalanceRow[] = []
let priorPeriodInfo: { start: string; end: string } | null = null
const hasRange = Boolean(options?.fromDate || options?.toDate)
const isFullPeriod = !hasRange && !options?.dimensions
if (isFullPeriod) {
// Prefer the explicit continuity chain; fall back to the period that ends
// immediately before this one. The fallback keeps the comparison working
// for companies whose chain was never linked: e.g. multi-year SIE imports
// created before the importer started setting previous_period_id.
let priorPeriodId: string | null = period.previous_period_id ?? null
if (!priorPeriodId) {
const { data: priorByDate } = await supabase
.from('fiscal_periods')
.select('id')
.eq('company_id', companyId)
.lt('period_end', period.period_start)
.order('period_end', { ascending: false })
.limit(1)
priorPeriodId = priorByDate && priorByDate.length > 0 ? priorByDate[0].id : null
}
if (priorPeriodId) {
const { data: prior } = await supabase
.from('fiscal_periods')
.select('period_start, period_end')
.eq('id', priorPeriodId)
.eq('company_id', companyId)
.single()
if (prior) {
// Same exclusion as the current period: a prior year is almost always
// closed, so without it the comparison column reads zero throughout.
const priorTb = await generateTrialBalance(supabase, companyId, priorPeriodId, {
closingEntry: 'exclude-all-year-end',
})
priorRows = filterPnl(priorTb.rows)
priorPeriodInfo = { start: prior.period_start, end: prior.period_end }
}
}
} else if (hasRange && !options?.dimensions) {
// Same window, prior year. Shift the window back one year (leap-day
// clamped) and read activity from whichever fiscal period(s) cover the
// shifted dates: brutet räkenskapsår can split a calendar window across
// two periods, so merge per account. The current period itself is a
// valid source (a long first fiscal year can contain both windows).
const shiftedFrom = shiftDateOneYearBack(effectiveFromDate)
const shiftedTo = shiftDateOneYearBack(effectiveToDate)
// A window longer than a year would overlap itself when shifted back;
// that comparison is meaningless, so leave the column empty.
if (shiftedTo < effectiveFromDate) {
const { data: candidatePeriods } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('company_id', companyId)
.lte('period_start', shiftedTo)
.gte('period_end', shiftedFrom)
.order('period_start', { ascending: true })
const merged = new Map<string, TrialBalanceRow>()
let coveredAny = false
for (const p of candidatePeriods ?? []) {
const from = shiftedFrom > p.period_start ? shiftedFrom : p.period_start
const to = shiftedTo < p.period_end ? shiftedTo : p.period_end
if (from > to) continue
const tbPart = await generateTrialBalance(supabase, companyId, p.id, {
closingEntry: 'exclude-all-year-end',
fromDate: from,
toDate: to,
})
coveredAny = true
for (const row of filterPnl(tbPart.rows)) {
const existing = merged.get(row.account_number)
if (!existing) {
merged.set(row.account_number, { ...row })
} else {
existing.period_debit = round2(existing.period_debit + row.period_debit)
existing.period_credit = round2(existing.period_credit + row.period_credit)
}
}
}
if (coveredAny) {
priorRows = [...merged.values()]
priorPeriodInfo = { start: shiftedFrom, end: shiftedTo }
}
}
}
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)
// Reconciliation aid for the header: which vouchers are actually in here.
// Scoped to the reported window, so a Q1 report says something true about Q1.
// Dimension filter: skipped entirely. The report already discloses that it is
// partial, and an unfiltered voucher range next to a filtered result invites
// exactly the wrong conclusion during avstämning.
// Best-effort: a header nicety never breaks the report.
let latestVouchers: LatestVoucherPerSeries[] = []
if (!options?.dimensions) {
try {
latestVouchers = await getLatestPostedVouchers(supabase, companyId, fiscalPeriodId, {
fromDate: effectiveFromDate,
toDate: effectiveToDate,
})
} catch {
// Best-effort header line only.
}
}
return {
groups,
net_result_current: round2(netResultCurrent),
net_result_prior: round2(netResultPrior),
period: { start: effectiveFromDate, end: effectiveToDate },
prior_period: priorPeriodInfo,
...(latestVouchers.length > 0 ? { latest_vouchers: latestVouchers } : {}),
}
}
function filterPnl(rows: TrialBalanceRow[]): TrialBalanceRow[] {
return rows.filter((r) => r.account_class >= 3 && r.account_class <= 8)
}
/**
* 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.
*
* Window activity (`period_*`), not `closing_*`: when fromDate > period_start
* the trial balance rolls pre-window activity into the opening columns, so
* `closing_*` on a P&L account would silently report year-to-date amounts in
* a month/quarter window. In the full-period case P&L accounts carry no
* opening balance, so period_* equals closing_* and nothing changes there.
*/
function signedAmount(row: TrialBalanceRow): number {
return row.period_credit - row.period_debit
}
/**
* `2026-03-15` -> `2025-03-15`; leap day clamps to the target month's last
* day (`2028-02-29` -> `2027-02-28`). String math on the ISO parts: no Date
* object, no timezone edge.
*/
export function shiftDateOneYearBack(isoDate: string): string {
const [y, m, d] = isoDate.split('-').map(Number)
const year = y - 1
const daysInMonth = [31, year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
const day = Math.min(d, daysInMonth[m - 1])
return `${year}-${String(m).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
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
}