feat(bokslut): calculate and book overavskrivningar (2150/8850) (#1379)
Add an automatic excess-depreciation calculator for machinery and equipment under IL 18 kap: 30-rule and 20-rule residuals (fiscal-period aware for short and long years), ledger vs asset-register reconciliation, fail-closed blocking states, and a signed proposal that books via the dispositions flow (8853/2153). Releases of an over-target reserve are mandatory and not overridable; increases are optional and capped server-side. Fixes #323 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
16fbcefbbc
commit
6782da3e9e
+255
@@ -35,6 +35,10 @@ vi.mock('@/lib/bokslut/reserves/periodiseringsfond-service', async (importOrigin
|
||||
return { ...actual, listExistingPeriodiseringsfonder: vi.fn() }
|
||||
})
|
||||
|
||||
vi.mock('@/lib/bokslut/reserves/overavskrivningar-calculator', () => ({
|
||||
calculateOveravskrivningar: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||
createJournalEntry: vi.fn(),
|
||||
}))
|
||||
@@ -66,6 +70,7 @@ import {
|
||||
} from '@/lib/bokslut/tax-provision/bolagsskatt-calculator'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { listExistingPeriodiseringsfonder } from '@/lib/bokslut/reserves/periodiseringsfond-service'
|
||||
import { calculateOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-calculator'
|
||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||
import { POST, PUT } from '../route'
|
||||
|
||||
@@ -133,6 +138,15 @@ beforeEach(() => {
|
||||
})
|
||||
vi.mocked(getBookedBolagsskatt).mockResolvedValue(0)
|
||||
vi.mocked(listExistingPeriodiseringsfonder).mockResolvedValue([])
|
||||
vi.mocked(calculateOveravskrivningar).mockResolvedValue({
|
||||
status: 'not_applicable',
|
||||
proposal: null,
|
||||
warning: null,
|
||||
currentReserve: 0,
|
||||
currentPeriodChange: 0,
|
||||
targetReserve: 0,
|
||||
maximumSignedChange: 0,
|
||||
})
|
||||
vi.mocked(calculateBolagsskatt).mockResolvedValue({
|
||||
kind: 'bolagsskatt',
|
||||
label: 'Bolagsskatt 20,6 %',
|
||||
@@ -343,6 +357,247 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () =
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('posts a validated excess depreciation increase through the bookkeeping engine', async () => {
|
||||
const supabase = periodClient({
|
||||
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,
|
||||
})
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
supabase,
|
||||
error: null,
|
||||
})
|
||||
vi.mocked(calculateOveravskrivningar).mockResolvedValue({
|
||||
status: 'ready',
|
||||
proposal: {
|
||||
kind: 'overavskrivningar',
|
||||
label: 'Överavskrivningar',
|
||||
description: 'Skillnad mellan bokförd och skattemässig avskrivning.',
|
||||
amount: 10_000,
|
||||
signedAmount: 10_000,
|
||||
lines: [
|
||||
{ account_number: '8853', debit_amount: 10_000, credit_amount: 0 },
|
||||
{ account_number: '2153', debit_amount: 0, credit_amount: 10_000 },
|
||||
],
|
||||
warnings: [],
|
||||
computation: {
|
||||
openingBookValue: 80_000,
|
||||
closingBookValue: 70_000,
|
||||
openingTaxValue: 80_000,
|
||||
closingTaxValue: 60_000,
|
||||
taxDepreciation: 20_000,
|
||||
bookedDepreciation: 10_000,
|
||||
maxAdditionalDepreciation: 10_000,
|
||||
targetReserve: 10_000,
|
||||
currentReserve: 0,
|
||||
method: '30-rule',
|
||||
},
|
||||
},
|
||||
warning: null,
|
||||
currentReserve: 0,
|
||||
currentPeriodChange: 0,
|
||||
targetReserve: 10_000,
|
||||
maximumSignedChange: 10_000,
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { created: Array<{ kind: string }> }
|
||||
}>(
|
||||
await post({
|
||||
items: [{ kind: 'overavskrivningar', additionalAmount: 8_000 }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.created).toHaveLength(1)
|
||||
expect(createJournalEntry).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
fiscal_period_id: 'period-1',
|
||||
entry_date: '2025-12-31',
|
||||
source_type: 'year_end',
|
||||
lines: [
|
||||
{
|
||||
account_number: '8853',
|
||||
debit_amount: 8_000,
|
||||
credit_amount: 0,
|
||||
line_description: 'Förändring av överavskrivningar',
|
||||
},
|
||||
{
|
||||
account_number: '2153',
|
||||
debit_amount: 0,
|
||||
credit_amount: 8_000,
|
||||
line_description: 'Ackumulerade överavskrivningar',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns 409 when a stale excess depreciation amount exceeds the current maximum', async () => {
|
||||
const supabase = periodClient({
|
||||
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,
|
||||
})
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
supabase,
|
||||
error: null,
|
||||
})
|
||||
vi.mocked(calculateOveravskrivningar).mockResolvedValue({
|
||||
status: 'ready',
|
||||
proposal: {
|
||||
kind: 'overavskrivningar',
|
||||
label: 'Överavskrivningar',
|
||||
description: 'Skillnad mellan bokförd och skattemässig avskrivning.',
|
||||
amount: 5_000,
|
||||
signedAmount: 5_000,
|
||||
lines: [
|
||||
{ account_number: '8853', debit_amount: 5_000, credit_amount: 0 },
|
||||
{ account_number: '2153', debit_amount: 0, credit_amount: 5_000 },
|
||||
],
|
||||
warnings: [],
|
||||
},
|
||||
warning: null,
|
||||
currentReserve: 0,
|
||||
currentPeriodChange: 0,
|
||||
targetReserve: 5_000,
|
||||
maximumSignedChange: 5_000,
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(
|
||||
await post({
|
||||
items: [{ kind: 'overavskrivningar', additionalAmount: 6_000 }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(status).toBe(409)
|
||||
expect(body.error.code).toBe('CONFLICT')
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('posts a required excess depreciation release with reversed lines', async () => {
|
||||
const supabase = periodClient({
|
||||
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,
|
||||
})
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
supabase,
|
||||
error: null,
|
||||
})
|
||||
vi.mocked(calculateOveravskrivningar).mockResolvedValue({
|
||||
status: 'ready',
|
||||
proposal: {
|
||||
kind: 'overavskrivningar',
|
||||
label: 'Återföring av överavskrivningar',
|
||||
description: 'Den skattemässiga reserven måste minskas.',
|
||||
amount: 10_000,
|
||||
signedAmount: -10_000,
|
||||
lines: [
|
||||
{
|
||||
account_number: '2153',
|
||||
debit_amount: 10_000,
|
||||
credit_amount: 0,
|
||||
line_description: 'Upplösning ackumulerade överavskrivningar',
|
||||
},
|
||||
{
|
||||
account_number: '8853',
|
||||
debit_amount: 0,
|
||||
credit_amount: 10_000,
|
||||
line_description: 'Förändring av överavskrivningar',
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
required: true,
|
||||
},
|
||||
warning: null,
|
||||
currentReserve: 20_000,
|
||||
currentPeriodChange: 0,
|
||||
targetReserve: 10_000,
|
||||
maximumSignedChange: -10_000,
|
||||
})
|
||||
|
||||
const { status } = await parseJsonResponse(
|
||||
await post({
|
||||
items: [{ kind: 'overavskrivningar', additionalAmount: -10_000 }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(createJournalEntry).toHaveBeenCalledWith(
|
||||
supabase,
|
||||
'company-1',
|
||||
'user-1',
|
||||
expect.objectContaining({
|
||||
lines: [
|
||||
{
|
||||
account_number: '2153',
|
||||
debit_amount: 10_000,
|
||||
credit_amount: 0,
|
||||
line_description: 'Upplösning ackumulerade överavskrivningar',
|
||||
},
|
||||
{
|
||||
account_number: '8853',
|
||||
debit_amount: 0,
|
||||
credit_amount: 10_000,
|
||||
line_description: 'Förändring av överavskrivningar',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not post a duplicate excess depreciation decision', async () => {
|
||||
const supabase = periodClient({
|
||||
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,
|
||||
})
|
||||
requireAuthMock.mockResolvedValue({
|
||||
user: { id: 'user-1' },
|
||||
supabase,
|
||||
error: null,
|
||||
})
|
||||
|
||||
const { status, body } = await parseJsonResponse<{
|
||||
data: { created: Array<{ kind: string }> }
|
||||
}>(
|
||||
await post({
|
||||
items: [{ kind: 'overavskrivningar', additionalAmount: 8_000 }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(status).toBe(200)
|
||||
expect(body.data.created).toEqual([])
|
||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 409 instead of posting over a different booked tax amount', async () => {
|
||||
const supabase = periodClient({
|
||||
id: 'period-1',
|
||||
|
||||
@@ -23,7 +23,9 @@ import {
|
||||
proposeAteforing,
|
||||
} from '@/lib/bokslut/reserves/periodiseringsfond-service'
|
||||
import { proposeOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-service'
|
||||
import { calculateOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-calculator'
|
||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import {
|
||||
buildDispositionsProposal,
|
||||
buildLatentTaxProposal,
|
||||
@@ -243,6 +245,13 @@ export const POST = withRouteContext(
|
||||
|
||||
return NextResponse.json({ data: { created } })
|
||||
} catch (err) {
|
||||
if (err instanceof OveravskrivningarConflictError) {
|
||||
return errorResponseFromCode('CONFLICT', opLog, {
|
||||
requestId,
|
||||
messageSv: err.message,
|
||||
messageEn: err.messageEn,
|
||||
})
|
||||
}
|
||||
if (err instanceof TaxProvisionConflictError) {
|
||||
return errorResponseFromCode('CONFLICT', opLog, {
|
||||
requestId,
|
||||
@@ -295,6 +304,16 @@ class TaxProvisionConflictError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
class OveravskrivningarConflictError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly messageEn: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'OveravskrivningarConflictError'
|
||||
}
|
||||
}
|
||||
|
||||
async function computeProposal(
|
||||
item: PostItem,
|
||||
supabase: Parameters<typeof calculateBolagsskatt>[0],
|
||||
@@ -442,11 +461,43 @@ async function computeProposal(
|
||||
if (result.proposals.length === 0) return null
|
||||
return mergeAteforingProposals(result.proposals)
|
||||
}
|
||||
case 'overavskrivningar':
|
||||
return proposeOveravskrivningar({
|
||||
additionalAmount: item.additionalAmount,
|
||||
category: item.category,
|
||||
case 'overavskrivningar': {
|
||||
if (item.category && item.category !== 'machinery_equipment') {
|
||||
throw new OveravskrivningarConflictError(
|
||||
'Den automatiska beräkningen omfattar endast maskiner och inventarier enligt IL 18 kap.',
|
||||
'The automatic calculation only covers machinery and equipment under Chapter 18 of the Income Tax Act.',
|
||||
)
|
||||
}
|
||||
const calculation = await calculateOveravskrivningar({
|
||||
supabase,
|
||||
companyId,
|
||||
fiscalPeriod: period,
|
||||
})
|
||||
if (calculation.status === 'blocked') {
|
||||
throw new OveravskrivningarConflictError(
|
||||
calculation.warning ?? 'Överavskrivningen kan inte beräknas säkert.',
|
||||
'The excess depreciation cannot be calculated safely.',
|
||||
)
|
||||
}
|
||||
if (!calculation.proposal) return null
|
||||
|
||||
const requested = roundOre(item.additionalAmount)
|
||||
const maximum = calculation.proposal.signedAmount ?? calculation.proposal.amount
|
||||
const invalidIncrease = maximum > 0 && (requested <= 0 || requested > maximum)
|
||||
const invalidRelease = maximum < 0 && requested !== maximum
|
||||
if (invalidIncrease || invalidRelease) {
|
||||
throw new OveravskrivningarConflictError(
|
||||
'Beloppet är inte längre giltigt. Ladda om bokslutet och använd den aktuella beräkningen.',
|
||||
'The amount is no longer valid. Reload the year-end flow and use the current calculation.',
|
||||
)
|
||||
}
|
||||
|
||||
return proposeOveravskrivningar({
|
||||
additionalAmount: requested,
|
||||
category: 'machinery_equipment',
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user