diff --git a/DECISIONS.md b/DECISIONS.md index afc6b919..96b4503e 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -747,3 +747,4 @@ One line per decision: `[YYYY-MM-DD] : `. 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] 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. diff --git a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/__tests__/route.test.ts index 184673f1..80a399ac 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/__tests__/route.test.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/__tests__/route.test.ts @@ -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', diff --git a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts index c64dafe7..d4ad8fbb 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts @@ -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[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 diff --git a/components/bookkeeping/year-end/DispositionsStep.tsx b/components/bookkeeping/year-end/DispositionsStep.tsx index a051c568..a854ca85 100644 --- a/components/bookkeeping/year-end/DispositionsStep.tsx +++ b/components/bookkeeping/year-end/DispositionsStep.tsx @@ -255,6 +255,15 @@ export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsS /> )} + {proposal.warnings?.map((warning) => ( + + + + {warning} + + + ))} + {(proposal.completedDispositions ?? []).map((completed) => ( @@ -486,7 +495,7 @@ function ProposalCard({ lockedSkip: boolean onChange: (next: { accept?: boolean; overrideAmount?: number }) => void }) { - const overridable = isOverridable(proposal.kind) + const overridable = isOverridable(proposal) const displayedAmount = overridable ? overrideAmount ?? proposal.amount : proposal.amount 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 // would silently break the journal posting (the calculator would still // recompute server-side). p-fond avsättning and överavskrivningar take a // 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 // 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 { @@ -605,12 +620,16 @@ function buildPostItems(proposal: DispositionsProposal, ui: UiState): PostItem[] if (account) ateforingReturns[account] = p.amount break } - case 'overavskrivningar': + case 'overavskrivningar': { + const signedDefault = p.signedAmount ?? p.amount + const selectedAmount = sel.overrideAmount ?? p.amount items.push({ kind: 'overavskrivningar', - additionalAmount: sel.overrideAmount ?? p.amount, + additionalAmount: + signedDefault < 0 ? -Math.abs(selectedAmount) : selectedAmount, }) break + } case 'uppskjuten_skatt': // K3 only: server recomputes the amount; client just signals intent. items.push({ kind: 'uppskjuten_skatt' }) diff --git a/lib/bokslut/__tests__/k3-framework-dispositions.test.ts b/lib/bokslut/__tests__/k3-framework-dispositions.test.ts index ceee86b0..07d60c99 100644 --- a/lib/bokslut/__tests__/k3-framework-dispositions.test.ts +++ b/lib/bokslut/__tests__/k3-framework-dispositions.test.ts @@ -20,9 +20,14 @@ vi.mock('@/lib/reports/trial-balance', () => ({ generateTrialBalance: vi.fn(), })) +vi.mock('@/lib/bokslut/reserves/overavskrivningar-calculator', () => ({ + calculateOveravskrivningar: vi.fn(), +})) + import { buildDispositionsProposal } from '../dispositions-proposal-builder' import { generateIncomeStatement } from '@/lib/reports/income-statement' import { generateTrialBalance } from '@/lib/reports/trial-balance' +import { calculateOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-calculator' interface ChainableMock { from: ReturnType @@ -138,6 +143,15 @@ function makeSupabase(opts: { beforeEach(() => { vi.clearAllMocks() + vi.mocked(calculateOveravskrivningar).mockResolvedValue({ + status: 'not_applicable', + proposal: null, + warning: null, + currentReserve: 0, + currentPeriodChange: 0, + targetReserve: 0, + maximumSignedChange: 0, + }) // Zero result so the builder doesn't propose a new avsättning: keeps the // 21xx balance stable at the trial-balance value, which makes the latent // tax math testable in isolation. @@ -211,6 +225,80 @@ describe('buildDispositionsProposal: K3 framework', () => { 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>) + 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[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[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 () => { const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k2' }) const result = await buildDispositionsProposal( diff --git a/lib/bokslut/__tests__/overavskrivningar-calculator.test.ts b/lib/bokslut/__tests__/overavskrivningar-calculator.test.ts new file mode 100644 index 00000000..637440aa --- /dev/null +++ b/lib/bokslut/__tests__/overavskrivningar-calculator.test.ts @@ -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 { + 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 { + 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 = {} + 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() + }) +}) diff --git a/lib/bokslut/__tests__/overavskrivningar-service.test.ts b/lib/bokslut/__tests__/overavskrivningar-service.test.ts index f5617ef3..f81daee3 100644 --- a/lib/bokslut/__tests__/overavskrivningar-service.test.ts +++ b/lib/bokslut/__tests__/overavskrivningar-service.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest' import { compute30Rule, compute20Rule, + compute20RuleForFiscalPeriods, pickLowerResidual, proposeOveravskrivningar, OVERAVSKRIVNING_30_RULE, @@ -31,15 +32,48 @@ describe('compute20Rule', () => { // current year, year-1, year-2, year-3, year-4 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 - expect(result.minimumResidual).toBe(300_000) + // The acquisition period gets the first 20% deduction. Residuals are + // therefore 80 + 60 + 40 + 20 + 0 = 200. + expect(result.minimumResidual).toBe(200_000) }) it('skips cohorts where no acquisitions happened', () => { const result = compute20Rule({ 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[1].account_number).toBe('2153') expect(result!.lines[1].credit_amount).toBe(25_000) + expect(result!.signedAmount).toBe(25_000) expect(result!.warnings).toHaveLength(0) }) @@ -80,6 +115,8 @@ describe('proposeOveravskrivningar', () => { expect(result!.lines[0].debit_amount).toBe(10_000) expect(result!.lines[1].account_number).toBe('8853') 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) }) @@ -87,11 +124,11 @@ describe('proposeOveravskrivningar', () => { expect(proposeOveravskrivningar({ additionalAmount: 0 })).toBeNull() }) - it('rounds fractional input to whole krona', () => { - const result = proposeOveravskrivningar({ additionalAmount: 1234.7 }) - expect(result!.amount).toBe(1_235) - expect(result!.lines[0].debit_amount).toBe(1_235) - expect(result!.lines[1].credit_amount).toBe(1_235) + it('rounds fractional input to öre', () => { + const result = proposeOveravskrivningar({ additionalAmount: 1234.567 }) + expect(result!.amount).toBe(1_234.57) + expect(result!.lines[0].debit_amount).toBe(1_234.57) + expect(result!.lines[1].credit_amount).toBe(1_234.57) }) it('uses building accounts 8852/2152 when category=building', () => { diff --git a/lib/bokslut/dispositions-proposal-builder.ts b/lib/bokslut/dispositions-proposal-builder.ts index 37edc105..898d0b8a 100644 --- a/lib/bokslut/dispositions-proposal-builder.ts +++ b/lib/bokslut/dispositions-proposal-builder.ts @@ -21,6 +21,7 @@ import { proposeAvsattning, proposeAteforing, } from './reserves/periodiseringsfond-service' +import { calculateOveravskrivningar } from './reserves/overavskrivningar-calculator' import type { CompletedDisposition, DispositionsProposal, ProposedDisposition } from './types' import type { AccountingFramework } from '@/types' @@ -89,6 +90,7 @@ export async function buildDispositionsProposal( const proposals: ProposedDisposition[] = [] const completedDispositions: CompletedDisposition[] = [] + const warnings: string[] = [] // Dispositions already POSTED in this period (a partially completed // bokslut run) are excluded from resultBeforeTax like all year_end @@ -123,6 +125,31 @@ export async function buildDispositionsProposal( proposals.push(...ateforing.proposals) 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 // (that would book it twice) and don't subtract it twice below (its // effect is already inside postedEffect.total). @@ -150,6 +177,7 @@ export async function buildDispositionsProposal( // proposed återföringar and schablonintäkt, minus deductible SLP. const taxableBeforeAvsattning = normalizedResultBeforeTax + postedEffect.total + alreadyProvisioned + ateforingTotal + + overavskrivningarResultEffect + ateforing.schablonintaktAmount - (slp?.amount ?? 0) + taxAdjustments.nonDeductibleExpenses - taxAdjustments.nonTaxableIncome const avsattning = alreadyProvisioned > 0 @@ -182,6 +210,7 @@ export async function buildDispositionsProposal( // diverges from what the sequential commit books and from ÅR/INK2. const resultAfterDispositions = normalizedResultBeforeTax + postedEffect.total + ateforingTotal + + overavskrivningarResultEffect - (avsattning?.amount ?? 0) - (slp?.amount ?? 0) const bolagsskatt = await calculateBolagsskatt(supabase, companyId, fiscalPeriodId, { @@ -232,6 +261,7 @@ export async function buildDispositionsProposal( proposals, taxAdjustments, completedDispositions, + warnings, } } @@ -267,13 +297,8 @@ export async function buildLatentTaxProposal(params: { .reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0) // Pending 21xx postings from the proposals that will commit alongside - // latent tax. Avsättning adds to the reserves (credit 21xx), återföring - // removes (debit 21xx). + // latent tax. Credits add to reserves and debits remove them. for (const p of proposalsBeforeLatentTax) { - if ( - p.kind !== 'periodiseringsfond_avsattning' - && p.kind !== 'periodiseringsfond_ateforing' - ) continue for (const line of p.lines) { if (!line.account_number.startsWith('21')) continue untaxedReserves += (line.credit_amount ?? 0) - (line.debit_amount ?? 0) diff --git a/lib/bokslut/reserves/overavskrivningar-calculator.ts b/lib/bokslut/reserves/overavskrivningar-calculator.ts new file mode 100644 index 00000000..d31a1a17 --- /dev/null +++ b/lib/bokslut/reserves/overavskrivningar-calculator.ts @@ -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([ + '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 { + 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 +}): 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 { + 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 { + 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) +} diff --git a/lib/bokslut/reserves/overavskrivningar-service.ts b/lib/bokslut/reserves/overavskrivningar-service.ts index 5ecfb5ee..cb5d25df 100644 --- a/lib/bokslut/reserves/overavskrivningar-service.ts +++ b/lib/bokslut/reserves/overavskrivningar-service.ts @@ -1,7 +1,8 @@ import type { ProposedDisposition } from '../types' +import { roundOre } from '@/lib/money' -/** 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). */ +/** 30-rule (huvudregel, IL 18 kap 13 §): restvärde minst 70 % av ingående + * bokfört värde plus årets anskaffningar minus årets försäljningar. */ export const OVERAVSKRIVNING_30_RULE = 0.7 /** 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 interface Compute30RuleInput { - /** IB bokfört värde maskiner & inventarier (12xx netto). */ + /** IB skattemässigt värde maskiner & inventarier. */ openingBookValue: number /** Årets anskaffningar (debet på anskaffningskonto, t.ex. 1220). */ additions: number /** Försäljningsvärde och utrangering av tillgångar (kredit på anskaffningskonto). */ disposals: number + /** Räkenskapsperiodens längd. 30 % proportioneras för perioder som inte är 12 månader. */ + fiscalPeriodMonths?: number } 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. */ acquisitionCostByYearOffset: [number, number, number, number, number] } @@ -32,19 +35,21 @@ export function compute30Rule(input: Compute30RuleInput): { minimumResidual: number maxAllowedAccumulated: number } { - const base = input.openingBookValue + input.additions - input.disposals - const minimumResidual = Math.round(base * OVERAVSKRIVNING_30_RULE * 100) / 100 + const base = Math.max(0, input.openingBookValue + input.additions - input.disposals) + const periodMonths = input.fiscalPeriodMonths ?? 12 + const depreciationRate = Math.min(1, 0.3 * (periodMonths / 12)) + const minimumResidual = roundOre(base * (1 - depreciationRate)) return { base, 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 - * skattemässigt restvärde är summan av 20 % × ((5 − offset) / 5) × anskaffningar - * från år (innevarande − offset). + * skattemässigt restvärde är anskaffningskostnaden minus 20 % per tolvmånadersperiod, + * inklusive anskaffningsperioden. * * Returns the allowed depreciation if 20-rule is used as the sole basis, * computed against ALL still-active 20-rule cohorts. @@ -52,15 +57,37 @@ export function compute30Rule(input: Compute30RuleInput): { export function compute20Rule(input: Compute20RuleInput): { minimumResidual: number } { - // Residual per cohort = anskaffningskostnad × (5 − ageInYears) / 5. - // ageInYears 0 = current year (residual 100 %), 4 = oldest still-live (20 %). + return compute20RuleForFiscalPeriods({ + 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 - for (let offset = 0; offset < OVERAVSKRIVNING_20_RULE_YEARS; offset++) { - const cost = input.acquisitionCostByYearOffset[offset] ?? 0 - const remainingFraction = (OVERAVSKRIVNING_20_RULE_YEARS - offset) / OVERAVSKRIVNING_20_RULE_YEARS - residual += cost * remainingFraction + for (let offset = 0; offset < input.acquisitionCostByPeriod.length; offset++) { + const cost = input.acquisitionCostByPeriod[offset] ?? 0 + const elapsedMonths = input.fiscalPeriodMonths + .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 * ökar ackumulerade-kontot (debet 88xx), negativt minskar (kredit 88xx). */ 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. */ category?: OveravskrivningCategory /** Visa beräkningens bakgrund i UI:t. Helt fritt format. */ @@ -127,7 +155,7 @@ const CATEGORY_LABELS: Record = { } export function proposeOveravskrivningar(input: OveravskrivningarInput): ProposedDisposition | null { - const amount = Math.round(input.additionalAmount) + const amount = roundOre(input.additionalAmount) if (amount === 0) return null const category = input.category ?? 'machinery_equipment' @@ -140,6 +168,7 @@ export function proposeOveravskrivningar(input: OveravskrivningarInput): Propose label: `Ökning av överavskrivningar (${categoryLabel})`, description: `Debet ${accounts.expense}, kredit ${accounts.accumulated}. Bokför skattemässig avskrivning utöver planenlig.`, amount, + signedAmount: amount, lines: [ { account_number: accounts.expense, @@ -155,7 +184,7 @@ export function proposeOveravskrivningar(input: OveravskrivningarInput): Propose }, ], 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})`, description: `Debet ${accounts.accumulated}, kredit ${accounts.expense}. Återför tidigare gjord överavskrivning.`, amount: absAmount, + signedAmount: -absAmount, lines: [ { 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.'], - computation: input.computation, + computation: { ...input.computation, additionalAmount: -absAmount }, + required: true, } } diff --git a/lib/bokslut/types.ts b/lib/bokslut/types.ts index 0de1b331..ebe93830 100644 --- a/lib/bokslut/types.ts +++ b/lib/bokslut/types.ts @@ -21,6 +21,9 @@ export interface ProposedDisposition { description: string /** SEK amount displayed in the card header. Always a positive 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. */ lines: CreateJournalEntryLineInput[] /** Soft warnings the UI surfaces beside the card (e.g. forced p-fond reversal, @@ -81,4 +84,6 @@ export interface DispositionsProposal { proposals: ProposedDisposition[] taxAdjustments?: TaxAdjustmentSnapshot completedDispositions?: CompletedDisposition[] + /** Non-blocking calculation warnings surfaced in the statutory wizard. */ + warnings?: string[] }