From 12c59399ee5fd74c633984da51f814736f3bd3dd Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 6 Aug 2026 10:05:15 +0200 Subject: [PATCH] fix(bokslut): remove uppskjuten skatt on obeskattade reserver in juridisk person (K3 29.37) (#1421) * fix(bokslut): remove uppskjuten skatt on obeskattade reserver in juridisk person (K3 29.37) and confirm K3 to K2 reversion In juridisk person K3 29.37 keeps obeskattade reserver at gross; the 79.4/20.6 split belongs to koncernredovisning. The old disposition double-counted the tax portion (result charged twice, 2240 overstated on top of gross 21xx). Removes the proposal step, POST kind, UI case, K2-to-K3 account seeding and the interim framework gate; keeps LATENT_TAX_DEFAULT_RATE for analytical soliditet presentation. Also adds the K3-to-K2 consequence confirmation dialog in settings. Co-Authored-By: Claude Fable 5 * docs(decisions): scope the batch log to shipped code and record the 29.37 election nuance Co-Authored-By: Claude Fable 5 * fix(settings): stop promising deferred-tax accounting the engine no longer does The framework help text and the K2-to-K3 confirmation both told the user that switching to K3 means uppskjuten skatt is recognised separately on 2240/8940 with a 79.4/20.6 split. This PR removes exactly that behaviour, so the copy would have promised something the product does not do, which is the defect class this batch exists to remove. Both now describe what actually happens: kassaflodesanalys, komponentavskrivning and a wider note set, with obeskattade reserver carried gross per K3 29.37. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 3 + .../__tests__/route.test.ts | 57 +++++++- .../[id]/bokslutsdispositioner/route.ts | 22 +-- .../company/current/__tests__/route.test.ts | 1 - app/api/company/current/route.ts | 63 +-------- .../bookkeeping/year-end/DispositionsStep.tsx | 4 - .../settings/AccountingFrameworkForm.tsx | 58 +++++--- .../k3-framework-dispositions.test.ts | 112 +++------------ .../__tests__/latent-tax-calculator.test.ts | 96 ------------- lib/bokslut/dispositions-proposal-builder.ts | 110 --------------- .../tax-provision/latent-tax-calculator.ts | 128 +++--------------- lib/bokslut/types.ts | 1 - 12 files changed, 132 insertions(+), 523 deletions(-) delete mode 100644 lib/bokslut/__tests__/latent-tax-calculator.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 2ef839d6..fbe7f8ab 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -793,6 +793,9 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-03] whatsapp-inbox M11 ('stopp'): copy changed to say PAUSED rather than disconnected; the keyword only sets muted_at. Muting also stops persisting content (body_text/raw_payload null on muted senders, matching the unknown-sender discipline) so the promise is true in the data too. Actually revoking on 'stopp' was rejected: 'start' must be able to re-open the same binding, and the settings panel already owns real disconnection ("Koppla från"). [2026-08-03] whatsapp-inbox raw_payload: the sender's plaintext E.164 number is stripped before persisting (redactRawPayload) and replies resolve the recipient by decrypting the link's phone_enc. Storing the number verbatim on every message row defeated the point of the AES-256-GCM column and contradicted the RoPA claim that it is never in the clear. Legacy rows still holding `from` keep working via a fallback read. [2026-08-05] Representation clarifying question has NO amount floor (removed the 150 kr gate after the Swedish compliance review on PR #1340): documenting deltagare + syfte is what makes a representation expense deductible at all (BFL 5 kap 6-7 §) and that duty is not conditioned on any sum; the 300 kr/person figure is the VAT-deduction base cap, an unrelated rule. Noise is bounded by the triggers instead (receipt-shaped + restaurant/cafe/hotel merchant, <=1 question per receipt, <=2 per burst, <=6 per sender per day, one "nej" dismisses). +[2026-08-05] Trap-closing batch (G2/G3/G4/G10/G12/G14 from the aktivering/K3/projekt analysis) shipped as five separate PRs, so each entry below names the PR that carries it; nothing here describes code that lives only in this diff. Three non-obvious calls: (a) legal wording about WHEN switching frameworks is allowed was omitted from the K3-to-K2 dialog (this PR) because BFNAR 2012:4 is not covered by any repo skill, so the dialog states system consequences only; an interim K3_REQUIRED_FOR_UPPSKJUTEN_SKATT registry error was written and then deleted within this same PR when the founder decision removed the feature it gated, so it never shipped. (b) PR "assets K2 guard": account 1081 flagged k2_excluded in the TS BAS reference + data migration 20260805112000 for existing chart rows: BFNAR 2016:10 punkt 10.4 substance (skill k2-vs-k3.md line 67) outranks the incomplete 20260225103139 backfill list; 1260/1269 stay unflagged in this pass because no asset-register category range reaches 1260-1279, a full k2_excluded-list reconciliation against official BAS marks is a separate follow-up. (c) PR "year-end preflight blocker links": validateYearEndReadiness gained a REQUIRED blockers: {code,message}[] field with errors: string[] kept as a derived mirror, so the public v1 compliance route stays byte-identical while PreflightStep gets stable codes; the voucher-gap and trial-balance link branches in BlockerRow were deleted as dead code rather than given a new page. +[2026-08-05] K3 uppskjuten skatt on obeskattade reserver ESCALATED, not changed: the dispositions builder books Dr 8940 / Cr 2240 = 20.6 % of gross 21xx in juridisk person, but swedish-asset-accounting (depreciation.md:88) states K3 29.37 keeps obeskattade reserver at gross in juridisk person with the 79.4/20.6 split reserved for koncernredovisning, so the entry double-counts the tax portion (result charged twice, 2240 overstated). Prod blast radius verified read-only 2026-08-05: 0 companies on K3, 2 companies with any posted 2240/8940 lines (3 entries, SIE-import shaped), and INK2 field 7651 adds the whole 8900-8989 range back so the error is tax-neutral. Per CLAUDE.md "stop and ask" on Swedish tax law this goes to the founder before any engine change; the K3 principle note was meanwhile reworded to describe what the engine actually books. +[2026-08-05] K3 29.37 escalation RESOLVED same day, founder chose removal: the uppskjuten_skatt disposition (Dr 8940 / Cr 2240 = 20.6 % of gross 21xx) is deleted end to end in juridisk person: proposal step, POST kind, DispositionsStep case, K2-to-K3 account seeding, and the K3_REQUIRED_FOR_UPPSKJUTEN_SKATT error registered earlier the same day (the gate died with the feature it gated). latent-tax-calculator.ts keeps only LATENT_TAX_DEFAULT_RATE for analytical presentation (soliditet/justerat EK in build-data.ts); the 2240/8940 chart rows of already-switched companies are deliberately left in place (audit history precedent from the original seeding comment). Old clients sending the kind now get schema 400, chosen over a bespoke 410-style error because the kind no longer exists in the contract and the only known clients are our own UI and MCP, neither of which sends it. Refinement after the review round: 29.37 makes gross vs split an ELECTION for the legal entity rather than a prohibition, so what shipped is a product default (gross), not a claim that the split is unlawful; the deleted code implemented NEITHER election, since it credited 2240 while the BR still carried 21xx gross, and no company can be on the split method today (0 K3 companies on prod). Offering the split as a disclosed policy option is a possible future feature, and would have to ship together with the matching redovisningsprincip note and a BR that presents the reserves net. [2026-08-05] Dropped "ML 13 kap 8 §" cites for kontantmetoden VAT timing (comments/docs only): section is the old ML 1994:200 numbering; in ML 2023:200, 13 kap is input-VAT deduction. Rule stated without section cite until the current-law section is verified. [2026-08-05] The in-app assistant now reads invoice_inbox_items.channel_context (the answers a user gave in WhatsApp) as first-class underlag context, marked "uppgivna av användaren" and ranked above OCR output. Found in the field: the assistant asked for representation participants the user had typed into WhatsApp minutes earlier, because the intent's inbox query selected only document_id + extracted_data. Also backfilled by document_id, since a receipt can reach the intent through the document paths without the inbox row being matched to the transaction. [2026-08-05] The WhatsApp representation question now asks ONCE for a missing purpose instead of silently storing participants with purpose=null. Skatteverket wants participants AND purpose; accepting half and saying "Tack!" produced an undocumented deduction. Anti-loop: the follow-up fires only when no representation block exists yet, so a second incomplete answer is accepted as-is rather than nagging. 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 80a399ac..fee225ed 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 @@ -10,7 +10,6 @@ import { createMockRequest, parseJsonResponse } from '@/tests/helpers' vi.mock('@/lib/bokslut/dispositions-proposal-builder', () => ({ buildDispositionsProposal: vi.fn(), - buildLatentTaxProposal: vi.fn(), })) vi.mock('@/lib/bokslut/tax-provision/tax-adjustment-service', () => ({ @@ -107,6 +106,44 @@ function periodClient(period: unknown, error: unknown = null) { return { from: vi.fn().mockReturnValue(builder) } } +/** Like periodClient but also answers a companies.accounting_framework + * lookup. Pass null to simulate a missing company row. */ +function frameworkClient(period: unknown, framework: string | null) { + const periodBuilder = { + select: vi.fn(), + eq: vi.fn(), + single: vi.fn().mockResolvedValue({ data: period, error: null }), + } + periodBuilder.select.mockReturnValue(periodBuilder) + periodBuilder.eq.mockReturnValue(periodBuilder) + const companyBuilder = { + select: vi.fn(), + eq: vi.fn(), + maybeSingle: vi.fn().mockResolvedValue({ + data: framework === null ? null : { accounting_framework: framework }, + error: null, + }), + } + companyBuilder.select.mockReturnValue(companyBuilder) + companyBuilder.eq.mockReturnValue(companyBuilder) + return { + from: vi.fn((table: string) => + table === 'companies' ? companyBuilder : periodBuilder, + ), + } +} + +const openPeriod = { + id: 'period-1', + name: '2025', + period_start: '2025-01-01', + period_end: '2025-12-31', + opening_balance_entry_id: null, + is_closed: false, + locked_at: null, + closing_entry_id: null, +} + beforeEach(() => { vi.clearAllMocks() requireAuthMock.mockResolvedValue({ user: { id: 'user-1' }, supabase: {}, error: null }) @@ -625,4 +662,22 @@ describe('POST /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner', () = expect(body.error.details).toEqual({ bookedAmount: 123_181, expectedAmount: 123_180 }) expect(createJournalEntry).not.toHaveBeenCalled() }) + + it('rejects the removed uppskjuten_skatt kind as a validation error (K3 29.37)', async () => { + // The kind was removed 2026-08-05: in juridisk person obeskattade + // reserver stay at gross (K3 29.37), so no deferred-tax disposition + // exists for ANY framework. Old clients sending it get schema 400. + requireAuthMock.mockResolvedValue({ + user: { id: 'user-1' }, + supabase: frameworkClient(openPeriod, 'k3'), + error: null, + }) + + const { status } = await parseJsonResponse<{ error: { code: string } }>( + await post({ items: [{ kind: 'uppskjuten_skatt' }] }), + ) + + expect(status).toBe(400) + expect(createJournalEntry).not.toHaveBeenCalled() + }) }) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts index d4ad8fbb..a7f7e446 100644 --- a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts +++ b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts @@ -26,10 +26,7 @@ import { proposeOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivning import { calculateOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-calculator' import { generateIncomeStatement } from '@/lib/reports/income-statement' import { roundOre } from '@/lib/money' -import { - buildDispositionsProposal, - buildLatentTaxProposal, -} from '@/lib/bokslut/dispositions-proposal-builder' +import { buildDispositionsProposal } from '@/lib/bokslut/dispositions-proposal-builder' import type { ProposedDisposition } from '@/lib/bokslut/types' import type { JournalEntry } from '@/types' @@ -55,9 +52,6 @@ const DISPOSITION_ORDER: Record = { sarskild_loneskatt: 2, periodiseringsfond_avsattning: 3, bolagsskatt: 4, - // K3 only: posts last because it depends on the closing 21xx balance, - // which only stabilises once avsättning / återföring have been applied. - uppskjuten_skatt: 5, } // ============================================================ @@ -174,11 +168,6 @@ const ItemSchema = z.discriminatedUnion('kind', [ .enum(['machinery_equipment', 'building', 'immaterial', 'group']) .optional(), }), - // K3 only: uppskjuten skatt provision. Server recomputes the amount from - // current 2240 + 21xx state so the client cannot override it. - z.object({ - kind: z.literal('uppskjuten_skatt'), - }), ]) const PostBodySchema = z.object({ @@ -498,15 +487,6 @@ async function computeProposal( computation: calculation.proposal.computation, }) } - case 'uppskjuten_skatt': - // Server-only: recompute from current TB (which already reflects any - // 21xx postings that committed earlier in this batch). The client - // sends no amount: the calculator owns the K3 split. - return buildLatentTaxProposal({ - supabase, - companyId, - fiscalPeriodId, - }) } } diff --git a/app/api/company/current/__tests__/route.test.ts b/app/api/company/current/__tests__/route.test.ts index 32153563..1586d417 100644 --- a/app/api/company/current/__tests__/route.test.ts +++ b/app/api/company/current/__tests__/route.test.ts @@ -73,7 +73,6 @@ describe('PATCH /api/company/current', () => { it('updates the framework for an aktiebolag', async () => { enqueue({ data: { entity_type: 'aktiebolag' } }) // entity check enqueue({ data: { id: 'company-1', accounting_framework: 'k3', entity_type: 'aktiebolag' } }) // update - enqueue({ data: null }) // K3 latent-tax account upsert const req = createMockRequest('/api/company/current', { method: 'PATCH', diff --git a/app/api/company/current/route.ts b/app/api/company/current/route.ts index 24079e92..349973b8 100644 --- a/app/api/company/current/route.ts +++ b/app/api/company/current/route.ts @@ -3,21 +3,10 @@ import { requireAuth } from '@/lib/auth/require-auth' import { withRouteContext } from '@/lib/api/with-route-context' import { validateBody } from '@/lib/api/validate' import { AccountingFrameworkSchema } from '@/lib/api/schemas' -import { getBASReference } from '@/lib/bookkeeping/bas-reference' -import { createLogger } from '@/lib/logger' import { NextResponse } from 'next/server' import { z } from 'zod' import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message' -const log = createLogger('api/company/current') - -// BAS 2026 accounts required for K3's uppskjuten skatt (latent tax) entries. -// Both rows carry k2_excluded=true in lib/bookkeeping/bas-data so they are -// NOT seeded by seed_chart_of_accounts() for K2 companies. When a company -// opts into K3 we backfill them here so the engine can resolve them by -// account_number when the first latent-tax entry is posted. -const K3_LATENT_TAX_ACCOUNTS = ['2240', '8940'] as const - /** * GET /api/company/current * @@ -73,7 +62,7 @@ const PatchBodySchema = z.object({ export const PATCH = withRouteContext( 'company.update_current', async (request, ctx) => { - const { supabase, companyId, user } = ctx + const { supabase, companyId } = ctx const validation = await validateBody(request, PatchBodySchema) if (!validation.success) return validation.response @@ -128,56 +117,6 @@ export const PATCH = withRouteContext( return NextResponse.json({ error: getUserErrorMessage(error) }, { status: 500 }) } - // When opting in to K3, ensure the two latent-tax (uppskjuten skatt) - // accounts exist in the company's chart of accounts. The base seed skips - // them for K2 companies via k2_excluded=true, so without this backfill - // the engine cannot resolve account_id for the first latent-tax post. - // Wrapped in try/catch so a CoA insert failure does not block the - // framework update: the user can still re-trigger the seed later. - // The reverse switch (K3 → K2) intentionally keeps the rows for audit - // history; the legal record of past K3 postings must remain intact. - if (data.accounting_framework === 'k3') { - try { - const rows = K3_LATENT_TAX_ACCOUNTS.map(accountNumber => { - const basRef = getBASReference(accountNumber) - if (!basRef) return null - return { - user_id: user.id, - company_id: companyId, - account_number: basRef.account_number, - account_name: basRef.account_name, - account_class: basRef.account_class, - account_group: basRef.account_group, - account_type: basRef.account_type, - normal_balance: basRef.normal_balance, - sru_code: basRef.sru_code, - k2_excluded: basRef.k2_excluded, - plan_type: 'full_bas', - is_active: true, - is_system_account: true, - description: basRef.description, - } - }).filter((row): row is NonNullable => row !== null) - - if (rows.length > 0) { - const { error: seedError } = await supabase - .from('chart_of_accounts') - .upsert(rows, { onConflict: 'company_id,account_number', ignoreDuplicates: true }) - if (seedError) { - log.error('Failed to seed K3 latent-tax accounts', { - companyId, - error: seedError.message, - }) - } - } - } catch (err) { - log.error('Unexpected error seeding K3 latent-tax accounts', { - companyId, - error: err instanceof Error ? err.message : String(err), - }) - } - } - return NextResponse.json({ data }) }, { requireWrite: true }, diff --git a/components/bookkeeping/year-end/DispositionsStep.tsx b/components/bookkeeping/year-end/DispositionsStep.tsx index ca88bb15..a52383d8 100644 --- a/components/bookkeeping/year-end/DispositionsStep.tsx +++ b/components/bookkeeping/year-end/DispositionsStep.tsx @@ -694,10 +694,6 @@ function buildPostItems(proposal: DispositionsProposal, ui: UiState): PostItem[] }) break } - case 'uppskjuten_skatt': - // K3 only: server recomputes the amount; client just signals intent. - items.push({ kind: 'uppskjuten_skatt' }) - break } } if (Object.keys(ateforingReturns).length > 0) { diff --git a/components/settings/AccountingFrameworkForm.tsx b/components/settings/AccountingFrameworkForm.tsx index 9ea4b2ec..f5f31a83 100644 --- a/components/settings/AccountingFrameworkForm.tsx +++ b/components/settings/AccountingFrameworkForm.tsx @@ -33,9 +33,12 @@ interface AccountingFrameworkFormProps { * * UX rules (regulatory area: kept in Swedish): * - Default is K2 (matches the column default and BFNAR 2016:10 baseline). - * - Switching K2 → K3 fires a confirmation dialog. The recommendation per - * BFN is that the choice is permanent for the company once made; we - * surface that as a warning, not a block, so the user can still revert. + * - Switching in either direction fires a confirmation dialog. K2 → K3 + * warns about the added obligations; the recommendation per BFN is that + * the choice is permanent once made, surfaced as a warning, not a block. + * K3 → K2 warns about what the system does NOT do: uppskjuten skatt + * (2240/8940) balances and komponentavskrivningar are not unwound + * automatically, and the K3 årsredovisning content stops applying. * - The save is its own request (PATCH /api/company/current): separate * from /api/settings because the column lives on companies, not on * company_settings. @@ -88,13 +91,9 @@ export function AccountingFrameworkForm({ current, onSaved }: AccountingFramewor function handleChange(next: string) { const value = next as AccountingFramework if (value === selected) return - // K2 → K3 is the consequential direction: confirm before persisting. - if (selected === 'k2' && value === 'k3') { - setPending(value) - return - } - setSelected(value) - void persist(value) + // Both directions are consequential: K2 → K3 adds obligations, K3 → K2 + // leaves K3-only balances behind. Confirm before persisting either way. + setPending(value) } return ( @@ -107,8 +106,8 @@ export function AccountingFrameworkForm({ current, onSaved }: AccountingFramewor K2 är standard för mindre bolag och innebär förenklade regler. K3 krävs när bolaget når två av tre tröskelvärden (nettoomsättning > 80 MSEK, tillgångar > 40 MSEK, eller fler än 50 anställda). K3 ställer högre krav: kassaflödesanalys, - komponentavskrivning på materiella anläggningstillgångar och redovisning av - uppskjuten skatt på obeskattade reserver (79,4 % eget kapital / 20,6 % skuld). + komponentavskrivning på materiella anläggningstillgångar och mer omfattande noter. + Obeskattade reserver redovisas brutto i juridisk person enligt K3 punkt 29.37. } > @@ -137,15 +136,32 @@ export function AccountingFrameworkForm({ current, onSaved }: AccountingFramewor > - Byta till K3? + {pending === 'k2' ? 'Byta till K2?' : 'Byta till K3?'} - - K3 medför löpande att kassaflödesanalys upprättas, komponentavskrivning - används och uppskjuten skatt redovisas separat (konto 2240 / 8940). - - - Bytet är permanent enligt rekommendation. Fortsätt? - + {pending === 'k2' ? ( + <> + + Bokförda saldon för uppskjuten skatt (konto 2240 / 8940) och gjorda + komponentavskrivningar återförs inte automatiskt: de måste hanteras + manuellt. + + + Årsredovisningens K3-innehåll (kassaflödesanalys, K3-noter och + uppskjuten skatt) gäller inte längre. Fortsätt? + + + ) : ( + <> + + K3 medför löpande att kassaflödesanalys upprättas, att + komponentavskrivning kan användas och att årsredovisningen får ett + utökat notinnehåll. + + + Bytet är permanent enligt rekommendation. Fortsätt? + + + )} @@ -168,6 +184,8 @@ export function AccountingFrameworkForm({ current, onSaved }: AccountingFramewor <> Sparar… + ) : pending === 'k2' ? ( + 'Byt till K2' ) : ( 'Byt till K3' )} diff --git a/lib/bokslut/__tests__/k3-framework-dispositions.test.ts b/lib/bokslut/__tests__/k3-framework-dispositions.test.ts index 07d60c99..ce5c4c50 100644 --- a/lib/bokslut/__tests__/k3-framework-dispositions.test.ts +++ b/lib/bokslut/__tests__/k3-framework-dispositions.test.ts @@ -1,14 +1,15 @@ /** * K3 framework integration test for the dispositions builder. * - * Verifies that: - * - K3 companies get an `uppskjuten_skatt` proposal at the end of the chain. - * - K2 companies (the default) do NOT receive that proposal. - * - The latent tax amount equals 20.6 % × projected closing 21xx − current - * 2240 balance. + * Since 2026-08-05 (founder decision, K3 29.37) the builder books NO + * uppskjuten skatt on obeskattade reserver: in juridisk person the reserves + * stay at gross and the 79.4/20.6 split belongs to koncernredovisning. + * These tests pin that the proposal chain is framework-independent and that + * no uppskjuten_skatt proposal ever appears, alongside the disposition math + * that remains (periodiseringsfond, overavskrivningar, bolagsskatt). * - * Mocks generateIncomeStatement and generateTrialBalance directly so the - * test can drive numeric inputs without touching the database. + * Mocks generateIncomeStatement directly so the test can drive numeric + * inputs without touching the database. */ import { describe, it, expect, vi, beforeEach } from 'vitest' @@ -181,7 +182,7 @@ beforeEach(() => { }) describe('buildDispositionsProposal: K3 framework', () => { - it('appends an uppskjuten_skatt proposal for K3 aktiebolag', async () => { + it('never proposes uppskjuten skatt on obeskattade reserver, K3 included (K3 29.37)', async () => { const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k3' }) const result = await buildDispositionsProposal( supabase as unknown as Parameters[0], @@ -189,15 +190,12 @@ describe('buildDispositionsProposal: K3 framework', () => { 'fp1', ) expect(result.entityType).toBe('aktiebolag') - const latentTax = result.proposals.find((p) => p.kind === 'uppskjuten_skatt') - expect(latentTax).toBeDefined() - expect(latentTax!.amount).toBe(20_600) - expect(latentTax!.lines).toHaveLength(2) - // Liability increased → debit 8940 / credit 2240. - const debit = latentTax!.lines.find((l) => l.account_number === '8940')! - const credit = latentTax!.lines.find((l) => l.account_number === '2240')! - expect(debit.debit_amount).toBe(20_600) - expect(credit.credit_amount).toBe(20_600) + // In juridisk person obeskattade reserver stay at gross: no 8940/2240 + // proposal exists, and no proposal line may touch those accounts. + expect(result.proposals.map((p) => p.kind)).not.toContain('uppskjuten_skatt') + const touched = result.proposals.flatMap((p) => p.lines.map((l) => l.account_number)) + expect(touched).not.toContain('8940') + expect(touched).not.toContain('2240') }) it('computes bolagsskatt on the result AFTER the periodiseringsfond avsättning', async () => { @@ -263,42 +261,6 @@ describe('buildDispositionsProposal: K3 framework', () => { 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( @@ -307,7 +269,7 @@ describe('buildDispositionsProposal: K3 framework', () => { 'fp1', ) expect(result.entityType).toBe('aktiebolag') - expect(result.proposals.find((p) => p.kind === 'uppskjuten_skatt')).toBeUndefined() + expect(result.proposals.map((p) => p.kind)).not.toContain('uppskjuten_skatt') }) it('defaults to K2 when accounting_framework is null on the company row', async () => { @@ -317,7 +279,7 @@ describe('buildDispositionsProposal: K3 framework', () => { 'co', 'fp1', ) - expect(result.proposals.find((p) => p.kind === 'uppskjuten_skatt')).toBeUndefined() + expect(result.proposals.map((p) => p.kind)).not.toContain('uppskjuten_skatt') }) it('does NOT add an uppskjuten_skatt proposal for enskild firma even if mislabelled K3', async () => { @@ -333,44 +295,4 @@ describe('buildDispositionsProposal: K3 framework', () => { expect(result.proposals).toEqual([]) }) - it('skips uppskjuten_skatt when 2240 already matches target (no change)', async () => { - // Bump 2240 to exactly 20 600 so the delta is zero: calculator should - // return null and the builder skip the proposal entirely. - vi.mocked(generateTrialBalance).mockResolvedValue({ - rows: [ - { - account_number: '2125', - account_name: 'Periodiseringsfond 2025', - account_class: 2, - closing_credit: 100_000, - closing_debit: 0, - opening_credit: 0, - opening_debit: 0, - period_credit: 100_000, - period_debit: 0, - }, - { - account_number: '2240', - account_name: 'Avsättningar för uppskjutna skatter', - account_class: 2, - closing_credit: 20_600, - closing_debit: 0, - opening_credit: 20_600, - opening_debit: 0, - period_credit: 0, - period_debit: 0, - }, - ], - totalDebit: 0, - totalCredit: 120_600, - isBalanced: false, - } as unknown as Awaited>) - const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k3' }) - const result = await buildDispositionsProposal( - supabase as unknown as Parameters[0], - 'co', - 'fp1', - ) - expect(result.proposals.find((p) => p.kind === 'uppskjuten_skatt')).toBeUndefined() - }) }) diff --git a/lib/bokslut/__tests__/latent-tax-calculator.test.ts b/lib/bokslut/__tests__/latent-tax-calculator.test.ts deleted file mode 100644 index ab3fa130..00000000 --- a/lib/bokslut/__tests__/latent-tax-calculator.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { - computeLatentTax, - proposeLatentTaxChange, - LATENT_TAX_DEFAULT_RATE, - LATENT_TAX_LIABILITY_ACCOUNT, - LATENT_TAX_EXPENSE_ACCOUNT, -} from '../tax-provision/latent-tax-calculator' - -describe('computeLatentTax', () => { - it('splits 100 000 reserves into 79 400 equity + 20 600 liability at default 20,6 %', () => { - const split = computeLatentTax({ untaxedReserves: 100_000 }) - expect(split.liabilityPortion).toBe(20_600) - expect(split.equityPortion).toBe(79_400) - // Invariant: the two portions reconcile to the input. - expect(split.equityPortion + split.liabilityPortion).toBeCloseTo(100_000, 2) - }) - - it('returns zero portions for zero reserves', () => { - const split = computeLatentTax({ untaxedReserves: 0 }) - expect(split.equityPortion).toBe(0) - expect(split.liabilityPortion).toBe(0) - }) - - it('preserves the sign for negative reserves (over-reversal edge case)', () => { - // Unusual but the math should stay symmetric: e.g. when the - // dispositions builder posts more återföring than the existing reserves. - const split = computeLatentTax({ untaxedReserves: -50_000 }) - expect(split.liabilityPortion).toBe(-10_300) - expect(split.equityPortion).toBe(-39_700) - expect(split.equityPortion + split.liabilityPortion).toBeCloseTo(-50_000, 2) - }) - - it('accepts a custom tax rate (future flex for rate changes)', () => { - // If bolagsskatt drops to e.g. 18 %, K3 split would follow. - const split = computeLatentTax({ untaxedReserves: 100_000, taxRate: 0.18 }) - expect(split.liabilityPortion).toBe(18_000) - expect(split.equityPortion).toBe(82_000) - }) - - it('rounds to öre on non-integer reserves', () => { - // 12 345.67 × 0.206 = 2 543.20802 → öre rounding → 2 543.21 - // equity = 12 345.67 − 2 543.21 = 9 802.46 - const split = computeLatentTax({ untaxedReserves: 12_345.67 }) - expect(split.liabilityPortion).toBe(2_543.21) - expect(split.equityPortion).toBe(9_802.46) - expect(split.equityPortion + split.liabilityPortion).toBeCloseTo(12_345.67, 2) - }) - - it('exports the canonical 20.6 % rate constant', () => { - expect(LATENT_TAX_DEFAULT_RATE).toBe(0.206) - }) -}) - -describe('proposeLatentTaxChange', () => { - it('returns null when current already equals target (no change)', () => { - expect(proposeLatentTaxChange(20_600, 20_600)).toBeNull() - }) - - it('returns null when delta is below 1 öre tolerance', () => { - // Floating-point dust below 1 öre should not produce a verifikat. - expect(proposeLatentTaxChange(20_600, 20_600.001)).toBeNull() - expect(proposeLatentTaxChange(20_600.0049, 20_600)).toBeNull() - }) - - it('books an avsättning when liability grows: debit 8940 / credit 2240', () => { - const lines = proposeLatentTaxChange(0, 20_600) - expect(lines).not.toBeNull() - expect(lines).toHaveLength(2) - const debit = lines!.find((l) => l.account_number === LATENT_TAX_EXPENSE_ACCOUNT)! - const credit = lines!.find((l) => l.account_number === LATENT_TAX_LIABILITY_ACCOUNT)! - expect(debit.debit_amount).toBe(20_600) - expect(debit.credit_amount).toBe(0) - expect(credit.debit_amount).toBe(0) - expect(credit.credit_amount).toBe(20_600) - }) - - it('books a återföring when liability shrinks: debit 2240 / credit 8940', () => { - const lines = proposeLatentTaxChange(20_600, 15_000) - expect(lines).not.toBeNull() - expect(lines).toHaveLength(2) - const debit = lines!.find((l) => l.account_number === LATENT_TAX_LIABILITY_ACCOUNT)! - const credit = lines!.find((l) => l.account_number === LATENT_TAX_EXPENSE_ACCOUNT)! - expect(debit.debit_amount).toBe(5_600) - expect(credit.credit_amount).toBe(5_600) - }) - - it('produces a balanced verifikat (sum debit = sum credit)', () => { - const lines = proposeLatentTaxChange(10_000, 18_000) - expect(lines).not.toBeNull() - const totalDebit = lines!.reduce((s, l) => s + l.debit_amount, 0) - const totalCredit = lines!.reduce((s, l) => s + l.credit_amount, 0) - expect(totalDebit).toBe(totalCredit) - expect(totalDebit).toBe(8_000) - }) -}) diff --git a/lib/bokslut/dispositions-proposal-builder.ts b/lib/bokslut/dispositions-proposal-builder.ts index 898d0b8a..61181ec1 100644 --- a/lib/bokslut/dispositions-proposal-builder.ts +++ b/lib/bokslut/dispositions-proposal-builder.ts @@ -1,6 +1,5 @@ import type { SupabaseClient } from '@supabase/supabase-js' import { generateIncomeStatement } from '@/lib/reports/income-statement' -import { generateTrialBalance } from '@/lib/reports/trial-balance' import { calculateBolagsskatt, getBookedBolagsskatt, @@ -8,12 +7,6 @@ import { } from './tax-provision/bolagsskatt-calculator' import { loadTaxAdjustmentSnapshot } from './tax-provision/tax-adjustment-service' import { calculateSarskildLoneskatt } from './tax-provision/sarskild-loneskatt-calculator' -import { - computeLatentTax, - LATENT_TAX_EXPENSE_ACCOUNT, - LATENT_TAX_LIABILITY_ACCOUNT, - proposeLatentTaxChange, -} from './tax-provision/latent-tax-calculator' import { getPeriodiseringsfondCohortAccount, getSchablonintaktRate, @@ -23,7 +16,6 @@ import { } from './reserves/periodiseringsfond-service' import { calculateOveravskrivningar } from './reserves/overavskrivningar-calculator' import type { CompletedDisposition, DispositionsProposal, ProposedDisposition } from './types' -import type { AccountingFramework } from '@/types' /** * Shared core of the GET /bokslutsdispositioner endpoint, lifted out so the @@ -71,19 +63,6 @@ export async function buildDispositionsProposal( } } - // Look up the accounting framework: K3 (BFNAR 2012:1) triggers the - // uppskjuten-skatt provision step; K2 skips it. - const { data: companyRow } = await supabase - .from('companies') - .select('accounting_framework') - .eq('id', companyId) - .maybeSingle() - const accountingFramework: AccountingFramework = - (companyRow as { accounting_framework?: AccountingFramework } | null)?.accounting_framework - === 'k3' - ? 'k3' - : 'k2' - const fiscalYear = parseInt(period.period_end.slice(0, 4), 10) const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId) const resultBeforeTax = incomeStatement.net_result @@ -239,21 +218,6 @@ export async function buildDispositionsProposal( proposals.push(bolagsskatt) } - // K3 only: split obeskattade reserver into the 79.4 % equity portion and - // the 20.6 % uppskjuten skatteskuld. We sum the projected 21xx balance - // AFTER the dispositions above have been applied so the latent-tax - // amount reflects the closing position: anything else would diverge - // from the BR the user sees in the preview. - if (accountingFramework === 'k3') { - const latentTax = await buildLatentTaxProposal({ - supabase, - companyId, - fiscalPeriodId, - proposalsBeforeLatentTax: proposals, - }) - if (latentTax) proposals.push(latentTax) - } - return { entityType, fiscalPeriod: period, @@ -265,77 +229,3 @@ export async function buildDispositionsProposal( } } -/** - * Compose the K3 uppskjuten-skatt proposal. - * - * The latent tax provision must reflect the *closing* obeskattade-reserver - * balance, so we pull the current 21xx balance from the trial balance and - * adjust it for any 21xx-touching dispositions that haven't yet posted - * (avsättning ↑, återföring ↓). 2240's current balance is the existing - * provision; the delta becomes the new verifikat. - */ -export async function buildLatentTaxProposal(params: { - supabase: SupabaseClient - companyId: string - fiscalPeriodId: string - /** Optional: additional 21xx-touching dispositions that have NOT yet been - * posted but will be in the same batch. The TB already reflects everything - * posted, so leave this empty if the latent-tax run is sequenced after the - * 21xx postings (the API route's case). */ - proposalsBeforeLatentTax?: ProposedDisposition[] -}): Promise { - const { supabase, companyId, fiscalPeriodId, proposalsBeforeLatentTax = [] } = params - - // Reads 21xx and 2240 only (class 2), which no resultatavslut touches. - const tb = await generateTrialBalance(supabase, companyId, fiscalPeriodId, { - closingEntry: 'include', - }) - - // 21xx: obeskattade reserver (credit-normal, so we measure credit − debit). - let untaxedReserves = tb.rows - .filter((r) => r.account_number.startsWith('21')) - .reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0) - - // Pending 21xx postings from the proposals that will commit alongside - // latent tax. Credits add to reserves and debits remove them. - for (const p of proposalsBeforeLatentTax) { - for (const line of p.lines) { - if (!line.account_number.startsWith('21')) continue - untaxedReserves += (line.credit_amount ?? 0) - (line.debit_amount ?? 0) - } - } - - // Current 2240 balance: credit-normal. Equal to existing latent tax. - const current2240 = tb.rows - .filter((r) => r.account_number === LATENT_TAX_LIABILITY_ACCOUNT) - .reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0) - - const split = computeLatentTax({ untaxedReserves }) - const lines = proposeLatentTaxChange(current2240, split.liabilityPortion) - if (!lines) return null - - const delta = Math.round((split.liabilityPortion - current2240) * 100) / 100 - const amount = Math.abs(delta) - const direction = delta > 0 ? 'avsättning' : 'återföring' - - return { - kind: 'uppskjuten_skatt', - label: 'Uppskjuten skatt (K3)', - description: - delta > 0 - ? `Avsättning till uppskjuten skatteskuld 20,6 % av obeskattade reserver. Debet ${LATENT_TAX_EXPENSE_ACCOUNT}, kredit ${LATENT_TAX_LIABILITY_ACCOUNT}.` - : `Återföring av uppskjuten skatteskuld när obeskattade reserver minskar. Debet ${LATENT_TAX_LIABILITY_ACCOUNT}, kredit ${LATENT_TAX_EXPENSE_ACCOUNT}.`, - amount, - lines, - warnings: [], - computation: { - untaxedReserves, - taxRate: 0.206, - target2240: split.liabilityPortion, - current2240, - delta, - direction, - equityPortion: split.equityPortion, - }, - } -} diff --git a/lib/bokslut/tax-provision/latent-tax-calculator.ts b/lib/bokslut/tax-provision/latent-tax-calculator.ts index 2a848fcb..1005df73 100644 --- a/lib/bokslut/tax-provision/latent-tax-calculator.ts +++ b/lib/bokslut/tax-provision/latent-tax-calculator.ts @@ -1,118 +1,22 @@ -import type { CreateJournalEntryLineInput } from '@/types' - /** - * Statutory split of obeskattade reserver (21xx: periodiseringsfonder, - * överavskrivningar, etc.) under K3 (BFNAR 2012:1). + * K3 (BFNAR 2012:1) treatment of uppskjuten skatt on obeskattade reserver. * - * The full balance is taxable when the reserve is reversed, so K3 requires - * the balance to be presented as: - * - 79.4 % equity (bundet eget kapital: the post-tax economic value) - * - 20.6 % deferred tax liability (uppskjuten skatteskuld, account 2240) + * In JURIDISK PERSON, K3 kap 29 (29.37) keeps obeskattade reserver at gross: + * uppskjuten skatt attributable to them is NOT separately recognised, because + * the reserves are presented including their deferred-tax component. The + * 79.4 / 20.6 split into equity + uppskjuten skatteskuld belongs to + * KONCERNREDOVISNING (and analytical contexts such as kontrollbalansrakning + * and soliditet), which this product does not book. * - * 20.6 % is the current Swedish bolagsskatt rate (since 2021). If the rate - * changes (it has been 20.6 % since fiscal year 2021), pass an override. + * The engine therefore books NO 8940/2240 entry on obeskattade reserver. + * A dispositions step that did exactly that was removed 2026-08-05 (founder + * decision, see DECISIONS.md): it double-counted the tax portion in juridisk + * person (result charged twice, 2240 overstated on top of gross 21xx). * - * Under K2 this split does NOT apply: the obeskattade reserver row stays - * intact between eget kapital and skulder and 2240 is excluded from the chart. + * What remains here is the analytical rate, used for presentation-side + * calculations such as justerat eget kapital in soliditet (equity portion = + * obeskattade reserver x (1 - rate)). + * + * 20.6 % is the current Swedish bolagsskatt rate (since 2021). */ export const LATENT_TAX_DEFAULT_RATE = 0.206 - -/** BAS account for the K3 latent tax liability. */ -export const LATENT_TAX_LIABILITY_ACCOUNT = '2240' - -/** BAS account for the K3 latent tax expense (income statement). */ -export const LATENT_TAX_EXPENSE_ACCOUNT = '8940' - -export interface LatentTaxSplit { - /** The 79.4 % portion that K3 folds into equity for soliditet / BR purposes. */ - equityPortion: number - /** The 20.6 % portion presented as a separate deferred tax liability (2240). */ - liabilityPortion: number -} - -/** - * Compute the K3 equity / liability split of a given untaxed-reserves total. - * - * Pure function: no DB access. Caller passes the *current* sum of 21xx - * (post all dispositioner the user has accepted in the bokslut flow). - * - * Monetary precision: rounds to öre (2 decimals) via Math.round(x*100)/100 - * per project convention. liabilityPortion is computed first so the split - * always reconciles (equityPortion = total − liability rounded the same way). - * - * Negative reserves are unusual (would indicate over-reversal) but the math - * is symmetric: both portions come out negative, preserving the sign. - */ -export function computeLatentTax(params: { - untaxedReserves: number - taxRate?: number -}): LatentTaxSplit { - const taxRate = params.taxRate ?? LATENT_TAX_DEFAULT_RATE - const liabilityPortion = Math.round(params.untaxedReserves * taxRate * 100) / 100 - const equityPortion = Math.round((params.untaxedReserves - liabilityPortion) * 100) / 100 - return { equityPortion, liabilityPortion } -} - -/** - * Tolerance for "no change" detection. The latent tax provision posts in - * whole krona via the engine, so an absolute delta below 1 öre means the - * stored 2240 balance already matches the target and no adjustment is needed. - */ -const LATENT_TAX_ORE_TOLERANCE = 0.01 - -/** - * Generate the journal lines needed to move the 2240 balance from its - * current amount to the new target. Returns null when no adjustment is - * required (delta below 1 öre, see {@link LATENT_TAX_ORE_TOLERANCE}). - * - * Direction: - * - target > current → latent tax LIABILITY grew → debit 8940 (cost), - * credit 2240 (liability). - * - target < current → latent tax LIABILITY shrank → debit 2240, credit - * 8940 (income: a reversal of prior expense). - * - * The entry posts as `source_type='year_end'` and is meant to be created via - * `createJournalEntry()` so the engine assigns the voucher number atomically - * and enforces the balance/period rules. - */ -export function proposeLatentTaxChange( - currentLatentTax2240: number, - targetLatentTax2240: number, -): CreateJournalEntryLineInput[] | null { - const delta = Math.round((targetLatentTax2240 - currentLatentTax2240) * 100) / 100 - if (Math.abs(delta) < LATENT_TAX_ORE_TOLERANCE) return null - - const absoluteDelta = Math.abs(delta) - if (delta > 0) { - // Liability increased: debit 8940 (expense), credit 2240 (liability). - return [ - { - account_number: LATENT_TAX_EXPENSE_ACCOUNT, - debit_amount: absoluteDelta, - credit_amount: 0, - line_description: 'Förändring uppskjuten skatt (K3)', - }, - { - account_number: LATENT_TAX_LIABILITY_ACCOUNT, - debit_amount: 0, - credit_amount: absoluteDelta, - line_description: 'Avsättning uppskjuten skatteskuld', - }, - ] - } - // Liability decreased: debit 2240, credit 8940. - return [ - { - account_number: LATENT_TAX_LIABILITY_ACCOUNT, - debit_amount: absoluteDelta, - credit_amount: 0, - line_description: 'Återföring uppskjuten skatteskuld', - }, - { - account_number: LATENT_TAX_EXPENSE_ACCOUNT, - debit_amount: 0, - credit_amount: absoluteDelta, - line_description: 'Förändring uppskjuten skatt (K3)', - }, - ] -} diff --git a/lib/bokslut/types.ts b/lib/bokslut/types.ts index ebe93830..4bae2057 100644 --- a/lib/bokslut/types.ts +++ b/lib/bokslut/types.ts @@ -6,7 +6,6 @@ export type DispositionKind = | 'periodiseringsfond_ateforing' | 'overavskrivningar' | 'sarskild_loneskatt' - | 'uppskjuten_skatt' /** * Common shape every bokslut-disposition calculator returns. The wizard renders