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
@@ -747,3 +747,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
|||||||
[2026-08-03] Shared format contracts centralised in lib/invariants/ (org number, BAS account number, ISO date, fiscal year), each with its rationale recorded next to the rule. Trigger: four Skatteverket/Bolagsverket-bound export paths (KU10, AGI, SRU redovisare, iXBRL preflight) each had their own idea of a valid organisationsnummer, so a company stored with a space or in 12-digit form could file AGI all year and fail at the arsredovisning deadline. normalizeOrgNumber moved from lib/company-lookup/ and isSaneDateString from lib/utils.ts; both old paths re-export. The iXBRL check-digit verdict is warn, not error: we do not block a statutory filing on a Luhn assumption unverified against a primary source. KU10 12-digit passthrough pinned by test, not changed (open domain question). ROT/RUT brf_org_number left alone: different documented contract. Ratchet guard 8 holds the remaining 114 inline copies.
|
[2026-08-03] Shared format contracts centralised in lib/invariants/ (org number, BAS account number, ISO date, fiscal year), each with its rationale recorded next to the rule. Trigger: four Skatteverket/Bolagsverket-bound export paths (KU10, AGI, SRU redovisare, iXBRL preflight) each had their own idea of a valid organisationsnummer, so a company stored with a space or in 12-digit form could file AGI all year and fail at the arsredovisning deadline. normalizeOrgNumber moved from lib/company-lookup/ and isSaneDateString from lib/utils.ts; both old paths re-export. The iXBRL check-digit verdict is warn, not error: we do not block a statutory filing on a Luhn assumption unverified against a primary source. KU10 12-digit passthrough pinned by test, not changed (open domain question). ROT/RUT brf_org_number left alone: different documented contract. Ratchet guard 8 holds the remaining 114 inline copies.
|
||||||
|
|
||||||
[2026-08-03] CI gained a pg-upgrade job: apply the merge-base schema, seed real rows, apply ONLY the PR migrations, assert the data survived. Rationale: pg-real applies all 548 migrations to an EMPTY database, so a NOT NULL / CHECK / unique index / backfill passes against zero rows and can still break prod. Proven locally against supabase/postgres:15.8.1.060 with three bad migrations: a CHECK violating an ore-level row and a NOT NULL on a populated column both exit 0 on empty and exit 3 on seeded. Base migrations are read from the merge-base git tree, not the working tree, so a PR that edits a shipped migration still surfaces here.
|
[2026-08-03] CI gained a pg-upgrade job: apply the merge-base schema, seed real rows, apply ONLY the PR migrations, assert the data survived. Rationale: pg-real applies all 548 migrations to an EMPTY database, so a NOT NULL / CHECK / unique index / backfill passes against zero rows and can still break prod. Proven locally against supabase/postgres:15.8.1.060 with three bad migrations: a CHECK violating an ore-level row and a NOT NULL on a populated column both exit 0 on empty and exit 3 on seeded. Base migrations are read from the merge-base git tree, not the working tree, so a PR that edits a shipped migration still surfaces here.
|
||||||
|
[2026-08-03] Issue #323 automatic excess depreciation is limited to reconciled IL 18 machinery and equipment with linear book depreciation and posts 8853/2153: buildings, intangible assets, and the 25 percent rest-value method follow separate rules, so calculation fails closed on an incomplete register or unposted planned depreciation.
|
||||||
|
|||||||
+255
@@ -35,6 +35,10 @@ vi.mock('@/lib/bokslut/reserves/periodiseringsfond-service', async (importOrigin
|
|||||||
return { ...actual, listExistingPeriodiseringsfonder: vi.fn() }
|
return { ...actual, listExistingPeriodiseringsfonder: vi.fn() }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
vi.mock('@/lib/bokslut/reserves/overavskrivningar-calculator', () => ({
|
||||||
|
calculateOveravskrivningar: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/lib/bookkeeping/engine', () => ({
|
vi.mock('@/lib/bookkeeping/engine', () => ({
|
||||||
createJournalEntry: vi.fn(),
|
createJournalEntry: vi.fn(),
|
||||||
}))
|
}))
|
||||||
@@ -66,6 +70,7 @@ import {
|
|||||||
} from '@/lib/bokslut/tax-provision/bolagsskatt-calculator'
|
} from '@/lib/bokslut/tax-provision/bolagsskatt-calculator'
|
||||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||||
import { listExistingPeriodiseringsfonder } from '@/lib/bokslut/reserves/periodiseringsfond-service'
|
import { listExistingPeriodiseringsfonder } from '@/lib/bokslut/reserves/periodiseringsfond-service'
|
||||||
|
import { calculateOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-calculator'
|
||||||
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
import { createJournalEntry } from '@/lib/bookkeeping/engine'
|
||||||
import { POST, PUT } from '../route'
|
import { POST, PUT } from '../route'
|
||||||
|
|
||||||
@@ -133,6 +138,15 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
vi.mocked(getBookedBolagsskatt).mockResolvedValue(0)
|
vi.mocked(getBookedBolagsskatt).mockResolvedValue(0)
|
||||||
vi.mocked(listExistingPeriodiseringsfonder).mockResolvedValue([])
|
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({
|
vi.mocked(calculateBolagsskatt).mockResolvedValue({
|
||||||
kind: 'bolagsskatt',
|
kind: 'bolagsskatt',
|
||||||
label: 'Bolagsskatt 20,6 %',
|
label: 'Bolagsskatt 20,6 %',
|
||||||
@@ -343,6 +357,247 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () =
|
|||||||
expect(createJournalEntry).not.toHaveBeenCalled()
|
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 () => {
|
it('returns 409 instead of posting over a different booked tax amount', async () => {
|
||||||
const supabase = periodClient({
|
const supabase = periodClient({
|
||||||
id: 'period-1',
|
id: 'period-1',
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import {
|
|||||||
proposeAteforing,
|
proposeAteforing,
|
||||||
} from '@/lib/bokslut/reserves/periodiseringsfond-service'
|
} from '@/lib/bokslut/reserves/periodiseringsfond-service'
|
||||||
import { proposeOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-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 { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||||
|
import { roundOre } from '@/lib/money'
|
||||||
import {
|
import {
|
||||||
buildDispositionsProposal,
|
buildDispositionsProposal,
|
||||||
buildLatentTaxProposal,
|
buildLatentTaxProposal,
|
||||||
@@ -243,6 +245,13 @@ export const POST = withRouteContext(
|
|||||||
|
|
||||||
return NextResponse.json({ data: { created } })
|
return NextResponse.json({ data: { created } })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof OveravskrivningarConflictError) {
|
||||||
|
return errorResponseFromCode('CONFLICT', opLog, {
|
||||||
|
requestId,
|
||||||
|
messageSv: err.message,
|
||||||
|
messageEn: err.messageEn,
|
||||||
|
})
|
||||||
|
}
|
||||||
if (err instanceof TaxProvisionConflictError) {
|
if (err instanceof TaxProvisionConflictError) {
|
||||||
return errorResponseFromCode('CONFLICT', opLog, {
|
return errorResponseFromCode('CONFLICT', opLog, {
|
||||||
requestId,
|
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(
|
async function computeProposal(
|
||||||
item: PostItem,
|
item: PostItem,
|
||||||
supabase: Parameters<typeof calculateBolagsskatt>[0],
|
supabase: Parameters<typeof calculateBolagsskatt>[0],
|
||||||
@@ -442,11 +461,43 @@ async function computeProposal(
|
|||||||
if (result.proposals.length === 0) return null
|
if (result.proposals.length === 0) return null
|
||||||
return mergeAteforingProposals(result.proposals)
|
return mergeAteforingProposals(result.proposals)
|
||||||
}
|
}
|
||||||
case 'overavskrivningar':
|
case 'overavskrivningar': {
|
||||||
return proposeOveravskrivningar({
|
if (item.category && item.category !== 'machinery_equipment') {
|
||||||
additionalAmount: item.additionalAmount,
|
throw new OveravskrivningarConflictError(
|
||||||
category: item.category,
|
'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':
|
case 'uppskjuten_skatt':
|
||||||
// Server-only: recompute from current TB (which already reflects any
|
// Server-only: recompute from current TB (which already reflects any
|
||||||
// 21xx postings that committed earlier in this batch). The client
|
// 21xx postings that committed earlier in this batch). The client
|
||||||
|
|||||||
@@ -255,6 +255,15 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{proposal.warnings?.map((warning) => (
|
||||||
|
<Card key={warning}>
|
||||||
|
<CardContent className="p-4 text-sm text-warning-foreground flex items-start gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
|
<span>{warning}</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
{(proposal.completedDispositions ?? []).map((completed) => (
|
{(proposal.completedDispositions ?? []).map((completed) => (
|
||||||
<Card key={`completed-${completed.kind}`}>
|
<Card key={`completed-${completed.kind}`}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -486,7 +495,7 @@ function ProposalCard({
|
|||||||
lockedSkip: boolean
|
lockedSkip: boolean
|
||||||
onChange: (next: { accept?: boolean; overrideAmount?: number }) => void
|
onChange: (next: { accept?: boolean; overrideAmount?: number }) => void
|
||||||
}) {
|
}) {
|
||||||
const overridable = isOverridable(proposal.kind)
|
const overridable = isOverridable(proposal)
|
||||||
const displayedAmount = overridable ? overrideAmount ?? proposal.amount : proposal.amount
|
const displayedAmount = overridable ? overrideAmount ?? proposal.amount : proposal.amount
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -553,14 +562,20 @@ function ProposalCard({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOverridable(kind: DispositionKind): boolean {
|
function isOverridable(proposal: ProposedDisposition): boolean {
|
||||||
// Bolagsskatt and SLP are derived from posted entries: overriding the amount
|
// Bolagsskatt and SLP are derived from posted entries: overriding the amount
|
||||||
// would silently break the journal posting (the calculator would still
|
// would silently break the journal posting (the calculator would still
|
||||||
// recompute server-side). p-fond avsättning and överavskrivningar take a
|
// recompute server-side). p-fond avsättning and överavskrivningar take a
|
||||||
// desired amount as input, so editing is meaningful. p-fond återföring is
|
// desired amount as input, so editing is meaningful. p-fond återföring is
|
||||||
// composed of mandatory cohorts and isn't safely editable from a single
|
// composed of mandatory cohorts and isn't safely editable from a single
|
||||||
// amount field.
|
// amount field.
|
||||||
return kind === 'periodiseringsfond_avsattning' || kind === 'overavskrivningar'
|
return (
|
||||||
|
proposal.kind === 'periodiseringsfond_avsattning'
|
||||||
|
|| (
|
||||||
|
proposal.kind === 'overavskrivningar'
|
||||||
|
&& (proposal.signedAmount ?? proposal.amount) > 0
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function proposalKey(p: ProposedDisposition, index = 0): string {
|
function proposalKey(p: ProposedDisposition, index = 0): string {
|
||||||
@@ -605,12 +620,16 @@ function buildPostItems(proposal: DispositionsProposal, ui: UiState): PostItem[]
|
|||||||
if (account) ateforingReturns[account] = p.amount
|
if (account) ateforingReturns[account] = p.amount
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'overavskrivningar':
|
case 'overavskrivningar': {
|
||||||
|
const signedDefault = p.signedAmount ?? p.amount
|
||||||
|
const selectedAmount = sel.overrideAmount ?? p.amount
|
||||||
items.push({
|
items.push({
|
||||||
kind: 'overavskrivningar',
|
kind: 'overavskrivningar',
|
||||||
additionalAmount: sel.overrideAmount ?? p.amount,
|
additionalAmount:
|
||||||
|
signedDefault < 0 ? -Math.abs(selectedAmount) : selectedAmount,
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
}
|
||||||
case 'uppskjuten_skatt':
|
case 'uppskjuten_skatt':
|
||||||
// K3 only: server recomputes the amount; client just signals intent.
|
// K3 only: server recomputes the amount; client just signals intent.
|
||||||
items.push({ kind: 'uppskjuten_skatt' })
|
items.push({ kind: 'uppskjuten_skatt' })
|
||||||
|
|||||||
@@ -20,9 +20,14 @@ vi.mock('@/lib/reports/trial-balance', () => ({
|
|||||||
generateTrialBalance: vi.fn(),
|
generateTrialBalance: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/bokslut/reserves/overavskrivningar-calculator', () => ({
|
||||||
|
calculateOveravskrivningar: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
import { buildDispositionsProposal } from '../dispositions-proposal-builder'
|
import { buildDispositionsProposal } from '../dispositions-proposal-builder'
|
||||||
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
import { generateIncomeStatement } from '@/lib/reports/income-statement'
|
||||||
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||||
|
import { calculateOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-calculator'
|
||||||
|
|
||||||
interface ChainableMock {
|
interface ChainableMock {
|
||||||
from: ReturnType<typeof vi.fn>
|
from: ReturnType<typeof vi.fn>
|
||||||
@@ -138,6 +143,15 @@ function makeSupabase(opts: {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
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
|
// 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
|
// 21xx balance stable at the trial-balance value, which makes the latent
|
||||||
// tax math testable in isolation.
|
// tax math testable in isolation.
|
||||||
@@ -211,6 +225,80 @@ describe('buildDispositionsProposal: K3 framework', () => {
|
|||||||
expect(bolagsskatt?.amount).toBe(154_500)
|
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('includes a pending 2153 increase in the K3 latent tax proposal', async () => {
|
||||||
|
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: [],
|
||||||
|
},
|
||||||
|
warning: null,
|
||||||
|
currentReserve: 0,
|
||||||
|
currentPeriodChange: 0,
|
||||||
|
targetReserve: 10_000,
|
||||||
|
maximumSignedChange: 10_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k3' })
|
||||||
|
const result = await buildDispositionsProposal(
|
||||||
|
supabase as unknown as Parameters<typeof buildDispositionsProposal>[0],
|
||||||
|
'co',
|
||||||
|
'fp1',
|
||||||
|
)
|
||||||
|
|
||||||
|
const latentTax = result.proposals.find((p) => p.kind === 'uppskjuten_skatt')
|
||||||
|
expect(latentTax?.amount).toBe(22_660)
|
||||||
|
expect(latentTax?.computation).toEqual(
|
||||||
|
expect.objectContaining({ untaxedReserves: 110_000, target2240: 22_660 }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('does NOT add an uppskjuten_skatt proposal for K2 aktiebolag', async () => {
|
it('does NOT add an uppskjuten_skatt proposal for K2 aktiebolag', async () => {
|
||||||
const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k2' })
|
const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k2' })
|
||||||
const result = await buildDispositionsProposal(
|
const result = await buildDispositionsProposal(
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import type { Asset, TrialBalanceRow } from '@/types'
|
||||||
|
|
||||||
|
vi.mock('@/lib/bokslut/assets/asset-service', () => ({
|
||||||
|
listAssets: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/bokslut/assets/depreciation-engine', () => ({
|
||||||
|
proposeAnnualPostings: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/lib/reports/trial-balance', () => ({
|
||||||
|
generateTrialBalance: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { listAssets } from '@/lib/bokslut/assets/asset-service'
|
||||||
|
import { proposeAnnualPostings } from '@/lib/bokslut/assets/depreciation-engine'
|
||||||
|
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||||
|
import { calculateOveravskrivningar } from '../reserves/overavskrivningar-calculator'
|
||||||
|
|
||||||
|
const PERIOD = {
|
||||||
|
id: 'period-2026',
|
||||||
|
period_start: '2026-01-01',
|
||||||
|
period_end: '2026-12-31',
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAsset(overrides: Partial<Asset> = {}): Asset {
|
||||||
|
return {
|
||||||
|
id: 'asset-1',
|
||||||
|
user_id: 'user-1',
|
||||||
|
company_id: 'company-1',
|
||||||
|
name: 'Production equipment',
|
||||||
|
category: 'equipment',
|
||||||
|
acquisition_date: '2026-01-15',
|
||||||
|
acquisition_cost: 100_000,
|
||||||
|
salvage_value: 0,
|
||||||
|
useful_life_months: 60,
|
||||||
|
depreciation_method: 'linear',
|
||||||
|
bas_asset_account: '1220',
|
||||||
|
bas_accumulated_account: '1229',
|
||||||
|
bas_expense_account: '7832',
|
||||||
|
restvarde_target: null,
|
||||||
|
disposed_at: null,
|
||||||
|
disposed_proceeds: null,
|
||||||
|
disposed_proceeds_vat: 0,
|
||||||
|
disposed_vat_treatment: null,
|
||||||
|
jamkning_amount: 0,
|
||||||
|
jamkning_remaining_months: null,
|
||||||
|
jamkning_total_months: null,
|
||||||
|
jamkning_original_input_vat: null,
|
||||||
|
k3_components: null,
|
||||||
|
notes: null,
|
||||||
|
created_at: '2026-01-15T00:00:00Z',
|
||||||
|
updated_at: '2026-01-15T00:00:00Z',
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function row(
|
||||||
|
accountNumber: string,
|
||||||
|
values: Partial<TrialBalanceRow> = {},
|
||||||
|
): TrialBalanceRow {
|
||||||
|
return {
|
||||||
|
account_number: accountNumber,
|
||||||
|
account_name: accountNumber,
|
||||||
|
account_class: Number(accountNumber[0]),
|
||||||
|
opening_debit: 0,
|
||||||
|
opening_credit: 0,
|
||||||
|
period_debit: 0,
|
||||||
|
period_credit: 0,
|
||||||
|
closing_debit: 0,
|
||||||
|
closing_credit: 0,
|
||||||
|
...values,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSupabase(periods = [PERIOD]) {
|
||||||
|
const builder: Record<string, unknown> = {}
|
||||||
|
builder.select = vi.fn(() => builder)
|
||||||
|
builder.eq = vi.fn(() => builder)
|
||||||
|
builder.lte = vi.fn(() => builder)
|
||||||
|
builder.order = vi.fn(() => builder)
|
||||||
|
builder.limit = vi.fn(async () => ({ data: periods, error: null }))
|
||||||
|
return {
|
||||||
|
from: vi.fn(() => builder),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockTrialBalance(rows: TrialBalanceRow[]) {
|
||||||
|
vi.mocked(generateTrialBalance).mockResolvedValue({
|
||||||
|
rows,
|
||||||
|
totalDebit: 0,
|
||||||
|
totalCredit: 0,
|
||||||
|
isBalanced: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockPostedDepreciation(asset: Asset) {
|
||||||
|
vi.mocked(proposeAnnualPostings).mockResolvedValue({
|
||||||
|
fiscalPeriod: { ...PERIOD, name: '2026' },
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
asset,
|
||||||
|
amount: 20_000,
|
||||||
|
netBookValueAfter: 80_000,
|
||||||
|
proRated: false,
|
||||||
|
existingScheduleId: 'schedule-1',
|
||||||
|
existingJournalEntryId: 'entry-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalAmount: 20_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('calculateOveravskrivningar', () => {
|
||||||
|
it('proposes the 8853/2153 bridge after planned depreciation is posted', async () => {
|
||||||
|
const asset = makeAsset()
|
||||||
|
vi.mocked(listAssets).mockResolvedValue([asset])
|
||||||
|
mockPostedDepreciation(asset)
|
||||||
|
mockTrialBalance([
|
||||||
|
row('1220', { period_debit: 100_000, closing_debit: 100_000 }),
|
||||||
|
row('1229', { period_credit: 20_000, closing_credit: 20_000 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await calculateOveravskrivningar({
|
||||||
|
supabase: makeSupabase() as never,
|
||||||
|
companyId: 'company-1',
|
||||||
|
fiscalPeriod: PERIOD,
|
||||||
|
entityType: 'aktiebolag',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 30-rule residual 70,000 beats 20-rule residual 80,000. Book value is
|
||||||
|
// 80,000, so 10,000 is bridged through the untaxed reserve.
|
||||||
|
expect(result.status).toBe('ready')
|
||||||
|
expect(result.selectedRule).toBe('30-regeln')
|
||||||
|
expect(result.targetReserve).toBe(10_000)
|
||||||
|
expect(result.proposal?.signedAmount).toBe(10_000)
|
||||||
|
expect(result.proposal?.lines.map((line) => line.account_number)).toEqual([
|
||||||
|
'8853',
|
||||||
|
'2153',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires a release when the existing reserve exceeds the lawful target', async () => {
|
||||||
|
const asset = makeAsset({
|
||||||
|
acquisition_date: '2020-01-01',
|
||||||
|
acquisition_cost: 100_000,
|
||||||
|
})
|
||||||
|
vi.mocked(listAssets).mockResolvedValue([asset])
|
||||||
|
mockPostedDepreciation(asset)
|
||||||
|
mockTrialBalance([
|
||||||
|
row('1220', { opening_debit: 100_000, closing_debit: 100_000 }),
|
||||||
|
row('1229', { opening_credit: 60_000, closing_credit: 80_000 }),
|
||||||
|
row('2153', { opening_credit: 30_000, closing_credit: 30_000 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await calculateOveravskrivningar({
|
||||||
|
supabase: makeSupabase() as never,
|
||||||
|
companyId: 'company-1',
|
||||||
|
fiscalPeriod: PERIOD,
|
||||||
|
entityType: 'aktiebolag',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.targetReserve).toBe(20_000)
|
||||||
|
expect(result.proposal?.signedAmount).toBe(-10_000)
|
||||||
|
expect(result.proposal?.required).toBe(true)
|
||||||
|
expect(result.proposal?.lines.map((line) => line.account_number)).toEqual([
|
||||||
|
'2153',
|
||||||
|
'8853',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fails closed when the asset register does not reconcile to 12xx', async () => {
|
||||||
|
const asset = makeAsset()
|
||||||
|
vi.mocked(listAssets).mockResolvedValue([asset])
|
||||||
|
mockTrialBalance([
|
||||||
|
row('1220', { closing_debit: 90_000 }),
|
||||||
|
row('1229', { closing_credit: 20_000 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await calculateOveravskrivningar({
|
||||||
|
supabase: makeSupabase() as never,
|
||||||
|
companyId: 'company-1',
|
||||||
|
fiscalPeriod: PERIOD,
|
||||||
|
entityType: 'aktiebolag',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.status).toBe('blocked')
|
||||||
|
expect(result.warning).toContain('stämmer inte')
|
||||||
|
expect(result.proposal).toBeNull()
|
||||||
|
expect(proposeAnnualPostings).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('waits until current-period planned depreciation is posted', async () => {
|
||||||
|
const asset = makeAsset()
|
||||||
|
vi.mocked(listAssets).mockResolvedValue([asset])
|
||||||
|
vi.mocked(proposeAnnualPostings).mockResolvedValue({
|
||||||
|
fiscalPeriod: { ...PERIOD, name: '2026' },
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
asset,
|
||||||
|
amount: 20_000,
|
||||||
|
netBookValueAfter: 80_000,
|
||||||
|
proRated: false,
|
||||||
|
existingJournalEntryId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
totalAmount: 20_000,
|
||||||
|
})
|
||||||
|
mockTrialBalance([row('1220', { closing_debit: 100_000 })])
|
||||||
|
|
||||||
|
const result = await calculateOveravskrivningar({
|
||||||
|
supabase: makeSupabase() as never,
|
||||||
|
companyId: 'company-1',
|
||||||
|
fiscalPeriod: PERIOD,
|
||||||
|
entityType: 'aktiebolag',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.status).toBe('blocked')
|
||||||
|
expect(result.warning).toContain('planenliga avskrivningarna först')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not propose a second optional increase after one was posted this period', async () => {
|
||||||
|
const asset = makeAsset()
|
||||||
|
vi.mocked(listAssets).mockResolvedValue([asset])
|
||||||
|
mockPostedDepreciation(asset)
|
||||||
|
mockTrialBalance([
|
||||||
|
row('1220', { period_debit: 100_000, closing_debit: 100_000 }),
|
||||||
|
row('1229', { period_credit: 20_000, closing_credit: 20_000 }),
|
||||||
|
row('2153', { period_credit: 5_000, closing_credit: 5_000 }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const result = await calculateOveravskrivningar({
|
||||||
|
supabase: makeSupabase() as never,
|
||||||
|
companyId: 'company-1',
|
||||||
|
fiscalPeriod: PERIOD,
|
||||||
|
entityType: 'aktiebolag',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.maximumSignedChange).toBe(5_000)
|
||||||
|
expect(result.currentPeriodChange).toBe(5_000)
|
||||||
|
expect(result.proposal).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not calculate or query assets for a sole trader', async () => {
|
||||||
|
const result = await calculateOveravskrivningar({
|
||||||
|
supabase: makeSupabase() as never,
|
||||||
|
companyId: 'company-1',
|
||||||
|
fiscalPeriod: PERIOD,
|
||||||
|
entityType: 'enskild_firma',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.status).toBe('not_applicable')
|
||||||
|
expect(listAssets).not.toHaveBeenCalled()
|
||||||
|
expect(generateTrialBalance).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|||||||
import {
|
import {
|
||||||
compute30Rule,
|
compute30Rule,
|
||||||
compute20Rule,
|
compute20Rule,
|
||||||
|
compute20RuleForFiscalPeriods,
|
||||||
pickLowerResidual,
|
pickLowerResidual,
|
||||||
proposeOveravskrivningar,
|
proposeOveravskrivningar,
|
||||||
OVERAVSKRIVNING_30_RULE,
|
OVERAVSKRIVNING_30_RULE,
|
||||||
@@ -31,15 +32,48 @@ describe('compute20Rule', () => {
|
|||||||
// current year, year-1, year-2, year-3, year-4
|
// current year, year-1, year-2, year-3, year-4
|
||||||
acquisitionCostByYearOffset: [100_000, 100_000, 100_000, 100_000, 100_000],
|
acquisitionCostByYearOffset: [100_000, 100_000, 100_000, 100_000, 100_000],
|
||||||
})
|
})
|
||||||
// residuals: 100×(5/5) + 100×(4/5) + 100×(3/5) + 100×(2/5) + 100×(1/5) = 100+80+60+40+20 = 300
|
// The acquisition period gets the first 20% deduction. Residuals are
|
||||||
expect(result.minimumResidual).toBe(300_000)
|
// therefore 80 + 60 + 40 + 20 + 0 = 200.
|
||||||
|
expect(result.minimumResidual).toBe(200_000)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('skips cohorts where no acquisitions happened', () => {
|
it('skips cohorts where no acquisitions happened', () => {
|
||||||
const result = compute20Rule({
|
const result = compute20Rule({
|
||||||
acquisitionCostByYearOffset: [50_000, 0, 0, 0, 0],
|
acquisitionCostByYearOffset: [50_000, 0, 0, 0, 0],
|
||||||
})
|
})
|
||||||
expect(result.minimumResidual).toBe(50_000)
|
expect(result.minimumResidual).toBe(40_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pro-rates shortened fiscal periods', () => {
|
||||||
|
const result = compute20RuleForFiscalPeriods({
|
||||||
|
acquisitionCostByPeriod: [100_000, 100_000],
|
||||||
|
fiscalPeriodMonths: [6, 12],
|
||||||
|
})
|
||||||
|
// Current cohort: 10% deducted. Prior cohort: 30% cumulative.
|
||||||
|
expect(result.minimumResidual).toBe(160_000)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('compute30Rule: fiscal period length', () => {
|
||||||
|
it('pro-rates the 30% rate for a six-month period', () => {
|
||||||
|
const result = compute30Rule({
|
||||||
|
openingBookValue: 100_000,
|
||||||
|
additions: 0,
|
||||||
|
disposals: 0,
|
||||||
|
fiscalPeriodMonths: 6,
|
||||||
|
})
|
||||||
|
expect(result.minimumResidual).toBe(85_000)
|
||||||
|
expect(result.maxAllowedAccumulated).toBe(15_000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('never produces a negative tax base when proceeds exceed the basis', () => {
|
||||||
|
const result = compute30Rule({
|
||||||
|
openingBookValue: 10_000,
|
||||||
|
additions: 0,
|
||||||
|
disposals: 20_000,
|
||||||
|
})
|
||||||
|
expect(result.base).toBe(0)
|
||||||
|
expect(result.minimumResidual).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -69,6 +103,7 @@ describe('proposeOveravskrivningar', () => {
|
|||||||
expect(result!.lines[0].debit_amount).toBe(25_000)
|
expect(result!.lines[0].debit_amount).toBe(25_000)
|
||||||
expect(result!.lines[1].account_number).toBe('2153')
|
expect(result!.lines[1].account_number).toBe('2153')
|
||||||
expect(result!.lines[1].credit_amount).toBe(25_000)
|
expect(result!.lines[1].credit_amount).toBe(25_000)
|
||||||
|
expect(result!.signedAmount).toBe(25_000)
|
||||||
expect(result!.warnings).toHaveLength(0)
|
expect(result!.warnings).toHaveLength(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,6 +115,8 @@ describe('proposeOveravskrivningar', () => {
|
|||||||
expect(result!.lines[0].debit_amount).toBe(10_000)
|
expect(result!.lines[0].debit_amount).toBe(10_000)
|
||||||
expect(result!.lines[1].account_number).toBe('8853')
|
expect(result!.lines[1].account_number).toBe('8853')
|
||||||
expect(result!.lines[1].credit_amount).toBe(10_000)
|
expect(result!.lines[1].credit_amount).toBe(10_000)
|
||||||
|
expect(result!.signedAmount).toBe(-10_000)
|
||||||
|
expect(result!.required).toBe(true)
|
||||||
expect(result!.warnings).toHaveLength(1)
|
expect(result!.warnings).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -87,11 +124,11 @@ describe('proposeOveravskrivningar', () => {
|
|||||||
expect(proposeOveravskrivningar({ additionalAmount: 0 })).toBeNull()
|
expect(proposeOveravskrivningar({ additionalAmount: 0 })).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rounds fractional input to whole krona', () => {
|
it('rounds fractional input to öre', () => {
|
||||||
const result = proposeOveravskrivningar({ additionalAmount: 1234.7 })
|
const result = proposeOveravskrivningar({ additionalAmount: 1234.567 })
|
||||||
expect(result!.amount).toBe(1_235)
|
expect(result!.amount).toBe(1_234.57)
|
||||||
expect(result!.lines[0].debit_amount).toBe(1_235)
|
expect(result!.lines[0].debit_amount).toBe(1_234.57)
|
||||||
expect(result!.lines[1].credit_amount).toBe(1_235)
|
expect(result!.lines[1].credit_amount).toBe(1_234.57)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses building accounts 8852/2152 when category=building', () => {
|
it('uses building accounts 8852/2152 when category=building', () => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
proposeAvsattning,
|
proposeAvsattning,
|
||||||
proposeAteforing,
|
proposeAteforing,
|
||||||
} from './reserves/periodiseringsfond-service'
|
} from './reserves/periodiseringsfond-service'
|
||||||
|
import { calculateOveravskrivningar } from './reserves/overavskrivningar-calculator'
|
||||||
import type { CompletedDisposition, DispositionsProposal, ProposedDisposition } from './types'
|
import type { CompletedDisposition, DispositionsProposal, ProposedDisposition } from './types'
|
||||||
import type { AccountingFramework } from '@/types'
|
import type { AccountingFramework } from '@/types'
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ export async function buildDispositionsProposal(
|
|||||||
|
|
||||||
const proposals: ProposedDisposition[] = []
|
const proposals: ProposedDisposition[] = []
|
||||||
const completedDispositions: CompletedDisposition[] = []
|
const completedDispositions: CompletedDisposition[] = []
|
||||||
|
const warnings: string[] = []
|
||||||
|
|
||||||
// Dispositions already POSTED in this period (a partially completed
|
// Dispositions already POSTED in this period (a partially completed
|
||||||
// bokslut run) are excluded from resultBeforeTax like all year_end
|
// bokslut run) are excluded from resultBeforeTax like all year_end
|
||||||
@@ -123,6 +125,31 @@ export async function buildDispositionsProposal(
|
|||||||
proposals.push(...ateforing.proposals)
|
proposals.push(...ateforing.proposals)
|
||||||
const ateforingTotal = ateforing.proposals.reduce((sum, p) => sum + p.amount, 0)
|
const ateforingTotal = ateforing.proposals.reduce((sum, p) => sum + p.amount, 0)
|
||||||
|
|
||||||
|
const overavskrivningar = await calculateOveravskrivningar({
|
||||||
|
supabase,
|
||||||
|
companyId,
|
||||||
|
fiscalPeriod: period,
|
||||||
|
entityType,
|
||||||
|
})
|
||||||
|
if (overavskrivningar.warning) warnings.push(overavskrivningar.warning)
|
||||||
|
if (overavskrivningar.proposal) proposals.push(overavskrivningar.proposal)
|
||||||
|
if (
|
||||||
|
!overavskrivningar.proposal
|
||||||
|
&& overavskrivningar.status === 'ready'
|
||||||
|
&& Math.abs(overavskrivningar.currentPeriodChange) >= 0.01
|
||||||
|
) {
|
||||||
|
completedDispositions.push({
|
||||||
|
kind: 'overavskrivningar',
|
||||||
|
label: 'Förändring av överavskrivningar',
|
||||||
|
amount: Math.abs(overavskrivningar.currentPeriodChange),
|
||||||
|
status: 'booked',
|
||||||
|
warnings: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const overavskrivningarResultEffect = -(
|
||||||
|
overavskrivningar.proposal?.signedAmount ?? 0
|
||||||
|
)
|
||||||
|
|
||||||
// SLP already posted in this period (resumed run): don't re-propose it
|
// SLP already posted in this period (resumed run): don't re-propose it
|
||||||
// (that would book it twice) and don't subtract it twice below (its
|
// (that would book it twice) and don't subtract it twice below (its
|
||||||
// effect is already inside postedEffect.total).
|
// effect is already inside postedEffect.total).
|
||||||
@@ -150,6 +177,7 @@ export async function buildDispositionsProposal(
|
|||||||
// proposed återföringar and schablonintäkt, minus deductible SLP.
|
// proposed återföringar and schablonintäkt, minus deductible SLP.
|
||||||
const taxableBeforeAvsattning =
|
const taxableBeforeAvsattning =
|
||||||
normalizedResultBeforeTax + postedEffect.total + alreadyProvisioned + ateforingTotal
|
normalizedResultBeforeTax + postedEffect.total + alreadyProvisioned + ateforingTotal
|
||||||
|
+ overavskrivningarResultEffect
|
||||||
+ ateforing.schablonintaktAmount - (slp?.amount ?? 0)
|
+ ateforing.schablonintaktAmount - (slp?.amount ?? 0)
|
||||||
+ taxAdjustments.nonDeductibleExpenses - taxAdjustments.nonTaxableIncome
|
+ taxAdjustments.nonDeductibleExpenses - taxAdjustments.nonTaxableIncome
|
||||||
const avsattning = alreadyProvisioned > 0
|
const avsattning = alreadyProvisioned > 0
|
||||||
@@ -182,6 +210,7 @@ export async function buildDispositionsProposal(
|
|||||||
// diverges from what the sequential commit books and from ÅR/INK2.
|
// diverges from what the sequential commit books and from ÅR/INK2.
|
||||||
const resultAfterDispositions =
|
const resultAfterDispositions =
|
||||||
normalizedResultBeforeTax + postedEffect.total + ateforingTotal
|
normalizedResultBeforeTax + postedEffect.total + ateforingTotal
|
||||||
|
+ overavskrivningarResultEffect
|
||||||
- (avsattning?.amount ?? 0) - (slp?.amount ?? 0)
|
- (avsattning?.amount ?? 0) - (slp?.amount ?? 0)
|
||||||
|
|
||||||
const bolagsskatt = await calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
|
const bolagsskatt = await calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
|
||||||
@@ -232,6 +261,7 @@ export async function buildDispositionsProposal(
|
|||||||
proposals,
|
proposals,
|
||||||
taxAdjustments,
|
taxAdjustments,
|
||||||
completedDispositions,
|
completedDispositions,
|
||||||
|
warnings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,13 +297,8 @@ export async function buildLatentTaxProposal(params: {
|
|||||||
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
|
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
|
||||||
|
|
||||||
// Pending 21xx postings from the proposals that will commit alongside
|
// Pending 21xx postings from the proposals that will commit alongside
|
||||||
// latent tax. Avsättning adds to the reserves (credit 21xx), återföring
|
// latent tax. Credits add to reserves and debits remove them.
|
||||||
// removes (debit 21xx).
|
|
||||||
for (const p of proposalsBeforeLatentTax) {
|
for (const p of proposalsBeforeLatentTax) {
|
||||||
if (
|
|
||||||
p.kind !== 'periodiseringsfond_avsattning'
|
|
||||||
&& p.kind !== 'periodiseringsfond_ateforing'
|
|
||||||
) continue
|
|
||||||
for (const line of p.lines) {
|
for (const line of p.lines) {
|
||||||
if (!line.account_number.startsWith('21')) continue
|
if (!line.account_number.startsWith('21')) continue
|
||||||
untaxedReserves += (line.credit_amount ?? 0) - (line.debit_amount ?? 0)
|
untaxedReserves += (line.credit_amount ?? 0) - (line.debit_amount ?? 0)
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
||||||
|
import { listAssets } from '@/lib/bokslut/assets/asset-service'
|
||||||
|
import { proposeAnnualPostings } from '@/lib/bokslut/assets/depreciation-engine'
|
||||||
|
import { generateTrialBalance } from '@/lib/reports/trial-balance'
|
||||||
|
import { roundOre } from '@/lib/money'
|
||||||
|
import type { Asset, AssetCategory } from '@/types'
|
||||||
|
import type { ProposedDisposition } from '../types'
|
||||||
|
import {
|
||||||
|
compute20RuleForFiscalPeriods,
|
||||||
|
compute30Rule,
|
||||||
|
pickLowerResidual,
|
||||||
|
proposeOveravskrivningar,
|
||||||
|
} from './overavskrivningar-service'
|
||||||
|
|
||||||
|
const MACHINERY_RESERVE_ACCOUNT = '2153'
|
||||||
|
const RECONCILIATION_TOLERANCE = 0.01
|
||||||
|
const FULL_DEPRECIATION_MONTHS = 60
|
||||||
|
|
||||||
|
const ELIGIBLE_CATEGORIES = new Set<AssetCategory>([
|
||||||
|
'machinery',
|
||||||
|
'equipment',
|
||||||
|
'vehicle',
|
||||||
|
'computer',
|
||||||
|
'other_tangible',
|
||||||
|
])
|
||||||
|
|
||||||
|
interface FiscalPeriodInput {
|
||||||
|
id: string
|
||||||
|
period_start: string
|
||||||
|
period_end: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FiscalPeriodCohort extends FiscalPeriodInput {
|
||||||
|
months: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OveravskrivningarCalculationStatus = 'ready' | 'not_applicable' | 'blocked'
|
||||||
|
|
||||||
|
export interface OveravskrivningarCalculation {
|
||||||
|
status: OveravskrivningarCalculationStatus
|
||||||
|
warning?: string
|
||||||
|
proposal: ProposedDisposition | null
|
||||||
|
currentReserve: number
|
||||||
|
currentPeriodChange: number
|
||||||
|
targetReserve: number
|
||||||
|
maximumSignedChange: number
|
||||||
|
selectedRule?: '30-regeln' | '20-regeln'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalculateOveravskrivningarInput {
|
||||||
|
supabase: SupabaseClient
|
||||||
|
companyId: string
|
||||||
|
fiscalPeriod: FiscalPeriodInput
|
||||||
|
entityType?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the maximum lawful closing reserve for machinery and inventory.
|
||||||
|
*
|
||||||
|
* The ledger is authoritative for book value and the existing 2153 reserve.
|
||||||
|
* The asset register supplies acquisition cohorts and disposal proceeds. The
|
||||||
|
* two sources must reconcile before a proposal is allowed.
|
||||||
|
*/
|
||||||
|
export async function calculateOveravskrivningar(
|
||||||
|
input: CalculateOveravskrivningarInput,
|
||||||
|
): Promise<OveravskrivningarCalculation> {
|
||||||
|
const { supabase, companyId, fiscalPeriod } = input
|
||||||
|
const entityType = input.entityType ?? (await loadEntityType(supabase, companyId))
|
||||||
|
if (entityType !== 'aktiebolag') return notApplicable()
|
||||||
|
|
||||||
|
const [trialBalance, assets, fiscalPeriods] = await Promise.all([
|
||||||
|
generateTrialBalance(supabase, companyId, fiscalPeriod.id, {
|
||||||
|
closingEntry: 'include',
|
||||||
|
}),
|
||||||
|
listAssets(supabase, companyId),
|
||||||
|
loadFiscalPeriodCohorts(supabase, companyId, fiscalPeriod),
|
||||||
|
])
|
||||||
|
|
||||||
|
const reserveRow = trialBalance.rows.find(
|
||||||
|
(row) => row.account_number === MACHINERY_RESERVE_ACCOUNT,
|
||||||
|
)
|
||||||
|
const openingReserve = roundMoney(
|
||||||
|
(reserveRow?.opening_credit ?? 0) - (reserveRow?.opening_debit ?? 0),
|
||||||
|
)
|
||||||
|
const currentReserve = roundMoney(
|
||||||
|
(reserveRow?.closing_credit ?? 0) - (reserveRow?.closing_debit ?? 0),
|
||||||
|
)
|
||||||
|
const currentPeriodChange = roundMoney(
|
||||||
|
(reserveRow?.period_credit ?? 0) - (reserveRow?.period_debit ?? 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
const relevantAssets = assets.filter(
|
||||||
|
(asset) =>
|
||||||
|
isEligibleAsset(asset)
|
||||||
|
&& asset.acquisition_date <= fiscalPeriod.period_end
|
||||||
|
&& (!asset.disposed_at || asset.disposed_at >= fiscalPeriod.period_start),
|
||||||
|
)
|
||||||
|
const activeAtEnd = relevantAssets.filter(
|
||||||
|
(asset) => !asset.disposed_at || asset.disposed_at > fiscalPeriod.period_end,
|
||||||
|
)
|
||||||
|
|
||||||
|
const ledgerGrossValue = roundMoney(
|
||||||
|
trialBalance.rows
|
||||||
|
.filter((row) => isEligibleAcquisitionAccount(row.account_number))
|
||||||
|
.reduce(
|
||||||
|
(sum, row) => sum + row.closing_debit - row.closing_credit,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const registerGrossValue = roundMoney(
|
||||||
|
activeAtEnd.reduce((sum, asset) => sum + Number(asset.acquisition_cost), 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Math.abs(ledgerGrossValue - registerGrossValue) > RECONCILIATION_TOLERANCE) {
|
||||||
|
return blocked(
|
||||||
|
currentReserve,
|
||||||
|
currentPeriodChange,
|
||||||
|
'Anläggningsregistret stämmer inte mot bokförda anskaffningsvärden i 12xx. Stäm av registret innan överavskrivningar beräknas.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relevantAssets.length === 0) {
|
||||||
|
if (currentReserve <= 0) return notApplicable()
|
||||||
|
return readyCalculation({
|
||||||
|
currentReserve,
|
||||||
|
currentPeriodChange,
|
||||||
|
targetReserve: 0,
|
||||||
|
selectedRule: '20-regeln',
|
||||||
|
computation: {
|
||||||
|
openingReserve,
|
||||||
|
closingBookValue: 0,
|
||||||
|
taxResidual: 0,
|
||||||
|
reason: 'no_remaining_assets',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relevantAssets.some((asset) => asset.depreciation_method !== 'linear')) {
|
||||||
|
return blocked(
|
||||||
|
currentReserve,
|
||||||
|
currentPeriodChange,
|
||||||
|
'Automatisk överavskrivning kräver planenlig linjär avskrivning för hela 12xx-gruppen. Tillgångar med 30 %, 20 % eller restvärdeavskrivning måste hanteras enligt samma valda skattemetod.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const annualPostings = await proposeAnnualPostings(supabase, companyId, fiscalPeriod.id)
|
||||||
|
const pendingEligibleDepreciation = annualPostings.items.some(
|
||||||
|
(item) => isEligibleAsset(item.asset) && !item.existingJournalEntryId,
|
||||||
|
)
|
||||||
|
if (pendingEligibleDepreciation) {
|
||||||
|
return blocked(
|
||||||
|
currentReserve,
|
||||||
|
currentPeriodChange,
|
||||||
|
'Bokför de planenliga avskrivningarna först. Därefter kan överavskrivningen beräknas på rätt bokfört restvärde.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const acquisitionAccounts = new Set(relevantAssets.map((asset) => asset.bas_asset_account))
|
||||||
|
const accumulatedAccounts = new Set(
|
||||||
|
relevantAssets.map((asset) => asset.bas_accumulated_account),
|
||||||
|
)
|
||||||
|
const assetRows = trialBalance.rows.filter(
|
||||||
|
(row) =>
|
||||||
|
acquisitionAccounts.has(row.account_number)
|
||||||
|
|| accumulatedAccounts.has(row.account_number),
|
||||||
|
)
|
||||||
|
|
||||||
|
const openingBookValue = roundMoney(
|
||||||
|
assetRows.reduce(
|
||||||
|
(sum, row) => sum + row.opening_debit - row.opening_credit,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const closingBookValue = roundMoney(
|
||||||
|
assetRows.reduce(
|
||||||
|
(sum, row) => sum + row.closing_debit - row.closing_credit,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (openingBookValue < -RECONCILIATION_TOLERANCE || closingBookValue < -RECONCILIATION_TOLERANCE) {
|
||||||
|
return blocked(
|
||||||
|
currentReserve,
|
||||||
|
currentPeriodChange,
|
||||||
|
'Bokfört restvärde för maskiner och inventarier är negativt. Rätta bokföringen innan överavskrivningar beräknas.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const additions = roundMoney(
|
||||||
|
relevantAssets
|
||||||
|
.filter(
|
||||||
|
(asset) =>
|
||||||
|
asset.acquisition_date >= fiscalPeriod.period_start
|
||||||
|
&& asset.acquisition_date <= fiscalPeriod.period_end,
|
||||||
|
)
|
||||||
|
.reduce((sum, asset) => sum + Number(asset.acquisition_cost), 0),
|
||||||
|
)
|
||||||
|
const disposals = roundMoney(
|
||||||
|
relevantAssets
|
||||||
|
.filter(
|
||||||
|
(asset) =>
|
||||||
|
Boolean(asset.disposed_at)
|
||||||
|
&& asset.disposed_at! >= fiscalPeriod.period_start
|
||||||
|
&& asset.disposed_at! <= fiscalPeriod.period_end,
|
||||||
|
)
|
||||||
|
.reduce(
|
||||||
|
(sum, asset) =>
|
||||||
|
sum
|
||||||
|
+ Math.max(
|
||||||
|
0,
|
||||||
|
Number(asset.disposed_proceeds ?? 0) - Number(asset.disposed_proceeds_vat ?? 0),
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const openingTaxValue = Math.max(0, roundMoney(openingBookValue - openingReserve))
|
||||||
|
const rule30 = compute30Rule({
|
||||||
|
openingBookValue: openingTaxValue,
|
||||||
|
additions,
|
||||||
|
disposals,
|
||||||
|
fiscalPeriodMonths: countFiscalMonths(
|
||||||
|
fiscalPeriod.period_start,
|
||||||
|
fiscalPeriod.period_end,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const acquisitionCostByCohort = fiscalPeriods.map(() => 0)
|
||||||
|
for (const asset of activeAtEnd) {
|
||||||
|
const cohortIndex = fiscalPeriods.findIndex(
|
||||||
|
(period) =>
|
||||||
|
asset.acquisition_date >= period.period_start
|
||||||
|
&& asset.acquisition_date <= period.period_end,
|
||||||
|
)
|
||||||
|
if (cohortIndex >= 0) {
|
||||||
|
acquisitionCostByCohort[cohortIndex] += Number(asset.acquisition_cost)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monthsBetween(asset.acquisition_date, fiscalPeriod.period_end) < FULL_DEPRECIATION_MONTHS) {
|
||||||
|
return blocked(
|
||||||
|
currentReserve,
|
||||||
|
currentPeriodChange,
|
||||||
|
'Tidigare räkenskapsperioder saknas för en tillgång som ännu omfattas av 20-regeln. Komplettera periodhistoriken innan överavskrivningar beräknas.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rule20 = compute20RuleForFiscalPeriods({
|
||||||
|
acquisitionCostByPeriod: acquisitionCostByCohort,
|
||||||
|
fiscalPeriodMonths: fiscalPeriods.map((period) => period.months),
|
||||||
|
})
|
||||||
|
const selected = pickLowerResidual(rule30, rule20)
|
||||||
|
const targetReserve = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(closingBookValue, roundMoney(closingBookValue - selected.residual)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return readyCalculation({
|
||||||
|
currentReserve,
|
||||||
|
currentPeriodChange,
|
||||||
|
targetReserve,
|
||||||
|
selectedRule: selected.rule,
|
||||||
|
computation: {
|
||||||
|
openingBookValue,
|
||||||
|
openingReserve,
|
||||||
|
openingTaxValue,
|
||||||
|
additions,
|
||||||
|
disposals,
|
||||||
|
closingBookValue,
|
||||||
|
rule30Residual: rule30.minimumResidual,
|
||||||
|
rule20Residual: rule20.minimumResidual,
|
||||||
|
taxResidual: selected.residual,
|
||||||
|
selectedRule: selected.rule,
|
||||||
|
targetReserve,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function readyCalculation(input: {
|
||||||
|
currentReserve: number
|
||||||
|
currentPeriodChange: number
|
||||||
|
targetReserve: number
|
||||||
|
selectedRule: '30-regeln' | '20-regeln'
|
||||||
|
computation: Record<string, unknown>
|
||||||
|
}): OveravskrivningarCalculation {
|
||||||
|
const maximumSignedChange = roundMoney(input.targetReserve - input.currentReserve)
|
||||||
|
// A positive current-period posting records the user's optional deduction
|
||||||
|
// choice. Do not propose another increase on reload. A reserve above the
|
||||||
|
// lawful target is different: its release remains mandatory.
|
||||||
|
const proposalChange =
|
||||||
|
maximumSignedChange > 0 && Math.abs(input.currentPeriodChange) > RECONCILIATION_TOLERANCE
|
||||||
|
? 0
|
||||||
|
: maximumSignedChange
|
||||||
|
const proposal = proposeOveravskrivningar({
|
||||||
|
additionalAmount: proposalChange,
|
||||||
|
category: 'machinery_equipment',
|
||||||
|
computation: input.computation,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ready',
|
||||||
|
proposal,
|
||||||
|
currentReserve: input.currentReserve,
|
||||||
|
currentPeriodChange: input.currentPeriodChange,
|
||||||
|
targetReserve: input.targetReserve,
|
||||||
|
maximumSignedChange,
|
||||||
|
selectedRule: input.selectedRule,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function blocked(
|
||||||
|
currentReserve: number,
|
||||||
|
currentPeriodChange: number,
|
||||||
|
warning: string,
|
||||||
|
): OveravskrivningarCalculation {
|
||||||
|
return {
|
||||||
|
status: 'blocked',
|
||||||
|
warning,
|
||||||
|
proposal: null,
|
||||||
|
currentReserve,
|
||||||
|
currentPeriodChange,
|
||||||
|
targetReserve: currentReserve,
|
||||||
|
maximumSignedChange: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notApplicable(): OveravskrivningarCalculation {
|
||||||
|
return {
|
||||||
|
status: 'not_applicable',
|
||||||
|
proposal: null,
|
||||||
|
currentReserve: 0,
|
||||||
|
currentPeriodChange: 0,
|
||||||
|
targetReserve: 0,
|
||||||
|
maximumSignedChange: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEntityType(supabase: SupabaseClient, companyId: string): Promise<string> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('company_settings')
|
||||||
|
.select('entity_type')
|
||||||
|
.eq('company_id', companyId)
|
||||||
|
.maybeSingle()
|
||||||
|
if (error) throw new Error(`Failed to load company entity type: ${error.message}`)
|
||||||
|
return data?.entity_type ?? 'aktiebolag'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiscalPeriodCohorts(
|
||||||
|
supabase: SupabaseClient,
|
||||||
|
companyId: string,
|
||||||
|
current: FiscalPeriodInput,
|
||||||
|
): Promise<FiscalPeriodCohort[]> {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('fiscal_periods')
|
||||||
|
.select('id, period_start, period_end')
|
||||||
|
.eq('company_id', companyId)
|
||||||
|
.lte('period_end', current.period_end)
|
||||||
|
.order('period_end', { ascending: false })
|
||||||
|
.limit(5)
|
||||||
|
if (error) throw new Error(`Failed to load fiscal period history: ${error.message}`)
|
||||||
|
|
||||||
|
const periods = ((data ?? []) as FiscalPeriodInput[]).filter(
|
||||||
|
(period, index, rows) => rows.findIndex((candidate) => candidate.id === period.id) === index,
|
||||||
|
)
|
||||||
|
if (!periods.some((period) => period.id === current.id)) periods.unshift(current)
|
||||||
|
periods.sort((a, b) => b.period_end.localeCompare(a.period_end))
|
||||||
|
|
||||||
|
return periods.slice(0, 5).map((period) => ({
|
||||||
|
...period,
|
||||||
|
months: countFiscalMonths(period.period_start, period.period_end),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEligibleAsset(asset: Asset): boolean {
|
||||||
|
return (
|
||||||
|
ELIGIBLE_CATEGORIES.has(asset.category)
|
||||||
|
&& isEligibleAcquisitionAccount(asset.bas_asset_account)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEligibleAcquisitionAccount(account: string): boolean {
|
||||||
|
if (!/^12\d{2}$/.test(account)) return false
|
||||||
|
const numeric = Number(account)
|
||||||
|
if (numeric >= 1280 && numeric <= 1289) return false
|
||||||
|
if (account === '1291') return false
|
||||||
|
return !account.endsWith('8') && !account.endsWith('9')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countFiscalMonths(periodStart: string, periodEnd: string): number {
|
||||||
|
const start = new Date(`${periodStart}T00:00:00Z`)
|
||||||
|
const end = new Date(`${periodEnd}T00:00:00Z`)
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
(end.getUTCFullYear() - start.getUTCFullYear()) * 12
|
||||||
|
+ end.getUTCMonth()
|
||||||
|
- start.getUTCMonth()
|
||||||
|
+ 1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthsBetween(startDate: string, endDate: string): number {
|
||||||
|
const start = new Date(`${startDate}T00:00:00Z`)
|
||||||
|
const end = new Date(`${endDate}T00:00:00Z`)
|
||||||
|
return (
|
||||||
|
(end.getUTCFullYear() - start.getUTCFullYear()) * 12
|
||||||
|
+ end.getUTCMonth()
|
||||||
|
- start.getUTCMonth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundMoney(value: number): number {
|
||||||
|
return roundOre(value)
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { ProposedDisposition } from '../types'
|
import type { ProposedDisposition } from '../types'
|
||||||
|
import { roundOre } from '@/lib/money'
|
||||||
|
|
||||||
/** 30-rule (huvudregel, IL 18 kap 13 §): restvärde minst 70 % av (ingående
|
/** 30-rule (huvudregel, IL 18 kap 13 §): restvärde minst 70 % av ingående
|
||||||
* bokfört värde + årets anskaffningar − årets försäljningar och utrangeringar). */
|
* bokfört värde plus årets anskaffningar minus årets försäljningar. */
|
||||||
export const OVERAVSKRIVNING_30_RULE = 0.7
|
export const OVERAVSKRIVNING_30_RULE = 0.7
|
||||||
|
|
||||||
/** 20-rule (kompletteringsregel, IL 18 kap 17 §): restvärde minst 0 % efter
|
/** 20-rule (kompletteringsregel, IL 18 kap 17 §): restvärde minst 0 % efter
|
||||||
@@ -9,16 +10,18 @@ export const OVERAVSKRIVNING_30_RULE = 0.7
|
|||||||
export const OVERAVSKRIVNING_20_RULE_YEARS = 5
|
export const OVERAVSKRIVNING_20_RULE_YEARS = 5
|
||||||
|
|
||||||
export interface Compute30RuleInput {
|
export interface Compute30RuleInput {
|
||||||
/** IB bokfört värde maskiner & inventarier (12xx netto). */
|
/** IB skattemässigt värde maskiner & inventarier. */
|
||||||
openingBookValue: number
|
openingBookValue: number
|
||||||
/** Årets anskaffningar (debet på anskaffningskonto, t.ex. 1220). */
|
/** Årets anskaffningar (debet på anskaffningskonto, t.ex. 1220). */
|
||||||
additions: number
|
additions: number
|
||||||
/** Försäljningsvärde och utrangering av tillgångar (kredit på anskaffningskonto). */
|
/** Försäljningsvärde och utrangering av tillgångar (kredit på anskaffningskonto). */
|
||||||
disposals: number
|
disposals: number
|
||||||
|
/** Räkenskapsperiodens längd. 30 % proportioneras för perioder som inte är 12 månader. */
|
||||||
|
fiscalPeriodMonths?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Compute20RuleInput {
|
export interface Compute20RuleInput {
|
||||||
/** Anskaffningskostnad per anskaffningsår, från (innevarande år − 4) till
|
/** Anskaffningskostnad per anskaffningsår, från innevarande år minus 4 till
|
||||||
* innevarande år. Index 0 = innevarande år. */
|
* innevarande år. Index 0 = innevarande år. */
|
||||||
acquisitionCostByYearOffset: [number, number, number, number, number]
|
acquisitionCostByYearOffset: [number, number, number, number, number]
|
||||||
}
|
}
|
||||||
@@ -32,19 +35,21 @@ export function compute30Rule(input: Compute30RuleInput): {
|
|||||||
minimumResidual: number
|
minimumResidual: number
|
||||||
maxAllowedAccumulated: number
|
maxAllowedAccumulated: number
|
||||||
} {
|
} {
|
||||||
const base = input.openingBookValue + input.additions - input.disposals
|
const base = Math.max(0, input.openingBookValue + input.additions - input.disposals)
|
||||||
const minimumResidual = Math.round(base * OVERAVSKRIVNING_30_RULE * 100) / 100
|
const periodMonths = input.fiscalPeriodMonths ?? 12
|
||||||
|
const depreciationRate = Math.min(1, 0.3 * (periodMonths / 12))
|
||||||
|
const minimumResidual = roundOre(base * (1 - depreciationRate))
|
||||||
return {
|
return {
|
||||||
base,
|
base,
|
||||||
minimumResidual,
|
minimumResidual,
|
||||||
maxAllowedAccumulated: Math.round((base - minimumResidual) * 100) / 100,
|
maxAllowedAccumulated: roundOre(base - minimumResidual),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 20-regeln: varje årsanskaffning får skrivas av med 20 % under 5 år. Lägsta
|
* 20-regeln: varje årsanskaffning får skrivas av med 20 % under 5 år. Lägsta
|
||||||
* skattemässigt restvärde är summan av 20 % × ((5 − offset) / 5) × anskaffningar
|
* skattemässigt restvärde är anskaffningskostnaden minus 20 % per tolvmånadersperiod,
|
||||||
* från år (innevarande − offset).
|
* inklusive anskaffningsperioden.
|
||||||
*
|
*
|
||||||
* Returns the allowed depreciation if 20-rule is used as the sole basis,
|
* Returns the allowed depreciation if 20-rule is used as the sole basis,
|
||||||
* computed against ALL still-active 20-rule cohorts.
|
* computed against ALL still-active 20-rule cohorts.
|
||||||
@@ -52,15 +57,37 @@ export function compute30Rule(input: Compute30RuleInput): {
|
|||||||
export function compute20Rule(input: Compute20RuleInput): {
|
export function compute20Rule(input: Compute20RuleInput): {
|
||||||
minimumResidual: number
|
minimumResidual: number
|
||||||
} {
|
} {
|
||||||
// Residual per cohort = anskaffningskostnad × (5 − ageInYears) / 5.
|
return compute20RuleForFiscalPeriods({
|
||||||
// ageInYears 0 = current year (residual 100 %), 4 = oldest still-live (20 %).
|
acquisitionCostByPeriod: input.acquisitionCostByYearOffset,
|
||||||
|
fiscalPeriodMonths: [12, 12, 12, 12, 12],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Compute20RuleForFiscalPeriodsInput {
|
||||||
|
/** Anskaffningskostnad per period. Index 0 är innevarande period. */
|
||||||
|
acquisitionCostByPeriod: number[]
|
||||||
|
/** Periodlängd per motsvarande period, nyaste först. */
|
||||||
|
fiscalPeriodMonths: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kompletteringsregeln writes off 20 % per twelve fiscal months, including
|
||||||
|
* the acquisition period. Supplying actual period lengths handles shortened
|
||||||
|
* and extended fiscal years without shifting the acquisition cohort by a year.
|
||||||
|
*/
|
||||||
|
export function compute20RuleForFiscalPeriods(
|
||||||
|
input: Compute20RuleForFiscalPeriodsInput,
|
||||||
|
): { minimumResidual: number } {
|
||||||
let residual = 0
|
let residual = 0
|
||||||
for (let offset = 0; offset < OVERAVSKRIVNING_20_RULE_YEARS; offset++) {
|
for (let offset = 0; offset < input.acquisitionCostByPeriod.length; offset++) {
|
||||||
const cost = input.acquisitionCostByYearOffset[offset] ?? 0
|
const cost = input.acquisitionCostByPeriod[offset] ?? 0
|
||||||
const remainingFraction = (OVERAVSKRIVNING_20_RULE_YEARS - offset) / OVERAVSKRIVNING_20_RULE_YEARS
|
const elapsedMonths = input.fiscalPeriodMonths
|
||||||
residual += cost * remainingFraction
|
.slice(0, offset + 1)
|
||||||
|
.reduce((sum, months) => sum + months, 0)
|
||||||
|
const depreciatedFraction = Math.min(1, elapsedMonths / 60)
|
||||||
|
residual += cost * (1 - depreciatedFraction)
|
||||||
}
|
}
|
||||||
return { minimumResidual: Math.round(residual * 100) / 100 }
|
return { minimumResidual: roundOre(residual) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,7 +129,8 @@ export interface OveravskrivningarInput {
|
|||||||
/** Föreslagen ökning av ackumulerade överavskrivningar. Positivt belopp
|
/** Föreslagen ökning av ackumulerade överavskrivningar. Positivt belopp
|
||||||
* ökar ackumulerade-kontot (debet 88xx), negativt minskar (kredit 88xx). */
|
* ökar ackumulerade-kontot (debet 88xx), negativt minskar (kredit 88xx). */
|
||||||
additionalAmount: number
|
additionalAmount: number
|
||||||
/** Account pair to use. Defaults to maskiner & inventarier (8853 / 2153): * the only category where överavskrivningar is common in K2 SME. Override
|
/** Account pair to use. Defaults to maskiner & inventarier (8853 / 2153),
|
||||||
|
* the only category where överavskrivningar is common in K2 SME. Override
|
||||||
* for buildings or immateriella tillgångar when relevant. */
|
* for buildings or immateriella tillgångar when relevant. */
|
||||||
category?: OveravskrivningCategory
|
category?: OveravskrivningCategory
|
||||||
/** Visa beräkningens bakgrund i UI:t. Helt fritt format. */
|
/** Visa beräkningens bakgrund i UI:t. Helt fritt format. */
|
||||||
@@ -127,7 +155,7 @@ const CATEGORY_LABELS: Record<OveravskrivningCategory, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function proposeOveravskrivningar(input: OveravskrivningarInput): ProposedDisposition | null {
|
export function proposeOveravskrivningar(input: OveravskrivningarInput): ProposedDisposition | null {
|
||||||
const amount = Math.round(input.additionalAmount)
|
const amount = roundOre(input.additionalAmount)
|
||||||
if (amount === 0) return null
|
if (amount === 0) return null
|
||||||
|
|
||||||
const category = input.category ?? 'machinery_equipment'
|
const category = input.category ?? 'machinery_equipment'
|
||||||
@@ -140,6 +168,7 @@ export function proposeOveravskrivningar(input: OveravskrivningarInput): Propose
|
|||||||
label: `Ökning av överavskrivningar (${categoryLabel})`,
|
label: `Ökning av överavskrivningar (${categoryLabel})`,
|
||||||
description: `Debet ${accounts.expense}, kredit ${accounts.accumulated}. Bokför skattemässig avskrivning utöver planenlig.`,
|
description: `Debet ${accounts.expense}, kredit ${accounts.accumulated}. Bokför skattemässig avskrivning utöver planenlig.`,
|
||||||
amount,
|
amount,
|
||||||
|
signedAmount: amount,
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
account_number: accounts.expense,
|
account_number: accounts.expense,
|
||||||
@@ -155,7 +184,7 @@ export function proposeOveravskrivningar(input: OveravskrivningarInput): Propose
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
warnings: [],
|
warnings: [],
|
||||||
computation: input.computation,
|
computation: { ...input.computation, additionalAmount: amount },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +195,7 @@ export function proposeOveravskrivningar(input: OveravskrivningarInput): Propose
|
|||||||
label: `Upplösning av överavskrivningar (${categoryLabel})`,
|
label: `Upplösning av överavskrivningar (${categoryLabel})`,
|
||||||
description: `Debet ${accounts.accumulated}, kredit ${accounts.expense}. Återför tidigare gjord överavskrivning.`,
|
description: `Debet ${accounts.accumulated}, kredit ${accounts.expense}. Återför tidigare gjord överavskrivning.`,
|
||||||
amount: absAmount,
|
amount: absAmount,
|
||||||
|
signedAmount: -absAmount,
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
account_number: accounts.accumulated,
|
account_number: accounts.accumulated,
|
||||||
@@ -181,6 +211,7 @@ export function proposeOveravskrivningar(input: OveravskrivningarInput): Propose
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
warnings: ['Negativ förändring återför tidigare överavskrivning och ökar skattepliktigt resultat.'],
|
warnings: ['Negativ förändring återför tidigare överavskrivning och ökar skattepliktigt resultat.'],
|
||||||
computation: input.computation,
|
computation: { ...input.computation, additionalAmount: -absAmount },
|
||||||
|
required: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export interface ProposedDisposition {
|
|||||||
description: string
|
description: string
|
||||||
/** SEK amount displayed in the card header. Always a positive number. */
|
/** SEK amount displayed in the card header. Always a positive number. */
|
||||||
amount: number
|
amount: number
|
||||||
|
/** Signed posting amount when direction matters while `amount` remains a
|
||||||
|
* positive display value. Used by over-depreciation releases. */
|
||||||
|
signedAmount?: number
|
||||||
/** Final voucher lines if the user accepts. Already balanced. */
|
/** Final voucher lines if the user accepts. Already balanced. */
|
||||||
lines: CreateJournalEntryLineInput[]
|
lines: CreateJournalEntryLineInput[]
|
||||||
/** Soft warnings the UI surfaces beside the card (e.g. forced p-fond reversal,
|
/** Soft warnings the UI surfaces beside the card (e.g. forced p-fond reversal,
|
||||||
@@ -81,4 +84,6 @@ export interface DispositionsProposal {
|
|||||||
proposals: ProposedDisposition[]
|
proposals: ProposedDisposition[]
|
||||||
taxAdjustments?: TaxAdjustmentSnapshot
|
taxAdjustments?: TaxAdjustmentSnapshot
|
||||||
completedDispositions?: CompletedDisposition[]
|
completedDispositions?: CompletedDisposition[]
|
||||||
|
/** Non-blocking calculation warnings surfaced in the statutory wizard. */
|
||||||
|
warnings?: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user