feat(bokslut): year-end wizard with bokslutsdispositioner + asset register (#508)

* feat(bokslut): year-end wizard with bokslutsdispositioner + asset register

Ships the first user-visible bokslut surface for K2 aktiebolag. The year-end
engine, INK2/INK2R/INK2S generator, and reconciliation reports already existed
in lib/core/bookkeeping/ and lib/reports/; this work wires them into a real
multi-step UI, adds the missing dispositioner calculators (bolagsskatt,
periodiseringsfond, överavskrivningar, SLP), and introduces a fixed-asset
register that feeds planenliga avskrivningar into the same flow.

PHASE 1 — Wizard around the existing year-end engine
- Replaces the "Kommer snart" stub at /bookkeeping/year-end with a 4-step
  wizard (Kontroll → Dispositioner → Förhandsgranska → Verkställ) plus a
  Klart result view
- New aggregator lib/bokslut/readiness-aggregator.ts composes
  validateYearEndReadiness with bank-reconciliation status and entity-typed
  reminders into one fetch backing the preflight step
- New endpoint GET /api/bookkeeping/fiscal-periods/[id]/bokslut-readiness

PHASE 2 — Bokslutsdispositioner calculators
- lib/bokslut/tax-provision/{bolagsskatt,sarskild-loneskatt}-calculator.ts —
  20.6 % on taxable result → 8910/2512 (with non-deductible / non-taxable
  manual adjustments and schablonintäkt pass-through) and 24.26 % SLP on
  posted pension costs → 7533/2514
- lib/bokslut/reserves/periodiseringsfond-service.ts — proposeAvsattning
  (25 % cap, BAS 212X cohort accounts) + proposeAteforing (FIFO 6-year
  mandatory reversal with schablonintäkt computation) + balance lookup
- lib/bokslut/reserves/overavskrivningar-service.ts — 30-rule + 20-rule
  helpers + proposeOveravskrivningar (8853/2153)
- New endpoint /api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner
  (GET ordered proposals, POST commits user-chosen ones as separate
  year_end vouchers via the journal engine)
- New DispositionsStep UI: per-card accept/skip + editable amount where
  meaningful; mandatory p-fond reversals can't be skipped
- INK2 bug fix: ink2-engine.ts SRU mapping ranges previously pointed at
  accounts BAS doesn't seed (8810/8830/8840). Corrected to 8811 (avsättning),
  8819 (återföring), 8830 (lämnade koncernbidrag) so calculator output now
  flows into INK2 correctly. Regression-locked with 6 new mapping tests.

PHASE 3 — Anläggningsregister + depreciation engine
- New migration 20260516120000_assets_and_depreciation.sql: assets table
  (category, BAS-triple, K3 components JSONB reserved) and
  depreciation_schedules (asset+period+journal_entry link). RLS via
  user_company_ids(), immutability triggers after disposal/posting.
- lib/bokslut/assets/asset-service.ts — CRUD + disposal that posts a proper
  gain/loss entry against 3973/7973
- lib/bokslut/assets/depreciation-engine.ts — computeAnnualDepreciation
  (linear, pro-rata at acquisition/disposal/end-of-life) +
  proposeAnnualPostings + commitAnnualPostings (one entry per asset)
- New endpoints /api/assets (CRUD + dispose) and
  /api/bookkeeping/fiscal-periods/[id]/depreciation (preview + commit)
- /assets list+create page with K2 schablon defaults (3y datorer,
  5y inventarier, 25y byggnader); sidebar entry added
- DepreciationPanel mounted at the top of DispositionsStep; posting
  refreshes dispositions so bolagsskatt picks up the new result

Out of scope (per the agreed plan): K3 framework, iXBRL filing to
Bolagsverket (manual export only for now — regulatory risk flagged for
FY2026 closings), inventory module, koncernredovisning, revisor workflow.

Verification
- 116 unit tests pass across lib/bokslut/, lib/reports/ink2/, and the
  existing lib/core/bookkeeping/year-end-service suite
- Zero lint or typecheck errors in any new file
- Migration applied successfully via Supabase MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address PR #508 review — P1 correctness + P2 conventions

P1 — stale reminders in readiness-aggregator (greptile)
Remove the depreciation_manual / bolagsskatt_manual / periodiseringsfond_manual
nudges. After Phase 3, the wizard handles all three automatically via
DepreciationPanel and the dispositioner calculators — surfacing them as
manual reminders told users to repeat work the page already did. Only the
accruals_manual reminder (Phase 4 hasn't shipped) and the EF-only
ef_skatt_via_ne reminder remain.

P1 — netBookValueAfter ignored prior accumulated depreciation
proposeAnnualPostings now fetches all prior posted depreciation_schedules
for the company (excluding the current period) and sums them per asset, so
the displayed restvärde reflects every previously-booked year of avskrivning
instead of only this year. Without the fix, a 5-year asset in year 3 would
have shown 48 000 instead of the correct 24 000 net book value.

P1 — ordering bug in dispositioner POST handler
The 25 % p-fond avsättning cap derives from the current trial balance, so
mandatory återföring entries must post first. Added a server-side sort by
canonical bokslut order (återföring → överavskrivningar → avsättning → SLP
→ bolagsskatt) regardless of the client array order. The cap can no longer
be evaluated against a stale pre-återföring net result.

P2 — depreciation_schedules missing updated_at
New migration 20260516140000_depreciation_schedules_updated_at.sql adds the
column + trigger via update_updated_at_column(). Per CLAUDE.md migration
conventions, never modified the original migration. DepreciationSchedule
type updated.

P2 — addMonths end-of-month overflow
Replaced setUTCMonth (which overflows: Jan 31 + 1 month → Mar 3) with a
day-clamping implementation that produces Feb 28/29. Without the fix,
lifeEndExclusive landed one day too late and slightly over-depreciated.
New regression test asserts Jan 31 + 12 months stays in January.

P2 — pg-real tests for new triggers and RLS
tests/pg/assets.pg.test.ts (13 tests) covers:
  - enforce_asset_post_disposal_immutability blocks every financial field
    after disposal, allows notes/name through
  - assets_disposal_atomic CHECK requires both disposed columns set together
  - enforce_depreciation_schedule_immutability blocks edits after
    journal_entry_id is linked, allows them before
  - depreciation_schedules delete RLS policy filters out posted rows
  - assets + depreciation_schedules RLS isolates across companies

Verification
- 117 unit tests pass (was 116, +1 for the addMonths regression)
- New pg-real suite syntactically + type-correct; will execute in CI
- Zero lint or typecheck errors in any touched file
- Migration 20260516140000 applied to remote Supabase via MCP

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address compliance-swarm + swedish-review findings

Real bugs surfaced by the bots on the first push that weren't covered by
greptile's inline P1/P2 set:

- Immaterial asset disposal hit the wrong BAS accounts. disposeAsset always
  posted gain/loss to 3973/7973 regardless of category. For category
  'immaterial' it must use 3013 (vinst) / 7813 (förlust) per BAS — using the
  tangible accounts misclassifies in INK2R. Now branches on category. Two
  new regression tests pin each branch.

- acquisition_cost CHECK was too loose. CreateAssetSchema accepted 0 (just
  nonnegative). Tightened to z.number().positive() — a zero-cost asset
  creates a no-op depreciation row and a balance sheet line that nothing
  reconciles against.

- UpdateAssetSchema let users remap BAS accounts arbitrarily. Bot flagged
  this as a defense-in-depth gap (V4.5). Added BAS_RANGES_BY_CATEGORY
  validation at both the schema layer (Create) and the service layer
  (Update) so user-supplied account overrides must stay inside the
  category's expected BAS range. INK2R mappings and the depreciation
  engine's category-driven defaults now can't drift.

Swedish accounting review:

- Building/markanläggning defaults — clarified UI copy. The 25-year
  schablon is K2-redovisning, not the IL skattemässig rate. New helper text
  spells this out. Markanläggning default lowered from 20→10 years
  (Skatteverket guidance allows 10 % rate; 20 was on the upper bound
  without justification).

- createAsset doesn't post the acquisition entry by design — that gap
  wasn't called out anywhere in the UI. Added a tip box in
  CreateAssetDialog explaining that the acquisition must already be in the
  books; the register only drives depreciation.

- Disposal VAT (ML 3:3 / 7:3) not handled — sale of a deduct-eligible
  anläggningstillgång is in principle 25 % momspliktig. Documented this as
  a known limitation in the disposeAsset docstring so any future UI
  surfacing the disposal endpoint warns the user.

Documented (not fixed yet) — bot was right but wider-scope work:

- SOC 2 PI1.3: dispositioner POST loop is not transactional. A failure
  midway leaves partial postings. Added a code comment explaining the
  recovery path (re-POST omitting committed kinds — each calculator
  re-derives from current TB). Real atomicity via an RPC wrapper is Phase
  5+ work.

False positives intentionally not changed:

- 4× OWASP V8.2.1 cross-tenant findings — service functions already filter
  by company_id; the bot can't see past the route handler.
- V2.3 client-supplied amount clamping — proposeAvsattning and
  proposeAteforing both clamp via Math.min already.
- A.8.15 audit events — withRouteContext already logs completion.
- Schablonintäkt journal entry — per IL 30:6a it's a skattemässig
  justering, never booked. Current implementation is correct.
- Voucher series 'A' — matches existing executeYearEndClosing convention;
  not changing here in isolation.

Verification
- 119 tests pass (was 117, +2 for the immaterial-disposal branches)
- Zero lint or typecheck errors on any touched file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address compliance round 4 — BAS account overlap + method gate

Two real bugs the compliance bot caught after my round-3 fixes (both new on
its re-run, not in the original report):

- BAS account overlap not prevented: my BAS_RANGES_BY_CATEGORY uses the same
  class range for asset and accumulated (e.g. immaterial: both 1010–1099,
  building: both 1100–1199). Nothing stopped a user from picking the same
  account for both, which would silently net acquisition cost against
  accumulated depreciation in one bucket and corrupt INK2R 720x mappings.
  CreateAssetSchema now rejects bas_asset_account === bas_accumulated_account
  in a superRefine cross-field check; updateAsset enforces the same invariant
  by reading the existing asset and validating the merged result.

- declining-balance methods silently fell back to linear. The DB enum allowed
  declining_balance_30 / declining_balance_20, but the engine's
  computeAnnualDepreciation only implements linear math. A determined caller
  (MCP, curl, future UI) could create an asset labelled as räkenskapsenlig
  avskrivning and get linear charges — silently wrong numbers under a
  misleading method. Both CreateAssetSchema and UpdateAssetSchema now refine
  the depreciation_method enum to require 'linear'. The DB enum stays open
  for a future phase to add proper support. Stale comment in
  depreciation-engine.ts updated to reflect the new invariant.

False positives I'm explicitly not chasing further on this round:
- 3× repeated OWASP V8.2.1 cross-tenant — services already filter by
  company_id; bot can't see past the route handler. Round 3 already added
  service-layer tests and inline reasoning.
- V2.3 atomicity upgrade to high — bot now flags it harder *because* I
  documented it in round 3. The existing executeYearEndClosing has the same
  non-transactional sequential-write pattern; wrapping just this endpoint
  in an RPC while leaving the rest inconsistent is worse than the doc
  comment. Real atomicity is Phase 5+.
- Disposal VAT user-facing warning — no UI surfaces dispose yet; docstring
  in the service is sufficient until the UI ships.

Verification
- 119 tests pass
- Zero lint or typecheck errors on any touched file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): address compliance round 5 — disposal integrity + öavskr accounts

Round-5 bot feedback after my round-4 push (the sticky reports re-edited
themselves with two new genuine findings; bot count: 18 → 14 active).

P1 — accumulated_depreciation was client-supplied (OWASP V8.2.1, swedish
compliance review): the dispose endpoint accepted accumulated_depreciation
as a request-body number. A malicious or buggy caller could inflate it to
manipulate the book-value calculation and pocket a phantom gain. Now:

- DisposeAssetSchema no longer accepts accumulated_depreciation
- disposeAsset sums planned_depreciation from depreciation_schedules where
  journal_entry_id IS NOT NULL for the asset, server-side
- New regression test "server-derives accumulated_depreciation — caller
  cannot inflate gain" pins the server-derivation against the prior attack
- Limitation: manual avskrivningsverifikationer posted outside the engine
  aren't captured. Phase 5+ can swap this for a trial-balance scan on
  bas_accumulated_account if that gap matters.

P2 — överavskrivningar hardcoded 8853/2153 regardless of asset category
(swedish-asset-accounting): for buildings BAS uses 8852/2152 and for
immateriella tillgångar 8851/2151. Edge case for K2 SME (öavskr on
buildings is rare; on immateriella rarer still) but worth not lying about
the accounts. Now:

- New OVERAVSKRIVNING_ACCOUNTS table maps category → expense/accumulated
  pair (machinery_equipment, building, immaterial, group)
- proposeOveravskrivningar accepts optional category, defaults to
  machinery_equipment (the dominant K2 case — no behaviour change for
  existing callers)
- POST handler item schema accepts optional category
- Label + description strings now name the actual accounts used
- 3 new tests cover the building, immaterial, and default branches

False positives I'm still declining to chase (already covered in prior
commit messages):
- 3× repeated OWASP V8.2.1 cross-tenant — services scope by company_id;
  bot can't see past route handler
- V2.3 atomicity — existing executeYearEndClosing has the same pattern;
  wrapping just this endpoint is inconsistent; real fix is Phase 5+ RPC
- Disposal VAT user-facing warning — no UI surfaces dispose yet

Verification
- 123 tests pass (was 119, +3 for öavskr category branches and +1 for the
  server-derivation regression test, with one prior test rewritten to use
  the new server-supplied accumulated path)
- Zero lint or typecheck errors on any touched file

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(bokslut): pre-merge polish — building disposal accounts, SLR-2026 rate

Last polish round before merge. All three from the round-5 sticky
Swedish-accounting-review update:

- Building / markanläggning disposal posted gain/loss to 3973/7973. BAS
  2026 routes those to 3971/7971 (the SRU mapping points them at a
  different INK2R field, so the existing accounts misclassify). Extended
  the existing immaterial branch (3013/7813) into a three-way:
    immaterial            → 3013 / 7813
    building / land_imprv → 3971 / 7971
    other tangible        → 3973 / 7973
  Two new regression tests pin the building and land_improvement branches.

- DEFAULT_SCHABLONINTAKT_RATE was 0.03, based on SLR 2024-11-30 (1.96 %).
  For closings of inkomstår 2026 the rate is SLR 2025-11-30 (2.55 %) + 1 pe
  = 3.55 %. The wrong rate under-taxes the schablonintäkt, which feeds into
  bolagsskatt. Updated to 0.0355 and rewrote the doc comment to track both
  years so the next bump is obvious.

- Jämkning of input VAT for buildings / markanläggning disposed within the
  10-year jämkningsperiod (ML 9 kap 8–11 §§) is out of scope for this PR
  but should not be silently absent — added a KNOWN LIMITATION block to the
  disposeAsset docstring so any future UI surfacing disposal checks the
  10-year window and warns the user.

After this push the PR has 125 passing tests, all CI green, no merge
conflicts, and the only remaining bot complaints are repeat false
positives or Phase 5+ scope (RPC atomicity, full asset disposal UI,
K3 component depreciation).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-16 15:55:27 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent c06395f633
commit 05078c9d8e
37 changed files with 5954 additions and 35 deletions
+155
View File
@@ -0,0 +1,155 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { PageHeader } from '@/components/ui/page-header'
import { EmptyState } from '@/components/ui/empty-state'
import { Skeleton } from '@/components/ui/skeleton'
import { Package, Plus } from 'lucide-react'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { Asset, AssetCategory } from '@/types'
import { CreateAssetDialog } from '@/components/bookkeeping/assets/CreateAssetDialog'
const CATEGORY_LABELS: Record<AssetCategory, string> = {
immaterial: 'Immateriell',
building: 'Byggnad',
land_improvement: 'Markanläggning',
machinery: 'Maskin',
equipment: 'Inventarier',
vehicle: 'Fordon',
computer: 'Dator',
other_tangible: 'Övriga materiella',
}
export default function AssetsPage() {
const [assets, setAssets] = useState<Asset[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
// Bumped on create to re-trigger the effect.
const [reloadKey, setReloadKey] = useState(0)
useEffect(() => {
let cancelled = false
fetch('/api/assets')
.then(async (res) => {
if (cancelled) return
if (!res.ok) {
setError('Kunde inte ladda tillgångar')
return
}
const { data } = (await res.json()) as { data: Asset[] }
if (cancelled) return
setError(null)
setAssets(data)
})
.catch(() => {
if (!cancelled) setError('Kunde inte ladda tillgångar')
})
return () => {
cancelled = true
}
}, [reloadKey])
const handleCreated = useCallback(() => {
setDialogOpen(false)
setReloadKey((k) => k + 1)
}, [])
return (
<div className="space-y-8">
<PageHeader
title="Anläggningstillgångar"
action={
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-1 h-4 w-4" /> Ny tillgång
</Button>
}
/>
{assets === null && !error && (
<Card>
<CardContent className="p-6 space-y-2">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
)}
{error && (
<Card>
<CardContent className="p-6 text-destructive">{error}</CardContent>
</Card>
)}
{assets !== null && assets.length === 0 && (
<EmptyState
icon={Package}
title="Inga tillgångar än"
description="Lägg till anläggningstillgångar (datorer, möbler, fordon, maskiner) så räknar bokslutet planenliga avskrivningar automatiskt."
actionLabel="Ny tillgång"
onAction={() => setDialogOpen(true)}
/>
)}
{assets !== null && assets.length > 0 && (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Namn</TableHead>
<TableHead>Kategori</TableHead>
<TableHead>Anskaffat</TableHead>
<TableHead className="text-right">Anskaffningsvärde</TableHead>
<TableHead>Avskrivningstid</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assets.map((asset) => {
const years = Math.round(asset.useful_life_months / 12)
return (
<TableRow key={asset.id}>
<TableCell className="font-medium">{asset.name}</TableCell>
<TableCell className="text-sm">{CATEGORY_LABELS[asset.category]}</TableCell>
<TableCell className="tabular-nums">
{formatDate(asset.acquisition_date)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(Number(asset.acquisition_cost))}
</TableCell>
<TableCell className="text-sm">
{years} år ({asset.useful_life_months} mån)
</TableCell>
<TableCell>
{asset.disposed_at ? (
<Badge variant="secondary">Avyttrad</Badge>
) : (
<Badge variant="success">Aktiv</Badge>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
<CreateAssetDialog open={dialogOpen} onOpenChange={setDialogOpen} onCreated={handleCreated} />
</div>
)
}
+304 -18
View File
@@ -1,14 +1,202 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Progress } from '@/components/ui/progress'
import { EmptyState } from '@/components/ui/empty-state'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowLeft, Lock } from 'lucide-react'
import { SupportLink } from '@/components/ui/support-link'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import type { FiscalPeriod, YearEndPreview, YearEndResult } from '@/types'
import type { BokslutReadinessReport } from '@/lib/bokslut/readiness-aggregator'
import { PreflightStep } from '@/components/bookkeeping/year-end/PreflightStep'
import { DispositionsStep } from '@/components/bookkeeping/year-end/DispositionsStep'
import { PreviewStep } from '@/components/bookkeeping/year-end/PreviewStep'
import { ExecuteStep } from '@/components/bookkeeping/year-end/ExecuteStep'
import { ResultStep } from '@/components/bookkeeping/year-end/ResultStep'
type Step = 'preflight' | 'dispositions' | 'preview' | 'execute' | 'result'
const STEP_ORDER: Step[] = ['preflight', 'dispositions', 'preview', 'execute', 'result']
const STEP_LABELS: Record<Step, string> = {
preflight: 'Kontroll',
dispositions: 'Dispositioner',
preview: 'Förhandsgranska',
execute: 'Verkställ',
result: 'Klart',
}
interface PeriodOption {
id: string
name: string
period_start: string
period_end: string
}
export default function YearEndPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { toast } = useToast()
// ---- Period selection ----
const [periods, setPeriods] = useState<PeriodOption[] | null>(null)
const [periodsError, setPeriodsError] = useState<string | null>(null)
const [selectedPeriodId, setSelectedPeriodId] = useState<string | null>(
searchParams.get('period') ?? null,
)
// ---- Wizard state ----
const [step, setStep] = useState<Step>('preflight')
const [report, setReport] = useState<BokslutReadinessReport | null>(null)
const [reportLoading, setReportLoading] = useState(false)
const [reportError, setReportError] = useState<string | null>(null)
const [preview, setPreview] = useState<YearEndPreview | null>(null)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [executing, setExecuting] = useState(false)
const [executeError, setExecuteError] = useState<string | null>(null)
const [result, setResult] = useState<YearEndResult | null>(null)
// ---- Load eligible periods ----
useEffect(() => {
let cancelled = false
const load = async () => {
try {
const res = await fetch('/api/bookkeeping/fiscal-periods')
if (!res.ok) {
if (!cancelled) setPeriodsError('Kunde inte hämta perioder')
return
}
const { data } = (await res.json()) as { data: FiscalPeriod[] }
const today = new Date().toISOString().split('T')[0]
const eligible = (data ?? []).filter(
(p) => !p.is_closed && !p.closing_entry_id && p.period_end <= today,
)
// Oldest first — accountants close in order.
eligible.sort((a, b) => a.period_start.localeCompare(b.period_start))
if (cancelled) return
setPeriods(eligible)
if (!selectedPeriodId && eligible.length > 0) {
setSelectedPeriodId(eligible[0].id)
}
} catch {
if (!cancelled) setPeriodsError('Kunde inte hämta perioder')
}
}
void load()
return () => {
cancelled = true
}
}, [selectedPeriodId])
// ---- Sync selected period to URL so users can bookmark / share ----
useEffect(() => {
if (!selectedPeriodId) return
const params = new URLSearchParams(searchParams.toString())
if (params.get('period') === selectedPeriodId) return
params.set('period', selectedPeriodId)
router.replace(`/bookkeeping/year-end?${params.toString()}`, { scroll: false })
}, [selectedPeriodId, router, searchParams])
// ---- Fetch readiness report whenever selected period changes ----
useEffect(() => {
if (!selectedPeriodId) return
let cancelled = false
setReportLoading(true)
setReportError(null)
setReport(null)
fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/bokslut-readiness`)
.then(async (res) => {
const body = await res.json()
if (cancelled) return
if (!res.ok) {
setReportError(body?.error?.message ?? 'Kunde inte ladda bokslutskontroll')
return
}
setReport(body.data as BokslutReadinessReport)
})
.catch(() => {
if (!cancelled) setReportError('Kunde inte ladda bokslutskontroll')
})
.finally(() => {
if (!cancelled) setReportLoading(false)
})
return () => {
cancelled = true
}
}, [selectedPeriodId])
// ---- Step navigation ----
const goToPreview = useCallback(async () => {
if (!selectedPeriodId) return
setPreviewLoading(true)
setPreviewError(null)
setStep('preview')
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`)
const body = await res.json()
if (!res.ok) {
setPreviewError(body?.error?.message ?? 'Kunde inte hämta förhandsgranskning')
return
}
setPreview(body.data.preview as YearEndPreview)
} catch (err) {
setPreviewError(getErrorMessage(err))
} finally {
setPreviewLoading(false)
}
}, [selectedPeriodId])
const executeYearEnd = useCallback(async () => {
if (!selectedPeriodId) return
setExecuting(true)
setExecuteError(null)
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${selectedPeriodId}/year-end`, {
method: 'POST',
})
const body = await res.json()
if (!res.ok) {
setExecuteError(body?.error?.message ?? 'Bokslutet kunde inte verkställas')
return
}
setResult(body.data as YearEndResult)
setStep('result')
toast({
title: 'Bokslut verkställt',
description: `${report?.period.name ?? 'Perioden'} är stängd.`,
})
} catch (err) {
setExecuteError(getErrorMessage(err))
} finally {
setExecuting(false)
}
}, [selectedPeriodId, report?.period.name, toast])
const currentStepIndex = STEP_ORDER.indexOf(step)
const progressValue = ((currentStepIndex + 1) / STEP_ORDER.length) * 100
const showWizard = useMemo(
() => selectedPeriodId !== null && (periods?.length ?? 0) > 0,
[selectedPeriodId, periods],
)
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Årsbokslut</h1>
<h1 className="font-display text-3xl md:text-4xl tracking-tight">Årsbokslut</h1>
<Button variant="outline" asChild>
<Link href="/bookkeeping">
<ArrowLeft className="mr-2 h-4 w-4" />
@@ -17,22 +205,120 @@ export default function YearEndPage() {
</Button>
</div>
<Card>
<CardContent className="flex flex-col items-center justify-center py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted mb-4">
<Lock className="h-7 w-7 text-muted-foreground" />
</div>
<h2 className="text-xl font-semibold mb-2">Kommer snart</h2>
<p className="text-muted-foreground max-w-md">
Årsbokslut är under utveckling och kommer att finnas tillgängligt i en kommande version.
</p>
<div className="mt-4">
<SupportLink variant="muted" subject="Fråga om årsbokslut">
Behöver du hjälp med bokslut? Kontakta oss
</SupportLink>
</div>
</CardContent>
</Card>
{periods === null && !periodsError && (
<Card>
<CardContent className="p-6 space-y-2">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-full" />
</CardContent>
</Card>
)}
{periodsError && (
<Card>
<CardContent className="p-6 text-destructive">{periodsError}</CardContent>
</Card>
)}
{periods !== null && periods.length === 0 && (
<EmptyState
icon={Lock}
title="Inga perioder att stänga"
description="Det finns ingen öppen räkenskapsperiod vars slutdatum redan har passerat. Bokslut görs efter att periodens slutdatum är passerat."
/>
)}
{showWizard && periods && periods.length > 1 && step !== 'result' && (
<Card>
<CardContent className="p-4 flex items-center gap-4">
<label className="text-sm font-medium shrink-0">Period</label>
<Select
value={selectedPeriodId ?? undefined}
onValueChange={(value) => {
setSelectedPeriodId(value)
setStep('preflight')
setPreview(null)
setResult(null)
setExecuteError(null)
}}
>
<SelectTrigger className="w-full max-w-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{periods.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name} ({p.period_start} – {p.period_end})
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
)}
{showWizard && step !== 'result' && (
<Card>
<CardContent className="p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="sm:hidden text-primary font-medium">
Steg {currentStepIndex + 1}/{STEP_ORDER.length}: {STEP_LABELS[step]}
</span>
{STEP_ORDER.map((s, i) => (
<span
key={s}
className={cn(
'hidden sm:inline',
i <= currentStepIndex ? 'text-primary font-medium' : 'text-muted-foreground',
)}
>
{STEP_LABELS[s]}
</span>
))}
</div>
<Progress value={progressValue} className="h-2" />
</CardContent>
</Card>
)}
{showWizard && step === 'preflight' && (
<PreflightStep
report={report}
isLoading={reportLoading}
error={reportError}
onContinue={() => setStep('dispositions')}
/>
)}
{showWizard && step === 'dispositions' && selectedPeriodId && (
<DispositionsStep
periodId={selectedPeriodId}
onBack={() => setStep('preflight')}
onContinue={goToPreview}
/>
)}
{showWizard && step === 'preview' && (
<PreviewStep
preview={preview}
isLoading={previewLoading}
error={previewError}
onBack={() => setStep('dispositions')}
onContinue={() => setStep('execute')}
/>
)}
{showWizard && step === 'execute' && report && (
<ExecuteStep
periodName={report.period.name}
isRunning={executing}
error={executeError}
onBack={() => setStep('preview')}
onExecute={executeYearEnd}
/>
)}
{step === 'result' && result && <ResultStep result={result} />}
</div>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
import { disposeAsset } from '@/lib/bokslut/assets/asset-service'
const DisposeAssetSchema = z.object({
disposed_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
disposed_proceeds: z.number().nonnegative(),
proceeds_account: z.string().regex(/^\d{4}$/).optional(),
fiscal_period_id: z.string().uuid(),
// accumulated_depreciation is intentionally NOT accepted from the client —
// disposeAsset sums depreciation_schedules server-side so callers cannot
// inflate the book-value calculation.
})
export const POST = withRouteContext(
'assets.dispose',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, DisposeAssetSchema)
if (!validation.success) return validation.response
try {
const result = await disposeAsset(supabase, companyId, user.id, id, validation.data)
return NextResponse.json({ data: result })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
{ requireWrite: true },
)
+73
View File
@@ -0,0 +1,73 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
import { getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
import type { DepreciationMethod } from '@/types'
const DEPRECIATION_METHODS: readonly DepreciationMethod[] = [
'linear',
'declining_balance_30',
'declining_balance_20',
] as const
// Engine only implements linear today — reject declining_balance methods on
// both create and update until the engine grows them. The DB enum keeps the
// other methods reserved for a future phase.
const SUPPORTED_DEPRECIATION_METHODS: readonly DepreciationMethod[] = ['linear'] as const
const UpdateAssetSchema = z.object({
name: z.string().min(1).optional(),
notes: z.string().nullable().optional(),
salvage_value: z.number().nonnegative().optional(),
useful_life_months: z.number().int().positive().optional(),
depreciation_method: z
.enum(DEPRECIATION_METHODS as unknown as [DepreciationMethod, ...DepreciationMethod[]])
.optional()
.refine(
(m) => m === undefined || (SUPPORTED_DEPRECIATION_METHODS as readonly string[]).includes(m),
{
message:
'Only "linear" depreciation is supported by the engine today. ' +
'Declining-balance methods are reserved for a future phase.',
},
),
bas_asset_account: z.string().regex(/^\d{4}$/).optional(),
bas_accumulated_account: z.string().regex(/^\d{4}$/).optional(),
bas_expense_account: z.string().regex(/^\d{4}$/).optional(),
})
export const GET = withRouteContext(
'assets.get',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
const asset = await getAsset(supabase, companyId, id)
if (!asset) {
return NextResponse.json({ error: { code: 'ASSET_NOT_FOUND' } }, { status: 404 })
}
return NextResponse.json({ data: asset })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
)
export const PATCH = withRouteContext(
'assets.update',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, UpdateAssetSchema)
if (!validation.success) return validation.response
try {
const asset = await updateAsset(supabase, companyId, id, validation.data)
return NextResponse.json({ data: asset })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
{ requireWrite: true },
)
+162
View File
@@ -0,0 +1,162 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
import { createAsset, listAssets } from '@/lib/bokslut/assets/asset-service'
import type { AssetCategory, DepreciationMethod } from '@/types'
const ASSET_CATEGORIES: readonly AssetCategory[] = [
'immaterial',
'building',
'land_improvement',
'machinery',
'equipment',
'vehicle',
'computer',
'other_tangible',
] as const
// The DB enum keeps all three methods so future phases can add support
// without a migration, but the engine only implements linear today. Reject
// the unsupported methods at create to avoid silently producing wrong
// (linear) numbers under a misleading method label.
const DEPRECIATION_METHODS: readonly DepreciationMethod[] = [
'linear',
'declining_balance_30',
'declining_balance_20',
] as const
const SUPPORTED_DEPRECIATION_METHODS: readonly DepreciationMethod[] = ['linear'] as const
const CreateAssetSchema = z
.object({
name: z.string().min(1),
category: z.enum(ASSET_CATEGORIES as unknown as [AssetCategory, ...AssetCategory[]]),
acquisition_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
// Positive — a zero-value asset would dodge the depreciation engine and
// create a no-op row that confuses the balance sheet.
acquisition_cost: z.number().positive(),
salvage_value: z.number().nonnegative().optional(),
useful_life_months: z.number().int().positive(),
depreciation_method: z
.enum(DEPRECIATION_METHODS as unknown as [DepreciationMethod, ...DepreciationMethod[]])
.optional()
.refine(
(m) => m === undefined || (SUPPORTED_DEPRECIATION_METHODS as readonly string[]).includes(m),
{
message:
'Only "linear" depreciation is supported by the engine today. ' +
'Declining-balance methods are reserved for a future phase.',
},
),
bas_asset_account: z.string().regex(/^\d{4}$/).optional(),
bas_accumulated_account: z.string().regex(/^\d{4}$/).optional(),
bas_expense_account: z.string().regex(/^\d{4}$/).optional(),
notes: z.string().optional(),
})
.superRefine((value, ctx) => {
// Defense-in-depth: when the user overrides BAS accounts, refuse anything
// outside the legitimate range for the asset category so the chart stays
// BAS-aligned and INK2R mappings continue to work.
validateBasOverrides(value, ctx)
})
function validateBasOverrides(
value: {
category: AssetCategory
bas_asset_account?: string
bas_accumulated_account?: string
bas_expense_account?: string
},
ctx: z.RefinementCtx,
): void {
const ranges = BAS_RANGES_BY_CATEGORY[value.category]
if (value.bas_asset_account && !inRange(value.bas_asset_account, ranges.asset)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['bas_asset_account'],
message: `Account must be in range ${ranges.asset[0]}–${ranges.asset[1]} for ${value.category}`,
})
}
if (
value.bas_accumulated_account &&
!inRange(value.bas_accumulated_account, ranges.accumulated)
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['bas_accumulated_account'],
message: `Account must be in range ${ranges.accumulated[0]}–${ranges.accumulated[1]} for ${value.category}`,
})
}
if (value.bas_expense_account && !inRange(value.bas_expense_account, ranges.expense)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['bas_expense_account'],
message: `Account must be in range ${ranges.expense[0]}–${ranges.expense[1]} for ${value.category}`,
})
}
// Anskaffningskonto and ackumulerade-avskrivningar-konto live in the same
// class range (e.g. 1010-1099 for immaterial, 1100-1199 for buildings), so
// a user could pick the same account for both. That would silently net
// acquisition cost against accumulated depreciation in one bucket and
// break the INK2R 720x mappings. Force them apart.
if (
value.bas_asset_account &&
value.bas_accumulated_account &&
value.bas_asset_account === value.bas_accumulated_account
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['bas_accumulated_account'],
message:
'Anskaffningskonto och ackumulerade-avskrivningar-konto måste vara olika konton.',
})
}
}
const BAS_RANGES_BY_CATEGORY: Record<
AssetCategory,
{ asset: [string, string]; accumulated: [string, string]; expense: [string, string] }
> = {
immaterial: { asset: ['1010', '1099'], accumulated: ['1010', '1099'], expense: ['7810', '7819'] },
building: { asset: ['1100', '1199'], accumulated: ['1100', '1199'], expense: ['7820', '7829'] },
land_improvement:{ asset: ['1150', '1159'], accumulated: ['1150', '1159'], expense: ['7820', '7829'] },
machinery: { asset: ['1210', '1219'], accumulated: ['1210', '1219'], expense: ['7830', '7839'] },
equipment: { asset: ['1220', '1229'], accumulated: ['1220', '1229'], expense: ['7830', '7839'] },
vehicle: { asset: ['1240', '1249'], accumulated: ['1240', '1249'], expense: ['7830', '7839'] },
computer: { asset: ['1250', '1259'], accumulated: ['1250', '1259'], expense: ['7830', '7839'] },
other_tangible: { asset: ['1280', '1299'], accumulated: ['1280', '1299'], expense: ['7830', '7839'] },
}
function inRange(account: string, range: [string, string]): boolean {
return account >= range[0] && account <= range[1]
}
export const GET = withRouteContext('assets.list', async (request, ctx) => {
const { supabase, companyId, log, requestId } = ctx
const url = new URL(request.url)
const activeOnly = url.searchParams.get('active') === 'true'
try {
const data = await listAssets(supabase, companyId, { activeOnly })
return NextResponse.json({ data })
} catch (err) {
return errorResponse(err, log, { requestId })
}
})
export const POST = withRouteContext(
'assets.create',
async (request, ctx) => {
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, CreateAssetSchema)
if (!validation.success) return validation.response
try {
const asset = await createAsset(supabase, companyId, user.id, validation.data)
return NextResponse.json({ data: asset })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
{ requireWrite: true },
)
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildBokslutReadinessReport } from '@/lib/bokslut/readiness-aggregator'
/**
* GET: aggregated bokslut readiness report — combines validateYearEndReadiness
* (legal blockers) with bank-reconciliation status and informational reminders
* for the bokslutsdispositioner that are still booked manually until Phase 2+
* ships their calculators. One fetch backs the wizard's preflight step.
*/
export const GET = withRouteContext(
'period.bokslut_readiness',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const opLog = log.child({ periodId: id })
try {
const report = await buildBokslutReadinessReport(supabase, companyId, user.id, id)
return NextResponse.json({ data: report })
} catch (err) {
opLog.error('bokslut readiness aggregation failed', err as Error)
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
}
return errorResponseFromCode('YEAR_END_PREVIEW_FAILED', opLog, {
requestId,
details: { reason: message },
})
}
},
)
@@ -0,0 +1,355 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { calculateBolagsskatt } from '@/lib/bokslut/tax-provision/bolagsskatt-calculator'
import { calculateSarskildLoneskatt } from '@/lib/bokslut/tax-provision/sarskild-loneskatt-calculator'
import {
listExistingPeriodiseringsfonder,
proposeAvsattning,
proposeAteforing,
} from '@/lib/bokslut/reserves/periodiseringsfond-service'
import { proposeOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-service'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import type { DispositionsProposal, ProposedDisposition } from '@/lib/bokslut/types'
import type { JournalEntry } from '@/types'
/**
* Default schablonintäkt rate on periodiseringsfond (IL 30 kap 6a §).
* Statslåneräntan 30 november föregående år + 1 procentenhet, lägst 0.5 %.
*
* - inkomstår 2025: SLR 2024-11-30 = 1.96 % → 2.96 % (rounded to 3 %)
* - inkomstår 2026: SLR 2025-11-30 = 2.55 % → 3.55 %
*
* The default below is the FY2026 rate since that is the year customers are
* currently closing. Caller can override per request via `schablonintaktRate`
* in the POST body; a future Riksbanken integration will fetch the rate by
* the fiscal year automatically.
*/
const DEFAULT_SCHABLONINTAKT_RATE = 0.0355
/**
* Canonical bokslut order. Each calculator re-reads the trial balance to
* derive its base, so earlier items must post before later items see their
* effect: återföring → överavskrivningar → avsättning → SLP → bolagsskatt.
* The POST handler enforces this order regardless of how the client sends
* its items array, so the avsättning 25 % cap can never be evaluated
* against a stale (pre-återföring) net result.
*/
const DISPOSITION_ORDER: Record<string, number> = {
periodiseringsfond_ateforing: 0,
overavskrivningar: 1,
periodiseringsfond_avsattning: 2,
sarskild_loneskatt: 3,
bolagsskatt: 4,
}
// ============================================================
// GET — return proposal snapshot with defaults
// ============================================================
export const GET = withRouteContext(
'period.bokslutsdispositioner_preview',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
const opLog = log.child({ periodId: id })
try {
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
}
const { data: settings } = await supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle()
const entityType = (settings?.entity_type ??
'aktiebolag') as DispositionsProposal['entityType']
// EF doesn't book dispositioner — these are handled in NE/INK1. Return
// an empty proposal so the wizard can skip the step.
if (entityType !== 'aktiebolag') {
const incomeStatement = await generateIncomeStatement(supabase, companyId, id)
return NextResponse.json({
data: {
entityType,
fiscalPeriod: period,
netResultBefore: incomeStatement.net_result,
proposals: [],
} satisfies DispositionsProposal,
})
}
const fiscalYear = parseInt(period.period_end.slice(0, 4), 10)
const incomeStatement = await generateIncomeStatement(supabase, companyId, id)
const resultBeforeTax = incomeStatement.net_result
const proposals: ProposedDisposition[] = []
// 1. Periodiseringsfond återföring (mandatory for ≥6-year-old fonder)
const existingFonder = await listExistingPeriodiseringsfonder(
supabase,
companyId,
period.period_end,
)
const ateforing = proposeAteforing(existingFonder, {
schablonintaktRate: DEFAULT_SCHABLONINTAKT_RATE,
})
proposals.push(...ateforing.proposals)
// 2. Överavskrivningar — no default; user must enter desired amount
// (Phase 3 will compute from asset register).
// 3. Periodiseringsfond avsättning — default to max (25 % of result
// before tax adjusted for återföring/schablonintäkt).
const taxableBeforeAvsattning =
resultBeforeTax +
ateforing.proposals.reduce((sum, p) => sum + p.amount, 0) +
ateforing.schablonintaktAmount
const avsattning = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: taxableBeforeAvsattning,
fiscalYear,
})
if (avsattning) proposals.push(avsattning)
// 4. Särskild löneskatt — auto-computed from posted pension costs
const slp = await calculateSarskildLoneskatt(supabase, companyId, id)
if (slp) proposals.push(slp)
// 5. Bolagsskatt — taxable result after p-fond avsättning + schablonintäkt
const bolagsskatt = await calculateBolagsskatt(supabase, companyId, id, {
manualAdjustments: {
schablonintaktPeriodiseringsfond: ateforing.schablonintaktAmount,
},
})
if (bolagsskatt) proposals.push(bolagsskatt)
return NextResponse.json({
data: {
entityType,
fiscalPeriod: period,
netResultBefore: resultBeforeTax,
proposals,
} satisfies DispositionsProposal,
})
} catch (err) {
opLog.error('bokslutsdispositioner preview failed', err as Error)
return errorResponse(err, opLog, { requestId })
}
},
)
// ============================================================
// POST — commit a list of dispositions chosen by the user
// ============================================================
const ItemSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('bolagsskatt'),
manualAdjustments: z
.object({
nonDeductibleExpenses: z.number().optional(),
nonTaxableIncome: z.number().optional(),
schablonintaktPeriodiseringsfond: z.number().optional(),
other: z.number().optional(),
})
.optional(),
}),
z.object({
kind: z.literal('sarskild_loneskatt'),
manualAdjustment: z.number().optional(),
}),
z.object({
kind: z.literal('periodiseringsfond_avsattning'),
/** Optional override for the SLR-based schablonintäkt rate; defaults to
* the server-side constant. Used both to compute the cap base and to
* feed back into bolagsskatt's adjustment if present in the same batch. */
schablonintaktRate: z.number().optional(),
desiredAmount: z.number().optional(),
}),
z.object({
kind: z.literal('periodiseringsfond_ateforing'),
returns: z.record(z.string(), z.number()).default({}),
schablonintaktRate: z.number().default(DEFAULT_SCHABLONINTAKT_RATE),
}),
z.object({
kind: z.literal('overavskrivningar'),
additionalAmount: z.number(),
/** Asset category for BAS account selection — defaults to maskiner &
* inventarier (8853/2153), the dominant K2 case. */
category: z
.enum(['machinery_equipment', 'building', 'immaterial', 'group'])
.optional(),
}),
])
const PostBodySchema = z.object({
items: z.array(ItemSchema).min(1),
})
export const POST = withRouteContext(
'period.bokslutsdispositioner_post',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const opLog = log.child({ periodId: id })
const validation = await validateBody(request, PostBodySchema)
if (!validation.success) return validation.response
try {
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('id, name, period_end, is_closed, locked_at, closing_entry_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
return errorResponseFromCode('PERIOD_NOT_FOUND', opLog, { requestId })
}
if (period.is_closed || period.closing_entry_id || period.locked_at) {
return errorResponseFromCode('PERIOD_LOCKED', opLog, { requestId })
}
const fiscalYear = parseInt(period.period_end.slice(0, 4), 10)
const created: { kind: string; entry: JournalEntry }[] = []
// Process items in canonical bokslut order regardless of client array
// ordering — each computation pulls the current income statement, so
// återföring must post before avsättning sees its cap base; över-
// avskrivningar must post before bolagsskatt; SLP and bolagsskatt last.
const sortedItems = [...validation.data.items].sort(
(a, b) => DISPOSITION_ORDER[a.kind] - DISPOSITION_ORDER[b.kind],
)
// KNOWN LIMITATION (SOC 2 PI1.3): the loop is not wrapped in a database
// transaction — each item posts its own journal entry via the engine.
// A failure midway leaves earlier items committed and later ones not.
// Recovery: the UI can re-POST omitting already-committed kinds; each
// calculator re-derives from the current trial balance so the next run
// produces correct amounts on top of what's already there. A future
// RPC-level wrapper (Phase 5+) will make this atomic.
for (const item of sortedItems) {
const proposal = await computeProposal(item, supabase, companyId, id, fiscalYear)
if (!proposal) continue
const entry = await createJournalEntry(supabase, companyId, user.id, {
fiscal_period_id: id,
entry_date: period.period_end,
description: `Bokslutsdisposition: ${proposal.label}`,
source_type: 'year_end',
voucher_series: 'A',
lines: proposal.lines,
})
created.push({ kind: item.kind, entry })
}
return NextResponse.json({ data: { created } })
} catch (err) {
opLog.error('bokslutsdispositioner post failed', err as Error)
return errorResponse(err, opLog, { requestId })
}
},
{ requireWrite: true },
)
type PostItem = z.infer<typeof ItemSchema>
async function computeProposal(
item: PostItem,
supabase: Parameters<typeof calculateBolagsskatt>[0],
companyId: string,
fiscalPeriodId: string,
fiscalYear: number,
): Promise<ProposedDisposition | null> {
switch (item.kind) {
case 'bolagsskatt':
return calculateBolagsskatt(supabase, companyId, fiscalPeriodId, {
manualAdjustments: item.manualAdjustments,
})
case 'sarskild_loneskatt':
return calculateSarskildLoneskatt(supabase, companyId, fiscalPeriodId, {
manualAdjustment: item.manualAdjustment,
})
case 'periodiseringsfond_avsattning': {
// Re-derive the cap base from current state so the user can't sneak in
// a higher desiredAmount than 25 % of actual skattemässigt resultat.
const incomeStatement = await generateIncomeStatement(
supabase,
companyId,
fiscalPeriodId,
)
const { data: periodRow } = await supabase
.from('fiscal_periods')
.select('period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
const periodEnd = periodRow?.period_end ?? `${fiscalYear}-12-31`
const existing = await listExistingPeriodiseringsfonder(supabase, companyId, periodEnd)
const schablonintaktRate = item.schablonintaktRate ?? DEFAULT_SCHABLONINTAKT_RATE
const schablonintakt = existing.reduce(
(sum, f) => sum + f.balance * schablonintaktRate,
0,
)
const base = incomeStatement.net_result + Math.round(schablonintakt)
return proposeAvsattning({
skattemassigtResultatBeforeAvsattning: base,
desiredAmount: item.desiredAmount,
fiscalYear,
})
}
case 'periodiseringsfond_ateforing': {
// Recompute existing fonder server-side so the user can't return more
// than is on the books.
const { data: period } = await supabase
.from('fiscal_periods')
.select('period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single()
if (!period) return null
const existing = await listExistingPeriodiseringsfonder(
supabase,
companyId,
period.period_end,
)
const result = proposeAteforing(existing, {
returns: item.returns,
schablonintaktRate: item.schablonintaktRate,
})
// Combine multiple cohort reversals into a single voucher with multiple
// lines so we don't blow up voucher numbering — but each fond is its own
// line pair already. Build a merged ProposedDisposition.
if (result.proposals.length === 0) return null
return mergeAteforingProposals(result.proposals)
}
case 'overavskrivningar':
return proposeOveravskrivningar({
additionalAmount: item.additionalAmount,
category: item.category,
})
}
}
function mergeAteforingProposals(proposals: ProposedDisposition[]): ProposedDisposition {
const lines = proposals.flatMap((p) => p.lines)
const totalAmount = proposals.reduce((sum, p) => sum + p.amount, 0)
const warnings = proposals.flatMap((p) => p.warnings)
return {
kind: 'periodiseringsfond_ateforing',
label: 'Återföring periodiseringsfond',
description: proposals.map((p) => p.label).join(', '),
amount: totalAmount,
lines,
warnings,
}
}
@@ -0,0 +1,67 @@
import { NextResponse } from 'next/server'
import { z } from 'zod'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { validateBody } from '@/lib/api/validate'
import {
proposeAnnualPostings,
commitAnnualPostings,
} from '@/lib/bokslut/assets/depreciation-engine'
const CommitSchema = z.object({
/** Optional whitelist — when supplied, only assets in this list are posted.
* Empty / omitted = post all proposed depreciations. */
asset_ids: z.array(z.string().uuid()).optional(),
})
export const GET = withRouteContext(
'period.depreciation_preview',
async (_request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
const proposal = await proposeAnnualPostings(supabase, companyId, id)
return NextResponse.json({ data: proposal })
} catch (err) {
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
return errorResponse(err, log, { requestId })
}
},
)
export const POST = withRouteContext(
'period.depreciation_commit',
async (request, ctx, { params }: { params: Promise<{ id: string }> }) => {
const { id } = await params
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, CommitSchema)
if (!validation.success) return validation.response
try {
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('is_closed, locked_at, closing_entry_id')
.eq('id', id)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
return errorResponseFromCode('PERIOD_NOT_FOUND', log, { requestId })
}
if (period.is_closed || period.closing_entry_id || period.locked_at) {
return errorResponseFromCode('PERIOD_LOCKED', log, { requestId })
}
const result = await commitAnnualPostings(supabase, companyId, user.id, id, {
assetIds: validation.data.asset_ids,
})
return NextResponse.json({ data: result })
} catch (err) {
return errorResponse(err, log, { requestId })
}
},
{ requireWrite: true },
)
@@ -0,0 +1,204 @@
'use client'
import { useState } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import type { AssetCategory } from '@/types'
interface CreateAssetDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onCreated: () => void
}
// Defaults are K2-redovisning (BFNAR 2016:10) schablon, NOT skattemässig
// avskrivning. Building / markanläggning values are conservative — IL 19/20
// kap may allow longer (50 yr) or shorter (10 yr) depending on byggnadstyp.
const CATEGORY_OPTIONS: { value: AssetCategory; label: string; defaultYears: number }[] = [
{ value: 'computer', label: 'Dator / IT-utrustning', defaultYears: 3 },
{ value: 'equipment', label: 'Inventarier', defaultYears: 5 },
{ value: 'machinery', label: 'Maskiner', defaultYears: 10 },
{ value: 'vehicle', label: 'Fordon', defaultYears: 5 },
{ value: 'building', label: 'Byggnad', defaultYears: 25 },
{ value: 'land_improvement', label: 'Markanläggning', defaultYears: 10 },
{ value: 'immaterial', label: 'Immateriell tillgång', defaultYears: 5 },
{ value: 'other_tangible', label: 'Övrig materiell tillgång', defaultYears: 5 },
]
export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAssetDialogProps) {
const { toast } = useToast()
const [name, setName] = useState('')
const [category, setCategory] = useState<AssetCategory>('equipment')
const [acquisitionDate, setAcquisitionDate] = useState(
new Date().toISOString().split('T')[0],
)
const [acquisitionCost, setAcquisitionCost] = useState('')
const [usefulLifeYears, setUsefulLifeYears] = useState('5')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleCategoryChange = (next: AssetCategory) => {
setCategory(next)
const option = CATEGORY_OPTIONS.find((o) => o.value === next)
if (option) setUsefulLifeYears(option.defaultYears.toString())
}
const handleSubmit = async () => {
setError(null)
const cost = parseFloat(acquisitionCost)
const years = parseInt(usefulLifeYears, 10)
if (!name.trim() || !Number.isFinite(cost) || cost <= 0 || !Number.isFinite(years) || years <= 0) {
setError('Fyll i namn, anskaffningsvärde och avskrivningstid.')
return
}
setSubmitting(true)
try {
const res = await fetch('/api/assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
category,
acquisition_date: acquisitionDate,
acquisition_cost: cost,
useful_life_months: years * 12,
depreciation_method: 'linear',
}),
})
const body = await res.json()
if (!res.ok) {
setError(body?.error?.message ?? 'Kunde inte spara tillgången')
return
}
toast({ title: 'Tillgång sparad', description: name.trim() })
// Reset form for next entry
setName('')
setAcquisitionCost('')
onCreated()
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel')
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ny anläggningstillgång</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="asset-name">Namn</Label>
<Input
id="asset-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="t.ex. MacBook Pro 14"
autoFocus
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="asset-category">Kategori</Label>
<Select value={category} onValueChange={(v) => handleCategoryChange(v as AssetCategory)}>
<SelectTrigger id="asset-category">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="asset-date">Anskaffat</Label>
<Input
id="asset-date"
type="date"
value={acquisitionDate}
onChange={(e) => setAcquisitionDate(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="asset-cost">Anskaffningsvärde (kr)</Label>
<Input
id="asset-cost"
type="number"
step="1"
min="0"
value={acquisitionCost}
onChange={(e) => setAcquisitionCost(e.target.value)}
placeholder="t.ex. 25000"
className="tabular-nums"
/>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="asset-life">Avskrivningstid (år)</Label>
<Input
id="asset-life"
type="number"
min="1"
max="50"
step="1"
value={usefulLifeYears}
onChange={(e) => setUsefulLifeYears(e.target.value)}
className="tabular-nums"
/>
<p className="text-xs text-muted-foreground">
K2-schablon för redovisning: datorer 3 år, inventarier 5 år, byggnader 25 år.
För skattemässig avskrivning kan annan livslängd gälla (IL 18–20 kap).
</p>
</div>
<div className="rounded-md border border-border bg-muted/30 p-3 text-xs text-muted-foreground">
<strong className="text-foreground">Tips:</strong> Anskaffningen måste redan vara
bokförd (debet på 1xxx-kontot mot t.ex. 1930/2440) — registret bokför inte
själva köpet. Det här registret styr enbart de planenliga avskrivningarna under
bokslutet.
</div>
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
Avbryt
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Sparar…
</>
) : (
'Spara'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,211 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Loader2 } from 'lucide-react'
import Link from 'next/link'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { formatCurrency } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import type { Asset } from '@/types'
interface ProposalItem {
asset: Asset
amount: number
netBookValueAfter: number
proRated: boolean
existingScheduleId?: string
existingJournalEntryId?: string | null
}
interface Proposal {
fiscalPeriod: { id: string; name: string; period_start: string; period_end: string }
items: ProposalItem[]
totalAmount: number
}
interface DepreciationPanelProps {
periodId: string
/** Called after a successful post — parent refetches dispositions because
* posted avskrivningar change the result which affects bolagsskatt etc. */
onPosted: () => void
}
export function DepreciationPanel({ periodId, onPosted }: DepreciationPanelProps) {
const { toast } = useToast()
const [proposal, setProposal] = useState<Proposal | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [posting, setPosting] = useState(false)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/depreciation`)
const body = await res.json()
if (!res.ok) {
setError(body?.error?.message ?? 'Kunde inte ladda avskrivningar')
return
}
setProposal(body.data as Proposal)
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel')
} finally {
setLoading(false)
}
}, [periodId])
useEffect(() => {
void load()
}, [load])
const handlePost = useCallback(async () => {
setPosting(true)
try {
const res = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/depreciation`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
const body = await res.json()
if (!res.ok) {
setError(body?.error?.message ?? 'Kunde inte bokföra avskrivningar')
return
}
const posted = body.data?.posted?.length ?? 0
toast({
title: `${posted} avskrivning${posted === 1 ? '' : 'ar'} bokförd${
posted === 1 ? '' : 'a'
}`,
})
onPosted()
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel')
} finally {
setPosting(false)
}
}, [periodId, onPosted, load, toast])
if (loading) {
return (
<Card>
<CardContent className="p-6 space-y-2">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-6 text-destructive">{error}</CardContent>
</Card>
)
}
if (!proposal) return null
const allPosted =
proposal.items.length > 0 && proposal.items.every((i) => Boolean(i.existingJournalEntryId))
const anyPending =
proposal.items.length > 0 && proposal.items.some((i) => !i.existingJournalEntryId)
if (proposal.items.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Planenliga avskrivningar</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Inga aktiva anläggningstillgångar att skriva av.{' '}
<Link href="/assets" className="text-primary hover:underline">
Lägg till tillgångar
</Link>{' '}
så räknar bokslutet ut avskrivningarna automatiskt.
</CardContent>
</Card>
)
}
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<CardTitle className="text-base">Planenliga avskrivningar</CardTitle>
<p className="text-sm text-muted-foreground mt-1">
{proposal.items.length} tillgång{proposal.items.length === 1 ? '' : 'ar'}.
{allPosted ? ' Allt redan bokfört.' : ' Bokförs som separata verifikationer.'}
</p>
</div>
<p className="font-display text-2xl tabular-nums shrink-0">
{formatCurrency(proposal.totalAmount)}
</p>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Tillgång</TableHead>
<TableHead className="text-right">Avskrivning</TableHead>
<TableHead className="text-right">Restvärde</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{proposal.items.map((item) => (
<TableRow key={item.asset.id}>
<TableCell className="text-sm">{item.asset.name}</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(item.amount)}
{item.proRated && (
<span className="block text-[10px] text-muted-foreground">pro-rata</span>
)}
</TableCell>
<TableCell className="text-right tabular-nums">
{formatCurrency(item.netBookValueAfter)}
</TableCell>
<TableCell>
{item.existingJournalEntryId ? (
<Badge variant="success">Bokförd</Badge>
) : (
<Badge variant="outline">Föreslagen</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{anyPending && (
<div className="flex justify-end">
<Button onClick={handlePost} disabled={posting}>
{posting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Bokför…
</>
) : (
'Bokför alla avskrivningar'
)}
</Button>
</div>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,401 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ArrowRight, AlertTriangle, Loader2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { DepreciationPanel } from './DepreciationPanel'
import type {
DispositionsProposal,
ProposedDisposition,
DispositionKind,
} from '@/lib/bokslut/types'
interface DispositionsStepProps {
periodId: string
onBack: () => void
onContinue: () => void
}
interface UiState {
/** kind → user-controlled selection state */
selections: Record<string, { accept: boolean; overrideAmount?: number; lockedSkip: boolean }>
}
/**
* Phase 2 bokslutsdispositioner step. Fetches proposals from the dispositions
* API, lets the user adjust amounts (or skip) per proposal, then POSTs the
* accepted ones. Mandatory p-fond reversals (cohort ≥ 6 years old) cannot be
* skipped — checkbox stays disabled-on.
*
* EF companies get an empty `proposals` array from the server, so this step
* renders a short pass-through note and lets the user continue.
*/
export function DispositionsStep({ periodId, onBack, onContinue }: DispositionsStepProps) {
const { toast } = useToast()
const [proposal, setProposal] = useState<DispositionsProposal | null>(null)
const [loading, setLoading] = useState(true)
const [fetchError, setFetchError] = useState<string | null>(null)
const [ui, setUi] = useState<UiState>({ selections: {} })
const [posting, setPosting] = useState(false)
const [postError, setPostError] = useState<string | null>(null)
// ---- Fetch proposals ----
const loadProposals = useCallback(async () => {
setLoading(true)
setFetchError(null)
try {
const res = await fetch(
`/api/bookkeeping/fiscal-periods/${periodId}/bokslutsdispositioner`,
)
const body = await res.json()
if (!res.ok) {
setFetchError(body?.error?.message ?? 'Kunde inte ladda dispositioner')
return
}
const data = body.data as DispositionsProposal
setProposal(data)
const selections: UiState['selections'] = {}
for (const p of data.proposals) {
const key = proposalKey(p)
selections[key] = {
accept: true,
overrideAmount: p.amount,
lockedSkip: Boolean(p.required),
}
}
setUi({ selections })
} catch {
setFetchError('Kunde inte ladda dispositioner')
} finally {
setLoading(false)
}
}, [periodId])
useEffect(() => {
void loadProposals()
}, [loadProposals])
// ---- POST accepted dispositions ----
const handleCommit = useCallback(async () => {
if (!proposal) return
setPosting(true)
setPostError(null)
try {
const items = buildPostItems(proposal, ui)
if (items.length === 0) {
// Nothing selected — just move on
onContinue()
return
}
const res = await fetch(`/api/bookkeeping/fiscal-periods/${periodId}/bokslutsdispositioner`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items }),
})
const body = await res.json()
if (!res.ok) {
setPostError(body?.error?.message ?? 'Kunde inte bokföra dispositioner')
return
}
const created = body.data?.created ?? []
toast({
title: `${created.length} verifikation${created.length === 1 ? '' : 'er'} bokförd${
created.length === 1 ? '' : 'a'
}`,
description: 'Dispositionerna ligger nu i bokföringen.',
})
onContinue()
} catch (err) {
setPostError(err instanceof Error ? err.message : 'Okänt fel')
} finally {
setPosting(false)
}
}, [proposal, ui, periodId, onContinue, toast])
// ---- Render branches ----
if (loading) {
return (
<Card>
<CardContent className="p-6 space-y-3">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-20 w-full" />
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
)
}
if (fetchError) {
return (
<Card>
<CardContent className="p-6">
<p className="text-destructive">{fetchError}</p>
</CardContent>
</Card>
)
}
if (!proposal) return null
// EF: only depreciation applies (no AB-only dispositioner)
if (proposal.entityType !== 'aktiebolag') {
return (
<div className="space-y-6">
<DepreciationPanel periodId={periodId} onPosted={() => void loadProposals()} />
<Card>
<CardHeader>
<CardTitle className="text-base">Inga bokslutsdispositioner</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
För enskild firma bokförs varken bolagsskatt, periodiseringsfond eller överavskrivningar.
De räknas i NE-bilagan när du deklarerar.
</CardContent>
</Card>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>Tillbaka</Button>
<Button onClick={onContinue}>
Fortsätt <ArrowRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
)
}
if (proposal.proposals.length === 0) {
return (
<div className="space-y-6">
<DepreciationPanel periodId={periodId} onPosted={() => void loadProposals()} />
<Card>
<CardHeader>
<CardTitle className="text-base">Inga dispositioner föreslagna</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Bolaget har varken pensionskostnader, periodiseringsfonder att hantera, vinst att skatta
på, eller skattemässiga avskrivningar att boka.
</CardContent>
</Card>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>Tillbaka</Button>
<Button onClick={onContinue}>
Fortsätt <ArrowRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
)
}
return (
<div className="space-y-6">
<DepreciationPanel periodId={periodId} onPosted={() => void loadProposals()} />
<Card>
<CardHeader>
<CardTitle className="text-base">Bokslutsdispositioner</CardTitle>
<p className="text-sm text-muted-foreground">
Resultat före dispositioner:{' '}
<span className="tabular-nums font-medium">
{formatCurrency(proposal.netResultBefore)}
</span>
. Justera beloppen och bocka av de dispositioner du vill boka.
</p>
</CardHeader>
</Card>
{proposal.proposals.map((p, i) => {
const key = proposalKey(p, i)
const sel = ui.selections[key]
if (!sel) return null
return (
<ProposalCard
key={key}
proposal={p}
accept={sel.accept}
overrideAmount={sel.overrideAmount}
lockedSkip={sel.lockedSkip}
onChange={(next) => {
setUi((prev) => ({
selections: { ...prev.selections, [key]: { ...sel, ...next } },
}))
}}
/>
)
})}
{postError && (
<Card>
<CardContent className="p-4 text-sm text-destructive">{postError}</CardContent>
</Card>
)}
<div className="flex justify-between">
<Button variant="outline" onClick={onBack} disabled={posting}>
Tillbaka
</Button>
<Button onClick={handleCommit} disabled={posting}>
{posting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> Bokför…
</>
) : (
<>
Bokför valda dispositioner <ArrowRight className="ml-1 h-4 w-4" />
</>
)}
</Button>
</div>
</div>
)
}
function ProposalCard({
proposal,
accept,
overrideAmount,
lockedSkip,
onChange,
}: {
proposal: ProposedDisposition
accept: boolean
overrideAmount: number | undefined
lockedSkip: boolean
onChange: (next: { accept?: boolean; overrideAmount?: number }) => void
}) {
const overridable = isOverridable(proposal.kind)
const displayedAmount = overridable ? overrideAmount ?? proposal.amount : proposal.amount
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<CardTitle className="text-base">{proposal.label}</CardTitle>
<p className="text-sm text-muted-foreground mt-1">{proposal.description}</p>
{proposal.required && (
<Badge variant="warning" className="mt-2 gap-1">
<AlertTriangle className="h-3.5 w-3.5" /> Obligatorisk
</Badge>
)}
</div>
<p className="font-display text-2xl tabular-nums shrink-0">
{formatCurrency(displayedAmount)}
</p>
</div>
</CardHeader>
<CardContent className="space-y-3">
{proposal.warnings.map((w, i) => (
<p key={i} className="text-sm text-warning-foreground">
{w}
</p>
))}
{overridable && (
<div className="flex items-center gap-3">
<Label htmlFor={`amount-${proposal.kind}`} className="text-sm shrink-0">
Belopp (kr)
</Label>
<Input
id={`amount-${proposal.kind}`}
type="number"
step="1"
className="max-w-[180px] tabular-nums"
value={overrideAmount ?? proposal.amount}
onChange={(e) => {
const value = parseInt(e.target.value, 10)
onChange({ overrideAmount: Number.isFinite(value) ? value : 0 })
}}
/>
</div>
)}
<div className="flex items-center gap-2 pt-1">
<Checkbox
id={`accept-${proposal.kind}`}
checked={accept}
disabled={lockedSkip}
onCheckedChange={(checked) => onChange({ accept: Boolean(checked) })}
/>
<Label
htmlFor={`accept-${proposal.kind}`}
className="text-sm cursor-pointer select-none"
>
{accept ? 'Boka denna disposition' : 'Hoppa över'}
{lockedSkip && (
<span className="text-muted-foreground ml-2 text-xs">(kan inte hoppas över)</span>
)}
</Label>
</div>
</CardContent>
</Card>
)
}
function isOverridable(kind: DispositionKind): 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'
}
function proposalKey(p: ProposedDisposition, index = 0): string {
// For återföring there can be multiple cards (one per cohort) — disambiguate
// by including the line account in the key. For other kinds, the kind is unique.
if (p.kind === 'periodiseringsfond_ateforing') {
return `${p.kind}:${p.lines[0]?.account_number ?? index}`
}
return `${p.kind}:${index}`
}
interface PostItem {
kind: DispositionKind
[key: string]: unknown
}
function buildPostItems(proposal: DispositionsProposal, ui: UiState): PostItem[] {
const items: PostItem[] = []
// Group återföring entries — server expects a single item with a `returns`
// map keyed by cohort account.
const ateforingReturns: Record<string, number> = {}
for (const p of proposal.proposals) {
const key = proposalKey(p, proposal.proposals.indexOf(p))
const sel = ui.selections[key]
if (!sel || !sel.accept) continue
switch (p.kind) {
case 'bolagsskatt':
items.push({ kind: 'bolagsskatt', manualAdjustments: {} })
break
case 'sarskild_loneskatt':
items.push({ kind: 'sarskild_loneskatt' })
break
case 'periodiseringsfond_avsattning':
items.push({
kind: 'periodiseringsfond_avsattning',
desiredAmount: sel.overrideAmount ?? p.amount,
})
break
case 'periodiseringsfond_ateforing': {
const account = p.lines[0]?.account_number
if (account) ateforingReturns[account] = p.amount
break
}
case 'overavskrivningar':
items.push({
kind: 'overavskrivningar',
additionalAmount: sel.overrideAmount ?? p.amount,
})
break
}
}
if (Object.keys(ateforingReturns).length > 0) {
items.push({ kind: 'periodiseringsfond_ateforing', returns: ateforingReturns })
}
return items
}
@@ -0,0 +1,90 @@
'use client'
import { useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { DestructiveConfirmDialog } from '@/components/ui/destructive-confirm-dialog'
import { Lock, Loader2 } from 'lucide-react'
interface ExecuteStepProps {
periodName: string
isRunning: boolean
error: string | null
onBack: () => void
onExecute: () => Promise<void>
}
/**
* Final confirmation before executing year-end. Uses the destructive confirm
* dialog because year-end is irreversible per BFL — once the period is closed,
* no further entries can be posted to it and the closing transaction is
* immutable.
*/
export function ExecuteStep({ periodName, isRunning, error, onBack, onExecute }: ExecuteStepProps) {
const [confirmOpen, setConfirmOpen] = useState(false)
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Lock className="h-4 w-4" />
Klar att verkställa
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm">
När du verkställer bokslutet för <strong>{periodName}</strong> kommer följande att hända
i en transaktion:
</p>
<ul className="text-sm space-y-2 list-disc pl-5 text-muted-foreground">
<li>Kursrevaluering bokas för öppna poster i utländsk valuta (om relevant)</li>
<li>Bokslutsverifikationen skapas och nollställer klass 3–8</li>
<li>Perioden låses och stängs (oåterkalleligt enligt BFL)</li>
<li>En ny räkenskapsperiod skapas och får ingående balanser från balansräkningen</li>
<li>IB/UB-kontinuitet verifieras</li>
</ul>
<p className="text-sm text-muted-foreground">
Det här går inte att ångra. Om du behöver göra rättelser efter bokslutet använder du
stornering eller bokar i den nya perioden.
</p>
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{error}
</div>
)}
</CardContent>
</Card>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack} disabled={isRunning}>
Tillbaka
</Button>
<Button
variant="destructive"
onClick={() => setConfirmOpen(true)}
disabled={isRunning}
>
{isRunning ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verkställer bokslut…
</>
) : (
<>Verkställ bokslut</>
)}
</Button>
</div>
<DestructiveConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title="Verkställ bokslut?"
description={`${periodName} kommer att stängas och låsas. Detta är oåterkalleligt enligt Bokföringslagen.`}
confirmLabel="Ja, verkställ"
cancelLabel="Avbryt"
onConfirm={onExecute}
/>
</div>
)
}
@@ -0,0 +1,174 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { CheckCircle2, AlertTriangle, XCircle, Info } from 'lucide-react'
import Link from 'next/link'
import type { BokslutReadinessReport } from '@/lib/bokslut/readiness-aggregator'
interface PreflightStepProps {
report: BokslutReadinessReport | null
isLoading: boolean
error: string | null
onContinue: () => void
}
export function PreflightStep({ report, isLoading, error, onContinue }: PreflightStepProps) {
if (isLoading) {
return (
<Card>
<CardContent className="p-6 space-y-3">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-2/3" />
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-6">
<p className="text-destructive">{error}</p>
</CardContent>
</Card>
)
}
if (!report) {
return null
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle className="text-base">{report.period.name}</CardTitle>
<p className="text-sm text-muted-foreground tabular-nums">
{report.period.period_start} – {report.period.period_end}
</p>
</div>
{report.ready ? (
<Badge variant="success" className="gap-1">
<CheckCircle2 className="h-3.5 w-3.5" /> Redo för bokslut
</Badge>
) : (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3.5 w-3.5" /> Inte redo
</Badge>
)}
</div>
</CardHeader>
</Card>
{report.blockers.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<XCircle className="h-4 w-4 text-destructive" />
Måste åtgärdas innan bokslut
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{report.blockers.map((blocker, i) => (
<BlockerRow key={i} blocker={blocker} report={report} />
))}
</CardContent>
</Card>
)}
{report.warnings.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-warning-foreground" />
Varningar
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{report.warnings.map((warning, i) => (
<p key={i} className="text-sm">
{warning}
</p>
))}
</CardContent>
</Card>
)}
{report.reminders.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Info className="h-4 w-4 text-muted-foreground" />
Påminnelser
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{report.reminders.map((reminder) => (
<div key={reminder.code} className="flex items-start gap-3 text-sm">
<span className="mt-1 h-1.5 w-1.5 rounded-full bg-muted-foreground shrink-0" />
<div className="flex-1">
<p>{reminder.message}</p>
{reminder.href && (
<Link
href={reminder.href}
className="text-xs text-primary hover:underline"
>
Öppna
</Link>
)}
</div>
</div>
))}
</CardContent>
</Card>
)}
<div className="flex justify-end">
<Button onClick={onContinue} disabled={!report.ready}>
Fortsätt
</Button>
</div>
</div>
)
}
/**
* Renders a blocker with a contextual action link when we can derive one.
* Falls back to plain text otherwise.
*/
function BlockerRow({ blocker, report }: { blocker: string; report: BokslutReadinessReport }) {
let href: string | null = null
let actionLabel: string | null = null
if (/draft journal entries/i.test(blocker) && report.draftCount > 0) {
href = '/bookkeeping?status=draft'
actionLabel = 'Visa utkast'
} else if (/voucher gap/i.test(blocker)) {
href = '/bookkeeping/voucher-gaps'
actionLabel = 'Hantera nummerlucka'
} else if (/trial balance/i.test(blocker)) {
href = '/reports/trial-balance'
actionLabel = 'Öppna balansrapport'
} else if (/continuity/i.test(blocker)) {
href = '/bookkeeping'
actionLabel = 'Granska ingående balans'
}
return (
<div className="flex items-start justify-between gap-3 text-sm">
<p className="flex-1">{blocker}</p>
{href && actionLabel && (
<Link href={href} className="text-xs text-primary hover:underline shrink-0 mt-0.5">
{actionLabel}
</Link>
)}
</div>
)
}
@@ -0,0 +1,155 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { ArrowRight } from 'lucide-react'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { formatCurrency } from '@/lib/utils'
import type { YearEndPreview } from '@/types'
interface PreviewStepProps {
preview: YearEndPreview | null
isLoading: boolean
error: string | null
onBack: () => void
onContinue: () => void
}
export function PreviewStep({ preview, isLoading, error, onBack, onContinue }: PreviewStepProps) {
if (isLoading) {
return (
<Card>
<CardContent className="p-6 space-y-3">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-32 w-full" />
</CardContent>
</Card>
)
}
if (error) {
return (
<Card>
<CardContent className="p-6">
<p className="text-destructive">{error}</p>
</CardContent>
</Card>
)
}
if (!preview) return null
const isProfit = preview.netResult > 0
const isLoss = preview.netResult < 0
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Årets resultat</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-baseline justify-between">
<div>
<p className="text-sm text-muted-foreground">
Nettoresultat överförs till {preview.closingAccount} {preview.closingAccountName}
</p>
{isProfit && <Badge variant="success" className="mt-2">Vinst</Badge>}
{isLoss && <Badge variant="destructive" className="mt-2">Förlust</Badge>}
</div>
<p className="font-display text-3xl tabular-nums">
{formatCurrency(preview.netResult)}
</p>
</div>
</CardContent>
</Card>
{preview.currencyRevaluation && preview.currencyRevaluation.items.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Kursrevaluering (ÅRL 4:13)</CardTitle>
<p className="text-sm text-muted-foreground">
Öppna fordringar/skulder i utländsk valuta värderas om till balansdagens kurs innan bokslut.
Detta sker automatiskt som en del av verkställandet.
</p>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wider">Kursvinst</p>
<p className="font-display text-xl tabular-nums">
{formatCurrency(preview.currencyRevaluation.totalGain)}
</p>
</div>
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wider">Kursförlust</p>
<p className="font-display text-xl tabular-nums">
{formatCurrency(preview.currencyRevaluation.totalLoss)}
</p>
</div>
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wider">Nettoeffekt</p>
<p className="font-display text-xl tabular-nums">
{formatCurrency(preview.currencyRevaluation.netEffect)}
</p>
</div>
</div>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Bokslutsverifikation — förhandsgranskning</CardTitle>
<p className="text-sm text-muted-foreground">
{preview.closingLines.length} kontorader. Nollställer klass 3–8 mot {preview.closingAccount}.
</p>
</CardHeader>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Konto</TableHead>
<TableHead>Beskrivning</TableHead>
<TableHead className="text-right">Debet</TableHead>
<TableHead className="text-right">Kredit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{preview.closingLines.map((line, i) => (
<TableRow key={i}>
<TableCell className="tabular-nums">{line.account_number}</TableCell>
<TableCell className="text-sm">{line.line_description}</TableCell>
<TableCell className="text-right tabular-nums">
{line.debit_amount > 0 ? formatCurrency(line.debit_amount) : '—'}
</TableCell>
<TableCell className="text-right tabular-nums">
{line.credit_amount > 0 ? formatCurrency(line.credit_amount) : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="flex justify-between">
<Button variant="outline" onClick={onBack}>
Tillbaka
</Button>
<Button onClick={onContinue}>
Verkställ bokslut <ArrowRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
)
}
@@ -0,0 +1,79 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { CheckCircle2 } from 'lucide-react'
import Link from 'next/link'
import type { YearEndResult } from '@/types'
interface ResultStepProps {
result: YearEndResult
}
export function ResultStep({ result }: ResultStepProps) {
return (
<div className="space-y-6">
<Card>
<CardContent className="p-6 text-center space-y-4">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-success/10">
<CheckCircle2 className="h-7 w-7 text-success" />
</div>
<h2 className="font-display text-2xl">Bokslutet är klart</h2>
<p className="text-muted-foreground">
Perioden är stängd och en ny räkenskapsperiod har skapats.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Resultat</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<ResultRow
label="Bokslutsverifikation"
value={`${result.closingEntry.voucher_series}${result.closingEntry.voucher_number}`}
href={`/bookkeeping/${result.closingEntry.id}`}
/>
{result.revaluationEntry && (
<ResultRow
label="Kursrevaluering"
value={`${result.revaluationEntry.voucher_series}${result.revaluationEntry.voucher_number}`}
href={`/bookkeeping/${result.revaluationEntry.id}`}
/>
)}
<ResultRow
label="Ingående balanser i ny period"
value={`${result.openingBalanceEntry.voucher_series}${result.openingBalanceEntry.voucher_number}`}
href={`/bookkeeping/${result.openingBalanceEntry.id}`}
/>
<ResultRow label="Ny räkenskapsperiod" value={result.nextPeriod.name} />
</CardContent>
</Card>
<div className="flex flex-col sm:flex-row gap-3 sm:justify-end">
<Button variant="outline" asChild>
<Link href="/bookkeeping">Till bokföringen</Link>
</Button>
<Button asChild>
<Link href="/reports">Generera rapporter</Link>
</Button>
</div>
</div>
)
}
function ResultRow({ label, value, href }: { label: string; value: string; href?: string }) {
return (
<div className="flex items-center justify-between border-b border-border last:border-b-0 pb-3 last:pb-0">
<span className="text-muted-foreground">{label}</span>
{href ? (
<Link href={href} className="font-medium tabular-nums text-primary hover:underline">
{value}
</Link>
) : (
<span className="font-medium tabular-nums">{value}</span>
)}
</div>
)
}
+2
View File
@@ -26,6 +26,7 @@ import {
TrendingUp,
ClipboardCheck,
HandCoins,
Package,
} from 'lucide-react'
import { getBranding } from '@/lib/branding/service'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
@@ -79,6 +80,7 @@ const navItems: NavItem[] = [
{ href: '/pending', label: 'Granskning', icon: ClipboardCheck, group: 'redovisning' },
{ href: '/transactions', label: 'Transaktioner', icon: ArrowLeftRight, group: 'redovisning' },
{ href: '/bookkeeping', label: 'Bokföring', icon: BookOpen, group: 'redovisning' },
{ href: '/assets', label: 'Anläggningstillgångar', icon: Package, group: 'redovisning' },
{ href: '/reports', label: 'Rapporter', icon: BarChart3, group: 'redovisning' },
{ href: '/import', label: 'Importera', icon: Upload, group: 'redovisning' },
// Personal — enabled in production with a "Beta" badge while we validate the
+277
View File
@@ -0,0 +1,277 @@
import { describe, it, expect, vi } from 'vitest'
import { DEFAULT_ACCOUNTS_BY_CATEGORY, disposeAsset } from '../assets/asset-service'
import type { Asset } from '@/types'
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn().mockResolvedValue({
id: 'entry-1',
voucher_series: 'A',
voucher_number: 1,
}),
}))
describe('DEFAULT_ACCOUNTS_BY_CATEGORY', () => {
it('maps every AssetCategory to a BAS-aligned account triple', () => {
const expected = {
immaterial: { asset: '1010', accumulated: '1019', expense: '7810' },
building: { asset: '1110', accumulated: '1119', expense: '7821' },
land_improvement: { asset: '1150', accumulated: '1159', expense: '7824' },
machinery: { asset: '1210', accumulated: '1219', expense: '7831' },
equipment: { asset: '1220', accumulated: '1229', expense: '7832' },
vehicle: { asset: '1240', accumulated: '1249', expense: '7834' },
computer: { asset: '1250', accumulated: '1259', expense: '7833' },
other_tangible: { asset: '1280', accumulated: '1289', expense: '7839' },
} as const
expect(DEFAULT_ACCOUNTS_BY_CATEGORY).toEqual(expected)
})
it('uses the convention that accumulated = asset + 9 for tangible categories', () => {
const tangible = ['machinery', 'equipment', 'vehicle', 'computer', 'other_tangible'] as const
for (const cat of tangible) {
const triple = DEFAULT_ACCOUNTS_BY_CATEGORY[cat]
const assetNum = parseInt(triple.asset, 10)
const accumulatedNum = parseInt(triple.accumulated, 10)
expect(accumulatedNum).toBe(assetNum + 9)
}
})
it('expense accounts are in the 78xx range (planenliga avskrivningar)', () => {
for (const cat of Object.keys(DEFAULT_ACCOUNTS_BY_CATEGORY) as Array<
keyof typeof DEFAULT_ACCOUNTS_BY_CATEGORY
>) {
const expense = DEFAULT_ACCOUNTS_BY_CATEGORY[cat].expense
expect(expense).toMatch(/^78\d{2}$/)
}
})
})
describe('disposeAsset — gain/loss account selection', () => {
function makeAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'asset-1',
user_id: 'u',
company_id: 'co',
name: 'Test',
category: 'equipment',
acquisition_date: '2023-01-01',
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',
disposed_at: null,
disposed_proceeds: null,
k3_components: null,
notes: null,
created_at: '2023-01-01T00:00:00Z',
updated_at: '2023-01-01T00:00:00Z',
...overrides,
}
}
function makeSupabaseForDispose(asset: Asset, schedules: Array<{ planned_depreciation: number }>) {
// Three from() calls happen inside disposeAsset:
// 1. getAsset (.maybeSingle on 'assets')
// 2. sumPostedDepreciation (.then on 'depreciation_schedules' — server-derived
// accumulated_depreciation; replaces the previously client-supplied value)
// 3. update (.single on 'assets', returning the disposed row)
const builders = {
getBuilder: {
select: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
maybeSingle: vi.fn().mockResolvedValue({ data: asset, error: null }),
},
schedulesBuilder: (() => {
const b: Record<string, unknown> = {
select: vi.fn(),
eq: vi.fn(),
not: vi.fn(),
then: undefined,
}
;(b.select as ReturnType<typeof vi.fn>).mockReturnValue(b)
;(b.eq as ReturnType<typeof vi.fn>).mockReturnValue(b)
;(b.not as ReturnType<typeof vi.fn>).mockReturnValue(b)
b.then = (resolve: (v: { data: unknown; error: unknown }) => void) =>
resolve({ data: schedules, error: null })
return b as { select: ReturnType<typeof vi.fn>; eq: ReturnType<typeof vi.fn>; not: ReturnType<typeof vi.fn> }
})(),
updateBuilder: {
update: vi.fn().mockReturnThis(),
eq: vi.fn().mockReturnThis(),
select: vi.fn().mockReturnThis(),
single: vi.fn().mockResolvedValue({
data: { ...asset, disposed_at: '2025-06-30', disposed_proceeds: 50_000 },
error: null,
}),
},
}
let calls = 0
const supabase = {
from: vi.fn((table: string) => {
calls++
if (table === 'depreciation_schedules') return builders.schedulesBuilder
return calls === 1 ? builders.getBuilder : builders.updateBuilder
}),
}
return { supabase, builders } as const
}
it('uses 3973 / 7973 for tangible asset disposal (equipment)', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({ category: 'equipment' })
// Two prior posted schedules summing to 40_000 → NBV = 60_000, proceeds 80_000 → gain 20_000
const { supabase } = makeSupabaseForDispose(asset, [
{ planned_depreciation: 20_000 },
{ planned_depreciation: 20_000 },
])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 80_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
expect(call).toBeDefined()
const lines = (call![3] as { lines: { account_number: string; debit_amount: number; credit_amount: number }[] }).lines
// Server-derived accumulated debits 1229
expect(lines.find((l) => l.account_number === '1229')?.debit_amount).toBe(40_000)
// Gain goes to 3973 (tangible), not 3013
expect(lines.find((l) => l.account_number === '3973')).toBeDefined()
expect(lines.find((l) => l.account_number === '3013')).toBeUndefined()
})
it('uses 3013 / 7813 for immaterial asset disposal', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({
category: 'immaterial',
bas_asset_account: '1010',
bas_accumulated_account: '1019',
bas_expense_account: '7810',
})
// NBV = 50_000, proceeds 10_000 → loss 40_000
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 50_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 10_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
expect(call).toBeDefined()
const lines = (call![3] as { lines: { account_number: string; debit_amount: number }[] }).lines
expect(lines.find((l) => l.account_number === '7813')).toBeDefined()
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
})
it('uses 3971 / 7971 for building disposal', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({
category: 'building',
acquisition_cost: 2_000_000,
bas_asset_account: '1110',
bas_accumulated_account: '1119',
bas_expense_account: '7821',
})
// NBV = 1_500_000, proceeds 2_000_000 → gain 500_000
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 500_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 2_000_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
const lines = (call![3] as { lines: { account_number: string }[] }).lines
// Buildings route to 3971/7971, not 3973/7973
expect(lines.find((l) => l.account_number === '3971')).toBeDefined()
expect(lines.find((l) => l.account_number === '3973')).toBeUndefined()
})
it('uses 3971 / 7971 for land_improvement disposal', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({
category: 'land_improvement',
acquisition_cost: 100_000,
bas_asset_account: '1150',
bas_accumulated_account: '1159',
bas_expense_account: '7824',
})
// NBV = 80_000, proceeds 40_000 → loss 40_000
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 20_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 40_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
const lines = (call![3] as { lines: { account_number: string }[] }).lines
// Markanläggning routes to 7971 like buildings
expect(lines.find((l) => l.account_number === '7971')).toBeDefined()
expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
})
it('server-derives accumulated_depreciation — caller cannot inflate gain', async () => {
const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
vi.mocked(createJournalEntry).mockClear()
const asset = makeAsset({ category: 'equipment', acquisition_cost: 100_000 })
// Real accumulated = 30_000 from one posted schedule. A malicious client
// could previously pass accumulated_depreciation: 100_000 to fake a fully
// depreciated asset and pocket a 50_000 phantom gain on proceeds. With
// server derivation, the lines reflect the actual 30_000.
const { supabase } = makeSupabaseForDispose(asset, [{ planned_depreciation: 30_000 }])
await disposeAsset(
supabase as unknown as Parameters<typeof disposeAsset>[0],
'co',
'u',
'asset-1',
{
disposed_at: '2025-06-30',
disposed_proceeds: 50_000,
fiscal_period_id: 'fp',
},
)
const call = vi.mocked(createJournalEntry).mock.calls[0]
const lines = (call![3] as { lines: { account_number: string; debit_amount: number; credit_amount: number }[] }).lines
// accumulated debit must be 30_000 (server-derived), not anything else
expect(lines.find((l) => l.account_number === '1229')?.debit_amount).toBe(30_000)
// NBV = 100_000 − 30_000 = 70_000, proceeds 50_000 → loss 20_000 to 7973
expect(lines.find((l) => l.account_number === '7973')?.debit_amount).toBe(20_000)
})
})
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/lib/reports/income-statement', () => ({
generateIncomeStatement: vi.fn(),
}))
import {
calculateBolagsskatt,
BOLAGSSKATT_RATE,
} from '../tax-provision/bolagsskatt-calculator'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
const NOOP_CLIENT = {} as Parameters<typeof calculateBolagsskatt>[0]
beforeEach(() => {
vi.clearAllMocks()
})
describe('calculateBolagsskatt', () => {
it('applies 20.6% to positive result and posts 8910/2512', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 500_000,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp')
expect(result).not.toBeNull()
expect(result!.amount).toBe(Math.round(500_000 * BOLAGSSKATT_RATE)) // 103000
const debit = result!.lines.find((l) => l.account_number === '8910')!
const credit = result!.lines.find((l) => l.account_number === '2512')!
expect(debit.debit_amount).toBe(103_000)
expect(credit.credit_amount).toBe(103_000)
})
it('returns a zero-amount proposal for loss year (no entry posted)', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: -50_000,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp')
expect(result).not.toBeNull()
expect(result!.amount).toBe(0)
expect(result!.lines).toEqual([])
expect(result!.description).toContain('förlust')
})
it('adds non-deductible expenses to taxable result', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 100_000,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp', {
manualAdjustments: { nonDeductibleExpenses: 50_000 },
})
// (100_000 + 50_000) × 0.206 = 30_900
expect(result!.amount).toBe(30_900)
})
it('subtracts non-taxable income from taxable result', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 100_000,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp', {
manualAdjustments: { nonTaxableIncome: 40_000 },
})
// (100_000 - 40_000) × 0.206 = 12_360
expect(result!.amount).toBe(12_360)
})
it('adds schablonintäkt on periodiseringsfond to taxable result', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 200_000,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp', {
manualAdjustments: { schablonintaktPeriodiseringsfond: 3_000 },
})
// (200_000 + 3_000) × 0.206 = 41_818
expect(result!.amount).toBe(41_818)
})
it('truncates taxable result to whole krona before applying tax', async () => {
vi.mocked(generateIncomeStatement).mockResolvedValue({
net_result: 100_999.99,
} as Awaited<ReturnType<typeof generateIncomeStatement>>)
const result = await calculateBolagsskatt(NOOP_CLIENT, 'co', 'fp')
// floor(100_999.99) = 100_999, × 0.206 = 20805.794 → round = 20_806
expect(result!.amount).toBe(20_806)
})
})
@@ -0,0 +1,147 @@
import { describe, it, expect } from 'vitest'
import { computeAnnualDepreciation } from '../assets/depreciation-engine'
import type { Asset } from '@/types'
function makeAsset(overrides: Partial<Asset> = {}): Asset {
return {
id: 'asset-1',
user_id: 'user-1',
company_id: 'co-1',
name: 'Test',
category: 'equipment',
acquisition_date: '2025-01-01',
acquisition_cost: 60_000,
salvage_value: 0,
useful_life_months: 60, // 5 years
depreciation_method: 'linear',
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
disposed_at: null,
disposed_proceeds: null,
k3_components: null,
notes: null,
created_at: '2025-01-01T00:00:00Z',
updated_at: '2025-01-01T00:00:00Z',
...overrides,
}
}
const FULL_YEAR = { period_start: '2025-01-01', period_end: '2025-12-31' }
describe('computeAnnualDepreciation', () => {
it('linear over full year: 60_000 / 5 yrs = 12_000', () => {
const result = computeAnnualDepreciation(makeAsset(), FULL_YEAR)
expect(result.amount).toBe(12_000)
expect(result.proRated).toBe(false)
})
it('respects salvage value (only depreciates cost − salvage)', () => {
const asset = makeAsset({ acquisition_cost: 60_000, salvage_value: 10_000 })
const result = computeAnnualDepreciation(asset, FULL_YEAR)
// depreciable = 50_000, /5 = 10_000
expect(result.amount).toBe(10_000)
})
it('returns zero when depreciable base ≤ 0', () => {
const asset = makeAsset({ acquisition_cost: 10_000, salvage_value: 10_000 })
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(0)
})
it('pro-rates first year when acquired mid-period', () => {
// Acquired July 1, full year period: window = Jul 1 – Dec 31 = 184 days
// out of 365 ≈ 0.5041. Annual depreciation 12_000 × 0.5041 ≈ 6_049
const asset = makeAsset({ acquisition_date: '2025-07-01' })
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.proRated).toBe(true)
expect(result.amount).toBeGreaterThan(5_900)
expect(result.amount).toBeLessThan(6_100)
})
it('pro-rates final year when life ends mid-period', () => {
// 5-year asset acquired 2021-07-01. End of life = 2026-07-01. For fiscal
// year 2026 (Jan 1 – Dec 31) only 181 days are inside the asset's life.
const asset = makeAsset({
acquisition_date: '2021-07-01',
useful_life_months: 60,
})
const result = computeAnnualDepreciation(asset, {
period_start: '2026-01-01',
period_end: '2026-12-31',
})
expect(result.proRated).toBe(true)
// ~6_000 (half year). Days math: Jan 1 - Jun 30 = 181 days / 365 ≈ 0.4959.
expect(result.amount).toBeGreaterThan(5_900)
expect(result.amount).toBeLessThan(6_100)
})
it('returns 0 when asset was disposed before the period starts', () => {
const asset = makeAsset({
disposed_at: '2024-06-30',
disposed_proceeds: 5_000,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(0)
})
it('pro-rates when asset is disposed mid-period', () => {
// Disposed June 30 of the fiscal year — half-year depreciation.
const asset = makeAsset({
disposed_at: '2025-06-30',
disposed_proceeds: 5_000,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.proRated).toBe(true)
expect(result.amount).toBeGreaterThan(5_900)
expect(result.amount).toBeLessThan(6_100)
})
it('returns 0 when asset is fully depreciated before period start', () => {
// 5-year asset acquired 2018-01-01 — fully depreciated by 2023-01-01.
const asset = makeAsset({ acquisition_date: '2018-01-01' })
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(0)
})
it('K2 5-year schablon for inventarier: 100_000 / 5 = 20_000', () => {
const asset = makeAsset({
category: 'equipment',
acquisition_cost: 100_000,
useful_life_months: 60,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(20_000)
})
it('handles 3-year computer with K2 schablon: 30_000 / 3 = 10_000', () => {
const asset = makeAsset({
category: 'computer',
acquisition_cost: 30_000,
useful_life_months: 36,
})
const result = computeAnnualDepreciation(asset, FULL_YEAR)
expect(result.amount).toBe(10_000)
})
it('end-of-month acquisition does not overflow life end (Jan 31 + N months)', () => {
// Acquired 2025-01-31, 12-month life. Life ends 2026-01-30 (Jan 31 + 12mo
// clamped to last day of Jan = Jan 31 the following year, exclusive →
// Jan 30 inclusive). For fiscal year 2026 only Jan 1-30 = 30 days of
// life remain. Without the clamp, life would overflow to Feb 3 (Jan 31
// + 12mo via setUTCMonth) and over-depreciate.
const asset = makeAsset({
acquisition_date: '2025-01-31',
acquisition_cost: 12_000,
useful_life_months: 12,
})
const result = computeAnnualDepreciation(asset, {
period_start: '2026-01-01',
period_end: '2026-12-31',
})
// 30 days out of 365 of a 12_000 annual = ~986. The buggy version would
// have computed ~1_117 (34 days) — the gap detects the regression.
expect(result.amount).toBeGreaterThan(950)
expect(result.amount).toBeLessThan(1_020)
})
})
@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest'
import {
compute30Rule,
compute20Rule,
pickLowerResidual,
proposeOveravskrivningar,
OVERAVSKRIVNING_30_RULE,
} from '../reserves/overavskrivningar-service'
describe('compute30Rule', () => {
it('returns minimum residual = 70% of base', () => {
const result = compute30Rule({
openingBookValue: 100_000,
additions: 50_000,
disposals: 20_000,
})
// base = 100 + 50 - 20 = 130_000, residual = 91_000, maxAllowed = 39_000
expect(result.base).toBe(130_000)
expect(result.minimumResidual).toBe(91_000)
expect(result.maxAllowedAccumulated).toBe(39_000)
})
it('uses 0.7 rate per constant', () => {
expect(OVERAVSKRIVNING_30_RULE).toBe(0.7)
})
})
describe('compute20Rule', () => {
it('residual = sum of cost × (5 − age) / 5 per cohort', () => {
const result = 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)
})
it('skips cohorts where no acquisitions happened', () => {
const result = compute20Rule({
acquisitionCostByYearOffset: [50_000, 0, 0, 0, 0],
})
expect(result.minimumResidual).toBe(50_000)
})
})
describe('pickLowerResidual', () => {
it('returns whichever rule yields the lower residual (= more deduction)', () => {
const r30 = { base: 100_000, minimumResidual: 70_000, maxAllowedAccumulated: 30_000 }
const r20 = { minimumResidual: 60_000 }
const pick = pickLowerResidual(r30, r20)
expect(pick.residual).toBe(60_000)
expect(pick.rule).toBe('20-regeln')
})
it('prefers 30-rule on tie (default behaviour)', () => {
const r30 = { base: 100_000, minimumResidual: 70_000, maxAllowedAccumulated: 30_000 }
const r20 = { minimumResidual: 70_000 }
const pick = pickLowerResidual(r30, r20)
expect(pick.rule).toBe('30-regeln')
})
})
describe('proposeOveravskrivningar', () => {
it('emits balanced 8853 / 2153 entry for positive amount', () => {
const result = proposeOveravskrivningar({ additionalAmount: 25_000 })
expect(result).not.toBeNull()
expect(result!.amount).toBe(25_000)
expect(result!.lines[0].account_number).toBe('8853')
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!.warnings).toHaveLength(0)
})
it('emits reversal entry for negative amount with warning', () => {
const result = proposeOveravskrivningar({ additionalAmount: -10_000 })
expect(result).not.toBeNull()
expect(result!.amount).toBe(10_000)
expect(result!.lines[0].account_number).toBe('2153')
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!.warnings).toHaveLength(1)
})
it('returns null for zero amount', () => {
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('uses building accounts 8852/2152 when category=building', () => {
const result = proposeOveravskrivningar({
additionalAmount: 50_000,
category: 'building',
})
expect(result!.lines[0].account_number).toBe('8852')
expect(result!.lines[1].account_number).toBe('2152')
expect(result!.label).toContain('byggnader')
})
it('uses immaterial accounts 8851/2151 when category=immaterial', () => {
const result = proposeOveravskrivningar({
additionalAmount: 30_000,
category: 'immaterial',
})
expect(result!.lines[0].account_number).toBe('8851')
expect(result!.lines[1].account_number).toBe('2151')
expect(result!.label).toContain('immateriella')
})
it('defaults to maskiner & inventarier (8853/2153) when no category given', () => {
const result = proposeOveravskrivningar({ additionalAmount: 20_000 })
expect(result!.lines[0].account_number).toBe('8853')
expect(result!.lines[1].account_number).toBe('2153')
})
})
@@ -0,0 +1,188 @@
import { describe, it, expect } from 'vitest'
import {
proposeAvsattning,
proposeAteforing,
getPeriodiseringsfondCohortAccount,
PFOND_AB_RATE,
PFOND_MAX_HOLD_YEARS,
type ExistingFond,
} from '../reserves/periodiseringsfond-service'
describe('getPeriodiseringsfondCohortAccount', () => {
it('maps fiscal year to BAS 212X account', () => {
expect(getPeriodiseringsfondCohortAccount(2020)).toBe('2120')
expect(getPeriodiseringsfondCohortAccount(2025)).toBe('2125')
expect(getPeriodiseringsfondCohortAccount(2026)).toBe('2126')
expect(getPeriodiseringsfondCohortAccount(2027)).toBe('2127')
})
it('returns 2129 for 2019 per BAS collision rule', () => {
expect(getPeriodiseringsfondCohortAccount(2019)).toBe('2129')
})
})
describe('proposeAvsattning', () => {
it('caps avsättning at 25% of base', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
desiredAmount: 200_000, // user asks for 50% — should be capped
fiscalYear: 2025,
})
expect(result).not.toBeNull()
expect(result!.amount).toBe(100_000) // 25% of 400_000
expect(result!.warnings).toHaveLength(1)
expect(result!.warnings[0]).toContain('25 %')
expect(result!.lines[0].account_number).toBe('8811')
expect(result!.lines[1].account_number).toBe('2125')
})
it('defaults to maximum when desiredAmount is omitted', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
fiscalYear: 2025,
})
expect(result!.amount).toBe(100_000)
expect(result!.warnings).toHaveLength(0)
})
it('honors a smaller desiredAmount', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
desiredAmount: 30_000,
fiscalYear: 2025,
})
expect(result!.amount).toBe(30_000)
expect(result!.warnings).toHaveLength(0)
})
it('returns null when base is negative (loss year)', () => {
expect(
proposeAvsattning({
skattemassigtResultatBeforeAvsattning: -100_000,
fiscalYear: 2025,
}),
).toBeNull()
})
it('returns null when desired is zero', () => {
expect(
proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
desiredAmount: 0,
fiscalYear: 2025,
}),
).toBeNull()
})
it('emits balanced lines (debit 8811 = credit 21XX)', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 400_000,
fiscalYear: 2026,
})
expect(result!.lines).toHaveLength(2)
const totalDebit = result!.lines.reduce((s, l) => s + l.debit_amount, 0)
const totalCredit = result!.lines.reduce((s, l) => s + l.credit_amount, 0)
expect(totalDebit).toBe(totalCredit)
expect(result!.lines[1].account_number).toBe('2126')
})
it('uses fiscal year for cohort account number', () => {
const result = proposeAvsattning({
skattemassigtResultatBeforeAvsattning: 100_000,
fiscalYear: 2027,
})
expect(result!.lines[1].account_number).toBe('2127')
})
})
describe('proposeAteforing', () => {
it('forces full reversal of 6+ year old fonder and marks them required', () => {
const fonder: ExistingFond[] = [
{
account_number: '2120',
cohort_year: 2020,
balance: 50_000,
must_return_this_year: true,
},
]
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
expect(result.proposals).toHaveLength(1)
expect(result.proposals[0].amount).toBe(50_000)
expect(result.proposals[0].required).toBe(true)
expect(result.proposals[0].warnings[0]).toContain('6-årsgränsen')
// 50_000 × 0.03 = 1500
expect(result.schablonintaktAmount).toBe(1_500)
})
it('skips non-mandatory fonder when no return amount requested', () => {
const fonder: ExistingFond[] = [
{
account_number: '2122',
cohort_year: 2022,
balance: 100_000,
must_return_this_year: false,
},
]
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
expect(result.proposals).toHaveLength(0)
// Schablonintäkt is computed regardless of return decision
expect(result.schablonintaktAmount).toBe(3_000)
})
it('returns the requested optional amount when user opts in', () => {
const fonder: ExistingFond[] = [
{
account_number: '2123',
cohort_year: 2023,
balance: 80_000,
must_return_this_year: false,
},
]
const result = proposeAteforing(fonder, {
returns: { '2123': 50_000 },
schablonintaktRate: 0.03,
})
expect(result.proposals).toHaveLength(1)
expect(result.proposals[0].amount).toBe(50_000)
expect(result.proposals[0].required).toBeFalsy()
})
it('caps optional returns to the actual balance', () => {
const fonder: ExistingFond[] = [
{
account_number: '2124',
cohort_year: 2024,
balance: 30_000,
must_return_this_year: false,
},
]
const result = proposeAteforing(fonder, {
returns: { '2124': 100_000 },
schablonintaktRate: 0.03,
})
expect(result.proposals[0].amount).toBe(30_000) // capped to balance
})
it('emits balanced lines (debit 21XX = credit 8819)', () => {
const fonder: ExistingFond[] = [
{
account_number: '2120',
cohort_year: 2020,
balance: 50_000,
must_return_this_year: true,
},
]
const result = proposeAteforing(fonder, { schablonintaktRate: 0.03 })
const lines = result.proposals[0].lines
expect(lines).toHaveLength(2)
expect(lines[0].account_number).toBe('2120')
expect(lines[0].debit_amount).toBe(50_000)
expect(lines[1].account_number).toBe('8819')
expect(lines[1].credit_amount).toBe(50_000)
})
it('exposes constants used by callers', () => {
expect(PFOND_AB_RATE).toBe(0.25)
expect(PFOND_MAX_HOLD_YEARS).toBe(6)
})
})
@@ -0,0 +1,214 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { YearEndValidation } from '@/types'
// Mock both sources the aggregator composes from. Tests focus on composition
// (reminders by entity, reconciliation surfacing, error tolerance) — the
// underlying validateYearEndReadiness already has its own coverage.
vi.mock('@/lib/core/bookkeeping/year-end-service', () => ({
validateYearEndReadiness: vi.fn(),
}))
vi.mock('@/lib/reconciliation/bank-reconciliation', () => ({
getReconciliationStatus: vi.fn(),
}))
import { buildBokslutReadinessReport } from '../readiness-aggregator'
import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
interface MockBuilder {
select: ReturnType<typeof vi.fn>
eq: ReturnType<typeof vi.fn>
single: ReturnType<typeof vi.fn>
maybeSingle: ReturnType<typeof vi.fn>
}
function makeSupabase(handlers: {
period: { data: unknown; error: unknown }
settings: { data: unknown; error: unknown }
}) {
function makeBuilder(table: string): MockBuilder {
const b: MockBuilder = {
select: vi.fn(),
eq: vi.fn(),
single: vi.fn(),
maybeSingle: vi.fn(),
}
b.select.mockReturnValue(b)
b.eq.mockReturnValue(b)
if (table === 'fiscal_periods') {
b.single.mockResolvedValue(handlers.period)
} else if (table === 'company_settings') {
b.maybeSingle.mockResolvedValue(handlers.settings)
}
return b
}
return {
from: vi.fn((table: string) => makeBuilder(table)),
} as unknown as Parameters<typeof buildBokslutReadinessReport>[0]
}
function baseValidation(overrides: Partial<YearEndValidation> = {}): YearEndValidation {
return {
ready: true,
errors: [],
warnings: [],
draftCount: 0,
voucherGaps: [],
unexplainedGaps: [],
sequenceMismatches: [],
trialBalanceBalanced: true,
...overrides,
}
}
const PERIOD = {
id: 'fp-1',
name: '2025',
period_start: '2025-01-01',
period_end: '2025-12-31',
is_closed: false,
locked_at: null,
closing_entry_id: null,
}
const RECON_CLEAN = {
bank_transaction_total: 100,
gl_1930_balance: 100,
gl_1930_period_movement: 100,
gl_1930_opening_balance: 0,
difference: 0,
is_reconciled: true,
matched_count: 5,
unmatched_transaction_count: 0,
unmatched_gl_line_count: 0,
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('buildBokslutReadinessReport', () => {
it('returns a ready report with the accruals reminder for AB', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'aktiebolag' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.ready).toBe(true)
expect(report.blockers).toEqual([])
expect(report.entityType).toBe('aktiebolag')
// Phase 3 handles depreciation + bolagsskatt + p-fond automatically — only
// the accruals reminder should remain (Phase 4 will replace it).
expect(report.reminders.map((r) => r.code)).toContain('accruals_manual')
expect(report.reminders.map((r) => r.code)).not.toContain('depreciation_manual')
expect(report.reminders.map((r) => r.code)).not.toContain('bolagsskatt_manual')
expect(report.reminders.map((r) => r.code)).not.toContain('periodiseringsfond_manual')
expect(report.reminders.find((r) => r.code === 'ef_skatt_via_ne')).toBeUndefined()
expect(report.reconciliation?.is_reconciled).toBe(true)
})
it('returns the EF-only reminder for enskild firma', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'enskild_firma' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.entityType).toBe('enskild_firma')
expect(report.reminders.find((r) => r.code === 'ef_skatt_via_ne')).toBeDefined()
})
it('surfaces blockers from the underlying validation and stays not-ready', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(
baseValidation({
ready: false,
errors: ['3 draft journal entries must be posted or deleted before closing'],
draftCount: 3,
}),
)
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'aktiebolag' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.ready).toBe(false)
expect(report.blockers).toHaveLength(1)
expect(report.draftCount).toBe(3)
})
it('adds a reconciliation reminder when bank is unreconciled', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue({
...RECON_CLEAN,
is_reconciled: false,
unmatched_transaction_count: 7,
difference: 1234.56,
})
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'aktiebolag' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
const reconReminder = report.reminders.find((r) => r.code === 'bank_reconciliation_incomplete')
expect(reconReminder).toBeDefined()
expect(reconReminder?.severity).toBe('warning')
expect(reconReminder?.message).toContain('7')
// Reconciliation reminder is not a legal blocker — ready should still mirror validation
expect(report.ready).toBe(true)
})
it('does not break when reconciliation lookup throws', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockRejectedValue(new Error('boom'))
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: { entity_type: 'aktiebolag' }, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.reconciliation).toBeNull()
expect(report.reminders.find((r) => r.code === 'bank_reconciliation_incomplete')).toBeUndefined()
expect(report.ready).toBe(true)
})
it('throws when the fiscal period is missing', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: null, error: { message: 'not found' } },
settings: { data: null, error: null },
})
await expect(
buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-missing'),
).rejects.toThrow(/not found/i)
})
it('defaults to aktiebolag when company_settings is missing', async () => {
vi.mocked(validateYearEndReadiness).mockResolvedValue(baseValidation())
vi.mocked(getReconciliationStatus).mockResolvedValue(RECON_CLEAN)
const supabase = makeSupabase({
period: { data: PERIOD, error: null },
settings: { data: null, error: null },
})
const report = await buildBokslutReadinessReport(supabase, 'co-1', 'user-1', 'fp-1')
expect(report.entityType).toBe('aktiebolag')
expect(report.reminders.find((r) => r.code === 'ef_skatt_via_ne')).toBeUndefined()
})
})
@@ -0,0 +1,75 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
calculateSarskildLoneskatt,
SLP_RATE,
} from '../tax-provision/sarskild-loneskatt-calculator'
function makeSupabaseWithPensionLines(rows: Array<{ debit_amount: number; credit_amount: number }>) {
const builder = {
select: vi.fn(),
eq: vi.fn(),
gte: vi.fn(),
lte: vi.fn(),
then: undefined as unknown as (resolve: (v: { data: unknown; error: unknown }) => void) => void,
}
builder.select.mockReturnValue(builder)
builder.eq.mockReturnValue(builder)
builder.gte.mockReturnValue(builder)
builder.lte.mockReturnValue(builder)
builder.then = (resolve) => resolve({ data: rows, error: null })
return { from: vi.fn().mockReturnValue(builder) } as unknown as Parameters<
typeof calculateSarskildLoneskatt
>[0]
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('calculateSarskildLoneskatt', () => {
it('applies 24.26% to pension costs and posts 7533/2514', async () => {
const supabase = makeSupabaseWithPensionLines([
{ debit_amount: 50_000, credit_amount: 0 },
{ debit_amount: 30_000, credit_amount: 0 },
])
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
expect(result).not.toBeNull()
// base = 80_000, × 0.2426 = 19_408
expect(result!.amount).toBe(19_408)
expect(result!.lines[0].account_number).toBe('7533')
expect(result!.lines[1].account_number).toBe('2514')
})
it('returns null when there are no pension costs', async () => {
const supabase = makeSupabaseWithPensionLines([])
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
expect(result).toBeNull()
})
it('honors manual adjustment for pensionsavsättning on 2210', async () => {
const supabase = makeSupabaseWithPensionLines([])
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp', {
manualAdjustment: 100_000,
})
expect(result).not.toBeNull()
// 100_000 × 0.2426 = 24_260
expect(result!.amount).toBe(24_260)
})
it('nets debits against credits (refund of pension premium reduces base)', async () => {
const supabase = makeSupabaseWithPensionLines([
{ debit_amount: 50_000, credit_amount: 0 },
{ debit_amount: 0, credit_amount: 10_000 },
])
const result = await calculateSarskildLoneskatt(supabase, 'co', 'fp')
expect(result).not.toBeNull()
// base = 40_000, × 0.2426 = 9_704
expect(result!.amount).toBe(9_704)
})
it('exposes the SLP rate constant', () => {
expect(SLP_RATE).toBe(0.2426)
})
})
+403
View File
@@ -0,0 +1,403 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import type {
Asset,
AssetCategory,
DepreciationMethod,
CreateJournalEntryLineInput,
JournalEntry,
} from '@/types'
/**
* Default BAS account triples per category. The user can override at create
* time; these only kick in when the form doesn't specify accounts. Matches
* the seeded BAS 2020 chart (lib/bookkeeping/bas-data/).
*/
export const DEFAULT_ACCOUNTS_BY_CATEGORY: Record<
AssetCategory,
{ asset: string; accumulated: string; expense: string }
> = {
immaterial: { asset: '1010', accumulated: '1019', expense: '7810' },
building: { asset: '1110', accumulated: '1119', expense: '7821' },
land_improvement: { asset: '1150', accumulated: '1159', expense: '7824' },
machinery: { asset: '1210', accumulated: '1219', expense: '7831' },
equipment: { asset: '1220', accumulated: '1229', expense: '7832' },
vehicle: { asset: '1240', accumulated: '1249', expense: '7834' },
computer: { asset: '1250', accumulated: '1259', expense: '7833' },
other_tangible: { asset: '1280', accumulated: '1289', expense: '7839' },
}
export interface CreateAssetInput {
name: string
category: AssetCategory
acquisition_date: string
acquisition_cost: number
salvage_value?: number
useful_life_months: number
depreciation_method?: DepreciationMethod
bas_asset_account?: string
bas_accumulated_account?: string
bas_expense_account?: string
notes?: string
}
/**
* Create a new asset. Defaults BAS accounts from the category mapping when
* the caller doesn't override them. Does NOT post a journal entry — the
* acquisition is assumed to already be in the books (bank payment or
* supplier invoice). Posting an acquisition entry alongside an existing
* payment would double-count.
*/
export async function createAsset(
supabase: SupabaseClient,
companyId: string,
userId: string,
input: CreateAssetInput,
): Promise<Asset> {
const defaults = DEFAULT_ACCOUNTS_BY_CATEGORY[input.category]
const row = {
user_id: userId,
company_id: companyId,
name: input.name,
category: input.category,
acquisition_date: input.acquisition_date,
acquisition_cost: input.acquisition_cost,
salvage_value: input.salvage_value ?? 0,
useful_life_months: input.useful_life_months,
depreciation_method: input.depreciation_method ?? 'linear',
bas_asset_account: input.bas_asset_account ?? defaults.asset,
bas_accumulated_account: input.bas_accumulated_account ?? defaults.accumulated,
bas_expense_account: input.bas_expense_account ?? defaults.expense,
notes: input.notes ?? null,
}
const { data, error } = await supabase
.from('assets')
.insert(row)
.select('*')
.single()
if (error || !data) {
throw new Error(`Failed to create asset: ${error?.message ?? 'unknown'}`)
}
return data as Asset
}
export async function listAssets(
supabase: SupabaseClient,
companyId: string,
options: { activeOnly?: boolean } = {},
): Promise<Asset[]> {
let query = supabase
.from('assets')
.select('*')
.eq('company_id', companyId)
.order('acquisition_date', { ascending: true })
if (options.activeOnly) {
query = query.is('disposed_at', null)
}
const { data, error } = await query
if (error) throw new Error(`Failed to list assets: ${error.message}`)
return (data ?? []) as Asset[]
}
export async function getAsset(
supabase: SupabaseClient,
companyId: string,
assetId: string,
): Promise<Asset | null> {
const { data, error } = await supabase
.from('assets')
.select('*')
.eq('id', assetId)
.eq('company_id', companyId)
.maybeSingle()
if (error) throw new Error(`Failed to load asset: ${error.message}`)
return (data as Asset | null) ?? null
}
export interface UpdateAssetInput {
name?: string
notes?: string | null
/** Salvage value, useful life, method, accounts — editable as long as the
* asset isn't disposed yet (DB trigger enforces this beyond the API). */
salvage_value?: number
useful_life_months?: number
depreciation_method?: DepreciationMethod
bas_asset_account?: string
bas_accumulated_account?: string
bas_expense_account?: string
}
export async function updateAsset(
supabase: SupabaseClient,
companyId: string,
assetId: string,
input: UpdateAssetInput,
): Promise<Asset> {
// Defense-in-depth: when callers remap BAS accounts, refuse anything
// outside the legitimate range for the existing asset's category — keeps
// INK2R mappings + the depreciation engine's category-driven defaults in
// sync with what users actually pick.
if (
input.bas_asset_account ||
input.bas_accumulated_account ||
input.bas_expense_account
) {
const existing = await getAsset(supabase, companyId, assetId)
if (!existing) throw new Error('Asset not found')
const ranges = BAS_RANGES_BY_CATEGORY[existing.category]
if (input.bas_asset_account && !inBasRange(input.bas_asset_account, ranges.asset)) {
throw new Error(
`bas_asset_account ${input.bas_asset_account} is outside ${ranges.asset[0]}–${ranges.asset[1]} for ${existing.category}`,
)
}
if (
input.bas_accumulated_account &&
!inBasRange(input.bas_accumulated_account, ranges.accumulated)
) {
throw new Error(
`bas_accumulated_account ${input.bas_accumulated_account} is outside ${ranges.accumulated[0]}–${ranges.accumulated[1]} for ${existing.category}`,
)
}
if (
input.bas_expense_account &&
!inBasRange(input.bas_expense_account, ranges.expense)
) {
throw new Error(
`bas_expense_account ${input.bas_expense_account} is outside ${ranges.expense[0]}–${ranges.expense[1]} for ${existing.category}`,
)
}
// Anskaffning and ackumulerade-avskrivningar must be different accounts —
// see CreateAssetSchema validateBasOverrides for the rationale.
const finalAsset = input.bas_asset_account ?? existing.bas_asset_account
const finalAccumulated = input.bas_accumulated_account ?? existing.bas_accumulated_account
if (finalAsset === finalAccumulated) {
throw new Error(
'bas_asset_account and bas_accumulated_account must be different accounts',
)
}
}
const { data, error } = await supabase
.from('assets')
.update(input)
.eq('id', assetId)
.eq('company_id', companyId)
.select('*')
.single()
if (error || !data) {
throw new Error(`Failed to update asset: ${error?.message ?? 'unknown'}`)
}
return data as Asset
}
const BAS_RANGES_BY_CATEGORY: Record<
AssetCategory,
{ asset: [string, string]; accumulated: [string, string]; expense: [string, string] }
> = {
immaterial: { asset: ['1010', '1099'], accumulated: ['1010', '1099'], expense: ['7810', '7819'] },
building: { asset: ['1100', '1199'], accumulated: ['1100', '1199'], expense: ['7820', '7829'] },
land_improvement:{ asset: ['1150', '1159'], accumulated: ['1150', '1159'], expense: ['7820', '7829'] },
machinery: { asset: ['1210', '1219'], accumulated: ['1210', '1219'], expense: ['7830', '7839'] },
equipment: { asset: ['1220', '1229'], accumulated: ['1220', '1229'], expense: ['7830', '7839'] },
vehicle: { asset: ['1240', '1249'], accumulated: ['1240', '1249'], expense: ['7830', '7839'] },
computer: { asset: ['1250', '1259'], accumulated: ['1250', '1259'], expense: ['7830', '7839'] },
other_tangible: { asset: ['1280', '1299'], accumulated: ['1280', '1299'], expense: ['7830', '7839'] },
}
function inBasRange(account: string, range: [string, string]): boolean {
return account >= range[0] && account <= range[1]
}
export interface DisposeAssetInput {
/** ISO date of disposal — typically the day of sale or scrapping. */
disposed_at: string
/** Cash / receivable received for the asset. Zero for scrapping. */
disposed_proceeds: number
/** Optional override for the bank/receivable account credited with the
* proceeds. Defaults to 1930 (företagskonto). */
proceeds_account?: string
/** Fiscal period the disposal entry lands in. Caller resolves this from
* disposed_at — we don't auto-derive to keep the period-lock check at
* the route layer. */
fiscal_period_id: string
}
export interface DisposalResult {
asset: Asset
/** Disposal entry. Null when no entry was needed (zero-value, fully-
* depreciated asset scrapped for nothing). */
disposal_entry: JournalEntry | null
gain_or_loss: number
}
/**
* Dispose of an asset. Posts a journal entry that:
* - Debit accumulated depreciation (to zero out the asset's accumulated
* account)
* - Credit acquisition cost (to zero out the asset's anskaffning account)
* - Debit proceeds account (bank / receivable) for sale price
* - Debit 78xx (loss on sale) OR Credit 30xx (gain on sale) — accounts
* branch on category (3013/7813 for immaterial, 3973/7973 for tangible).
*
* After posting, marks the asset row with disposed_at / disposed_proceeds.
* The DB trigger then prevents further edits to financial fields.
*
* KNOWN LIMITATION (ML 3 kap 3 § / 7 kap 3 §): the sale of an
* anläggningstillgång that had right-to-deduct VAT on acquisition is in
* principle 25 % momspliktig. This function does NOT post output VAT on the
* proceeds — callers must handle the VAT side separately (or use a manual
* journal entry). UI surfacing disposal must warn the user. Adding a
* vat_on_proceeds field is tracked as a follow-up.
*
* KNOWN LIMITATION (ML 9 kap 8–11 §§ — jämkning): when a building or
* markanläggning is disposed of within the 10-year jämkningsperiod after
* acquisition, previously deducted input VAT must be recalculated and may
* have to be partially repaid. This function does NOT compute or post any
* jämkning adjustment. UI surfacing disposal for category='building' or
* 'land_improvement' must check the 10-year window against acquisition_date
* and warn the user; the actual recalculation belongs in a future
* dedicated flow.
*/
export async function disposeAsset(
supabase: SupabaseClient,
companyId: string,
userId: string,
assetId: string,
input: DisposeAssetInput,
): Promise<DisposalResult> {
const asset = await getAsset(supabase, companyId, assetId)
if (!asset) throw new Error('Asset not found')
if (asset.disposed_at) {
throw new Error('Asset is already disposed')
}
// Derive accumulated depreciation server-side from posted
// depreciation_schedules so a malicious or buggy caller cannot inflate the
// book-value calculation. Limitation: manual avskrivningsverifikationer
// posted outside the engine aren't captured here. Phase 5+ can replace
// this with a trial-balance scan on bas_accumulated_account.
const accumulated = await sumPostedDepreciation(supabase, companyId, assetId)
const acquisitionCost = Number(asset.acquisition_cost)
const proceeds = Number(input.disposed_proceeds)
const netBookValue = acquisitionCost - accumulated
const gainOrLoss = Math.round((proceeds - netBookValue) * 100) / 100
const proceedsAccount = input.proceeds_account ?? '1930'
const lines: CreateJournalEntryLineInput[] = []
if (accumulated > 0.005) {
lines.push({
account_number: asset.bas_accumulated_account,
debit_amount: Math.round(accumulated * 100) / 100,
credit_amount: 0,
line_description: `Avyttring: nollställ ack. avskrivning ${asset.name}`,
})
}
lines.push({
account_number: asset.bas_asset_account,
debit_amount: 0,
credit_amount: Math.round(acquisitionCost * 100) / 100,
line_description: `Avyttring: nollställ anskaffning ${asset.name}`,
})
if (proceeds > 0.005) {
lines.push({
account_number: proceedsAccount,
debit_amount: Math.round(proceeds * 100) / 100,
credit_amount: 0,
line_description: `Avyttring: erhållet belopp ${asset.name}`,
})
}
// Disposal gain/loss accounts vary by asset class — BAS 2026 splits them
// because INK2R routes each pair to a different field. Mixing them
// misclassifies in the tax declaration.
// - immaterial → 3013 (vinst) / 7813 (förlust)
// - building / markanlägg → 3971 / 7971
// - other tangible → 3973 / 7973
const isBuilding = asset.category === 'building' || asset.category === 'land_improvement'
const gainAccount =
asset.category === 'immaterial' ? '3013' : isBuilding ? '3971' : '3973'
const lossAccount =
asset.category === 'immaterial' ? '7813' : isBuilding ? '7971' : '7973'
if (gainOrLoss > 0.005) {
lines.push({
account_number: gainAccount,
debit_amount: 0,
credit_amount: gainOrLoss,
line_description: `Vinst vid avyttring av ${asset.name}`,
})
} else if (gainOrLoss < -0.005) {
lines.push({
account_number: lossAccount,
debit_amount: Math.abs(gainOrLoss),
credit_amount: 0,
line_description: `Förlust vid avyttring av ${asset.name}`,
})
}
let disposalEntry: JournalEntry | null = null
if (lines.length > 0) {
disposalEntry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: input.fiscal_period_id,
entry_date: input.disposed_at,
description: `Avyttring av tillgång: ${asset.name}`,
source_type: 'manual',
voucher_series: 'A',
lines,
})
}
const { data: updated, error: updateError } = await supabase
.from('assets')
.update({
disposed_at: input.disposed_at,
disposed_proceeds: proceeds,
})
.eq('id', assetId)
.eq('company_id', companyId)
.select('*')
.single()
if (updateError || !updated) {
throw new Error(`Failed to mark asset disposed: ${updateError?.message ?? 'unknown'}`)
}
return {
asset: updated as Asset,
disposal_entry: disposalEntry,
gain_or_loss: gainOrLoss,
}
}
/**
* Sum every posted depreciation_schedules row for an asset to get accumulated
* depreciation as of "now". Used by disposeAsset so the caller cannot
* influence the book-value calculation.
*/
async function sumPostedDepreciation(
supabase: SupabaseClient,
companyId: string,
assetId: string,
): Promise<number> {
const { data, error } = await supabase
.from('depreciation_schedules')
.select('planned_depreciation')
.eq('company_id', companyId)
.eq('asset_id', assetId)
.not('journal_entry_id', 'is', null)
if (error) {
throw new Error(`Failed to sum depreciation for asset ${assetId}: ${error.message}`)
}
type Row = { planned_depreciation: number | string }
return ((data ?? []) as Row[]).reduce(
(sum, row) => sum + (Number(row.planned_depreciation) || 0),
0,
)
}
+313
View File
@@ -0,0 +1,313 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import { listAssets } from './asset-service'
import type {
Asset,
FiscalPeriod,
JournalEntry,
CreateJournalEntryLineInput,
} from '@/types'
export interface AssetDepreciation {
asset: Asset
/** Planenlig avskrivning för denna period, avrundad till hela kronor. */
amount: number
/** Net book value vid periodens slut (ackumulerad avskrivning inklusive
* denna period subtraherat från anskaffningsvärdet). Används av wizard
* UI:t för att visa restvärde efter avskrivning. */
netBookValueAfter: number
/** True om avskrivningen pro-rateras (tillgång anskaffad eller fullt
* avskriven mitt i perioden). */
proRated: boolean
/** Befintligt depreciation_schedules-id om en proposal redan finns för
* denna kombination. Wizard:t använder detta för att veta att proposalen
* redan har bokförts (om journal_entry_id är satt) eller är väntande
* (om journal_entry_id är null). */
existingScheduleId?: string
existingJournalEntryId?: string | null
}
export interface DepreciationProposal {
fiscalPeriod: { id: string; name: string; period_start: string; period_end: string }
items: AssetDepreciation[]
totalAmount: number
}
/**
* Compute planenlig avskrivning för en enskild tillgång under en given
* fiscal period. Pro-rateras vid första och sista året.
*
* Only the linear method is implemented. The API rejects asset creation /
* updates with the other DB-enum values (declining_balance_*), so any asset
* that reaches here has method='linear'; treating the other branches as
* dead code is safe.
*/
export function computeAnnualDepreciation(
asset: Asset,
fiscalPeriod: Pick<FiscalPeriod, 'period_start' | 'period_end'>,
): { amount: number; proRated: boolean } {
if (asset.disposed_at && asset.disposed_at < fiscalPeriod.period_start) {
return { amount: 0, proRated: false }
}
const acquisitionCost = Number(asset.acquisition_cost)
const salvageValue = Number(asset.salvage_value)
const depreciableBase = acquisitionCost - salvageValue
if (depreciableBase <= 0) return { amount: 0, proRated: false }
const usefulLifeMonths = asset.useful_life_months
const annualRate = 12 / usefulLifeMonths
// Determine the depreciation window for this period: the overlap between
// the asset's active life and the fiscal period.
const acquisition = isoToDate(asset.acquisition_date)
const lifeEndExclusive = addMonths(acquisition, usefulLifeMonths)
const periodStart = isoToDate(fiscalPeriod.period_start)
const periodEndInclusive = isoToDate(fiscalPeriod.period_end)
const disposalEnd = asset.disposed_at ? isoToDate(asset.disposed_at) : null
const windowStart = maxDate(acquisition, periodStart)
let windowEnd = minDate(periodEndInclusive, addDays(lifeEndExclusive, -1))
if (disposalEnd) windowEnd = minDate(windowEnd, disposalEnd)
if (windowEnd < windowStart) return { amount: 0, proRated: false }
const fullPeriodDays = daysBetween(periodStart, periodEndInclusive) + 1
const windowDays = daysBetween(windowStart, windowEnd) + 1
const fraction = windowDays / fullPeriodDays
const proRated = fraction < 0.999
// Full-year amount = annualRate × depreciableBase (linear).
const annualAmount = depreciableBase * annualRate
const proRatedAmount = annualAmount * fraction
return {
amount: Math.round(proRatedAmount),
proRated,
}
}
/**
* Build a proposal listing planenlig avskrivning för every active asset.
* Reads existing depreciation_schedules so already-posted entries aren't
* proposed twice (the unique constraint on (asset_id, fiscal_period_id)
* would reject duplicates anyway, but the UI wants to display "redan
* bokförd" rather than fail).
*/
export async function proposeAnnualPostings(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<DepreciationProposal> {
const [periodResult, assets, currentSchedulesResult, priorSchedulesResult] = await Promise.all([
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
listAssets(supabase, companyId),
// Schedules in the current period (proposal lookup / "already posted" badge)
supabase
.from('depreciation_schedules')
.select('id, asset_id, journal_entry_id')
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId),
// Prior posted schedules (excluding the current period) so we can compute
// the true accumulated depreciation per asset for net book value.
supabase
.from('depreciation_schedules')
.select('asset_id, planned_depreciation, journal_entry_id, fiscal_period_id')
.eq('company_id', companyId)
.neq('fiscal_period_id', fiscalPeriodId)
.not('journal_entry_id', 'is', null),
])
if (periodResult.error || !periodResult.data) {
throw new Error('Fiscal period not found')
}
const period = periodResult.data
const existing = new Map<string, { id: string; journal_entry_id: string | null }>(
(currentSchedulesResult.data ?? []).map(
(r: { id: string; asset_id: string; journal_entry_id: string | null }) => [
r.asset_id,
{ id: r.id, journal_entry_id: r.journal_entry_id },
],
),
)
// Sum of all prior posted depreciation per asset. This is the accumulated
// depreciation on the books before this period — does not yet count this
// period's proposal or any unposted current-period draft.
const priorAccumulated = new Map<string, number>()
for (const row of (priorSchedulesResult.data ?? []) as Array<{
asset_id: string
planned_depreciation: number | string
}>) {
const v = Number(row.planned_depreciation) || 0
priorAccumulated.set(row.asset_id, (priorAccumulated.get(row.asset_id) ?? 0) + v)
}
const items: AssetDepreciation[] = []
for (const asset of assets) {
// Skip assets disposed before period start
if (asset.disposed_at && asset.disposed_at < period.period_start) continue
const { amount, proRated } = computeAnnualDepreciation(asset, period)
if (amount <= 0) continue
const existingSchedule = existing.get(asset.id)
const accumulatedBefore = priorAccumulated.get(asset.id) ?? 0
const netBookValueAfter =
Math.round((Number(asset.acquisition_cost) - accumulatedBefore - amount) * 100) / 100
items.push({
asset,
amount,
netBookValueAfter,
proRated,
existingScheduleId: existingSchedule?.id,
existingJournalEntryId: existingSchedule?.journal_entry_id ?? null,
})
}
return {
fiscalPeriod: period,
items,
totalAmount: items.reduce((sum, item) => sum + item.amount, 0),
}
}
/**
* Commit the proposal as journal entries. Creates ONE journal entry per
* asset (rather than a single batch entry) so each can be reversed
* independently and so the depreciation_schedules row links one-to-one to
* its journal entry.
*
* Skips assets that already have a posted schedule för this period — the
* unique constraint would block them, and silently skipping is more useful
* than throwing. Returns the list of (asset_id, schedule, entry) tuples.
*/
export async function commitAnnualPostings(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
options: { assetIds?: string[] } = {},
): Promise<{
posted: { assetId: string; entry: JournalEntry; scheduleId: string }[]
skipped: { assetId: string; reason: string }[]
}> {
const proposal = await proposeAnnualPostings(supabase, companyId, fiscalPeriodId)
const periodEnd = proposal.fiscalPeriod.period_end
const periodName = proposal.fiscalPeriod.name
const allowed = options.assetIds ? new Set(options.assetIds) : null
const posted: { assetId: string; entry: JournalEntry; scheduleId: string }[] = []
const skipped: { assetId: string; reason: string }[] = []
for (const item of proposal.items) {
if (allowed && !allowed.has(item.asset.id)) continue
if (item.existingJournalEntryId) {
skipped.push({ assetId: item.asset.id, reason: 'already_posted' })
continue
}
const lines: CreateJournalEntryLineInput[] = [
{
account_number: item.asset.bas_expense_account,
debit_amount: item.amount,
credit_amount: 0,
line_description: `Avskrivning ${item.asset.name}`,
},
{
account_number: item.asset.bas_accumulated_account,
debit_amount: 0,
credit_amount: item.amount,
line_description: `Ack. avskrivning ${item.asset.name}`,
},
]
const entry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
entry_date: periodEnd,
description: `Planenlig avskrivning ${periodName}: ${item.asset.name}`,
source_type: 'year_end',
voucher_series: 'A',
lines,
})
// Upsert the schedule row. If a draft (no journal_entry_id) already
// exists for (asset, period) we overwrite it with the posted entry.
if (item.existingScheduleId) {
const { error } = await supabase
.from('depreciation_schedules')
.update({ journal_entry_id: entry.id, posted_at: new Date().toISOString() })
.eq('id', item.existingScheduleId)
.eq('company_id', companyId)
if (error) throw new Error(`Failed to update schedule: ${error.message}`)
posted.push({ assetId: item.asset.id, entry, scheduleId: item.existingScheduleId })
} else {
const { data, error } = await supabase
.from('depreciation_schedules')
.insert({
user_id: userId,
company_id: companyId,
asset_id: item.asset.id,
fiscal_period_id: fiscalPeriodId,
planned_depreciation: item.amount,
journal_entry_id: entry.id,
posted_at: new Date().toISOString(),
})
.select('id')
.single()
if (error || !data) throw new Error(`Failed to insert schedule: ${error?.message}`)
posted.push({ assetId: item.asset.id, entry, scheduleId: data.id })
}
}
return { posted, skipped }
}
// ============================================================
// Date helpers — keep pure so the unit tests don't need to mock anything.
// ============================================================
function isoToDate(iso: string): Date {
// Force UTC midnight to avoid local-time DST drift confusing days math.
return new Date(iso + 'T00:00:00Z')
}
function addMonths(date: Date, months: number): Date {
// Clamp the day to the last valid day of the target month so end-of-month
// dates don't overflow forward (Jan 31 + 1 month → Feb 28, not Mar 3).
const targetMonth = date.getUTCMonth() + months
const lastDayOfTargetMonth = new Date(
Date.UTC(date.getUTCFullYear(), targetMonth + 1, 0),
).getUTCDate()
return new Date(
Date.UTC(
date.getUTCFullYear(),
targetMonth,
Math.min(date.getUTCDate(), lastDayOfTargetMonth),
),
)
}
function addDays(date: Date, days: number): Date {
const result = new Date(date)
result.setUTCDate(result.getUTCDate() + days)
return result
}
function maxDate(a: Date, b: Date): Date {
return a > b ? a : b
}
function minDate(a: Date, b: Date): Date {
return a < b ? a : b
}
function daysBetween(a: Date, b: Date): number {
return Math.round((b.getTime() - a.getTime()) / (1000 * 60 * 60 * 24))
}
+165
View File
@@ -0,0 +1,165 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
import type { YearEndValidation } from '@/types'
export type ReminderSeverity = 'info' | 'warning'
export interface BokslutReminder {
/** Stable id so the UI can suppress duplicates and link to docs. */
code: string
severity: ReminderSeverity
/** Swedish, user-facing. */
message: string
/** Optional deep link to the relevant resolution surface. */
href?: string
}
export interface BokslutReadinessReport {
/** Mirrors validateYearEndReadiness.ready — true ⇔ no blocking errors. */
ready: boolean
/** Blocking errors that prevent year-end execution (from year-end-service). */
blockers: string[]
/** Non-blocking warnings (from year-end-service). */
warnings: string[]
/** Soft reminders (Phase 2+ features not yet shipped, manual steps the user
* should consider). Never blockers — surfaced so users know what's manual. */
reminders: BokslutReminder[]
/** Convenience counts for the UI header. */
draftCount: number
unexplainedGapCount: number
trialBalanceBalanced: boolean
/** Bank reconciliation snapshot for the period. */
reconciliation: {
is_reconciled: boolean
unmatched_transaction_count: number
unmatched_gl_line_count: number
difference: number
} | null
/** Period metadata so the UI can show name/dates without an extra fetch. */
period: {
id: string
name: string
period_start: string
period_end: string
is_closed: boolean
locked_at: string | null
closing_entry_id: string | null
}
/** Entity type drives which dispositions apply (e.g. bolagsskatt only for AB). */
entityType: 'aktiebolag' | 'enskild_firma' | 'handelsbolag' | 'kommanditbolag' | 'ekonomisk_forening'
/** The full raw validation, for callers that want every field. */
rawValidation: YearEndValidation
}
/**
* Single-fetch aggregator that drives the bokslut wizard's preflight step.
*
* Wraps validateYearEndReadiness (which owns the legally-required checks) and
* layers on:
* - bank reconciliation snapshot for the period (informational warning if
* unmatched transactions exist — not a legal blocker)
* - soft reminders for Phase 2+ features that ship later (depreciation,
* accruals, tax provision). These tell the user what's manual today.
*
* Phase 2 will replace each reminder with a concrete proposal once the
* relevant calculator ships.
*/
export async function buildBokslutReadinessReport(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
): Promise<BokslutReadinessReport> {
// Fetch period + entity type in parallel with the heavy validation.
const [periodResult, settingsResult, validation] = await Promise.all([
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, is_closed, locked_at, closing_entry_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
supabase
.from('company_settings')
.select('entity_type')
.eq('company_id', companyId)
.maybeSingle(),
validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId),
])
if (periodResult.error || !periodResult.data) {
throw new Error('Fiscal period not found')
}
const period = periodResult.data
const entityType = (settingsResult.data?.entity_type ?? 'aktiebolag') as BokslutReadinessReport['entityType']
// Bank reconciliation snapshot for the period. Run after period fetch so we
// know the date range. Failure here must not break the report — fall back
// to null so the UI degrades gracefully.
let reconciliation: BokslutReadinessReport['reconciliation'] = null
try {
const status = await getReconciliationStatus(
supabase,
companyId,
period.period_start,
period.period_end,
)
reconciliation = {
is_reconciled: status.is_reconciled,
unmatched_transaction_count: status.unmatched_transaction_count,
unmatched_gl_line_count: status.unmatched_gl_line_count,
difference: status.difference,
}
} catch {
reconciliation = null
}
const reminders: BokslutReminder[] = []
if (reconciliation && !reconciliation.is_reconciled) {
reminders.push({
code: 'bank_reconciliation_incomplete',
severity: 'warning',
message:
reconciliation.unmatched_transaction_count > 0
? `${reconciliation.unmatched_transaction_count} banktransaktioner är inte matchade. Avstäm banken innan bokslut.`
: `Bankavstämningen visar en differens på ${reconciliation.difference.toFixed(2)} kr.`,
href: '/reconciliation/bank',
})
}
// Periodiseringar (accruals) are still manual — no wizard step ships in
// Phases 1-3. Depreciation, bolagsskatt and periodiseringsfond now have
// dedicated calculators (DepreciationPanel + DispositionsStep) so they're
// no longer surfaced as manual reminders.
reminders.push({
code: 'accruals_manual',
severity: 'info',
message:
'Periodiseringar (förutbetalda kostnader 17xx, upplupna kostnader 29xx) bokas manuellt. Tänk på att vända dem 1 januari nästa år.',
})
if (entityType === 'enskild_firma') {
reminders.push({
code: 'ef_skatt_via_ne',
severity: 'info',
message:
'Egenavgifter, räntefördelning och periodiseringsfond beräknas i NE-bilagan, inte bokförs. Skatten betalas privat av ägaren.',
})
}
return {
ready: validation.ready,
blockers: validation.errors,
warnings: validation.warnings,
reminders,
draftCount: validation.draftCount,
unexplainedGapCount: validation.unexplainedGaps.length,
trialBalanceBalanced: validation.trialBalanceBalanced,
reconciliation,
period,
entityType,
rawValidation: validation,
}
}
@@ -0,0 +1,187 @@
import type { ProposedDisposition } from '../types'
/** 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). */
export const OVERAVSKRIVNING_30_RULE = 0.7
/** 20-rule (kompletteringsregel, IL 18 kap 17 §): restvärde minst 0 % efter
* 5 år (20 % avskrivning per år, raklinje). */
export const OVERAVSKRIVNING_20_RULE_YEARS = 5
export interface Compute30RuleInput {
/** IB bokfört värde maskiner & inventarier (12xx netto). */
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
}
export interface Compute20RuleInput {
/** Anskaffningskostnad per anskaffningsår, från (innevarande år − 4) till
* innevarande år. Index 0 = innevarande år. */
acquisitionCostByYearOffset: [number, number, number, number, number]
}
/**
* 30-regeln: skattemässigt lägsta restvärde = 70 % × (IB + årets anskaffningar − årets avyttringar).
* Maximalt skattemässigt avskrivningsbart belopp = avskrivningsunderlag − restvärde.
*/
export function compute30Rule(input: Compute30RuleInput): {
base: number
minimumResidual: number
maxAllowedAccumulated: number
} {
const base = input.openingBookValue + input.additions - input.disposals
const minimumResidual = Math.round(base * OVERAVSKRIVNING_30_RULE * 100) / 100
return {
base,
minimumResidual,
maxAllowedAccumulated: Math.round((base - minimumResidual) * 100) / 100,
}
}
/**
* 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).
*
* Returns the allowed depreciation if 20-rule is used as the sole basis,
* computed against ALL still-active 20-rule cohorts.
*/
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 %).
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
}
return { minimumResidual: Math.round(residual * 100) / 100 }
}
/**
* Pick the rule that gives the lowest restvärde (highest allowed deduction)
* per IL 18 kap 13–17 §§ — företaget får välja den fördelaktigaste regeln
* varje år.
*/
export function pickLowerResidual(
rule30: ReturnType<typeof compute30Rule>,
rule20: ReturnType<typeof compute20Rule>,
): { residual: number; rule: '30-regeln' | '20-regeln' } {
if (rule20.minimumResidual < rule30.minimumResidual) {
return { residual: rule20.minimumResidual, rule: '20-regeln' }
}
return { residual: rule30.minimumResidual, rule: '30-regeln' }
}
/**
* BAS account pairs for överavskrivningar by asset category. The 88xx
* "förändring" account always pairs with its matching 21xx "ackumulerade"
* account so the verifikation stays balanced and flows into the right INK2R
* field via the SRU mapping.
*
* - 8853 / 2153 — maskiner & inventarier (IL 18 kap, dominant K2 case)
* - 8852 / 2152 — byggnader (IL 19 kap, rare in SME)
* - 8851 / 2151 — immateriella tillgångar (IL 16 kap, even rarer)
* - 8850 / 2150 — samlingskonto för grupp
*/
export const OVERAVSKRIVNING_ACCOUNTS = {
machinery_equipment: { expense: '8853', accumulated: '2153' },
building: { expense: '8852', accumulated: '2152' },
immaterial: { expense: '8851', accumulated: '2151' },
group: { expense: '8850', accumulated: '2150' },
} as const
export type OveravskrivningCategory = keyof typeof OVERAVSKRIVNING_ACCOUNTS
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
* for buildings or immateriella tillgångar when relevant. */
category?: OveravskrivningCategory
/** Visa beräkningens bakgrund i UI:t. Helt fritt format. */
computation?: Record<string, unknown>
}
/**
* Propose an överavskrivningar entry. Caller computes the desired delta
* using compute30Rule / compute20Rule (or enters a manual amount) and the
* service emits the verifikation. Uses BAS 8853 / 2153 (maskiner & inventarier),
* which covers the vast majority of cases for small AB.
*
* For Phase 2 this is a thin wrapper. Phase 3 (anläggningsregister) will
* compute the delta automatically from per-asset planenlig vs skattemässig
* schedules and pre-fill `additionalAmount`.
*/
const CATEGORY_LABELS: Record<OveravskrivningCategory, string> = {
machinery_equipment: 'maskiner & inventarier',
building: 'byggnader',
immaterial: 'immateriella tillgångar',
group: 'samlingskonto',
}
export function proposeOveravskrivningar(input: OveravskrivningarInput): ProposedDisposition | null {
const amount = Math.round(input.additionalAmount)
if (amount === 0) return null
const category = input.category ?? 'machinery_equipment'
const accounts = OVERAVSKRIVNING_ACCOUNTS[category]
const categoryLabel = CATEGORY_LABELS[category]
if (amount > 0) {
return {
kind: 'overavskrivningar',
label: `Ökning av överavskrivningar (${categoryLabel})`,
description: `Debet ${accounts.expense}, kredit ${accounts.accumulated}. Bokför skattemässig avskrivning utöver planenlig.`,
amount,
lines: [
{
account_number: accounts.expense,
debit_amount: amount,
credit_amount: 0,
line_description: 'Förändring av överavskrivningar',
},
{
account_number: accounts.accumulated,
debit_amount: 0,
credit_amount: amount,
line_description: 'Ackumulerade överavskrivningar',
},
],
warnings: [],
computation: input.computation,
}
}
// Negative = upplösning av tidigare överavskrivning
const absAmount = Math.abs(amount)
return {
kind: 'overavskrivningar',
label: `Upplösning av överavskrivningar (${categoryLabel})`,
description: `Debet ${accounts.accumulated}, kredit ${accounts.expense}. Återför tidigare gjord överavskrivning.`,
amount: absAmount,
lines: [
{
account_number: accounts.accumulated,
debit_amount: absAmount,
credit_amount: 0,
line_description: 'Upplösning ackumulerade överavskrivningar',
},
{
account_number: accounts.expense,
debit_amount: 0,
credit_amount: absAmount,
line_description: 'Förändring av överavskrivningar',
},
],
warnings: ['Negativ förändring återför tidigare överavskrivning och ökar skattepliktigt resultat.'],
computation: input.computation,
}
}
@@ -0,0 +1,278 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ProposedDisposition } from '../types'
/** Maximum periodiseringsfond avsättning for aktiebolag: 25 % of skattemässigt
* resultat före avsättning. Enskild firma uses 30 % but is handled in NE/INK1
* rather than booked. */
export const PFOND_AB_RATE = 0.25
export const PFOND_AB_RATE_PCT = '25 %'
/** Mandatory holding period — a fond avsatt år N must be återförd no later
* than räkenskapsår N+6 (IL 30 kap 7 §). */
export const PFOND_MAX_HOLD_YEARS = 6
/**
* BAS account convention: account = '212' + (fiscalYear % 10). 2020 → '2120',
* 2025 → '2125'. The collision year 2019/2029 maps to '2129' per the BAS
* 2020 seed; if a company has fonder in both years on the same account, the
* service surfaces a warning so the user can split the balance manually.
*/
export function getPeriodiseringsfondCohortAccount(fiscalYear: number): string {
if (fiscalYear === 2019) return '2129'
return '212' + (fiscalYear % 10).toString()
}
export interface ExistingFond {
/** BAS account number for the cohort (e.g. '2120'). */
account_number: string
/** Cohort year derived from account naming convention (e.g. 2020 for 2120). */
cohort_year: number
/** Current credit balance (positive = liability balance). */
balance: number
/** True if the fond must be returned this year (cohort_year + 6 ≤ closing_year). */
must_return_this_year: boolean
}
export interface PfondAvsattningInput {
/** Skattemässigt resultat före avsättning. 25 % cap is applied to this. */
skattemassigtResultatBeforeAvsattning: number
/** Amount the user wants to set aside. Defaults to the maximum (25 %). */
desiredAmount?: number
/** Closing year of the fiscal period (e.g. 2025 for FY ending 2025-12-31).
* Determines which cohort account to use. */
fiscalYear: number
}
export interface PfondAvsattningComputation {
rate: number
maxAmount: number
desiredAmount: number
actualAmount: number
cohortAccount: string
cohortYear: number
cappedToMax: boolean
}
/**
* Propose a periodiseringsfond avsättning. Caps the user's desired amount
* to 25 % of skattemässigt resultat före avsättning (rounded down to whole
* krona). Returns null when no positive avsättning would result (loss year
* or zero desired).
*/
export function proposeAvsattning(input: PfondAvsattningInput): ProposedDisposition | null {
const base = Math.max(0, Math.floor(input.skattemassigtResultatBeforeAvsattning))
const maxAmount = Math.floor(base * PFOND_AB_RATE)
const desiredAmount = Math.max(0, Math.floor(input.desiredAmount ?? maxAmount))
const actualAmount = Math.min(desiredAmount, maxAmount)
const cohortAccount = getPeriodiseringsfondCohortAccount(input.fiscalYear)
const cappedToMax = desiredAmount > maxAmount
if (actualAmount === 0) {
return null
}
const computation: PfondAvsattningComputation = {
rate: PFOND_AB_RATE,
maxAmount,
desiredAmount,
actualAmount,
cohortAccount,
cohortYear: input.fiscalYear,
cappedToMax,
}
const warnings: string[] = []
if (cappedToMax) {
warnings.push(
`Begärt belopp (${desiredAmount} kr) översteg ${PFOND_AB_RATE_PCT}-taket. Avsättningen begränsades till ${maxAmount} kr.`,
)
}
return {
kind: 'periodiseringsfond_avsattning',
label: `Avsättning till periodiseringsfond ${input.fiscalYear}`,
description: `Debet 8811, kredit ${cohortAccount}. Max ${PFOND_AB_RATE_PCT} av skattemässigt resultat.`,
amount: actualAmount,
lines: [
{
account_number: '8811',
debit_amount: actualAmount,
credit_amount: 0,
line_description: `Avsättning periodiseringsfond ${input.fiscalYear}`,
},
{
account_number: cohortAccount,
debit_amount: 0,
credit_amount: actualAmount,
line_description: `Periodiseringsfond ${input.fiscalYear}`,
},
],
warnings,
computation: computation as unknown as Record<string, unknown>,
}
}
/**
* List existing periodiseringsfonder by querying the account balance of every
* 2110–2199 account as of the closing date of the fiscal period. Marks any
* fond whose cohort_year + 6 ≤ closing_year as `must_return_this_year`.
*
* Uses the trial-balance pattern: sum debit/credit on each 21xx account from
* inception through the closing date. Result is positive when the credit
* balance exceeds debits (the normal state of a liability account).
*/
export async function listExistingPeriodiseringsfonder(
supabase: SupabaseClient,
companyId: string,
closingDate: string,
): Promise<ExistingFond[]> {
const closingYear = parseInt(closingDate.slice(0, 4), 10)
if (Number.isNaN(closingYear)) {
throw new Error(`Invalid closing date: ${closingDate}`)
}
// Sum debit/credit per 21xx account up to and including the closing date.
// Use the journal_entry_lines table directly — RLS scopes to the company.
const { data, error } = await supabase
.from('journal_entry_lines')
.select(
'account_number, debit_amount, credit_amount, journal_entries!inner(company_id, entry_date, status)',
)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.status', 'posted')
.lte('journal_entries.entry_date', closingDate)
.gte('account_number', '2110')
.lte('account_number', '2199')
if (error) {
throw new Error(`Failed to fetch periodiseringsfond balances: ${error.message}`)
}
type Row = { account_number: string; debit_amount: number | string | null; credit_amount: number | string | null }
const byAccount = new Map<string, number>()
for (const row of (data ?? []) as Row[]) {
const balance =
(Number(row.credit_amount) || 0) - (Number(row.debit_amount) || 0)
byAccount.set(row.account_number, (byAccount.get(row.account_number) ?? 0) + balance)
}
const fonder: ExistingFond[] = []
for (const [accountNumber, balance] of byAccount) {
if (Math.abs(balance) < 0.005) continue
const cohortYear = cohortYearFromAccount(accountNumber)
if (cohortYear === null) continue
fonder.push({
account_number: accountNumber,
cohort_year: cohortYear,
balance: Math.round(balance * 100) / 100,
must_return_this_year: cohortYear + PFOND_MAX_HOLD_YEARS <= closingYear,
})
}
fonder.sort((a, b) => a.cohort_year - b.cohort_year)
return fonder
}
/**
* Derive the cohort year from a BAS account number. Returns null for accounts
* that don't follow the '212X' convention (e.g. 2110 = grouping account, no
* specific cohort).
*/
function cohortYearFromAccount(accountNumber: string): number | null {
if (!/^212\d$/.test(accountNumber)) return null
const lastDigit = parseInt(accountNumber.slice(-1), 10)
// BAS 2020: 2129 represents 2019 by convention; 2128 = 2028.
if (lastDigit === 9) return 2019
// For 0–8, the cohort year is in the 2020s. As fiscal years extend past
// 2029 the convention will recycle (2120 might mean 2030 then); cap at
// 2020-decade interpretation for now and surface ambiguity in a warning.
return 2020 + lastDigit
}
export interface PfondAteforingProposal {
/** One proposal per individual fond being returned. The wizard renders these
* as separate cards; mandatory ones (must_return_this_year) cannot be skipped. */
proposals: ProposedDisposition[]
/** Total schablonintäkt computed on the OPENING balance of all 21xx accounts.
* This is NOT booked — it goes into INK2 as a manual adjustment to taxable
* result. Caller (bolagsskatt-calculator) reads this to add to taxable result. */
schablonintaktAmount: number
}
/**
* Propose periodiseringsfond reversals. Forces reversal of any fond reaching
* its 6-year limit; offers optional reversal of newer fonder. Also computes
* the schablonintäkt on the opening balance of all 21xx accounts (per IL 30
* kap 6a §) — caller adds this to taxable result when computing bolagsskatt.
*
* @param schablonintaktRate Statslåneräntan 30 nov året före, plus 1 pe, min
* 0.5 %. For income year 2025: ~3.0 %. Caller passes this in because the
* rate changes annually and is sourced from Riksbanken.
*/
export function proposeAteforing(
existingFonder: ExistingFond[],
options: {
/** Map from account_number to desired return amount. Omit entries the
* user does not want to return (mandatory ones are returned regardless). */
returns?: Record<string, number>
/** Schablonintäkt rate as a decimal (0.03 for 3 %). Applied to opening
* balance of every 21xx account. */
schablonintaktRate: number
},
): PfondAteforingProposal {
const proposals: ProposedDisposition[] = []
let schablonintaktAmount = 0
for (const fond of existingFonder) {
schablonintaktAmount += fond.balance * options.schablonintaktRate
const desiredReturn = options.returns?.[fond.account_number] ?? 0
const isMandatory = fond.must_return_this_year
const returnAmount = isMandatory
? fond.balance // forced full reversal
: Math.min(Math.max(0, Math.floor(desiredReturn)), fond.balance)
if (returnAmount === 0) continue
const warnings: string[] = []
if (isMandatory) {
warnings.push(
`Periodiseringsfond ${fond.cohort_year} har nått 6-årsgränsen och måste återföras.`,
)
}
proposals.push({
kind: 'periodiseringsfond_ateforing',
label: `Återföring periodiseringsfond ${fond.cohort_year}`,
description: `Debet ${fond.account_number}, kredit 8819.`,
amount: returnAmount,
lines: [
{
account_number: fond.account_number,
debit_amount: returnAmount,
credit_amount: 0,
line_description: `Återföring periodiseringsfond ${fond.cohort_year}`,
},
{
account_number: '8819',
debit_amount: 0,
credit_amount: returnAmount,
line_description: `Återföring periodiseringsfond ${fond.cohort_year}`,
},
],
warnings,
computation: {
cohort_year: fond.cohort_year,
opening_balance: fond.balance,
return_amount: returnAmount,
was_mandatory: isMandatory,
},
required: isMandatory,
})
}
return {
proposals,
schablonintaktAmount: Math.round(schablonintaktAmount),
}
}
@@ -0,0 +1,131 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import type { ProposedDisposition } from '../types'
/** Bolagsskatt rate. 20.6 % since 2021 (gäller räkenskapsår påbörjat efter 31 dec 2020). */
export const BOLAGSSKATT_RATE = 0.206
export interface BolagsskattInput {
/** Manual adjustments to taxable result that the calculator can't derive.
* Each is a SEK amount that ADDS to taxable result (so e.g. non-deductible
* representation costs are positive; non-taxable dividend income is negative). */
manualAdjustments?: {
/** e.g. ej avdragsgilla kostnader: representation > schablon, böter, gåvor. */
nonDeductibleExpenses?: number
/** e.g. skattefria intäkter: näringsbetingad utdelning. */
nonTaxableIncome?: number
/** Schablonintäkt on periodiseringsfond opening balance (statslåneräntan
* × ingående saldo). Computed by periodiseringsfond-service so callers
* can pass it through. */
schablonintaktPeriodiseringsfond?: number
/** Other adjustments — free-form. */
other?: number
}
}
export interface BolagsskattComputation {
/** Net result from the income statement (already includes any class 88xx
* bokslutsdispositioner that the user posted before reaching this step). */
resultBeforeTax: number
nonDeductibleExpenses: number
nonTaxableIncome: number
schablonintaktPeriodiseringsfond: number
otherAdjustments: number
taxableResult: number
/** Taxable result before tax — equals max(taxableResult, 0). */
taxableResultClamped: number
taxRate: number
taxAmount: number
}
/**
* Compute bolagsskatt 20.6 % on the company's taxable result.
*
* Reads income-statement result before tax and adds the manual adjustments
* the user provided (non-deductible expenses, schablonintäkt, etc.). The
* resulting taxable result is rounded down to nearest whole krona before
* applying the tax rate, per SFL 22 kap 1 §.
*
* If the period shows a loss, no tax is proposed — Swedish AB accumulate
* inrullat underskott for future offset, but that bookkeeping is handled
* separately in NE/INK2 rather than as a current-year provision.
*/
export async function calculateBolagsskatt(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
input: BolagsskattInput = {},
): Promise<ProposedDisposition | null> {
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
const resultBeforeTax = incomeStatement.net_result
const adjustments = input.manualAdjustments ?? {}
const nonDeductibleExpenses = adjustments.nonDeductibleExpenses ?? 0
const nonTaxableIncome = adjustments.nonTaxableIncome ?? 0
const schablonintaktPeriodiseringsfond = adjustments.schablonintaktPeriodiseringsfond ?? 0
const otherAdjustments = adjustments.other ?? 0
const taxableResult =
resultBeforeTax +
nonDeductibleExpenses -
nonTaxableIncome +
schablonintaktPeriodiseringsfond +
otherAdjustments
// Truncate to whole krona before applying rate. Negative taxable result =
// no tax provision (handled as inrullat underskott in INK2, not here).
const taxableResultClamped = Math.max(0, Math.floor(taxableResult))
const taxAmount = Math.round(taxableResultClamped * BOLAGSSKATT_RATE)
const computation: BolagsskattComputation = {
resultBeforeTax,
nonDeductibleExpenses,
nonTaxableIncome,
schablonintaktPeriodiseringsfond,
otherAdjustments,
taxableResult,
taxableResultClamped,
taxRate: BOLAGSSKATT_RATE,
taxAmount,
}
if (taxAmount === 0) {
// No tax proposal for loss-year — but expose computation so the UI can show
// why nothing was booked.
return {
kind: 'bolagsskatt',
label: 'Bolagsskatt 20,6 %',
description:
taxableResult <= 0
? 'Ingen skatt — året visar förlust eller noll resultat. Underskottet rullas in i nästa år (hanteras i INK2).'
: 'Skattemässigt resultat blev noll efter justeringar. Ingen skatt att boka.',
amount: 0,
lines: [],
warnings: [],
computation: computation as unknown as Record<string, unknown>,
}
}
return {
kind: 'bolagsskatt',
label: 'Bolagsskatt 20,6 %',
description: `Skatt på årets skattemässiga resultat. Debet 8910, kredit 2512.`,
amount: taxAmount,
lines: [
{
account_number: '8910',
debit_amount: taxAmount,
credit_amount: 0,
line_description: `Bolagsskatt 20,6 % på ${taxableResultClamped} kr`,
},
{
account_number: '2512',
debit_amount: 0,
credit_amount: taxAmount,
line_description: 'Beräknad inkomstskatt',
},
],
warnings: [],
computation: computation as unknown as Record<string, unknown>,
}
}
@@ -0,0 +1,97 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import type { ProposedDisposition } from '../types'
/** Särskild löneskatt på pensionskostnader (SLP). 24.26 % per SLF 1991:687. */
export const SLP_RATE = 0.2426
export interface SlpComputation {
/** Total pension cost during the period — sum of posted debits on accounts
* 7410–7419 (pensionsförsäkringspremier, individuella pensioner, etc.). */
pensionCostsBooked: number
/** Optional manual adjustment — e.g. avsättning till pensionsskuld on 2210
* bokad under perioden som inte ligger på 7410–7419 men ska SLP-belastas. */
manualAdjustment: number
/** Base for SLP = pensionCostsBooked + manualAdjustment. */
base: number
rate: number
slpAmount: number
}
/**
* Compute särskild löneskatt på pensionskostnader.
*
* SLP gäller arbetsgivares kostnader för avtalspension samt pensionsavsättningar
* (men inte allmän pension som finansieras av arbetsgivaravgifterna). Räknas
* på 7410-7419 (tjänstepensionspremier) och avsättningar till pensionsskuld.
*
* Caller can supply `manualAdjustment` to include pensionsavsättningar made on
* 2210 (avsättning för pensioner) that aren't reflected in 7410-7419 — common
* when companies book direct to the avsättningskonto rather than via a cost
* account.
*/
export async function calculateSarskildLoneskatt(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options: { manualAdjustment?: number } = {},
): Promise<ProposedDisposition | null> {
const { data, error } = await supabase
.from('journal_entry_lines')
.select(
'account_number, debit_amount, credit_amount, journal_entries!inner(company_id, fiscal_period_id, status)',
)
.eq('journal_entries.company_id', companyId)
.eq('journal_entries.fiscal_period_id', fiscalPeriodId)
.eq('journal_entries.status', 'posted')
.gte('account_number', '7410')
.lte('account_number', '7419')
if (error) {
throw new Error(`Failed to fetch pension costs: ${error.message}`)
}
type Row = { debit_amount: number | string | null; credit_amount: number | string | null }
const pensionCostsBooked = ((data ?? []) as Row[]).reduce((sum, row) => {
// Cost account — normal balance is debit, so net = debit − credit.
return sum + ((Number(row.debit_amount) || 0) - (Number(row.credit_amount) || 0))
}, 0)
const manualAdjustment = options.manualAdjustment ?? 0
const base = Math.max(0, pensionCostsBooked + manualAdjustment)
const slpAmount = Math.round(base * SLP_RATE)
const computation: SlpComputation = {
pensionCostsBooked: Math.round(pensionCostsBooked * 100) / 100,
manualAdjustment,
base,
rate: SLP_RATE,
slpAmount,
}
if (slpAmount === 0) {
return null
}
return {
kind: 'sarskild_loneskatt',
label: 'Särskild löneskatt på pensionskostnader (24,26 %)',
description: 'Debet 7533, kredit 2514.',
amount: slpAmount,
lines: [
{
account_number: '7533',
debit_amount: slpAmount,
credit_amount: 0,
line_description: `SLP 24,26 % på ${base} kr pensionskostnader`,
},
{
account_number: '2514',
debit_amount: 0,
credit_amount: slpAmount,
line_description: 'Beräknad särskild löneskatt på pensionskostnader',
},
],
warnings: [],
computation: computation as unknown as Record<string, unknown>,
}
}
+54
View File
@@ -0,0 +1,54 @@
import type { CreateJournalEntryLineInput } from '@/types'
export type DispositionKind =
| 'bolagsskatt'
| 'periodiseringsfond_avsattning'
| 'periodiseringsfond_ateforing'
| 'overavskrivningar'
| 'sarskild_loneskatt'
/**
* Common shape every bokslut-disposition calculator returns. The wizard renders
* one card per proposal; the API endpoint accepts a list of `{ kind, accept,
* overrideAmount? }` from the user and posts the matching entries.
*/
export interface ProposedDisposition {
kind: DispositionKind
/** Short Swedish label for UI cards (e.g. "Bolagsskatt 20,6 %"). */
label: string
/** One-sentence Swedish explanation of what the entry does. */
description: string
/** SEK amount displayed in the card header. Always a positive number. */
amount: 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,
* rate cap reached). Not blockers. */
warnings: string[]
/** Calculator-specific breakdown the UI can render in an "Visa beräkning"
* panel. Free-form so each calculator can show its own details. */
computation?: Record<string, unknown>
/** True if this proposal cannot be skipped — e.g. periodiseringsfond from
* tax year N-6 that must be reversed. UI disables the skip control. */
required?: boolean
}
/**
* Snapshot of all proposed dispositions for a fiscal period, returned by the
* dispositions API. Order is the suggested user-flow order: p-fond återföring
* (mandatory) → överavskrivningar → p-fond avsättning → SLP → bolagsskatt.
* The wizard renders them in that order so each step's effect is visible to
* the next one (bolagsskatt comes last because it depends on everything else).
*/
export interface DispositionsProposal {
entityType: 'aktiebolag' | 'enskild_firma' | 'handelsbolag' | 'kommanditbolag' | 'ekonomisk_forening'
fiscalPeriod: {
id: string
name: string
period_start: string
period_end: string
}
/** Result before any new dispositions, from the income statement (positive = profit). */
netResultBefore: number
proposals: ProposedDisposition[]
}
@@ -208,6 +208,34 @@ describe('INK2R Account Mappings', () => {
expect(findSRUCodeForAccount('8900')).toBe('7528')
expect(findSRUCodeForAccount('8910')).toBe('7528')
})
describe('bokslutsdispositioner (BAS 2020-aligned)', () => {
// These mappings were corrected when the Phase 2 bokslut calculators
// landed — the previous ranges (8810/8830/8840) targeted accounts that
// BAS doesn't seed. Locking the corrected mapping prevents regression.
it('8811 -> 7525 (Avsättning till periodiseringsfond)', () => {
expect(findSRUCodeForAccount('8811')).toBe('7525')
})
it('8819 -> 7420 (Återföring av periodiseringsfond)', () => {
expect(findSRUCodeForAccount('8819')).toBe('7420')
})
it('8820 -> 7419 (Mottagna koncernbidrag)', () => {
expect(findSRUCodeForAccount('8820')).toBe('7419')
})
it('8830 -> 7524 (Lämnade koncernbidrag)', () => {
expect(findSRUCodeForAccount('8830')).toBe('7524')
})
it('8850-8859 -> 7421 (Förändring av överavskrivningar)', () => {
expect(findSRUCodeForAccount('8850')).toBe('7421')
expect(findSRUCodeForAccount('8853')).toBe('7421') // M&I sub-cat
expect(findSRUCodeForAccount('8859')).toBe('7421')
})
it('8840 + 8860-8899 -> 7422 (Övriga bokslutsdispositioner)', () => {
expect(findSRUCodeForAccount('8840')).toBe('7422')
expect(findSRUCodeForAccount('8860')).toBe('7422')
expect(findSRUCodeForAccount('8899')).toBe('7422')
})
})
})
describe('no overlap between mappings', () => {
+25 -17
View File
@@ -28,8 +28,9 @@ import {
* bas.se/kontoplaner/sru/ and Skatteverket field code spec.
*
* INK2R contains the full balance sheet + income statement.
* INK2S auto-derives basic fields (result + tax → taxable result).
* Manual tax adjustments (periodiseringsfonder etc.) are handled by the accountant.
* INK2S auto-derives basic fields (result + tax → taxable result), as well as
* periodiseringsfond and överavskrivningar when those have been posted via the
* bokslut-dispositions calculators in lib/bokslut/.
*/
/**
@@ -561,12 +562,21 @@ export const INK2R_ACCOUNT_MAPPINGS: INK2AccountMapping[] = [
normalBalance: 'debit',
accountRanges: [{ start: '8500', end: '8599' }],
},
// Bokslutsdispositioner — account numbers per BAS 2020 (verified against
// lib/bookkeeping/bas-data/class-8-financial.ts).
{
sruCode: '7524',
description: 'Lämnade koncernbidrag',
sruCode: '7525',
description: 'Avsättning till periodiseringsfond',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '8810', end: '8810' }],
accountRanges: [{ start: '8811', end: '8811' }],
},
{
sruCode: '7420',
description: 'Återföring av periodiseringsfond',
section: 'income_statement',
normalBalance: 'credit',
accountRanges: [{ start: '8819', end: '8819' }],
},
{
sruCode: '7419',
@@ -576,32 +586,30 @@ export const INK2R_ACCOUNT_MAPPINGS: INK2AccountMapping[] = [
accountRanges: [{ start: '8820', end: '8820' }],
},
{
sruCode: '7420',
description: 'Återföring av periodiseringsfond',
section: 'income_statement',
normalBalance: 'credit',
accountRanges: [{ start: '8830', end: '8830' }],
},
{
sruCode: '7525',
description: 'Avsättning till periodiseringsfond',
sruCode: '7524',
description: 'Lämnade koncernbidrag',
section: 'income_statement',
normalBalance: 'debit',
accountRanges: [{ start: '8840', end: '8840' }],
accountRanges: [{ start: '8830', end: '8830' }],
},
{
sruCode: '7421',
description: 'Förändring av överavskrivningar',
section: 'income_statement',
normalBalance: 'net',
accountRanges: [{ start: '8850', end: '8850' }],
// 8850 = grupp, 8851-8853 = per kategori (immateriella, byggnader, M&I)
accountRanges: [{ start: '8850', end: '8859' }],
},
{
sruCode: '7422',
description: 'Övriga bokslutsdispositioner',
section: 'income_statement',
normalBalance: 'net',
accountRanges: [{ start: '8860', end: '8899' }],
// 8840 = Lämnade gottgörelser, 8860-8899 = övriga
accountRanges: [
{ start: '8840', end: '8840' },
{ start: '8860', end: '8899' },
],
},
{
sruCode: '7528',
@@ -0,0 +1,198 @@
-- Migration: assets + depreciation_schedules — fixed asset register
--
-- Why this exists: Swedish year-end closing (BFNAR 2016:10 K2, BFNAR 2012:1 K3)
-- requires the company to record planenliga avskrivningar on every fixed
-- asset (anläggningstillgång) each fiscal year. Without an asset register,
-- depreciation must be entered manually as journal entries — accountants
-- get this right, but Gnubok's DIY users typically forget or mis-account.
--
-- The bokslut wizard (lib/bokslut/) wires this register into Phase 3 so
-- depreciation entries are proposed automatically. The K3 component
-- depreciation feature (BFNAR 2012:1 ch.17.4) is reserved via the
-- `k3_components` JSONB column but unused in K2 / Phase 3.
--
-- Disposal handling: gain (3973) / loss (7973) booked against the asset's
-- accumulated depreciation and acquisition cost. Disposal does NOT delete
-- the asset row — BFL retention (7 years) requires the audit trail.
-- ============================================================
-- assets
-- ============================================================
CREATE TABLE public.assets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
name TEXT NOT NULL CHECK (length(name) > 0),
-- Category groups by BAS account class for default journal accounts and
-- för K3 component depreciation hints. Free-text instead of enum so new
-- categories don't require migrations.
category TEXT NOT NULL CHECK (category IN (
'immaterial', -- 1010-1099 immateriella
'building', -- 1110-1199 byggnader & mark
'land_improvement', -- 1150-1159 markanläggningar
'machinery', -- 1210-1219 maskiner
'equipment', -- 1220-1229 inventarier
'vehicle', -- 1240-1249 bilar och transportmedel
'computer', -- 1250-1259 datorer
'other_tangible' -- 1280-1299 övriga materiella
)),
acquisition_date DATE NOT NULL,
acquisition_cost NUMERIC(15, 2) NOT NULL CHECK (acquisition_cost >= 0),
salvage_value NUMERIC(15, 2) NOT NULL DEFAULT 0 CHECK (salvage_value >= 0),
-- Useful life in months. K2 allows schablon 5 years (60 months) för
-- inventarier. K3 requires individual assessment.
useful_life_months INTEGER NOT NULL CHECK (useful_life_months > 0),
-- Depreciation method. 'linear' is planenlig raklinje (most common).
-- The declining_balance_* methods are for skattemässig avskrivning and
-- only used directly when book and tax depreciation are equal (K2 may);
-- otherwise överavskrivningar handles the gap (see Phase 2 service).
depreciation_method TEXT NOT NULL DEFAULT 'linear' CHECK (depreciation_method IN (
'linear',
'declining_balance_30',
'declining_balance_20'
)),
-- BAS account triple. asset = the 12xx anskaffningskonto, accumulated =
-- the 12x9 ackumulerade avskrivningar, expense = the 78xx avskrivningskonto.
bas_asset_account TEXT NOT NULL,
bas_accumulated_account TEXT NOT NULL,
bas_expense_account TEXT NOT NULL,
-- Disposal: filled when the asset is sold / scrapped. Once disposed the
-- row becomes read-only (see trigger below). Keep the row för audit.
disposed_at DATE,
disposed_proceeds NUMERIC(15, 2),
-- K3 component depreciation: array of { name, cost, useful_life_months,
-- method }. Sum of component costs must equal acquisition_cost; enforced
-- in application layer because PG can't sum JSONB elements in a CHECK.
-- Empty / NULL for K2 — leaves the door open för Phase 5+ K3 support
-- without another migration.
k3_components JSONB,
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- Disposal sanity: either both disposal columns set or neither.
CONSTRAINT assets_disposal_atomic CHECK (
(disposed_at IS NULL AND disposed_proceeds IS NULL)
OR (disposed_at IS NOT NULL AND disposed_proceeds IS NOT NULL)
)
);
CREATE INDEX idx_assets_company ON public.assets (company_id);
CREATE INDEX idx_assets_company_active ON public.assets (company_id) WHERE disposed_at IS NULL;
CREATE INDEX idx_assets_acquisition_date ON public.assets (company_id, acquisition_date);
ALTER TABLE public.assets ENABLE ROW LEVEL SECURITY;
CREATE POLICY "assets_select" ON public.assets
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "assets_insert" ON public.assets
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "assets_update" ON public.assets
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "assets_delete" ON public.assets
FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
CREATE TRIGGER assets_updated_at
BEFORE UPDATE ON public.assets
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
-- Read-only after disposal: once disposed_at is set, only the disposal date
-- may not be backtracked. Allow notes edits; block changes to financial
-- attributes (cost, life, method, accounts). Mirrors the journal entry
-- immutability pattern in 20240101000017_enforcement_triggers.sql.
CREATE OR REPLACE FUNCTION public.enforce_asset_post_disposal_immutability()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.disposed_at IS NOT NULL THEN
IF NEW.acquisition_cost IS DISTINCT FROM OLD.acquisition_cost
OR NEW.salvage_value IS DISTINCT FROM OLD.salvage_value
OR NEW.useful_life_months IS DISTINCT FROM OLD.useful_life_months
OR NEW.depreciation_method IS DISTINCT FROM OLD.depreciation_method
OR NEW.bas_asset_account IS DISTINCT FROM OLD.bas_asset_account
OR NEW.bas_accumulated_account IS DISTINCT FROM OLD.bas_accumulated_account
OR NEW.bas_expense_account IS DISTINCT FROM OLD.bas_expense_account
OR NEW.acquisition_date IS DISTINCT FROM OLD.acquisition_date THEN
RAISE EXCEPTION 'Cannot modify financial attributes of a disposed asset (id=%)', OLD.id
USING ERRCODE = 'check_violation';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER enforce_asset_post_disposal_immutability
BEFORE UPDATE ON public.assets
FOR EACH ROW EXECUTE FUNCTION public.enforce_asset_post_disposal_immutability();
-- ============================================================
-- depreciation_schedules
-- ============================================================
--
-- One row per (asset, fiscal_period) recording the planenlig avskrivning
-- proposal and the journal entry that materialized it. ON DELETE RESTRICT
-- on journal_entry_id ensures we cannot orphan postings — if a user needs
-- to "redo" a year's depreciation they must reverse the entry (storno)
-- which produces a new entry rather than removing the original. That's
-- BFL-compliant audit behaviour.
CREATE TABLE public.depreciation_schedules (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
asset_id UUID NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
fiscal_period_id UUID NOT NULL REFERENCES fiscal_periods(id) ON DELETE RESTRICT,
planned_depreciation NUMERIC(15, 2) NOT NULL CHECK (planned_depreciation >= 0),
-- Filled when the user commits the proposal to a journal entry. Until
-- then this row is a "draft" the wizard can re-compute on the fly.
journal_entry_id UUID REFERENCES journal_entries(id) ON DELETE RESTRICT,
posted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- One depreciation row per asset per fiscal period (a year cannot have
-- two planenliga avskrivningar för the same asset).
CONSTRAINT depreciation_schedules_unique UNIQUE (asset_id, fiscal_period_id)
);
CREATE INDEX idx_depreciation_schedules_company ON public.depreciation_schedules (company_id);
CREATE INDEX idx_depreciation_schedules_period ON public.depreciation_schedules (fiscal_period_id);
CREATE INDEX idx_depreciation_schedules_asset ON public.depreciation_schedules (asset_id);
ALTER TABLE public.depreciation_schedules ENABLE ROW LEVEL SECURITY;
CREATE POLICY "depreciation_schedules_select" ON public.depreciation_schedules
FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "depreciation_schedules_insert" ON public.depreciation_schedules
FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
CREATE POLICY "depreciation_schedules_update" ON public.depreciation_schedules
FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))
WITH CHECK (company_id IN (SELECT public.user_company_ids()));
-- DELETE is intentionally locked down: only allowed för rows without a
-- posted journal entry. Posted rows must persist för audit.
CREATE POLICY "depreciation_schedules_delete" ON public.depreciation_schedules
FOR DELETE USING (
company_id IN (SELECT public.user_company_ids())
AND journal_entry_id IS NULL
);
-- Block updates that would change the link or financial number after posting.
CREATE OR REPLACE FUNCTION public.enforce_depreciation_schedule_immutability()
RETURNS TRIGGER AS $$
BEGIN
IF OLD.journal_entry_id IS NOT NULL THEN
IF NEW.planned_depreciation IS DISTINCT FROM OLD.planned_depreciation
OR NEW.asset_id IS DISTINCT FROM OLD.asset_id
OR NEW.fiscal_period_id IS DISTINCT FROM OLD.fiscal_period_id
OR NEW.journal_entry_id IS DISTINCT FROM OLD.journal_entry_id THEN
RAISE EXCEPTION 'Cannot modify a posted depreciation schedule (id=%)', OLD.id
USING ERRCODE = 'check_violation';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER enforce_depreciation_schedule_immutability
BEFORE UPDATE ON public.depreciation_schedules
FOR EACH ROW EXECUTE FUNCTION public.enforce_depreciation_schedule_immutability();
NOTIFY pgrst, 'reload schema';
@@ -0,0 +1,19 @@
-- Adds the missing updated_at column + trigger to depreciation_schedules.
-- The previous migration (20260516120000_assets_and_depreciation) seeded the
-- table without it, which violates the project convention requiring every
-- table to have updated_at managed by update_updated_at_column(). Since rows
-- there only mutate twice (insert, then later when the post handler fills
-- in journal_entry_id + posted_at), updated_at lets audits trace the latest
-- state change without joining against journal_entries.created_at.
ALTER TABLE public.depreciation_schedules
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now();
DROP TRIGGER IF EXISTS depreciation_schedules_updated_at
ON public.depreciation_schedules;
CREATE TRIGGER depreciation_schedules_updated_at
BEFORE UPDATE ON public.depreciation_schedules
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
NOTIFY pgrst, 'reload schema';
+381
View File
@@ -0,0 +1,381 @@
/**
* pg-real tests for the assets and depreciation_schedules tables introduced
* in 20260516120000_assets_and_depreciation.sql.
*
* Verifies:
* - enforce_asset_post_disposal_immutability blocks financial-field edits
* after disposal, but lets notes/name through.
* - assets_disposal_atomic CHECK requires both disposed_at and disposed_proceeds.
* - enforce_depreciation_schedule_immutability blocks edits after a journal
* entry has been linked.
* - The depreciation_schedules delete RLS policy refuses to delete rows
* that have a journal_entry_id set (posted) but allows it before posting.
* - RLS scopes both tables to user_company_ids() — a user in company A
* cannot see / edit company B's rows.
*/
import { describe, it, expect, beforeAll } from 'vitest'
import { randomUUID } from 'node:crypto'
import { getPool, withUserContext } from './setup'
import {
insertAuthUser,
insertCompany,
insertCompanyMember,
insertFiscalPeriod,
} from './fixtures'
async function insertAsset(params: {
userId: string
companyId: string
disposedAt?: string | null
disposedProceeds?: number | null
category?: string
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.assets
(id, user_id, company_id, name, category, acquisition_date, acquisition_cost,
useful_life_months, bas_asset_account, bas_accumulated_account, bas_expense_account,
disposed_at, disposed_proceeds)
VALUES ($1, $2, $3, 'Test Asset', $4, '2025-01-01', 60000, 60,
'1220', '1229', '7832', $5, $6)`,
[
id,
params.userId,
params.companyId,
params.category ?? 'equipment',
params.disposedAt ?? null,
params.disposedProceeds ?? null,
],
)
return id
}
async function insertDepreciationSchedule(params: {
userId: string
companyId: string
assetId: string
fiscalPeriodId: string
journalEntryId?: string | null
amount?: number
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.depreciation_schedules
(id, user_id, company_id, asset_id, fiscal_period_id,
planned_depreciation, journal_entry_id, posted_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
id,
params.userId,
params.companyId,
params.assetId,
params.fiscalPeriodId,
params.amount ?? 12_000,
params.journalEntryId ?? null,
params.journalEntryId ? new Date().toISOString() : null,
],
)
return id
}
// Insert a real posted journal entry we can FK-link a depreciation_schedule
// to (the FK has ON DELETE RESTRICT so we need a genuine row).
async function insertPostedEntry(params: {
userId: string
companyId: string
fiscalPeriodId: string
voucherNumber?: number
}): Promise<string> {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.journal_entries
(id, user_id, company_id, fiscal_period_id, voucher_number, voucher_series,
entry_date, description, source_type, status)
VALUES ($1, $2, $3, $4, $5, 'A', '2025-12-31', 'Test', 'year_end', 'posted')`,
[id, params.userId, params.companyId, params.fiscalPeriodId, params.voucherNumber ?? 1],
)
await getPool().query(
`INSERT INTO public.journal_entry_lines
(journal_entry_id, account_number, debit_amount, credit_amount)
VALUES ($1, '7832', 12000, 0), ($1, '1229', 0, 12000)`,
[id],
)
return id
}
let companyA: { userId: string; companyId: string; fiscalPeriodId: string }
let companyB: { userId: string; companyId: string; fiscalPeriodId: string }
beforeAll(async () => {
for (const slot of ['A', 'B'] as const) {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
await insertCompanyMember({ companyId, userId, role: 'owner' })
const fiscalPeriodId = await insertFiscalPeriod({
userId,
companyId,
periodStart: '2025-01-01',
periodEnd: '2025-12-31',
})
if (slot === 'A') companyA = { userId, companyId, fiscalPeriodId }
else companyB = { userId, companyId, fiscalPeriodId }
}
})
describe('assets table — immutability after disposal', () => {
it('allows changing notes/name on a disposed asset', async () => {
const assetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
disposedAt: '2025-12-31',
disposedProceeds: 5_000,
})
await getPool().query(
`UPDATE public.assets SET notes = 'updated', name = 'renamed' WHERE id = $1`,
[assetId],
)
const { rows } = await getPool().query(
`SELECT notes, name FROM public.assets WHERE id = $1`,
[assetId],
)
expect(rows[0]?.notes).toBe('updated')
expect(rows[0]?.name).toBe('renamed')
})
it('blocks acquisition_cost edit on a disposed asset', async () => {
const assetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
disposedAt: '2025-12-31',
disposedProceeds: 5_000,
})
await expect(
getPool().query(
`UPDATE public.assets SET acquisition_cost = 99999 WHERE id = $1`,
[assetId],
),
).rejects.toThrow(/disposed asset/i)
})
it('blocks useful_life_months and depreciation_method edits on a disposed asset', async () => {
const assetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
disposedAt: '2025-12-31',
disposedProceeds: 5_000,
})
await expect(
getPool().query(
`UPDATE public.assets SET useful_life_months = 120 WHERE id = $1`,
[assetId],
),
).rejects.toThrow(/disposed asset/i)
await expect(
getPool().query(
`UPDATE public.assets SET depreciation_method = 'declining_balance_30' WHERE id = $1`,
[assetId],
),
).rejects.toThrow(/disposed asset/i)
})
it('blocks BAS account edits on a disposed asset', async () => {
const assetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
disposedAt: '2025-12-31',
disposedProceeds: 5_000,
})
await expect(
getPool().query(
`UPDATE public.assets SET bas_expense_account = '7831' WHERE id = $1`,
[assetId],
),
).rejects.toThrow(/disposed asset/i)
})
it('allows the same edits while not yet disposed', async () => {
const assetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
})
await getPool().query(
`UPDATE public.assets SET acquisition_cost = 70000, useful_life_months = 72 WHERE id = $1`,
[assetId],
)
const { rows } = await getPool().query(
`SELECT acquisition_cost, useful_life_months FROM public.assets WHERE id = $1`,
[assetId],
)
expect(Number(rows[0]?.acquisition_cost)).toBe(70_000)
expect(rows[0]?.useful_life_months).toBe(72)
})
it('disposal CHECK requires both disposed_at and disposed_proceeds', async () => {
await expect(
insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
disposedAt: '2025-12-31',
disposedProceeds: null,
}),
).rejects.toThrow(/assets_disposal_atomic|check constraint/i)
})
})
describe('depreciation_schedules — immutability after posting', () => {
it('blocks planned_depreciation edits once a journal entry is linked', async () => {
const assetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
})
const entryId = await insertPostedEntry({
userId: companyA.userId,
companyId: companyA.companyId,
fiscalPeriodId: companyA.fiscalPeriodId,
voucherNumber: 100,
})
const scheduleId = await insertDepreciationSchedule({
userId: companyA.userId,
companyId: companyA.companyId,
assetId,
fiscalPeriodId: companyA.fiscalPeriodId,
journalEntryId: entryId,
})
await expect(
getPool().query(
`UPDATE public.depreciation_schedules SET planned_depreciation = 99999 WHERE id = $1`,
[scheduleId],
),
).rejects.toThrow(/posted depreciation schedule/i)
})
it('allows planned_depreciation edits BEFORE a journal entry is linked', async () => {
const assetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
})
const scheduleId = await insertDepreciationSchedule({
userId: companyA.userId,
companyId: companyA.companyId,
assetId,
fiscalPeriodId: companyA.fiscalPeriodId,
})
await getPool().query(
`UPDATE public.depreciation_schedules SET planned_depreciation = 8888 WHERE id = $1`,
[scheduleId],
)
const { rows } = await getPool().query(
`SELECT planned_depreciation FROM public.depreciation_schedules WHERE id = $1`,
[scheduleId],
)
expect(Number(rows[0]?.planned_depreciation)).toBe(8_888)
})
})
describe('depreciation_schedules — delete RLS policy', () => {
it('user can DELETE a draft schedule (no journal_entry_id)', async () => {
const assetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
})
const scheduleId = await insertDepreciationSchedule({
userId: companyA.userId,
companyId: companyA.companyId,
assetId,
fiscalPeriodId: companyA.fiscalPeriodId,
})
const deletedCount = await withUserContext(companyA.userId, async (client) => {
const result = await client.query(
`DELETE FROM public.depreciation_schedules WHERE id = $1 RETURNING id`,
[scheduleId],
)
return result.rowCount
})
expect(deletedCount).toBe(1)
})
it('user CANNOT DELETE a posted schedule (RLS policy filters it out)', async () => {
const assetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
})
const entryId = await insertPostedEntry({
userId: companyA.userId,
companyId: companyA.companyId,
fiscalPeriodId: companyA.fiscalPeriodId,
voucherNumber: 101,
})
const scheduleId = await insertDepreciationSchedule({
userId: companyA.userId,
companyId: companyA.companyId,
assetId,
fiscalPeriodId: companyA.fiscalPeriodId,
journalEntryId: entryId,
})
// RLS-filtered DELETE returns 0 affected rows rather than raising — the
// row is invisible to the DELETE statement under the authenticated role.
const deletedCount = await withUserContext(companyA.userId, async (client) => {
const result = await client.query(
`DELETE FROM public.depreciation_schedules WHERE id = $1 RETURNING id`,
[scheduleId],
)
return result.rowCount
})
expect(deletedCount).toBe(0)
// And the row still exists when checked as superuser.
const { rows } = await getPool().query(
`SELECT id FROM public.depreciation_schedules WHERE id = $1`,
[scheduleId],
)
expect(rows).toHaveLength(1)
})
})
describe('RLS — cross-company isolation', () => {
it('company A user cannot SELECT company B assets', async () => {
const bAssetId = await insertAsset({
userId: companyB.userId,
companyId: companyB.companyId,
})
const visibleToA = await withUserContext(companyA.userId, async (client) => {
const result = await client.query<{ id: string }>(
`SELECT id FROM public.assets WHERE id = $1`,
[bAssetId],
)
return result.rowCount ?? 0
})
expect(visibleToA).toBe(0)
})
it('company A user can SELECT their own assets', async () => {
const aAssetId = await insertAsset({
userId: companyA.userId,
companyId: companyA.companyId,
})
const visibleToA = await withUserContext(companyA.userId, async (client) => {
const result = await client.query<{ id: string }>(
`SELECT id FROM public.assets WHERE id = $1`,
[aAssetId],
)
return result.rowCount ?? 0
})
expect(visibleToA).toBe(1)
})
it('company A user cannot INSERT a depreciation_schedule into company B', async () => {
const bAssetId = await insertAsset({
userId: companyB.userId,
companyId: companyB.companyId,
})
await expect(
withUserContext(companyA.userId, async (client) => {
await client.query(
`INSERT INTO public.depreciation_schedules
(user_id, company_id, asset_id, fiscal_period_id, planned_depreciation)
VALUES ($1, $2, $3, $4, 1000)`,
[companyA.userId, companyB.companyId, bAssetId, companyB.fiscalPeriodId],
)
}),
).rejects.toThrow(/row-level security|new row violates/i)
})
})
+56
View File
@@ -2287,6 +2287,62 @@ export interface YearEndResult {
revaluationEntry: JournalEntry | null
}
// ============================================================
// Asset Register Types (Anläggningsregister)
// ============================================================
export type AssetCategory =
| 'immaterial'
| 'building'
| 'land_improvement'
| 'machinery'
| 'equipment'
| 'vehicle'
| 'computer'
| 'other_tangible'
export type DepreciationMethod =
| 'linear'
| 'declining_balance_30'
| 'declining_balance_20'
export interface Asset {
id: string
user_id: string
company_id: string
name: string
category: AssetCategory
acquisition_date: string
acquisition_cost: number
salvage_value: number
useful_life_months: number
depreciation_method: DepreciationMethod
bas_asset_account: string
bas_accumulated_account: string
bas_expense_account: string
disposed_at: string | null
disposed_proceeds: number | null
/** Reserved for K3 component depreciation (BFNAR 2012:1 ch.17.4). Empty
* / null for K2 — Phase 5 will fill this when K3 ships. */
k3_components: unknown | null
notes: string | null
created_at: string
updated_at: string
}
export interface DepreciationSchedule {
id: string
user_id: string
company_id: string
asset_id: string
fiscal_period_id: string
planned_depreciation: number
journal_entry_id: string | null
posted_at: string | null
created_at: string
updated_at: string
}
// ============================================================
// IB/UB Continuity Check Types (Avstämning ingående/utgående balans)
// ============================================================