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>
This commit is contained in:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
8f38baca05
commit
12c59399ee
+56
-1
@@ -10,7 +10,6 @@ import { createMockRequest, parseJsonResponse } from '@/tests/helpers'
|
||||
|
||||
vi.mock('@/lib/bokslut/dispositions-proposal-builder', () => ({
|
||||
buildDispositionsProposal: vi.fn(),
|
||||
buildLatentTaxProposal: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bokslut/tax-provision/tax-adjustment-service', () => ({
|
||||
@@ -107,6 +106,44 @@ function periodClient(period: unknown, error: unknown = null) {
|
||||
return { from: vi.fn().mockReturnValue(builder) }
|
||||
}
|
||||
|
||||
/** Like periodClient but also answers a companies.accounting_framework
|
||||
* lookup. Pass null to simulate a missing company row. */
|
||||
function frameworkClient(period: unknown, framework: string | null) {
|
||||
const periodBuilder = {
|
||||
select: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
single: vi.fn().mockResolvedValue({ data: period, error: null }),
|
||||
}
|
||||
periodBuilder.select.mockReturnValue(periodBuilder)
|
||||
periodBuilder.eq.mockReturnValue(periodBuilder)
|
||||
const companyBuilder = {
|
||||
select: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
maybeSingle: vi.fn().mockResolvedValue({
|
||||
data: framework === null ? null : { accounting_framework: framework },
|
||||
error: null,
|
||||
}),
|
||||
}
|
||||
companyBuilder.select.mockReturnValue(companyBuilder)
|
||||
companyBuilder.eq.mockReturnValue(companyBuilder)
|
||||
return {
|
||||
from: vi.fn((table: string) =>
|
||||
table === 'companies' ? companyBuilder : periodBuilder,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const openPeriod = {
|
||||
id: 'period-1',
|
||||
name: '2025',
|
||||
period_start: '2025-01-01',
|
||||
period_end: '2025-12-31',
|
||||
opening_balance_entry_id: null,
|
||||
is_closed: false,
|
||||
locked_at: null,
|
||||
closing_entry_id: null,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null })
|
||||
@@ -625,4 +662,22 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () =
|
||||
expect(body.error.details).toEqual({ bookedAmount: 123_181, expectedAmount: 123_180 })
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects the removed uppskjuten_skatt kind as a validation error (K3 29.37)', async () => {
|
||||
// The kind was removed 2026-08-05: in juridisk person obeskattade
|
||||
// reserver stay at gross (K3 29.37), so no deferred-tax disposition
|
||||
// exists for ANY framework. Old clients sending it get schema 400.
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
supabase: frameworkClient(openPeriod, 'k3'),
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { status } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await post({ items: [{ kind: 'uppskjuten_skatt' }] }),
|
||||
)
|
||||
|
||||
expect(status).toBe(400)
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,10 +26,7 @@ import { proposeOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivning
|
||||
import { calculateOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-calculator'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import {
|
||||
buildDispositionsProposal,
|
||||
buildLatentTaxProposal,
|
||||
} from '@/lib/bokslut/dispositions-proposal-builder'
|
||||
import { buildDispositionsProposal } from '@/lib/bokslut/dispositions-proposal-builder'
|
||||
import type { ProposedDisposition } from '@/lib/bokslut/types'
|
||||
import type { JournalEntry } from '@/types'
|
||||
|
||||
@@ -55,9 +52,6 @@ const DISPOSITION_ORDER: Record<string, number> = {
|
||||
sarskild_loneskatt: 2,
|
||||
periodiseringsfond_avsattning: 3,
|
||||
bolagsskatt: 4,
|
||||
// K3 only: posts last because it depends on the closing 21xx balance,
|
||||
// which only stabilises once avsättning / återföring have been applied.
|
||||
uppskjuten_skatt: 5,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -174,11 +168,6 @@ const ItemSchema = z.discriminatedUnion('kind', [
|
||||
.enum(['machinery_equipment', 'building', 'immaterial', 'group'])
|
||||
.optional(),
|
||||
}),
|
||||
// K3 only: uppskjuten skatt provision. Server recomputes the amount from
|
||||
// current 2240 + 21xx state so the client cannot override it.
|
||||
z.object({
|
||||
kind: z.literal('uppskjuten_skatt'),
|
||||
}),
|
||||
])
|
||||
|
||||
const PostBodySchema = z.object({
|
||||
@@ -498,15 +487,6 @@ async function computeProposal(
|
||||
computation: calculation.proposal.computation,
|
||||
})
|
||||
}
|
||||
case 'uppskjuten_skatt':
|
||||
// Server-only: recompute from current TB (which already reflects any
|
||||
// 21xx postings that committed earlier in this batch). The client
|
||||
// sends no amount: the calculator owns the K3 split.
|
||||
return buildLatentTaxProposal({
|
||||
supabase,
|
||||
companyId,
|
||||
fiscalPeriodId,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@ describe('PATCH /api/company/current', () => {
|
||||
it('updates the framework for an aktiebolag', async () => {
|
||||
enqueue({ data: { entity_type: 'aktiebolag' } }) // entity check
|
||||
enqueue({ data: { id: 'company-1', accounting_framework: 'k3', entity_type: 'aktiebolag' } }) // update
|
||||
enqueue({ data: null }) // K3 latent-tax account upsert
|
||||
|
||||
const req = createMockRequest('/api/company/current', {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -3,21 +3,10 @@ import { requireAuth } from '@/lib/auth/require-auth'
|
||||
import { withRouteContext } from '@/lib/api/with-route-context'
|
||||
import { validateBody } from '@/lib/api/validate'
|
||||
import { AccountingFrameworkSchema } from '@/lib/api/schemas'
|
||||
import { getBASReference } from '@/lib/bookkeeping/bas-reference'
|
||||
import { createLogger } from '@/lib/logger'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
|
||||
const log = createLogger('api/company/current')
|
||||
|
||||
// BAS 2026 accounts required for K3's uppskjuten skatt (latent tax) entries.
|
||||
// Both rows carry k2_excluded=true in lib/bookkeeping/bas-data so they are
|
||||
// NOT seeded by seed_chart_of_accounts() for K2 companies. When a company
|
||||
// opts into K3 we backfill them here so the engine can resolve them by
|
||||
// account_number when the first latent-tax entry is posted.
|
||||
const K3_LATENT_TAX_ACCOUNTS = ['2240', '8940'] as const
|
||||
|
||||
/**
|
||||
* GET /api/company/current
|
||||
*
|
||||
@@ -73,7 +62,7 @@ const PatchBodySchema = z.object({
|
||||
export const PATCH = withRouteContext(
|
||||
'company.update_current',
|
||||
async (request, ctx) => {
|
||||
const { supabase, companyId, user } = ctx
|
||||
const { supabase, companyId } = ctx
|
||||
|
||||
const validation = await validateBody(request, PatchBodySchema)
|
||||
if (!validation.success) return validation.response
|
||||
@@ -128,56 +117,6 @@ export const PATCH = withRouteContext(
|
||||
return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 })
|
||||
}
|
||||
|
||||
// When opting in to K3, ensure the two latent-tax (uppskjuten skatt)
|
||||
// accounts exist in the company's chart of accounts. The base seed skips
|
||||
// them for K2 companies via k2_excluded=true, so without this backfill
|
||||
// the engine cannot resolve account_id for the first latent-tax post.
|
||||
// Wrapped in try/catch so a CoA insert failure does not block the
|
||||
// framework update: the user can still re-trigger the seed later.
|
||||
// The reverse switch (K3 → K2) intentionally keeps the rows for audit
|
||||
// history; the legal record of past K3 postings must remain intact.
|
||||
if (data.accounting_framework === 'k3') {
|
||||
try {
|
||||
const rows = K3_LATENT_TAX_ACCOUNTS.map(accountNumber => {
|
||||
const basRef = getBASReference(accountNumber)
|
||||
if (!basRef) return null
|
||||
return {
|
||||
user_id: user.id,
|
||||
company_id: companyId,
|
||||
account_number: basRef.account_number,
|
||||
account_name: basRef.account_name,
|
||||
account_class: basRef.account_class,
|
||||
account_group: basRef.account_group,
|
||||
account_type: basRef.account_type,
|
||||
normal_balance: basRef.normal_balance,
|
||||
sru_code: basRef.sru_code,
|
||||
k2_excluded: basRef.k2_excluded,
|
||||
plan_type: 'full_bas',
|
||||
is_active: true,
|
||||
is_system_account: true,
|
||||
description: basRef.description,
|
||||
}
|
||||
}).filter((row): row is NonNullable<typeof row> => row !== null)
|
||||
|
||||
if (rows.length > 0) {
|
||||
const { error: seedError } = await supabase
|
||||
.from('chart_of_accounts')
|
||||
.upsert(rows, { onConflict: 'company_id,account_number', ignoreDuplicates: true })
|
||||
if (seedError) {
|
||||
log.error('Failed to seed K3 latent-tax accounts', {
|
||||
companyId,
|
||||
error: seedError.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.error('Unexpected error seeding K3 latent-tax accounts', {
|
||||
companyId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ data })
|
||||
},
|
||||
{ requireWrite: true },
|
||||
|
||||
Reference in New Issue
Block a user