Files
accounted/lib/bokslut/__tests__/k3-framework-dispositions.test.ts
T
12c59399ee fix(bokslut): remove uppskjuten skatt on obeskattade reserver in juridisk person (K3 29.37) (#1421)
* fix(bokslut): remove uppskjuten skatt on obeskattade reserver in juridisk person (K3 29.37) and confirm K3 to K2 reversion

In juridisk person K3 29.37 keeps obeskattade reserver at gross; the 79.4/20.6 split belongs to koncernredovisning. The old disposition double-counted the tax portion (result charged twice, 2240 overstated on top of gross 21xx). Removes the proposal step, POST kind, UI case, K2-to-K3 account seeding and the interim framework gate; keeps LATENT_TAX_DEFAULT_RATE for analytical soliditet presentation. Also adds the K3-to-K2 consequence confirmation dialog in settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(decisions): scope the batch log to shipped code and record the 29.37 election nuance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(settings): stop promising deferred-tax accounting the engine no longer does

The framework help text and the K2-to-K3 confirmation both told the user that switching to K3 means uppskjuten skatt is recognised separately on 2240/8940 with a 79.4/20.6 split. This PR removes exactly that behaviour, so the copy would have promised something the product does not do, which is the defect class this batch exists to remove. Both now describe what actually happens: kassaflodesanalys, komponentavskrivning and a wider note set, with obeskattade reserver carried gross per K3 29.37.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-06 10:05:15 +02:00

299 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* K3 framework integration test for the dispositions builder.
*
* Since 2026-08-05 (founder decision, K3 29.37) the builder books NO
* uppskjuten skatt on obeskattade reserver: in juridisk person the reserves
* stay at gross and the 79.4/20.6 split belongs to koncernredovisning.
* These tests pin that the proposal chain is framework-independent and that
* no uppskjuten_skatt proposal ever appears, alongside the disposition math
* that remains (periodiseringsfond, overavskrivningar, bolagsskatt).
*
* Mocks generateIncomeStatement directly so the test can drive numeric
* inputs without touching the database.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/reports/income-statement', () => ({
generateIncomeStatement: vi.fn(),
}))
vi.mock('@/lib/reports/trial-balance', () => ({
generateTrialBalance: vi.fn(),
}))
vi.mock('@/lib/bokslut/reserves/overavskrivningar-calculator', () => ({
calculateOveravskrivningar: vi.fn(),
}))
import { buildDispositionsProposal } from '../dispositions-proposal-builder'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { calculateOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-calculator'
interface ChainableMock {
from: ReturnType<typeof vi.fn>
}
function makeSupabase(opts: {
entityType: 'aktiebolag' | 'enskild_firma'
accountingFramework: 'k2' | 'k3' | null
periodEnd?: string
/** Existing periodiseringsfond rows the builder queries. */
periodiseringsfondRows?: Array<{ account_number: string; cohort_year: number; balance: number; must_return_this_year: boolean }>
}): ChainableMock {
const periodEnd = opts.periodEnd ?? '2026-12-31'
// The builder makes several .from(...) queries. We respond per-table.
const from = vi.fn((table: string) => {
if (table === 'fiscal_periods') {
return {
select: () => ({
eq: () => ({
eq: () => ({
// The builder's own period read.
single: () =>
Promise.resolve({
data: {
id: 'fp1',
name: '2026',
period_start: '2026-01-01',
period_end: periodEnd,
},
error: null,
}),
// sumPostedYearEndDispositions reads closing_entry_id with
// .eq('id').eq('company_id').maybeSingle() to exclude the final
// bokslutsverifikation from the add-back (#1051).
maybeSingle: () =>
Promise.resolve({ data: { closing_entry_id: null }, error: null }),
}),
}),
}),
}
}
if (table === 'company_settings') {
return {
select: () => ({
eq: () => ({
maybeSingle: () =>
Promise.resolve({
data: { entity_type: opts.entityType },
error: null,
}),
}),
}),
}
}
if (table === 'companies') {
return {
select: () => ({
eq: () => ({
maybeSingle: () =>
Promise.resolve({
data: { accounting_framework: opts.accountingFramework },
error: null,
}),
}),
}),
}
}
if (table === 'journal_entry_lines') {
// Used by listExistingPeriodiseringsfonder and calculateSarskildLoneskatt.
// We return either the test-supplied periodiseringsfond rows (when the
// builder is walking 21xx) or an empty SLP result.
const rows = opts.periodiseringsfondRows
? opts.periodiseringsfondRows.flatMap((f) => [
{
account_number: f.account_number,
credit_amount: f.balance,
debit_amount: 0,
entry_date: `${f.cohort_year}-12-31`,
journal_entries: { entry_date: `${f.cohort_year}-12-31`, status: 'posted' },
},
])
: []
return {
select: () => {
// Make the chainable builder resolve to { data: rows, error: null }
const result: { data: typeof rows; error: null } = { data: rows, error: null }
const handler: ProxyHandler<object> = {
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) => resolve(result)
}
return () => new Proxy({}, handler)
},
}
return new Proxy({}, handler)
},
}
}
// Catch-all chainable that resolves to empty.
const handler: ProxyHandler<object> = {
get(_t, prop) {
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({ data: null, error: null })
}
return () => new Proxy({}, handler)
},
}
return new Proxy({}, handler)
})
return { from } as ChainableMock
}
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(calculateOveravskrivningar).mockResolvedValue({
status: 'not_applicable',
proposal: null,
warning: null,
currentReserve: 0,
currentPeriodChange: 0,
targetReserve: 0,
maximumSignedChange: 0,
})
// Zero result so the builder doesn't propose a new avsättning: keeps the
// 21xx balance stable at the trial-balance value, which makes the latent
// tax math testable in isolation.
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 0,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
vi.mocked(generateTrialBalance).mockResolvedValue({
rows: [
// 100 000 in periodiseringsfond → 20 600 latent tax target.
{
account_number: '2125',
account_name: 'Periodiseringsfond 2025',
account_class: 2,
closing_credit: 100_000,
closing_debit: 0,
opening_credit: 0,
opening_debit: 0,
period_credit: 100_000,
period_debit: 0,
},
// No existing 2240 balance.
],
totalDebit: 0,
totalCredit: 100_000,
isBalanced: false,
} as unknown as Awaited<ReturnType<typeof generateTrialBalance>>)
})
describe('buildDispositionsProposal: K3 framework', () => {
it('never proposes uppskjuten skatt on obeskattade reserver, K3 included (K3 29.37)', async () => {
const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k3' })
const result = await buildDispositionsProposal(
supabase as unknown as Parameters<typeof buildDispositionsProposal>[0],
'co',
'fp1',
)
expect(result.entityType).toBe('aktiebolag')
// In juridisk person obeskattade reserver stay at gross: no 8940/2240
// proposal exists, and no proposal line may touch those accounts.
expect(result.proposals.map((p) => p.kind)).not.toContain('uppskjuten_skatt')
const touched = result.proposals.flatMap((p) => p.lines.map((l) => l.account_number))
expect(touched).not.toContain('8940')
expect(touched).not.toContain('2240')
})
it('computes bolagsskatt on the result AFTER the periodiseringsfond avsättning', async () => {
// Regression (customer report): the preview computed bolagsskatt on the
// pre-disposition net result, ignoring the avsättning it proposes in the
// same snapshot: so the previewed/agent-facing tax was too high and
// diverged from ÅR/INK2. With a 1,000,000 result and no existing fonder:
// avsättning = 25 % × 1,000,000 = 250,000
// skattem. res = 1,000,000 − 250,000 = 750,000
// bolagsskatt = 20.6 % × 750,000 = 154,500 (NOT 206,000)
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 1_000_000,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k2' })
const result = await buildDispositionsProposal(
supabase as unknown as Parameters<typeof buildDispositionsProposal>[0],
'co',
'fp1',
)
const avsattning = result.proposals.find((p) => p.kind === 'periodiseringsfond_avsattning')
const bolagsskatt = result.proposals.find((p) => p.kind === 'bolagsskatt')
expect(avsattning?.amount).toBe(250_000)
expect(bolagsskatt?.amount).toBe(154_500)
})
it('includes excess depreciation in the periodiseringsfond and tax bases', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 1_000_000,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
vi.mocked(calculateOveravskrivningar).mockResolvedValue({
status: 'ready',
proposal: {
kind: 'overavskrivningar',
label: 'Överavskrivningar',
description: 'Skillnad mellan bokförd och skattemässig avskrivning.',
amount: 100_000,
signedAmount: 100_000,
lines: [
{ account_number: '8853', debit_amount: 100_000, credit_amount: 0 },
{ account_number: '2153', debit_amount: 0, credit_amount: 100_000 },
],
warnings: [],
},
warning: null,
currentReserve: 0,
currentPeriodChange: 0,
targetReserve: 100_000,
maximumSignedChange: 100_000,
})
const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k2' })
const result = await buildDispositionsProposal(
supabase as unknown as Parameters<typeof buildDispositionsProposal>[0],
'co',
'fp1',
)
const avsattning = result.proposals.find((p) => p.kind === 'periodiseringsfond_avsattning')
const bolagsskatt = result.proposals.find((p) => p.kind === 'bolagsskatt')
expect(avsattning?.amount).toBe(225_000)
expect(bolagsskatt?.amount).toBe(139_050)
})
it('does NOT add an uppskjuten_skatt proposal for K2 aktiebolag', async () => {
const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k2' })
const result = await buildDispositionsProposal(
supabase as unknown as Parameters<typeof buildDispositionsProposal>[0],
'co',
'fp1',
)
expect(result.entityType).toBe('aktiebolag')
expect(result.proposals.map((p) => p.kind)).not.toContain('uppskjuten_skatt')
})
it('defaults to K2 when accounting_framework is null on the company row', async () => {
const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: null })
const result = await buildDispositionsProposal(
supabase as unknown as Parameters<typeof buildDispositionsProposal>[0],
'co',
'fp1',
)
expect(result.proposals.map((p) => p.kind)).not.toContain('uppskjuten_skatt')
})
it('does NOT add an uppskjuten_skatt proposal for enskild firma even if mislabelled K3', async () => {
// EF should never carry K3 (validation rejects it on the API side), but if
// somehow set the EF branch returns early with an empty proposal list.
const supabase = makeSupabase({ entityType: 'enskild_firma', accountingFramework: 'k3' })
const result = await buildDispositionsProposal(
supabase as unknown as Parameters<typeof buildDispositionsProposal>[0],
'co',
'fp1',
)
expect(result.entityType).toBe('enskild_firma')
expect(result.proposals).toEqual([])
})
})