diff --git a/app/(dashboard)/assets/page.tsx b/app/(dashboard)/assets/page.tsx new file mode 100644 index 00000000..26768dad --- /dev/null +++ b/app/(dashboard)/assets/page.tsx @@ -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 = { + 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(null) + const [error, setError] = useState(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 ( +
+ setDialogOpen(true)}> + Ny tillgång + + } + /> + + {assets === null && !error && ( + + + + + + + )} + + {error && ( + + {error} + + )} + + {assets !== null && assets.length === 0 && ( + setDialogOpen(true)} + /> + )} + + {assets !== null && assets.length > 0 && ( + + + + + + Namn + Kategori + Anskaffat + Anskaffningsvärde + Avskrivningstid + Status + + + + {assets.map((asset) => { + const years = Math.round(asset.useful_life_months / 12) + return ( + + {asset.name} + {CATEGORY_LABELS[asset.category]} + + {formatDate(asset.acquisition_date)} + + + {formatCurrency(Number(asset.acquisition_cost))} + + + {years} år ({asset.useful_life_months} mån) + + + {asset.disposed_at ? ( + Avyttrad + ) : ( + Aktiv + )} + + + ) + })} + +
+
+
+ )} + + +
+ ) +} diff --git a/app/(dashboard)/bookkeeping/year-end/page.tsx b/app/(dashboard)/bookkeeping/year-end/page.tsx index 0bbca857..ac69659f 100644 --- a/app/(dashboard)/bookkeeping/year-end/page.tsx +++ b/app/(dashboard)/bookkeeping/year-end/page.tsx @@ -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 = { + 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(null) + const [periodsError, setPeriodsError] = useState(null) + const [selectedPeriodId, setSelectedPeriodId] = useState( + searchParams.get('period') ?? null, + ) + + // ---- Wizard state ---- + const [step, setStep] = useState('preflight') + const [report, setReport] = useState(null) + const [reportLoading, setReportLoading] = useState(false) + const [reportError, setReportError] = useState(null) + const [preview, setPreview] = useState(null) + const [previewLoading, setPreviewLoading] = useState(false) + const [previewError, setPreviewError] = useState(null) + const [executing, setExecuting] = useState(false) + const [executeError, setExecuteError] = useState(null) + const [result, setResult] = useState(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 (
-

Årsbokslut

+

Årsbokslut

- - -
- -
-

Kommer snart

-

- Årsbokslut är under utveckling och kommer att finnas tillgängligt i en kommande version. -

-
- - Behöver du hjälp med bokslut? Kontakta oss - -
-
-
+ {periods === null && !periodsError && ( + + + + + + + )} + + {periodsError && ( + + {periodsError} + + )} + + {periods !== null && periods.length === 0 && ( + + )} + + {showWizard && periods && periods.length > 1 && step !== 'result' && ( + + + + + + + )} + + {showWizard && step !== 'result' && ( + + +
+ + Steg {currentStepIndex + 1}/{STEP_ORDER.length}: {STEP_LABELS[step]} + + {STEP_ORDER.map((s, i) => ( + + {STEP_LABELS[s]} + + ))} +
+ +
+
+ )} + + {showWizard && step === 'preflight' && ( + setStep('dispositions')} + /> + )} + + {showWizard && step === 'dispositions' && selectedPeriodId && ( + setStep('preflight')} + onContinue={goToPreview} + /> + )} + + {showWizard && step === 'preview' && ( + setStep('dispositions')} + onContinue={() => setStep('execute')} + /> + )} + + {showWizard && step === 'execute' && report && ( + setStep('preview')} + onExecute={executeYearEnd} + /> + )} + + {step === 'result' && result && }
) } diff --git a/app/api/assets/[id]/dispose/route.ts b/app/api/assets/[id]/dispose/route.ts new file mode 100644 index 00000000..9442f98e --- /dev/null +++ b/app/api/assets/[id]/dispose/route.ts @@ -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 }, +) diff --git a/app/api/assets/[id]/route.ts b/app/api/assets/[id]/route.ts new file mode 100644 index 00000000..8309c575 --- /dev/null +++ b/app/api/assets/[id]/route.ts @@ -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 }, +) diff --git a/app/api/assets/route.ts b/app/api/assets/route.ts new file mode 100644 index 00000000..2b961813 --- /dev/null +++ b/app/api/assets/route.ts @@ -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 }, +) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/bokslut-readiness/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/bokslut-readiness/route.ts new file mode 100644 index 00000000..398f50e0 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/bokslut-readiness/route.ts @@ -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 }, + }) + } + }, +) diff --git a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts new file mode 100644 index 00000000..d3780ee1 --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts @@ -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 = { + 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 + +async function computeProposal( + item: PostItem, + supabase: Parameters[0], + companyId: string, + fiscalPeriodId: string, + fiscalYear: number, +): Promise { + 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, + } +} diff --git a/app/api/bookkeeping/fiscal-periods/[id]/depreciation/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/depreciation/route.ts new file mode 100644 index 00000000..7f5f77cf --- /dev/null +++ b/app/api/bookkeeping/fiscal-periods/[id]/depreciation/route.ts @@ -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 }, +) diff --git a/components/bookkeeping/assets/CreateAssetDialog.tsx b/components/bookkeeping/assets/CreateAssetDialog.tsx new file mode 100644 index 00000000..c7be0155 --- /dev/null +++ b/components/bookkeeping/assets/CreateAssetDialog.tsx @@ -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('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(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 ( + + + + Ny anläggningstillgång + +
+
+ + setName(e.target.value)} + placeholder="t.ex. MacBook Pro 14" + autoFocus + /> +
+
+ + +
+
+
+ + setAcquisitionDate(e.target.value)} + /> +
+
+ + setAcquisitionCost(e.target.value)} + placeholder="t.ex. 25000" + className="tabular-nums" + /> +
+
+
+ + setUsefulLifeYears(e.target.value)} + className="tabular-nums" + /> +

+ 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). +

+
+
+ Tips: 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. +
+ {error && ( +
+ {error} +
+ )} +
+ + + + +
+
+ ) +} diff --git a/components/bookkeeping/year-end/DepreciationPanel.tsx b/components/bookkeeping/year-end/DepreciationPanel.tsx new file mode 100644 index 00000000..78787070 --- /dev/null +++ b/components/bookkeeping/year-end/DepreciationPanel.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( + + + + + + + ) + } + + if (error) { + return ( + + {error} + + ) + } + + 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 ( + + + Planenliga avskrivningar + + + Inga aktiva anläggningstillgångar att skriva av.{' '} + + Lägg till tillgångar + {' '} + så räknar bokslutet ut avskrivningarna automatiskt. + + + ) + } + + return ( + + +
+
+ Planenliga avskrivningar +

+ {proposal.items.length} tillgång{proposal.items.length === 1 ? '' : 'ar'}. + {allPosted ? ' Allt redan bokfört.' : ' Bokförs som separata verifikationer.'} +

+
+

+ {formatCurrency(proposal.totalAmount)} +

+
+
+ + + + + Tillgång + Avskrivning + Restvärde + Status + + + + {proposal.items.map((item) => ( + + {item.asset.name} + + {formatCurrency(item.amount)} + {item.proRated && ( + pro-rata + )} + + + {formatCurrency(item.netBookValueAfter)} + + + {item.existingJournalEntryId ? ( + Bokförd + ) : ( + Föreslagen + )} + + + ))} + +
+ + {anyPending && ( +
+ +
+ )} +
+
+ ) +} diff --git a/components/bookkeeping/year-end/DispositionsStep.tsx b/components/bookkeeping/year-end/DispositionsStep.tsx new file mode 100644 index 00000000..e170a8a5 --- /dev/null +++ b/components/bookkeeping/year-end/DispositionsStep.tsx @@ -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 +} + +/** + * 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(null) + const [loading, setLoading] = useState(true) + const [fetchError, setFetchError] = useState(null) + const [ui, setUi] = useState({ selections: {} }) + const [posting, setPosting] = useState(false) + const [postError, setPostError] = useState(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 ( + + + + + + + + ) + } + + if (fetchError) { + return ( + + +

{fetchError}

+
+
+ ) + } + + if (!proposal) return null + + // EF: only depreciation applies (no AB-only dispositioner) + if (proposal.entityType !== 'aktiebolag') { + return ( +
+ void loadProposals()} /> + + + Inga bokslutsdispositioner + + + För enskild firma bokförs varken bolagsskatt, periodiseringsfond eller överavskrivningar. + De räknas i NE-bilagan när du deklarerar. + + +
+ + +
+
+ ) + } + + if (proposal.proposals.length === 0) { + return ( +
+ void loadProposals()} /> + + + Inga dispositioner föreslagna + + + Bolaget har varken pensionskostnader, periodiseringsfonder att hantera, vinst att skatta + på, eller skattemässiga avskrivningar att boka. + + +
+ + +
+
+ ) + } + + return ( +
+ void loadProposals()} /> + + + Bokslutsdispositioner +

+ Resultat före dispositioner:{' '} + + {formatCurrency(proposal.netResultBefore)} + + . Justera beloppen och bocka av de dispositioner du vill boka. +

+
+
+ + {proposal.proposals.map((p, i) => { + const key = proposalKey(p, i) + const sel = ui.selections[key] + if (!sel) return null + return ( + { + setUi((prev) => ({ + selections: { ...prev.selections, [key]: { ...sel, ...next } }, + })) + }} + /> + ) + })} + + {postError && ( + + {postError} + + )} + +
+ + +
+
+ ) +} + +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 ( + + +
+
+ {proposal.label} +

{proposal.description}

+ {proposal.required && ( + + Obligatorisk + + )} +
+

+ {formatCurrency(displayedAmount)} +

+
+
+ + {proposal.warnings.map((w, i) => ( +

+ {w} +

+ ))} + {overridable && ( +
+ + { + const value = parseInt(e.target.value, 10) + onChange({ overrideAmount: Number.isFinite(value) ? value : 0 }) + }} + /> +
+ )} +
+ onChange({ accept: Boolean(checked) })} + /> + +
+
+
+ ) +} + +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 = {} + 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 +} diff --git a/components/bookkeeping/year-end/ExecuteStep.tsx b/components/bookkeeping/year-end/ExecuteStep.tsx new file mode 100644 index 00000000..342f69e0 --- /dev/null +++ b/components/bookkeeping/year-end/ExecuteStep.tsx @@ -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 +} + +/** + * 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 ( +
+ + + + + Klar att verkställa + + + +

+ När du verkställer bokslutet för {periodName} kommer följande att hända + i en transaktion: +

+
    +
  • Kursrevaluering bokas för öppna poster i utländsk valuta (om relevant)
  • +
  • Bokslutsverifikationen skapas och nollställer klass 3–8
  • +
  • Perioden låses och stängs (oåterkalleligt enligt BFL)
  • +
  • En ny räkenskapsperiod skapas och får ingående balanser från balansräkningen
  • +
  • IB/UB-kontinuitet verifieras
  • +
+

+ 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. +

+ {error && ( +
+ {error} +
+ )} +
+
+ +
+ + +
+ + +
+ ) +} diff --git a/components/bookkeeping/year-end/PreflightStep.tsx b/components/bookkeeping/year-end/PreflightStep.tsx new file mode 100644 index 00000000..63c27991 --- /dev/null +++ b/components/bookkeeping/year-end/PreflightStep.tsx @@ -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 ( + + + + + + + + + ) + } + + if (error) { + return ( + + +

{error}

+
+
+ ) + } + + if (!report) { + return null + } + + return ( +
+ + +
+
+ {report.period.name} +

+ {report.period.period_start} – {report.period.period_end} +

+
+ {report.ready ? ( + + Redo för bokslut + + ) : ( + + Inte redo + + )} +
+
+
+ + {report.blockers.length > 0 && ( + + + + + Måste åtgärdas innan bokslut + + + + {report.blockers.map((blocker, i) => ( + + ))} + + + )} + + {report.warnings.length > 0 && ( + + + + + Varningar + + + + {report.warnings.map((warning, i) => ( +

+ {warning} +

+ ))} +
+
+ )} + + {report.reminders.length > 0 && ( + + + + + Påminnelser + + + + {report.reminders.map((reminder) => ( +
+ +
+

{reminder.message}

+ {reminder.href && ( + + Öppna + + )} +
+
+ ))} +
+
+ )} + +
+ +
+
+ ) +} + +/** + * 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 ( +
+

{blocker}

+ {href && actionLabel && ( + + {actionLabel} + + )} +
+ ) +} diff --git a/components/bookkeeping/year-end/PreviewStep.tsx b/components/bookkeeping/year-end/PreviewStep.tsx new file mode 100644 index 00000000..e35b349c --- /dev/null +++ b/components/bookkeeping/year-end/PreviewStep.tsx @@ -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 ( + + + + + + + ) + } + + if (error) { + return ( + + +

{error}

+
+
+ ) + } + + if (!preview) return null + + const isProfit = preview.netResult > 0 + const isLoss = preview.netResult < 0 + + return ( +
+ + + Årets resultat + + +
+
+

+ Nettoresultat överförs till {preview.closingAccount} {preview.closingAccountName} +

+ {isProfit && Vinst} + {isLoss && Förlust} +
+

+ {formatCurrency(preview.netResult)} +

+
+
+
+ + {preview.currencyRevaluation && preview.currencyRevaluation.items.length > 0 && ( + + + Kursrevaluering (ÅRL 4:13) +

+ Öppna fordringar/skulder i utländsk valuta värderas om till balansdagens kurs innan bokslut. + Detta sker automatiskt som en del av verkställandet. +

+
+ +
+
+

Kursvinst

+

+ {formatCurrency(preview.currencyRevaluation.totalGain)} +

+
+
+

Kursförlust

+

+ {formatCurrency(preview.currencyRevaluation.totalLoss)} +

+
+
+

Nettoeffekt

+

+ {formatCurrency(preview.currencyRevaluation.netEffect)} +

+
+
+
+
+ )} + + + + Bokslutsverifikation — förhandsgranskning +

+ {preview.closingLines.length} kontorader. Nollställer klass 3–8 mot {preview.closingAccount}. +

+
+ + + + + Konto + Beskrivning + Debet + Kredit + + + + {preview.closingLines.map((line, i) => ( + + {line.account_number} + {line.line_description} + + {line.debit_amount > 0 ? formatCurrency(line.debit_amount) : '—'} + + + {line.credit_amount > 0 ? formatCurrency(line.credit_amount) : '—'} + + + ))} + +
+
+
+ +
+ + +
+
+ ) +} diff --git a/components/bookkeeping/year-end/ResultStep.tsx b/components/bookkeeping/year-end/ResultStep.tsx new file mode 100644 index 00000000..dd95a87d --- /dev/null +++ b/components/bookkeeping/year-end/ResultStep.tsx @@ -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 ( +
+ + +
+ +
+

Bokslutet är klart

+

+ Perioden är stängd och en ny räkenskapsperiod har skapats. +

+
+
+ + + + Resultat + + + + {result.revaluationEntry && ( + + )} + + + + + +
+ + +
+
+ ) +} + +function ResultRow({ label, value, href }: { label: string; value: string; href?: string }) { + return ( +
+ {label} + {href ? ( + + {value} + + ) : ( + {value} + )} +
+ ) +} diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index 9f592481..c6bd4706 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -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 diff --git a/lib/bokslut/__tests__/asset-service.test.ts b/lib/bokslut/__tests__/asset-service.test.ts new file mode 100644 index 00000000..844641f6 --- /dev/null +++ b/lib/bokslut/__tests__/asset-service.test.ts @@ -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 { + 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 = { + select: vi.fn(), + eq: vi.fn(), + not: vi.fn(), + then: undefined, + } + ;(b.select as ReturnType).mockReturnValue(b) + ;(b.eq as ReturnType).mockReturnValue(b) + ;(b.not as ReturnType).mockReturnValue(b) + b.then = (resolve: (v: { data: unknown; error: unknown }) => void) => + resolve({ data: schedules, error: null }) + return b as { select: ReturnType; eq: ReturnType; not: ReturnType } + })(), + 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[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[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[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[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[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) + }) +}) diff --git a/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts b/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts new file mode 100644 index 00000000..4095a595 --- /dev/null +++ b/lib/bokslut/__tests__/bolagsskatt-calculator.test.ts @@ -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[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>) + + 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>) + + 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>) + + 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>) + + 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>) + + 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>) + + 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) + }) +}) diff --git a/lib/bokslut/__tests__/depreciation-engine.test.ts b/lib/bokslut/__tests__/depreciation-engine.test.ts new file mode 100644 index 00000000..318941e0 --- /dev/null +++ b/lib/bokslut/__tests__/depreciation-engine.test.ts @@ -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 { + 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) + }) +}) diff --git a/lib/bokslut/__tests__/overavskrivningar-service.test.ts b/lib/bokslut/__tests__/overavskrivningar-service.test.ts new file mode 100644 index 00000000..f5617ef3 --- /dev/null +++ b/lib/bokslut/__tests__/overavskrivningar-service.test.ts @@ -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') + }) +}) diff --git a/lib/bokslut/__tests__/periodiseringsfond-service.test.ts b/lib/bokslut/__tests__/periodiseringsfond-service.test.ts new file mode 100644 index 00000000..129b1ec0 --- /dev/null +++ b/lib/bokslut/__tests__/periodiseringsfond-service.test.ts @@ -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) + }) +}) diff --git a/lib/bokslut/__tests__/readiness-aggregator.test.ts b/lib/bokslut/__tests__/readiness-aggregator.test.ts new file mode 100644 index 00000000..6914e1ef --- /dev/null +++ b/lib/bokslut/__tests__/readiness-aggregator.test.ts @@ -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 + eq: ReturnType + single: ReturnType + maybeSingle: ReturnType +} + +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[0] +} + +function baseValidation(overrides: Partial = {}): 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() + }) +}) diff --git a/lib/bokslut/__tests__/sarskild-loneskatt-calculator.test.ts b/lib/bokslut/__tests__/sarskild-loneskatt-calculator.test.ts new file mode 100644 index 00000000..f643496f --- /dev/null +++ b/lib/bokslut/__tests__/sarskild-loneskatt-calculator.test.ts @@ -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) + }) +}) diff --git a/lib/bokslut/assets/asset-service.ts b/lib/bokslut/assets/asset-service.ts new file mode 100644 index 00000000..d239205c --- /dev/null +++ b/lib/bokslut/assets/asset-service.ts @@ -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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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, + ) +} diff --git a/lib/bokslut/assets/depreciation-engine.ts b/lib/bokslut/assets/depreciation-engine.ts new file mode 100644 index 00000000..670fbed0 --- /dev/null +++ b/lib/bokslut/assets/depreciation-engine.ts @@ -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, +): { 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 { + 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( + (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() + 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)) +} diff --git a/lib/bokslut/readiness-aggregator.ts b/lib/bokslut/readiness-aggregator.ts new file mode 100644 index 00000000..22dddb9d --- /dev/null +++ b/lib/bokslut/readiness-aggregator.ts @@ -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 { + // 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, + } +} diff --git a/lib/bokslut/reserves/overavskrivningar-service.ts b/lib/bokslut/reserves/overavskrivningar-service.ts new file mode 100644 index 00000000..585e54e9 --- /dev/null +++ b/lib/bokslut/reserves/overavskrivningar-service.ts @@ -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, + rule20: ReturnType, +): { 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 +} + +/** + * 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 = { + 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, + } +} diff --git a/lib/bokslut/reserves/periodiseringsfond-service.ts b/lib/bokslut/reserves/periodiseringsfond-service.ts new file mode 100644 index 00000000..e3ed3c7b --- /dev/null +++ b/lib/bokslut/reserves/periodiseringsfond-service.ts @@ -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, + } +} + +/** + * 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 { + 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() + 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 + /** 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), + } +} diff --git a/lib/bokslut/tax-provision/bolagsskatt-calculator.ts b/lib/bokslut/tax-provision/bolagsskatt-calculator.ts new file mode 100644 index 00000000..c80f1e30 --- /dev/null +++ b/lib/bokslut/tax-provision/bolagsskatt-calculator.ts @@ -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 { + 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, + } + } + + 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, + } +} diff --git a/lib/bokslut/tax-provision/sarskild-loneskatt-calculator.ts b/lib/bokslut/tax-provision/sarskild-loneskatt-calculator.ts new file mode 100644 index 00000000..fec5d8df --- /dev/null +++ b/lib/bokslut/tax-provision/sarskild-loneskatt-calculator.ts @@ -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 { + 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, + } +} diff --git a/lib/bokslut/types.ts b/lib/bokslut/types.ts new file mode 100644 index 00000000..49ccb844 --- /dev/null +++ b/lib/bokslut/types.ts @@ -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 + /** 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[] +} diff --git a/lib/reports/ink2/__tests__/ink2-engine.test.ts b/lib/reports/ink2/__tests__/ink2-engine.test.ts index 724251cc..f2012a52 100644 --- a/lib/reports/ink2/__tests__/ink2-engine.test.ts +++ b/lib/reports/ink2/__tests__/ink2-engine.test.ts @@ -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', () => { diff --git a/lib/reports/ink2/ink2-engine.ts b/lib/reports/ink2/ink2-engine.ts index e9f95ea7..a09fd6ba 100644 --- a/lib/reports/ink2/ink2-engine.ts +++ b/lib/reports/ink2/ink2-engine.ts @@ -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', diff --git a/supabase/migrations/20260516120000_assets_and_depreciation.sql b/supabase/migrations/20260516120000_assets_and_depreciation.sql new file mode 100644 index 00000000..16d91549 --- /dev/null +++ b/supabase/migrations/20260516120000_assets_and_depreciation.sql @@ -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'; diff --git a/supabase/migrations/20260516140000_depreciation_schedules_updated_at.sql b/supabase/migrations/20260516140000_depreciation_schedules_updated_at.sql new file mode 100644 index 00000000..8bae9b3a --- /dev/null +++ b/supabase/migrations/20260516140000_depreciation_schedules_updated_at.sql @@ -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'; diff --git a/tests/pg/assets.pg.test.ts b/tests/pg/assets.pg.test.ts new file mode 100644 index 00000000..09930b34 --- /dev/null +++ b/tests/pg/assets.pg.test.ts @@ -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 { + 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 { + 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 { + 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) + }) +}) diff --git a/types/index.ts b/types/index.ts index a9591339..cfe4b96e 100644 --- a/types/index.ts +++ b/types/index.ts @@ -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) // ============================================================