diff --git a/DECISIONS.md b/DECISIONS.md index e2675067..642f45df 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -190,3 +190,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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. diff --git a/lib/core/bookkeeping/__tests__/year-end-service.test.ts b/lib/core/bookkeeping/__tests__/year-end-service.test.ts index 41fa86e6..2bff8231 100644 --- a/lib/core/bookkeeping/__tests__/year-end-service.test.ts +++ b/lib/core/bookkeeping/__tests__/year-end-service.test.ts @@ -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 () => { diff --git a/lib/core/bookkeeping/year-end-service.ts b/lib/core/bookkeeping/year-end-service.ts index e9a9e109..d4b64ca2 100644 --- a/lib/core/bookkeeping/year-end-service.ts +++ b/lib/core/bookkeeping/year-end-service.ts @@ -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) {