fix(bokslut): derive preview netResult from the 2099/2010 closing amount (#1045)

The Arets resultat summary card on the bokslut preview step read its
figure from generateIncomeStatement, which excludes entries tagged
source_type='year_end'. Bokslut-flow entries (annual depreciation,
bokslutsdispositioner) carry that tag, so the card showed the
pre-depreciation result while the bokslutsverifikation table below it
(built from the unfiltered trial balance) included depreciation in the
2099 balancing line.

previewYearEndClosing now derives netResult from the closing-lines
totals before the balancing line is appended: it equals, by
construction, the signed amount transferred to 2099 (AB) or 2010 (EF);
positive = credit = vinst, negative = debit = forlust. The posted
verifikat is unchanged: executeYearEndClosing only consumes
preview.closingLines, never netResult.

Fixes #766

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-17 11:51:10 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 88f53350de
commit 03fd1b60b7
3 changed files with 66 additions and 6 deletions
+1
View File
@@ -190,3 +190,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-07-17] Settings PUT keeps update(body) without a pick() allow-list: UpdateSettingsSchema is the write boundary (z.object strips unknown keys; company_id/onboarding_complete are not in the schema, org_number is deleted post-onboarding), so the mass-assignment finding does not apply.
[2026-07-17] Turning vat_registered off (or vat_has_eu_trade off) now coerces the dependent flags (vat_taxable_base_over_40m, vat_has_eu_trade, periodisk_sammanstallning_enabled) to false server-side instead of 400-ing on the stale stored combination; explicitly enabling PS without registration or EU trade still 400s. PS period/filing-method preferences are deliberately preserved while PS is disabled (they are inert until re-enabled).
[2026-07-17] Kept the cron's cross-tenant company_settings/deadlines scans on the service client: a daily all-company repair job is inherently cross-tenant, is cron-secret-gated, and per-company scoping would turn one paginated query into N queries; the per-company writes remain scoped by company_id inside the generator.
[2026-07-17] YearEndPreview.netResult now derived from the closing-lines totals (the signed 2099/2010 transfer) instead of generateIncomeStatement: the income statement excludes source_type='year_end' entries, so bokslut-flow depreciation/dispositioner were missing from the preview summary card while the bokslutsverifikation table included them (issue #766); patching the income statement instead was rejected because its exclusion is load-bearing (post-closing RR would collapse to zero) and executeYearEndClosing never reads netResult.
@@ -407,8 +407,10 @@ describe('previewYearEndClosing', () => {
{ data: { period_end: '2024-12-31' }, error: null },
]
// Deliberately different from the trial-balance-derived result: netResult
// must NOT come from the income statement anymore (issue #766).
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 150000,
net_result: 999,
} as never)
vi.mocked(generateTrialBalance).mockResolvedValue({
@@ -426,10 +428,62 @@ describe('previewYearEndClosing', () => {
const preview = await previewYearEndClosing(supabase as never, 'company-1', 'user-1', 'fp-1')
expect(preview.netResult).toBe(150000)
expect(generateIncomeStatement).not.toHaveBeenCalled()
expect(preview.closingAccount).toBe('2099')
expect(preview.closingAccountName).toBe('Årets resultat')
expect(preview.closingLines.length).toBeGreaterThanOrEqual(3)
expect(preview.resultAccountSummary).toHaveLength(3)
// Profit: the 2099 line is a credit equal to netResult.
const closingLine2099 = preview.closingLines.find((l) => l.account_number === '2099')
expect(closingLine2099).toBeDefined()
expect(closingLine2099?.debit_amount).toBe(0)
expect(closingLine2099?.credit_amount).toBe(preview.netResult)
})
it('includes year_end-tagged depreciation in netResult and matches the 2099 line (issue #766)', async () => {
results = [
// 0: fetch company_settings (.single)
{ data: { entity_type: 'aktiebolag' }, error: null },
// 1: fetch fiscal period for closing date (.single)
{ data: { period_end: '2024-12-31' }, error: null },
]
// Old behavior took netResult from the income statement, which excludes
// source_type='year_end' entries: it would have reported the
// pre-depreciation loss of 10 000. The mock returns that stale value to
// prove the service no longer uses it.
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: -10000,
} as never)
// Trial balance WITHOUT excludeYearEndClosing sees the bokslut-flow
// depreciation verifikat (78xx, source_type='year_end').
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [
{ account_number: '3001', account_name: 'Tjänsteintäkter', account_class: 3, closing_debit: 0, closing_credit: 90000 },
{ account_number: '5010', account_name: 'Lokalhyra', account_class: 5, closing_debit: 100000, closing_credit: 0 },
{ account_number: '7832', account_name: 'Avskrivningar inventarier', account_class: 7, closing_debit: 2000, closing_credit: 0 },
],
isBalanced: true,
totalDebit: 102000,
totalCredit: 90000,
} as never)
const supabase = makeClient()
const preview = await previewYearEndClosing(supabase as never, 'company-1', 'user-1', 'fp-1')
// Loss including depreciation: 90 000 - 100 000 - 2 000 = -12 000,
// not the pre-depreciation -10 000.
expect(preview.netResult).toBe(-12000)
// The summary figure equals the signed amount on the 2099 balancing line:
// a loss is a debit to 2099.
const closingLine2099 = preview.closingLines.find((l) => l.account_number === '2099')
expect(closingLine2099).toBeDefined()
expect(closingLine2099?.debit_amount).toBe(12000)
expect(closingLine2099?.credit_amount).toBe(0)
expect(preview.netResult).toBe(-(closingLine2099?.debit_amount ?? NaN))
})
it('uses 2010 for EF entity type', async () => {
+10 -5
View File
@@ -6,7 +6,6 @@ import { createLogger } from '@/lib/logger'
const log = createLogger('year-end-service')
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { lockPeriod, closePeriod, createNextPeriod, findNextPeriod } from './period-service'
import { generateResultAppropriation } from './result-appropriation-service'
import {
@@ -310,10 +309,6 @@ export async function previewYearEndClosing(
? 'Eget kapital'
: 'Årets resultat'
// Get income statement for net result
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
const netResult = incomeStatement.net_result
// Get trial balance for individual account balances in class 3-8
const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
const resultAccounts = rows.filter(
@@ -361,6 +356,16 @@ export async function previewYearEndClosing(
// If negative (loss): debit to equity (2099/2010)
const totalClosingDebit = closingLines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalClosingCredit = closingLines.reduce((sum, l) => sum + l.credit_amount, 0)
// netResult must equal, by construction, the signed amount transferred to the
// closing account (2099/2010) by the balancing line below: positive = credit
// = vinst, negative = debit = forlust. It is deliberately NOT taken from
// generateIncomeStatement: that report excludes source_type='year_end'
// entries, and bokslut-flow entries (annual depreciation, dispositioner)
// carry that tag, so the income-statement figure misses them and the
// summary card would mismatch the bokslutsverifikation table (issue #766).
const netResult = roundOre(totalClosingDebit - totalClosingCredit)
const balancingAmount = roundOre(Math.abs(totalClosingDebit - totalClosingCredit))
if (balancingAmount > ORE_TOLERANCE) {