+
+ window.open(`/api/reports/journal-register/xlsx?period_id=${periodId}`, '_blank')}
+ >
+
+ Ladda ner Excel
+
+
{data.period.start && (
Period: {data.period.start} — {data.period.end} | {data.total_entries} verifikationer
@@ -1999,7 +2295,7 @@ function JournalRegisterView({ periodId }: { periodId: string }) {
)}
- {entry.voucher_series}{entry.voucher_number}
+ {formatVoucher(entry)}
{entry.date}
@@ -2086,6 +2382,102 @@ interface ARLedgerData {
} | null
}
+// Inner expansion row component for AR ledger.
+// Fetches per-customer invoices (with journal_entry_id) and renders each as a
+// link to /bookkeeping/[id] when posted, /invoices/[id] when still draft.
+function ARCustomerInvoiceRows({
+ customerId,
+ invoices,
+}: {
+ customerId: string
+ invoices: {
+ invoice_id: string
+ invoice_number: string
+ invoice_date: string
+ due_date: string
+ total: number
+ paid_amount: number
+ outstanding: number
+ outstanding_sek: number | null
+ days_overdue: number
+ currency: string
+ }[]
+}) {
+ // ARCustomerInvoiceRows is mounted lazily — only when a customer is
+ // expanded, so initial state matches "still loading" and resets on
+ // unmount. No synchronous setState in the effect is needed.
+ const [enriched, setEnriched] = useState>({})
+ const [loaded, setLoaded] = useState(false)
+
+ useEffect(() => {
+ let cancelled = false
+ fetch(`/api/reports/ar-ledger/customer/${encodeURIComponent(customerId)}/invoices`)
+ .then((r) => r.json())
+ .then((json) => {
+ if (cancelled) return
+ const map: typeof enriched = {}
+ for (const line of json.data?.lines || []) {
+ if (line.invoice_id && line.journal_entry_id) {
+ map[line.invoice_id] = {
+ journal_entry_id: line.journal_entry_id,
+ voucher_series: line.voucher_series,
+ voucher_number: line.voucher_number,
+ }
+ }
+ }
+ setEnriched(map)
+ })
+ .catch(() => { /* fail silently; rows still render without verifikat link */ })
+ .finally(() => { if (!cancelled) setLoaded(true) })
+ return () => { cancelled = true }
+ }, [customerId])
+ const loading = !loaded
+
+ return (
+ <>
+ {invoices.map((inv) => {
+ const entry = enriched[inv.invoice_id]
+ const targetHref = entry?.journal_entry_id
+ ? `/bookkeeping/${entry.journal_entry_id}`
+ : `/invoices/${inv.invoice_id}`
+ return (
+
+
+
+
+ {inv.invoice_number || '(utkast)'}
+
+ {entry && (
+
+ {formatVoucher(entry)}
+
+ )}
+ {formatDate(inv.invoice_date)}
+ förfaller {formatDate(inv.due_date)}
+
+
+ {inv.days_overdue > 0 ? `${inv.days_overdue} dagar förfallen` : 'Ej förfallen'}
+
+
+ {inv.paid_amount > 0 ? `Betalt: ${formatAmount(inv.paid_amount)}` : ''}
+
+
+
+ {formatAmount(inv.outstanding)} {inv.currency}
+
+
+ )
+ })}
+ {loading && (
+
+
+ Letar verifikat…
+
+ )}
+ >
+ )
+}
+
function ARLedgerView({ periodId }: { periodId: string }) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(false)
@@ -2161,6 +2553,16 @@ function ARLedgerView({ periodId }: { periodId: string }) {
return (
+
+ window.open(`/api/reports/ar-ledger/xlsx?period_id=${periodId}`, '_blank')}
+ >
+
+ Ladda ner Excel
+
+
{/* Summary cards */}
@@ -2239,26 +2641,12 @@ function ARLedgerView({ periodId }: { periodId: string }) {
{entry.days_90_plus > 0 ? formatAmount(entry.days_90_plus) : ''}
{formatAmount(entry.total_outstanding)}
- {isExpanded && entry.invoices.map((inv) => (
-
-
-
- {inv.invoice_number}
- {formatDate(inv.invoice_date)}
- förfaller {formatDate(inv.due_date)}
-
-
- {inv.days_overdue > 0 ? `${inv.days_overdue} dagar förfallen` : 'Ej förfallen'}
-
-
- {inv.paid_amount > 0 ? `Betalt: ${formatAmount(inv.paid_amount)}` : ''}
-
-
-
- {formatAmount(inv.outstanding)} {inv.currency}
-
-
- ))}
+ {isExpanded && (
+
+ )}
)
})}
diff --git a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx
index 758ff6af..2ca047c1 100644
--- a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx
+++ b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx
@@ -13,6 +13,12 @@ const LINE_ITEM_TYPE_LABELS: Record = {
monthly_salary: 'Månadslön',
hourly_salary: 'Timlön',
overtime: 'Övertid',
+ overtime_50: 'Övertid 50 %',
+ overtime_100: 'Övertid 100 %',
+ ob_weekday_evening: 'OB vardag kväll',
+ ob_weekend: 'OB helg',
+ ob_night: 'OB natt',
+ ob_holiday: 'OB helgdag',
bonus: 'Bonus',
commission: 'Provision',
gross_deduction_pension: 'Bruttoavdrag — pension',
diff --git a/app/(dashboard)/settings/bookkeeping/page.tsx b/app/(dashboard)/settings/bookkeeping/page.tsx
index c887b4b6..41cd37a9 100644
--- a/app/(dashboard)/settings/bookkeeping/page.tsx
+++ b/app/(dashboard)/settings/bookkeeping/page.tsx
@@ -1,21 +1,34 @@
'use client'
import Link from 'next/link'
+import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { SettingsFormWrapper } from '@/components/settings/SettingsFormWrapper'
import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSkeleton'
import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettings'
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
+import { VoucherSeriesPerSourceTypeForm } from '@/components/settings/VoucherSeriesPerSourceTypeForm'
+import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle'
+import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm'
import { useSettings } from '@/components/settings/useSettings'
+import { useCompany } from '@/contexts/CompanyContext'
import { Label } from '@/components/ui/label'
import { ExternalLink } from 'lucide-react'
-import type { CompanySettings } from '@/types'
+import type { AccountingFramework, CompanySettings } from '@/types'
const SERIES_OPTIONS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
export default function BookkeepingSettingsPage() {
const t = useTranslations('settings_bookkeeping')
const { settings, isLoading, updateSettings } = useSettings()
+ const { company } = useCompany()
+ // Local mirror of the company-level accounting_framework so the K2/K3
+ // selector can reflect its own saves without waiting for the layout to
+ // re-render through the server. Falls back to k2 (matches the column
+ // default) until the company row is loaded.
+ const [framework, setFramework] = useState(
+ company?.accounting_framework ?? 'k2',
+ )
if (isLoading || !settings) return
@@ -39,8 +52,19 @@ export default function BookkeepingSettingsPage() {
}
}
+ // K2/K3 selector is only meaningful for AB. EF stays on EF rules and never
+ // picks a framework. Use the company row (source of truth) since
+ // company_settings.entity_type can be stale on legacy data.
+ const isAktiebolag = company?.entity_type === 'aktiebolag'
+
return (
+ {isAktiebolag && (
+
setFramework(next)}
+ />
+ )}
{/* Accounting method */}
@@ -95,11 +119,24 @@ export default function BookkeepingSettingsPage() {
+ {/* Voucher series — per-source-type mapping */}
+
+
+
+
{/* Voucher series — read-only display */}
+ {/* Periodisering auto-detect toggle */}
+
+
{/* Cross-links */}
diff --git a/app/(dashboard)/settings/layout.tsx b/app/(dashboard)/settings/layout.tsx
index a145e22f..21a88cc3 100644
--- a/app/(dashboard)/settings/layout.tsx
+++ b/app/(dashboard)/settings/layout.tsx
@@ -14,6 +14,7 @@ const TAB_TO_ROUTE: Record = {
team: '/settings/team',
banking: '/settings/banking',
templates: '/settings/templates',
+ 'approval-rules': '/settings/approval-rules',
account: '/settings/account',
api: '/settings/api',
}
diff --git a/app/(dashboard)/skattekonto/page.tsx b/app/(dashboard)/skattekonto/page.tsx
index 1fdf69d0..cced4501 100644
--- a/app/(dashboard)/skattekonto/page.tsx
+++ b/app/(dashboard)/skattekonto/page.tsx
@@ -17,6 +17,7 @@ import {
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
+import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import {
Copy,
ExternalLink,
@@ -517,7 +518,10 @@ function TransactionTable({
Möjlig dublett av{' '}
{row.match_suggestion.voucher_series && row.match_suggestion.voucher_number
- ? `${row.match_suggestion.voucher_series}${row.match_suggestion.voucher_number}`
+ ? formatVoucher({
+ voucher_series: row.match_suggestion.voucher_series,
+ voucher_number: row.match_suggestion.voucher_number,
+ })
: 'utkast'}{' '}
({row.match_suggestion.entry_date})
@@ -647,9 +651,7 @@ function MatchDialog({
{c.entry_date}
- {c.voucher_series && c.voucher_number
- ? `${c.voucher_series}${c.voucher_number}`
- : '–'}
+ {formatVoucher(c)}
{c.description}
diff --git a/app/(public)/invoice-action/[token]/page.tsx b/app/(public)/invoice-action/[token]/page.tsx
index 0ab618ed..10293488 100644
--- a/app/(public)/invoice-action/[token]/page.tsx
+++ b/app/(public)/invoice-action/[token]/page.tsx
@@ -22,6 +22,14 @@ interface InvoiceData {
reminderLevel: number
alreadyResponded: boolean
previousResponse: 'marked_paid' | 'disputed' | null
+ // Dröjsmålsränta + lagstadgad påminnelseavgift (Räntelagen §6, Lag 1981:739).
+ // Default to 0 for older reminders sent before the surcharge feature shipped.
+ interestAmount: number
+ interestRate: number
+ interestFromDate: string | null
+ interestDays: number | null
+ reminderFee: number
+ totalDue: number
}
export default function InvoiceActionPage({ params }: { params: Promise<{ token: string }> }) {
@@ -179,13 +187,46 @@ export default function InvoiceActionPage({ params }: { params: Promise<{ token:
-
-
+
+
Förfallen med {daysOverdue} dagar
-
- {formatCurrency(invoice.total, invoice.currency)}
+
+ {(invoice.interestAmount > 0 || invoice.reminderFee > 0) && (
+
+
+ Ursprungligt belopp
+ {formatCurrency(invoice.total, invoice.currency)}
+
+ {invoice.interestAmount > 0 && (
+
+
+ Dröjsmålsränta
+ {invoice.interestRate > 0 && invoice.interestDays != null
+ ? ` (${(invoice.interestRate * 100).toLocaleString('sv-SE', { maximumFractionDigits: 2 })}% per år, ${invoice.interestDays} dagar)`
+ : ''}
+
+ {formatCurrency(invoice.interestAmount, invoice.currency)}
+
+ )}
+ {invoice.reminderFee > 0 && (
+
+ Påminnelseavgift
+ {formatCurrency(invoice.reminderFee, invoice.currency)}
+
+ )}
+
+
+ )}
+
+
+ {formatCurrency(invoice.totalDue || invoice.total, invoice.currency)}
+ {(invoice.interestAmount > 0 || invoice.reminderFee > 0) && (
+
+ Att betala (inkl. dröjsmålsränta och påminnelseavgift)
+
+ )}
{error && (
diff --git a/app/api/account/password/__tests__/route.test.ts b/app/api/account/password/__tests__/route.test.ts
index 9dca9705..bbd8fbe5 100644
--- a/app/api/account/password/__tests__/route.test.ts
+++ b/app/api/account/password/__tests__/route.test.ts
@@ -12,8 +12,10 @@ import { POST } from '../route'
const mockCreateClient = vi.mocked(createClient)
const mockCreateServiceClient = vi.mocked(createServiceClient)
+type AuthMetadata = Record
+
function mockUserClient(opts: {
- user: { id: string } | null
+ user: { id: string; app_metadata?: AuthMetadata } | null
updateUserError?: { message: string; status?: number; code?: string } | null
}) {
const updateUser = vi.fn().mockResolvedValue({
@@ -33,12 +35,24 @@ function mockUserClient(opts: {
}
function mockService(opts: {
- priorAppMetadata?: Record
- updateUserByIdError?: Error | null
+ priorAppMetadata?: AuthMetadata
+ // Returned-error from admin.updateUserById when called with { password }
+ passwordSetError?: { message: string; status?: number; code?: string } | null
+ // Thrown error from admin.updateUserById when called with { app_metadata }
+ flagFlipError?: Error | null
}) {
- const updateUserById = opts.updateUserByIdError
- ? vi.fn().mockRejectedValue(opts.updateUserByIdError)
- : vi.fn().mockResolvedValue({ data: {}, error: null })
+ const updateUserById = vi
+ .fn()
+ .mockImplementation((_id: string, args: Record) => {
+ if ('password' in args) {
+ return Promise.resolve({
+ data: {},
+ error: opts.passwordSetError ?? null,
+ })
+ }
+ if (opts.flagFlipError) return Promise.reject(opts.flagFlipError)
+ return Promise.resolve({ data: {}, error: null })
+ })
const getUserById = vi.fn().mockResolvedValue({
data: { user: { app_metadata: opts.priorAppMetadata ?? {} } },
@@ -54,6 +68,18 @@ function mockService(opts: {
const STRONG_PASSWORD = 'StrongP@ssword1'
+function flagFlipCall(updateUserById: ReturnType) {
+ return updateUserById.mock.calls.find(
+ ([, args]) => args && typeof args === 'object' && 'app_metadata' in args,
+ )
+}
+
+function passwordSetCall(updateUserById: ReturnType) {
+ return updateUserById.mock.calls.find(
+ ([, args]) => args && typeof args === 'object' && 'password' in args,
+ )
+}
+
beforeEach(() => {
vi.clearAllMocks()
})
@@ -72,8 +98,8 @@ describe('POST /api/account/password', () => {
})
it('returns 400 when password is too weak', async () => {
- mockUserClient({ user: { id: 'user-1' } })
- mockService({})
+ mockUserClient({ user: { id: 'user-1', app_metadata: { has_password: true } } })
+ mockService({ priorAppMetadata: { has_password: true } })
const req = createMockRequest('/api/account/password', {
method: 'POST',
@@ -83,65 +109,187 @@ describe('POST /api/account/password', () => {
expect(status).toBe(400)
})
- it('returns 400 when Supabase rejects the password update', async () => {
- const { updateUser } = mockUserClient({
- user: { id: 'user-1' },
- updateUserError: { message: 'Password too similar to old', status: 400 },
- })
- const { updateUserById } = mockService({})
+ describe('first-time set (has_password !== true)', () => {
+ it('writes the password via admin API and flips the flag', async () => {
+ const { updateUser } = mockUserClient({
+ user: {
+ id: 'user-1',
+ app_metadata: { has_password: false, bankid_linked: true },
+ },
+ })
+ const { updateUserById } = mockService({
+ priorAppMetadata: { has_password: false, bankid_linked: true },
+ })
- const req = createMockRequest('/api/account/password', {
- method: 'POST',
- body: { password: STRONG_PASSWORD },
- })
- const { status, body } = await parseJsonResponse<{ error?: string }>(
- await POST(req),
- )
- expect(status).toBe(400)
- expect(body.error).toContain('Password too similar')
- expect(updateUser).toHaveBeenCalledWith({ password: STRONG_PASSWORD })
- // Flag should NOT be flipped on a failed password update
- expect(updateUserById).not.toHaveBeenCalled()
- })
+ const req = createMockRequest('/api/account/password', {
+ method: 'POST',
+ body: { password: STRONG_PASSWORD },
+ })
+ const { status, body } = await parseJsonResponse<{
+ data?: { ok: boolean }
+ }>(await POST(req))
- it('flips app_metadata.has_password to true on success and preserves siblings', async () => {
- const { updateUser } = mockUserClient({ user: { id: 'user-1' } })
- const { getUserById, updateUserById } = mockService({
- priorAppMetadata: { bankid_linked: true, provider: 'email' },
+ expect(status).toBe(200)
+ expect(body.data?.ok).toBe(true)
+ // Did NOT go through the user session — that path would fail with AAL2.
+ expect(updateUser).not.toHaveBeenCalled()
+ // Password set via admin
+ expect(passwordSetCall(updateUserById)).toEqual([
+ 'user-1',
+ { password: STRONG_PASSWORD },
+ ])
+ // Flag flipped, siblings preserved
+ expect(flagFlipCall(updateUserById)).toEqual([
+ 'user-1',
+ {
+ app_metadata: {
+ has_password: true,
+ bankid_linked: true,
+ },
+ },
+ ])
})
- const req = createMockRequest('/api/account/password', {
- method: 'POST',
- body: { password: STRONG_PASSWORD },
+ it('treats unset has_password as first-time set', async () => {
+ const { updateUser } = mockUserClient({
+ user: { id: 'user-1' /* no app_metadata */ },
+ })
+ const { updateUserById } = mockService({})
+
+ const req = createMockRequest('/api/account/password', {
+ method: 'POST',
+ body: { password: STRONG_PASSWORD },
+ })
+ const { status } = await parseJsonResponse(await POST(req))
+
+ expect(status).toBe(200)
+ expect(updateUser).not.toHaveBeenCalled()
+ expect(passwordSetCall(updateUserById)).toBeDefined()
})
- const { status, body } = await parseJsonResponse<{ data?: { ok: boolean } }>(
- await POST(req),
- )
- expect(status).toBe(200)
- expect(body.data?.ok).toBe(true)
- expect(updateUser).toHaveBeenCalledWith({ password: STRONG_PASSWORD })
- expect(getUserById).toHaveBeenCalledWith('user-1')
- expect(updateUserById).toHaveBeenCalledWith('user-1', {
- app_metadata: {
- bankid_linked: true,
- provider: 'email',
- has_password: true,
- },
+
+ it('returns 400 and skips flag flip when the admin password set fails', async () => {
+ const { updateUser } = mockUserClient({
+ user: { id: 'user-1', app_metadata: { has_password: false } },
+ })
+ const { updateUserById } = mockService({
+ priorAppMetadata: { has_password: false },
+ passwordSetError: { message: 'Password too weak', status: 400 },
+ })
+
+ const req = createMockRequest('/api/account/password', {
+ method: 'POST',
+ body: { password: STRONG_PASSWORD },
+ })
+ const { status, body } = await parseJsonResponse<{ error?: string }>(
+ await POST(req),
+ )
+
+ expect(status).toBe(400)
+ expect(body.error).toContain('Password too weak')
+ expect(updateUser).not.toHaveBeenCalled()
+ expect(flagFlipCall(updateUserById)).toBeUndefined()
+ })
+
+ it('still returns success when the flag flip fails after admin password set', async () => {
+ mockUserClient({
+ user: { id: 'user-1', app_metadata: { has_password: false } },
+ })
+ mockService({
+ priorAppMetadata: { has_password: false },
+ flagFlipError: new Error('admin down'),
+ })
+
+ const req = createMockRequest('/api/account/password', {
+ method: 'POST',
+ body: { password: STRONG_PASSWORD },
+ })
+ const { status, body } = await parseJsonResponse<{
+ data?: { ok: boolean }
+ }>(await POST(req))
+
+ expect(status).toBe(200)
+ expect(body.data?.ok).toBe(true)
})
})
- it('still returns success when the flag flip fails (password is set; logged)', async () => {
- mockUserClient({ user: { id: 'user-1' } })
- mockService({ updateUserByIdError: new Error('admin down') })
+ describe('change-password (has_password === true)', () => {
+ it('writes via the user session so Supabase enforces AAL2', async () => {
+ const { updateUser } = mockUserClient({
+ user: { id: 'user-1', app_metadata: { has_password: true } },
+ })
+ const { updateUserById } = mockService({
+ priorAppMetadata: { has_password: true, provider: 'email' },
+ })
- const req = createMockRequest('/api/account/password', {
- method: 'POST',
- body: { password: STRONG_PASSWORD },
+ const req = createMockRequest('/api/account/password', {
+ method: 'POST',
+ body: { password: STRONG_PASSWORD },
+ })
+ const { status, body } = await parseJsonResponse<{
+ data?: { ok: boolean }
+ }>(await POST(req))
+
+ expect(status).toBe(200)
+ expect(body.data?.ok).toBe(true)
+ // Used user session, NOT admin API for the password itself
+ expect(updateUser).toHaveBeenCalledWith({ password: STRONG_PASSWORD })
+ expect(passwordSetCall(updateUserById)).toBeUndefined()
+ // Flag is still flipped (idempotent) with siblings preserved
+ expect(flagFlipCall(updateUserById)).toEqual([
+ 'user-1',
+ {
+ app_metadata: {
+ has_password: true,
+ provider: 'email',
+ },
+ },
+ ])
+ })
+
+ it('returns 400 and skips flag flip when Supabase rejects the password update', async () => {
+ const { updateUser } = mockUserClient({
+ user: { id: 'user-1', app_metadata: { has_password: true } },
+ updateUserError: { message: 'Password too similar to old', status: 400 },
+ })
+ const { updateUserById } = mockService({
+ priorAppMetadata: { has_password: true },
+ })
+
+ const req = createMockRequest('/api/account/password', {
+ method: 'POST',
+ body: { password: STRONG_PASSWORD },
+ })
+ const { status, body } = await parseJsonResponse<{ error?: string }>(
+ await POST(req),
+ )
+
+ expect(status).toBe(400)
+ expect(body.error).toContain('Password too similar')
+ expect(updateUser).toHaveBeenCalledWith({ password: STRONG_PASSWORD })
+ expect(flagFlipCall(updateUserById)).toBeUndefined()
+ })
+
+ it('surfaces the AAL2 error verbatim so the client can step up via /mfa/verify', async () => {
+ mockUserClient({
+ user: { id: 'user-1', app_metadata: { has_password: true } },
+ updateUserError: {
+ message:
+ 'AAL2 session is required to update email or password when MFA is enabled',
+ status: 422,
+ },
+ })
+ mockService({ priorAppMetadata: { has_password: true } })
+
+ const req = createMockRequest('/api/account/password', {
+ method: 'POST',
+ body: { password: STRONG_PASSWORD },
+ })
+ const { status, body } = await parseJsonResponse<{ error?: string }>(
+ await POST(req),
+ )
+
+ expect(status).toBe(400)
+ expect(body.error).toContain('AAL2')
})
- const { status, body } = await parseJsonResponse<{ data?: { ok: boolean } }>(
- await POST(req),
- )
- expect(status).toBe(200)
- expect(body.data?.ok).toBe(true)
})
})
diff --git a/app/api/account/password/route.ts b/app/api/account/password/route.ts
index 602b311a..cd258e06 100644
--- a/app/api/account/password/route.ts
+++ b/app/api/account/password/route.ts
@@ -23,12 +23,25 @@ const SetPasswordSchema = z.object({
/**
* POST /api/account/password
*
- * Server-routed password set/change. Wraps `supabase.auth.updateUser({ password })`
- * on the user's own session, then flips `app_metadata.has_password = true` via the
- * service client (clients can't write app_metadata).
+ * Server-routed password set/change, then flips `app_metadata.has_password =
+ * true` via the service client (clients can't write app_metadata).
+ *
+ * Two paths depending on whether the user already has a real password:
+ *
+ * - First-time set (`app_metadata.has_password !== true`): write via the
+ * admin API. BankID-only users — and legacy users whose `has_password`
+ * flag was set to false by the backfill — sit at AAL1 with a TOTP factor
+ * enrolled, and `updateUser` on the user session would be rejected with
+ * "AAL2 session is required to update email or password when MFA is
+ * enabled". Setting an initial password has no existing credential to
+ * protect, so bypassing AAL2 is safe.
+ *
+ * - Change-password (`app_metadata.has_password === true`): write via the
+ * user session so Supabase's AAL2 guard still fires. A stolen AAL1
+ * cookie must not be able to rotate a known password.
*
* This route is the single write path for setting a password. SecuritySettings,
- * the reset-password page, and the new /account/set-password page all funnel
+ * the reset-password page, and the /account/set-password page all funnel
* through here so the flag stays in sync — see lib/auth/has-password.ts.
*
* If the password update succeeds but the flag write fails, we log and still
@@ -49,11 +62,29 @@ export async function POST(request: Request) {
if (!result.success) return result.response
const { password } = result.data
- const { error: updateError } = await supabase.auth.updateUser({ password })
+ const isFirstTimeSet = user.app_metadata?.has_password !== true
+ const service = createServiceClient()
+
+ let updateError:
+ | { message?: string; status?: number; code?: string }
+ | null
+ | undefined = null
+
+ if (isFirstTimeSet) {
+ const { error } = await service.auth.admin.updateUserById(user.id, {
+ password,
+ })
+ updateError = error
+ } else {
+ const { error } = await supabase.auth.updateUser({ password })
+ updateError = error
+ }
+
if (updateError) {
- log.warn('updateUser({password}) failed', {
+ log.warn('password update failed', {
userId: user.id,
- code: (updateError as { code?: string }).code,
+ isFirstTimeSet,
+ code: updateError.code,
status: updateError.status,
})
return NextResponse.json(
@@ -69,7 +100,6 @@ export async function POST(request: Request) {
// Read-merge-write so we don't wipe sibling app_metadata keys.
// updateUserById replaces app_metadata wholesale (see lib/auth/has-password.ts
// and the comment in app/api/account/delete/route.ts).
- const service = createServiceClient()
let flagWriteOk = false
try {
const { data: u } = await service.auth.admin.getUserById(user.id)
@@ -87,7 +117,7 @@ export async function POST(request: Request) {
// banner will show once more and a retry will succeed.
}
- log.info('password set', { userId: user.id, flagWriteOk })
+ log.info('password set', { userId: user.id, isFirstTimeSet, flagWriteOk })
return NextResponse.json({ data: { ok: true } })
}
diff --git a/app/api/assets/[id]/dispose/route.ts b/app/api/assets/[id]/dispose/route.ts
index 9442f98e..3891be02 100644
--- a/app/api/assets/[id]/dispose/route.ts
+++ b/app/api/assets/[id]/dispose/route.ts
@@ -5,15 +5,87 @@ 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.
-})
+const VAT_TREATMENTS = [
+ 'standard_25',
+ 'reduced_12',
+ 'reduced_6',
+ 'reverse_charge',
+ 'export',
+ 'exempt',
+] as const
+
+const DisposeAssetSchema = z
+ .object({
+ disposed_at: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
+ /** Gross proceeds (INCL VAT when applicable). */
+ disposed_proceeds: z.number().nonnegative(),
+ proceeds_account: z.string().regex(/^\d{4}$/).optional(),
+ fiscal_period_id: z.string().uuid(),
+ /** Output VAT on the proceeds. Defaults to 0 (sale was momsfri). */
+ proceeds_vat: z.number().nonnegative().optional(),
+ /** Required when proceeds_vat > 0 so the engine can resolve a 26xx account. */
+ vat_treatment: z.enum(VAT_TREATMENTS).optional(),
+ /** Precomputed jämkning amount (ML 8a kap 7 §). Caller supplies; engine
+ * books a 2641 credit + loss-account debit. */
+ jamkning_amount: z.number().nonnegative().optional(),
+ /** Audit metadata. */
+ jamkning_remaining_months: z.number().int().nonnegative().optional(),
+ jamkning_total_months: z.number().int().positive().optional(),
+ jamkning_original_input_vat: z.number().nonnegative().optional(),
+ // accumulated_depreciation is intentionally NOT accepted from the client —
+ // disposeAsset sums depreciation_schedules server-side so callers cannot
+ // inflate the book-value calculation.
+ })
+ .superRefine((value, ctx) => {
+ // VAT consistency: if a treatment that produces a VAT line is selected,
+ // the VAT amount must equal 25%/12%/6% of the net proceeds. Tolerance is
+ // ±0.50 kr to handle rounding on item prices.
+ if (value.proceeds_vat && value.proceeds_vat > 0) {
+ if (!value.vat_treatment) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['vat_treatment'],
+ message: 'vat_treatment krävs när proceeds_vat > 0.',
+ })
+ return
+ }
+ const rate = vatRateFromTreatment(value.vat_treatment)
+ if (rate === null) {
+ // Treatments without a VAT line must carry 0 VAT.
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['proceeds_vat'],
+ message: `proceeds_vat måste vara 0 för momsbehandling "${value.vat_treatment}".`,
+ })
+ return
+ }
+ // Expected: proceeds_gross = net × (1 + rate), so net = gross / (1 + rate)
+ // and vat = gross - net = gross × rate / (1 + rate).
+ const expectedVat = (value.disposed_proceeds * rate) / (1 + rate)
+ if (Math.abs(expectedVat - value.proceeds_vat) > 0.5) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['proceeds_vat'],
+ message: `proceeds_vat ska vara ~${Math.round(expectedVat * 100) / 100} kr för momsbehandling "${value.vat_treatment}" på ${value.disposed_proceeds} kr brutto.`,
+ })
+ }
+ }
+ })
+
+function vatRateFromTreatment(t: (typeof VAT_TREATMENTS)[number]): number | null {
+ switch (t) {
+ case 'standard_25':
+ return 0.25
+ case 'reduced_12':
+ return 0.12
+ case 'reduced_6':
+ return 0.06
+ case 'reverse_charge':
+ case 'export':
+ case 'exempt':
+ return null
+ }
+}
export const POST = withRouteContext(
'assets.dispose',
diff --git a/app/api/assets/[id]/route.ts b/app/api/assets/[id]/route.ts
index 8309c575..1bfa7035 100644
--- a/app/api/assets/[id]/route.ts
+++ b/app/api/assets/[id]/route.ts
@@ -3,40 +3,66 @@ 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 { K3ComponentSchema } from '@/lib/api/schemas'
import { getAsset, updateAsset } from '@/lib/bokslut/assets/asset-service'
+import { validateComponents } from '@/lib/bokslut/assets/k3-components'
import type { DepreciationMethod } from '@/types'
const DEPRECIATION_METHODS: readonly DepreciationMethod[] = [
'linear',
'declining_balance_30',
'declining_balance_20',
+ 'restvardesavskrivning_25',
] 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(),
+ restvarde_target: z.number().nonnegative().nullable().optional(),
+ 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(),
+ // K3 component depreciation. Accepting `null` lets the caller clear an
+ // existing breakdown (the engine then falls back to depreciation_method).
+ // Per-component validation runs whenever the field is set to a non-null
+ // value; the cross-sum check needs acquisition_cost so it's deferred to
+ // updateAsset() which can read the existing row.
+ k3_components: z.array(K3ComponentSchema).nullable().optional(),
+ })
+ .superRefine((value, ctx) => {
+ // Enforce the method/target biconditional when EITHER field is supplied.
+ // We can't see the existing row from a zod refinement, so the
+ // application-level updateAsset() carries the cross-row check; here we
+ // only catch the obviously inconsistent combinations within a single
+ // PATCH body.
+ const hasMethod = value.depreciation_method !== undefined
+ const hasTarget = value.restvarde_target !== undefined
+ if (!hasMethod && !hasTarget) return
-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(),
-})
+ const isRestvarde = value.depreciation_method === 'restvardesavskrivning_25'
+ const targetIsSet = value.restvarde_target !== null && value.restvarde_target !== undefined
+
+ if (hasMethod && isRestvarde && hasTarget && !targetIsSet) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['restvarde_target'],
+ message: 'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).',
+ })
+ }
+ if (hasMethod && !isRestvarde && hasTarget && targetIsSet) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['restvarde_target'],
+ message: 'restvarde_target får bara anges för restvärdeavskrivning (25 %).',
+ })
+ }
+ })
export const GET = withRouteContext(
'assets.get',
@@ -62,6 +88,51 @@ export const PATCH = withRouteContext(
const { supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, UpdateAssetSchema)
if (!validation.success) return validation.response
+
+ // K3 component depreciation gating + cross-sum check.
+ // The Zod refinement cannot see the existing asset's acquisition_cost,
+ // so we do both the framework check and the sum validation here at
+ // route level before delegating to updateAsset().
+ if (validation.data.k3_components !== undefined && validation.data.k3_components !== null) {
+ const [{ data: company }, existing] = await Promise.all([
+ supabase
+ .from('companies')
+ .select('accounting_framework')
+ .eq('id', companyId)
+ .single(),
+ getAsset(supabase, companyId, id),
+ ])
+ if (!company || company.accounting_framework !== 'k3') {
+ return NextResponse.json(
+ {
+ error: {
+ code: 'K3_REQUIRED_FOR_COMPONENTS',
+ message: 'Komponentuppdelning (k3_components) kräver att företaget tillämpar K3 (BFNAR 2012:1).',
+ },
+ },
+ { status: 422 },
+ )
+ }
+ if (!existing) {
+ return NextResponse.json({ error: { code: 'ASSET_NOT_FOUND' } }, { status: 404 })
+ }
+ const { errors } = validateComponents({
+ acquisition_cost: Number(existing.acquisition_cost),
+ k3_components: validation.data.k3_components,
+ })
+ if (errors.length > 0) {
+ return NextResponse.json(
+ {
+ error: {
+ code: 'INVALID_K3_COMPONENTS',
+ message: errors.join(' '),
+ },
+ },
+ { status: 400 },
+ )
+ }
+ }
+
try {
const asset = await updateAsset(supabase, companyId, id, validation.data)
return NextResponse.json({ data: asset })
diff --git a/app/api/assets/route.ts b/app/api/assets/route.ts
index 2b961813..43046f3d 100644
--- a/app/api/assets/route.ts
+++ b/app/api/assets/route.ts
@@ -3,7 +3,9 @@ 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 { K3ComponentSchema } from '@/lib/api/schemas'
import { createAsset, listAssets } from '@/lib/bokslut/assets/asset-service'
+import { validateComponents } from '@/lib/bokslut/assets/k3-components'
import type { AssetCategory, DepreciationMethod } from '@/types'
const ASSET_CATEGORIES: readonly AssetCategory[] = [
@@ -17,18 +19,16 @@ const ASSET_CATEGORIES: readonly AssetCategory[] = [
'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.
+// All four depreciation methods are now implemented by the engine. The DB
+// CHECK constraint mirrors this list (see
+// 20260526120100_restvardeavskrivning.sql).
const DEPRECIATION_METHODS: readonly DepreciationMethod[] = [
'linear',
'declining_balance_30',
'declining_balance_20',
+ 'restvardesavskrivning_25',
] as const
-const SUPPORTED_DEPRECIATION_METHODS: readonly DepreciationMethod[] = ['linear'] as const
-
const CreateAssetSchema = z
.object({
name: z.string().min(1),
@@ -41,18 +41,21 @@ const CreateAssetSchema = z
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.',
- },
- ),
+ .optional(),
+ // Restvärde-target floor for restvärdeavskrivning. Required iff
+ // depreciation_method = 'restvardesavskrivning_25'. The DB CHECK enforces
+ // the same biconditional; we mirror it in the API for an early, Swedish
+ // error message rather than a Postgres check_violation surfacing.
+ restvarde_target: z.number().nonnegative().nullable().optional(),
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(),
+ // K3 component depreciation (BFNAR 2012:1 ch.17.4). Only meaningful for
+ // companies with accounting_framework='k3' — the route handler rejects
+ // K3_REQUIRED_FOR_COMPONENTS for K2 companies. When present, the engine
+ // dispatches to per-component linear depreciation instead of the
+ // asset-level depreciation_method.
+ k3_components: z.array(K3ComponentSchema).nullable().optional(),
notes: z.string().optional(),
})
.superRefine((value, ctx) => {
@@ -60,8 +63,70 @@ const CreateAssetSchema = z
// outside the legitimate range for the asset category so the chart stays
// BAS-aligned and INK2R mappings continue to work.
validateBasOverrides(value, ctx)
+ validateRestvardeTarget(value, ctx)
+ validateK3Components(value, ctx)
})
+function validateK3Components(
+ value: {
+ acquisition_cost: number
+ k3_components?: { name: string; cost: number; useful_life_months: number; salvage_value?: number }[] | null
+ },
+ ctx: z.RefinementCtx,
+): void {
+ if (value.k3_components === undefined || value.k3_components === null) return
+ const { errors } = validateComponents({
+ acquisition_cost: value.acquisition_cost,
+ k3_components: value.k3_components,
+ })
+ for (const message of errors) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['k3_components'],
+ message,
+ })
+ }
+}
+
+function validateRestvardeTarget(
+ value: {
+ depreciation_method?: DepreciationMethod
+ restvarde_target?: number | null
+ acquisition_cost?: number
+ },
+ ctx: z.RefinementCtx,
+): void {
+ const isRestvarde = value.depreciation_method === 'restvardesavskrivning_25'
+ const hasTarget = value.restvarde_target !== undefined && value.restvarde_target !== null
+ if (isRestvarde && !hasTarget) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['restvarde_target'],
+ message: 'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).',
+ })
+ }
+ if (!isRestvarde && hasTarget) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['restvarde_target'],
+ message: 'restvarde_target får bara anges för restvärdeavskrivning (25 %).',
+ })
+ }
+ if (
+ isRestvarde &&
+ hasTarget &&
+ value.acquisition_cost !== undefined &&
+ (value.restvarde_target as number) >= value.acquisition_cost
+ ) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['restvarde_target'],
+ message:
+ 'restvarde_target måste vara lägre än anskaffningsvärdet — annars finns inget kvar att skriva av.',
+ })
+ }
+}
+
function validateBasOverrides(
value: {
category: AssetCategory
@@ -151,6 +216,28 @@ export const POST = withRouteContext(
const { user, supabase, companyId, log, requestId } = ctx
const validation = await validateBody(request, CreateAssetSchema)
if (!validation.success) return validation.response
+ // K3_REQUIRED_FOR_COMPONENTS: K3 component depreciation is only
+ // meaningful when the company applies the K3 framework. Reject the
+ // write with 422 (Unprocessable Entity) rather than silently dropping
+ // the field so the user knows their input was discarded.
+ if (validation.data.k3_components !== undefined && validation.data.k3_components !== null) {
+ const { data: company } = await supabase
+ .from('companies')
+ .select('accounting_framework')
+ .eq('id', companyId)
+ .single()
+ if (!company || company.accounting_framework !== 'k3') {
+ return NextResponse.json(
+ {
+ error: {
+ code: 'K3_REQUIRED_FOR_COMPONENTS',
+ message: 'Komponentuppdelning (k3_components) kräver att företaget tillämpar K3 (BFNAR 2012:1).',
+ },
+ },
+ { status: 422 },
+ )
+ }
+ }
try {
const asset = await createAsset(supabase, companyId, user.id, validation.data)
return NextResponse.json({ data: asset })
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/accruals/__tests__/route.test.ts b/app/api/bookkeeping/fiscal-periods/[id]/accruals/__tests__/route.test.ts
new file mode 100644
index 00000000..146a9891
--- /dev/null
+++ b/app/api/bookkeeping/fiscal-periods/[id]/accruals/__tests__/route.test.ts
@@ -0,0 +1,104 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { createMockRequest, createMockRouteParams, parseJsonResponse } from '@/tests/helpers'
+
+const mockCreateClient = vi.fn()
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: () => mockCreateClient(),
+}))
+
+vi.mock('@/lib/init', () => ({
+ ensureInitialized: vi.fn(),
+}))
+
+vi.mock('@/lib/company/context', () => ({
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
+}))
+
+const mockBuildAccrualsProposal = vi.fn()
+const mockDetectPeriodisering = vi.fn()
+vi.mock('@/lib/bokslut/accruals/accrual-detector', async () => {
+ const actual =
+ (await vi.importActual('@/lib/bokslut/accruals/accrual-detector')) as Record
+ return {
+ ...actual,
+ buildAccrualsProposal: (...args: unknown[]) => mockBuildAccrualsProposal(...args),
+ }
+})
+
+vi.mock('@/lib/bokslut/accruals/auto-detect', () => ({
+ detectPeriodisering: (...args: unknown[]) => mockDetectPeriodisering(...args),
+}))
+
+const mockUser = { id: 'user-1', email: 'test@test.se' }
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockCreateClient.mockResolvedValue({
+ auth: { getUser: vi.fn().mockResolvedValue({ data: { user: mockUser } }) },
+ })
+})
+
+describe('GET /api/bookkeeping/fiscal-periods/[id]/accruals', () => {
+ it('returns 401 when unauthenticated', async () => {
+ mockCreateClient.mockResolvedValue({
+ auth: { getUser: vi.fn().mockResolvedValue({ data: { user: null } }) },
+ })
+ const { GET } = await import('../route')
+ const res = await GET(
+ createMockRequest('/api/bookkeeping/fiscal-periods/period-1/accruals'),
+ createMockRouteParams({ id: 'period-1' }),
+ )
+ expect(res.status).toBe(401)
+ })
+
+ it('returns the snapshot plus autoDetected suggestions', async () => {
+ mockBuildAccrualsProposal.mockResolvedValue({
+ fiscalPeriod: { id: 'period-1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' },
+ proposals: [],
+ })
+ mockDetectPeriodisering.mockResolvedValue([
+ {
+ source_invoice_id: 'sup-1',
+ source_type: 'supplier_invoice',
+ original_amount: 12000,
+ periodisering_amount: 6000,
+ parsed_start: '2025-07-01',
+ parsed_end: '2026-06-30',
+ confidence: 'high',
+ reason: 'Mock reason',
+ source_label: 'Test Supplier',
+ suggested_prepaid_account: '1710',
+ suggested_deferred_account: null,
+ },
+ ])
+ const { GET } = await import('../route')
+ const res = await GET(
+ createMockRequest('/api/bookkeeping/fiscal-periods/period-1/accruals'),
+ createMockRouteParams({ id: 'period-1' }),
+ )
+ const { status, body } = await parseJsonResponse<{ data: { autoDetected: unknown[] } }>(res)
+ expect(status).toBe(200)
+ expect(body.data.autoDetected).toHaveLength(1)
+ })
+
+ it('still returns the snapshot when auto-detect throws', async () => {
+ mockBuildAccrualsProposal.mockResolvedValue({
+ fiscalPeriod: { id: 'period-1', name: 'FY 2025', period_start: '2025-01-01', period_end: '2025-12-31' },
+ proposals: [],
+ })
+ mockDetectPeriodisering.mockRejectedValue(new Error('boom'))
+ const { GET } = await import('../route')
+ const res = await GET(
+ createMockRequest('/api/bookkeeping/fiscal-periods/period-1/accruals'),
+ createMockRouteParams({ id: 'period-1' }),
+ )
+ const { status, body } = await parseJsonResponse<{ data: { autoDetected: unknown[] } }>(res)
+ expect(status).toBe(200)
+ expect(body.data.autoDetected).toEqual([])
+ })
+})
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/accruals/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/accruals/route.ts
index 10d4217a..b055df4f 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/accruals/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/accruals/route.ts
@@ -6,11 +6,15 @@ import { validateBody } from '@/lib/api/validate'
import { createJournalEntry } from '@/lib/bookkeeping/engine'
import {
buildAccrualsProposal,
+ proposeAccruedInterest,
+ proposeAccruedUtility,
proposeAuditFee,
proposeManualAccrued,
proposeManualPrepaid,
+ proposeRevenueDeferral,
proposeVacationLiabilityChange,
} from '@/lib/bokslut/accruals/accrual-detector'
+import { detectPeriodisering } from '@/lib/bokslut/accruals/auto-detect'
import type { AccrualProposal } from '@/lib/bokslut/accruals/types'
import type { JournalEntry } from '@/types'
@@ -20,8 +24,18 @@ export const GET = withRouteContext(
const { id } = await params
const { supabase, companyId, log, requestId } = ctx
try {
- const proposal = await buildAccrualsProposal(supabase, companyId, id)
- return NextResponse.json({ data: proposal })
+ // Run the two independent scans in parallel so the wizard's first
+ // paint isn't gated on the slower auto-detect query.
+ const [proposal, autoDetected] = await Promise.all([
+ buildAccrualsProposal(supabase, companyId, id),
+ detectPeriodisering(supabase, companyId, id).catch((err) => {
+ // Auto-detect is best-effort — a malformed invoice description
+ // shouldn't break the rest of the preflight. Log + return empty.
+ log.warn('auto-detect failed', { error: (err as Error)?.message })
+ return []
+ }),
+ ])
+ return NextResponse.json({ data: { ...proposal, autoDetected } })
} catch (err) {
const message = err instanceof Error ? err.message : ''
if (/not found/i.test(message)) {
@@ -32,6 +46,19 @@ export const GET = withRouteContext(
},
)
+// Defense-in-depth on caller-supplied account numbers. The wizard sends
+// accounts from a closed template list, but the API accepts them as plain
+// strings so we constrain the BAS class per accrual kind:
+// - cost accounts (5xxx-8xxx) for expense legs
+// - revenue accounts (3xxx) for revenue legs
+// - 17xx for förutbetalda kostnader (prepaid)
+// - 29xx for upplupna poster (accrued / deferred)
+// Anything outside these ranges is rejected with 400 before reaching the
+// engine — keeps a compromised browser session from posting arbitrary
+// balance-sheet hits.
+const EXPENSE_ACCOUNT_RE = /^[5-8]\d{3}$/
+const REVENUE_ACCOUNT_RE = /^3\d{3}$/
+
const PostItemSchema = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('vacation_liability_change') }),
z.object({
@@ -42,14 +69,35 @@ const PostItemSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('manual_prepaid_expense'),
amount: z.number().positive(),
- expense_account: z.string().regex(/^\d{4}$/),
+ expense_account: z.string().regex(EXPENSE_ACCOUNT_RE),
prepaid_account: z.string().regex(/^17\d{2}$/),
description: z.string().min(1),
}),
z.object({
kind: z.literal('manual_accrued_expense'),
amount: z.number().positive(),
- expense_account: z.string().regex(/^\d{4}$/),
+ expense_account: z.string().regex(EXPENSE_ACCOUNT_RE),
+ accrued_account: z.string().regex(/^29\d{2}$/),
+ description: z.string().min(1),
+ }),
+ z.object({
+ kind: z.literal('deferred_revenue'),
+ amount: z.number().positive(),
+ revenue_account: z.string().regex(REVENUE_ACCOUNT_RE),
+ deferred_account: z.string().regex(/^29\d{2}$/),
+ description: z.string().min(1),
+ }),
+ z.object({
+ kind: z.literal('accrued_interest'),
+ amount: z.number().positive(),
+ expense_account: z.string().regex(EXPENSE_ACCOUNT_RE),
+ accrued_account: z.string().regex(/^29\d{2}$/),
+ description: z.string().min(1),
+ }),
+ z.object({
+ kind: z.literal('accrued_utility'),
+ amount: z.number().positive(),
+ expense_account: z.string().regex(EXPENSE_ACCOUNT_RE),
accrued_account: z.string().regex(/^29\d{2}$/),
description: z.string().min(1),
}),
@@ -132,6 +180,33 @@ export const POST = withRouteContext(
closingDate: period.period_end,
})
break
+ case 'deferred_revenue':
+ proposal = proposeRevenueDeferral({
+ amount: item.amount,
+ revenueAccount: item.revenue_account,
+ deferredAccount: item.deferred_account,
+ description: item.description,
+ closingDate: period.period_end,
+ })
+ break
+ case 'accrued_interest':
+ proposal = proposeAccruedInterest({
+ amount: item.amount,
+ expenseAccount: item.expense_account,
+ accruedAccount: item.accrued_account,
+ description: item.description,
+ closingDate: period.period_end,
+ })
+ break
+ case 'accrued_utility':
+ proposal = proposeAccruedUtility({
+ amount: item.amount,
+ expenseAccount: item.expense_account,
+ accruedAccount: item.accrued_account,
+ description: item.description,
+ closingDate: period.period_end,
+ })
+ break
}
if (!proposal) continue
@@ -199,6 +274,15 @@ async function findExistingAccrualEntry(
case 'manual_accrued_expense':
pattern = `Periodisering: Upplupen kostnad: ${escapeLike(item.description)}%`
break
+ case 'deferred_revenue':
+ pattern = `Periodisering: Förutbetald intäkt: ${escapeLike(item.description)}%`
+ break
+ case 'accrued_interest':
+ pattern = `Periodisering: Upplupen ränta: ${escapeLike(item.description)}%`
+ break
+ case 'accrued_utility':
+ pattern = `Periodisering: Upplupen förbrukning: ${escapeLike(item.description)}%`
+ break
}
const { data } = await supabase
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/pdf/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/pdf/route.ts
index 0b7856ec..de23f741 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/pdf/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/pdf/route.ts
@@ -3,6 +3,7 @@ import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
import { ArsredovisningPDF } from '@/lib/bokslut/arsredovisning/arsredovisning-pdf'
+import { ArsredovisningK3PDF } from '@/lib/bokslut/arsredovisning/arsredovisning-k3-pdf'
export const GET = withRouteContext(
'period.arsredovisning_pdf',
@@ -14,7 +15,15 @@ export const GET = withRouteContext(
// inside buildArsredovisningData. The URL stays clean — no narrative
// text in query params, access logs, or browser history.
const data = await buildArsredovisningData(supabase, companyId, id)
- const pdfBuffer = await renderToBuffer(ArsredovisningPDF({ data }))
+ // Dispatch on the framework recorded in the data. K3 documents need
+ // the additional kassaflöde + equity-changes pages + richer noter
+ // that ArsredovisningK3PDF renders. K2 (the default) keeps the
+ // existing template byte-for-byte unchanged.
+ const PdfComponent =
+ data.accounting_framework === 'k3'
+ ? ArsredovisningK3PDF
+ : ArsredovisningPDF
+ const pdfBuffer = await renderToBuffer(PdfComponent({ data }))
// "-utkast" suffix mirrors the existing PDF routes; the file becomes
// "fastställd" only after the signature flow records all signatures.
// Sanitize the dynamic segment so a stray quote / newline in the date
diff --git a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts
index 69284c02..3d92ea1e 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/bokslutsdispositioner/route.ts
@@ -13,7 +13,10 @@ import {
} from '@/lib/bokslut/reserves/periodiseringsfond-service'
import { proposeOveravskrivningar } from '@/lib/bokslut/reserves/overavskrivningar-service'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
-import { buildDispositionsProposal } from '@/lib/bokslut/dispositions-proposal-builder'
+import {
+ buildDispositionsProposal,
+ buildLatentTaxProposal,
+} from '@/lib/bokslut/dispositions-proposal-builder'
import type { ProposedDisposition } from '@/lib/bokslut/types'
import type { JournalEntry } from '@/types'
@@ -45,6 +48,9 @@ const DISPOSITION_ORDER: Record = {
periodiseringsfond_avsattning: 2,
sarskild_loneskatt: 3,
bolagsskatt: 4,
+ // K3 only — posts last because it depends on the closing 21xx balance,
+ // which only stabilises once avsättning / återföring have been applied.
+ uppskjuten_skatt: 5,
}
// ============================================================
@@ -112,6 +118,11 @@ const ItemSchema = z.discriminatedUnion('kind', [
.enum(['machinery_equipment', 'building', 'immaterial', 'group'])
.optional(),
}),
+ // K3 only — uppskjuten skatt provision. Server recomputes the amount from
+ // current 2240 + 21xx state so the client cannot override it.
+ z.object({
+ kind: z.literal('uppskjuten_skatt'),
+ }),
])
const PostBodySchema = z.object({
@@ -260,6 +271,15 @@ async function computeProposal(
additionalAmount: item.additionalAmount,
category: item.category,
})
+ case 'uppskjuten_skatt':
+ // Server-only: recompute from current TB (which already reflects any
+ // 21xx postings that committed earlier in this batch). The client
+ // sends no amount — the calculator owns the K3 split.
+ return buildLatentTaxProposal({
+ supabase,
+ companyId,
+ fiscalPeriodId,
+ })
}
}
diff --git a/app/api/bookkeeping/journal-entries/route.ts b/app/api/bookkeeping/journal-entries/route.ts
index da207be1..b806575e 100644
--- a/app/api/bookkeeping/journal-entries/route.ts
+++ b/app/api/bookkeeping/journal-entries/route.ts
@@ -28,6 +28,10 @@ export async function GET(request: Request) {
const dateFrom = searchParams.get('date_from')
const dateTo = searchParams.get('date_to')
const sortDate = searchParams.get('sort_date') // 'asc' | 'desc'
+ // 'series' optional filter — single uppercase letter A–Z. Ignored if any
+ // other value is passed (defense against trivial injection / typos).
+ const seriesRaw = searchParams.get('series')
+ const seriesFilter = seriesRaw && /^[A-Z]$/.test(seriesRaw) ? seriesRaw : null
// 'date_desc' (default) | 'date_asc' | 'voucher_asc' | 'voucher_desc'
// sort_by overrides sort_date when present. sort_date is kept for backwards
// compatibility with older clients.
@@ -69,8 +73,18 @@ export async function GET(request: Request) {
}
const rows = data ?? []
- const entries = rows.map((r: { entry: unknown }) => r.entry)
- const count = rows.length > 0 ? Number((rows[0] as { total_count: number | string }).total_count) : 0
+ let entries = rows.map((r: { entry: unknown }) => r.entry) as Array<{ voucher_series?: string }>
+ let count = rows.length > 0 ? Number((rows[0] as { total_count: number | string }).total_count) : 0
+
+ // The list_fiscal_period_entries_with_related RPC doesn't accept a series
+ // filter, so post-filter here. Recompute count from the filtered set so
+ // the paginator stays consistent; consequence: when a series filter is
+ // applied, the cross-period follow-up surfacing is still on but the
+ // visible total drops to the matching subset.
+ if (seriesFilter) {
+ entries = entries.filter((e) => (e?.voucher_series ?? 'A') === seriesFilter)
+ count = entries.length
+ }
return NextResponse.json({ data: entries, count })
}
@@ -115,6 +129,10 @@ export async function GET(request: Request) {
query = query.lte('entry_date', dateTo)
}
+ if (seriesFilter) {
+ query = query.eq('voucher_series', seriesFilter)
+ }
+
const { data, error, count } = await query
if (error) {
diff --git a/app/api/company/current/route.ts b/app/api/company/current/route.ts
index 8f10f4d6..b4334132 100644
--- a/app/api/company/current/route.ts
+++ b/app/api/company/current/route.ts
@@ -1,6 +1,21 @@
import { createClient } from '@/lib/supabase/server'
-import { getActiveCompanyId } from '@/lib/company/context'
+import { getActiveCompanyId, requireCompanyId } from '@/lib/company/context'
+import { requireWritePermission } from '@/lib/auth/require-write'
+import { validateBody } from '@/lib/api/validate'
+import { AccountingFrameworkSchema } from '@/lib/api/schemas'
+import { getBASReference } from '@/lib/bookkeeping/bas-reference'
+import { createLogger } from '@/lib/logger'
import { NextResponse } from 'next/server'
+import { z } from 'zod'
+
+const log = createLogger('api/company/current')
+
+// BAS 2026 accounts required for K3's uppskjuten skatt (latent tax) entries.
+// Both rows carry k2_excluded=true in lib/bookkeeping/bas-data so they are
+// NOT seeded by seed_chart_of_accounts() for K2 companies. When a company
+// opts into K3 we backfill them here so the engine can resolve them by
+// account_number when the first latent-tax entry is posted.
+const K3_LATENT_TAX_ACCOUNTS = ['2240', '8940'] as const
/**
* GET /api/company/current
@@ -34,3 +49,143 @@ export async function GET() {
{ headers: { 'Cache-Control': 'private, no-store' } },
)
}
+
+/**
+ * Body shape for PATCH /api/company/current.
+ *
+ * Currently only carries `accounting_framework` (K2 / K3). Adding more
+ * companies-level fields here is fine but anything that belongs on
+ * company_settings should go to /api/settings instead.
+ */
+const PatchBodySchema = z.object({
+ accounting_framework: AccountingFrameworkSchema.optional(),
+})
+
+/**
+ * PATCH /api/company/current
+ *
+ * Updates company-level fields (in the `companies` table) for the active
+ * company. Separate from /api/settings (which writes to `company_settings`)
+ * because the columns live on different tables.
+ *
+ * Currently scoped to `accounting_framework` (K2 / K3) — only meaningful for
+ * entity_type='aktiebolag'. The handler rejects K3 for non-AB to prevent
+ * impossible chart-of-accounts states downstream.
+ */
+export async function PATCH(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const writeCheck = await requireWritePermission(supabase, user.id)
+ if (!writeCheck.ok) return writeCheck.response
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const validation = await validateBody(request, PatchBodySchema)
+ if (!validation.success) return validation.response
+
+ const updates: Record = {}
+
+ if (validation.data.accounting_framework !== undefined) {
+ // Only AB can opt in to K3 — EF stays on the simpler EF rules and never
+ // touches K2/K3. Fetch the entity_type before applying.
+ const { data: company } = await supabase
+ .from('companies')
+ .select('entity_type')
+ .eq('id', companyId)
+ .single()
+ if (!company) {
+ return NextResponse.json(
+ { error: 'Företaget kunde inte hittas' },
+ { status: 404 },
+ )
+ }
+ if (
+ validation.data.accounting_framework === 'k3'
+ && company.entity_type !== 'aktiebolag'
+ ) {
+ return NextResponse.json(
+ { error: 'K3 (BFNAR 2012:1) gäller endast aktiebolag.' },
+ { status: 400 },
+ )
+ }
+ updates.accounting_framework = validation.data.accounting_framework
+ }
+
+ if (Object.keys(updates).length === 0) {
+ // Nothing to write — surface the current row so the client can refresh
+ // its local state without a no-op write.
+ const { data } = await supabase
+ .from('companies')
+ .select('id, accounting_framework, entity_type')
+ .eq('id', companyId)
+ .single()
+ return NextResponse.json({ data })
+ }
+
+ const { data, error } = await supabase
+ .from('companies')
+ .update(updates)
+ .eq('id', companyId)
+ .select('id, accounting_framework, entity_type')
+ .single()
+
+ if (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 })
+ }
+
+ // When opting in to K3, ensure the two latent-tax (uppskjuten skatt)
+ // accounts exist in the company's chart of accounts. The base seed skips
+ // them for K2 companies via k2_excluded=true, so without this backfill
+ // the engine cannot resolve account_id for the first latent-tax post.
+ // Wrapped in try/catch so a CoA insert failure does not block the
+ // framework update — the user can still re-trigger the seed later.
+ // The reverse switch (K3 → K2) intentionally keeps the rows for audit
+ // history; the legal record of past K3 postings must remain intact.
+ if (data.accounting_framework === 'k3') {
+ try {
+ const rows = K3_LATENT_TAX_ACCOUNTS.map(accountNumber => {
+ const basRef = getBASReference(accountNumber)
+ if (!basRef) return null
+ return {
+ user_id: user.id,
+ company_id: companyId,
+ account_number: basRef.account_number,
+ account_name: basRef.account_name,
+ account_class: basRef.account_class,
+ account_group: basRef.account_group,
+ account_type: basRef.account_type,
+ normal_balance: basRef.normal_balance,
+ sru_code: basRef.sru_code,
+ k2_excluded: basRef.k2_excluded,
+ plan_type: 'full_bas',
+ is_active: true,
+ is_system_account: true,
+ description: basRef.description,
+ }
+ }).filter((row): row is NonNullable => row !== null)
+
+ if (rows.length > 0) {
+ const { error: seedError } = await supabase
+ .from('chart_of_accounts')
+ .upsert(rows, { onConflict: 'company_id,account_number', ignoreDuplicates: true })
+ if (seedError) {
+ log.error('Failed to seed K3 latent-tax accounts', {
+ companyId,
+ error: seedError.message,
+ })
+ }
+ }
+ } catch (err) {
+ log.error('Unexpected error seeding K3 latent-tax accounts', {
+ companyId,
+ error: err instanceof Error ? err.message : String(err),
+ })
+ }
+ }
+
+ return NextResponse.json({ data })
+}
diff --git a/app/api/documents/[id]/__tests__/route.test.ts b/app/api/documents/[id]/__tests__/route.test.ts
new file mode 100644
index 00000000..6924eda3
--- /dev/null
+++ b/app/api/documents/[id]/__tests__/route.test.ts
@@ -0,0 +1,154 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { eventBus } from '@/lib/events/bus'
+import {
+ parseJsonResponse,
+ createMockRouteParams,
+ createQueuedMockSupabase,
+} from '@/tests/helpers'
+
+const { supabase: mockSupabase, enqueue, reset } = createQueuedMockSupabase()
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: () => Promise.resolve(mockSupabase),
+}))
+
+vi.mock('@/lib/company/context', () => ({
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+ getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+vi.mock('@/lib/auth/require-write', () => ({
+ requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
+}))
+
+vi.mock('@/lib/init', () => ({
+ ensureInitialized: vi.fn(),
+}))
+
+import { DELETE } from '../route'
+import { requireWritePermission } from '@/lib/auth/require-write'
+import { NextResponse } from 'next/server'
+
+const mockUser = { id: 'user-1', email: 'test@test.se' }
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ reset()
+ eventBus.clear()
+ mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
+ // Reset write-permission mock to default ok
+ vi.mocked(requireWritePermission).mockResolvedValue({ ok: true })
+})
+
+function makeReq() {
+ return new Request('http://localhost/api/documents/doc-1', { method: 'DELETE' })
+}
+
+describe('DELETE /api/documents/[id]', () => {
+ it('returns 401 when not authenticated', async () => {
+ mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
+ const res = await DELETE(makeReq(), createMockRouteParams({ id: 'doc-1' }))
+ const { status, body } = await parseJsonResponse(res)
+ expect(status).toBe(401)
+ expect(body).toEqual({ error: 'Unauthorized' })
+ })
+
+ it('returns 403 when caller has read-only role', async () => {
+ vi.mocked(requireWritePermission).mockResolvedValue({
+ ok: false,
+ response: NextResponse.json(
+ { error: 'Du har endast läsbehörighet i detta företag.' },
+ { status: 403 },
+ ),
+ })
+ const res = await DELETE(makeReq(), createMockRouteParams({ id: 'doc-1' }))
+ const { status } = await parseJsonResponse(res)
+ expect(status).toBe(403)
+ })
+
+ it('returns 404 when document not found in company', async () => {
+ enqueue({ data: null, error: null }) // doc lookup
+ const res = await DELETE(makeReq(), createMockRouteParams({ id: 'doc-1' }))
+ const { status, body } = await parseJsonResponse<{ error: string }>(res)
+ expect(status).toBe(404)
+ expect(body.error).toContain('hittades inte')
+ })
+
+ it('returns 409 with BFL message when doc is linked to a journal entry', async () => {
+ enqueue({
+ data: {
+ id: 'doc-1',
+ file_name: 'kvitto.pdf',
+ storage_path: 'documents/user-1/kvitto.pdf',
+ journal_entry_id: 'je-99',
+ user_id: 'user-1',
+ },
+ error: null,
+ })
+ const res = await DELETE(makeReq(), createMockRouteParams({ id: 'doc-1' }))
+ const { status, body } = await parseJsonResponse<{ error: string }>(res)
+ expect(status).toBe(409)
+ expect(body.error).toContain('Bokföringslagen')
+ expect(body.error).toContain('7 kap')
+ })
+
+ it('deletes the row, removes Storage file, and emits document.deleted on unlinked doc', async () => {
+ enqueue({
+ data: {
+ id: 'doc-1',
+ file_name: 'kvitto.pdf',
+ storage_path: 'documents/user-1/kvitto.pdf',
+ journal_entry_id: null,
+ user_id: 'user-1',
+ },
+ error: null,
+ })
+ enqueue({ data: null, error: null }) // delete
+
+ const handler = vi.fn()
+ eventBus.on('document.deleted', handler)
+
+ const res = await DELETE(makeReq(), createMockRouteParams({ id: 'doc-1' }))
+ const { status, body } = await parseJsonResponse<{ data: { id: string; deleted: boolean } }>(res)
+
+ expect(status).toBe(200)
+ expect(body.data).toEqual({ id: 'doc-1', deleted: true })
+
+ expect(mockSupabase.storage.from).toHaveBeenCalledWith('documents')
+ const storageBucket = mockSupabase.storage.from.mock.results[0]?.value
+ expect(storageBucket.remove).toHaveBeenCalledWith(['documents/user-1/kvitto.pdf'])
+
+ expect(handler).toHaveBeenCalledOnce()
+ expect(handler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ document: expect.objectContaining({ id: 'doc-1', file_name: 'kvitto.pdf' }),
+ userId: 'user-1',
+ companyId: 'company-1',
+ }),
+ )
+ })
+
+ it('returns 409 with BFL message when DB trigger blocks deletion (defense-in-depth)', async () => {
+ // Caller bypasses the application-layer check (e.g. race condition).
+ // The block_document_deletion() trigger raises with "Bokföringslagen" in the
+ // message; the service maps it to a 409.
+ enqueue({
+ data: {
+ id: 'doc-1',
+ file_name: 'kvitto.pdf',
+ storage_path: 'documents/user-1/kvitto.pdf',
+ journal_entry_id: null,
+ user_id: 'user-1',
+ },
+ error: null,
+ })
+ enqueue({
+ data: null,
+ error: { message: 'Cannot delete document linked to a posted journal entry (Bokföringslagen)' },
+ })
+
+ const res = await DELETE(makeReq(), createMockRouteParams({ id: 'doc-1' }))
+ const { status, body } = await parseJsonResponse<{ error: string }>(res)
+ expect(status).toBe(409)
+ expect(body.error).toContain('Bokföringslagen')
+ })
+})
diff --git a/app/api/documents/[id]/route.ts b/app/api/documents/[id]/route.ts
index dca1e9eb..4e38c362 100644
--- a/app/api/documents/[id]/route.ts
+++ b/app/api/documents/[id]/route.ts
@@ -2,6 +2,8 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { ensureInitialized } from '@/lib/init'
import { requireCompanyId } from '@/lib/company/context'
+import { requireWritePermission } from '@/lib/auth/require-write'
+import { deleteDocument } from '@/lib/core/documents/document-service'
import { eventBus } from '@/lib/events'
ensureInitialized()
@@ -66,3 +68,45 @@ export async function GET(
},
})
}
+
+/**
+ * DELETE /api/documents/:id
+ * Remove an uploaded document. Only permitted when the document is not yet
+ * linked to a journal entry — once linked, it is räkenskapsinformation under
+ * BFL 7 kap 2§ and must be retained for 7 years. For linked docs the caller
+ * should use POST /api/documents/:id/versions to supersede via a new version.
+ */
+export async function DELETE(
+ _request: Request,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const supabase = await createClient()
+
+ const { data: { user } } = await supabase.auth.getUser()
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const writeCheck = await requireWritePermission(supabase, user.id)
+ if (!writeCheck.ok) return writeCheck.response
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { id } = await params
+
+ try {
+ const result = await deleteDocument(supabase, companyId, id)
+
+ if (!result.ok) {
+ return NextResponse.json({ error: result.message }, { status: result.status })
+ }
+
+ return NextResponse.json({ data: { id: result.document.id, deleted: true } })
+ } catch (error) {
+ console.error('[documents/DELETE] Failed to delete document:', error)
+ return NextResponse.json(
+ { error: error instanceof Error ? error.message : 'Failed to delete document' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/import/sie/[id]/replace/route.ts b/app/api/import/sie/[id]/replace/route.ts
index fa7e0ad2..a50e63ae 100644
--- a/app/api/import/sie/[id]/replace/route.ts
+++ b/app/api/import/sie/[id]/replace/route.ts
@@ -6,8 +6,8 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
/**
* POST /api/import/sie/[id]/replace
*
- * Replace a completed SIE import by cancelling its entries, allowing the user
- * to re-import corrected data for the same fiscal period.
+ * Replace a completed SIE import by hard-deleting its entries, allowing the
+ * user to re-import corrected data for the same fiscal period.
*/
export const POST = withRouteContext(
'sie_import.replace',
@@ -25,7 +25,7 @@ export const POST = withRouteContext(
})
}
- return NextResponse.json({ success: true, cancelledEntries: result.cancelledEntries })
+ return NextResponse.json({ success: true, deletedEntries: result.deletedEntries })
},
{ requireWrite: true },
)
diff --git a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts
index 3dfee258..6cd47091 100644
--- a/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts
+++ b/app/api/invoices/[id]/mark-sent/__tests__/route.test.ts
@@ -39,6 +39,7 @@ vi.mock('@react-pdf/renderer', () => ({
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
+ brandingFromCompanySettings: vi.fn().mockReturnValue({}),
}))
import { InvoicePDF } from '@/lib/invoices/pdf-template'
diff --git a/app/api/invoices/[id]/mark-sent/route.ts b/app/api/invoices/[id]/mark-sent/route.ts
index ff95c8eb..c2c177b1 100644
--- a/app/api/invoices/[id]/mark-sent/route.ts
+++ b/app/api/invoices/[id]/mark-sent/route.ts
@@ -5,6 +5,7 @@ import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { ensureInitialized } from '@/lib/init'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
+import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { requireCompanyId } from '@/lib/company/context'
import { requireWritePermission } from '@/lib/auth/require-write'
@@ -134,13 +135,16 @@ export async function POST(
// The DB status flip already happened above, but the in-memory `invoice`
// is stale and still reads 'draft' — override here so the archived
// underlag isn't stamped "UTKAST – inte en giltig faktura".
+ const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
+ const { branding } = prepareInvoicePdfRender(settings as CompanySettings)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
- invoice: { ...(invoice as Invoice), status: 'sent' as const },
+ invoice: renderableInvoice,
customer: invoice.customer as Customer,
items,
company: settings as CompanySettings,
originalInvoiceNumber,
+ branding,
})
)
diff --git a/app/api/invoices/[id]/pdf/route.ts b/app/api/invoices/[id]/pdf/route.ts
index 077bea5c..9befa4d2 100644
--- a/app/api/invoices/[id]/pdf/route.ts
+++ b/app/api/invoices/[id]/pdf/route.ts
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
+import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice, InvoiceItem, Customer, CompanySettings } from '@/types'
@@ -66,6 +67,7 @@ export async function GET(
try {
// Generate PDF
+ const { branding } = prepareInvoicePdfRender(company as CompanySettings)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: invoice as Invoice,
@@ -73,6 +75,7 @@ export async function GET(
items,
company: company as CompanySettings,
originalInvoiceNumber,
+ branding,
})
)
diff --git a/app/api/invoices/[id]/send/__tests__/route.test.ts b/app/api/invoices/[id]/send/__tests__/route.test.ts
index 05c4067e..adac2750 100644
--- a/app/api/invoices/[id]/send/__tests__/route.test.ts
+++ b/app/api/invoices/[id]/send/__tests__/route.test.ts
@@ -40,6 +40,7 @@ vi.mock('@react-pdf/renderer', () => ({
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue('mock-pdf-element'),
+ brandingFromCompanySettings: vi.fn().mockReturnValue({}),
}))
import { InvoicePDF } from '@/lib/invoices/pdf-template'
diff --git a/app/api/invoices/[id]/send/route.ts b/app/api/invoices/[id]/send/route.ts
index fd46a782..28b6a70f 100644
--- a/app/api/invoices/[id]/send/route.ts
+++ b/app/api/invoices/[id]/send/route.ts
@@ -3,6 +3,7 @@ import { eventBus } from '@/lib/events'
import { ensureInitialized } from '@/lib/init'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
+import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { getEmailService } from '@/lib/email/service'
import {
generateInvoiceEmailHtml,
@@ -93,6 +94,7 @@ export const POST = withRouteContext(
const isFreshAllocation = !invoice.invoice_number
if (isFreshAllocation) {
try {
+ const preflight = prepareInvoicePdfRender(company as CompanySettings)
await renderToBuffer(
InvoicePDF({
invoice: { ...(invoice as Invoice), invoice_number: 'F-PREVIEW' },
@@ -100,6 +102,7 @@ export const POST = withRouteContext(
items,
company: company as CompanySettings,
originalInvoiceNumber,
+ branding: preflight.branding,
}),
)
} catch (err) {
@@ -121,13 +124,16 @@ export const POST = withRouteContext(
// the in-memory copy: the DB flip happens after email delivery (line
// ~185), but if we render with the stale 'draft' status the customer
// receives a PDF stamped "UTKAST – inte en giltig faktura".
+ const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
+ const { branding } = prepareInvoicePdfRender(company as CompanySettings)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
- invoice: { ...(invoice as Invoice), status: 'sent' as const },
+ invoice: renderableInvoice,
customer,
items,
company: company as CompanySettings,
originalInvoiceNumber,
+ branding,
}),
)
diff --git a/app/api/invoices/next-number/route.ts b/app/api/invoices/next-number/route.ts
index 0b28169d..457fba81 100644
--- a/app/api/invoices/next-number/route.ts
+++ b/app/api/invoices/next-number/route.ts
@@ -9,7 +9,7 @@ export const GET = withRouteContext(
const url = new URL(request.url)
const documentType = url.searchParams.get('document_type') ?? 'invoice'
- if (!['invoice', 'proforma', 'delivery_note'].includes(documentType)) {
+ if (!['invoice', 'proforma', 'delivery_note', 'quote'].includes(documentType)) {
return NextResponse.json(
{ error: 'invalid document_type', requestId },
{ status: 400 },
diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts
index ef2ff7d2..80739e5d 100644
--- a/app/api/invoices/preview-pdf/route.ts
+++ b/app/api/invoices/preview-pdf/route.ts
@@ -2,6 +2,7 @@ import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
+import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { getVatRules } from '@/lib/invoices/vat-rules'
import { requireCompanyId } from '@/lib/company/context'
import type { Invoice, InvoiceItem, Customer, CompanySettings, InvoiceDocumentType } from '@/types'
@@ -167,6 +168,7 @@ export async function POST(request: Request) {
} as Invoice
try {
+ const { branding } = prepareInvoicePdfRender(company as CompanySettings)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: previewInvoice,
@@ -174,6 +176,7 @@ export async function POST(request: Request) {
items: invoiceItems,
company: company as CompanySettings,
isPreview: true,
+ branding,
})
)
diff --git a/app/api/invoices/reminders/action/route.ts b/app/api/invoices/reminders/action/route.ts
index 448a0ba2..431eb6ef 100644
--- a/app/api/invoices/reminders/action/route.ts
+++ b/app/api/invoices/reminders/action/route.ts
@@ -120,6 +120,11 @@ export async function GET(request: Request) {
sent_at,
response_type,
action_token_used,
+ interest_amount,
+ interest_rate,
+ interest_from_date,
+ interest_days,
+ reminder_fee,
invoice:invoices(
id,
invoice_number,
@@ -159,6 +164,11 @@ export async function GET(request: Request) {
const customerData = invoice.customer
const customer = Array.isArray(customerData) ? customerData[0] : customerData
+ const interestAmount = Number(reminder.interest_amount ?? 0)
+ const reminderFee = Number(reminder.reminder_fee ?? 0)
+ const totalDue =
+ Math.round((Number(invoice.total) + interestAmount + reminderFee) * 100) / 100
+
return NextResponse.json({
invoiceNumber: invoice.invoice_number,
invoiceDate: invoice.invoice_date,
@@ -168,6 +178,15 @@ export async function GET(request: Request) {
customerName: customer?.name,
reminderLevel: reminder.reminder_level,
alreadyResponded: reminder.action_token_used,
- previousResponse: reminder.response_type
+ previousResponse: reminder.response_type,
+ // Dröjsmålsränta + lagstadgad påminnelseavgift surfaced to the
+ // customer-facing action page. Numeric defaults preserve back-compat
+ // for old reminders sent before the surcharge feature shipped.
+ interestAmount,
+ interestRate: reminder.interest_rate !== null ? Number(reminder.interest_rate) : 0,
+ interestFromDate: reminder.interest_from_date,
+ interestDays: reminder.interest_days,
+ reminderFee,
+ totalDue,
})
}
diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts
index 0296cb66..c6316742 100644
--- a/app/api/invoices/route.ts
+++ b/app/api/invoices/route.ts
@@ -8,6 +8,16 @@ import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules'
import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken'
import { createCreditNoteJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
+import {
+ computeDeduction,
+ computeInvoiceDeductionTotal,
+ validateInvoice as validateRotRut,
+} from '@/lib/invoices/rot-rut-rules'
+import {
+ encryptPersonnummer,
+ extractLast4,
+ validatePersonnummer,
+} from '@/lib/salary/personnummer'
import { withRouteContext } from '@/lib/api/with-route-context'
import { errorResponse, errorResponseFromCode } from '@/lib/errors/get-structured-error'
import type { Logger } from '@/lib/logger'
@@ -135,6 +145,51 @@ export const POST = withRouteContext(
}
const total = documentType === 'delivery_note' ? 0 : subtotal + vatAmount
+ // ROT/RUT-avdrag: validate prerequisites and compute the per-item +
+ // invoice-level deduction. Computed server-side (never trusted from
+ // the client) so a tampered request can't expand the 1513 receivable.
+ // Skipped entirely for proformas, delivery notes, and quotes — those
+ // documents don't post journal entries and have no deduction model.
+ let deductionTotal = 0
+ let deductionPersonnummerEncrypted: string | null = null
+ let deductionPersonnummerLast4: string | null = null
+ if (documentType === 'invoice') {
+ const housingProvided = !!invoiceInput.deduction_housing_designation?.trim()
+ const personnummerRaw = invoiceInput.deduction_personnummer?.trim() || ''
+ const personnummerProvided = personnummerRaw.length > 0
+
+ const validateInput = invoiceInput.items.map((item) => ({
+ unit_price: item.unit_price,
+ quantity: item.quantity,
+ deduction_type: item.deduction_type ?? null,
+ labor_hours: item.labor_hours ?? null,
+ housing_designation: item.housing_designation ?? null,
+ }))
+ const validation = validateRotRut(validateInput, personnummerProvided, housingProvided)
+ if (validation.errors.length > 0) {
+ return errorResponseFromCode('INVOICE_CREATE_ROT_RUT_VALIDATION', log, {
+ requestId,
+ details: { errors: validation.errors, warnings: validation.warnings },
+ })
+ }
+
+ // Compute and (when present) encrypt the personnummer. The plaintext
+ // value never touches the DB — only the AES-256-GCM ciphertext + the
+ // last four digits go into invoices columns.
+ deductionTotal = computeInvoiceDeductionTotal(validateInput)
+ if (personnummerProvided) {
+ const pnValid = validatePersonnummer(personnummerRaw)
+ if (!pnValid.valid) {
+ return errorResponseFromCode('INVOICE_CREATE_ROT_RUT_PERSONNUMMER_INVALID', log, {
+ requestId,
+ details: { error: pnValid.error },
+ })
+ }
+ deductionPersonnummerEncrypted = encryptPersonnummer(personnummerRaw)
+ deductionPersonnummerLast4 = extractLast4(personnummerRaw)
+ }
+ }
+
const uniqueRates = new Set(invoiceInput.items.map((item) => item.vat_rate ?? vatRules.rate))
const isMixedRate = uniqueRates.size > 1
@@ -182,13 +237,14 @@ export const POST = withRouteContext(
vat_amount_sek: documentType === 'delivery_note' ? null : vatAmountSek,
total,
total_sek: documentType === 'delivery_note' ? null : totalSek,
- // Initialize remaining_amount to total for real invoices so the open-
- // invoice queries (InvoicePicker, AR ledger, supplier matching) treat
- // newly-created invoices as fully unpaid. The DB default is 0 — without
- // this, brand-new fakturor look settled and disappear from match
- // candidate lists. Proformas and delivery notes have no payment
- // obligation, so they keep the 0 default.
- remaining_amount: documentType === 'invoice' ? total : 0,
+ // Initialize remaining_amount to total - deduction for real invoices
+ // so the open-invoice queries (InvoicePicker, AR ledger, supplier
+ // matching) treat newly-created invoices as fully unpaid for the
+ // CUSTOMER's share — the Skatteverket portion is on 1513 and will be
+ // cleared when the agency pays out, not by the customer payment.
+ // Proformas, delivery notes and quotes have no payment obligation,
+ // so they keep the 0 default.
+ remaining_amount: documentType === 'invoice' ? total - deductionTotal : 0,
vat_treatment: vatRules.treatment,
vat_rate: documentType === 'delivery_note' ? 0 : (isMixedRate ? null : (uniqueRates.values().next().value ?? vatRules.rate)),
moms_ruta: vatRules.momsRuta,
@@ -197,6 +253,9 @@ export const POST = withRouteContext(
our_reference: invoiceInput.our_reference,
notes: invoiceInput.notes,
document_type: documentType,
+ deduction_total: deductionTotal,
+ deduction_personnummer_encrypted: deductionPersonnummerEncrypted,
+ deduction_personnummer_last4: deductionPersonnummerLast4,
})
.select()
.single()
@@ -213,6 +272,18 @@ export const POST = withRouteContext(
const itemRate = item.vat_rate !== undefined ? item.vat_rate : vatRules.rate
const lineTotal = item.quantity * item.unit_price
const itemVat = documentType === 'delivery_note' ? 0 : Math.round(lineTotal * itemRate / 100 * 100) / 100
+ // ROT/RUT deduction is recomputed server-side so a tampered client
+ // can't expand the 1513 receivable beyond the rules. Non-invoice
+ // document types never carry deduction_type (rules above strip them
+ // implicitly because validateRotRut isn't invoked).
+ const deductionType = documentType === 'invoice' ? (item.deduction_type ?? null) : null
+ const deductionAmount = deductionType
+ ? computeDeduction({
+ unit_price: item.unit_price,
+ quantity: item.quantity,
+ deduction_type: deductionType,
+ })
+ : 0
return {
invoice_id: invoice.id,
sort_order: index,
@@ -223,6 +294,12 @@ export const POST = withRouteContext(
line_total: lineTotal,
vat_rate: itemRate,
vat_amount: itemVat,
+ deduction_type: deductionType,
+ deduction_amount: deductionAmount,
+ labor_hours: documentType === 'invoice' ? (item.labor_hours ?? null) : null,
+ work_type: documentType === 'invoice' ? (item.work_type ?? null) : null,
+ housing_designation: documentType === 'invoice' ? (item.housing_designation ?? null) : null,
+ apartment_number: documentType === 'invoice' ? (item.apartment_number ?? null) : null,
}
})
@@ -296,7 +373,7 @@ export const POST = withRouteContext(
.eq('id', invoice.id)
.single()
- // Emit event only for real invoices (proformas / delivery notes are informational).
+ // Emit event only for real invoices (proformas / delivery notes / quotes are informational).
if (completeInvoice && documentType === 'invoice') {
await eventBus.emit({
type: 'invoice.created',
diff --git a/app/api/reports/ar-ledger/customer/[customerId]/invoices/__tests__/route.test.ts b/app/api/reports/ar-ledger/customer/[customerId]/invoices/__tests__/route.test.ts
new file mode 100644
index 00000000..f3af3850
--- /dev/null
+++ b/app/api/reports/ar-ledger/customer/[customerId]/invoices/__tests__/route.test.ts
@@ -0,0 +1,156 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { createMockRequest, createMockRouteParams } from '@/tests/helpers'
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(),
+}))
+
+vi.mock('@/lib/company/context', () => ({
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+vi.mock('@/lib/bookkeeping/currency-utils', () => ({
+ resolveSekAmount: vi.fn((amount: number) => amount),
+}))
+
+import { createClient } from '@/lib/supabase/server'
+import { GET } from '../route'
+
+const mockCreateClient = vi.mocked(createClient)
+
+interface QueryResult {
+ data: unknown
+ error: unknown
+}
+
+function buildSupabase(
+ user: { id: string } | null,
+ customer: { id: string; name: string } | null,
+ invoicesResult: QueryResult,
+ entriesResult: QueryResult
+) {
+ let invoiceCallNum = 0
+ let entryCallNum = 0
+ return {
+ auth: {
+ getUser: vi.fn().mockResolvedValue({ data: { user } }),
+ },
+ from: vi.fn().mockImplementation((table: string) => {
+ if (table === 'customers') {
+ return {
+ select: vi.fn().mockReturnThis(),
+ eq: vi.fn().mockReturnThis(),
+ maybeSingle: vi.fn().mockResolvedValue({ data: customer, error: null }),
+ }
+ }
+ if (table === 'invoices') {
+ invoiceCallNum += 1
+ return {
+ select: vi.fn().mockReturnThis(),
+ eq: vi.fn().mockReturnThis(),
+ in: vi.fn().mockReturnThis(),
+ order: vi.fn().mockReturnThis(),
+ limit: vi.fn().mockReturnThis(),
+ then: (resolve: (v: QueryResult) => void) => resolve(invoicesResult),
+ }
+ }
+ // journal_entries
+ entryCallNum += 1
+ return {
+ select: vi.fn().mockReturnThis(),
+ eq: vi.fn().mockReturnThis(),
+ in: vi.fn().mockReturnThis(),
+ then: (resolve: (v: QueryResult) => void) => resolve(entriesResult),
+ }
+ }),
+ _stats: () => ({ invoiceCallNum, entryCallNum }),
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('GET /api/reports/ar-ledger/customer/[customerId]/invoices', () => {
+ it('returns 401 when not authenticated', async () => {
+ mockCreateClient.mockResolvedValue(
+ buildSupabase(null, null, { data: [], error: null }, { data: [], error: null }) as never
+ )
+ const req = createMockRequest(
+ '/api/reports/ar-ledger/customer/cust-1/invoices'
+ )
+ const res = await GET(req, createMockRouteParams({ customerId: 'cust-1' }))
+ expect(res.status).toBe(401)
+ })
+
+ it('returns 404 when customer is unknown', async () => {
+ mockCreateClient.mockResolvedValue(
+ buildSupabase({ id: 'user-1' }, null, { data: [], error: null }, { data: [], error: null }) as never
+ )
+ const req = createMockRequest(
+ '/api/reports/ar-ledger/customer/cust-1/invoices'
+ )
+ const res = await GET(req, createMockRouteParams({ customerId: 'cust-1' }))
+ expect(res.status).toBe(404)
+ })
+
+ it('happy path: returns invoices with linked journal entries', async () => {
+ const invoices = [
+ {
+ id: 'inv-1',
+ invoice_number: '2026-001',
+ invoice_date: '2026-05-01',
+ due_date: '2026-06-01',
+ total: 1250,
+ paid_amount: 0,
+ currency: 'SEK',
+ exchange_rate: null,
+ remaining_amount: 1250,
+ notes: null,
+ },
+ ]
+ const entries = [
+ {
+ id: 'je-1',
+ voucher_number: 22,
+ voucher_series: 'A',
+ description: 'Faktura 2026-001',
+ source_id: 'inv-1',
+ },
+ ]
+ mockCreateClient.mockResolvedValue(
+ buildSupabase(
+ { id: 'user-1' },
+ { id: 'cust-1', name: 'Acme AB' },
+ { data: invoices, error: null },
+ { data: entries, error: null }
+ ) as never
+ )
+ const req = createMockRequest(
+ '/api/reports/ar-ledger/customer/cust-1/invoices'
+ )
+ const res = await GET(req, createMockRouteParams({ customerId: 'cust-1' }))
+ expect(res.status).toBe(200)
+
+ const body = (await res.json()) as {
+ data: {
+ customer_id: string
+ customer_name: string
+ lines: Array<{
+ invoice_id: string
+ voucher_number: number
+ journal_entry_id: string
+ outstanding: number
+ }>
+ }
+ }
+
+ expect(body.data.customer_id).toBe('cust-1')
+ expect(body.data.customer_name).toBe('Acme AB')
+ expect(body.data.lines).toHaveLength(1)
+ expect(body.data.lines[0].invoice_id).toBe('inv-1')
+ expect(body.data.lines[0].journal_entry_id).toBe('je-1')
+ expect(body.data.lines[0].voucher_number).toBe(22)
+ expect(body.data.lines[0].outstanding).toBe(1250)
+ })
+})
diff --git a/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts b/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts
new file mode 100644
index 00000000..ccb166a2
--- /dev/null
+++ b/app/api/reports/ar-ledger/customer/[customerId]/invoices/route.ts
@@ -0,0 +1,152 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { requireCompanyId } from '@/lib/company/context'
+import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
+import type { ReportSourceLine } from '@/lib/reports/source-lines'
+
+/**
+ * GET /api/reports/ar-ledger/customer/[customerId]/invoices
+ *
+ * Returns the invoices that contribute to a customer's outstanding balance.
+ * Each row exposes the registration journal entry (if any) via
+ * `journal_entry_id`, so the UI can link directly to `/bookkeeping/[id]`.
+ *
+ * If an invoice has no posted registration entry yet (still draft), the
+ * `journal_entry_id` is null and the UI must fall back to `/invoices/[id]`.
+ */
+const PAGE_LIMIT = 500
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ customerId: string }> }
+) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+ const { customerId } = await params
+
+ // Verify customer belongs to the company.
+ const { data: customer } = await supabase
+ .from('customers')
+ .select('id, name')
+ .eq('id', customerId)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (!customer) {
+ return NextResponse.json({ error: 'Kund saknas' }, { status: 404 })
+ }
+
+ // Pull this customer's outstanding invoices. Mirrors the filter in
+ // `generateARLedger` so the UI sees the same set the aggregate is built
+ // from.
+ const { data, error } = await supabase
+ .from('invoices')
+ .select(`
+ id,
+ invoice_number,
+ invoice_date,
+ due_date,
+ total,
+ paid_amount,
+ currency,
+ exchange_rate,
+ remaining_amount,
+ notes
+ `)
+ .eq('company_id', companyId)
+ .eq('customer_id', customerId)
+ .in('status', ['sent', 'overdue', 'credited'])
+ .order('invoice_date', { ascending: true })
+ .limit(PAGE_LIMIT)
+
+ if (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 })
+ }
+
+ // For each invoice, find the registration journal entry (source_type =
+ // 'invoice_created', source_id = invoice.id). We batch them to keep this
+ // a single DB roundtrip.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const invoices = (data || []) as any[]
+ const ids = invoices.map((i) => i.id)
+ const entryMap = new Map<
+ string,
+ { id: string; voucher_number: number; voucher_series: string; description: string | null }
+ >()
+
+ if (ids.length > 0) {
+ const { data: entries } = await supabase
+ .from('journal_entries')
+ .select('id, voucher_number, voucher_series, description, source_id')
+ .eq('company_id', companyId)
+ .eq('source_type', 'invoice_created')
+ .in('source_id', ids)
+ .in('status', ['posted', 'reversed'])
+
+ for (const e of entries || []) {
+ entryMap.set(e.source_id, {
+ id: e.id,
+ voucher_number: e.voucher_number,
+ voucher_series: e.voucher_series || 'A',
+ description: e.description,
+ })
+ }
+ }
+
+ // Shape each invoice as a ReportSourceLine. The "debit" column carries
+ // the outstanding SEK amount (it's a receivable on 1510); "credit" is 0
+ // unless the invoice is fully a credit note.
+ const lines: (ReportSourceLine & {
+ invoice_id: string
+ invoice_number: string | null
+ outstanding: number
+ outstanding_sek: number | null
+ currency: string
+ paid_amount: number
+ due_date: string
+ })[] = invoices.map((inv) => {
+ const entry = entryMap.get(inv.id)
+ const paidAmount = Number(inv.paid_amount) || 0
+ const total = Number(inv.total) || 0
+ const outstanding = Math.round((total - paidAmount) * 100) / 100
+ const isFx = inv.currency && inv.currency !== 'SEK'
+ const hasRate = inv.exchange_rate != null && Number(inv.exchange_rate) > 0
+ const outstandingSek =
+ isFx && !hasRate
+ ? null
+ : resolveSekAmount(outstanding, null, inv.currency, inv.exchange_rate)
+
+ return {
+ journal_entry_id: entry?.id ?? '',
+ voucher_number: entry?.voucher_number ?? 0,
+ voucher_series: entry?.voucher_series ?? 'A',
+ date: inv.invoice_date || '',
+ description:
+ entry?.description ?? `Faktura ${inv.invoice_number || '(utkast)'}`,
+ debit: outstandingSek ?? outstanding,
+ credit: 0,
+ invoice_id: inv.id,
+ invoice_number: inv.invoice_number,
+ outstanding,
+ outstanding_sek: outstandingSek,
+ currency: inv.currency || 'SEK',
+ paid_amount: paidAmount,
+ due_date: inv.due_date,
+ }
+ })
+
+ return NextResponse.json({
+ data: {
+ customer_id: customer.id,
+ customer_name: customer.name,
+ lines,
+ next_cursor: null,
+ },
+ })
+}
diff --git a/app/api/reports/ar-ledger/xlsx/route.ts b/app/api/reports/ar-ledger/xlsx/route.ts
new file mode 100644
index 00000000..49f67a24
--- /dev/null
+++ b/app/api/reports/ar-ledger/xlsx/route.ts
@@ -0,0 +1,163 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { generateARLedger } from '@/lib/reports/ar-ledger'
+import { requireCompanyId } from '@/lib/company/context'
+import {
+ reportToWorkbook,
+ textColumn,
+ currencyColumn,
+ dateColumn,
+ integerColumn,
+ xlsxFilename,
+} from '@/lib/reports/xlsx-export'
+
+interface AgingRow {
+ customer_name: string
+ current: number
+ days_1_30: number
+ days_31_60: number
+ days_61_90: number
+ days_90_plus: number
+ total_outstanding: number
+}
+
+interface InvoiceRow {
+ customer_name: string
+ invoice_number: string
+ invoice_date: Date | string
+ due_date: Date | string
+ total: number
+ paid_amount: number
+ outstanding: number
+ outstanding_sek: number | null
+ days_overdue: number
+ currency: string
+}
+
+function toDate(s: string): Date | null {
+ if (!s) return null
+ const d = new Date(s)
+ return isNaN(d.getTime()) ? null : d
+}
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const asOfDate = searchParams.get('as_of_date') || undefined
+
+ const { data: companyRow } = await supabase
+ .from('company_settings')
+ .select('company_name')
+ .eq('company_id', companyId)
+ .single()
+
+ try {
+ const ledger = await generateARLedger(supabase, companyId, asOfDate)
+
+ const agingRows: AgingRow[] = ledger.entries.map((e) => ({
+ customer_name: e.customer_name,
+ current: e.current,
+ days_1_30: e.days_1_30,
+ days_31_60: e.days_31_60,
+ days_61_90: e.days_61_90,
+ days_90_plus: e.days_90_plus,
+ total_outstanding: e.total_outstanding,
+ }))
+
+ const invoiceRows: InvoiceRow[] = []
+ for (const e of ledger.entries) {
+ for (const inv of e.invoices) {
+ invoiceRows.push({
+ customer_name: e.customer_name,
+ invoice_number: inv.invoice_number,
+ invoice_date: toDate(inv.invoice_date) ?? inv.invoice_date,
+ due_date: toDate(inv.due_date) ?? inv.due_date,
+ total: inv.total,
+ paid_amount: inv.paid_amount,
+ outstanding: inv.outstanding,
+ outstanding_sek: inv.outstanding_sek,
+ days_overdue: inv.days_overdue,
+ currency: inv.currency,
+ })
+ }
+ }
+
+ const buffer = reportToWorkbook([
+ {
+ name: 'Åldersfördelning',
+ columns: [
+ textColumn('Kund'),
+ currencyColumn('Ej förfallet'),
+ currencyColumn('1-30 dagar'),
+ currencyColumn('31-60 dagar'),
+ currencyColumn('61-90 dagar'),
+ currencyColumn('90+ dagar'),
+ currencyColumn('Totalt utestående'),
+ ],
+ rows: agingRows,
+ mapRow: (r) => [
+ r.customer_name,
+ r.current,
+ r.days_1_30,
+ r.days_31_60,
+ r.days_61_90,
+ r.days_90_plus,
+ r.total_outstanding,
+ ],
+ },
+ {
+ name: 'Fakturor',
+ columns: [
+ textColumn('Kund'),
+ textColumn('Fakturanr'),
+ dateColumn('Fakturadatum'),
+ dateColumn('Förfallodatum'),
+ currencyColumn('Totalt'),
+ currencyColumn('Betalt'),
+ currencyColumn('Utestående'),
+ currencyColumn('Utestående (SEK)'),
+ integerColumn('Dagar förfallet'),
+ textColumn('Valuta'),
+ ],
+ rows: invoiceRows,
+ mapRow: (r) => [
+ r.customer_name,
+ r.invoice_number,
+ r.invoice_date instanceof Date ? r.invoice_date : null,
+ r.due_date instanceof Date ? r.due_date : null,
+ r.total,
+ r.paid_amount,
+ r.outstanding,
+ r.outstanding_sek,
+ r.days_overdue,
+ r.currency,
+ ],
+ },
+ ])
+
+ const filename = xlsxFilename(
+ 'kundreskontra',
+ companyRow?.company_name ?? '',
+ asOfDate ?? new Date().toISOString().slice(0, 10),
+ )
+ return new NextResponse(new Uint8Array(buffer), {
+ headers: {
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera kundreskontra' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/reports/balance-sheet/xlsx/route.ts b/app/api/reports/balance-sheet/xlsx/route.ts
new file mode 100644
index 00000000..1ed0f11e
--- /dev/null
+++ b/app/api/reports/balance-sheet/xlsx/route.ts
@@ -0,0 +1,153 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
+import { requireCompanyId } from '@/lib/company/context'
+import {
+ reportToWorkbook,
+ textColumn,
+ currencyColumn,
+ xlsxFilename,
+} from '@/lib/reports/xlsx-export'
+
+interface FlatRow {
+ section: string
+ account_number: string
+ account_name: string
+ amount: number
+ isSubtotal: boolean
+}
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const periodId = searchParams.get('period_id')
+
+ if (!periodId) {
+ return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
+ }
+
+ const [{ data: period }, { data: companyRow }] = await Promise.all([
+ supabase
+ .from('fiscal_periods')
+ .select('period_start, period_end')
+ .eq('id', periodId)
+ .eq('company_id', companyId)
+ .single(),
+ supabase
+ .from('company_settings')
+ .select('company_name')
+ .eq('company_id', companyId)
+ .single(),
+ ])
+
+ if (!period) {
+ return NextResponse.json({ error: 'Räkenskapsperioden kunde inte läsas.' }, { status: 400 })
+ }
+
+ try {
+ const report = await generateBalanceSheet(supabase, companyId, periodId)
+
+ // Flatten nested sections into a single tabular view, mirroring how the
+ // PDF lays them out: each section's rows followed by a subtotal line, with
+ // grand totals at the end. The "Sektion" column keeps the grouping queryable.
+ const assetRows: FlatRow[] = []
+ for (const s of report.asset_sections) {
+ for (const r of s.rows) {
+ assetRows.push({
+ section: s.title,
+ account_number: r.account_number,
+ account_name: r.account_name,
+ amount: r.amount,
+ isSubtotal: false,
+ })
+ }
+ assetRows.push({
+ section: s.title,
+ account_number: '',
+ account_name: `Summa ${s.title}`,
+ amount: s.subtotal,
+ isSubtotal: true,
+ })
+ }
+ assetRows.push({
+ section: 'Tillgångar',
+ account_number: '',
+ account_name: 'Summa tillgångar',
+ amount: report.total_assets,
+ isSubtotal: true,
+ })
+
+ const equityRows: FlatRow[] = []
+ for (const s of report.equity_liability_sections) {
+ for (const r of s.rows) {
+ equityRows.push({
+ section: s.title,
+ account_number: r.account_number,
+ account_name: r.account_name,
+ amount: r.amount,
+ isSubtotal: false,
+ })
+ }
+ equityRows.push({
+ section: s.title,
+ account_number: '',
+ account_name: `Summa ${s.title}`,
+ amount: s.subtotal,
+ isSubtotal: true,
+ })
+ }
+ equityRows.push({
+ section: 'Eget kapital och skulder',
+ account_number: '',
+ account_name: 'Summa eget kapital och skulder',
+ amount: report.total_equity_liabilities,
+ isSubtotal: true,
+ })
+
+ const buffer = reportToWorkbook([
+ {
+ name: 'Tillgångar',
+ columns: [
+ textColumn('Sektion'),
+ textColumn('Konto'),
+ textColumn('Kontonamn'),
+ currencyColumn('Belopp'),
+ ],
+ rows: assetRows,
+ mapRow: (r) => [r.section, r.account_number, r.account_name, r.amount],
+ },
+ {
+ name: 'Eget kapital och skulder',
+ columns: [
+ textColumn('Sektion'),
+ textColumn('Konto'),
+ textColumn('Kontonamn'),
+ currencyColumn('Belopp'),
+ ],
+ rows: equityRows,
+ mapRow: (r) => [r.section, r.account_number, r.account_name, r.amount],
+ },
+ ])
+
+ const filename = xlsxFilename('balansrakning', companyRow?.company_name ?? '', period.period_end)
+ return new NextResponse(new Uint8Array(buffer), {
+ headers: {
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera balansräkning' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/reports/balansrapport/xlsx/route.ts b/app/api/reports/balansrapport/xlsx/route.ts
new file mode 100644
index 00000000..86e2a6cf
--- /dev/null
+++ b/app/api/reports/balansrapport/xlsx/route.ts
@@ -0,0 +1,117 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { generateBalansrapport } from '@/lib/reports/balansrapport'
+import { requireCompanyId } from '@/lib/company/context'
+import {
+ reportToWorkbook,
+ textColumn,
+ currencyColumn,
+ xlsxFilename,
+} from '@/lib/reports/xlsx-export'
+
+interface FlatRow {
+ group: string
+ account_number: string
+ account_name: string
+ ib: number
+ period_change: number
+ ub: number
+}
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const periodId = searchParams.get('period_id')
+
+ if (!periodId) {
+ return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
+ }
+
+ const { data: companyRow } = await supabase
+ .from('company_settings')
+ .select('company_name')
+ .eq('company_id', companyId)
+ .single()
+
+ try {
+ const report = await generateBalansrapport(supabase, companyId, periodId)
+
+ const rows: FlatRow[] = []
+ for (const g of report.groups) {
+ for (const r of g.rows) {
+ rows.push({
+ group: g.class_label,
+ account_number: r.account_number,
+ account_name: r.account_name,
+ ib: r.ib,
+ period_change: r.period_change,
+ ub: r.ub,
+ })
+ }
+ rows.push({
+ group: g.class_label,
+ account_number: '',
+ account_name: `Summa ${g.class_label}`,
+ ib: g.subtotal_ib,
+ period_change: Math.round((g.subtotal_ub - g.subtotal_ib) * 100) / 100,
+ ub: g.subtotal_ub,
+ })
+ }
+ rows.push({
+ group: 'Beräknat resultat',
+ account_number: '',
+ account_name: 'Beräknat resultat',
+ ib: 0,
+ period_change: report.beraknat_resultat,
+ ub: report.beraknat_resultat,
+ })
+
+ const buffer = reportToWorkbook([
+ {
+ name: 'Balansrapport',
+ columns: [
+ textColumn('Grupp'),
+ textColumn('Konto'),
+ textColumn('Kontonamn'),
+ currencyColumn('IB'),
+ currencyColumn('Periodförändring'),
+ currencyColumn('UB'),
+ ],
+ rows,
+ mapRow: (r) => [
+ r.group,
+ r.account_number,
+ r.account_name,
+ r.ib,
+ r.period_change,
+ r.ub,
+ ],
+ },
+ ])
+
+ const filename = xlsxFilename(
+ 'balansrapport',
+ companyRow?.company_name ?? '',
+ report.period.end,
+ )
+ return new NextResponse(new Uint8Array(buffer), {
+ headers: {
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera balansrapport' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/reports/general-ledger/xlsx/route.ts b/app/api/reports/general-ledger/xlsx/route.ts
new file mode 100644
index 00000000..9ba4dc69
--- /dev/null
+++ b/app/api/reports/general-ledger/xlsx/route.ts
@@ -0,0 +1,144 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { generateGeneralLedger } from '@/lib/reports/general-ledger'
+import { requireCompanyId } from '@/lib/company/context'
+import {
+ reportToWorkbook,
+ textColumn,
+ currencyColumn,
+ dateColumn,
+ xlsxFilename,
+} from '@/lib/reports/xlsx-export'
+
+interface FlatRow {
+ account_number: string
+ account_name: string
+ date: Date | string
+ voucher: string
+ description: string
+ source_type: string
+ debit: number
+ credit: number
+ balance: number
+}
+
+function toDate(s: string): Date | string {
+ // Preserve original ISO string in the cell if parsing fails (avoids NaN
+ // dates polluting the workbook).
+ const d = new Date(s)
+ return isNaN(d.getTime()) ? s : d
+}
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const periodId = searchParams.get('period_id')
+ const accountFrom = searchParams.get('account_from') || undefined
+ const accountTo = searchParams.get('account_to') || undefined
+
+ if (!periodId) {
+ return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
+ }
+
+ const { data: companyRow } = await supabase
+ .from('company_settings')
+ .select('company_name')
+ .eq('company_id', companyId)
+ .single()
+
+ try {
+ const report = await generateGeneralLedger(supabase, companyId, periodId, accountFrom, accountTo)
+
+ // Flatten accounts + their lines into a single sheet. Each account contributes
+ // an opening-balance row, its lines (with running balance), and a closing
+ // row — matching how huvudbok is read in Fortnox/Visma.
+ const rows: FlatRow[] = []
+ for (const acc of report.accounts) {
+ rows.push({
+ account_number: acc.account_number,
+ account_name: acc.account_name,
+ date: '',
+ voucher: '',
+ description: 'Ingående balans',
+ source_type: '',
+ debit: 0,
+ credit: 0,
+ balance: acc.opening_balance,
+ })
+ for (const line of acc.lines) {
+ rows.push({
+ account_number: acc.account_number,
+ account_name: acc.account_name,
+ date: toDate(line.date),
+ voucher: `${line.voucher_series}${line.voucher_number}`,
+ description: line.description,
+ source_type: line.source_type,
+ debit: line.debit,
+ credit: line.credit,
+ balance: line.balance,
+ })
+ }
+ rows.push({
+ account_number: acc.account_number,
+ account_name: acc.account_name,
+ date: '',
+ voucher: '',
+ description: 'Utgående balans',
+ source_type: '',
+ debit: acc.total_debit,
+ credit: acc.total_credit,
+ balance: acc.closing_balance,
+ })
+ }
+
+ const buffer = reportToWorkbook([
+ {
+ name: 'Huvudbok',
+ columns: [
+ textColumn('Konto'),
+ textColumn('Kontonamn'),
+ dateColumn('Datum'),
+ textColumn('Verifikat'),
+ textColumn('Beskrivning'),
+ textColumn('Källa'),
+ currencyColumn('Debet'),
+ currencyColumn('Kredit'),
+ currencyColumn('Saldo'),
+ ],
+ rows,
+ mapRow: (r) => [
+ r.account_number,
+ r.account_name,
+ r.date instanceof Date ? r.date : null,
+ r.voucher,
+ r.description,
+ r.source_type,
+ r.debit,
+ r.credit,
+ r.balance,
+ ],
+ },
+ ])
+
+ const filename = xlsxFilename('huvudbok', companyRow?.company_name ?? '', report.period.end)
+ return new NextResponse(new Uint8Array(buffer), {
+ headers: {
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera huvudbok' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/reports/income-statement/xlsx/route.ts b/app/api/reports/income-statement/xlsx/route.ts
new file mode 100644
index 00000000..bb3bde40
--- /dev/null
+++ b/app/api/reports/income-statement/xlsx/route.ts
@@ -0,0 +1,156 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { generateIncomeStatement } from '@/lib/reports/income-statement'
+import { requireCompanyId } from '@/lib/company/context'
+import {
+ reportToWorkbook,
+ textColumn,
+ currencyColumn,
+ xlsxFilename,
+} from '@/lib/reports/xlsx-export'
+import type { IncomeStatementSection } from '@/types'
+
+interface FlatRow {
+ section: string
+ account_number: string
+ account_name: string
+ amount: number
+}
+
+function flatten(
+ sections: IncomeStatementSection[],
+ groupLabel: string,
+ groupTotalLabel: string,
+ groupTotal: number,
+): FlatRow[] {
+ const rows: FlatRow[] = []
+ for (const s of sections) {
+ for (const r of s.rows) {
+ rows.push({
+ section: s.title,
+ account_number: r.account_number,
+ account_name: r.account_name,
+ amount: r.amount,
+ })
+ }
+ rows.push({
+ section: s.title,
+ account_number: '',
+ account_name: `Summa ${s.title}`,
+ amount: s.subtotal,
+ })
+ }
+ rows.push({
+ section: groupLabel,
+ account_number: '',
+ account_name: groupTotalLabel,
+ amount: groupTotal,
+ })
+ return rows
+}
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const periodId = searchParams.get('period_id')
+
+ if (!periodId) {
+ return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
+ }
+
+ const [{ data: period }, { data: companyRow }] = await Promise.all([
+ supabase
+ .from('fiscal_periods')
+ .select('period_start, period_end')
+ .eq('id', periodId)
+ .eq('company_id', companyId)
+ .single(),
+ supabase
+ .from('company_settings')
+ .select('company_name')
+ .eq('company_id', companyId)
+ .single(),
+ ])
+
+ if (!period) {
+ return NextResponse.json({ error: 'Räkenskapsperioden kunde inte läsas.' }, { status: 400 })
+ }
+
+ try {
+ const report = await generateIncomeStatement(supabase, companyId, periodId)
+
+ const revenueRows = flatten(
+ report.revenue_sections,
+ 'Rörelseintäkter',
+ 'Summa rörelseintäkter',
+ report.total_revenue,
+ )
+ const expenseRows = flatten(
+ report.expense_sections,
+ 'Rörelsekostnader',
+ 'Summa rörelsekostnader',
+ report.total_expenses,
+ )
+ const financialRows = flatten(
+ report.financial_sections,
+ 'Finansiella poster',
+ 'Summa finansiella poster',
+ report.total_financial,
+ )
+
+ const summaryRows: FlatRow[] = [
+ {
+ section: 'Sammanfattning',
+ account_number: '',
+ account_name: 'Rörelseresultat',
+ amount: Math.round((report.total_revenue - report.total_expenses) * 100) / 100,
+ },
+ {
+ section: 'Sammanfattning',
+ account_number: '',
+ account_name: 'Årets resultat',
+ amount: report.net_result,
+ },
+ ]
+
+ const columns = [
+ textColumn('Sektion'),
+ textColumn('Konto'),
+ textColumn('Kontonamn'),
+ currencyColumn('Belopp'),
+ ]
+ const mapRow = (r: FlatRow) => [r.section, r.account_number, r.account_name, r.amount]
+
+ const buffer = reportToWorkbook([
+ { name: 'Intäkter', columns, rows: revenueRows, mapRow },
+ { name: 'Kostnader', columns, rows: expenseRows, mapRow },
+ { name: 'Finansiella poster', columns, rows: financialRows, mapRow },
+ { name: 'Sammanfattning', columns, rows: summaryRows, mapRow },
+ ])
+
+ const filename = xlsxFilename(
+ 'resultatrakning',
+ companyRow?.company_name ?? '',
+ period.period_end,
+ )
+ return new NextResponse(new Uint8Array(buffer), {
+ headers: {
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera resultaträkning' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/reports/journal-register/xlsx/route.ts b/app/api/reports/journal-register/xlsx/route.ts
new file mode 100644
index 00000000..be62bbec
--- /dev/null
+++ b/app/api/reports/journal-register/xlsx/route.ts
@@ -0,0 +1,119 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { generateJournalRegister } from '@/lib/reports/journal-register'
+import { requireCompanyId } from '@/lib/company/context'
+import {
+ reportToWorkbook,
+ textColumn,
+ currencyColumn,
+ dateColumn,
+ xlsxFilename,
+} from '@/lib/reports/xlsx-export'
+
+interface FlatRow {
+ voucher: string
+ date: Date | null
+ description: string
+ source_type: string
+ status: string
+ account_number: string
+ account_name: string
+ debit: number
+ credit: number
+}
+
+function toDate(s: string): Date | null {
+ if (!s) return null
+ const d = new Date(s)
+ return isNaN(d.getTime()) ? null : d
+}
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const periodId = searchParams.get('period_id')
+
+ if (!periodId) {
+ return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
+ }
+
+ const { data: companyRow } = await supabase
+ .from('company_settings')
+ .select('company_name')
+ .eq('company_id', companyId)
+ .single()
+
+ try {
+ const report = await generateJournalRegister(supabase, companyId, periodId)
+
+ // Flatten: one row per (entry, line). Voucher metadata repeats so the
+ // file is filterable in Excel without losing context.
+ const rows: FlatRow[] = []
+ for (const entry of report.entries) {
+ const voucherLabel = `${entry.voucher_series}${entry.voucher_number}`
+ for (const line of entry.lines) {
+ rows.push({
+ voucher: voucherLabel,
+ date: toDate(entry.date),
+ description: entry.description,
+ source_type: entry.source_type,
+ status: entry.status,
+ account_number: line.account_number,
+ account_name: line.account_name,
+ debit: line.debit,
+ credit: line.credit,
+ })
+ }
+ }
+
+ const buffer = reportToWorkbook([
+ {
+ name: 'Grundbok',
+ columns: [
+ textColumn('Verifikat'),
+ dateColumn('Datum'),
+ textColumn('Beskrivning'),
+ textColumn('Källa'),
+ textColumn('Status'),
+ textColumn('Konto'),
+ textColumn('Kontonamn'),
+ currencyColumn('Debet'),
+ currencyColumn('Kredit'),
+ ],
+ rows,
+ mapRow: (r) => [
+ r.voucher,
+ r.date,
+ r.description,
+ r.source_type,
+ r.status,
+ r.account_number,
+ r.account_name,
+ r.debit,
+ r.credit,
+ ],
+ },
+ ])
+
+ const filename = xlsxFilename('grundbok', companyRow?.company_name ?? '', report.period.end)
+ return new NextResponse(new Uint8Array(buffer), {
+ headers: {
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera grundbok' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/reports/kassaflodesanalys/pdf/route.ts b/app/api/reports/kassaflodesanalys/pdf/route.ts
new file mode 100644
index 00000000..cc861860
--- /dev/null
+++ b/app/api/reports/kassaflodesanalys/pdf/route.ts
@@ -0,0 +1,80 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { renderToBuffer } from '@react-pdf/renderer'
+import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
+import { KassaflodesanalysPDF } from '@/lib/reports/kassaflodesanalys-pdf-template'
+import { requireCompanyId } from '@/lib/company/context'
+import type { CompanySettings } from '@/types'
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const periodId = searchParams.get('period_id')
+
+ if (!periodId) {
+ return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
+ }
+
+ const [{ data: period }, { data: companyRow }] = await Promise.all([
+ supabase
+ .from('fiscal_periods')
+ .select('period_start, period_end')
+ .eq('id', periodId)
+ .eq('company_id', companyId)
+ .single(),
+ supabase
+ .from('company_settings')
+ .select('*')
+ .eq('company_id', companyId)
+ .single(),
+ ])
+
+ if (!companyRow) {
+ return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
+ }
+ // An identifiable period is part of räkenskapsinformation (BFL 7 kap). Refuse
+ // to render a PDF that can't be archived with the period it refers to.
+ if (!period) {
+ return NextResponse.json(
+ {
+ error:
+ 'Räkenskapsperioden kunde inte läsas. Välj en befintlig period innan du genererar PDF.',
+ },
+ { status: 400 }
+ )
+ }
+
+ try {
+ const report = await generateKassaflodesanalys(supabase, companyId, periodId)
+
+ const pdfBuffer = await renderToBuffer(
+ KassaflodesanalysPDF({
+ report,
+ company: companyRow as CompanySettings,
+ generatedAt: new Date().toISOString(),
+ })
+ )
+
+ const filename = `kassaflodesanalys-${report.period_start}.pdf`
+
+ return new Response(new Uint8Array(pdfBuffer), {
+ headers: {
+ 'Content-Type': 'application/pdf',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera kassaflödesanalys' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/reports/kassaflodesanalys/route.ts b/app/api/reports/kassaflodesanalys/route.ts
new file mode 100644
index 00000000..cdc07c99
--- /dev/null
+++ b/app/api/reports/kassaflodesanalys/route.ts
@@ -0,0 +1,32 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
+import { requireCompanyId } from '@/lib/company/context'
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const periodId = searchParams.get('period_id')
+
+ if (!periodId) {
+ return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
+ }
+
+ try {
+ const result = await generateKassaflodesanalys(supabase, companyId, periodId)
+ return NextResponse.json({ data: result })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Failed to generate kassaflödesanalys' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/reports/kpi/xlsx/route.ts b/app/api/reports/kpi/xlsx/route.ts
new file mode 100644
index 00000000..efae6718
--- /dev/null
+++ b/app/api/reports/kpi/xlsx/route.ts
@@ -0,0 +1,256 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { generateIncomeStatement } from '@/lib/reports/income-statement'
+import { generateTrialBalance } from '@/lib/reports/trial-balance'
+import { generateARLedger } from '@/lib/reports/ar-ledger'
+import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
+import {
+ calculateCashPosition,
+ calculateGrossMargin,
+ calculateExpenseRatio,
+ calculateAvgPaymentDays,
+} from '@/lib/reports/kpi'
+import { requireCompanyId } from '@/lib/company/context'
+import {
+ reportToWorkbook,
+ textColumn,
+ currencyColumn,
+ percentColumn,
+ integerColumn,
+ xlsxFilename,
+} from '@/lib/reports/xlsx-export'
+
+interface KpiKv {
+ label: string
+ value: number | null
+}
+
+interface MonthRow {
+ label: string
+ income: number
+ expenses: number
+ net: number
+}
+
+interface CompositionRow {
+ klass: string
+ amount: number
+}
+
+interface SupplierRow {
+ supplier_name: string
+ total: number
+}
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+ if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const periodId = searchParams.get('period_id')
+ if (!periodId) {
+ return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
+ }
+
+ const [{ data: period }, { data: companyRow }] = await Promise.all([
+ supabase
+ .from('fiscal_periods')
+ .select('period_start, period_end, is_closed')
+ .eq('id', periodId)
+ .eq('company_id', companyId)
+ .single(),
+ supabase
+ .from('company_settings')
+ .select('company_name')
+ .eq('company_id', companyId)
+ .single(),
+ ])
+
+ if (!period) {
+ return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
+ }
+
+ try {
+ const [
+ incomeStatement,
+ trialBalanceResult,
+ arLedger,
+ monthlyBreakdown,
+ paidInvoicesResult,
+ topSuppliersResult,
+ ] = await Promise.all([
+ generateIncomeStatement(supabase, companyId, periodId),
+ generateTrialBalance(supabase, companyId, periodId),
+ generateARLedger(supabase, companyId),
+ generateMonthlyBreakdown(supabase, companyId, periodId),
+ supabase
+ .from('invoices')
+ .select('invoice_date, paid_at')
+ .eq('company_id', companyId)
+ .eq('status', 'paid')
+ .not('paid_at', 'is', null),
+ supabase
+ .from('supplier_invoices')
+ .select('supplier_id, total_sek, total, supplier:suppliers(id, name)')
+ .eq('company_id', companyId)
+ .gte('invoice_date', period.period_start)
+ .lte('invoice_date', period.period_end)
+ .neq('status', 'credited'),
+ ])
+
+ const cashPosition = calculateCashPosition(trialBalanceResult.rows)
+ const vatOutputAccounts = ['2611', '2621', '2631']
+ const vatInputAccounts = ['2641', '2645']
+ const outputVat = trialBalanceResult.rows
+ .filter((r) => vatOutputAccounts.includes(r.account_number))
+ .reduce((sum, r) => sum + (r.closing_credit - r.closing_debit), 0)
+ const inputVat = trialBalanceResult.rows
+ .filter((r) => vatInputAccounts.includes(r.account_number))
+ .reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0)
+ const vatLiability = Math.round((outputVat - inputVat) * 100) / 100
+
+ const paidInvoices = (paidInvoicesResult.data ?? []).map((inv) => ({
+ invoice_date: inv.invoice_date as string,
+ paid_at: inv.paid_at as string,
+ }))
+
+ // Expense composition by BAS class (mirrors KPI JSON route logic).
+ const expenseComposition = trialBalanceResult.rows.reduce(
+ (acc, r) => {
+ if (r.account_class < 4 || r.account_class > 7) return acc
+ const amount = r.closing_debit - r.closing_credit
+ if (amount <= 0) return acc
+ if (r.account_class === 4) acc.class4 += amount
+ else if (r.account_class === 5) acc.class5 += amount
+ else if (r.account_class === 6) acc.class6 += amount
+ else if (r.account_class === 7) acc.class7 += amount
+ return acc
+ },
+ { class4: 0, class5: 0, class6: 0, class7: 0 },
+ )
+
+ type SupplierInvoiceRow = {
+ supplier_id: string | null
+ total_sek: number | null
+ total: number | null
+ supplier: { id: string; name: string } | { id: string; name: string }[] | null
+ }
+ const supplierTotals = new Map()
+ for (const row of (topSuppliersResult.data ?? []) as SupplierInvoiceRow[]) {
+ if (!row.supplier_id) continue
+ const supplier = Array.isArray(row.supplier) ? row.supplier[0] : row.supplier
+ if (!supplier?.name) continue
+ const amount = row.total_sek ?? null
+ if (amount == null) continue
+ const existing = supplierTotals.get(row.supplier_id)
+ if (existing) existing.total += amount
+ else supplierTotals.set(row.supplier_id, { name: supplier.name, total: amount })
+ }
+ const topSuppliers = Array.from(supplierTotals.values())
+ .map((v) => ({
+ supplier_name: v.name,
+ total: Math.round(v.total * 100) / 100,
+ }))
+ .sort((a, b) => b.total - a.total)
+ .slice(0, 7)
+
+ // Sheet 1: scalar KPIs, label + value. Currency by default; percent rows
+ // are split into a separate sheet so the formatting is unambiguous.
+ const currencyKpis: KpiKv[] = [
+ { label: 'Årets resultat', value: incomeStatement.net_result },
+ { label: 'Likvida medel', value: cashPosition },
+ { label: 'Utestående kundfordringar', value: arLedger.total_outstanding },
+ { label: 'Förfallna kundfordringar', value: arLedger.total_overdue },
+ { label: 'Momsskuld (ruta 49)', value: vatLiability },
+ { label: 'Totala intäkter', value: incomeStatement.total_revenue },
+ { label: 'Totala kostnader', value: incomeStatement.total_expenses },
+ ]
+
+ const percentKpis: KpiKv[] = [
+ // calculateGrossMargin returns percentage as `25.5` (i.e. percent units).
+ // The xlsx percent format expects fractional values (0.255 → 25.50%).
+ // Divide by 100 so the displayed value matches the in-app KPI tile.
+ { label: 'Bruttomarginal', value: scaleToFraction(calculateGrossMargin(incomeStatement)) },
+ { label: 'Kostnadsandel', value: scaleToFraction(calculateExpenseRatio(incomeStatement)) },
+ ]
+
+ const integerKpis: KpiKv[] = [
+ { label: 'Genomsnittliga betaldagar', value: calculateAvgPaymentDays(paidInvoices) },
+ ]
+
+ const monthRows: MonthRow[] = monthlyBreakdown.months
+
+ const compositionRows: CompositionRow[] = [
+ { klass: '4 — Material/varor', amount: Math.round(expenseComposition.class4 * 100) / 100 },
+ { klass: '5 — Externa kostnader', amount: Math.round(expenseComposition.class5 * 100) / 100 },
+ { klass: '6 — Externa kostnader', amount: Math.round(expenseComposition.class6 * 100) / 100 },
+ { klass: '7 — Personalkostnader', amount: Math.round(expenseComposition.class7 * 100) / 100 },
+ ]
+
+ const supplierRows: SupplierRow[] = topSuppliers
+
+ const buffer = reportToWorkbook([
+ {
+ name: 'Nyckeltal (kr)',
+ columns: [textColumn('Nyckeltal'), currencyColumn('Värde')],
+ rows: currencyKpis,
+ mapRow: (r) => [r.label, r.value],
+ },
+ {
+ name: 'Nyckeltal (%)',
+ columns: [textColumn('Nyckeltal'), percentColumn('Värde')],
+ rows: percentKpis,
+ mapRow: (r) => [r.label, r.value],
+ },
+ {
+ name: 'Nyckeltal (övrigt)',
+ columns: [textColumn('Nyckeltal'), integerColumn('Värde')],
+ rows: integerKpis,
+ mapRow: (r) => [r.label, r.value],
+ },
+ {
+ name: 'Månadsbrytning',
+ columns: [
+ textColumn('Månad'),
+ currencyColumn('Intäkter'),
+ currencyColumn('Kostnader'),
+ currencyColumn('Netto'),
+ ],
+ rows: monthRows,
+ mapRow: (m) => [m.label, m.income, m.expenses, m.net],
+ },
+ {
+ name: 'Kostnadssammansättning',
+ columns: [textColumn('Kontoklass'), currencyColumn('Belopp')],
+ rows: compositionRows,
+ mapRow: (r) => [r.klass, r.amount],
+ },
+ {
+ name: 'Topp leverantörer',
+ columns: [textColumn('Leverantör'), currencyColumn('Totalt')],
+ rows: supplierRows,
+ mapRow: (r) => [r.supplier_name, r.total],
+ },
+ ])
+
+ const filename = xlsxFilename('nyckeltal', companyRow?.company_name ?? '', period.period_end)
+ return new NextResponse(new Uint8Array(buffer), {
+ headers: {
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera nyckeltalsrapport' },
+ { status: 500 }
+ )
+ }
+}
+
+function scaleToFraction(value: number | null): number | null {
+ return value === null ? null : Math.round(value) / 100
+}
diff --git a/app/api/reports/monthly-breakdown/xlsx/route.ts b/app/api/reports/monthly-breakdown/xlsx/route.ts
new file mode 100644
index 00000000..89618a15
--- /dev/null
+++ b/app/api/reports/monthly-breakdown/xlsx/route.ts
@@ -0,0 +1,77 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown'
+import { requireCompanyId } from '@/lib/company/context'
+import {
+ reportToWorkbook,
+ textColumn,
+ currencyColumn,
+ xlsxFilename,
+} from '@/lib/reports/xlsx-export'
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const periodId = searchParams.get('period_id')
+
+ if (!periodId) {
+ return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
+ }
+
+ const [{ data: period }, { data: companyRow }] = await Promise.all([
+ supabase
+ .from('fiscal_periods')
+ .select('period_start, period_end')
+ .eq('id', periodId)
+ .eq('company_id', companyId)
+ .single(),
+ supabase
+ .from('company_settings')
+ .select('company_name')
+ .eq('company_id', companyId)
+ .single(),
+ ])
+
+ try {
+ const breakdown = await generateMonthlyBreakdown(supabase, companyId, periodId)
+
+ const buffer = reportToWorkbook([
+ {
+ name: 'Månadsbrytning',
+ columns: [
+ textColumn('Månad'),
+ currencyColumn('Intäkter'),
+ currencyColumn('Kostnader'),
+ currencyColumn('Netto'),
+ ],
+ rows: breakdown.months,
+ mapRow: (m) => [m.label, m.income, m.expenses, m.net],
+ },
+ ])
+
+ const filename = xlsxFilename(
+ 'manadsbrytning',
+ companyRow?.company_name ?? '',
+ period?.period_end ?? new Date().toISOString().slice(0, 10),
+ )
+ return new NextResponse(new Uint8Array(buffer), {
+ headers: {
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera månadsbrytning' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/reports/resultatrapport/xlsx/route.ts b/app/api/reports/resultatrapport/xlsx/route.ts
new file mode 100644
index 00000000..5c9a31b3
--- /dev/null
+++ b/app/api/reports/resultatrapport/xlsx/route.ts
@@ -0,0 +1,111 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { generateResultatrapport } from '@/lib/reports/resultatrapport'
+import { requireCompanyId } from '@/lib/company/context'
+import {
+ reportToWorkbook,
+ textColumn,
+ currencyColumn,
+ xlsxFilename,
+} from '@/lib/reports/xlsx-export'
+
+interface FlatRow {
+ group: string
+ account_number: string
+ account_name: string
+ current_period: number
+ prior_period: number
+}
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const periodId = searchParams.get('period_id')
+
+ if (!periodId) {
+ return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
+ }
+
+ const { data: companyRow } = await supabase
+ .from('company_settings')
+ .select('company_name')
+ .eq('company_id', companyId)
+ .single()
+
+ try {
+ const report = await generateResultatrapport(supabase, companyId, periodId)
+
+ const rows: FlatRow[] = []
+ for (const g of report.groups) {
+ for (const r of g.rows) {
+ rows.push({
+ group: g.class_label,
+ account_number: r.account_number,
+ account_name: r.account_name,
+ current_period: r.current_period,
+ prior_period: r.prior_period,
+ })
+ }
+ rows.push({
+ group: g.class_label,
+ account_number: '',
+ account_name: `Summa ${g.class_label}`,
+ current_period: g.subtotal_current,
+ prior_period: g.subtotal_prior,
+ })
+ }
+ rows.push({
+ group: 'Resultat',
+ account_number: '',
+ account_name: 'Årets resultat',
+ current_period: report.net_result_current,
+ prior_period: report.net_result_prior,
+ })
+
+ const buffer = reportToWorkbook([
+ {
+ name: 'Resultatrapport',
+ columns: [
+ textColumn('Grupp'),
+ textColumn('Konto'),
+ textColumn('Kontonamn'),
+ currencyColumn('Aktuell period'),
+ currencyColumn('Föregående period'),
+ ],
+ rows,
+ mapRow: (r) => [
+ r.group,
+ r.account_number,
+ r.account_name,
+ r.current_period,
+ r.prior_period,
+ ],
+ },
+ ])
+
+ const filename = xlsxFilename(
+ 'resultatrapport',
+ companyRow?.company_name ?? '',
+ report.period.end,
+ )
+ return new NextResponse(new Uint8Array(buffer), {
+ headers: {
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera resultatrapport' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/reports/salary-journal/xlsx/route.ts b/app/api/reports/salary-journal/xlsx/route.ts
new file mode 100644
index 00000000..95775c6d
--- /dev/null
+++ b/app/api/reports/salary-journal/xlsx/route.ts
@@ -0,0 +1,113 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { requireCompanyId } from '@/lib/company/context'
+import { generateSalaryJournal } from '@/lib/reports/salary-journal'
+import {
+ reportToWorkbook,
+ textColumn,
+ currencyColumn,
+ dateColumn,
+ integerColumn,
+ xlsxFilename,
+} from '@/lib/reports/xlsx-export'
+
+function toDate(s: string): Date | null {
+ if (!s) return null
+ const d = new Date(s)
+ return isNaN(d.getTime()) ? null : d
+}
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const year = parseInt(searchParams.get('year') || new Date().getFullYear().toString())
+ const monthFrom = searchParams.get('month_from') ? parseInt(searchParams.get('month_from')!) : undefined
+ const monthTo = searchParams.get('month_to') ? parseInt(searchParams.get('month_to')!) : undefined
+
+ const { data: companyRow } = await supabase
+ .from('company_settings')
+ .select('company_name')
+ .eq('company_id', companyId)
+ .single()
+
+ try {
+ const report = await generateSalaryJournal(supabase, companyId, year, monthFrom, monthTo)
+
+ const buffer = reportToWorkbook([
+ {
+ name: 'Lönejournal',
+ columns: [
+ textColumn('Anställd'),
+ textColumn('Personnr (4)'),
+ textColumn('Anställning'),
+ integerColumn('År'),
+ integerColumn('Månad'),
+ dateColumn('Utbetalningsdatum'),
+ currencyColumn('Bruttolön'),
+ currencyColumn('Skatt'),
+ currencyColumn('Nettolön'),
+ currencyColumn('Arbetsgivaravgifter'),
+ currencyColumn('Semesterlönereservation'),
+ currencyColumn('Semesterskuld avgifter'),
+ currencyColumn('Total arbetsgivarkostnad'),
+ integerColumn('Sjukdagar'),
+ integerColumn('VAB-dagar'),
+ integerColumn('Föräldradagar'),
+ integerColumn('Semesterdagar uttagna'),
+ textColumn('Status'),
+ ],
+ rows: report.rows,
+ mapRow: (r) => [
+ r.employeeName,
+ r.personnummerLast4,
+ r.employmentType,
+ r.periodYear,
+ r.periodMonth,
+ toDate(r.paymentDate),
+ r.grossSalary,
+ r.taxWithheld,
+ r.netSalary,
+ r.avgifterAmount,
+ r.vacationAccrual,
+ r.vacationAccrualAvgifter,
+ r.totalEmployerCost,
+ r.sickDays,
+ r.vabDays,
+ r.parentalDays,
+ r.vacationDaysTaken,
+ r.salaryRunStatus,
+ ],
+ },
+ ])
+
+ // Use the period's last month-end as the filename anchor. For full-year
+ // reports this is `YYYY-12-31`; for narrowed month ranges we approximate
+ // with the end month's last day (good enough for filename ordering).
+ const endMonth = monthTo ?? 12
+ const periodAnchor = `${year}-${String(endMonth).padStart(2, '0')}-31`
+ const filename = xlsxFilename(
+ 'lonejournal',
+ companyRow?.company_name ?? '',
+ periodAnchor,
+ )
+ return new NextResponse(new Uint8Array(buffer), {
+ headers: {
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera lönejournal' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/__tests__/route.test.ts b/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/__tests__/route.test.ts
new file mode 100644
index 00000000..d985dc04
--- /dev/null
+++ b/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/__tests__/route.test.ts
@@ -0,0 +1,151 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { createMockRequest, createMockRouteParams } from '@/tests/helpers'
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(),
+}))
+
+vi.mock('@/lib/company/context', () => ({
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+vi.mock('@/lib/bookkeeping/currency-utils', () => ({
+ resolveSekAmount: vi.fn((amount: number) => amount),
+}))
+
+import { createClient } from '@/lib/supabase/server'
+import { GET } from '../route'
+
+const mockCreateClient = vi.mocked(createClient)
+
+interface QueryResult {
+ data: unknown
+ error: unknown
+}
+
+function buildSupabase(
+ user: { id: string } | null,
+ supplier: { id: string; name: string } | null,
+ invoicesResult: QueryResult,
+ entriesResult: QueryResult
+) {
+ return {
+ auth: {
+ getUser: vi.fn().mockResolvedValue({ data: { user } }),
+ },
+ from: vi.fn().mockImplementation((table: string) => {
+ if (table === 'suppliers') {
+ return {
+ select: vi.fn().mockReturnThis(),
+ eq: vi.fn().mockReturnThis(),
+ maybeSingle: vi.fn().mockResolvedValue({ data: supplier, error: null }),
+ }
+ }
+ if (table === 'supplier_invoices') {
+ return {
+ select: vi.fn().mockReturnThis(),
+ eq: vi.fn().mockReturnThis(),
+ in: vi.fn().mockReturnThis(),
+ order: vi.fn().mockReturnThis(),
+ limit: vi.fn().mockReturnThis(),
+ then: (resolve: (v: QueryResult) => void) => resolve(invoicesResult),
+ }
+ }
+ // journal_entries
+ return {
+ select: vi.fn().mockReturnThis(),
+ eq: vi.fn().mockReturnThis(),
+ in: vi.fn().mockReturnThis(),
+ then: (resolve: (v: QueryResult) => void) => resolve(entriesResult),
+ }
+ }),
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('GET /api/reports/supplier-ledger/supplier/[supplierId]/invoices', () => {
+ it('returns 401 when not authenticated', async () => {
+ mockCreateClient.mockResolvedValue(
+ buildSupabase(null, null, { data: [], error: null }, { data: [], error: null }) as never
+ )
+ const req = createMockRequest(
+ '/api/reports/supplier-ledger/supplier/sup-1/invoices'
+ )
+ const res = await GET(req, createMockRouteParams({ supplierId: 'sup-1' }))
+ expect(res.status).toBe(401)
+ })
+
+ it('returns 404 when supplier is unknown', async () => {
+ mockCreateClient.mockResolvedValue(
+ buildSupabase({ id: 'user-1' }, null, { data: [], error: null }, { data: [], error: null }) as never
+ )
+ const req = createMockRequest(
+ '/api/reports/supplier-ledger/supplier/sup-1/invoices'
+ )
+ const res = await GET(req, createMockRouteParams({ supplierId: 'sup-1' }))
+ expect(res.status).toBe(404)
+ })
+
+ it('happy path: returns supplier invoices with journal entries', async () => {
+ const invoices = [
+ {
+ id: 'si-1',
+ supplier_invoice_number: 'INV-7',
+ invoice_date: '2026-05-10',
+ due_date: '2026-06-10',
+ total: 2500,
+ paid_amount: 0,
+ remaining_amount: 2500,
+ currency: 'SEK',
+ exchange_rate: null,
+ registration_journal_entry_id: 'je-3',
+ },
+ ]
+ const entries = [
+ {
+ id: 'je-3',
+ voucher_number: 33,
+ voucher_series: 'B',
+ description: 'Leverantörsfaktura INV-7',
+ entry_date: '2026-05-10',
+ },
+ ]
+ mockCreateClient.mockResolvedValue(
+ buildSupabase(
+ { id: 'user-1' },
+ { id: 'sup-1', name: 'Office Supply AB' },
+ { data: invoices, error: null },
+ { data: entries, error: null }
+ ) as never
+ )
+ const req = createMockRequest(
+ '/api/reports/supplier-ledger/supplier/sup-1/invoices'
+ )
+ const res = await GET(req, createMockRouteParams({ supplierId: 'sup-1' }))
+ expect(res.status).toBe(200)
+
+ const body = (await res.json()) as {
+ data: {
+ supplier_id: string
+ supplier_name: string
+ lines: Array<{
+ supplier_invoice_id: string
+ journal_entry_id: string
+ voucher_number: number
+ credit: number
+ }>
+ }
+ }
+
+ expect(body.data.supplier_id).toBe('sup-1')
+ expect(body.data.supplier_name).toBe('Office Supply AB')
+ expect(body.data.lines).toHaveLength(1)
+ expect(body.data.lines[0].supplier_invoice_id).toBe('si-1')
+ expect(body.data.lines[0].journal_entry_id).toBe('je-3')
+ expect(body.data.lines[0].voucher_number).toBe(33)
+ expect(body.data.lines[0].credit).toBe(2500)
+ })
+})
diff --git a/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts b/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts
new file mode 100644
index 00000000..62e898a5
--- /dev/null
+++ b/app/api/reports/supplier-ledger/supplier/[supplierId]/invoices/route.ts
@@ -0,0 +1,143 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { requireCompanyId } from '@/lib/company/context'
+import { resolveSekAmount } from '@/lib/bookkeeping/currency-utils'
+import type { ReportSourceLine } from '@/lib/reports/source-lines'
+
+/**
+ * GET /api/reports/supplier-ledger/supplier/[supplierId]/invoices
+ *
+ * Returns the supplier invoices behind a supplier's outstanding balance.
+ * Each row's `journal_entry_id` points at the registration journal entry
+ * (when posted) so the UI can link to `/bookkeeping/[id]`.
+ */
+const PAGE_LIMIT = 500
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ supplierId: string }> }
+) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+ const { supplierId } = await params
+
+ const { data: supplier } = await supabase
+ .from('suppliers')
+ .select('id, name')
+ .eq('id', supplierId)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (!supplier) {
+ return NextResponse.json({ error: 'Leverantör saknas' }, { status: 404 })
+ }
+
+ // Mirror `generateSupplierLedger`'s filter: registered/approved/partially
+ // paid/overdue invoices that still have an outstanding balance.
+ const { data, error } = await supabase
+ .from('supplier_invoices')
+ .select(`
+ id,
+ supplier_invoice_number,
+ invoice_date,
+ due_date,
+ total,
+ paid_amount,
+ remaining_amount,
+ currency,
+ exchange_rate,
+ registration_journal_entry_id
+ `)
+ .eq('company_id', companyId)
+ .eq('supplier_id', supplierId)
+ .in('status', ['registered', 'approved', 'partially_paid', 'overdue'])
+ .order('invoice_date', { ascending: true })
+ .limit(PAGE_LIMIT)
+
+ if (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 })
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const invoices = (data || []) as any[]
+
+ // Pull the registration entries in one batch to get voucher numbers.
+ const entryIds = invoices
+ .map((i) => i.registration_journal_entry_id)
+ .filter((id): id is string => !!id)
+ const entryMap = new Map<
+ string,
+ { voucher_number: number; voucher_series: string; description: string | null; entry_date: string }
+ >()
+ if (entryIds.length > 0) {
+ const { data: entries } = await supabase
+ .from('journal_entries')
+ .select('id, voucher_number, voucher_series, description, entry_date')
+ .eq('company_id', companyId)
+ .in('id', entryIds)
+ .in('status', ['posted', 'reversed'])
+ for (const e of entries || []) {
+ entryMap.set(e.id, {
+ voucher_number: e.voucher_number,
+ voucher_series: e.voucher_series || 'A',
+ description: e.description,
+ entry_date: e.entry_date,
+ })
+ }
+ }
+
+ const lines: (ReportSourceLine & {
+ supplier_invoice_id: string
+ supplier_invoice_number: string
+ remaining_sek: number | null
+ currency: string
+ paid_amount: number
+ due_date: string
+ })[] = invoices.map((inv) => {
+ const entry = inv.registration_journal_entry_id
+ ? entryMap.get(inv.registration_journal_entry_id)
+ : undefined
+
+ const remaining = Number(inv.remaining_amount) || 0
+ const isFx = inv.currency && inv.currency !== 'SEK'
+ const hasRate = inv.exchange_rate != null && Number(inv.exchange_rate) > 0
+ const remainingSek =
+ isFx && !hasRate
+ ? null
+ : resolveSekAmount(remaining, null, inv.currency, inv.exchange_rate)
+
+ return {
+ journal_entry_id: inv.registration_journal_entry_id || '',
+ voucher_number: entry?.voucher_number ?? 0,
+ voucher_series: entry?.voucher_series ?? 'A',
+ date: inv.invoice_date || entry?.entry_date || '',
+ description:
+ entry?.description ??
+ `Leverantörsfaktura ${inv.supplier_invoice_number || ''}`,
+ debit: 0,
+ // For an unpaid AP entry, the open balance is a credit on 2440.
+ credit: remainingSek ?? remaining,
+ supplier_invoice_id: inv.id,
+ supplier_invoice_number: inv.supplier_invoice_number || '',
+ remaining_sek: remainingSek,
+ currency: inv.currency || 'SEK',
+ paid_amount: Number(inv.paid_amount) || 0,
+ due_date: inv.due_date,
+ }
+ })
+
+ return NextResponse.json({
+ data: {
+ supplier_id: supplier.id,
+ supplier_name: supplier.name,
+ lines,
+ next_cursor: null,
+ },
+ })
+}
diff --git a/app/api/reports/supplier-ledger/xlsx/route.ts b/app/api/reports/supplier-ledger/xlsx/route.ts
new file mode 100644
index 00000000..dc7df499
--- /dev/null
+++ b/app/api/reports/supplier-ledger/xlsx/route.ts
@@ -0,0 +1,96 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { generateSupplierLedger } from '@/lib/reports/supplier-ledger'
+import { requireCompanyId } from '@/lib/company/context'
+import {
+ reportToWorkbook,
+ textColumn,
+ currencyColumn,
+ xlsxFilename,
+} from '@/lib/reports/xlsx-export'
+
+interface AgingRow {
+ supplier_name: string
+ current: number
+ days_1_30: number
+ days_31_60: number
+ days_61_90: number
+ days_90_plus: number
+ total_outstanding: number
+}
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const asOfDate = searchParams.get('as_of_date') || undefined
+
+ const { data: companyRow } = await supabase
+ .from('company_settings')
+ .select('company_name')
+ .eq('company_id', companyId)
+ .single()
+
+ try {
+ const ledger = await generateSupplierLedger(supabase, companyId, asOfDate)
+
+ const rows: AgingRow[] = ledger.entries.map((e) => ({
+ supplier_name: e.supplier_name,
+ current: e.current,
+ days_1_30: e.days_1_30,
+ days_31_60: e.days_31_60,
+ days_61_90: e.days_61_90,
+ days_90_plus: e.days_90_plus,
+ total_outstanding: e.total_outstanding,
+ }))
+
+ const buffer = reportToWorkbook([
+ {
+ name: 'Leverantörsreskontra',
+ columns: [
+ textColumn('Leverantör'),
+ currencyColumn('Ej förfallet'),
+ currencyColumn('1-30 dagar'),
+ currencyColumn('31-60 dagar'),
+ currencyColumn('61-90 dagar'),
+ currencyColumn('90+ dagar'),
+ currencyColumn('Totalt utestående'),
+ ],
+ rows,
+ mapRow: (r) => [
+ r.supplier_name,
+ r.current,
+ r.days_1_30,
+ r.days_31_60,
+ r.days_61_90,
+ r.days_90_plus,
+ r.total_outstanding,
+ ],
+ },
+ ])
+
+ const filename = xlsxFilename(
+ 'leverantorsreskontra',
+ companyRow?.company_name ?? '',
+ asOfDate ?? new Date().toISOString().slice(0, 10),
+ )
+ return new NextResponse(new Uint8Array(buffer), {
+ headers: {
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera leverantörsreskontra' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts b/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts
new file mode 100644
index 00000000..48b45cc1
--- /dev/null
+++ b/app/api/reports/trial-balance/account/[accountNumber]/sources/__tests__/route.test.ts
@@ -0,0 +1,163 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { createMockRequest, createMockRouteParams } from '@/tests/helpers'
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(),
+}))
+
+vi.mock('@/lib/company/context', () => ({
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+import { createClient } from '@/lib/supabase/server'
+import { GET } from '../route'
+
+const mockCreateClient = vi.mocked(createClient)
+
+interface AuthShape {
+ auth: { getUser: ReturnType }
+ from: ReturnType
+}
+
+function buildSupabase(
+ user: { id: string } | null,
+ account: { account_number: string; account_name: string } | null,
+ linesResult: { data: unknown; error: unknown }
+): AuthShape {
+ return {
+ auth: {
+ getUser: vi.fn().mockResolvedValue({ data: { user } }),
+ },
+ from: vi.fn().mockImplementation((table: string) => {
+ if (table === 'chart_of_accounts') {
+ const chain = {
+ select: vi.fn().mockReturnThis(),
+ eq: vi.fn().mockReturnThis(),
+ maybeSingle: vi.fn().mockResolvedValue({ data: account, error: null }),
+ }
+ return chain
+ }
+ // journal_entry_lines
+ const chain = {
+ select: vi.fn().mockReturnThis(),
+ eq: vi.fn().mockReturnThis(),
+ in: vi.fn().mockReturnThis(),
+ gte: vi.fn().mockReturnThis(),
+ lte: vi.fn().mockReturnThis(),
+ order: vi.fn().mockReturnThis(),
+ limit: vi.fn().mockReturnThis(),
+ or: vi.fn().mockReturnThis(),
+ then: (resolve: (v: unknown) => void) => resolve(linesResult),
+ }
+ return chain
+ }),
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('GET /api/reports/trial-balance/account/[accountNumber]/sources', () => {
+ it('returns 401 when not authenticated', async () => {
+ mockCreateClient.mockResolvedValue(
+ buildSupabase(null, null, { data: [], error: null }) as never
+ )
+ const req = createMockRequest(
+ '/api/reports/trial-balance/account/1930/sources',
+ { searchParams: { fiscal_period_id: 'period-1' } }
+ )
+ const res = await GET(req, createMockRouteParams({ accountNumber: '1930' }))
+ expect(res.status).toBe(401)
+ })
+
+ it('returns 400 when fiscal_period_id is missing', async () => {
+ mockCreateClient.mockResolvedValue(
+ buildSupabase({ id: 'user-1' }, null, { data: [], error: null }) as never
+ )
+ const req = createMockRequest(
+ '/api/reports/trial-balance/account/1930/sources'
+ )
+ const res = await GET(req, createMockRouteParams({ accountNumber: '1930' }))
+ expect(res.status).toBe(400)
+ })
+
+ it('returns 404 when account is unknown for the company', async () => {
+ mockCreateClient.mockResolvedValue(
+ buildSupabase({ id: 'user-1' }, null, { data: [], error: null }) as never
+ )
+ const req = createMockRequest(
+ '/api/reports/trial-balance/account/9999/sources',
+ { searchParams: { fiscal_period_id: 'period-1' } }
+ )
+ const res = await GET(req, createMockRouteParams({ accountNumber: '9999' }))
+ expect(res.status).toBe(404)
+ })
+
+ it('happy path: returns mapped lines for an account', async () => {
+ const linesData = [
+ {
+ debit_amount: 1250,
+ credit_amount: 0,
+ journal_entry_id: 'je-1',
+ journal_entries: {
+ id: 'je-1',
+ voucher_number: 7,
+ voucher_series: 'A',
+ entry_date: '2026-05-02',
+ description: 'Provision',
+ status: 'posted',
+ company_id: 'company-1',
+ fiscal_period_id: 'period-1',
+ },
+ },
+ {
+ debit_amount: 0,
+ credit_amount: 700,
+ journal_entry_id: 'je-2',
+ journal_entries: {
+ id: 'je-2',
+ voucher_number: 8,
+ voucher_series: 'A',
+ entry_date: '2026-05-03',
+ description: 'Återbet',
+ status: 'posted',
+ company_id: 'company-1',
+ fiscal_period_id: 'period-1',
+ },
+ },
+ ]
+ mockCreateClient.mockResolvedValue(
+ buildSupabase(
+ { id: 'user-1' },
+ { account_number: '1930', account_name: 'Företagskonto' },
+ { data: linesData, error: null }
+ ) as never
+ )
+
+ const req = createMockRequest(
+ '/api/reports/trial-balance/account/1930/sources',
+ { searchParams: { fiscal_period_id: 'period-1' } }
+ )
+ const res = await GET(req, createMockRouteParams({ accountNumber: '1930' }))
+ expect(res.status).toBe(200)
+
+ const body = (await res.json()) as {
+ data: {
+ account_number: string
+ account_name: string
+ lines: Array<{ voucher_number: number; debit: number; credit: number; journal_entry_id: string }>
+ next_cursor: string | null
+ }
+ }
+
+ expect(body.data.account_number).toBe('1930')
+ expect(body.data.account_name).toBe('Företagskonto')
+ expect(body.data.lines).toHaveLength(2)
+ expect(body.data.lines[0].voucher_number).toBe(7)
+ expect(body.data.lines[0].debit).toBe(1250)
+ expect(body.data.lines[0].journal_entry_id).toBe('je-1')
+ expect(body.data.lines[1].credit).toBe(700)
+ expect(body.data.next_cursor).toBeNull()
+ })
+})
diff --git a/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts b/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts
new file mode 100644
index 00000000..ba261117
--- /dev/null
+++ b/app/api/reports/trial-balance/account/[accountNumber]/sources/route.ts
@@ -0,0 +1,138 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { requireCompanyId } from '@/lib/company/context'
+import type { ReportSourceLine } from '@/lib/reports/source-lines'
+
+/**
+ * GET /api/reports/trial-balance/account/[accountNumber]/sources
+ *
+ * Returns the journal entry lines for one account in a fiscal period,
+ * ordered by entry date then voucher number ASC. Used by the trial balance
+ * drilldown UI to show the verifikat behind an aggregated row.
+ *
+ * Pagination uses an opaque cursor of `|` for
+ * the last seen row; pass it back as `cursor` to continue.
+ */
+const PAGE_LIMIT = 500
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ accountNumber: string }> }
+) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+ const { accountNumber } = await params
+
+ const { searchParams } = new URL(request.url)
+ const fiscalPeriodId = searchParams.get('fiscal_period_id')
+ const cursor = searchParams.get('cursor')
+
+ if (!fiscalPeriodId) {
+ return NextResponse.json(
+ { error: 'fiscal_period_id is required' },
+ { status: 400 }
+ )
+ }
+
+ // Look up account name (and verify account belongs to the company)
+ const { data: account } = await supabase
+ .from('chart_of_accounts')
+ .select('account_number, account_name')
+ .eq('company_id', companyId)
+ .eq('account_number', accountNumber)
+ .maybeSingle()
+
+ if (!account) {
+ return NextResponse.json(
+ { error: 'Konto saknas' },
+ { status: 404 }
+ )
+ }
+
+ // Pull all lines on this account in this period. We rely on the same
+ // join+filter pattern as `generateTrialBalance`. Pagination is server-side
+ // via cursor so even an account with tens of thousands of rows stays cheap.
+ let query = supabase
+ .from('journal_entry_lines')
+ .select(`
+ debit_amount,
+ credit_amount,
+ journal_entry_id,
+ journal_entries!inner(
+ id,
+ voucher_number,
+ voucher_series,
+ entry_date,
+ description,
+ status,
+ company_id,
+ fiscal_period_id
+ )
+ `)
+ .eq('account_number', accountNumber)
+ .eq('journal_entries.company_id', companyId)
+ .eq('journal_entries.fiscal_period_id', fiscalPeriodId)
+ .in('journal_entries.status', ['posted', 'reversed'])
+ .order('entry_date', { foreignTable: 'journal_entries', ascending: true })
+ .order('voucher_number', { foreignTable: 'journal_entries', ascending: true })
+ .limit(PAGE_LIMIT + 1)
+
+ if (cursor) {
+ // Cursor format: |
+ const [cursorDate, cursorVoucher] = cursor.split('|')
+ const cursorVoucherNum = parseInt(cursorVoucher, 10)
+ if (!cursorDate || isNaN(cursorVoucherNum)) {
+ return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 })
+ }
+ // Filter for rows strictly after the cursor (date>cur OR same date & voucher>cur).
+ // Supabase doesn't expose tuple compare, so use an `or()` clause.
+ query = query.or(
+ `entry_date.gt.${cursorDate},and(entry_date.eq.${cursorDate},voucher_number.gt.${cursorVoucherNum})`,
+ { foreignTable: 'journal_entries' }
+ )
+ }
+
+ const { data, error } = await query
+
+ if (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 })
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const rows = (data || []) as any[]
+
+ const lines: ReportSourceLine[] = rows
+ .slice(0, PAGE_LIMIT)
+ .map((row) => ({
+ journal_entry_id: row.journal_entries.id,
+ voucher_number: row.journal_entries.voucher_number,
+ voucher_series: row.journal_entries.voucher_series || 'A',
+ date: row.journal_entries.entry_date,
+ description: row.journal_entries.description || '',
+ debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100,
+ credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100,
+ }))
+
+ // If we got more than PAGE_LIMIT rows back, the next cursor points at the
+ // last delivered row so the next call resumes from after it.
+ let next_cursor: string | null = null
+ if (rows.length > PAGE_LIMIT && lines.length > 0) {
+ const last = lines[lines.length - 1]
+ next_cursor = `${last.date}|${last.voucher_number}`
+ }
+
+ return NextResponse.json({
+ data: {
+ account_number: account.account_number,
+ account_name: account.account_name,
+ lines,
+ next_cursor,
+ },
+ })
+}
diff --git a/app/api/reports/trial-balance/xlsx/route.ts b/app/api/reports/trial-balance/xlsx/route.ts
new file mode 100644
index 00000000..d12bd758
--- /dev/null
+++ b/app/api/reports/trial-balance/xlsx/route.ts
@@ -0,0 +1,94 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { generateTrialBalance } from '@/lib/reports/trial-balance'
+import { requireCompanyId } from '@/lib/company/context'
+import {
+ reportToWorkbook,
+ textColumn,
+ currencyColumn,
+ integerColumn,
+ xlsxFilename,
+} from '@/lib/reports/xlsx-export'
+import type { TrialBalanceRow } from '@/types'
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const periodId = searchParams.get('period_id')
+
+ if (!periodId) {
+ return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
+ }
+
+ const [{ data: period }, { data: companyRow }] = await Promise.all([
+ supabase
+ .from('fiscal_periods')
+ .select('period_start, period_end')
+ .eq('id', periodId)
+ .eq('company_id', companyId)
+ .single(),
+ supabase
+ .from('company_settings')
+ .select('company_name')
+ .eq('company_id', companyId)
+ .single(),
+ ])
+
+ if (!period) {
+ return NextResponse.json({ error: 'Räkenskapsperioden kunde inte läsas.' }, { status: 400 })
+ }
+
+ try {
+ const report = await generateTrialBalance(supabase, companyId, periodId)
+
+ const buffer = reportToWorkbook([
+ {
+ name: 'Saldobalans',
+ columns: [
+ textColumn('Konto'),
+ textColumn('Kontonamn'),
+ integerColumn('Klass'),
+ currencyColumn('IB Debet'),
+ currencyColumn('IB Kredit'),
+ currencyColumn('Period Debet'),
+ currencyColumn('Period Kredit'),
+ currencyColumn('UB Debet'),
+ currencyColumn('UB Kredit'),
+ ],
+ rows: report.rows,
+ mapRow: (r) => [
+ r.account_number,
+ r.account_name,
+ r.account_class,
+ r.opening_debit,
+ r.opening_credit,
+ r.period_debit,
+ r.period_credit,
+ r.closing_debit,
+ r.closing_credit,
+ ],
+ },
+ ])
+
+ const filename = xlsxFilename('saldobalans', companyRow?.company_name ?? '', period.period_end)
+ return new NextResponse(new Uint8Array(buffer), {
+ headers: {
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera saldobalans' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts
new file mode 100644
index 00000000..640ade60
--- /dev/null
+++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/__tests__/route.test.ts
@@ -0,0 +1,120 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { createMockRequest, createMockRouteParams } from '@/tests/helpers'
+
+vi.mock('@/lib/supabase/server', () => ({
+ createClient: vi.fn(),
+}))
+
+vi.mock('@/lib/company/context', () => ({
+ requireCompanyId: vi.fn().mockResolvedValue('company-1'),
+}))
+
+import { createClient } from '@/lib/supabase/server'
+import { GET } from '../route'
+
+const mockCreateClient = vi.mocked(createClient)
+
+function buildSupabase(
+ user: { id: string } | null,
+ linesResult: { data: unknown; error: unknown }
+) {
+ return {
+ auth: {
+ getUser: vi.fn().mockResolvedValue({ data: { user } }),
+ },
+ from: vi.fn().mockImplementation(() => ({
+ select: vi.fn().mockReturnThis(),
+ eq: vi.fn().mockReturnThis(),
+ in: vi.fn().mockReturnThis(),
+ gte: vi.fn().mockReturnThis(),
+ lte: vi.fn().mockReturnThis(),
+ order: vi.fn().mockReturnThis(),
+ limit: vi.fn().mockReturnThis(),
+ or: vi.fn().mockReturnThis(),
+ maybeSingle: vi.fn().mockResolvedValue({ data: null, error: null }),
+ then: (resolve: (v: unknown) => void) => resolve(linesResult),
+ })),
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('GET /api/reports/vat-declaration/ruta/[ruta]/sources', () => {
+ it('returns 401 when not authenticated', async () => {
+ mockCreateClient.mockResolvedValue(
+ buildSupabase(null, { data: [], error: null }) as never
+ )
+ const req = createMockRequest(
+ '/api/reports/vat-declaration/ruta/10/sources',
+ { searchParams: { periodType: 'monthly', year: '2026', period: '5' } }
+ )
+ const res = await GET(req, createMockRouteParams({ ruta: '10' }))
+ expect(res.status).toBe(401)
+ })
+
+ it('returns 400 when period params are missing', async () => {
+ mockCreateClient.mockResolvedValue(
+ buildSupabase({ id: 'user-1' }, { data: [], error: null }) as never
+ )
+ const req = createMockRequest(
+ '/api/reports/vat-declaration/ruta/10/sources'
+ )
+ const res = await GET(req, createMockRouteParams({ ruta: '10' }))
+ expect(res.status).toBe(400)
+ })
+
+ it('returns 404 when ruta has no underlying BAS accounts', async () => {
+ mockCreateClient.mockResolvedValue(
+ buildSupabase({ id: 'user-1' }, { data: [], error: null }) as never
+ )
+ const req = createMockRequest(
+ '/api/reports/vat-declaration/ruta/99/sources',
+ { searchParams: { periodType: 'monthly', year: '2026', period: '5' } }
+ )
+ const res = await GET(req, createMockRouteParams({ ruta: '99' }))
+ expect(res.status).toBe(404)
+ })
+
+ it('happy path: returns mapped lines for ruta10', async () => {
+ const linesData = [
+ {
+ account_number: '2611',
+ debit_amount: 0,
+ credit_amount: 250,
+ journal_entries: {
+ id: 'je-1',
+ voucher_number: 12,
+ voucher_series: 'A',
+ entry_date: '2026-05-12',
+ description: 'Faktura 1001',
+ status: 'posted',
+ company_id: 'company-1',
+ },
+ },
+ ]
+ mockCreateClient.mockResolvedValue(
+ buildSupabase({ id: 'user-1' }, { data: linesData, error: null }) as never
+ )
+
+ const req = createMockRequest(
+ '/api/reports/vat-declaration/ruta/10/sources',
+ { searchParams: { periodType: 'monthly', year: '2026', period: '5' } }
+ )
+ const res = await GET(req, createMockRouteParams({ ruta: '10' }))
+ expect(res.status).toBe(200)
+
+ const body = (await res.json()) as {
+ data: {
+ ruta: string
+ lines: Array<{ voucher_number: number; credit: number }>
+ }
+ }
+
+ expect(body.data.ruta).toBe('ruta10')
+ expect(body.data.lines).toHaveLength(1)
+ expect(body.data.lines[0].voucher_number).toBe(12)
+ expect(body.data.lines[0].credit).toBe(250)
+ })
+})
diff --git a/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts
new file mode 100644
index 00000000..984dc29e
--- /dev/null
+++ b/app/api/reports/vat-declaration/ruta/[ruta]/sources/route.ts
@@ -0,0 +1,167 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import { requireCompanyId } from '@/lib/company/context'
+import {
+ ACCOUNT_RUTA,
+ calculatePeriodDates,
+} from '@/lib/reports/vat-declaration'
+import type { ReportSourceLine } from '@/lib/reports/source-lines'
+import type { VatDeclarationRutor, VatPeriodType } from '@/types'
+
+/**
+ * GET /api/reports/vat-declaration/ruta/[ruta]/sources
+ *
+ * Returns the journal entry lines that contribute to a single ruta on the
+ * VAT declaration. The mapping ruta → BAS accounts is the inverse of
+ * `ACCOUNT_RUTA` in `lib/reports/vat-declaration.ts`.
+ *
+ * Period can be specified either via:
+ * ?periodType=monthly|quarterly|yearly&year=2026&period=5
+ * ?fiscal_period_id=
+ *
+ * The periodType form mirrors the way the main VAT report is fetched.
+ */
+const PAGE_LIMIT = 500
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ ruta: string }> }
+) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+ const { ruta: rutaParam } = await params
+
+ const { searchParams } = new URL(request.url)
+ const cursor = searchParams.get('cursor')
+
+ // Normalise ruta param to the keyof VatDeclarationRutor (`ruta10`, `ruta48`).
+ const rutaKey = (
+ rutaParam.startsWith('ruta') ? rutaParam : `ruta${rutaParam}`
+ ) as keyof VatDeclarationRutor
+
+ // Invert ACCOUNT_RUTA: which BAS accounts feed this ruta?
+ const accountsForRuta = Object.entries(ACCOUNT_RUTA)
+ .filter(([, m]) => m.box === rutaKey)
+ .map(([acc]) => acc)
+
+ if (accountsForRuta.length === 0) {
+ return NextResponse.json(
+ { error: `Ruta ${rutaParam} har inga underliggande konton` },
+ { status: 404 }
+ )
+ }
+
+ // Resolve the period — either by fiscal_period_id or periodType/year/period.
+ let start: string | null = null
+ let end: string | null = null
+ const fiscalPeriodId = searchParams.get('fiscal_period_id')
+ if (fiscalPeriodId) {
+ const { data: period } = await supabase
+ .from('fiscal_periods')
+ .select('period_start, period_end')
+ .eq('id', fiscalPeriodId)
+ .eq('company_id', companyId)
+ .maybeSingle()
+ if (!period) {
+ return NextResponse.json({ error: 'Period saknas' }, { status: 404 })
+ }
+ start = period.period_start
+ end = period.period_end
+ } else {
+ const periodType = searchParams.get('periodType') as VatPeriodType | null
+ const yearStr = searchParams.get('year')
+ const periodStr = searchParams.get('period')
+ if (!periodType || !yearStr || !periodStr) {
+ return NextResponse.json(
+ { error: 'periodType/year/period or fiscal_period_id is required' },
+ { status: 400 }
+ )
+ }
+ const year = parseInt(yearStr, 10)
+ const periodNum = parseInt(periodStr, 10)
+ if (isNaN(year) || isNaN(periodNum)) {
+ return NextResponse.json({ error: 'Invalid period' }, { status: 400 })
+ }
+ const dates = calculatePeriodDates(periodType, year, periodNum)
+ start = dates.start
+ end = dates.end
+ }
+
+ let query = supabase
+ .from('journal_entry_lines')
+ .select(`
+ account_number,
+ debit_amount,
+ credit_amount,
+ journal_entries!inner(
+ id,
+ voucher_number,
+ voucher_series,
+ entry_date,
+ description,
+ status,
+ company_id
+ )
+ `)
+ .in('account_number', accountsForRuta)
+ .eq('journal_entries.company_id', companyId)
+ .in('journal_entries.status', ['posted', 'reversed'])
+ .gte('journal_entries.entry_date', start)
+ .lte('journal_entries.entry_date', end)
+ .order('entry_date', { foreignTable: 'journal_entries', ascending: true })
+ .order('voucher_number', { foreignTable: 'journal_entries', ascending: true })
+ .limit(PAGE_LIMIT + 1)
+
+ if (cursor) {
+ const [cursorDate, cursorVoucher] = cursor.split('|')
+ const cursorVoucherNum = parseInt(cursorVoucher, 10)
+ if (!cursorDate || isNaN(cursorVoucherNum)) {
+ return NextResponse.json({ error: 'Invalid cursor' }, { status: 400 })
+ }
+ query = query.or(
+ `entry_date.gt.${cursorDate},and(entry_date.eq.${cursorDate},voucher_number.gt.${cursorVoucherNum})`,
+ { foreignTable: 'journal_entries' }
+ )
+ }
+
+ const { data, error } = await query
+
+ if (error) {
+ return NextResponse.json({ error: error.message }, { status: 500 })
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const rows = (data || []) as any[]
+
+ const lines: ReportSourceLine[] = rows
+ .slice(0, PAGE_LIMIT)
+ .map((row) => ({
+ journal_entry_id: row.journal_entries.id,
+ voucher_number: row.journal_entries.voucher_number,
+ voucher_series: row.journal_entries.voucher_series || 'A',
+ date: row.journal_entries.entry_date,
+ description: row.journal_entries.description || '',
+ debit: Math.round((Number(row.debit_amount) || 0) * 100) / 100,
+ credit: Math.round((Number(row.credit_amount) || 0) * 100) / 100,
+ }))
+
+ let next_cursor: string | null = null
+ if (rows.length > PAGE_LIMIT && lines.length > 0) {
+ const last = lines[lines.length - 1]
+ next_cursor = `${last.date}|${last.voucher_number}`
+ }
+
+ return NextResponse.json({
+ data: {
+ ruta: rutaKey,
+ lines,
+ next_cursor,
+ },
+ })
+}
diff --git a/app/api/reports/vat-declaration/xlsx/route.ts b/app/api/reports/vat-declaration/xlsx/route.ts
new file mode 100644
index 00000000..3910c94e
--- /dev/null
+++ b/app/api/reports/vat-declaration/xlsx/route.ts
@@ -0,0 +1,116 @@
+import { createClient } from '@/lib/supabase/server'
+import { NextResponse } from 'next/server'
+import {
+ calculateVatDeclaration,
+ formatPeriodLabel,
+} from '@/lib/reports/vat-declaration'
+import { requireCompanyId } from '@/lib/company/context'
+import {
+ reportToWorkbook,
+ textColumn,
+ currencyColumn,
+ xlsxFilename,
+} from '@/lib/reports/xlsx-export'
+import {
+ VAT_RUTA_LABELS,
+ type VatPeriodType,
+ type VatDeclarationRutor,
+ type AccountingMethod,
+} from '@/types'
+
+interface RutaRow {
+ ruta: string
+ label: string
+ amount: number
+}
+
+export async function GET(request: Request) {
+ const supabase = await createClient()
+ const { data: { user } } = await supabase.auth.getUser()
+
+ if (!user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const companyId = await requireCompanyId(supabase, user.id)
+
+ const { searchParams } = new URL(request.url)
+ const periodType = searchParams.get('periodType') as VatPeriodType | null
+ const yearStr = searchParams.get('year')
+ const periodStr = searchParams.get('period')
+
+ if (!periodType || !yearStr || !periodStr) {
+ return NextResponse.json(
+ { error: 'periodType, year, and period are required' },
+ { status: 400 }
+ )
+ }
+ if (!['monthly', 'quarterly', 'yearly'].includes(periodType)) {
+ return NextResponse.json({ error: 'Invalid periodType' }, { status: 400 })
+ }
+
+ const year = parseInt(yearStr, 10)
+ const period = parseInt(periodStr, 10)
+ if (isNaN(year) || isNaN(period)) {
+ return NextResponse.json({ error: 'Invalid year or period' }, { status: 400 })
+ }
+
+ const [{ data: settings }, { data: companyRow }] = await Promise.all([
+ supabase
+ .from('company_settings')
+ .select('accounting_method')
+ .eq('company_id', companyId)
+ .single(),
+ supabase
+ .from('company_settings')
+ .select('company_name')
+ .eq('company_id', companyId)
+ .single(),
+ ])
+
+ const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
+
+ try {
+ const declaration = await calculateVatDeclaration(
+ supabase, companyId, periodType, year, period, accountingMethod,
+ )
+
+ const rows: RutaRow[] = (Object.keys(declaration.rutor) as (keyof VatDeclarationRutor)[]).map(
+ (key) => ({
+ ruta: key.replace(/^ruta/, 'Ruta '),
+ label: VAT_RUTA_LABELS[key],
+ amount: declaration.rutor[key],
+ }),
+ )
+
+ const buffer = reportToWorkbook([
+ {
+ name: `Moms ${formatPeriodLabel(periodType, year, period)}`,
+ columns: [
+ textColumn('Ruta'),
+ textColumn('Beskrivning'),
+ currencyColumn('Belopp'),
+ ],
+ rows,
+ mapRow: (r) => [r.ruta, r.label, r.amount],
+ },
+ ])
+
+ const filename = xlsxFilename(
+ 'momsdeklaration',
+ companyRow?.company_name ?? '',
+ declaration.period.end,
+ )
+ return new NextResponse(new Uint8Array(buffer), {
+ headers: {
+ 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ 'Content-Disposition': `attachment; filename="${filename}"`,
+ },
+ })
+ } catch (err) {
+ return NextResponse.json(
+ { error: err instanceof Error ? err.message : 'Kunde inte generera momsdeklaration' },
+ { status: 500 }
+ )
+ }
+}
diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts
index 6a85e9f4..d402d924 100644
--- a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts
+++ b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/__tests__/route.test.ts
@@ -39,6 +39,7 @@ vi.mock('@react-pdf/renderer', () => ({
}))
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue({}),
+ brandingFromCompanySettings: vi.fn().mockReturnValue({}),
}))
import { validateApiKey, createServiceClientNoCookies } from '@/lib/auth/api-keys'
diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts
index b24cca8a..4178e121 100644
--- a/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts
+++ b/app/api/v1/companies/[companyId]/invoices/[id]/pdf/route.ts
@@ -20,6 +20,7 @@
import { z } from 'zod'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
+import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
@@ -148,6 +149,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
let pdfBuffer: Buffer
try {
+ const { branding } = prepareInvoicePdfRender(company as CompanySettings)
pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: typed as Invoice,
@@ -155,6 +157,7 @@ export const GET = withApiV1<{ params: Promise<{ companyId: string; id: string }
items,
company: company as CompanySettings,
originalInvoiceNumber,
+ branding,
}),
)
} catch (err) {
diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts
index 85fe35c7..364d0013 100644
--- a/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts
+++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/__tests__/route.test.ts
@@ -71,6 +71,7 @@ vi.mock('@/lib/email/invoice-templates', () => ({
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn().mockReturnValue({}),
+ brandingFromCompanySettings: vi.fn().mockReturnValue({}),
}))
import { InvoicePDF } from '@/lib/invoices/pdf-template'
diff --git a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts
index 5f5cacea..eb58fa4b 100644
--- a/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts
+++ b/app/api/v1/companies/[companyId]/invoices/[id]/send/route.ts
@@ -43,6 +43,7 @@ import { registerEndpoint } from '@/lib/api/v1/registry'
import { withApiV1 } from '@/lib/api/v1/with-api-v1'
import { v1ErrorResponse, v1ErrorResponseFromCode } from '@/lib/api/v1/errors'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
+import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { getEmailService } from '@/lib/email/service'
import {
generateInvoiceEmailHtml,
@@ -264,6 +265,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
const isFreshAllocation = !typed.invoice_number
if (isFreshAllocation) {
try {
+ const preflight = prepareInvoicePdfRender(settings)
await renderToBuffer(
InvoicePDF({
invoice: { ...(typed as Invoice), invoice_number: 'F-PREVIEW' },
@@ -271,6 +273,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
items,
company: settings,
originalInvoiceNumber,
+ branding: preflight.branding,
}),
)
} catch (err) {
@@ -352,6 +355,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
let pdfBuffer: Buffer
try {
+ const { branding } = prepareInvoicePdfRender(settings)
pdfBuffer = await renderToBuffer(
InvoicePDF({
invoice: renderableInvoice,
@@ -359,6 +363,7 @@ export const POST = withApiV1<{ params: Promise<{ companyId: string; id: string
items,
company: settings,
originalInvoiceNumber,
+ branding,
}),
)
} catch (err) {
diff --git a/components/bookkeeping/AttachmentPreviewSheet.tsx b/components/bookkeeping/AttachmentPreviewSheet.tsx
index 7ee6fe75..7b472c63 100644
--- a/components/bookkeeping/AttachmentPreviewSheet.tsx
+++ b/components/bookkeeping/AttachmentPreviewSheet.tsx
@@ -1,14 +1,28 @@
'use client'
-import { useCallback, useEffect, useState } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslations } from 'next-intl'
-import { ExternalLink, FileText, ImageIcon, Paperclip } from 'lucide-react'
import {
- Sheet,
- SheetContent,
- SheetHeader,
- SheetTitle,
-} from '@/components/ui/sheet'
+ AlertTriangle,
+ ExternalLink,
+ FileText,
+ ImageIcon,
+ Loader2,
+ Lock,
+ Paperclip,
+ RefreshCw,
+ Trash2,
+} from 'lucide-react'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { useToast } from '@/components/ui/use-toast'
import { Skeleton } from '@/components/ui/skeleton'
interface DocumentRecord {
@@ -47,9 +61,16 @@ export default function AttachmentPreviewSheet({
onOpenChange,
}: AttachmentPreviewSheetProps) {
const t = useTranslations('attachment_preview_sheet')
+ const tj = useTranslations('journal_attachments')
+ const { toast } = useToast()
const [documents, setDocuments] = useState([])
const [loading, setLoading] = useState(false)
+ const [blockedDoc, setBlockedDoc] = useState(null)
+ const [replacingDocId, setReplacingDocId] = useState(null)
+ const replaceFileInputRef = useRef(null)
+ const replaceTargetIdRef = useRef(null)
+
const fetchAttachments = useCallback(async (id: string) => {
setLoading(true)
try {
@@ -87,20 +108,64 @@ export default function AttachmentPreviewSheet({
if (open && entryId) {
fetchAttachments(entryId)
} else if (!open) {
- // Reset state when closed so the next open starts fresh
setDocuments([])
+ setBlockedDoc(null)
}
}, [open, entryId, fetchAttachments])
+ const handleOpenReplacePicker = (docId: string) => {
+ replaceTargetIdRef.current = docId
+ replaceFileInputRef.current?.click()
+ }
+
+ const handleReplaceFileSelected = async (file: File | null) => {
+ const docId = replaceTargetIdRef.current
+ replaceTargetIdRef.current = null
+ if (replaceFileInputRef.current) {
+ replaceFileInputRef.current.value = ''
+ }
+ if (!file || !docId || !entryId) return
+
+ setReplacingDocId(docId)
+ try {
+ const fd = new FormData()
+ fd.append('file', file)
+ const res = await fetch(`/api/documents/${docId}/versions`, {
+ method: 'POST',
+ body: fd,
+ })
+ if (!res.ok) {
+ const { error } = await res.json().catch(() => ({ error: undefined }))
+ toast({
+ title: tj('replace_failed'),
+ description: error || undefined,
+ variant: 'destructive',
+ })
+ } else {
+ await fetchAttachments(entryId)
+ setBlockedDoc(null)
+ }
+ } catch {
+ toast({ title: tj('replace_failed'), variant: 'destructive' })
+ } finally {
+ setReplacingDocId(null)
+ }
+ }
+
return (
-
-
-
- {t('title')}
-
+
+
+
+ {t('title')}
+
+
+ handleReplaceFileSelected(e.target.files?.[0] ?? null)}
+ />
{loading ? (
{isPdfType(doc.mime_type) && (
-
)}
-
-
+
+ {
+ if (!o) setBlockedDoc(null)
+ }}
+ >
+
+
+
+
+
+
+
{tj('remove_blocked_title')}
+
+
+ {tj('remove_blocked_body')}
+
+
+
+
+
+
+
{tj('remove_blocked_hint')}
+
+
+
+
+ setBlockedDoc(null)}>
+ {tj('remove_blocked_cancel_cta')}
+
+ {
+ if (blockedDoc) handleOpenReplacePicker(blockedDoc.id)
+ }}
+ disabled={blockedDoc !== null && replacingDocId === blockedDoc.id}
+ >
+ {blockedDoc !== null && replacingDocId === blockedDoc.id ? (
+ <>
+
+ {tj('replace_uploading')}
+ >
+ ) : (
+ tj('remove_blocked_replace_cta')
+ )}
+
+
+
+
+
+
)
}
diff --git a/components/bookkeeping/CorrectionChain.tsx b/components/bookkeeping/CorrectionChain.tsx
index 4f2ba150..1f924072 100644
--- a/components/bookkeeping/CorrectionChain.tsx
+++ b/components/bookkeeping/CorrectionChain.tsx
@@ -6,6 +6,7 @@ import { Badge } from '@/components/ui/badge'
import { Info } from 'lucide-react'
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
import { formatDate } from '@/lib/utils'
+import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import type { JournalEntry, JournalEntryLine } from '@/types'
interface Props {
@@ -73,7 +74,7 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
{role.label}
- {entry.voucher_series}{entry.voucher_number}
+ {formatVoucher(entry)}
{formatDate(entry.entry_date)}
diff --git a/components/bookkeeping/CorrectionEntryDialog.tsx b/components/bookkeeping/CorrectionEntryDialog.tsx
index aa7629c3..1249ad06 100644
--- a/components/bookkeeping/CorrectionEntryDialog.tsx
+++ b/components/bookkeeping/CorrectionEntryDialog.tsx
@@ -18,6 +18,7 @@ import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { Plus, Trash2 } from 'lucide-react'
import { formatDate } from '@/lib/utils'
+import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types'
interface CorrectionLine {
@@ -165,7 +166,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
{/* Original entry (read-only) */}
- {entry.voucher_series}{entry.voucher_number}
+ {formatVoucher(entry)}
{formatDate(entry.entry_date)}
Original
diff --git a/components/bookkeeping/JournalEntryAttachments.tsx b/components/bookkeeping/JournalEntryAttachments.tsx
index bdcf9527..a80ab70d 100644
--- a/components/bookkeeping/JournalEntryAttachments.tsx
+++ b/components/bookkeeping/JournalEntryAttachments.tsx
@@ -3,7 +3,28 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
-import { FileText, ImageIcon, Download, ChevronDown, ChevronUp, Plus } from 'lucide-react'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from '@/components/ui/dialog'
+import { useToast } from '@/components/ui/use-toast'
+import {
+ FileText,
+ ImageIcon,
+ Download,
+ ChevronDown,
+ ChevronUp,
+ Plus,
+ Trash2,
+ RefreshCw,
+ Loader2,
+ Lock,
+ AlertTriangle,
+} from 'lucide-react'
import DocumentUploadZone from '@/components/bookkeeping/DocumentUploadZone'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
@@ -45,12 +66,22 @@ export default function JournalEntryAttachments({
onCountChange,
}: JournalEntryAttachmentsProps) {
const t = useTranslations('journal_attachments')
+ const { toast } = useToast()
const [documents, setDocuments] = useState
([])
const [loading, setLoading] = useState(true)
const [expandedDoc, setExpandedDoc] = useState(null)
const [showUpload, setShowUpload] = useState(false)
const [uploadFiles, setUploadFiles] = useState([])
+ // Docs listed here are filtered by journal_entry_id, so every row is bound
+ // to a verifikation — BFL 7 kap 2§ blocks deletion. "Ta bort" therefore
+ // surfaces the educational modal; "Ersätt" goes through createNewVersion()
+ // so the original stays in the version chain.
+ const [blockedDoc, setBlockedDoc] = useState(null)
+ const [replacingDocId, setReplacingDocId] = useState(null)
+ const replaceFileInputRef = useRef(null)
+ const replaceTargetIdRef = useRef(null)
+
const onCountChangeRef = useRef(onCountChange)
onCountChangeRef.current = onCountChange
@@ -102,7 +133,6 @@ export default function JournalEntryAttachments({
return
}
- // Fetch signed URL for preview if not already loaded
if (!doc.download_url) {
try {
const res = await fetch(`/api/documents/${doc.id}`)
@@ -113,7 +143,6 @@ export default function JournalEntryAttachments({
)
}
} catch {
- // Non-critical — silently ignore
return
}
}
@@ -121,6 +150,49 @@ export default function JournalEntryAttachments({
setExpandedDoc(doc.id)
}
+ const handleRequestRemove = (doc: DocumentRecord) => {
+ setBlockedDoc(doc)
+ }
+
+ const handleOpenReplacePicker = (docId: string) => {
+ replaceTargetIdRef.current = docId
+ replaceFileInputRef.current?.click()
+ }
+
+ const handleReplaceFileSelected = async (file: File | null) => {
+ const docId = replaceTargetIdRef.current
+ replaceTargetIdRef.current = null
+ if (replaceFileInputRef.current) {
+ replaceFileInputRef.current.value = ''
+ }
+ if (!file || !docId) return
+
+ setReplacingDocId(docId)
+ try {
+ const fd = new FormData()
+ fd.append('file', file)
+ const res = await fetch(`/api/documents/${docId}/versions`, {
+ method: 'POST',
+ body: fd,
+ })
+ if (!res.ok) {
+ const { error } = await res.json().catch(() => ({ error: undefined }))
+ toast({
+ title: t('replace_failed'),
+ description: error || undefined,
+ variant: 'destructive',
+ })
+ } else {
+ await fetchDocuments()
+ setBlockedDoc(null)
+ }
+ } catch {
+ toast({ title: t('replace_failed'), variant: 'destructive' })
+ } finally {
+ setReplacingDocId(null)
+ }
+ }
+
if (loading) {
return (
@@ -131,6 +203,14 @@ export default function JournalEntryAttachments({
return (
+
handleReplaceFileSelected(e.target.files?.[0] ?? null)}
+ />
+
{t('title')} {documents.length > 0 && `(${documents.length})`}
@@ -146,7 +226,6 @@ export default function JournalEntryAttachments({
- {/* Upload zone */}
{showUpload && (
)}
- {/* Document list */}
{documents.length === 0 && !showUpload ? (
{t('empty')}
) : (
- {documents.map((doc) => (
-
-
- {isPreviewable(doc.mime_type) ? (
-
handlePreviewToggle(doc)}
- className="shrink-0 hover:text-primary transition-colors"
- >
- {expandedDoc === doc.id ? (
-
- ) : (
-
- )}
-
- ) : (
-
- )}
-
- {isPreviewable(doc.mime_type) && expandedDoc !== doc.id && (
- isImageType(doc.mime_type) ? (
-
+ {documents.map((doc) => {
+ const isReplacing = replacingDocId === doc.id
+ return (
+
+
+ {isPreviewable(doc.mime_type) ? (
+ handlePreviewToggle(doc)}
+ className="shrink-0 hover:text-primary transition-colors"
+ >
+ {expandedDoc === doc.id ? (
+
+ ) : (
+
+ )}
+
) : (
- )
+ )}
+
+ {isPreviewable(doc.mime_type) && expandedDoc !== doc.id && (
+ isImageType(doc.mime_type) ? (
+
+ ) : (
+
+ )
+ )}
+
+ {doc.file_name}
+
+ {formatFileSize(doc.file_size_bytes)}
+
+
+ handleOpenReplacePicker(doc.id)}
+ disabled={isReplacing}
+ title={t('replace')}
+ aria-label={t('replace')}
+ >
+ {isReplacing ? (
+
+ ) : (
+
+ )}
+
+
+ handleRequestRemove(doc)}
+ title={t('remove')}
+ aria-label={t('remove')}
+ >
+
+
+
+ handleDownload(doc.id)}
+ title={t('download')}
+ aria-label={t('download')}
+ >
+
+
+
+
+ {expandedDoc === doc.id && doc.download_url && isImageType(doc.mime_type) && (
+
+
+
)}
-
{doc.file_name}
-
- {formatFileSize(doc.file_size_bytes)}
-
-
-
handleDownload(doc.id)}
- title={t('download')}
- >
-
-
+ {expandedDoc === doc.id && doc.download_url && isPdfType(doc.mime_type) && (
+
+
+
+ )}
-
- {/* Image preview */}
- {expandedDoc === doc.id && doc.download_url && isImageType(doc.mime_type) && (
-
-
-
- )}
-
- {/* PDF preview */}
- {expandedDoc === doc.id && doc.download_url && isPdfType(doc.mime_type) && (
-
-
-
- )}
-
- ))}
+ )
+ })}
)}
+
+
{
+ if (!o) setBlockedDoc(null)
+ }}
+ >
+
+
+
+
+
+
+
{t('remove_blocked_title')}
+
+
+ {t('remove_blocked_body')}
+
+
+
+
+
+
+
{t('remove_blocked_hint')}
+
+
+
+
+ setBlockedDoc(null)}>
+ {t('remove_blocked_cancel_cta')}
+
+ {
+ if (blockedDoc) handleOpenReplacePicker(blockedDoc.id)
+ }}
+ disabled={blockedDoc !== null && replacingDocId === blockedDoc.id}
+ >
+ {blockedDoc !== null && replacingDocId === blockedDoc.id ? (
+ <>
+
+ {t('replace_uploading')}
+ >
+ ) : (
+ t('remove_blocked_replace_cta')
+ )}
+
+
+
+
)
}
diff --git a/components/bookkeeping/JournalEntryForm.tsx b/components/bookkeeping/JournalEntryForm.tsx
index 8ac0ccc7..0d075076 100644
--- a/components/bookkeeping/JournalEntryForm.tsx
+++ b/components/bookkeeping/JournalEntryForm.tsx
@@ -25,6 +25,7 @@ import {
} from '@/lib/hooks/use-submit-with-account-activation'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils'
+import { formatVoucher, resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { useCompany } from '@/contexts/CompanyContext'
import type { UploadedFile } from '@/components/bookkeeping/DocumentUploadZone'
@@ -144,13 +145,22 @@ export default function JournalEntryForm({
useEffect(() => {
fetchPeriods()
fetchAccounts()
- // Fetch default voucher series from company settings
+ // Fetch default voucher series from company settings — prefer the
+ // per-source-type mapping when present; fall back to the legacy
+ // default_voucher_series, then to 'A'.
if (!embedded) {
fetch('/api/settings').then(r => r.json()).then(({ data }) => {
- if (data?.default_voucher_series) setVoucherSeries(data.default_voucher_series)
+ if (!data) return
+ const effectiveSourceType = sourceType ?? 'manual'
+ const perSource = resolveDefaultSeriesForSource(
+ data as { default_voucher_series_per_source_type?: Record | null } | null,
+ effectiveSourceType,
+ )
+ const fallback = data.default_voucher_series || 'A'
+ setVoucherSeries(perSource !== 'A' ? perSource : fallback)
}).catch(() => {/* keep 'A' */})
}
- }, [])
+ }, [embedded, sourceType])
// Auto-select period when entry date changes
useEffect(() => {
@@ -464,7 +474,7 @@ export default function JournalEntryForm({
toast({
title: t('toast_created_title'),
- description: t('toast_created_description', { voucher: `${result.data?.voucher_series ?? ''}${result.data?.voucher_number ?? ''}` }),
+ description: t('toast_created_description', { voucher: formatVoucher(result.data ?? {}) }),
})
setShowReview(false)
setDescription('')
@@ -996,7 +1006,7 @@ export default function JournalEntryForm({
isSubmitting={isSubmitting}
title={
!embedded && nextVoucherNumber != null
- ? t('review_title_with_voucher', { voucher: `${voucherSeries}${nextVoucherNumber}` })
+ ? t('review_title_with_voucher', { voucher: formatVoucher({ voucher_series: voucherSeries, voucher_number: nextVoucherNumber }) })
: t('review_title')
}
warningText={embedded ? '' : t('review_warning')}
diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx
index cff68fd8..5697d5b6 100644
--- a/components/bookkeeping/JournalEntryList.tsx
+++ b/components/bookkeeping/JournalEntryList.tsx
@@ -12,6 +12,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Switch } from '@/components/ui/switch'
import { ChevronDown, ChevronRight, Paperclip, AlertTriangle, Loader2, BookOpen, X, Copy, Lock } from 'lucide-react'
import { formatDate } from '@/lib/utils'
+import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { Input } from '@/components/ui/input'
import { AccountNumber } from '@/components/ui/account-number'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
@@ -57,6 +58,7 @@ export default function JournalEntryList({ periodId }: Props) {
const [dateTo, setDateTo] = useState('')
const [dateFromInput, setDateFromInput] = useState('')
const [dateToInput, setDateToInput] = useState('')
+ const [seriesFilter, setSeriesFilter] = useState('all')
const pageSize = 20
const normalizeDate = (v: string): string | null => {
@@ -123,6 +125,7 @@ export default function JournalEntryList({ periodId }: Props) {
if (periodId) params.set('period_id', periodId)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
+ if (seriesFilter !== 'all') params.set('series', seriesFilter)
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
if (!res.ok) {
@@ -142,7 +145,7 @@ export default function JournalEntryList({ periodId }: Props) {
useEffect(() => {
fetchEntries()
- }, [periodId, page, sortBy, dateFrom, dateTo])
+ }, [periodId, page, sortBy, dateFrom, dateTo, seriesFilter])
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
@@ -161,7 +164,7 @@ export default function JournalEntryList({ periodId }: Props) {
const posted = result.data
toast({
title: t('toast_posted_title'),
- description: t('toast_posted_description', { voucher: `${posted?.voucher_series ?? ''}${posted?.voucher_number ?? ''}` }),
+ description: t('toast_posted_description', { voucher: formatVoucher(posted ?? {}) }),
})
await fetchEntries()
} else {
@@ -242,6 +245,22 @@ export default function JournalEntryList({ periodId }: Props) {
{t('sort_voucher_desc')}
+ { setSeriesFilter(v); setPage(0) }}>
+
+
+
+
+ Alla serier
+ {'ABCDEFG'.split('').map((letter) => (
+
+ Serie {letter}
+
+ ))}
+
+
e.stopPropagation()}
>
- {entry.voucher_series}{entry.voucher_number}
+ {formatVoucher(entry)}
{formatDate(entry.entry_date)}
@@ -396,7 +415,7 @@ export default function JournalEntryList({ periodId }: Props) {
className="font-mono text-sm text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
- {entry.voucher_series}{entry.voucher_number}
+ {formatVoucher(entry)}
{formatDate(entry.entry_date)}
diff --git a/components/bookkeeping/JournalEntryStatusBadge.tsx b/components/bookkeeping/JournalEntryStatusBadge.tsx
index e0cdf551..8720f558 100644
--- a/components/bookkeeping/JournalEntryStatusBadge.tsx
+++ b/components/bookkeeping/JournalEntryStatusBadge.tsx
@@ -35,6 +35,7 @@ const SOURCE_TYPES = [
'supplier_invoice_paid',
'supplier_invoice_cash_payment',
'currency_revaluation',
+ 'reminder_fee',
] as const
/**
diff --git a/components/bookkeeping/assets/CreateAssetDialog.tsx b/components/bookkeeping/assets/CreateAssetDialog.tsx
index c7be0155..79e0e169 100644
--- a/components/bookkeeping/assets/CreateAssetDialog.tsx
+++ b/components/bookkeeping/assets/CreateAssetDialog.tsx
@@ -1,6 +1,6 @@
'use client'
-import { useState } from 'react'
+import { useMemo, useState } from 'react'
import {
Dialog,
DialogContent,
@@ -18,9 +18,11 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
-import { Loader2 } from 'lucide-react'
+import { Loader2, Plus, X } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
-import type { AssetCategory } from '@/types'
+import { useCompanyOptional } from '@/contexts/CompanyContext'
+import { formatCurrency } from '@/lib/utils'
+import type { AssetCategory, DepreciationMethod, K3Component } from '@/types'
interface CreateAssetDialogProps {
open: boolean
@@ -28,6 +30,28 @@ interface CreateAssetDialogProps {
onCreated: () => void
}
+/** Editor row state — strings so the user can clear inputs without zeroing
+ * out the component immediately. Converted to numbers at submit time. */
+interface ComponentRow {
+ id: string
+ name: string
+ cost: string
+ useful_life_months: string
+ salvage_value: string
+}
+
+let componentRowCounter = 0
+function newComponentRow(): ComponentRow {
+ componentRowCounter += 1
+ return {
+ id: `cmp-${componentRowCounter}`,
+ name: '',
+ cost: '',
+ useful_life_months: '',
+ salvage_value: '',
+ }
+}
+
// 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.
@@ -42,8 +66,36 @@ const CATEGORY_OPTIONS: { value: AssetCategory; label: string; defaultYears: num
{ value: 'other_tangible', label: 'Övrig materiell tillgång', defaultYears: 5 },
]
+const DEPRECIATION_METHOD_OPTIONS: { value: DepreciationMethod; label: string; hint: string }[] = [
+ {
+ value: 'linear',
+ label: 'Linjär',
+ hint: 'Planenlig raklinje över nyttjandeperioden (ÅRL 4 kap 4§).',
+ },
+ {
+ value: 'declining_balance_30',
+ label: 'Räkenskapsenlig 30 %',
+ hint: 'Huvudregeln (IL 18 kap 13§) — 30 % degressivt på avskrivningsunderlaget.',
+ },
+ {
+ value: 'declining_balance_20',
+ label: 'Räkenskapsenlig 20 %',
+ hint: 'Kompletteringsregeln (IL 18 kap 17§) — 20 % degressivt. Vanlig för byggnader.',
+ },
+ {
+ value: 'restvardesavskrivning_25',
+ label: 'Restvärdeavskrivning 25 %',
+ hint: 'IL 18 kap 13§ st.3 — 25 % degressivt ner till angivet restvärde.',
+ },
+]
+
export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAssetDialogProps) {
const { toast } = useToast()
+ // useCompanyOptional so the dialog still works in tests / storyboards
+ // that don't wrap it in CompanyProvider. K3 features simply hide.
+ const companyCtx = useCompanyOptional()
+ const isK3 = companyCtx?.company?.accounting_framework === 'k3'
+
const [name, setName] = useState('')
const [category, setCategory] = useState('equipment')
const [acquisitionDate, setAcquisitionDate] = useState(
@@ -51,6 +103,12 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
)
const [acquisitionCost, setAcquisitionCost] = useState('')
const [usefulLifeYears, setUsefulLifeYears] = useState('5')
+ const [depreciationMethod, setDepreciationMethod] = useState('linear')
+ const [restvardeTarget, setRestvardeTarget] = useState('')
+ // K3 component depreciation. `useComponents` toggles the advanced section;
+ // null when disabled, an array (possibly empty during editing) when enabled.
+ const [useComponents, setUseComponents] = useState(false)
+ const [componentRows, setComponentRows] = useState([])
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
@@ -60,6 +118,40 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
if (option) setUsefulLifeYears(option.defaultYears.toString())
}
+ const isRestvarde = depreciationMethod === 'restvardesavskrivning_25'
+ const methodHint =
+ DEPRECIATION_METHOD_OPTIONS.find((o) => o.value === depreciationMethod)?.hint ?? ''
+
+ const totalComponentCost = useMemo(() => {
+ return componentRows.reduce((sum, row) => {
+ const v = parseFloat(row.cost)
+ return Number.isFinite(v) ? sum + v : sum
+ }, 0)
+ }, [componentRows])
+
+ const parsedAcquisitionCost = parseFloat(acquisitionCost)
+ const componentMismatch =
+ useComponents
+ && componentRows.length > 0
+ && Number.isFinite(parsedAcquisitionCost)
+ && Math.abs(totalComponentCost - parsedAcquisitionCost) > 1
+
+ const addComponentRow = () => {
+ setComponentRows((rows) => [...rows, newComponentRow()])
+ }
+ const removeComponentRow = (id: string) => {
+ setComponentRows((rows) => rows.filter((r) => r.id !== id))
+ }
+ const updateComponentRow = (id: string, patch: Partial) => {
+ setComponentRows((rows) => rows.map((r) => (r.id === id ? { ...r, ...patch } : r)))
+ }
+ const toggleUseComponents = (next: boolean) => {
+ setUseComponents(next)
+ if (next && componentRows.length === 0) {
+ setComponentRows([newComponentRow()])
+ }
+ }
+
const handleSubmit = async () => {
setError(null)
const cost = parseFloat(acquisitionCost)
@@ -68,6 +160,73 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
setError('Fyll i namn, anskaffningsvärde och avskrivningstid.')
return
}
+ let restvardeTargetNumber: number | null = null
+ if (isRestvarde) {
+ const parsed = parseFloat(restvardeTarget)
+ if (!Number.isFinite(parsed) || parsed < 0) {
+ setError('Ange ett restvärde (0 kr eller högre).')
+ return
+ }
+ if (parsed >= cost) {
+ setError('Restvärdet måste vara lägre än anskaffningsvärdet.')
+ return
+ }
+ restvardeTargetNumber = parsed
+ }
+ // K3 components — only when both the framework permits (gate at API)
+ // and the user opted into the section. Empty array is invalid (the
+ // validator rejects it) so the dialog also flips back to "off" when
+ // every row is removed.
+ let componentsPayload: K3Component[] | null = null
+ if (useComponents && isK3) {
+ if (componentRows.length === 0) {
+ setError('Lägg till minst en komponent eller stäng av komponentuppdelningen.')
+ return
+ }
+ const parsed: K3Component[] = []
+ for (const [index, row] of componentRows.entries()) {
+ const componentCost = parseFloat(row.cost)
+ const months = parseInt(row.useful_life_months, 10)
+ const salvageRaw = row.salvage_value.trim()
+ const salvage = salvageRaw === '' ? undefined : parseFloat(salvageRaw)
+ const trimmedName = row.name.trim()
+ if (!trimmedName) {
+ setError(`Komponent ${index + 1}: ange ett namn.`)
+ return
+ }
+ if (!Number.isFinite(componentCost) || componentCost <= 0) {
+ setError(`${trimmedName}: anskaffningsvärdet måste vara större än 0.`)
+ return
+ }
+ if (!Number.isFinite(months) || months <= 0) {
+ setError(`${trimmedName}: ange ett positivt heltal månader.`)
+ return
+ }
+ if (salvage !== undefined && (!Number.isFinite(salvage) || salvage < 0)) {
+ setError(`${trimmedName}: restvärdet får inte vara negativt.`)
+ return
+ }
+ if (salvage !== undefined && salvage > componentCost) {
+ setError(`${trimmedName}: restvärdet får inte överstiga anskaffningsvärdet.`)
+ return
+ }
+ parsed.push({
+ name: trimmedName,
+ cost: componentCost,
+ useful_life_months: months,
+ ...(salvage !== undefined ? { salvage_value: salvage } : {}),
+ })
+ }
+ const sum = parsed.reduce((s, c) => s + c.cost, 0)
+ if (Math.abs(sum - cost) > 1) {
+ setError(
+ `Komponenter summerar till ${formatCurrency(sum)} men anskaffningsvärdet är ${formatCurrency(cost)}.`,
+ )
+ return
+ }
+ componentsPayload = parsed
+ }
+
setSubmitting(true)
try {
const res = await fetch('/api/assets', {
@@ -79,7 +238,11 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
acquisition_date: acquisitionDate,
acquisition_cost: cost,
useful_life_months: years * 12,
- depreciation_method: 'linear',
+ depreciation_method: depreciationMethod,
+ ...(restvardeTargetNumber !== null
+ ? { restvarde_target: restvardeTargetNumber }
+ : {}),
+ ...(componentsPayload !== null ? { k3_components: componentsPayload } : {}),
}),
})
const body = await res.json()
@@ -91,6 +254,10 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
// Reset form for next entry
setName('')
setAcquisitionCost('')
+ setDepreciationMethod('linear')
+ setRestvardeTarget('')
+ setUseComponents(false)
+ setComponentRows([])
onCreated()
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel')
@@ -101,7 +268,7 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
return (
-
+
Ny anläggningstillgång
@@ -172,6 +339,196 @@ export function CreateAssetDialog({ open, onOpenChange, onCreated }: CreateAsset
För skattemässig avskrivning kan annan livslängd gälla (IL 18–20 kap).
+
+
Avskrivningsmetod
+
setDepreciationMethod(v as DepreciationMethod)}
+ >
+
+
+
+
+ {DEPRECIATION_METHOD_OPTIONS.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+
{methodHint}
+
+ {isRestvarde && (
+
+
Restvärde (kr)
+
setRestvardeTarget(e.target.value)}
+ placeholder="t.ex. 5000"
+ className="tabular-nums"
+ />
+
+ Avskrivningen stannar när bokfört värde når restvärdet. Restvärdet
+ måste vara lägre än anskaffningsvärdet.
+
+
+ )}
+ {isK3 && (
+
+
+
+
+ Avancerat — komponentuppdelning
+
+
+ K3 (BFNAR 2012:1 17.4) — när väsentliga komponenter har olika nyttjandeperiod
+ skrivs varje komponent av för sig. Typisk för fastigheter (tak, fasad, stomme,
+ installationer).
+
+
+
toggleUseComponents(!useComponents)}
+ >
+ {useComponents ? 'Aktiverad' : 'Aktivera'}
+
+
+
+ {useComponents && (
+
+ {componentRows.map((row, idx) => (
+
+ ))}
+
+
+
+ Lägg till komponent
+
+
+ Summa komponenter:{' '}
+
+ {formatCurrency(totalComponentCost)}
+
+
+
+
+ {componentMismatch && (
+
+ Komponenter summerar inte till anskaffningsvärdet (
+ {formatCurrency(parsedAcquisitionCost)}).
+
+ )}
+
+ )}
+
+ )}
Tips: Anskaffningen måste redan vara
bokförd (debet på 1xxx-kontot mot t.ex. 1930/2440) — registret bokför inte
diff --git a/components/bookkeeping/year-end/DispositionsStep.tsx b/components/bookkeeping/year-end/DispositionsStep.tsx
index 2aaca907..e002b04c 100644
--- a/components/bookkeeping/year-end/DispositionsStep.tsx
+++ b/components/bookkeeping/year-end/DispositionsStep.tsx
@@ -391,6 +391,10 @@ function buildPostItems(proposal: DispositionsProposal, ui: UiState): PostItem[]
additionalAmount: sel.overrideAmount ?? p.amount,
})
break
+ case 'uppskjuten_skatt':
+ // K3 only — server recomputes the amount; client just signals intent.
+ items.push({ kind: 'uppskjuten_skatt' })
+ break
}
}
if (Object.keys(ateforingReturns).length > 0) {
diff --git a/components/bookkeeping/year-end/EfDeclarationSection.tsx b/components/bookkeeping/year-end/EfDeclarationSection.tsx
index 381426e0..90f77db0 100644
--- a/components/bookkeeping/year-end/EfDeclarationSection.tsx
+++ b/components/bookkeeping/year-end/EfDeclarationSection.tsx
@@ -1,101 +1,211 @@
'use client'
-import { useMemo, useState } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
-import { FileDown, Info } from 'lucide-react'
+import { Skeleton } from '@/components/ui/skeleton'
+import { AlertTriangle, FileDown, Info } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
-import { calculateEgenavgifter, type EgenavgiftCategory } from '@/lib/bokslut/enskild-firma/egenavgifter-calculator'
-import { calculateRantefordelning } from '@/lib/bokslut/enskild-firma/rantefordelning-calculator'
-import { proposeEfPfondAvsattning } from '@/lib/bokslut/enskild-firma/periodiseringsfond-ef'
-import { calculateExpansionsfondChange } from '@/lib/bokslut/enskild-firma/expansionsfond-calculator'
+import type { EgenavgiftCategory } from '@/lib/bokslut/enskild-firma/egenavgifter-calculator'
import type { EfDeclarationItem } from '@/lib/bokslut/enskild-firma/types'
interface EfDeclarationSectionProps {
fiscalPeriodId: string
- /** Bokfört resultat (income statement net_result) — used as the default
- * surplus base for the calculators. */
+ /** Bokfört resultat (income statement net_result) — shown as the surplus
+ * base in the wizard header. Server recomputes from the trial balance. */
bookedSurplus: number
/** Closing year of the fiscal period (for periodiseringsfond cohort). */
fiscalYear: number
}
+interface EfOverrideInputs {
+ category: EgenavgiftCategory
+ kapitalunderlag: string
+ priorSchablon: string
+ priorActual: string
+ pfondDesired: string
+ expansionsfondBalance: string
+ expansionsfondChange: string
+}
+
+interface EfPreviewResponse {
+ fiscalPeriod: {
+ id: string
+ name: string
+ period_start: string
+ period_end: string
+ }
+ bookedSurplus: number
+ items: EfDeclarationItem[]
+ postedEntryCount: number
+ inputWarnings: string[]
+}
+
+const DEFAULT_OVERRIDES: EfOverrideInputs = {
+ category: 'full',
+ kapitalunderlag: '',
+ priorSchablon: '',
+ priorActual: '',
+ pfondDesired: '',
+ expansionsfondBalance: '',
+ expansionsfondChange: '',
+}
+
/**
- * Read-only EF declaration computation. All values are skattemässiga
- * justeringar that are filed in NE-bilaga / INK1 — never booked. The card
- * runs calculators in the browser as the user adjusts inputs and shows the
- * NE-bilaga ruta where each number lands.
+ * EF declaration step — fetches the four calculator outputs (egenavgifter,
+ * räntefördelning, periodiseringsfond-EF, expansionsfond) from the server
+ * so the same source-of-truth (computeEfDeclarationPreview) is used by
+ * wizard, MCP tool and NE-bilaga.
+ *
+ * EF tax mechanisms are declaration-only — they NEVER produce journal
+ * entries. The banner makes that BFL distinction visible.
+ *
+ * Override inputs persist to localStorage scoped by fiscal period id, so
+ * re-entering the wizard recalls them without round-tripping a write.
*/
export function EfDeclarationSection({
fiscalPeriodId,
bookedSurplus,
fiscalYear,
}: EfDeclarationSectionProps) {
- const [category, setCategory] = useState
('full')
- const [priorSchablon, setPriorSchablon] = useState('')
- const [priorActual, setPriorActual] = useState('')
- const [kapitalunderlag, setKapitalunderlag] = useState('')
- const [pfondDesired, setPfondDesired] = useState('')
- const [expansionsfondBalance, setExpansionsfondBalance] = useState('')
- const [expansionsfondChange, setExpansionsfondChange] = useState('')
+ const storageKey = `ef-declaration-overrides:${fiscalPeriodId}`
- const items: EfDeclarationItem[] = useMemo(() => {
- const list: EfDeclarationItem[] = []
- const eg = calculateEgenavgifter({
- surplusBeforeEgenavgifter: bookedSurplus,
- category,
- priorYearSchablonavdrag: parseFloat(priorSchablon) || 0,
- priorYearActualCharged: parseFloat(priorActual) || 0,
- })
- list.push(eg)
+ const [overrides, setOverrides] = useState(DEFAULT_OVERRIDES)
+ const [preview, setPreview] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
- const kap = parseFloat(kapitalunderlag) || 0
- const r = calculateRantefordelning({ kapitalunderlag: kap })
- if (r) list.push(r)
-
- const surplusAfterEg = bookedSurplus - eg.amount
- const pfond = proposeEfPfondAvsattning({
- surplus: surplusAfterEg,
- fiscalYear,
- desiredAmount: pfondDesired === '' ? undefined : parseFloat(pfondDesired),
- })
- if (pfond) list.push(pfond)
-
- const expChange = parseFloat(expansionsfondChange) || 0
- if (expChange !== 0) {
- const exp = calculateExpansionsfondChange({
- kapitalunderlag: kap,
- existingBalance: parseFloat(expansionsfondBalance) || 0,
- desiredChange: expChange,
- })
- if (exp) list.push(exp)
+ // Restore overrides from localStorage on mount.
+ useEffect(() => {
+ if (typeof window === 'undefined') return
+ try {
+ const raw = window.localStorage.getItem(storageKey)
+ if (raw) {
+ const parsed = JSON.parse(raw) as Partial
+ setOverrides({ ...DEFAULT_OVERRIDES, ...parsed })
+ }
+ } catch {
+ // Ignore — start with defaults.
}
- return list
- }, [
- bookedSurplus,
- category,
- priorSchablon,
- priorActual,
- kapitalunderlag,
- pfondDesired,
- expansionsfondBalance,
- expansionsfondChange,
- fiscalYear,
- ])
+ }, [storageKey])
+
+ // Persist overrides on change.
+ useEffect(() => {
+ if (typeof window === 'undefined') return
+ try {
+ window.localStorage.setItem(storageKey, JSON.stringify(overrides))
+ } catch {
+ // Quota exceeded or disabled — non-fatal.
+ }
+ }, [storageKey, overrides])
+
+ const queryString = useMemo(() => {
+ const params = new URLSearchParams()
+ params.set('category', overrides.category)
+ const kap = parseFloat(overrides.kapitalunderlag)
+ if (Number.isFinite(kap)) params.set('kapitalunderlag', String(kap))
+ const ps = parseFloat(overrides.priorSchablon)
+ if (Number.isFinite(ps)) params.set('priorYearSchablonavdrag', String(ps))
+ const pa = parseFloat(overrides.priorActual)
+ if (Number.isFinite(pa)) params.set('priorYearActualCharged', String(pa))
+ const pf = parseFloat(overrides.pfondDesired)
+ if (Number.isFinite(pf)) params.set('pfondDesiredAmount', String(pf))
+ const eb = parseFloat(overrides.expansionsfondBalance)
+ if (Number.isFinite(eb)) params.set('expansionsfondExistingBalance', String(eb))
+ const ec = parseFloat(overrides.expansionsfondChange)
+ if (Number.isFinite(ec) && ec !== 0) params.set('expansionsfondDesiredChange', String(ec))
+ return params.toString()
+ }, [overrides])
+
+ const loadPreview = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const res = await fetch(
+ `/api/bookkeeping/fiscal-periods/${fiscalPeriodId}/ef-declaration?${queryString}`,
+ )
+ const body = await res.json()
+ if (!res.ok) {
+ setError(body?.error?.message ?? 'Kunde inte ladda EF-deklaration')
+ setPreview(null)
+ return
+ }
+ setPreview(body.data as EfPreviewResponse)
+ } catch {
+ setError('Kunde inte ladda EF-deklaration')
+ setPreview(null)
+ } finally {
+ setLoading(false)
+ }
+ }, [fiscalPeriodId, queryString])
+
+ // Debounce refetch so each keystroke doesn't slam the API. 350 ms feels
+ // responsive without being noisy.
+ useEffect(() => {
+ const handle = setTimeout(() => {
+ void loadPreview()
+ }, 350)
+ return () => clearTimeout(handle)
+ }, [loadPreview])
+
+ const update = useCallback(
+ (field: K, value: EfOverrideInputs[K]) => {
+ setOverrides((prev) => ({ ...prev, [field]: value }))
+ },
+ [],
+ )
+
+ const items = preview?.items ?? []
+ const inputWarnings = preview?.inputWarnings ?? []
+ const noPostedEntries = preview ? preview.postedEntryCount === 0 : false
return (
+ {/* BFL distinction banner — EF values are declaration-only, never booked. */}
+
+
+
+
+
+ Skattemässiga justeringar — NE-bilaga
+
+
+ För enskild firma bokförs varken skatt, egenavgifter, fonder eller räntefördelning.
+ Värdena nedan visar vad du fyller i på NE-bilagan när du deklarerar. Inga verifikat skapas.
+
+
+
+
+ {/* "No journal entries posted" banner — surfaced when the period has
+ zero posted vouchers, so the user knows the surplus is 0 because
+ nothing's booked yet, not because the calculators failed. */}
+ {noPostedEntries && (
+
+
+
+
+ Inga verifikat bokförda i perioden. {' '}
+ Värdena nedan baseras enbart på NE-bilagans räkenskapsschema (intäkter och
+ kostnader hittills). Bokför löpande verifikat först för att få ett realistiskt
+ överskott.
+
+
+
+ )}
+
+ {/* Input form */}
- Skattemässiga justeringar — NE-bilaga
+ Indata
- För enskild firma bokförs inte skatt, egenavgifter, fonder eller
- räntefördelning. Beräkningarna nedan visar vad du fyller i på NE-bilagan
- när du deklarerar.
+ Bokfört överskott:{' '}
+
+ {formatCurrency(preview?.bookedSurplus ?? bookedSurplus)}
+
@@ -104,8 +214,8 @@ export function EfDeclarationSection({
Egenavgifter — kategori
setCategory(e.target.value as EgenavgiftCategory)}
+ value={overrides.category}
+ onChange={(e) => update('category', e.target.value as EgenavgiftCategory)}
>
Aktiv, full sats (28,97 %)
Pensionär (10,21 %)
@@ -117,8 +227,8 @@ export function EfDeclarationSection({
setKapitalunderlag(e.target.value)}
+ value={overrides.kapitalunderlag}
+ onChange={(e) => update('kapitalunderlag', e.target.value)}
placeholder="0"
className="tabular-nums h-9"
/>
@@ -128,8 +238,8 @@ export function EfDeclarationSection({
setPriorSchablon(e.target.value)}
+ value={overrides.priorSchablon}
+ onChange={(e) => update('priorSchablon', e.target.value)}
placeholder="0"
className="tabular-nums h-9"
/>
@@ -139,8 +249,8 @@ export function EfDeclarationSection({
setPriorActual(e.target.value)}
+ value={overrides.priorActual}
+ onChange={(e) => update('priorActual', e.target.value)}
placeholder="0"
className="tabular-nums h-9"
/>
@@ -150,8 +260,8 @@ export function EfDeclarationSection({
setPfondDesired(e.target.value)}
+ value={overrides.pfondDesired}
+ onChange={(e) => update('pfondDesired', e.target.value)}
placeholder="0"
className="tabular-nums h-9"
/>
@@ -161,8 +271,8 @@ export function EfDeclarationSection({
setExpansionsfondBalance(e.target.value)}
+ value={overrides.expansionsfondBalance}
+ onChange={(e) => update('expansionsfondBalance', e.target.value)}
placeholder="0"
className="tabular-nums h-9"
/>
@@ -174,8 +284,8 @@ export function EfDeclarationSection({
setExpansionsfondChange(e.target.value)}
+ value={overrides.expansionsfondChange}
+ onChange={(e) => update('expansionsfondChange', e.target.value)}
placeholder="0"
className="tabular-nums h-9"
/>
@@ -184,6 +294,32 @@ export function EfDeclarationSection({
+ {/* Top-level input warnings (e.g. missing kapitalunderlag) */}
+ {inputWarnings.map((w, i) => (
+
+
+
+ {w}
+
+
+ ))}
+
+ {/* Loading / error / items */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {loading && !preview && (
+
+
+
+
+
+
+ )}
+
{items.map((item) => (
@@ -210,6 +346,7 @@ export function EfDeclarationSection({
))}
+ {/* NE-bilaga download */}
@@ -217,8 +354,8 @@ export function EfDeclarationSection({
NE-bilaga räkenskapsschema (R1–R11)
- Räkenskapsschema-delen genereras automatiskt från bokföringen. Ladda ner
- SRU-filen och ladda upp den i Skatteverkets e-tjänst för Inkomstdeklaration 1.
+ Räkenskapsschema-delen genereras automatiskt från bokföringen för räkenskapsåret {fiscalYear}.
+ Ladda ner SRU-filen och ladda upp den i Skatteverkets e-tjänst för Inkomstdeklaration 1.
diff --git a/components/bookkeeping/year-end/ResultStep.tsx b/components/bookkeeping/year-end/ResultStep.tsx
index 0e214408..f86ebf32 100644
--- a/components/bookkeeping/year-end/ResultStep.tsx
+++ b/components/bookkeeping/year-end/ResultStep.tsx
@@ -1,16 +1,39 @@
'use client'
+import { useMemo, useState } from 'react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
-import { CheckCircle2 } from 'lucide-react'
+import { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { CheckCircle2, AlertTriangle } from 'lucide-react'
import Link from 'next/link'
-import type { YearEndResult } from '@/types'
+import type { YearEndResult, ContinuityDiscrepancy } from '@/types'
+import { formatCurrency } from '@/lib/utils'
+import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
interface ResultStepProps {
result: YearEndResult
}
+const ORE_TOLERANCE = 0.005
+
export function ResultStep({ result }: ResultStepProps) {
+ const [acknowledged, setAcknowledged] = useState(false)
+
+ const continuity = result.continuity
+ const discrepancies = continuity?.discrepancies ?? []
+
+ // If the wizard reached ResultStep, executeYearEndClosing already enforced
+ // that no per-account diff exceeded ORE_TOLERANCE — but surface a panel
+ // grouped by BAS class so the user can confirm visually before leaving.
return (
@@ -32,40 +55,82 @@ export function ResultStep({ result }: ResultStepProps) {
{result.revaluationEntry && (
)}
-
-
- Till bokföringen
-
-
- Generera rapporter
-
-
-
- Skapa årsredovisning
-
-
-
+ {continuity && (
+
+ )}
+
+
+
+
+ setAcknowledged(v === true)}
+ className="mt-0.5"
+ aria-label="Bekräfta bokslut"
+ />
+
+ Jag har granskat bokslutet och IB/UB-kontinuiteten ovan, och
+ bekräftar att alla balanskonton stämmer mot föregående periods
+ utgående balans.
+
+
+
+
+
+
+ Till bokföringen
+
+
+
+
+ Generera rapporter
+
+
+
+
+ Skapa årsredovisning
+
+
+
+
+
)
}
@@ -84,3 +149,114 @@ function ResultRow({ label, value, href }: { label: string; value: string; href?
)
}
+
+interface ContinuityPanelProps {
+ discrepancies: ContinuityDiscrepancy[]
+ checkedAccounts: number
+}
+
+function ContinuityPanel({ discrepancies, checkedAccounts }: ContinuityPanelProps) {
+ const grouped = useMemo(() => {
+ const byClass = new Map()
+ for (const d of discrepancies) {
+ const klass = parseInt(d.account_number[0]) || 0
+ if (klass !== 1 && klass !== 2) continue
+ const list = byClass.get(klass) ?? []
+ list.push(d)
+ byClass.set(klass, list)
+ }
+ return byClass
+ }, [discrepancies])
+
+ const hasIssues = discrepancies.some(
+ (d) => Math.abs(d.difference) > ORE_TOLERANCE
+ )
+
+ return (
+
+
+ IB/UB-avstämning
+ {hasIssues ? (
+
+
+ Avvikelser
+
+ ) : (
+
+
+ Stämmer
+
+ )}
+
+
+
+ {checkedAccounts} balanskonto(n) jämförda mellan utgående balans i
+ stängd period och ingående balans i ny period.
+
+
+ {discrepancies.length === 0 ? (
+
+ Inga avvikelser. Alla balanskonton i klass 1 och 2 matchar inom
+ tolerans (±0,005 SEK).
+
+ ) : (
+
+ {[1, 2].map((klass) => {
+ const rows = grouped.get(klass) ?? []
+ if (rows.length === 0) return null
+ return (
+
+
+ Klass {klass} – {klass === 1 ? 'Tillgångar' : 'Skulder & eget kapital'}
+
+
+
+
+ Konto
+ UB (föregående)
+ IB (ny period)
+ Diff
+ Status
+
+
+
+ {rows.map((d) => {
+ const overTol = Math.abs(d.difference) > ORE_TOLERANCE
+ return (
+
+
+ {d.account_number}
+
+ {d.account_name}
+
+
+
+ {formatCurrency(d.previous_ub_net)}
+
+
+ {formatCurrency(d.current_ib_net)}
+
+
+ {formatCurrency(d.difference)}
+
+
+ {overTol ? (
+ Avviker
+ ) : (
+ OK
+ )}
+
+
+ )
+ })}
+
+
+
+ )
+ })}
+
+ )}
+
+
+ )
+}
diff --git a/components/extensions/general/ArcimMigrationWorkspace.tsx b/components/extensions/general/ArcimMigrationWorkspace.tsx
index c8c01512..a3790832 100644
--- a/components/extensions/general/ArcimMigrationWorkspace.tsx
+++ b/components/extensions/general/ArcimMigrationWorkspace.tsx
@@ -1098,9 +1098,9 @@ function FiscalYearResult({ result, index }: { result: ImportResult; index: numb
{' · '}{d.skippedVouchers.total} hoppade över
)}
- {result.replacedPriorImport && result.replacedPriorImport.cancelledEntries > 0 && (
+ {result.replacedPriorImport && result.replacedPriorImport.deletedEntries > 0 && (
- {' · '}ersatte {result.replacedPriorImport.cancelledEntries.toLocaleString('sv-SE')} tidigare importerade verifikationer
+ {' · '}ersatte {result.replacedPriorImport.deletedEntries.toLocaleString('sv-SE')} tidigare importerade verifikationer
)}
@@ -1868,7 +1868,7 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
// Send every file to the engine. The Fortnox endpoint runs in
// replace-mode, so a year that already has a completed import
// gets its prior import marked 'replaced' (imported entries
- // cancelled, user-created entries untouched) before the new
+ // deleted, user-created entries untouched) before the new
// SIE is loaded. The per-file result reports replacedPriorImport.
const filesToImport = sieData.rawContent.map((content, i) => ({
content,
diff --git a/components/extensions/general/BookDirectlyDialog.tsx b/components/extensions/general/BookDirectlyDialog.tsx
index 4d8f6945..c5e1cc9a 100644
--- a/components/extensions/general/BookDirectlyDialog.tsx
+++ b/components/extensions/general/BookDirectlyDialog.tsx
@@ -24,6 +24,7 @@ import {
throwOnStructuredError,
} from '@/lib/hooks/use-submit-with-account-activation'
import { getErrorMessage } from '@/lib/errors/get-error-message'
+import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import type { BASAccount, FiscalPeriod, InvoiceExtractionResult } from '@/types'
interface InboxItem {
@@ -341,7 +342,7 @@ export default function BookDirectlyDialog({ open, onOpenChange, item, onSuccess
toast({
title: 'Bokfört',
description: voucher
- ? `Verifikation ${voucher.voucher_series}${voucher.voucher_number} skapad.`
+ ? `Verifikation ${formatVoucher(voucher)} skapad.`
: 'Verifikation skapad.',
})
await onSuccess()
diff --git a/components/reports/BankReconciliationView.tsx b/components/reports/BankReconciliationView.tsx
index a29833f8..feb90ae9 100644
--- a/components/reports/BankReconciliationView.tsx
+++ b/components/reports/BankReconciliationView.tsx
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge'
import { AccountNumber } from '@/components/ui/account-number'
import { AlertCircle, ChevronDown, ChevronRight, Link2, Unlink, Play, Eye } from 'lucide-react'
import { formatCurrency, formatDate } from '@/lib/utils'
+import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { CashAccountSelector } from '@/components/common/CashAccountSelector'
function formatAmount(amount: number): string {
@@ -411,7 +412,7 @@ export function BankReconciliationView() {
{formatAmount(m.transaction_amount)}
↔
- {m.voucher_series}{m.voucher_number}
+ {formatVoucher(m)}
{m.entry_description}
{formatDate(m.entry_date)}
@@ -478,7 +479,7 @@ export function BankReconciliationView() {
const lineAmount = line.debit_amount > 0 ? line.debit_amount : -line.credit_amount
return (
- {line.voucher_series}{line.voucher_number} | {formatDate(line.entry_date)} | {formatCurrency(lineAmount)} | {line.entry_description}
+ {formatVoucher(line)} | {formatDate(line.entry_date)} | {formatCurrency(lineAmount)} | {line.entry_description}
)
})}
@@ -528,7 +529,7 @@ export function BankReconciliationView() {
return (
- {line.voucher_series}{line.voucher_number}
+ {formatVoucher(line)}
{formatDate(line.entry_date)}
diff --git a/components/reports/ReportRowExpansion.tsx b/components/reports/ReportRowExpansion.tsx
new file mode 100644
index 00000000..8a6cf422
--- /dev/null
+++ b/components/reports/ReportRowExpansion.tsx
@@ -0,0 +1,307 @@
+'use client'
+
+import React, { useState, useCallback, useMemo } from 'react'
+import Link from 'next/link'
+import { ChevronDown, ChevronRight, AlertCircle } from 'lucide-react'
+import { Skeleton } from '@/components/ui/skeleton'
+import { formatDate } from '@/lib/utils'
+import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
+import {
+ createSourceLoader,
+ type ReportSourceLine,
+ type ReportSourceFetcher,
+} from '@/lib/reports/source-lines'
+
+interface ReportRowExpansionProps {
+ /** Lazy fetcher invoked the first time the row is expanded. */
+ fetcher: ReportSourceFetcher
+ /** Column span used by the inline expansion ` `. */
+ colSpan: number
+ /** Stable id used as the toggle's aria-controls reference. */
+ rowId: string
+}
+
+interface UseSourceLinesResult {
+ lines: ReportSourceLine[] | null
+ loading: boolean
+ error: string | null
+ load: () => Promise
+}
+
+/**
+ * Hook owning the lazy fetch + caching of source lines. Thin wrapper on top
+ * of `createSourceLoader` from `lib/reports/source-lines` so the loading
+ * semantics can be unit-tested in node without DOM.
+ *
+ * The cache lives per-instance — reopening the same row never refetches.
+ */
+export function useSourceLines(fetcher: ReportSourceFetcher): UseSourceLinesResult {
+ const [lines, setLines] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ // The loader is stable for the lifetime of the fetcher reference; callers
+ // are expected to memoise their fetcher with `useMemo` (every callsite in
+ // `reports/page.tsx` does).
+ const loader = useMemo(
+ () =>
+ createSourceLoader(fetcher, (s) => {
+ setLines(s.lines)
+ setLoading(s.loading)
+ setError(s.error)
+ }),
+ [fetcher]
+ )
+
+ const load = useCallback(() => loader.load(), [loader])
+
+ return { lines, loading, error, load }
+}
+
+/**
+ * Drilldown affordance for aggregated report rows.
+ *
+ * Renders two cells (the chevron-toggle is placed in the calling row; the
+ * expansion itself is rendered as a sibling `` only when expanded).
+ * The caller is responsible for placing `` inside
+ * the aggregated row and `` immediately below.
+ *
+ * Usage:
+ * const expansion = useReportRowExpansion(fetcher)
+ * ...
+ * {expansion.expanded && }
+ *
+ * Or use the all-in-one component below where the caller owns the
+ * surrounding `` and just plugs the expansion in.
+ */
+export function ReportRowExpansion({
+ fetcher,
+ colSpan,
+ rowId,
+}: ReportRowExpansionProps) {
+ const [expanded, setExpanded] = useState(false)
+ const { lines, loading, error, load } = useSourceLines(fetcher)
+
+ const toggle = useCallback(() => {
+ const next = !expanded
+ setExpanded(next)
+ if (next) load()
+ }, [expanded, load])
+
+ return (
+ <>
+
+
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+
+ {expanded && (
+
+ )}
+ >
+ )
+}
+
+/**
+ * Variant where the toggle and the expansion are placed by the caller. The
+ * caller renders ` ` inside the aggregated ` `, then below
+ * conditionally renders ` ` as a sibling ` ` so its `` lines up with the rest of the table.
+ */
+export function useReportRowExpansion(fetcher: ReportSourceFetcher, rowId: string) {
+ const [expanded, setExpanded] = useState(false)
+ const { lines, loading, error, load } = useSourceLines(fetcher)
+
+ const toggle = useCallback(() => {
+ const next = !expanded
+ setExpanded(next)
+ if (next) load()
+ }, [expanded, load])
+
+ const Toggle = () => (
+
+ {expanded ? (
+
+ ) : (
+
+ )}
+
+ )
+
+ const Panel = ({ colSpan }: { colSpan: number }) =>
+ expanded ? (
+
+ ) : null
+
+ return { expanded, Toggle, Panel }
+}
+
+/**
+ * Body of the expansion when the wrapping ` ` colSpan is provided —
+ * renders inline (used by ReportRowExpansion).
+ */
+function ExpansionPanel({
+ colSpan,
+ loading,
+ error,
+ lines,
+ rowId,
+}: {
+ colSpan: number
+ loading: boolean
+ error: string | null
+ lines: ReportSourceLine[] | null
+ rowId: string
+}) {
+ return (
+
+
+
+ )
+}
+
+/**
+ * Body of the expansion when used in a sibling ` ` (used by
+ * `useReportRowExpansion`'s Panel).
+ */
+function ExpansionPanelRow({
+ colSpan,
+ loading,
+ error,
+ lines,
+ rowId,
+}: {
+ colSpan: number
+ loading: boolean
+ error: string | null
+ lines: ReportSourceLine[] | null
+ rowId: string
+}) {
+ return (
+
+
+
+
+
+ )
+}
+
+function ExpansionContent({
+ loading,
+ error,
+ lines,
+}: {
+ loading: boolean
+ error: string | null
+ lines: ReportSourceLine[] | null
+}) {
+ if (loading) {
+ return (
+
+
+
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ if (!lines || lines.length === 0) {
+ return (
+
+ Inga underliggande verifikat.
+
+ )
+ }
+
+ return (
+
+
+
+
+ Verifikat
+ Datum
+ Beskrivning
+ Debet
+ Kredit
+
+
+
+ {lines.map((line) => (
+
+
+ {line.journal_entry_id ? (
+
+ {formatVoucher(line)}
+
+ ) : (
+ —
+ )}
+
+
+ {line.date ? formatDate(line.date) : ''}
+
+
+ {line.description}
+
+
+ {line.debit > 0 ? formatAmount(line.debit) : ''}
+
+
+ {line.credit > 0 ? formatAmount(line.credit) : ''}
+
+
+ ))}
+
+
+
+ )
+}
+
+function formatAmount(amount: number): string {
+ return amount.toLocaleString('sv-SE', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })
+}
diff --git a/components/reports/ReportsNav.tsx b/components/reports/ReportsNav.tsx
index 0e475161..c20e99ae 100644
--- a/components/reports/ReportsNav.tsx
+++ b/components/reports/ReportsNav.tsx
@@ -38,6 +38,8 @@ const CATEGORIES: ReportCategory[] = [
items: [
{ value: 'income-statement', labelKey: 'name_income_statement' },
{ value: 'balance-sheet', labelKey: 'name_balance_sheet' },
+ { value: 'kassaflodesanalys', labelKey: 'name_kassaflodesanalys' },
+ { value: 'arsredovisning', labelKey: 'name_arsredovisning', entityType: 'aktiebolag' },
],
},
{
diff --git a/components/settings/AccountingFrameworkForm.tsx b/components/settings/AccountingFrameworkForm.tsx
new file mode 100644
index 00000000..a79141e2
--- /dev/null
+++ b/components/settings/AccountingFrameworkForm.tsx
@@ -0,0 +1,179 @@
+'use client'
+
+import { useState } from 'react'
+import { Label } from '@/components/ui/label'
+import { Button } from '@/components/ui/button'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { useToast } from '@/components/ui/use-toast'
+import type { AccountingFramework } from '@/types'
+import { Loader2 } from 'lucide-react'
+
+interface AccountingFrameworkFormProps {
+ /** Current framework on the company row. */
+ current: AccountingFramework
+ /** Bubble up after a successful save so parent state can refresh. */
+ onSaved?: (next: AccountingFramework) => void
+}
+
+/**
+ * K2/K3 selector for AB. Lives on the bookkeeping settings page. Renders nothing
+ * for non-AB entities — the parent gates this component by entity_type.
+ *
+ * UX rules (regulatory area — kept in Swedish):
+ * - Default is K2 (matches the column default and BFNAR 2016:10 baseline).
+ * - Switching K2 → K3 fires a confirmation dialog. The recommendation per
+ * BFN is that the choice is permanent for the company once made; we
+ * surface that as a warning, not a block, so the user can still revert.
+ * - The save is its own request (PATCH /api/company/current) — separate
+ * from /api/settings because the column lives on companies, not on
+ * company_settings.
+ */
+export function AccountingFrameworkForm({ current, onSaved }: AccountingFrameworkFormProps) {
+ const { toast } = useToast()
+ const [selected, setSelected] = useState(current)
+ const [pending, setPending] = useState(null)
+ const [saving, setSaving] = useState(false)
+
+ async function persist(next: AccountingFramework) {
+ setSaving(true)
+ try {
+ const res = await fetch('/api/company/current', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ accounting_framework: next }),
+ })
+ const body = await res.json()
+ if (!res.ok) {
+ toast({
+ title: 'Kunde inte spara',
+ description: body?.error ?? 'Försök igen.',
+ variant: 'destructive',
+ })
+ setSelected(current)
+ return
+ }
+ toast({
+ title: 'Sparat',
+ description:
+ next === 'k3'
+ ? 'Bolaget redovisar nu enligt K3 (BFNAR 2012:1).'
+ : 'Bolaget redovisar nu enligt K2 (BFNAR 2016:10).',
+ })
+ onSaved?.(next)
+ } catch {
+ toast({
+ title: 'Kunde inte spara',
+ description: 'Försök igen.',
+ variant: 'destructive',
+ })
+ setSelected(current)
+ } finally {
+ setSaving(false)
+ setPending(null)
+ }
+ }
+
+ function handleChange(next: string) {
+ const value = next as AccountingFramework
+ if (value === selected) return
+ // K2 → K3 is the consequential direction: confirm before persisting.
+ if (selected === 'k2' && value === 'k3') {
+ setPending(value)
+ return
+ }
+ setSelected(value)
+ void persist(value)
+ }
+
+ return (
+
+
+ Redovisningsregelverk
+
+
+
Regelverk
+
+
+
+
+
+ K2 (BFNAR 2016:10) — mindre företag
+ K3 (BFNAR 2012:1) — större företag
+
+
+
+ K2 är standard för mindre bolag och innebär förenklade regler. K3 krävs när
+ bolaget når två av tre tröskelvärden (nettoomsättning > 80 MSEK, tillgångar
+ > 40 MSEK, eller fler än 50 anställda). K3 ställer högre krav: kassaflödesanalys,
+ komponentavskrivning på materiella anläggningstillgångar och redovisning av
+ uppskjuten skatt på obeskattade reserver (79,4 % eget kapital / 20,6 % skuld).
+
+
+
+ {
+ if (!open) setPending(null)
+ }}
+ >
+
+
+ Byta till K3?
+
+
+ K3 medför löpande att kassaflödesanalys upprättas, komponentavskrivning
+ används och uppskjuten skatt redovisas separat (konto 2240 / 8940).
+
+
+ Bytet är permanent enligt rekommendation. Fortsätt?
+
+
+
+
+ setPending(null)}
+ disabled={saving}
+ >
+ Avbryt
+
+ {
+ if (!pending) return
+ setSelected(pending)
+ void persist(pending)
+ }}
+ disabled={saving}
+ >
+ {saving ? (
+ <>
+ Sparar…
+ >
+ ) : (
+ 'Byt till K3'
+ )}
+
+
+
+
+
+ )
+}
diff --git a/components/settings/PdfPrintSettings.tsx b/components/settings/PdfPrintSettings.tsx
index 4c0cb7fe..63c26347 100644
--- a/components/settings/PdfPrintSettings.tsx
+++ b/components/settings/PdfPrintSettings.tsx
@@ -2,6 +2,7 @@
import { useTranslations } from 'next-intl'
import { useState, useCallback } from 'react'
+import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
@@ -18,6 +19,15 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
const { toast } = useToast()
const [lateFeeText, setLateFeeText] = useState(settings.invoice_late_fee_text || '')
const [creditTermsText, setCreditTermsText] = useState(settings.invoice_credit_terms_text || '')
+ const [reminderFeeAmount, setReminderFeeAmount] = useState(
+ String(settings.reminder_fee_amount ?? 60),
+ )
+ // Display override as a percentage (the DB stores a decimal). Empty = no override.
+ const [interestRatePercent, setInterestRatePercent] = useState(
+ settings.reminder_interest_rate_override != null
+ ? String(settings.reminder_interest_rate_override * 100)
+ : '',
+ )
const saveToggle = useCallback(async (field: string, value: boolean) => {
try {
@@ -61,6 +71,62 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
}
}, [onUpdate, toast, t])
+ const saveReminderFeeAmount = useCallback(async (raw: string) => {
+ const parsed = parseFloat(raw.replace(',', '.'))
+ if (Number.isNaN(parsed) || parsed < 0) {
+ toast({ title: 'Ogiltigt belopp', variant: 'destructive' })
+ return
+ }
+ // Lag 1981:739: maxgräns 60 kr för lagstadgad påminnelseavgift.
+ const clamped = Math.min(parsed, 60)
+ try {
+ const response = await fetch('/api/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ reminder_fee_amount: clamped }),
+ })
+ if (!response.ok) throw new Error()
+ onUpdate({ reminder_fee_amount: clamped })
+ setReminderFeeAmount(String(clamped))
+ } catch {
+ toast({ title: t('toast_save_failed'), variant: 'destructive' })
+ }
+ }, [onUpdate, toast, t])
+
+ const saveInterestOverride = useCallback(async (raw: string) => {
+ if (raw.trim() === '') {
+ try {
+ const response = await fetch('/api/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ reminder_interest_rate_override: null }),
+ })
+ if (!response.ok) throw new Error()
+ onUpdate({ reminder_interest_rate_override: null })
+ } catch {
+ toast({ title: t('toast_save_failed'), variant: 'destructive' })
+ }
+ return
+ }
+ const percent = parseFloat(raw.replace(',', '.'))
+ if (Number.isNaN(percent) || percent < 0 || percent >= 100) {
+ toast({ title: 'Ogiltig räntesats (0–99%)', variant: 'destructive' })
+ return
+ }
+ const decimal = Math.round((percent / 100) * 10_000) / 10_000
+ try {
+ const response = await fetch('/api/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ reminder_interest_rate_override: decimal }),
+ })
+ if (!response.ok) throw new Error()
+ onUpdate({ reminder_interest_rate_override: decimal })
+ } catch {
+ toast({ title: t('toast_save_failed'), variant: 'destructive' })
+ }
+ }, [onUpdate, toast, t])
+
return (
@@ -118,7 +184,7 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
{t('show_swish_help')}
saveToggle('invoice_show_swish', v)}
/>
@@ -221,6 +287,62 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
onCheckedChange={(v) => saveToggle('send_invoice_reminders', v)}
/>
+
+
+
+
Aktivera påminnelseavgift
+
+ Debitera lagstadgad påminnelseavgift (Lag 1981:739, max 60 kr) på varje påminnelse.
+ Avgiften bokförs automatiskt (1510 / 3990) och adderas till kundens fordran.
+
+
+
saveToggle('reminder_fee_enabled', v)}
+ />
+
+
+ {(settings.reminder_fee_enabled ?? true) && (
+
+
+
Påminnelseavgift (kr)
+
setReminderFeeAmount(e.target.value)}
+ onBlur={() => saveReminderFeeAmount(reminderFeeAmount)}
+ />
+
+ Standardvärde 60 kr. Maxgräns enligt Lag 1981:739.
+
+
+
+ )}
+
+
+
+ Räntesats för dröjsmålsränta (% per år)
+
+
setInterestRatePercent(e.target.value)}
+ onBlur={() => saveInterestOverride(interestRatePercent)}
+ />
+
+ Om tom används lagstadgad dröjsmålsränta (Räntelagen §6 = Riksbankens
+ referensränta + 8 procentenheter). Räntan visas i påminnelsen men bokförs inte.
+
+
)
diff --git a/components/settings/PeriodiseringAutoDetectToggle.tsx b/components/settings/PeriodiseringAutoDetectToggle.tsx
new file mode 100644
index 00000000..0f5118c2
--- /dev/null
+++ b/components/settings/PeriodiseringAutoDetectToggle.tsx
@@ -0,0 +1,108 @@
+'use client'
+
+import { useCallback, useSyncExternalStore } from 'react'
+import Link from 'next/link'
+import { ExternalLink } from 'lucide-react'
+import { Switch } from '@/components/ui/switch'
+import { Label } from '@/components/ui/label'
+
+/**
+ * Per-user toggle for the periodisering wizard's auto-detection step.
+ *
+ * Backed by localStorage (key: `periodisering_autodetect_enabled`) because
+ * the company_settings table does not yet have a dedicated column for this
+ * preference, and the task description explicitly allows the persistence to
+ * be UI-local. A future migration can promote this to a real
+ * `company_settings.periodisering_autodetect_enabled boolean` column and
+ * the wizard's auto-detect step will read either source.
+ *
+ * Default: enabled. The wizard's auto-detect step renders regardless — the
+ * toggle merely controls whether the GET response includes `autoDetected`
+ * on subsequent fetches. (Today the API always returns it; the wizard step
+ * can early-out based on this setting locally.)
+ */
+const STORAGE_KEY = 'periodisering_autodetect_enabled'
+
+function readStored(): boolean {
+ if (typeof window === 'undefined') return true
+ try {
+ const stored = window.localStorage.getItem(STORAGE_KEY)
+ return stored === null ? true : stored !== 'false'
+ } catch {
+ return true
+ }
+}
+
+/** Subscribe to localStorage changes from OTHER tabs. Same-tab updates are
+ * picked up via the explicit re-render after `setItem` — see
+ * `notifyChange` below. */
+function subscribe(callback: () => void): () => void {
+ if (typeof window === 'undefined') return () => {}
+ const handler = (e: StorageEvent) => {
+ if (e.key === STORAGE_KEY || e.key === null) callback()
+ }
+ const customHandler = () => callback()
+ window.addEventListener('storage', handler)
+ window.addEventListener('gnubok-periodisering-toggle', customHandler)
+ return () => {
+ window.removeEventListener('storage', handler)
+ window.removeEventListener('gnubok-periodisering-toggle', customHandler)
+ }
+}
+
+/** Fire a same-tab notification so useSyncExternalStore re-subscribers
+ * see the change without a manual setState. */
+function notifyChange() {
+ if (typeof window === 'undefined') return
+ window.dispatchEvent(new Event('gnubok-periodisering-toggle'))
+}
+
+export function PeriodiseringAutoDetectToggle() {
+ const enabled = useSyncExternalStore(
+ subscribe,
+ readStored,
+ // Server snapshot: default to enabled. Matches the client default so
+ // hydration is identical.
+ () => true,
+ )
+
+ const handleChange = useCallback((value: boolean) => {
+ try {
+ window.localStorage.setItem(STORAGE_KEY, String(value))
+ } catch {
+ // No-op; if storage is blocked the toggle simply won't persist.
+ }
+ notifyChange()
+ }, [])
+
+ return (
+
+
+ Periodisering
+
+
+
+
+ Aktivera automatisk periodiseringsdetektering
+
+
+ Skannar fakturor i bokslutet efter datumintervall som sträcker sig
+ in i nästa räkenskapsår och föreslår periodiseringar i bokslut-wizarden.
+
+
+
+
+
+
+ Öppna periodiserings-wizarden
+
+
+ )
+}
diff --git a/components/settings/SecuritySettings.tsx b/components/settings/SecuritySettings.tsx
index 1075620c..aa6753ff 100644
--- a/components/settings/SecuritySettings.tsx
+++ b/components/settings/SecuritySettings.tsx
@@ -88,6 +88,15 @@ export function SecuritySettings() {
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: string }
+ // Supabase rejects updateUser({password}) with this exact message when
+ // the user has a TOTP factor enrolled but is at AAL1. Send them through
+ // /mfa/verify to step up; on return they land back here and can retry.
+ if (body.error?.includes('AAL2')) {
+ router.push(
+ `/mfa/verify?returnTo=${encodeURIComponent('/settings/account')}`,
+ )
+ return
+ }
toast({
title: t('toast_update_failed_title'),
description: body.error || t('toast_update_failed_description'),
@@ -122,6 +131,14 @@ export function SecuritySettings() {
const { error } = await supabase.auth.mfa.unenroll({ factorId: mfaFactorId })
if (error) {
+ // mfa.unenroll requires AAL2 — for BankID-linked users at AAL1 (the
+ // shouldEnforceMfa skip path), this is the only way to step up.
+ if (error.message?.includes('AAL2')) {
+ router.push(
+ `/mfa/verify?returnTo=${encodeURIComponent('/settings/account')}`,
+ )
+ return
+ }
toast({
title: t('toast_unenroll_failed_title'),
description: error.message,
diff --git a/components/settings/SettingsSidebar.tsx b/components/settings/SettingsSidebar.tsx
index 309dfb51..7d3e02f5 100644
--- a/components/settings/SettingsSidebar.tsx
+++ b/components/settings/SettingsSidebar.tsx
@@ -35,6 +35,7 @@ export function SettingsNav({ isSandbox }: { isSandbox?: boolean }) {
{ href: '/settings/skatteverket', label: t('skatteverket'), show: hasCompany && !isSandbox && hasSkatteverketExtension },
{ href: '/settings/salary', label: t('salary'), show: hasCompany && company?.entity_type === 'aktiebolag' },
{ href: '/settings/templates', label: t('templates'), show: hasCompany },
+ { href: '/settings/approval-rules', label: t('approval_rules'), show: hasCompany },
{ href: '/settings/account', label: t('account'), show: true },
{ href: '/settings/api', label: t('api'), show: hasCompany && hasMcpExtension },
].filter(item => item.show)
diff --git a/components/settings/VoucherSeriesPerSourceTypeForm.tsx b/components/settings/VoucherSeriesPerSourceTypeForm.tsx
new file mode 100644
index 00000000..7c457a23
--- /dev/null
+++ b/components/settings/VoucherSeriesPerSourceTypeForm.tsx
@@ -0,0 +1,168 @@
+'use client'
+
+import { useState } from 'react'
+import { Label } from '@/components/ui/label'
+import { Button } from '@/components/ui/button'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { useToast } from '@/components/ui/use-toast'
+import { Loader2 } from 'lucide-react'
+import { getErrorMessage } from '@/lib/errors/get-error-message'
+import type { CompanySettings, JournalEntrySourceType } from '@/types'
+
+const SERIES_OPTIONS = 'ABCDEFG'.split('')
+
+// Subset of source_types presented to the user. The DB column accepts every
+// JournalEntrySourceType, but several values (storno, correction, etc.) are
+// derived from the original entry's series and would surprise the user if
+// surfaced as configurable. We expose only the user-relevant subset; the
+// engine still falls back to 'A' for the keys we hide.
+const VISIBLE_SOURCE_TYPES: Array<{ key: JournalEntrySourceType; labelKey: string }> = [
+ { key: 'manual', labelKey: 'manual' },
+ { key: 'invoice_created', labelKey: 'invoice_created' },
+ { key: 'invoice_paid', labelKey: 'invoice_paid' },
+ { key: 'supplier_invoice_registered', labelKey: 'supplier_invoice_registered' },
+ { key: 'supplier_invoice_paid', labelKey: 'supplier_invoice_paid' },
+ { key: 'salary_payment', labelKey: 'salary_payment' },
+ { key: 'bank_transaction', labelKey: 'bank_transaction' },
+ { key: 'reminder_fee', labelKey: 'reminder_fee' },
+ { key: 'opening_balance', labelKey: 'opening_balance' },
+ { key: 'year_end', labelKey: 'year_end' },
+]
+
+// Swedish labels. Kept inline so this component is self-contained — these
+// labels are bookkeeping-domain terms that intentionally stay Swedish across
+// locales (see CLAUDE.md i18n table).
+const SV_LABELS: Record = {
+ manual: 'Manuella verifikat',
+ invoice_created: 'Kundfakturor (skapande)',
+ invoice_paid: 'Kundfakturor (betalning)',
+ supplier_invoice_registered: 'Leverantörsfakturor (registrering)',
+ supplier_invoice_paid: 'Leverantörsfakturor (betalning)',
+ salary_payment: 'Lön',
+ bank_transaction: 'Banktransaktioner',
+ reminder_fee: 'Påminnelseavgifter',
+ opening_balance: 'Ingående balanser',
+ year_end: 'Bokslut',
+}
+
+interface Props {
+ settings: CompanySettings
+ onSettingsUpdated: (settings: Partial) => void
+}
+
+export function VoucherSeriesPerSourceTypeForm({ settings, onSettingsUpdated }: Props) {
+ const { toast } = useToast()
+ const initialMap = settings.default_voucher_series_per_source_type || {}
+ const [draft, setDraft] = useState>>(
+ initialMap as Partial>,
+ )
+ const [isSaving, setIsSaving] = useState(false)
+
+ const handleChange = (sourceType: JournalEntrySourceType, value: string) => {
+ setDraft((prev) => ({ ...prev, [sourceType]: value }))
+ }
+
+ const hasChanges =
+ JSON.stringify(draft) !== JSON.stringify(initialMap)
+
+ const handleSave = async () => {
+ setIsSaving(true)
+ try {
+ const res = await fetch('/api/settings', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ default_voucher_series_per_source_type: draft,
+ }),
+ })
+ const json = await res.json()
+ if (!res.ok) {
+ toast({
+ title: 'Kunde inte spara',
+ description: getErrorMessage(json, { context: 'settings', statusCode: res.status }),
+ variant: 'destructive',
+ })
+ return
+ }
+ onSettingsUpdated({
+ default_voucher_series_per_source_type: draft as Record,
+ })
+ toast({
+ title: 'Verifikationsserier sparade',
+ description: 'Nya verifikat använder de uppdaterade serierna.',
+ })
+ } catch (err) {
+ toast({
+ title: 'Kunde inte spara',
+ description: getErrorMessage(err, { context: 'settings' }),
+ variant: 'destructive',
+ })
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ return (
+
+
+
+ Verifikationsserier per typ
+
+
+ Tilldela en standardserie per typ av verifikat. Vanlig svensk
+ praxis: leverantörsfakturor på serie B, löner på serie C, övrigt på
+ serie A. Kan alltid ändras per verifikat när du bokför.
+
+
+
+
+ {VISIBLE_SOURCE_TYPES.map(({ key, labelKey }) => (
+
+
+ {SV_LABELS[labelKey] ?? key}
+
+ handleChange(key, v)}
+ >
+
+
+
+
+ {SERIES_OPTIONS.map((letter) => (
+
+ {letter}
+
+ ))}
+
+
+
+ ))}
+
+
+
+
+ {isSaving && }
+ Spara serier
+
+
+
+ )
+}
diff --git a/components/skattekonto/SkattekontoMatchDialog.tsx b/components/skattekonto/SkattekontoMatchDialog.tsx
index 8efdcbc3..79b425fa 100644
--- a/components/skattekonto/SkattekontoMatchDialog.tsx
+++ b/components/skattekonto/SkattekontoMatchDialog.tsx
@@ -22,6 +22,7 @@ import {
} from '@/components/ui/table'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
+import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import type { StoredSkattekontoTransaction } from '@/types/skatteverket'
interface MatchCandidate {
@@ -177,9 +178,7 @@ export function SkattekontoMatchDialog({
{c.entry_date}
- {c.voucher_series && c.voucher_number
- ? `${c.voucher_series}${c.voucher_number}`
- : '–'}
+ {formatVoucher(c)}
{c.description}
diff --git a/components/transactions/SkattekontoInboxCard.tsx b/components/transactions/SkattekontoInboxCard.tsx
index 0d28d600..6335adfe 100644
--- a/components/transactions/SkattekontoInboxCard.tsx
+++ b/components/transactions/SkattekontoInboxCard.tsx
@@ -11,6 +11,7 @@ import {
DataListMetaSeparator,
} from '@/components/ui/data-list'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
+import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import { AlertCircle, ArrowUpRight, ArrowDownRight, Landmark, Link2, Loader2 } from 'lucide-react'
import type {
SkattekontoMatchSuggestion,
@@ -46,7 +47,10 @@ export default function SkattekontoInboxCard({
const duplicateLabel =
matchSuggestion?.voucher_series && matchSuggestion?.voucher_number
? t('duplicate_title_with_voucher', {
- label: `${matchSuggestion.voucher_series}${matchSuggestion.voucher_number}`,
+ label: formatVoucher({
+ voucher_series: matchSuggestion.voucher_series,
+ voucher_number: matchSuggestion.voucher_number,
+ }),
})
: t('duplicate_title_draft')
diff --git a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts
index 1abeaf29..69a20d78 100644
--- a/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts
+++ b/extensions/general/mcp-server/__tests__/receipt-matcher.test.ts
@@ -121,6 +121,7 @@ vi.mock('@react-pdf/renderer', () => ({
vi.mock('@/lib/invoices/pdf-template', () => ({
InvoicePDF: vi.fn(),
+ brandingFromCompanySettings: vi.fn().mockReturnValue({}),
}))
vi.mock('@/lib/email/service', () => ({
diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts
index 70ebac95..82f90411 100644
--- a/lib/api/schemas.ts
+++ b/lib/api/schemas.ts
@@ -26,6 +26,28 @@ const timeString = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/, 'Expected HH:MM or
export const EntityTypeSchema = z.enum(['enskild_firma', 'aktiebolag'])
+export const AccountingFrameworkSchema = z.enum(['k2', 'k3'])
+
+/**
+ * Single K3 component (BFNAR 2012:1 ch.17.4 — komponentavskrivning).
+ *
+ * Used inside AssetCreateSchema / AssetUpdateSchema's `k3_components` array.
+ * The cross-component invariant (sum of `cost` equals asset `acquisition_cost`)
+ * lives in `validateComponents` from `lib/bokslut/assets/k3-components.ts`
+ * and is called by the route-layer refinement — it cannot be expressed in
+ * a single-object schema. Component-level checks (cost > 0, salvage ≤ cost,
+ * positive useful life) are reinforced by `validateComponents` too so any
+ * future caller that uses just the validator gets the same guarantees.
+ *
+ * `salvage_value` is optional; the engine treats omission as 0.
+ */
+export const K3ComponentSchema = z.object({
+ name: z.string().min(1, 'Komponentens namn krävs.'),
+ cost: z.number().positive('Anskaffningsvärdet måste vara större än 0.'),
+ useful_life_months: z.number().int().positive('Nyttjandeperioden måste vara ett positivt heltal månader.'),
+ salvage_value: z.number().nonnegative().optional(),
+})
+
export const CustomerTypeSchema = z.enum([
'individual',
'swedish_business',
@@ -103,6 +125,7 @@ export const JournalEntrySourceTypeSchema = z.enum([
'supplier_invoice_privately_paid',
'supplier_credit_note',
'currency_revaluation',
+ 'reminder_fee',
])
export const AccountTypeSchema = z.enum([
@@ -156,6 +179,14 @@ export const CreateInvoiceItemSchema = z.object({
unit: z.string().min(1, 'Unit is required'),
unit_price: z.number(),
vat_rate: z.number().min(0).max(100).optional(),
+ // ROT/RUT-avdrag fields. `deduction_amount` is intentionally omitted from
+ // the client schema — the API computes it from rot-rut-rules.ts so a
+ // tampered client can't expand the 1513 receivable beyond the line total.
+ deduction_type: z.enum(['rot', 'rut']).nullable().optional(),
+ labor_hours: z.number().nonnegative().nullable().optional(),
+ work_type: z.string().max(64).nullable().optional(),
+ housing_designation: z.string().max(128).nullable().optional(),
+ apartment_number: z.string().max(32).nullable().optional(),
})
const optionalIsoDate = isoDate.or(z.literal('')).transform(v => v || undefined).optional()
@@ -170,6 +201,13 @@ export const CreateInvoiceSchema = z.object({
your_reference: z.string().optional(),
our_reference: z.string().optional(),
notes: z.string().optional(),
+ // ROT/RUT claim info. The personnummer is plaintext on the wire and gets
+ // encrypted server-side before it ever hits the DB (see encryptPersonnummer
+ // in lib/salary/personnummer.ts). `deduction_housing_designation` is the
+ // fastighetsbeteckning at invoice level — required when any ROT item is
+ // present (enforced via rot-rut-rules.validateInvoice in the API).
+ deduction_personnummer: z.string().max(20).optional(),
+ deduction_housing_designation: z.string().max(128).optional(),
items: z.array(CreateInvoiceItemSchema).min(1, 'At least one item is required'),
})
@@ -515,6 +553,16 @@ export const UpdateSettingsSchema = z.object({
auto_lock_period_days: z.number().int().positive().nullable().optional(),
// Voucher series
default_voucher_series: z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z').optional(),
+ // Per-source-type voucher series map. Keys are journal_entries.source_type
+ // values; values are single uppercase letters A–Z. Read by the engine
+ // (`createDraftEntry`) when no explicit voucher_series is passed, with a
+ // fallback to 'A' for unknown keys.
+ default_voucher_series_per_source_type: z
+ .record(
+ JournalEntrySourceTypeSchema,
+ z.string().regex(/^[A-Z]$/, 'Verifikationsserie måste vara en bokstav A–Z'),
+ )
+ .optional(),
// Invoice PDF settings
ore_rounding: z.boolean().optional(),
invoice_show_ocr: z.boolean().optional(),
@@ -526,8 +574,36 @@ export const UpdateSettingsSchema = z.object({
invoice_company_name_position: z.enum(['header', 'footer']).optional(),
invoice_late_fee_text: z.string().nullable().optional(),
invoice_credit_terms_text: z.string().nullable().optional(),
+ // Invoice branding — colors enforced as #RRGGBB at the DB level too
+ // (see migration 20260526120200_invoice_branding.sql). The dedicated
+ // /api/settings/invoicing/branding route is the primary path; these
+ // entries let the generic PUT /api/settings also accept the same fields.
+ invoice_primary_color: z
+ .string()
+ .regex(/^#[0-9A-Fa-f]{6}$/, 'Ange en giltig hex-färg (#RRGGBB)')
+ .optional(),
+ invoice_accent_color: z
+ .string()
+ .regex(/^#[0-9A-Fa-f]{6}$/, 'Ange en giltig hex-färg (#RRGGBB)')
+ .optional(),
+ invoice_font_family: z.enum(['Helvetica', 'Times-Roman', 'Courier']).optional(),
+ invoice_header_text: z.string().max(200).nullable().optional(),
+ invoice_footer_text: z.string().max(500).nullable().optional(),
// Automation
send_invoice_reminders: z.boolean().optional(),
+ // Reminder surcharges (dröjsmålsränta + lagstadgad påminnelseavgift)
+ reminder_fee_enabled: z.boolean().optional(),
+ reminder_fee_amount: z
+ .number()
+ .min(0, 'Påminnelseavgift kan inte vara negativ')
+ .max(60, 'Lagstadgad maxgräns för påminnelseavgift är 60 kr (Lag 1981:739)')
+ .optional(),
+ reminder_interest_rate_override: z
+ .number()
+ .min(0, 'Räntesats kan inte vara negativ')
+ .max(0.9999, 'Ange räntesatsen som en decimal mindre än 1 (t.ex. 0.115 för 11,5%)')
+ .nullable()
+ .optional(),
// AI agent flow
ai_flow_enabled: z.boolean().optional(),
// Salary payment file
@@ -830,11 +906,14 @@ export const VacationRuleSchema = z.enum(['procentregeln', 'sammaloneregeln', 'n
export const SalaryRunStatusSchema = z.enum(['draft', 'review', 'approved', 'paid', 'booked', 'corrected'])
export const SalaryLineItemTypeSchema = z.enum([
- 'monthly_salary', 'hourly_salary', 'overtime', 'bonus', 'commission',
+ 'monthly_salary', 'hourly_salary',
+ 'overtime', 'overtime_50', 'overtime_100',
+ 'ob_weekday_evening', 'ob_weekend', 'ob_night', 'ob_holiday',
+ 'bonus', 'commission',
'gross_deduction_pension', 'gross_deduction_other',
'benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_bike', 'benefit_other',
'sick_karens', 'sick_day2_14', 'sick_day15_plus',
- 'vab', 'parental_leave', 'vacation',
+ 'vab', 'parental_leave', 'vacation', 'semesterersattning',
'traktamente_taxfree', 'traktamente_taxable',
'mileage_taxfree', 'mileage_taxable',
'net_deduction_advance', 'net_deduction_union', 'net_deduction_benefit_payment',
@@ -1117,12 +1196,25 @@ export const AbsenceRangeQuerySchema = z.object({
// same calendar UX, half-day mixing with absence enforced by the 24h cap
// trigger. The calculator sums these per pay period at calculate time.
-export const UpsertWorkedDaySchema = z.object({
- work_date: isoDate,
- hours: z.number().positive().max(24).default(8),
- notes: z.string().max(2000).optional(),
- salary_run_employee_id: uuid.optional(),
-})
+export const UpsertWorkedDaySchema = z
+ .object({
+ work_date: isoDate,
+ hours: z.number().positive().max(24).default(8),
+ notes: z.string().max(2000).optional(),
+ salary_run_employee_id: uuid.optional(),
+ // Optional shift window. Feeds the shift-premium engine — without explicit
+ // times, the engine assumes a default 08:00–17:00 day shift. Either both
+ // fields are provided or neither.
+ start_time: timeString.optional(),
+ end_time: timeString.optional(),
+ })
+ .refine(
+ (data) => (data.start_time == null && data.end_time == null) || (data.start_time != null && data.end_time != null),
+ {
+ message: 'Ange både start- och sluttid eller låt båda vara tomma',
+ path: ['start_time'],
+ },
+ )
export const WorkedHoursRangeQuerySchema = z.object({
from: isoDate,
@@ -1132,14 +1224,26 @@ export const WorkedHoursRangeQuerySchema = z.object({
path: ['from'],
})
-export const BatchUpsertWorkedDaysSchema = z.object({
- // 100-row sanity cap: typical use is one pay period (~22 weekdays). A larger
- // value usually indicates the caller is iterating wrong.
- dates: z.array(isoDate).min(1).max(100),
- hours: z.number().positive().max(24).default(8),
- notes: z.string().max(2000).optional(),
- salary_run_employee_id: uuid.optional(),
-})
+export const BatchUpsertWorkedDaysSchema = z
+ .object({
+ // 100-row sanity cap: typical use is one pay period (~22 weekdays). A larger
+ // value usually indicates the caller is iterating wrong.
+ dates: z.array(isoDate).min(1).max(100),
+ hours: z.number().positive().max(24).default(8),
+ notes: z.string().max(2000).optional(),
+ salary_run_employee_id: uuid.optional(),
+ // Optional shift window applied to every date in the batch. Pair both or
+ // neither; same fallback behaviour as the single-row endpoint.
+ start_time: timeString.optional(),
+ end_time: timeString.optional(),
+ })
+ .refine(
+ (data) => (data.start_time == null && data.end_time == null) || (data.start_time != null && data.end_time != null),
+ {
+ message: 'Ange både start- och sluttid eller låt båda vara tomma',
+ path: ['start_time'],
+ },
+ )
// ============================================================
// AI agent flow schemas
@@ -1228,3 +1332,69 @@ export const ListProposalsQuerySchema = z.object({
export const AttachDocumentSchema = z.object({
document_id: uuid,
})
+
+// ============================================================
+// Shift-premium rules (OB-tillägg och övertid)
+// ============================================================
+
+export const ShiftPremiumItemTypeSchema = z.enum([
+ 'overtime_50',
+ 'overtime_100',
+ 'ob_weekday_evening',
+ 'ob_weekend',
+ 'ob_night',
+ 'ob_holiday',
+])
+
+const dayOfWeekArray = z
+ .array(z.number().int().min(1).max(7))
+ .min(1, 'Välj minst en veckodag')
+ .max(7, 'Högst sju veckodagar tillåtna')
+
+export const CreateShiftPremiumRuleSchema = z
+ .object({
+ name: z.string().min(1).max(120),
+ applies_to_all_employees: z.boolean().default(true),
+ applies_to_employee_ids: z.array(uuid).default([]),
+ day_of_week: dayOfWeekArray,
+ start_time: timeString,
+ end_time: timeString,
+ premium_percent: z.number().min(0).max(500),
+ item_type: ShiftPremiumItemTypeSchema,
+ priority: z.number().int().min(0).max(1000).default(0),
+ is_active: z.boolean().default(true),
+ })
+ .refine(
+ (data) => data.applies_to_all_employees || data.applies_to_employee_ids.length > 0,
+ {
+ message: 'Välj minst en anställd när regeln inte gäller alla',
+ path: ['applies_to_employee_ids'],
+ },
+ )
+
+export const UpdateShiftPremiumRuleSchema = z
+ .object({
+ name: z.string().min(1).max(120).optional(),
+ applies_to_all_employees: z.boolean().optional(),
+ applies_to_employee_ids: z.array(uuid).optional(),
+ day_of_week: dayOfWeekArray.optional(),
+ start_time: timeString.optional(),
+ end_time: timeString.optional(),
+ premium_percent: z.number().min(0).max(500).optional(),
+ item_type: ShiftPremiumItemTypeSchema.optional(),
+ priority: z.number().int().min(0).max(1000).optional(),
+ is_active: z.boolean().optional(),
+ })
+ .refine(
+ (data) => {
+ if (data.applies_to_all_employees === false && data.applies_to_employee_ids !== undefined) {
+ return data.applies_to_employee_ids.length > 0
+ }
+ return true
+ },
+ {
+ message: 'Välj minst en anställd när regeln inte gäller alla',
+ path: ['applies_to_employee_ids'],
+ },
+ )
+
diff --git a/lib/auth/__tests__/safe-return-to.test.ts b/lib/auth/__tests__/safe-return-to.test.ts
index 3f14d2fe..976e5de4 100644
--- a/lib/auth/__tests__/safe-return-to.test.ts
+++ b/lib/auth/__tests__/safe-return-to.test.ts
@@ -39,4 +39,9 @@ describe('safeReturnTo', () => {
expect(safeReturnTo('settings', '/')).toBe('/')
expect(safeReturnTo('javascript:alert(1)', '/')).toBe('/')
})
+
+ it('rejects data: URIs', () => {
+ expect(safeReturnTo('data:text/html,', '/')).toBe('/')
+ expect(safeReturnTo('data:,', '/')).toBe('/')
+ })
})
diff --git a/lib/auth/__tests__/webmail-search.test.ts b/lib/auth/__tests__/webmail-search.test.ts
new file mode 100644
index 00000000..7811fa6d
--- /dev/null
+++ b/lib/auth/__tests__/webmail-search.test.ts
@@ -0,0 +1,78 @@
+import { describe, it, expect } from 'vitest'
+import { detectWebmailHint } from '../webmail-search'
+
+const FROM = 'noreply@gnubok.se'
+
+describe('detectWebmailHint', () => {
+ it('returns Gmail with a pre-populated search for gmail.com', () => {
+ const hint = detectWebmailHint('user@gmail.com', FROM)
+ expect(hint).not.toBeNull()
+ expect(hint!.id).toBe('gmail')
+ expect(hint!.name).toBe('Gmail')
+ expect(hint!.hasSearch).toBe(true)
+ expect(hint!.url).toBe(
+ `https://mail.google.com/mail/u/0/#search/${encodeURIComponent('from:noreply@gnubok.se')}`,
+ )
+ })
+
+ it('also handles googlemail.com as Gmail', () => {
+ const hint = detectWebmailHint('user@googlemail.com', FROM)
+ expect(hint!.id).toBe('gmail')
+ expect(hint!.hasSearch).toBe(true)
+ })
+
+ it('detects Outlook variants (outlook.com, hotmail.com, live.com, msn.com)', () => {
+ for (const domain of ['outlook.com', 'hotmail.com', 'live.com', 'msn.com', 'hotmail.se']) {
+ const hint = detectWebmailHint(`user@${domain}`, FROM)
+ expect(hint?.id, domain).toBe('outlook')
+ expect(hint?.hasSearch, domain).toBe(false)
+ expect(hint?.url, domain).toBe('https://outlook.live.com/mail/0/inbox')
+ }
+ })
+
+ it('detects Yahoo variants', () => {
+ for (const domain of ['yahoo.com', 'yahoo.co.uk', 'yahoo.se', 'ymail.com']) {
+ const hint = detectWebmailHint(`user@${domain}`, FROM)
+ expect(hint?.id, domain).toBe('yahoo')
+ expect(hint?.hasSearch, domain).toBe(false)
+ }
+ })
+
+ it('detects iCloud variants (icloud.com, me.com, mac.com)', () => {
+ for (const domain of ['icloud.com', 'me.com', 'mac.com']) {
+ const hint = detectWebmailHint(`user@${domain}`, FROM)
+ expect(hint?.id, domain).toBe('icloud')
+ expect(hint?.hasSearch, domain).toBe(false)
+ }
+ })
+
+ it('detects Proton variants', () => {
+ for (const domain of ['proton.me', 'protonmail.com', 'pm.me']) {
+ const hint = detectWebmailHint(`user@${domain}`, FROM)
+ expect(hint?.id, domain).toBe('proton')
+ expect(hint?.hasSearch, domain).toBe(false)
+ }
+ })
+
+ it('is case-insensitive on the domain', () => {
+ const hint = detectWebmailHint('User@GMAIL.COM', FROM)
+ expect(hint?.id).toBe('gmail')
+ })
+
+ it('returns null for unknown / custom domains', () => {
+ expect(detectWebmailHint('me@mycompany.com', FROM)).toBeNull()
+ expect(detectWebmailHint('me@example.org', FROM)).toBeNull()
+ })
+
+ it('returns null for malformed email input', () => {
+ expect(detectWebmailHint('not-an-email', FROM)).toBeNull()
+ expect(detectWebmailHint('', FROM)).toBeNull()
+ expect(detectWebmailHint('@gmail.com', FROM)).not.toBeNull()
+ expect(detectWebmailHint('user@', FROM)).toBeNull()
+ })
+
+ it('URL-encodes the sender address (handles special characters)', () => {
+ const hint = detectWebmailHint('user@gmail.com', 'no+reply@gnubok.se')
+ expect(hint!.url).toContain(encodeURIComponent('from:no+reply@gnubok.se'))
+ })
+})
diff --git a/lib/auth/webmail-search.ts b/lib/auth/webmail-search.ts
new file mode 100644
index 00000000..69f445f5
--- /dev/null
+++ b/lib/auth/webmail-search.ts
@@ -0,0 +1,129 @@
+/**
+ * Webmail deep links for the "check your email" screen after signup or
+ * password reset. Mirrors Stripe's pattern: detect the user's webmail
+ * provider from the email domain and link straight to their inbox, with
+ * a `from:` search pre-populated where supported (Gmail).
+ *
+ * Custom domains (Google Workspace, Microsoft 365, Fastmail, etc.) can't
+ * be detected from the address alone — callers should fall back to the
+ * plain "check your inbox" copy when this returns null.
+ */
+
+export type WebmailProviderId = 'gmail' | 'outlook' | 'yahoo' | 'icloud' | 'proton'
+
+export interface WebmailHint {
+ id: WebmailProviderId
+ /** Display name, e.g. "Gmail". Not translated — provider names are global brands. */
+ name: string
+ /** URL that opens the user's inbox, ideally pre-populated with a from: search. */
+ url: string
+ /** True when the URL pre-populates a search for the sender (Gmail only today). */
+ hasSearch: boolean
+}
+
+const DOMAIN_TO_PROVIDER: Record = {
+ // Google free tier
+ 'gmail.com': 'gmail',
+ 'googlemail.com': 'gmail',
+
+ // Microsoft free tier
+ 'outlook.com': 'outlook',
+ 'outlook.co.uk': 'outlook',
+ 'outlook.se': 'outlook',
+ 'hotmail.com': 'outlook',
+ 'hotmail.co.uk': 'outlook',
+ 'hotmail.se': 'outlook',
+ 'live.com': 'outlook',
+ 'live.se': 'outlook',
+ 'msn.com': 'outlook',
+
+ // Yahoo
+ 'yahoo.com': 'yahoo',
+ 'yahoo.co.uk': 'yahoo',
+ 'yahoo.se': 'yahoo',
+ 'ymail.com': 'yahoo',
+ 'rocketmail.com': 'yahoo',
+
+ // Apple
+ 'icloud.com': 'icloud',
+ 'me.com': 'icloud',
+ 'mac.com': 'icloud',
+
+ // Proton
+ 'proton.me': 'proton',
+ 'protonmail.com': 'proton',
+ 'protonmail.ch': 'proton',
+ 'pm.me': 'proton',
+}
+
+const PROVIDER_NAMES: Record = {
+ gmail: 'Gmail',
+ outlook: 'Outlook',
+ yahoo: 'Yahoo Mail',
+ icloud: 'iCloud Mail',
+ proton: 'Proton Mail',
+}
+
+function extractDomain(email: string): string | null {
+ const at = email.lastIndexOf('@')
+ if (at === -1) return null
+ const domain = email.slice(at + 1).trim().toLowerCase()
+ return domain || null
+}
+
+export function detectWebmailHint(email: string, fromAddress: string): WebmailHint | null {
+ const domain = extractDomain(email)
+ if (!domain) return null
+ const id = DOMAIN_TO_PROVIDER[domain]
+ if (!id) return null
+
+ const name = PROVIDER_NAMES[id]
+
+ switch (id) {
+ case 'gmail': {
+ // Hash-based search that survives client-side routing — the URL Stripe uses.
+ const query = encodeURIComponent(`from:${fromAddress}`)
+ return {
+ id,
+ name,
+ url: `https://mail.google.com/mail/u/0/#search/${query}`,
+ hasSearch: true,
+ }
+ }
+
+ case 'outlook':
+ // Outlook's URL-based search has shifted between OWA / Outlook.com / Office
+ // and no single deep link is reliable across all account types. Open the
+ // inbox so the user can locate the email themselves.
+ return {
+ id,
+ name,
+ url: 'https://outlook.live.com/mail/0/inbox',
+ hasSearch: false,
+ }
+
+ case 'yahoo':
+ return {
+ id,
+ name,
+ url: 'https://mail.yahoo.com/',
+ hasSearch: false,
+ }
+
+ case 'icloud':
+ return {
+ id,
+ name,
+ url: 'https://www.icloud.com/mail/',
+ hasSearch: false,
+ }
+
+ case 'proton':
+ return {
+ id,
+ name,
+ url: 'https://mail.proton.me/',
+ hasSearch: false,
+ }
+ }
+}
diff --git a/lib/bokslut/__tests__/accrual-detector.test.ts b/lib/bokslut/__tests__/accrual-detector.test.ts
index 16dfc84e..64a67e47 100644
--- a/lib/bokslut/__tests__/accrual-detector.test.ts
+++ b/lib/bokslut/__tests__/accrual-detector.test.ts
@@ -3,6 +3,9 @@ import {
proposeAuditFee,
proposeManualPrepaid,
proposeManualAccrued,
+ proposeRevenueDeferral,
+ proposeAccruedInterest,
+ proposeAccruedUtility,
} from '../accruals/accrual-detector'
describe('proposeAuditFee', () => {
@@ -87,3 +90,68 @@ describe('proposeManualAccrued', () => {
expect(r!.reverses_on).toBe('2026-01-01')
})
})
+
+describe('proposeRevenueDeferral', () => {
+ it('debits the revenue account and credits the 29xx deferred account', () => {
+ const r = proposeRevenueDeferral({
+ amount: 24_000,
+ revenueAccount: '3001',
+ deferredAccount: '2970',
+ description: 'Årsabonnemang 2026',
+ closingDate: '2025-12-31',
+ })
+ expect(r).not.toBeNull()
+ expect(r!.lines[0].account_number).toBe('3001')
+ expect(r!.lines[0].debit_amount).toBe(24_000)
+ expect(r!.lines[1].account_number).toBe('2970')
+ expect(r!.lines[1].credit_amount).toBe(24_000)
+ expect(r!.reverses_on).toBe('2026-01-01')
+ expect(r!.label).toContain('Förutbetald intäkt')
+ })
+
+ it('enforces 29xx range on deferredAccount', () => {
+ expect(() =>
+ proposeRevenueDeferral({
+ amount: 1000,
+ revenueAccount: '3001',
+ deferredAccount: '1710', // wrong range
+ description: 'x',
+ closingDate: '2025-12-31',
+ }),
+ ).toThrow(/29xx/)
+ })
+})
+
+describe('proposeAccruedInterest', () => {
+ it('emits an accrued-interest entry with the right label', () => {
+ const r = proposeAccruedInterest({
+ amount: 4_500,
+ expenseAccount: '8410',
+ accruedAccount: '2960',
+ description: 'Banklån Q4',
+ closingDate: '2025-12-31',
+ })
+ expect(r).not.toBeNull()
+ expect(r!.lines[0].account_number).toBe('8410')
+ expect(r!.lines[1].account_number).toBe('2960')
+ expect(r!.label).toBe('Upplupen ränta: Banklån Q4')
+ expect(r!.reverses_on).toBe('2026-01-01')
+ })
+})
+
+describe('proposeAccruedUtility', () => {
+ it('emits an accrued-utility entry with the right label', () => {
+ const r = proposeAccruedUtility({
+ amount: 2_300,
+ expenseAccount: '5020',
+ accruedAccount: '2990',
+ description: 'El december',
+ closingDate: '2025-12-31',
+ })
+ expect(r).not.toBeNull()
+ expect(r!.lines[0].account_number).toBe('5020')
+ expect(r!.lines[1].account_number).toBe('2990')
+ expect(r!.label).toBe('Upplupen förbrukning: El december')
+ expect(r!.reverses_on).toBe('2026-01-01')
+ })
+})
diff --git a/lib/bokslut/__tests__/asset-service.test.ts b/lib/bokslut/__tests__/asset-service.test.ts
index 844641f6..3130449a 100644
--- a/lib/bokslut/__tests__/asset-service.test.ts
+++ b/lib/bokslut/__tests__/asset-service.test.ts
@@ -61,8 +61,15 @@ describe('disposeAsset — gain/loss account selection', () => {
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
+ restvarde_target: null,
disposed_at: null,
disposed_proceeds: null,
+ disposed_proceeds_vat: 0,
+ disposed_vat_treatment: null,
+ jamkning_amount: 0,
+ jamkning_remaining_months: null,
+ jamkning_total_months: null,
+ jamkning_original_input_vat: null,
k3_components: null,
notes: null,
created_at: '2023-01-01T00:00:00Z',
diff --git a/lib/bokslut/__tests__/depreciation-engine.test.ts b/lib/bokslut/__tests__/depreciation-engine.test.ts
index 318941e0..316d6cfd 100644
--- a/lib/bokslut/__tests__/depreciation-engine.test.ts
+++ b/lib/bokslut/__tests__/depreciation-engine.test.ts
@@ -17,8 +17,15 @@ function makeAsset(overrides: Partial = {}): Asset {
bas_asset_account: '1220',
bas_accumulated_account: '1229',
bas_expense_account: '7832',
+ restvarde_target: null,
disposed_at: null,
disposed_proceeds: null,
+ disposed_proceeds_vat: 0,
+ disposed_vat_treatment: null,
+ jamkning_amount: 0,
+ jamkning_remaining_months: null,
+ jamkning_total_months: null,
+ jamkning_original_input_vat: null,
k3_components: null,
notes: null,
created_at: '2025-01-01T00:00:00Z',
@@ -145,3 +152,180 @@ describe('computeAnnualDepreciation', () => {
expect(result.amount).toBeLessThan(1_020)
})
})
+
+// ============================================================
+// Declining-balance methods (IL 18 kap 13§ huvudregel + kompletteringsregel)
+// ============================================================
+//
+// Swedish practice: declining methods take the full annual amount regardless
+// of acquisition month (K2 10.23 — "Full annual amount regardless of partial
+// year"). The engine therefore does NOT pro-rate by day-overlap for these
+// methods. Disposal during the period still yields the full year because the
+// disposal entry zeroes out the remaining book value separately.
+
+describe('computeAnnualDepreciation — declining_balance_30 (räkenskapsenlig huvudregel)', () => {
+ it('year 1: 100 000 kr × 30 % = 30 000 kr (no prior accumulated)', () => {
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ depreciation_method: 'declining_balance_30',
+ })
+ const result = computeAnnualDepreciation(asset, FULL_YEAR)
+ expect(result.amount).toBe(30_000)
+ expect(result.proRated).toBe(false)
+ })
+
+ it('year 2: book value 70 000 × 30 % = 21 000', () => {
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ depreciation_method: 'declining_balance_30',
+ })
+ const result = computeAnnualDepreciation(asset, FULL_YEAR, 30_000)
+ expect(result.amount).toBe(21_000)
+ })
+
+ it('year 3: book value 49 000 × 30 % = 14 700', () => {
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ depreciation_method: 'declining_balance_30',
+ })
+ const result = computeAnnualDepreciation(asset, FULL_YEAR, 51_000)
+ expect(result.amount).toBe(14_700)
+ })
+
+ it('does NOT pro-rate for mid-year acquisition (full annual amount)', () => {
+ // Acquired July 1 — linear would pro-rate to ~50 %. Declining methods
+ // take the full year amount per K2 10.23 and tax practice.
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ acquisition_date: '2025-07-01',
+ depreciation_method: 'declining_balance_30',
+ })
+ const result = computeAnnualDepreciation(asset, FULL_YEAR)
+ expect(result.amount).toBe(30_000)
+ expect(result.proRated).toBe(false)
+ })
+
+ it('returns 0 when book value already at zero (or below)', () => {
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ depreciation_method: 'declining_balance_30',
+ })
+ // Prior accumulated ≥ acquisition cost → book value 0.
+ const result = computeAnnualDepreciation(asset, FULL_YEAR, 100_000)
+ expect(result.amount).toBe(0)
+ })
+
+ it('returns 0 when asset disposed before period start', () => {
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ depreciation_method: 'declining_balance_30',
+ disposed_at: '2024-12-31',
+ disposed_proceeds: 50_000,
+ })
+ const result = computeAnnualDepreciation(asset, FULL_YEAR)
+ expect(result.amount).toBe(0)
+ })
+})
+
+describe('computeAnnualDepreciation — declining_balance_20 (kompletteringsregel, byggnader)', () => {
+ it('year 1: 1 000 000 kr building × 20 % = 200 000', () => {
+ const asset = makeAsset({
+ category: 'building',
+ bas_asset_account: '1110',
+ bas_accumulated_account: '1119',
+ bas_expense_account: '7821',
+ acquisition_cost: 1_000_000,
+ depreciation_method: 'declining_balance_20',
+ })
+ const result = computeAnnualDepreciation(asset, FULL_YEAR)
+ expect(result.amount).toBe(200_000)
+ expect(result.proRated).toBe(false)
+ })
+
+ it('year 2: book value 800 000 × 20 % = 160 000', () => {
+ const asset = makeAsset({
+ category: 'building',
+ acquisition_cost: 1_000_000,
+ depreciation_method: 'declining_balance_20',
+ })
+ const result = computeAnnualDepreciation(asset, FULL_YEAR, 200_000)
+ expect(result.amount).toBe(160_000)
+ })
+})
+
+describe('computeAnnualDepreciation — restvardesavskrivning_25 (IL 18 kap 13§ st.3)', () => {
+ it('year 1: (100 000 − 20 000 restvärde) × 25 % = 20 000', () => {
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ depreciation_method: 'restvardesavskrivning_25',
+ restvarde_target: 20_000,
+ })
+ const result = computeAnnualDepreciation(asset, FULL_YEAR)
+ expect(result.amount).toBe(20_000)
+ expect(result.proRated).toBe(false)
+ })
+
+ it('year 2: book value 80 000, depreciable (80 000 − 20 000) × 25 % = 15 000', () => {
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ depreciation_method: 'restvardesavskrivning_25',
+ restvarde_target: 20_000,
+ })
+ const result = computeAnnualDepreciation(asset, FULL_YEAR, 20_000)
+ expect(result.amount).toBe(15_000)
+ })
+
+ it('floors at restvärde — book value already at floor returns 0', () => {
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ depreciation_method: 'restvardesavskrivning_25',
+ restvarde_target: 20_000,
+ })
+ // Prior accumulated brings book value to exactly the floor (20 000).
+ const result = computeAnnualDepreciation(asset, FULL_YEAR, 80_000)
+ expect(result.amount).toBe(0)
+ })
+
+ it('multi-year convergence: book value approaches restvärde but never goes below', () => {
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ depreciation_method: 'restvardesavskrivning_25',
+ restvarde_target: 20_000,
+ })
+ // Simulate 10 years of compounding to verify the floor.
+ let accumulated = 0
+ for (let year = 0; year < 10; year++) {
+ const { amount } = computeAnnualDepreciation(asset, FULL_YEAR, accumulated)
+ accumulated += amount
+ }
+ const finalBookValue = 100_000 - accumulated
+ expect(finalBookValue).toBeGreaterThanOrEqual(20_000)
+ // Should be tracking toward the floor — within a kr or two after 10 years.
+ expect(finalBookValue).toBeLessThan(26_000)
+ })
+
+ it('does NOT pro-rate for mid-year acquisition (full annual amount)', () => {
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ acquisition_date: '2025-07-01',
+ depreciation_method: 'restvardesavskrivning_25',
+ restvarde_target: 20_000,
+ })
+ const result = computeAnnualDepreciation(asset, FULL_YEAR)
+ expect(result.amount).toBe(20_000)
+ expect(result.proRated).toBe(false)
+ })
+
+ it('treats restvarde_target=null as 0 (defensive — DB CHECK should prevent this state)', () => {
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ depreciation_method: 'restvardesavskrivning_25',
+ restvarde_target: null,
+ })
+ // (100 000 − 0) × 25 % = 25 000. The DB CHECK forbids method=restvärde
+ // with null target, but the engine should still produce a deterministic
+ // answer rather than crash.
+ const result = computeAnnualDepreciation(asset, FULL_YEAR)
+ expect(result.amount).toBe(25_000)
+ })
+})
diff --git a/lib/bokslut/__tests__/k3-framework-dispositions.test.ts b/lib/bokslut/__tests__/k3-framework-dispositions.test.ts
new file mode 100644
index 00000000..8e34550b
--- /dev/null
+++ b/lib/bokslut/__tests__/k3-framework-dispositions.test.ts
@@ -0,0 +1,257 @@
+/**
+ * K3 framework integration test for the dispositions builder.
+ *
+ * Verifies that:
+ * - K3 companies get an `uppskjuten_skatt` proposal at the end of the chain.
+ * - K2 companies (the default) do NOT receive that proposal.
+ * - The latent tax amount equals 20.6 % × projected closing 21xx − current
+ * 2240 balance.
+ *
+ * Mocks generateIncomeStatement and generateTrialBalance directly so the
+ * test can drive numeric inputs without touching the database.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/reports/income-statement', () => ({
+ generateIncomeStatement: vi.fn(),
+}))
+
+vi.mock('@/lib/reports/trial-balance', () => ({
+ generateTrialBalance: vi.fn(),
+}))
+
+import { buildDispositionsProposal } from '../dispositions-proposal-builder'
+import { generateIncomeStatement } from '@/lib/reports/income-statement'
+import { generateTrialBalance } from '@/lib/reports/trial-balance'
+
+interface ChainableMock {
+ from: ReturnType
+}
+
+function makeSupabase(opts: {
+ entityType: 'aktiebolag' | 'enskild_firma'
+ accountingFramework: 'k2' | 'k3' | null
+ periodEnd?: string
+ /** Existing periodiseringsfond rows the builder queries. */
+ periodiseringsfondRows?: Array<{ account_number: string; cohort_year: number; balance: number; must_return_this_year: boolean }>
+}): ChainableMock {
+ const periodEnd = opts.periodEnd ?? '2026-12-31'
+ // The builder makes several .from(...) queries. We respond per-table.
+ const from = vi.fn((table: string) => {
+ if (table === 'fiscal_periods') {
+ return {
+ select: () => ({
+ eq: () => ({
+ eq: () => ({
+ single: () =>
+ Promise.resolve({
+ data: {
+ id: 'fp1',
+ name: '2026',
+ period_start: '2026-01-01',
+ period_end: periodEnd,
+ },
+ error: null,
+ }),
+ }),
+ }),
+ }),
+ }
+ }
+ if (table === 'company_settings') {
+ return {
+ select: () => ({
+ eq: () => ({
+ maybeSingle: () =>
+ Promise.resolve({
+ data: { entity_type: opts.entityType },
+ error: null,
+ }),
+ }),
+ }),
+ }
+ }
+ if (table === 'companies') {
+ return {
+ select: () => ({
+ eq: () => ({
+ maybeSingle: () =>
+ Promise.resolve({
+ data: { accounting_framework: opts.accountingFramework },
+ error: null,
+ }),
+ }),
+ }),
+ }
+ }
+ if (table === 'journal_entry_lines') {
+ // Used by listExistingPeriodiseringsfonder and calculateSarskildLoneskatt.
+ // We return either the test-supplied periodiseringsfond rows (when the
+ // builder is walking 21xx) or an empty SLP result.
+ const rows = opts.periodiseringsfondRows
+ ? opts.periodiseringsfondRows.flatMap((f) => [
+ {
+ account_number: f.account_number,
+ credit_amount: f.balance,
+ debit_amount: 0,
+ entry_date: `${f.cohort_year}-12-31`,
+ journal_entries: { entry_date: `${f.cohort_year}-12-31`, status: 'posted' },
+ },
+ ])
+ : []
+ return {
+ select: () => {
+ // Make the chainable builder resolve to { data: rows, error: null }
+ const result: { data: typeof rows; error: null } = { data: rows, error: null }
+ const handler: ProxyHandler = {
+ get(_t, prop) {
+ if (prop === 'then') {
+ return (resolve: (v: unknown) => void) => resolve(result)
+ }
+ return () => new Proxy({}, handler)
+ },
+ }
+ return new Proxy({}, handler)
+ },
+ }
+ }
+ // Catch-all chainable that resolves to empty.
+ const handler: ProxyHandler = {
+ get(_t, prop) {
+ if (prop === 'then') {
+ return (resolve: (v: unknown) => void) =>
+ resolve({ data: null, error: null })
+ }
+ return () => new Proxy({}, handler)
+ },
+ }
+ return new Proxy({}, handler)
+ })
+ return { from } as ChainableMock
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ // Zero result so the builder doesn't propose a new avsättning — keeps the
+ // 21xx balance stable at the trial-balance value, which makes the latent
+ // tax math testable in isolation.
+ vi.mocked(generateIncomeStatement).mockResolvedValue({
+ net_result: 0,
+ } as Awaited>)
+ vi.mocked(generateTrialBalance).mockResolvedValue({
+ rows: [
+ // 100 000 in periodiseringsfond → 20 600 latent tax target.
+ {
+ account_number: '2125',
+ account_name: 'Periodiseringsfond 2025',
+ account_class: 2,
+ closing_credit: 100_000,
+ closing_debit: 0,
+ opening_credit: 0,
+ opening_debit: 0,
+ period_credit: 100_000,
+ period_debit: 0,
+ },
+ // No existing 2240 balance.
+ ],
+ totalDebit: 0,
+ totalCredit: 100_000,
+ isBalanced: false,
+ } as unknown as Awaited>)
+})
+
+describe('buildDispositionsProposal — K3 framework', () => {
+ it('appends an uppskjuten_skatt proposal for K3 aktiebolag', async () => {
+ const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k3' })
+ const result = await buildDispositionsProposal(
+ supabase as unknown as Parameters[0],
+ 'co',
+ 'fp1',
+ )
+ expect(result.entityType).toBe('aktiebolag')
+ const latentTax = result.proposals.find((p) => p.kind === 'uppskjuten_skatt')
+ expect(latentTax).toBeDefined()
+ expect(latentTax!.amount).toBe(20_600)
+ expect(latentTax!.lines).toHaveLength(2)
+ // Liability increased → debit 8940 / credit 2240.
+ const debit = latentTax!.lines.find((l) => l.account_number === '8940')!
+ const credit = latentTax!.lines.find((l) => l.account_number === '2240')!
+ expect(debit.debit_amount).toBe(20_600)
+ expect(credit.credit_amount).toBe(20_600)
+ })
+
+ it('does NOT add an uppskjuten_skatt proposal for K2 aktiebolag', async () => {
+ const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k2' })
+ const result = await buildDispositionsProposal(
+ supabase as unknown as Parameters[0],
+ 'co',
+ 'fp1',
+ )
+ expect(result.entityType).toBe('aktiebolag')
+ expect(result.proposals.find((p) => p.kind === 'uppskjuten_skatt')).toBeUndefined()
+ })
+
+ it('defaults to K2 when accounting_framework is null on the company row', async () => {
+ const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: null })
+ const result = await buildDispositionsProposal(
+ supabase as unknown as Parameters[0],
+ 'co',
+ 'fp1',
+ )
+ expect(result.proposals.find((p) => p.kind === 'uppskjuten_skatt')).toBeUndefined()
+ })
+
+ it('does NOT add an uppskjuten_skatt proposal for enskild firma even if mislabelled K3', async () => {
+ // EF should never carry K3 (validation rejects it on the API side), but if
+ // somehow set the EF branch returns early with an empty proposal list.
+ const supabase = makeSupabase({ entityType: 'enskild_firma', accountingFramework: 'k3' })
+ const result = await buildDispositionsProposal(
+ supabase as unknown as Parameters[0],
+ 'co',
+ 'fp1',
+ )
+ expect(result.entityType).toBe('enskild_firma')
+ expect(result.proposals).toEqual([])
+ })
+
+ it('skips uppskjuten_skatt when 2240 already matches target (no change)', async () => {
+ // Bump 2240 to exactly 20 600 so the delta is zero — calculator should
+ // return null and the builder skip the proposal entirely.
+ vi.mocked(generateTrialBalance).mockResolvedValue({
+ rows: [
+ {
+ account_number: '2125',
+ account_name: 'Periodiseringsfond 2025',
+ account_class: 2,
+ closing_credit: 100_000,
+ closing_debit: 0,
+ opening_credit: 0,
+ opening_debit: 0,
+ period_credit: 100_000,
+ period_debit: 0,
+ },
+ {
+ account_number: '2240',
+ account_name: 'Avsättningar för uppskjutna skatter',
+ account_class: 2,
+ closing_credit: 20_600,
+ closing_debit: 0,
+ opening_credit: 20_600,
+ opening_debit: 0,
+ period_credit: 0,
+ period_debit: 0,
+ },
+ ],
+ totalDebit: 0,
+ totalCredit: 120_600,
+ isBalanced: false,
+ } as unknown as Awaited>)
+ const supabase = makeSupabase({ entityType: 'aktiebolag', accountingFramework: 'k3' })
+ const result = await buildDispositionsProposal(
+ supabase as unknown as Parameters[0],
+ 'co',
+ 'fp1',
+ )
+ expect(result.proposals.find((p) => p.kind === 'uppskjuten_skatt')).toBeUndefined()
+ })
+})
diff --git a/lib/bokslut/__tests__/latent-tax-calculator.test.ts b/lib/bokslut/__tests__/latent-tax-calculator.test.ts
new file mode 100644
index 00000000..81ed2726
--- /dev/null
+++ b/lib/bokslut/__tests__/latent-tax-calculator.test.ts
@@ -0,0 +1,96 @@
+import { describe, it, expect } from 'vitest'
+import {
+ computeLatentTax,
+ proposeLatentTaxChange,
+ LATENT_TAX_DEFAULT_RATE,
+ LATENT_TAX_LIABILITY_ACCOUNT,
+ LATENT_TAX_EXPENSE_ACCOUNT,
+} from '../tax-provision/latent-tax-calculator'
+
+describe('computeLatentTax', () => {
+ it('splits 100 000 reserves into 79 400 equity + 20 600 liability at default 20,6 %', () => {
+ const split = computeLatentTax({ untaxedReserves: 100_000 })
+ expect(split.liabilityPortion).toBe(20_600)
+ expect(split.equityPortion).toBe(79_400)
+ // Invariant — the two portions reconcile to the input.
+ expect(split.equityPortion + split.liabilityPortion).toBeCloseTo(100_000, 2)
+ })
+
+ it('returns zero portions for zero reserves', () => {
+ const split = computeLatentTax({ untaxedReserves: 0 })
+ expect(split.equityPortion).toBe(0)
+ expect(split.liabilityPortion).toBe(0)
+ })
+
+ it('preserves the sign for negative reserves (over-reversal edge case)', () => {
+ // Unusual but the math should stay symmetric — e.g. when the
+ // dispositions builder posts more återföring than the existing reserves.
+ const split = computeLatentTax({ untaxedReserves: -50_000 })
+ expect(split.liabilityPortion).toBe(-10_300)
+ expect(split.equityPortion).toBe(-39_700)
+ expect(split.equityPortion + split.liabilityPortion).toBeCloseTo(-50_000, 2)
+ })
+
+ it('accepts a custom tax rate (future flex for rate changes)', () => {
+ // If bolagsskatt drops to e.g. 18 %, K3 split would follow.
+ const split = computeLatentTax({ untaxedReserves: 100_000, taxRate: 0.18 })
+ expect(split.liabilityPortion).toBe(18_000)
+ expect(split.equityPortion).toBe(82_000)
+ })
+
+ it('rounds to öre on non-integer reserves', () => {
+ // 12 345.67 × 0.206 = 2 543.20802 → öre rounding → 2 543.21
+ // equity = 12 345.67 − 2 543.21 = 9 802.46
+ const split = computeLatentTax({ untaxedReserves: 12_345.67 })
+ expect(split.liabilityPortion).toBe(2_543.21)
+ expect(split.equityPortion).toBe(9_802.46)
+ expect(split.equityPortion + split.liabilityPortion).toBeCloseTo(12_345.67, 2)
+ })
+
+ it('exports the canonical 20.6 % rate constant', () => {
+ expect(LATENT_TAX_DEFAULT_RATE).toBe(0.206)
+ })
+})
+
+describe('proposeLatentTaxChange', () => {
+ it('returns null when current already equals target (no change)', () => {
+ expect(proposeLatentTaxChange(20_600, 20_600)).toBeNull()
+ })
+
+ it('returns null when delta is below 1 öre tolerance', () => {
+ // Floating-point dust below 1 öre should not produce a verifikat.
+ expect(proposeLatentTaxChange(20_600, 20_600.001)).toBeNull()
+ expect(proposeLatentTaxChange(20_600.0049, 20_600)).toBeNull()
+ })
+
+ it('books an avsättning when liability grows: debit 8940 / credit 2240', () => {
+ const lines = proposeLatentTaxChange(0, 20_600)
+ expect(lines).not.toBeNull()
+ expect(lines).toHaveLength(2)
+ const debit = lines!.find((l) => l.account_number === LATENT_TAX_EXPENSE_ACCOUNT)!
+ const credit = lines!.find((l) => l.account_number === LATENT_TAX_LIABILITY_ACCOUNT)!
+ expect(debit.debit_amount).toBe(20_600)
+ expect(debit.credit_amount).toBe(0)
+ expect(credit.debit_amount).toBe(0)
+ expect(credit.credit_amount).toBe(20_600)
+ })
+
+ it('books a återföring when liability shrinks: debit 2240 / credit 8940', () => {
+ const lines = proposeLatentTaxChange(20_600, 15_000)
+ expect(lines).not.toBeNull()
+ expect(lines).toHaveLength(2)
+ const debit = lines!.find((l) => l.account_number === LATENT_TAX_LIABILITY_ACCOUNT)!
+ const credit = lines!.find((l) => l.account_number === LATENT_TAX_EXPENSE_ACCOUNT)!
+ expect(debit.debit_amount).toBe(5_600)
+ expect(credit.credit_amount).toBe(5_600)
+ })
+
+ it('produces a balanced verifikat (sum debit = sum credit)', () => {
+ const lines = proposeLatentTaxChange(10_000, 18_000)
+ expect(lines).not.toBeNull()
+ const totalDebit = lines!.reduce((s, l) => s + l.debit_amount, 0)
+ const totalCredit = lines!.reduce((s, l) => s + l.credit_amount, 0)
+ expect(totalDebit).toBe(totalCredit)
+ expect(totalDebit).toBe(8_000)
+ })
+})
diff --git a/lib/bokslut/__tests__/rounding.test.ts b/lib/bokslut/__tests__/rounding.test.ts
new file mode 100644
index 00000000..0b3fd1b5
--- /dev/null
+++ b/lib/bokslut/__tests__/rounding.test.ts
@@ -0,0 +1,65 @@
+import { describe, it, expect } from 'vitest'
+import { roundOre, ORE_TOLERANCE } from '../rounding'
+
+describe('roundOre', () => {
+ it('rounds typical positive amounts to two decimals', () => {
+ expect(roundOre(1.234)).toBe(1.23)
+ expect(roundOre(1.235)).toBe(1.24)
+ expect(roundOre(1.236)).toBe(1.24)
+ })
+
+ it('rounds negative amounts symmetrically', () => {
+ // Math.round rounds half toward +∞: -1.235 -> -1.23.
+ // Verified behavior so callers can rely on it.
+ expect(roundOre(-1.234)).toBe(-1.23)
+ expect(roundOre(-1.236)).toBe(-1.24)
+ })
+
+ it('returns 0 unchanged', () => {
+ expect(roundOre(0)).toBe(0)
+ // Math.round(-0 * 100) preserves the negative-zero sign; treat it as
+ // numerically equal to 0 rather than enforcing Object.is equality.
+ expect(roundOre(-0)).toEqual(-0)
+ expect(Math.abs(roundOre(-0))).toBe(0)
+ })
+
+ it('exposes a half-öre tolerance constant', () => {
+ expect(ORE_TOLERANCE).toBe(0.005)
+ })
+
+ it('sum of rounded parts equals rounded sum for representative cases', () => {
+ const cases: number[][] = [
+ [100, 200, 300],
+ [1.11, 2.22, 3.33],
+ [1.005, 2.005, 3.005],
+ [-100, 50, 50],
+ [-1.234, 2.345, -3.456],
+ [0.1, 0.2, 0.3], // classic IEEE 754 trap
+ [12345.67, -12345.67],
+ [1_000_000.01, 2_000_000.02, 3_000_000.03],
+ ]
+
+ // Half-up rounding doesn't preserve sums exactly: each part can shift
+ // by up to half an öre, so cumulative drift over N parts is bounded by
+ // N * ORE_TOLERANCE. The pathological case is [1.005, 2.005, 3.005] —
+ // three exact-half values that all round up to .01, drifting the sum
+ // by one öre versus summing then rounding.
+ for (const parts of cases) {
+ const summedThenRounded = roundOre(parts.reduce((a, b) => a + b, 0))
+ const roundedThenSummed = roundOre(
+ parts.map(roundOre).reduce((a, b) => a + b, 0)
+ )
+ expect(
+ Math.abs(summedThenRounded - roundedThenSummed),
+ `parts=${JSON.stringify(parts)}`
+ ).toBeLessThanOrEqual(ORE_TOLERANCE * parts.length)
+ }
+ })
+
+ it('roundOre is idempotent', () => {
+ const samples = [1.005, -2.345, 99.999, -0.005]
+ for (const s of samples) {
+ expect(roundOre(roundOre(s))).toBe(roundOre(s))
+ }
+ })
+})
diff --git a/lib/bokslut/accruals/__tests__/auto-detect.test.ts b/lib/bokslut/accruals/__tests__/auto-detect.test.ts
new file mode 100644
index 00000000..9765d176
--- /dev/null
+++ b/lib/bokslut/accruals/__tests__/auto-detect.test.ts
@@ -0,0 +1,244 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { detectPeriodisering } from '../auto-detect'
+import { createQueuedMockSupabase } from '@/tests/helpers'
+
+describe('detectPeriodisering', () => {
+ let mock: ReturnType
+
+ beforeEach(() => {
+ mock = createQueuedMockSupabase()
+ vi.clearAllMocks()
+ })
+
+ it('returns empty array when the fiscal period is not found', async () => {
+ mock.enqueue({ data: null, error: { message: 'not found' } })
+ const result = await detectPeriodisering(
+ mock.supabase as never,
+ 'company-1',
+ 'period-1',
+ )
+ expect(result).toEqual([])
+ })
+
+ it('detects a supplier invoice that spans year-end and pro-rates the amount', async () => {
+ // 1) fiscal_periods
+ mock.enqueue({
+ data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
+ error: null,
+ })
+ // 2) invoices (none)
+ mock.enqueue({ data: [], error: null })
+ // 3) supplier_invoices — one 12-month annual license invoice
+ // Window: 2025-07-01 → 2026-06-30 = 365 days
+ // After period_end 2025-12-31: 2026-01-01 → 2026-06-30 = 181 days
+ // Subtotal 12000 → 12000 * 181/365 ≈ 5950.68 → 5950.68 rounded keeps 2 decimals
+ mock.enqueue({
+ data: [
+ {
+ id: 'sup-inv-1',
+ supplier_invoice_number: 'LF-100',
+ invoice_date: '2025-07-01',
+ subtotal: 12000,
+ notes: 'Mjukvarulicens period: 2025-07-01 till 2026-06-30',
+ suppliers: { name: 'Acme SaaS AB' },
+ supplier_invoice_items: [{ description: 'Årslicens', account_number: '5800' }],
+ },
+ ],
+ error: null,
+ })
+
+ const result = await detectPeriodisering(
+ mock.supabase as never,
+ 'company-1',
+ 'period-1',
+ )
+ expect(result).toHaveLength(1)
+ expect(result[0].source_type).toBe('supplier_invoice')
+ expect(result[0].source_invoice_id).toBe('sup-inv-1')
+ expect(result[0].parsed_start).toBe('2025-07-01')
+ expect(result[0].parsed_end).toBe('2026-06-30')
+ expect(result[0].confidence).toBe('high')
+ // 12000 * 181/365 = 5950.6849... → 5950.68
+ expect(result[0].periodisering_amount).toBeCloseTo(5950.68, 2)
+ expect(result[0].suggested_prepaid_account).toBe('1710')
+ expect(result[0].suggested_deferred_account).toBeNull()
+ })
+
+ it('detects a customer invoice with a service window in its notes', async () => {
+ mock.enqueue({
+ data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
+ error: null,
+ })
+ // Customer invoice for an annual subscription billed Dec 1 2025 covering
+ // Jan 1 2026 → Dec 31 2026 entirely. Entire amount belongs to next year.
+ mock.enqueue({
+ data: [
+ {
+ id: 'inv-1',
+ invoice_number: 'F-2001',
+ invoice_date: '2025-12-01',
+ subtotal: 24000,
+ notes: 'Årsabonnemang för period 2026-01-01 till 2026-12-31',
+ customers: { name: 'Kund AB' },
+ invoice_items: [{ description: 'Premium abonnemang' }],
+ },
+ ],
+ error: null,
+ })
+ mock.enqueue({ data: [], error: null }) // supplier_invoices
+
+ const result = await detectPeriodisering(
+ mock.supabase as never,
+ 'company-1',
+ 'period-1',
+ )
+ expect(result).toHaveLength(1)
+ expect(result[0].source_type).toBe('invoice')
+ expect(result[0].confidence).toBe('high')
+ // Entire 24000 belongs to next year
+ expect(result[0].periodisering_amount).toBe(24000)
+ expect(result[0].suggested_deferred_account).toBe('2970')
+ expect(result[0].suggested_prepaid_account).toBeNull()
+ })
+
+ it('downgrades confidence to "medium" when the date range comes from line items', async () => {
+ mock.enqueue({
+ data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
+ error: null,
+ })
+ mock.enqueue({ data: [], error: null }) // invoices
+ mock.enqueue({
+ data: [
+ {
+ id: 'sup-inv-2',
+ supplier_invoice_number: 'LF-200',
+ invoice_date: '2025-12-15',
+ subtotal: 6000,
+ notes: 'Försäkringspremie', // no date range in head
+ suppliers: { name: 'Försäkring AB' },
+ supplier_invoice_items: [
+ // Range only on the line item
+ {
+ description: 'Försäkring period 2026-01-01 till 2026-06-30',
+ account_number: '6310',
+ },
+ ],
+ },
+ ],
+ error: null,
+ })
+
+ const result = await detectPeriodisering(
+ mock.supabase as never,
+ 'company-1',
+ 'period-1',
+ )
+ expect(result).toHaveLength(1)
+ expect(result[0].confidence).toBe('medium')
+ expect(result[0].periodisering_amount).toBe(6000) // entire 6000 → next year
+ })
+
+ it('ignores invoices whose parsed range ends within the period', async () => {
+ mock.enqueue({
+ data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
+ error: null,
+ })
+ mock.enqueue({ data: [], error: null }) // invoices
+ mock.enqueue({
+ data: [
+ {
+ id: 'sup-inv-3',
+ supplier_invoice_number: 'LF-300',
+ invoice_date: '2025-06-01',
+ subtotal: 4000,
+ notes: 'Hyra perioden 2025-06-01 till 2025-08-31',
+ suppliers: { name: 'Hyresvärd AB' },
+ supplier_invoice_items: [{ description: 'Hyra Q3', account_number: '5010' }],
+ },
+ ],
+ error: null,
+ })
+
+ const result = await detectPeriodisering(
+ mock.supabase as never,
+ 'company-1',
+ 'period-1',
+ )
+ expect(result).toEqual([])
+ })
+
+ it('ignores invoices with no parseable date range', async () => {
+ mock.enqueue({
+ data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
+ error: null,
+ })
+ mock.enqueue({ data: [], error: null })
+ mock.enqueue({
+ data: [
+ {
+ id: 'sup-inv-4',
+ supplier_invoice_number: 'LF-400',
+ invoice_date: '2025-12-30',
+ subtotal: 5000,
+ notes: 'Tack för köpet hos oss!',
+ suppliers: { name: 'Random AB' },
+ supplier_invoice_items: [{ description: 'Diverse', account_number: '6590' }],
+ },
+ ],
+ error: null,
+ })
+
+ const result = await detectPeriodisering(
+ mock.supabase as never,
+ 'company-1',
+ 'period-1',
+ )
+ expect(result).toEqual([])
+ })
+
+ it('sorts suggestions by confidence (high first) then by amount desc', async () => {
+ mock.enqueue({
+ data: { id: 'period-1', period_start: '2025-01-01', period_end: '2025-12-31' },
+ error: null,
+ })
+ mock.enqueue({ data: [], error: null })
+ mock.enqueue({
+ data: [
+ {
+ id: 'sup-medium',
+ supplier_invoice_number: 'LF-A',
+ invoice_date: '2025-12-01',
+ subtotal: 9000, // bigger, but medium confidence (range in line item)
+ notes: 'Försäkring',
+ suppliers: { name: 'A' },
+ supplier_invoice_items: [
+ {
+ description: 'Period 2026-01-01 till 2026-12-31',
+ account_number: '6310',
+ },
+ ],
+ },
+ {
+ id: 'sup-high',
+ supplier_invoice_number: 'LF-B',
+ invoice_date: '2025-12-01',
+ subtotal: 3000, // smaller, but high confidence
+ notes: 'Mjukvara perioden 2026-01-01 till 2026-12-31',
+ suppliers: { name: 'B' },
+ supplier_invoice_items: [{ description: 'License', account_number: '5800' }],
+ },
+ ],
+ error: null,
+ })
+
+ const result = await detectPeriodisering(
+ mock.supabase as never,
+ 'company-1',
+ 'period-1',
+ )
+ expect(result).toHaveLength(2)
+ // high confidence wins over a larger medium-confidence suggestion
+ expect(result[0].source_invoice_id).toBe('sup-high')
+ expect(result[1].source_invoice_id).toBe('sup-medium')
+ })
+})
diff --git a/lib/bokslut/accruals/__tests__/date-range-parser.test.ts b/lib/bokslut/accruals/__tests__/date-range-parser.test.ts
new file mode 100644
index 00000000..03819034
--- /dev/null
+++ b/lib/bokslut/accruals/__tests__/date-range-parser.test.ts
@@ -0,0 +1,133 @@
+import { describe, it, expect } from 'vitest'
+import { parseInvoiceDateRange } from '../date-range-parser'
+
+describe('parseInvoiceDateRange — ISO patterns', () => {
+ it('parses "period: 2026-01-01 till 2027-12-31"', () => {
+ expect(parseInvoiceDateRange('period: 2026-01-01 till 2027-12-31')).toEqual({
+ startDate: '2026-01-01',
+ endDate: '2027-12-31',
+ })
+ })
+
+ it('parses "perioden 2026-01-01 - 2026-12-31"', () => {
+ expect(parseInvoiceDateRange('perioden 2026-01-01 - 2026-12-31')).toEqual({
+ startDate: '2026-01-01',
+ endDate: '2026-12-31',
+ })
+ })
+
+ it('parses ISO with en dash', () => {
+ expect(parseInvoiceDateRange('2026-01-01–2026-06-30')).toEqual({
+ startDate: '2026-01-01',
+ endDate: '2026-06-30',
+ })
+ })
+
+ it('parses "giltig från 2026-01-01 till 2027-12-31"', () => {
+ expect(parseInvoiceDateRange('giltig från 2026-01-01 till 2027-12-31')).toEqual({
+ startDate: '2026-01-01',
+ endDate: '2027-12-31',
+ })
+ })
+
+ it('handles t.o.m. as the separator', () => {
+ expect(parseInvoiceDateRange('Faktura 2026-01-01 t.o.m. 2026-12-31')).toEqual({
+ startDate: '2026-01-01',
+ endDate: '2026-12-31',
+ })
+ })
+})
+
+describe('parseInvoiceDateRange — Swedish long form', () => {
+ it('parses "period: 1 jan 2026 - 31 dec 2026"', () => {
+ expect(parseInvoiceDateRange('period: 1 jan 2026 - 31 dec 2026')).toEqual({
+ startDate: '2026-01-01',
+ endDate: '2026-12-31',
+ })
+ })
+
+ it('parses month-only form "jan 2026 till dec 2026"', () => {
+ expect(parseInvoiceDateRange('Premie för perioden jan 2026 till dec 2026')).toEqual({
+ startDate: '2026-01-01',
+ endDate: '2026-12-31',
+ })
+ })
+
+ it('uses last day of month for non-31-day months in Swedish form', () => {
+ // feb-feb expands to a Feb 1 → Feb 28 window — the "same month" case
+ // still has measurable length so it's accepted, not rejected.
+ expect(parseInvoiceDateRange('feb 2026 till feb 2026')).toEqual({
+ startDate: '2026-02-01',
+ endDate: '2026-02-28',
+ })
+ expect(parseInvoiceDateRange('jan 2026 till feb 2026')).toEqual({
+ startDate: '2026-01-01',
+ endDate: '2026-02-28', // 2026 is not a leap year
+ })
+ })
+
+ it('handles leap year correctly', () => {
+ expect(parseInvoiceDateRange('jan 2024 till feb 2024')).toEqual({
+ startDate: '2024-01-01',
+ endDate: '2024-02-29',
+ })
+ })
+
+ it('parses full Swedish month names', () => {
+ expect(parseInvoiceDateRange('januari 2026 till december 2026')).toEqual({
+ startDate: '2026-01-01',
+ endDate: '2026-12-31',
+ })
+ })
+})
+
+describe('parseInvoiceDateRange — yyyy-mm form', () => {
+ it('parses "2026-01 till 2027-12"', () => {
+ expect(parseInvoiceDateRange('Avtal 2026-01 till 2027-12')).toEqual({
+ startDate: '2026-01-01',
+ endDate: '2027-12-31',
+ })
+ })
+
+ it('does NOT misparse a full ISO ymd as a yyyy-mm prefix', () => {
+ // The leading "2026-01-01" should not be eaten by the yyyy-mm regex —
+ // the iso-iso branch should handle this first.
+ expect(parseInvoiceDateRange('period 2026-01-01 till 2026-12-31')).toEqual({
+ startDate: '2026-01-01',
+ endDate: '2026-12-31',
+ })
+ })
+})
+
+describe('parseInvoiceDateRange — rejected inputs', () => {
+ it('returns null for a single date with no end', () => {
+ expect(parseInvoiceDateRange('Faktura daterad 2026-01-01')).toBeNull()
+ })
+
+ it('returns null for a future-only date "från 2026-01-01"', () => {
+ expect(parseInvoiceDateRange('Gäller från 2026-01-01')).toBeNull()
+ })
+
+ it('returns null for malformed date "2026-13-01"', () => {
+ expect(parseInvoiceDateRange('period: 2026-13-01 till 2026-12-31')).toBeNull()
+ })
+
+ it('returns null when end <= start', () => {
+ expect(parseInvoiceDateRange('2026-12-31 till 2026-01-01')).toBeNull()
+ })
+
+ it('returns null for empty / null input', () => {
+ expect(parseInvoiceDateRange('')).toBeNull()
+ expect(parseInvoiceDateRange(null)).toBeNull()
+ expect(parseInvoiceDateRange(undefined)).toBeNull()
+ })
+
+ it('returns null for completely irrelevant text', () => {
+ expect(parseInvoiceDateRange('Tack för att du handlar hos oss!')).toBeNull()
+ })
+
+ it('returns null when only one side is parseable', () => {
+ // "Q1 2026" is not in the supported grammar.
+ expect(parseInvoiceDateRange('period Q1 2026 till Q2 2026')).toBeNull()
+ })
+})
diff --git a/lib/bokslut/accruals/accrual-detector.ts b/lib/bokslut/accruals/accrual-detector.ts
index e61af929..388bd822 100644
--- a/lib/bokslut/accruals/accrual-detector.ts
+++ b/lib/bokslut/accruals/accrual-detector.ts
@@ -281,6 +281,122 @@ export function proposeManualAccrued(input: ManualAccruedInput): AccrualProposal
}
}
+export interface RevenueDeferralInput {
+ amount: number
+ /** Revenue account to debit (e.g. 3000 / 3001). The full periodisering
+ * flow debits revenue and credits 2970 — opposite direction to a prepaid
+ * expense. */
+ revenueAccount: string
+ /** Target deferred-revenue account. Must be in the 29xx range; the wizard
+ * defaults this to 2970 specifically (förutbetalda intäkter). */
+ deferredAccount: string
+ description: string
+ closingDate: string
+}
+
+/**
+ * Propose a deferred-revenue entry. Customer has paid (or has been invoiced)
+ * for a service that spans across year-end — the portion attributable to
+ * NEXT year is reclassified out of revenue and into 2970. Reverses on Jan 1.
+ *
+ * Thin wrapper around `proposeManualAccrued` reversed direction-wise: debit
+ * the revenue account, credit 2970. Built on the same engine to keep the
+ * idempotency / period-lock guarantees identical.
+ */
+export function proposeRevenueDeferral(input: RevenueDeferralInput): AccrualProposal | null {
+ if (!/^29\d{2}$/.test(input.deferredAccount)) {
+ throw new Error(`deferredAccount must be in 29xx range, got ${input.deferredAccount}`)
+ }
+ const amount = Math.round(input.amount)
+ if (amount <= 0) return null
+
+ return {
+ kind: 'deferred_revenue',
+ label: `Förutbetald intäkt: ${input.description}`,
+ description: `Debet ${input.revenueAccount}, kredit ${input.deferredAccount}. Vänds vid årsskiftet.`,
+ amount,
+ lines: [
+ {
+ account_number: input.revenueAccount,
+ debit_amount: amount,
+ credit_amount: 0,
+ line_description: `Periodisering intäkt ut: ${input.description}`,
+ },
+ {
+ account_number: input.deferredAccount,
+ debit_amount: 0,
+ credit_amount: amount,
+ line_description: `Förutbetald intäkt: ${input.description}`,
+ },
+ ],
+ reverses_on: nextDayIso(input.closingDate),
+ warnings: [],
+ }
+}
+
+export interface AccruedInterestInput {
+ amount: number
+ /** Interest-expense account, typically 8410 räntekostnader. */
+ expenseAccount: string
+ /** Accrued-interest liability, typically 2940 upplupna sociala avgifter
+ * is wrong — actual choice is the more general 2940 / 2960 family. The
+ * wizard defaults to 2960 / 2950; this helper validates 29xx. */
+ accruedAccount: string
+ description: string
+ closingDate: string
+}
+
+/**
+ * Propose accrued interest expense. Same shape as a generic accrued cost,
+ * but emits a clearer label so the user can tell apart from rent/utilities
+ * in the wizard's review step.
+ */
+export function proposeAccruedInterest(input: AccruedInterestInput): AccrualProposal | null {
+ const base = proposeManualAccrued({
+ amount: input.amount,
+ expenseAccount: input.expenseAccount,
+ accruedAccount: input.accruedAccount,
+ description: input.description,
+ closingDate: input.closingDate,
+ })
+ if (!base) return null
+ return {
+ ...base,
+ kind: 'accrued_interest',
+ label: `Upplupen ränta: ${input.description}`,
+ }
+}
+
+export interface AccruedUtilityInput {
+ amount: number
+ /** Utility-expense account (e.g. 5020 el för kontorslokal). */
+ expenseAccount: string
+ /** Accrued liability, typically 2990 övriga upplupna kostnader. */
+ accruedAccount: string
+ description: string
+ closingDate: string
+}
+
+/**
+ * Propose accrued utility cost. Same shape as proposeAccruedInterest with a
+ * different label — helps the wizard group similar accruals visually.
+ */
+export function proposeAccruedUtility(input: AccruedUtilityInput): AccrualProposal | null {
+ const base = proposeManualAccrued({
+ amount: input.amount,
+ expenseAccount: input.expenseAccount,
+ accruedAccount: input.accruedAccount,
+ description: input.description,
+ closingDate: input.closingDate,
+ })
+ if (!base) return null
+ return {
+ ...base,
+ kind: 'accrued_utility',
+ label: `Upplupen förbrukning: ${input.description}`,
+ }
+}
+
/**
* Build a snapshot of automatically-detectable accrual proposals for the
* wizard's preflight. Today this is just the vacation-liability delta;
diff --git a/lib/bokslut/accruals/auto-detect.ts b/lib/bokslut/accruals/auto-detect.ts
new file mode 100644
index 00000000..6fd37595
--- /dev/null
+++ b/lib/bokslut/accruals/auto-detect.ts
@@ -0,0 +1,254 @@
+import type { SupabaseClient } from '@supabase/supabase-js'
+import { parseInvoiceDateRange } from './date-range-parser'
+
+export type PeriodiseringSource = 'invoice' | 'supplier_invoice'
+export type PeriodiseringConfidence = 'high' | 'medium' | 'low'
+
+export interface PeriodiseringSuggestion {
+ /** Underlying source invoice id (invoices.id or supplier_invoices.id). */
+ source_invoice_id: string
+ source_type: PeriodiseringSource
+ /** Net amount of the invoice (subtotal — excludes VAT, since VAT is
+ * reported in its own period and not periodiserad). */
+ original_amount: number
+ /** Portion of `original_amount` that falls AFTER period_end and should be
+ * reclassified to 17xx / 2970. Rounded to whole krona to match the
+ * manual prepaid/accrued helpers. */
+ periodisering_amount: number
+ /** Inclusive ISO start of the parsed service window. */
+ parsed_start: string
+ /** Inclusive ISO end of the parsed service window. */
+ parsed_end: string
+ confidence: PeriodiseringConfidence
+ /** One-sentence Swedish explanation for the wizard card. */
+ reason: string
+ /** Human-readable label of the source (supplier name / customer name +
+ * invoice number) for the wizard card. */
+ source_label: string
+ /** Suggested BAS accounts. For supplier invoices: prepaid (1710) ← expense
+ * (the source line's account_number, fallback 5800). For customer
+ * invoices: deferred revenue (2970) ← revenue (3001 default). */
+ suggested_prepaid_account: string | null
+ suggested_deferred_account: string | null
+}
+
+interface InvoiceRow {
+ id: string
+ invoice_number: string | null
+ invoice_date: string
+ subtotal: number
+ notes: string | null
+ customers: { name: string } | null
+ invoice_items: { description: string }[] | null
+}
+
+interface SupplierInvoiceRow {
+ id: string
+ supplier_invoice_number: string
+ invoice_date: string
+ subtotal: number
+ notes: string | null
+ suppliers: { name: string } | null
+ supplier_invoice_items: { description: string; account_number: string }[] | null
+}
+
+/** Compute the inclusive number of days between two ISO dates. */
+function daysBetweenInclusive(startIso: string, endIso: string): number {
+ const start = new Date(startIso + 'T00:00:00Z').getTime()
+ const end = new Date(endIso + 'T00:00:00Z').getTime()
+ const days = Math.round((end - start) / 86_400_000) + 1
+ return days
+}
+
+/** First ISO date strictly after `iso`. */
+function nextDayIso(iso: string): string {
+ const d = new Date(iso + 'T00:00:00Z')
+ d.setUTCDate(d.getUTCDate() + 1)
+ return d.toISOString().slice(0, 10)
+}
+
+/**
+ * Build a suggestion if the parsed window extends beyond `periodEnd`. The
+ * portion AFTER period_end is the periodiseringsbelopp — pro-rated over
+ * total days in the parsed window.
+ *
+ * Returns null when:
+ * - no parseable range in the description / line items
+ * - parsed range ends on or before period_end (nothing to periodisera)
+ * - parsed range starts on or after the day after period_end (entire
+ * window is in the next year — that's a true prepaid for the next year,
+ * but it was booked in THIS year; pro-rate is 100%)
+ */
+function buildSuggestion(args: {
+ sourceId: string
+ sourceType: PeriodiseringSource
+ netAmount: number
+ description: string | null
+ itemDescriptions: string[]
+ /** Default expense account from the first supplier-invoice line. Reserved
+ * for a future enhancement where the wizard can pre-fill the manual-entry
+ * form with the actual account rather than the 5800 fallback. Not used
+ * yet but kept on the buildSuggestion args to keep the call sites stable. */
+ _itemDefaultAccount: string | null
+ sourceLabel: string
+ periodEnd: string
+}): PeriodiseringSuggestion | null {
+ const { sourceId, sourceType, netAmount, description, itemDescriptions, sourceLabel, periodEnd } = args
+ if (!Number.isFinite(netAmount) || netAmount <= 0) return null
+
+ // Try the head text first, then each item — first hit wins.
+ let parsed = parseInvoiceDateRange(description)
+ let parsedFromItem = false
+ if (!parsed) {
+ for (const itemDesc of itemDescriptions) {
+ const p = parseInvoiceDateRange(itemDesc)
+ if (p) {
+ parsed = p
+ parsedFromItem = true
+ break
+ }
+ }
+ }
+ if (!parsed) return null
+
+ // If the parsed range ends within the period, nothing to periodisera.
+ if (parsed.endDate <= periodEnd) return null
+
+ const totalDays = daysBetweenInclusive(parsed.startDate, parsed.endDate)
+ if (totalDays <= 0) return null
+
+ const periodisationStart = parsed.startDate > periodEnd ? parsed.startDate : nextDayIso(periodEnd)
+ const daysAfterPeriodEnd = daysBetweenInclusive(periodisationStart, parsed.endDate)
+ if (daysAfterPeriodEnd <= 0) return null
+
+ const ratio = daysAfterPeriodEnd / totalDays
+ const periodisationAmount = Math.round(netAmount * ratio * 100) / 100
+
+ if (periodisationAmount <= 0) return null
+
+ // Confidence policy: parsed from the head description wins "high"; parsed
+ // from a line item lands at "medium" since the head text is the canonical
+ // location. "low" is reserved for future heuristics that catch e.g. a
+ // single date + interpretation rules.
+ const confidence: PeriodiseringConfidence = parsedFromItem ? 'medium' : 'high'
+
+ const isSupplier = sourceType === 'supplier_invoice'
+ const reason = isSupplier
+ ? `Leverantörsfakturan löper ${parsed.startDate} – ${parsed.endDate}. ${daysAfterPeriodEnd} av ${totalDays} dagar avser nästa räkenskapsår.`
+ : `Kundfakturan löper ${parsed.startDate} – ${parsed.endDate}. ${daysAfterPeriodEnd} av ${totalDays} dagar avser nästa räkenskapsår.`
+
+ return {
+ source_invoice_id: sourceId,
+ source_type: sourceType,
+ original_amount: netAmount,
+ periodisering_amount: periodisationAmount,
+ parsed_start: parsed.startDate,
+ parsed_end: parsed.endDate,
+ confidence,
+ reason,
+ source_label: sourceLabel,
+ suggested_prepaid_account: isSupplier ? '1710' : null,
+ suggested_deferred_account: isSupplier ? null : '2970',
+ }
+}
+
+/**
+ * Auto-detect candidate periodiseringar for a fiscal period. Scans:
+ * - customer invoices (sent / partially_paid / paid) issued within the
+ * period whose notes / line items mention a service window
+ * - supplier invoices (approved or paid) registered within the period,
+ * same parsing
+ *
+ * The returned suggestions are NEVER posted automatically — the wizard
+ * surfaces them with a confidence badge and the user accepts/rejects each.
+ */
+export async function detectPeriodisering(
+ supabase: SupabaseClient,
+ companyId: string,
+ fiscalPeriodId: string,
+): Promise {
+ // Resolve the fiscal period window. We scope candidate invoices to those
+ // dated within the period — anything outside is either an opening-balance
+ // carryover (its own concern) or a future invoice (no period to detect).
+ const { data: period, error: periodError } = await supabase
+ .from('fiscal_periods')
+ .select('id, period_start, period_end')
+ .eq('id', fiscalPeriodId)
+ .eq('company_id', companyId)
+ .single()
+ if (periodError || !period) return []
+
+ const periodStart = period.period_start as string
+ const periodEnd = period.period_end as string
+
+ // Customer invoices — only "real" ones (sent/paid). Drafts and overdue
+ // get skipped: drafts haven't moved through the engine, overdue is just a
+ // status label that overlaps with sent here.
+ const { data: invoiceRows } = await supabase
+ .from('invoices')
+ .select('id, invoice_number, invoice_date, subtotal, notes, customers(name), invoice_items(description)')
+ .eq('company_id', companyId)
+ .gte('invoice_date', periodStart)
+ .lte('invoice_date', periodEnd)
+ .in('status', ['sent', 'partially_paid', 'paid', 'overdue'])
+
+ // Supplier invoices — approved or paid (registration journal entry exists).
+ const { data: supplierRows } = await supabase
+ .from('supplier_invoices')
+ .select(
+ 'id, supplier_invoice_number, invoice_date, subtotal, notes, suppliers(name), supplier_invoice_items(description, account_number)',
+ )
+ .eq('company_id', companyId)
+ .gte('invoice_date', periodStart)
+ .lte('invoice_date', periodEnd)
+ .in('status', ['approved', 'partially_paid', 'paid'])
+
+ const suggestions: PeriodiseringSuggestion[] = []
+
+ for (const row of (invoiceRows ?? []) as unknown as InvoiceRow[]) {
+ const itemDescs = (row.invoice_items ?? []).map((i) => i.description).filter(Boolean)
+ const customerName = row.customers?.name ?? 'Okänd kund'
+ const sourceLabel = row.invoice_number
+ ? `${customerName} (faktura ${row.invoice_number})`
+ : customerName
+ const s = buildSuggestion({
+ sourceId: row.id,
+ sourceType: 'invoice',
+ netAmount: Number(row.subtotal ?? 0),
+ description: row.notes,
+ itemDescriptions: itemDescs,
+ _itemDefaultAccount: null,
+ sourceLabel,
+ periodEnd,
+ })
+ if (s) suggestions.push(s)
+ }
+
+ for (const row of (supplierRows ?? []) as unknown as SupplierInvoiceRow[]) {
+ const itemDescs = (row.supplier_invoice_items ?? []).map((i) => i.description).filter(Boolean)
+ const firstAccount = row.supplier_invoice_items?.[0]?.account_number ?? null
+ const supplierName = row.suppliers?.name ?? 'Okänd leverantör'
+ const sourceLabel = `${supplierName} (lev.faktura ${row.supplier_invoice_number})`
+ const s = buildSuggestion({
+ sourceId: row.id,
+ sourceType: 'supplier_invoice',
+ netAmount: Number(row.subtotal ?? 0),
+ description: row.notes,
+ itemDescriptions: itemDescs,
+ _itemDefaultAccount: firstAccount,
+ sourceLabel,
+ periodEnd,
+ })
+ if (s) suggestions.push(s)
+ }
+
+ // Sort by confidence (high first) then by amount desc so the wizard shows
+ // the biggest, most-confident proposals at the top.
+ suggestions.sort((a, b) => {
+ const order: Record = { high: 0, medium: 1, low: 2 }
+ if (order[a.confidence] !== order[b.confidence]) return order[a.confidence] - order[b.confidence]
+ return b.periodisering_amount - a.periodisering_amount
+ })
+
+ return suggestions
+}
diff --git a/lib/bokslut/accruals/date-range-parser.ts b/lib/bokslut/accruals/date-range-parser.ts
new file mode 100644
index 00000000..b28eae8e
--- /dev/null
+++ b/lib/bokslut/accruals/date-range-parser.ts
@@ -0,0 +1,177 @@
+/**
+ * Parses Swedish (and a few mixed Swedish/English) date-range patterns out
+ * of free-text invoice descriptions / line items so the periodisering wizard
+ * can auto-detect supplier invoices and customer invoices whose service
+ * window crosses the fiscal-period boundary.
+ *
+ * The function is intentionally conservative — it only returns a parse when
+ * BOTH a recognizable start and end date are present. Single dates, "från
+ * 2026-01-01" with no end, and malformed strings all return null. The auto-
+ * detect step later sets `confidence: 'low'` when the parser only caught a
+ * partial pattern (currently always "high" since partials return null —
+ * kept as a knob so the wizard UI can downgrade future heuristics without
+ * touching this file).
+ *
+ * Patterns supported (case-insensitive):
+ * 1. ISO : "period: 2026-01-01 till 2027-12-31"
+ * 2. ISO : "perioden 2026-01-01 - 2026-12-31"
+ * 3. Swede : "period: 1 jan 2026 - 31 dec 2026"
+ * 4. Swede : "jan 2026 till dec 2026" (whole-month range, expanded to 1st / last)
+ * 5. yyyy-mm: "2026-01 till 2027-12" (expanded to 1st of start / last of end)
+ * 6. Free : "giltig från 2026-01-01 till 2027-12-31"
+ *
+ * All return ISO `yyyy-mm-dd`. The function does no fiscal logic — it just
+ * reports the parsed window. The caller decides whether endDate > period_end.
+ */
+
+const SWEDISH_MONTHS: Record = {
+ jan: 0,
+ januari: 0,
+ feb: 1,
+ februari: 1,
+ mar: 2,
+ mars: 2,
+ apr: 3,
+ april: 3,
+ maj: 4,
+ jun: 5,
+ juni: 5,
+ jul: 6,
+ juli: 6,
+ aug: 7,
+ augusti: 7,
+ sep: 8,
+ september: 8,
+ okt: 9,
+ oktober: 9,
+ nov: 10,
+ november: 10,
+ dec: 11,
+ december: 11,
+}
+
+const MONTH_NAMES_PATTERN = Object.keys(SWEDISH_MONTHS).join('|')
+
+/** Separator tokens between the two dates: " till ", " - ", "–", "—". */
+const SEP = '(?:\\s*[-–—]\\s*|\\s+till\\s+|\\s+t\\.?\\s*o\\.?\\s*m\\.?\\s+)'
+
+/** ISO date `yyyy-mm-dd`. */
+const ISO = '(\\d{4}-\\d{2}-\\d{2})'
+
+/** yyyy-mm without day. */
+const YM = '(\\d{4}-\\d{2})'
+
+/** Swedish long form "1 jan 2026" — day optional. */
+const SWE_LONG = `(?:(\\d{1,2})\\s+)?(${MONTH_NAMES_PATTERN})\\s+(\\d{4})`
+
+function pad2(n: number): string {
+ return n < 10 ? `0${n}` : String(n)
+}
+
+/** Number of days in a (1-based) month/year, honoring leap years. */
+function daysInMonth(year: number, monthZeroBased: number): number {
+ return new Date(Date.UTC(year, monthZeroBased + 1, 0)).getUTCDate()
+}
+
+function isoFromYMD(year: number, monthZeroBased: number, day: number): string {
+ return `${year}-${pad2(monthZeroBased + 1)}-${pad2(day)}`
+}
+
+/** "2026-01" → "2026-01-01"; "2026-02" with `lastDay=true` → "2026-02-28". */
+function expandYearMonth(ym: string, lastDay: boolean): string | null {
+ const m = /^(\d{4})-(\d{2})$/.exec(ym)
+ if (!m) return null
+ const year = parseInt(m[1], 10)
+ const monthZero = parseInt(m[2], 10) - 1
+ if (monthZero < 0 || monthZero > 11) return null
+ const day = lastDay ? daysInMonth(year, monthZero) : 1
+ return isoFromYMD(year, monthZero, day)
+}
+
+/** Swedish long form ("1 jan 2026" or "jan 2026") → ISO. When the day is
+ * missing, anchor to the 1st (start side) or the last day of the month
+ * (end side). */
+function expandSwedishLong(
+ day: string | undefined,
+ monthName: string,
+ year: string,
+ endSide: boolean,
+): string | null {
+ const monthZero = SWEDISH_MONTHS[monthName.toLowerCase()]
+ if (monthZero === undefined) return null
+ const y = parseInt(year, 10)
+ if (Number.isNaN(y)) return null
+ if (day) {
+ const d = parseInt(day, 10)
+ if (d < 1 || d > daysInMonth(y, monthZero)) return null
+ return isoFromYMD(y, monthZero, d)
+ }
+ return isoFromYMD(y, monthZero, endSide ? daysInMonth(y, monthZero) : 1)
+}
+
+function validateRange(startDate: string, endDate: string): boolean {
+ // endDate must be strictly after startDate. A single date repeated isn't
+ // a range, just a point — the caller should treat that as "no range".
+ return endDate > startDate
+}
+
+export interface ParsedDateRange {
+ startDate: string // ISO yyyy-mm-dd
+ endDate: string // ISO yyyy-mm-dd
+}
+
+/**
+ * Attempt to extract a date range from a free-text description. Returns
+ * null if nothing recognizable is found.
+ */
+export function parseInvoiceDateRange(description: string | null | undefined): ParsedDateRange | null {
+ if (!description) return null
+ const text = description.toLowerCase()
+
+ // 1. ISO–ISO: "2026-01-01 till 2027-12-31", "2026-01-01 - 2027-12-31"
+ const isoRe = new RegExp(`${ISO}${SEP}${ISO}`, 'i')
+ const isoMatch = isoRe.exec(text)
+ if (isoMatch) {
+ const start = isoMatch[1]
+ const end = isoMatch[2]
+ if (isValidIso(start) && isValidIso(end) && validateRange(start, end)) {
+ return { startDate: start, endDate: end }
+ }
+ }
+
+ // 2. Swedish long form on both sides: "1 jan 2026 - 31 dec 2026"
+ const sweRe = new RegExp(`${SWE_LONG}${SEP}${SWE_LONG}`, 'i')
+ const sweMatch = sweRe.exec(text)
+ if (sweMatch) {
+ const startDate = expandSwedishLong(sweMatch[1], sweMatch[2], sweMatch[3], false)
+ const endDate = expandSwedishLong(sweMatch[4], sweMatch[5], sweMatch[6], true)
+ if (startDate && endDate && validateRange(startDate, endDate)) {
+ return { startDate, endDate }
+ }
+ }
+
+ // 3. yyyy-mm on both sides: "2026-01 till 2027-12"
+ // Guarded: don't allow a full ISO date here — anchor to space / start.
+ const ymRe = new RegExp(`(?:^|[^\\d-])${YM}${SEP}${YM}(?![\\d-])`, 'i')
+ const ymMatch = ymRe.exec(text)
+ if (ymMatch) {
+ const startDate = expandYearMonth(ymMatch[1], false)
+ const endDate = expandYearMonth(ymMatch[2], true)
+ if (startDate && endDate && validateRange(startDate, endDate)) {
+ return { startDate, endDate }
+ }
+ }
+
+ return null
+}
+
+function isValidIso(s: string): boolean {
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false
+ const [yStr, mStr, dStr] = s.split('-')
+ const year = parseInt(yStr, 10)
+ const monthZero = parseInt(mStr, 10) - 1
+ const day = parseInt(dStr, 10)
+ if (monthZero < 0 || monthZero > 11) return false
+ if (day < 1 || day > daysInMonth(year, monthZero)) return false
+ return true
+}
diff --git a/lib/bokslut/accruals/templates.ts b/lib/bokslut/accruals/templates.ts
new file mode 100644
index 00000000..9297b70e
--- /dev/null
+++ b/lib/bokslut/accruals/templates.ts
@@ -0,0 +1,163 @@
+import type { AccrualProposal } from './types'
+import {
+ proposeManualAccrued,
+ proposeManualPrepaid,
+ proposeRevenueDeferral,
+ proposeAccruedInterest,
+ proposeAccruedUtility,
+} from './accrual-detector'
+
+/**
+ * Pre-filled patterns the wizard offers as one-click templates. Each maps
+ * to one of the proposeManual* / proposeAccrued* helpers in
+ * accrual-detector.ts. The wizard renders these as "Lägg till" buttons.
+ *
+ * BAS account choices follow the standard 2026 chart:
+ * - 17xx förutbetalda kostnader (prepaid expenses, asset side)
+ * - 29xx upplupna kostnader (accrued expenses + förutbetalda intäkter)
+ * - 2970 förutbetalda intäkter (deferred revenue specifically)
+ *
+ * Adding a new template means: 1) extend this array, 2) add a wrapper in
+ * accrual-detector if needed, 3) the wizard picks it up automatically.
+ */
+
+export type PeriodiseringTemplateKind =
+ | 'prepaid_rent'
+ | 'prepaid_insurance'
+ | 'prepaid_subscription'
+ | 'deferred_revenue'
+ | 'accrued_interest_expense'
+ | 'accrued_utilities'
+
+export interface PeriodiseringTemplate {
+ kind: PeriodiseringTemplateKind
+ /** Swedish label for the wizard card. */
+ name: string
+ /** One-sentence Swedish description / typical use case. */
+ hint: string
+ /** Engine that builds the AccrualProposal. */
+ side: 'prepaid' | 'accrued' | 'deferred_revenue' | 'accrued_interest' | 'accrued_utility'
+ /** Default BAS accounts pre-filled in the form. */
+ prepaid_account?: string
+ expense_account?: string
+ deferred_account?: string
+ revenue_account?: string
+ accrued_account?: string
+}
+
+export const PERIODISERING_TEMPLATES: PeriodiseringTemplate[] = [
+ {
+ kind: 'prepaid_rent',
+ name: 'Förutbetald hyra',
+ hint: 'Hyra som löper över årsskiftet (t.ex. lokalhyra för januari betald i december).',
+ side: 'prepaid',
+ prepaid_account: '1710',
+ expense_account: '5010',
+ },
+ {
+ kind: 'prepaid_insurance',
+ name: 'Förutbetald försäkring',
+ hint: 'Försäkringspremie för kommande räkenskapsår, betald i förskott.',
+ side: 'prepaid',
+ prepaid_account: '1710',
+ expense_account: '6310',
+ },
+ {
+ kind: 'prepaid_subscription',
+ name: 'Förutbetald prenumeration',
+ hint: 'Mjukvara, licens eller medlemskap som löper över årsskiftet.',
+ side: 'prepaid',
+ prepaid_account: '1710',
+ expense_account: '5800',
+ },
+ {
+ kind: 'deferred_revenue',
+ name: 'Förutbetald intäkt',
+ hint: 'Kund har betalat för en tjänst eller prenumeration som avser nästa räkenskapsår.',
+ side: 'deferred_revenue',
+ deferred_account: '2970',
+ revenue_account: '3000',
+ },
+ {
+ kind: 'accrued_interest_expense',
+ name: 'Upplupen ränta',
+ hint: 'Räntekostnad som löpt under perioden men ännu inte fakturerats av banken.',
+ side: 'accrued_interest',
+ accrued_account: '2940',
+ expense_account: '8410',
+ },
+ {
+ kind: 'accrued_utilities',
+ name: 'Upplupna förbrukningar',
+ hint: 'El, vatten, sophämtning eller bredband — kostnaden har uppstått men fakturan dröjer.',
+ side: 'accrued_utility',
+ accrued_account: '2990',
+ expense_account: '5020',
+ },
+]
+
+export interface TemplateApplyParams {
+ amount: number
+ description: string
+ closingDate: string
+ /** Caller-overridable account numbers. Falls back to template defaults. */
+ prepaidAccount?: string
+ expenseAccount?: string
+ deferredAccount?: string
+ revenueAccount?: string
+ accruedAccount?: string
+}
+
+/**
+ * Build an AccrualProposal from a template + caller-supplied amount/desc.
+ * Throws if the template kind is unknown or the resulting accounts violate
+ * the 17xx/29xx range checks in the underlying engine functions.
+ */
+export function applyTemplate(
+ template: PeriodiseringTemplate,
+ params: TemplateApplyParams,
+): AccrualProposal | null {
+ const { amount, description, closingDate } = params
+ switch (template.side) {
+ case 'prepaid':
+ return proposeManualPrepaid({
+ amount,
+ description,
+ closingDate,
+ prepaidAccount: params.prepaidAccount ?? template.prepaid_account!,
+ expenseAccount: params.expenseAccount ?? template.expense_account!,
+ })
+ case 'accrued':
+ return proposeManualAccrued({
+ amount,
+ description,
+ closingDate,
+ accruedAccount: params.accruedAccount ?? template.accrued_account!,
+ expenseAccount: params.expenseAccount ?? template.expense_account!,
+ })
+ case 'deferred_revenue':
+ return proposeRevenueDeferral({
+ amount,
+ description,
+ closingDate,
+ deferredAccount: params.deferredAccount ?? template.deferred_account!,
+ revenueAccount: params.revenueAccount ?? template.revenue_account!,
+ })
+ case 'accrued_interest':
+ return proposeAccruedInterest({
+ amount,
+ description,
+ closingDate,
+ accruedAccount: params.accruedAccount ?? template.accrued_account!,
+ expenseAccount: params.expenseAccount ?? template.expense_account!,
+ })
+ case 'accrued_utility':
+ return proposeAccruedUtility({
+ amount,
+ description,
+ closingDate,
+ accruedAccount: params.accruedAccount ?? template.accrued_account!,
+ expenseAccount: params.expenseAccount ?? template.expense_account!,
+ })
+ }
+}
diff --git a/lib/bokslut/accruals/types.ts b/lib/bokslut/accruals/types.ts
index 8544ff67..9acf71dc 100644
--- a/lib/bokslut/accruals/types.ts
+++ b/lib/bokslut/accruals/types.ts
@@ -6,6 +6,9 @@ export type AccrualKind =
| 'social_fees_on_accrued_salary'
| 'manual_prepaid_expense'
| 'manual_accrued_expense'
+ | 'deferred_revenue'
+ | 'accrued_interest'
+ | 'accrued_utility'
/**
* A single accrual proposal the wizard renders as one card. Mirrors the
diff --git a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts
new file mode 100644
index 00000000..09639d51
--- /dev/null
+++ b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts
@@ -0,0 +1,185 @@
+/**
+ * Snapshot/structure test for the K3 ÅR PDF template. We don't snapshot the
+ * binary output — instead we verify that:
+ * 1. The template renders to a non-empty PDF buffer (no exceptions thrown
+ * by react-pdf — the most common failure when a structural mistake
+ * slips into the layout).
+ * 2. The K3 template can render with minimal / empty kassaflöde and
+ * equity_changes (defensive — the PDF should handle reduced data).
+ *
+ * A full visual snapshot is overkill at this layer; if visual regressions
+ * matter we'll add a Playwright-based screenshot test later.
+ */
+import { describe, it, expect } from 'vitest'
+import { renderToBuffer } from '@react-pdf/renderer'
+import { ArsredovisningK3PDF } from '../arsredovisning-k3-pdf'
+import { ArsredovisningPDF } from '../arsredovisning-pdf'
+import type { ArsredovisningData } from '../types'
+
+function makeMinimalK3Data(): ArsredovisningData {
+ return {
+ company: {
+ name: 'Testbolaget AB',
+ org_number: '556677-8899',
+ city: 'Stockholm',
+ },
+ fiscal_period: {
+ id: 'fp1',
+ name: '2025',
+ period_start: '2025-01-01',
+ period_end: '2025-12-31',
+ },
+ accounting_framework: 'k3',
+ forvaltningsberattelse: {
+ description: 'Bolaget bedriver konsultverksamhet inom IT.',
+ important_events: 'Inga väsentliga händelser.',
+ kontrollbalans_required: false,
+ flerarsoversikt: [
+ { year: '2025', net_revenue: 500_000, result_after_financial: 300_000, soliditet_pct: 80.0 },
+ ],
+ egen_kapital_changes: [
+ { label: '2081 Aktiekapital', amount: 50_000 },
+ { label: '2099 Årets resultat', amount: 300_000 },
+ ],
+ resultatdisposition: 'Styrelsen föreslår att årets resultat balanseras i ny räkning.',
+ agm_date: '2026-06-15',
+ },
+ resultatrakning: [
+ { label: '3001 Försäljning', amount: 500_000 },
+ { label: 'Summa rörelseintäkter', amount: 500_000, is_total: true },
+ { label: '4010 Inköp', amount: -200_000 },
+ { label: 'Rörelseresultat', amount: 300_000, is_total: true },
+ { label: 'Årets resultat', amount: 300_000, is_total: true },
+ ],
+ balansrakning: {
+ assets: [
+ { label: 'Omsättningstillgångar', amount: 600_000, is_total: true, indent: 0 },
+ { label: '1930 Bank', amount: 600_000, indent: 1 },
+ ],
+ total_assets: 600_000,
+ equity_liabilities: [
+ { label: 'Eget kapital', amount: 600_000, is_total: true, indent: 0 },
+ { label: '2081 Aktiekapital', amount: 50_000, indent: 1 },
+ { label: '2099 Årets resultat', amount: 300_000, indent: 1 },
+ { label: '2098 Balanserade vinstmedel', amount: 250_000, indent: 1 },
+ ],
+ total_equity_liabilities: 600_000,
+ },
+ noter: [
+ {
+ number: 1,
+ title: 'Redovisnings- och värderingsprinciper',
+ body: 'Årsredovisningen är upprättad enligt BFNAR 2012:1.',
+ },
+ {
+ number: 2,
+ title: 'Uppskjutna skatter',
+ body: 'Ingående saldo (2240): 50 000 kr\nÅrets förändring (8940): 20 600 kr\nUtgående saldo (2240): 70 600 kr',
+ },
+ {
+ number: 3,
+ title: 'Eventualförpliktelser',
+ body: 'Inga.',
+ },
+ ],
+ kassaflodesanalys: {
+ period_start: '2025-01-01',
+ period_end: '2025-12-31',
+ lopande: {
+ resultat_efter_finansiella_poster: 300_000,
+ avskrivningar: 0,
+ ovriga_ej_kassaflodesposter: 0,
+ delta_kortfristiga_fordringar: 0,
+ delta_varulager: 0,
+ delta_kortfristiga_skulder: 0,
+ skatt_betald: 0,
+ total: 300_000,
+ },
+ investerings: { forvarv_anlaggningar: 0, avyttring_anlaggningar: 0, total: 0 },
+ finansierings: { delta_lan: 0, utdelningar: 0, nyemission: 0, total: 0 },
+ total_cash_flow: 300_000,
+ reconciliation: {
+ opening_cash_1xxx: 300_000,
+ closing_cash_1xxx: 600_000,
+ delta_actual: 300_000,
+ delta_calculated: 300_000,
+ mismatch_amount: 0,
+ is_reconciled: true,
+ },
+ },
+ equity_changes_statement: {
+ rows: [
+ { label: 'Ingående aktiekapital', amount: 50_000 },
+ { label: 'Ingående balanserade vinstmedel', amount: 250_000 },
+ { label: 'Summa ingående eget kapital', amount: 300_000 },
+ { label: 'Årets resultat', amount: 300_000 },
+ { label: 'Summa utgående eget kapital', amount: 600_000 },
+ ],
+ closing_total: 600_000,
+ },
+ signatures: [],
+ warnings: [],
+ }
+}
+
+describe('ArsredovisningK3PDF', () => {
+ it('renders without throwing against a minimal K3 fixture', async () => {
+ const doc = ArsredovisningK3PDF({ data: makeMinimalK3Data() })
+ const buffer = await renderToBuffer(doc)
+ expect(buffer).toBeInstanceOf(Buffer)
+ expect(buffer.length).toBeGreaterThan(0)
+ })
+
+ it('renders with reconciled and unreconciled cash flow', async () => {
+ const data = makeMinimalK3Data()
+ data.kassaflodesanalys!.reconciliation.is_reconciled = false
+ data.kassaflodesanalys!.reconciliation.mismatch_amount = 100
+ const doc = ArsredovisningK3PDF({ data })
+ const buffer = await renderToBuffer(doc)
+ expect(buffer.length).toBeGreaterThan(0)
+ })
+
+ it('renders when kassaflöde + equity_changes are omitted (defensive)', async () => {
+ const data = makeMinimalK3Data()
+ delete data.kassaflodesanalys
+ delete data.equity_changes_statement
+ const doc = ArsredovisningK3PDF({ data })
+ const buffer = await renderToBuffer(doc)
+ expect(buffer.length).toBeGreaterThan(0)
+ })
+
+ it('renders with no signatures (empty array fallback path)', async () => {
+ const data = makeMinimalK3Data()
+ data.signatures = []
+ const doc = ArsredovisningK3PDF({ data })
+ const buffer = await renderToBuffer(doc)
+ expect(buffer.length).toBeGreaterThan(0)
+ })
+
+ it('renders with multiple signatures', async () => {
+ const data = makeMinimalK3Data()
+ data.signatures = [
+ { role: 'Styrelseledamot', name: 'Anna Andersson', signed_at: null },
+ { role: 'Styrelseledamot', name: 'Bo Bengtsson', signed_at: '2026-06-15' },
+ { role: 'VD', name: 'Cecilia Carlsson', signed_at: null },
+ ]
+ const doc = ArsredovisningK3PDF({ data })
+ const buffer = await renderToBuffer(doc)
+ expect(buffer.length).toBeGreaterThan(0)
+ })
+})
+
+describe('ArsredovisningPDF (K2) — byte-equivalence guard', () => {
+ it('K2 PDF still renders the same template (no breaking change from K3 work)', async () => {
+ const data = makeMinimalK3Data()
+ data.accounting_framework = 'k2'
+ // The K2 template is invoked when data.accounting_framework === 'k2' in
+ // the route. It should still render cleanly against the same data shape
+ // (it just ignores the K3-specific fields).
+ delete data.kassaflodesanalys
+ delete data.equity_changes_statement
+ const doc = ArsredovisningPDF({ data })
+ const buffer = await renderToBuffer(doc)
+ expect(buffer.length).toBeGreaterThan(0)
+ })
+})
diff --git a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts
new file mode 100644
index 00000000..bcdea8fb
--- /dev/null
+++ b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3.test.ts
@@ -0,0 +1,390 @@
+/**
+ * Integration tests for the K3 årsredovisning end-to-end:
+ * - buildArsredovisningData produces the K3 noter + kassaflöde + equity
+ * statement when accounting_framework is 'k3'
+ * - K2 byte-equivalence: when framework is 'k2' the existing structure is
+ * unchanged (no kassaflodesanalys, no equity_changes_statement, K2 noter)
+ * - The K3 PDF template renders without errors against the resulting data
+ *
+ * Mocks the three report generators (income statement, balance sheet, trial
+ * balance, kassaflöde) so the test can plant deterministic inputs.
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+vi.mock('@/lib/reports/income-statement', () => ({
+ generateIncomeStatement: vi.fn(),
+}))
+vi.mock('@/lib/reports/balance-sheet', () => ({
+ generateBalanceSheet: vi.fn(),
+}))
+vi.mock('@/lib/reports/trial-balance', () => ({
+ generateTrialBalance: vi.fn(),
+}))
+vi.mock('@/lib/reports/kassaflodesanalys', () => ({
+ generateKassaflodesanalys: vi.fn(),
+}))
+vi.mock('@/lib/bokslut/assets/asset-service', () => ({
+ listAssets: vi.fn().mockResolvedValue([]),
+}))
+vi.mock('@/lib/supabase/fetch-all', () => ({
+ fetchAllRows: vi.fn().mockResolvedValue([]),
+}))
+
+import { buildArsredovisningData } from '../build-data'
+import { generateIncomeStatement } from '@/lib/reports/income-statement'
+import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
+import { generateTrialBalance } from '@/lib/reports/trial-balance'
+import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
+import { listAssets } from '@/lib/bokslut/assets/asset-service'
+
+interface ChainableMock {
+ from: ReturnType
+}
+
+function makeSupabase(opts: {
+ accountingFramework: 'k2' | 'k3'
+ entityType?: string
+ aktiekapital?: number | null
+ agmDate?: string | null
+}): ChainableMock {
+ const from = vi.fn((table: string) => {
+ if (table === 'fiscal_periods') {
+ return {
+ select: () => ({
+ eq: () => ({
+ eq: () => ({
+ single: () =>
+ Promise.resolve({
+ data: {
+ id: 'fp1',
+ name: '2025',
+ period_start: '2025-01-01',
+ period_end: '2025-12-31',
+ previous_period_id: null,
+ closing_entry_id: null,
+ },
+ error: null,
+ }),
+ }),
+ }),
+ }),
+ }
+ }
+ if (table === 'company_settings') {
+ return {
+ select: () => ({
+ eq: () => ({
+ maybeSingle: () =>
+ Promise.resolve({
+ data: {
+ company_name: 'Testbolaget AB',
+ org_number: '556677-8899',
+ address: { city: 'Stockholm' },
+ entity_type: opts.entityType ?? 'aktiebolag',
+ aktiekapital: opts.aktiekapital ?? null,
+ antal_aktier: opts.aktiekapital ? 500 : null,
+ kvotvarde: opts.aktiekapital ? 100 : null,
+ },
+ error: null,
+ }),
+ }),
+ }),
+ }
+ }
+ if (table === 'companies') {
+ return {
+ select: () => ({
+ eq: () => ({
+ maybeSingle: () =>
+ Promise.resolve({
+ data: {
+ entity_type: opts.entityType ?? 'aktiebolag',
+ accounting_framework: opts.accountingFramework,
+ },
+ error: null,
+ }),
+ }),
+ }),
+ }
+ }
+ if (table === 'arsredovisning_narratives') {
+ return {
+ select: () => ({
+ eq: () => ({
+ eq: () => ({
+ maybeSingle: () =>
+ Promise.resolve({
+ data: opts.agmDate
+ ? {
+ agm_date: opts.agmDate,
+ description: null,
+ important_events: null,
+ resultatdisposition: null,
+ }
+ : null,
+ error: null,
+ }),
+ }),
+ }),
+ }),
+ }
+ }
+ if (table === 'employees') {
+ return {
+ select: () => ({
+ eq: () => ({
+ eq: () => Promise.resolve({ count: 0, error: null }),
+ }),
+ }),
+ }
+ }
+ return {
+ select: () => ({
+ eq: () => ({
+ eq: () => ({
+ single: () => Promise.resolve({ data: null, error: null }),
+ maybeSingle: () => Promise.resolve({ data: null, error: null }),
+ }),
+ }),
+ }),
+ }
+ })
+ return { from }
+}
+
+const mockedIncomeStatement = vi.mocked(generateIncomeStatement)
+const mockedBalanceSheet = vi.mocked(generateBalanceSheet)
+const mockedTrialBalance = vi.mocked(generateTrialBalance)
+const mockedKassaflode = vi.mocked(generateKassaflodesanalys)
+const mockedListAssets = vi.mocked(listAssets)
+
+function plantStandardReports() {
+ mockedIncomeStatement.mockResolvedValue({
+ revenue_sections: [
+ {
+ title: 'Rörelsens intäkter',
+ rows: [{ account_number: '3001', account_name: 'Försäljning 25%', amount: 500_000 }],
+ subtotal: 500_000,
+ },
+ ],
+ total_revenue: 500_000,
+ expense_sections: [
+ {
+ title: 'Rörelsens kostnader',
+ rows: [{ account_number: '4010', account_name: 'Inköp material', amount: 200_000 }],
+ subtotal: 200_000,
+ },
+ ],
+ total_expenses: 200_000,
+ financial_sections: [],
+ total_financial: 0,
+ net_result: 300_000,
+ period: { start: '2025-01-01', end: '2025-12-31' },
+ })
+ mockedBalanceSheet.mockResolvedValue({
+ asset_sections: [
+ {
+ title: 'Omsättningstillgångar',
+ rows: [{ account_number: '1930', account_name: 'Bank', amount: 600_000 }],
+ subtotal: 600_000,
+ },
+ ],
+ total_assets: 600_000,
+ equity_liability_sections: [
+ {
+ title: 'Eget kapital',
+ rows: [
+ { account_number: '2081', account_name: 'Aktiekapital', amount: 50_000 },
+ { account_number: '2099', account_name: 'Årets resultat', amount: 300_000 },
+ { account_number: '2098', account_name: 'Balanserade vinstmedel', amount: 250_000 },
+ ],
+ subtotal: 600_000,
+ },
+ ],
+ total_equity_liabilities: 600_000,
+ period: { start: '2025-01-01', end: '2025-12-31' },
+ })
+ mockedTrialBalance.mockResolvedValue({
+ rows: [
+ {
+ account_number: '2240',
+ account_name: 'Uppskjuten skatteskuld',
+ account_class: 2,
+ opening_debit: 0,
+ opening_credit: 50_000,
+ period_debit: 0,
+ period_credit: 20_600,
+ closing_debit: 0,
+ closing_credit: 70_600,
+ },
+ {
+ account_number: '8940',
+ account_name: 'Uppskjuten skatt',
+ account_class: 8,
+ opening_debit: 0,
+ opening_credit: 0,
+ period_debit: 20_600,
+ period_credit: 0,
+ closing_debit: 20_600,
+ closing_credit: 0,
+ },
+ ],
+ totalDebit: 20_600,
+ totalCredit: 20_600,
+ isBalanced: true,
+ })
+ mockedKassaflode.mockResolvedValue({
+ fiscal_period_id: 'fp1',
+ period_start: '2025-01-01',
+ period_end: '2025-12-31',
+ lopande: {
+ resultat_efter_finansiella_poster: 300_000,
+ avskrivningar: 0,
+ ovriga_ej_kassaflodesposter: 0,
+ delta_kortfristiga_fordringar: 0,
+ delta_varulager: 0,
+ delta_kortfristiga_skulder: 0,
+ skatt_betald: 0,
+ total: 300_000,
+ },
+ investerings: {
+ forvarv_anlaggningar: 0,
+ avyttring_anlaggningar: 0,
+ total: 0,
+ },
+ finansierings: {
+ delta_lan: 0,
+ utdelningar: 0,
+ nyemission: 0,
+ total: 0,
+ },
+ total_cash_flow: 300_000,
+ reconciliation: {
+ opening_cash_1xxx: 300_000,
+ closing_cash_1xxx: 600_000,
+ delta_actual: 300_000,
+ delta_calculated: 300_000,
+ mismatch_amount: 0,
+ is_reconciled: true,
+ },
+ })
+ mockedListAssets.mockResolvedValue([])
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ plantStandardReports()
+})
+
+describe('buildArsredovisningData — K3', () => {
+ it('records accounting_framework=k3 in the output', async () => {
+ const supabase = makeSupabase({ accountingFramework: 'k3' })
+ // @ts-expect-error — chainable mock isn't fully typed as SupabaseClient — chainable mock isn't fully typed
+ const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
+ expect(data.accounting_framework).toBe('k3')
+ })
+
+ it('includes a kassaflödesanalys when framework is K3', async () => {
+ const supabase = makeSupabase({ accountingFramework: 'k3' })
+ // @ts-expect-error — chainable mock isn't fully typed as SupabaseClient
+ const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
+ expect(data.kassaflodesanalys).toBeDefined()
+ expect(data.kassaflodesanalys?.total_cash_flow).toBe(300_000)
+ expect(data.kassaflodesanalys?.reconciliation.is_reconciled).toBe(true)
+ })
+
+ it('includes a separate equity_changes_statement when framework is K3', async () => {
+ const supabase = makeSupabase({ accountingFramework: 'k3' })
+ // @ts-expect-error — chainable mock isn't fully typed as SupabaseClient
+ const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
+ expect(data.equity_changes_statement).toBeDefined()
+ expect(data.equity_changes_statement!.rows.length).toBeGreaterThan(0)
+ })
+
+ it('emits the K3-style redovisningsprinciper note with framework citation', async () => {
+ const supabase = makeSupabase({ accountingFramework: 'k3' })
+ // @ts-expect-error — chainable mock isn't fully typed as SupabaseClient
+ const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
+ const principles = data.noter.find((n) => n.title.startsWith('Redovisnings'))
+ expect(principles).toBeDefined()
+ expect(principles!.body).toContain('BFNAR 2012:1')
+ })
+
+ it('emits an "Uppskjutna skatter" note with 2240 movement when balances exist', async () => {
+ const supabase = makeSupabase({ accountingFramework: 'k3' })
+ // @ts-expect-error — chainable mock isn't fully typed as SupabaseClient
+ const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
+ const uppskjuten = data.noter.find((n) => n.title === 'Uppskjutna skatter')
+ expect(uppskjuten).toBeDefined()
+ // Opening 50 000, change +20 600, closing 70 600
+ expect(uppskjuten!.body).toMatch(/Ingående saldo.*50/)
+ expect(uppskjuten!.body).toMatch(/Utgående saldo.*70/)
+ })
+
+ it('emits an Eventualförpliktelser note', async () => {
+ const supabase = makeSupabase({ accountingFramework: 'k3' })
+ // @ts-expect-error — chainable mock isn't fully typed as SupabaseClient
+ const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
+ expect(data.noter.find((n) => n.title === 'Eventualförpliktelser')).toBeDefined()
+ })
+
+ it('emits Väsentliga händelser efter balansdagen for K3', async () => {
+ const supabase = makeSupabase({ accountingFramework: 'k3' })
+ // @ts-expect-error — chainable mock isn't fully typed as SupabaseClient
+ const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
+ expect(
+ data.noter.find((n) => n.title === 'Väsentliga händelser efter balansdagen'),
+ ).toBeDefined()
+ })
+
+ it('DROPS the old "K3 noter need manual augmentation" warning text', async () => {
+ const supabase = makeSupabase({ accountingFramework: 'k3' })
+ // @ts-expect-error — chainable mock isn't fully typed as SupabaseClient
+ const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
+ // The warning should no longer say the K3 noter need manual augmentation
+ expect(
+ data.warnings.find((w) =>
+ /finns ännu inte i mallen och behöver kompletteras manuellt/.test(w),
+ ),
+ ).toBeUndefined()
+ })
+})
+
+describe('buildArsredovisningData — K2 byte-equivalence', () => {
+ it('records accounting_framework=k2', async () => {
+ const supabase = makeSupabase({ accountingFramework: 'k2' })
+ // @ts-expect-error — chainable mock isn't fully typed as SupabaseClient
+ const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
+ expect(data.accounting_framework).toBe('k2')
+ })
+
+ it('OMITS kassaflödesanalys when framework is K2', async () => {
+ const supabase = makeSupabase({ accountingFramework: 'k2' })
+ // @ts-expect-error — chainable mock isn't fully typed as SupabaseClient
+ const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
+ expect(data.kassaflodesanalys).toBeUndefined()
+ })
+
+ it('OMITS equity_changes_statement when framework is K2', async () => {
+ const supabase = makeSupabase({ accountingFramework: 'k2' })
+ // @ts-expect-error — chainable mock isn't fully typed as SupabaseClient
+ const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
+ expect(data.equity_changes_statement).toBeUndefined()
+ })
+
+ it('emits the K2-style redovisningsprinciper note (BFNAR 2016:10)', async () => {
+ const supabase = makeSupabase({ accountingFramework: 'k2' })
+ // @ts-expect-error — chainable mock isn't fully typed as SupabaseClient
+ const data = await buildArsredovisningData(supabase, 'co1', 'fp1')
+ const principles = data.noter.find((n) => n.title.startsWith('Redovisnings'))
+ expect(principles).toBeDefined()
+ expect(principles!.body).toContain('BFNAR 2016:10')
+ })
+
+ it('does NOT call generateKassaflodesanalys for K2', async () => {
+ const supabase = makeSupabase({ accountingFramework: 'k2' })
+ // @ts-expect-error — chainable mock isn't fully typed as SupabaseClient
+ await buildArsredovisningData(supabase, 'co1', 'fp1')
+ expect(mockedKassaflode).not.toHaveBeenCalled()
+ })
+})
diff --git a/lib/bokslut/arsredovisning/__tests__/k3-noter-builder.test.ts b/lib/bokslut/arsredovisning/__tests__/k3-noter-builder.test.ts
new file mode 100644
index 00000000..05b4c071
--- /dev/null
+++ b/lib/bokslut/arsredovisning/__tests__/k3-noter-builder.test.ts
@@ -0,0 +1,385 @@
+import { describe, it, expect } from 'vitest'
+import {
+ anyAssetHasComponents,
+ buildEquityChangesNote,
+ buildK3RedovisningsPrinciper,
+ buildMateriellaAnlaggningsNot,
+ buildUppskjutenSkattNot,
+} from '../k3-noter-builder'
+
+describe('buildK3RedovisningsPrinciper', () => {
+ it('always includes the K3 framework citation and the standard policy paragraphs', () => {
+ const note = buildK3RedovisningsPrinciper(false)
+ expect(note.number).toBe(1)
+ expect(note.title).toBe('Redovisnings- och värderingsprinciper')
+ expect(note.body).toContain('BFNAR 2012:1')
+ expect(note.body).toContain('Uppskjuten skatt')
+ expect(note.body).toContain('Intäktsredovisning')
+ expect(note.body).toContain('Leasing')
+ expect(note.body).toContain('Finansiella instrument')
+ })
+
+ it('OMITS the komponentavskrivning paragraph when no asset has components', () => {
+ const note = buildK3RedovisningsPrinciper(false)
+ expect(note.body).not.toMatch(/Komponentavskrivning/)
+ })
+
+ it('INCLUDES the komponentavskrivning paragraph when an asset has components', () => {
+ const note = buildK3RedovisningsPrinciper(true)
+ expect(note.body).toMatch(/Komponentavskrivning/)
+ expect(note.body).toMatch(/betydande komponenter/)
+ })
+})
+
+describe('buildUppskjutenSkattNot', () => {
+ it('renders opening + change + closing line in the body', () => {
+ const note = buildUppskjutenSkattNot({
+ noteNumber: 4,
+ latentTaxOpening: 50_000,
+ latentTaxChange: 20_600,
+ latentTaxClosing: 70_600,
+ })
+ expect(note.number).toBe(4)
+ expect(note.title).toBe('Uppskjutna skatter')
+ // sv-SE thousand separator uses non-breaking space (\u00A0)
+ expect(note.body).toMatch(/Ingående saldo.*50/)
+ expect(note.body).toMatch(/Årets förändring.*20/)
+ expect(note.body).toMatch(/Utgående saldo.*70/)
+ })
+
+ it('handles zero values without producing NaN strings', () => {
+ const note = buildUppskjutenSkattNot({
+ noteNumber: 2,
+ latentTaxOpening: 0,
+ latentTaxChange: 0,
+ latentTaxClosing: 0,
+ })
+ expect(note.body).not.toMatch(/NaN/)
+ expect(note.body).toMatch(/0 kr/)
+ })
+
+ it('handles a negative change (återföring)', () => {
+ const note = buildUppskjutenSkattNot({
+ noteNumber: 3,
+ latentTaxOpening: 70_600,
+ latentTaxChange: -10_000,
+ latentTaxClosing: 60_600,
+ })
+ // Swedish locale formats negative numbers with the Unicode minus sign
+ // (U+2212), not the ASCII hyphen-minus — match either to be robust.
+ expect(note.body).toMatch(/[-−]10/)
+ expect(note.body).toMatch(/Ingående saldo.*70/)
+ expect(note.body).toMatch(/Utgående saldo.*60/)
+ })
+
+ it('mentions the 20.6% latent tax rate so readers understand the figures', () => {
+ const note = buildUppskjutenSkattNot({
+ noteNumber: 1,
+ latentTaxOpening: 0,
+ latentTaxChange: 0,
+ latentTaxClosing: 0,
+ })
+ expect(note.body).toMatch(/20,6/)
+ })
+})
+
+describe('buildEquityChangesNote', () => {
+ it('reconciles opening + changes to closing total', () => {
+ const result = buildEquityChangesNote({
+ opening: {
+ aktiekapital: 50_000,
+ bundna_reserver: 10_000,
+ balanserade_vinstmedel: 100_000,
+ },
+ changes: {
+ nyemission: 25_000,
+ utdelning: -15_000,
+ arets_resultat: 80_000,
+ },
+ })
+ // 50 000 + 10 000 + 100 000 + 25 000 - 15 000 + 80 000 = 250 000
+ expect(result.closing_total).toBe(250_000)
+ })
+
+ it('emits rows for each opening component plus changes plus closing', () => {
+ const result = buildEquityChangesNote({
+ opening: {
+ aktiekapital: 50_000,
+ bundna_reserver: 0,
+ balanserade_vinstmedel: 100_000,
+ },
+ changes: {
+ nyemission: 25_000,
+ utdelning: -15_000,
+ arets_resultat: 80_000,
+ },
+ })
+ const labels = result.rows.map((r) => r.label)
+ expect(labels).toContain('Ingående aktiekapital')
+ expect(labels).toContain('Ingående balanserade vinstmedel')
+ expect(labels).toContain('Nyemission')
+ expect(labels).toContain('Utdelning')
+ expect(labels).toContain('Årets resultat')
+ expect(labels).toContain('Summa utgående eget kapital')
+ })
+
+ it('OMITS the nyemission row when no nyemission happened (cleaner statement)', () => {
+ const result = buildEquityChangesNote({
+ opening: {
+ aktiekapital: 50_000,
+ bundna_reserver: 0,
+ balanserade_vinstmedel: 100_000,
+ },
+ changes: {
+ nyemission: 0,
+ utdelning: -15_000,
+ arets_resultat: 80_000,
+ },
+ })
+ const labels = result.rows.map((r) => r.label)
+ expect(labels).not.toContain('Nyemission')
+ // Utdelning + årets resultat still present
+ expect(labels).toContain('Utdelning')
+ expect(labels).toContain('Årets resultat')
+ })
+
+ it('OMITS the utdelning row when no utdelning happened', () => {
+ const result = buildEquityChangesNote({
+ opening: {
+ aktiekapital: 50_000,
+ bundna_reserver: 0,
+ balanserade_vinstmedel: 100_000,
+ },
+ changes: {
+ nyemission: 0,
+ utdelning: 0,
+ arets_resultat: 80_000,
+ },
+ })
+ const labels = result.rows.map((r) => r.label)
+ expect(labels).not.toContain('Nyemission')
+ expect(labels).not.toContain('Utdelning')
+ // Årets resultat always shown — even when zero — so the year-end
+ // disposition is visible in the statement.
+ expect(labels).toContain('Årets resultat')
+ })
+
+ it('handles all-zero opening + changes without crashing', () => {
+ const result = buildEquityChangesNote({
+ opening: { aktiekapital: 0, bundna_reserver: 0, balanserade_vinstmedel: 0 },
+ changes: { nyemission: 0, utdelning: 0, arets_resultat: 0 },
+ })
+ expect(result.closing_total).toBe(0)
+ expect(result.rows.length).toBeGreaterThan(0)
+ })
+})
+
+describe('buildMateriellaAnlaggningsNot', () => {
+ it('returns null when there are no tangible assets', () => {
+ const note = buildMateriellaAnlaggningsNot({
+ noteNumber: 5,
+ assets: [],
+ })
+ expect(note).toBeNull()
+ })
+
+ it('returns null when only immaterial assets exist (they belong in a separate note)', () => {
+ const note = buildMateriellaAnlaggningsNot({
+ noteNumber: 5,
+ assets: [
+ {
+ name: 'Software License',
+ category: 'immaterial',
+ acquisition_date: '2025-01-01',
+ acquisition_cost: 50_000,
+ k3_components: null,
+ disposed_at: null,
+ useful_life_months: 60,
+ },
+ ],
+ })
+ expect(note).toBeNull()
+ })
+
+ it('emits the avskrivningstider summary for tangible assets without components', () => {
+ const note = buildMateriellaAnlaggningsNot({
+ noteNumber: 3,
+ assets: [
+ {
+ name: 'Macbook Pro',
+ category: 'computer',
+ acquisition_date: '2025-01-01',
+ acquisition_cost: 30_000,
+ k3_components: null,
+ disposed_at: null,
+ useful_life_months: 36, // 3 år
+ },
+ {
+ name: 'Skrivbord',
+ category: 'equipment',
+ acquisition_date: '2025-01-01',
+ acquisition_cost: 5_000,
+ k3_components: null,
+ disposed_at: null,
+ useful_life_months: 60, // 5 år
+ },
+ ],
+ })
+ expect(note).not.toBeNull()
+ expect(note!.title).toBe('Materiella anläggningstillgångar')
+ expect(note!.body).toMatch(/Datorer: 3 år/)
+ expect(note!.body).toMatch(/Inventarier: 5 år/)
+ // Should NOT mention komponentuppdelning since no components were given
+ expect(note!.body).not.toMatch(/Komponentuppdelning/)
+ })
+
+ it('renders per-component sub-totals when an asset has K3 components', () => {
+ const note = buildMateriellaAnlaggningsNot({
+ noteNumber: 3,
+ assets: [
+ {
+ name: 'Lokalbyggnad Sjövägen 12',
+ category: 'building',
+ acquisition_date: '2020-01-01',
+ acquisition_cost: 5_000_000,
+ k3_components: [
+ {
+ name: 'Stomme',
+ acquisition_cost: 3_000_000,
+ accumulated_depreciation: 300_000,
+ useful_life_months: 600, // 50 år
+ },
+ {
+ name: 'Tak',
+ acquisition_cost: 800_000,
+ accumulated_depreciation: 200_000,
+ useful_life_months: 360, // 30 år
+ },
+ {
+ name: 'Installationer',
+ acquisition_cost: 1_200_000,
+ accumulated_depreciation: 600_000,
+ useful_life_months: 240, // 20 år
+ },
+ ],
+ disposed_at: null,
+ useful_life_months: 600,
+ },
+ ],
+ })
+ expect(note).not.toBeNull()
+ expect(note!.body).toMatch(/Komponentuppdelning/)
+ expect(note!.body).toMatch(/Stomme/)
+ expect(note!.body).toMatch(/Tak/)
+ expect(note!.body).toMatch(/Installationer/)
+ // Per-component totals must reconcile: 3,000,000 + 800,000 + 1,200,000 = 5,000,000
+ // Look for the summary line
+ expect(note!.body).toMatch(/Summa:/)
+ // Total acquisition cost text (with sv-SE separator)
+ // Just confirm 5 000 000 appears as a sub-total somewhere
+ expect(note!.body).toMatch(/5\s?000\s?000/)
+ })
+
+ it('skips disposed assets in the calculation', () => {
+ const note = buildMateriellaAnlaggningsNot({
+ noteNumber: 3,
+ assets: [
+ {
+ name: 'Old Macbook',
+ category: 'computer',
+ acquisition_date: '2020-01-01',
+ acquisition_cost: 20_000,
+ k3_components: null,
+ disposed_at: '2025-06-01',
+ useful_life_months: 36,
+ },
+ ],
+ })
+ expect(note).toBeNull()
+ })
+})
+
+describe('anyAssetHasComponents', () => {
+ it('returns false for empty array', () => {
+ expect(anyAssetHasComponents([])).toBe(false)
+ })
+
+ it('returns false when no asset has a components array', () => {
+ expect(
+ anyAssetHasComponents([
+ {
+ name: 'X',
+ category: 'computer',
+ acquisition_date: '2025-01-01',
+ acquisition_cost: 1000,
+ k3_components: null,
+ disposed_at: null,
+ useful_life_months: 36,
+ },
+ ]),
+ ).toBe(false)
+ })
+
+ it('returns true when at least one asset has a valid components array', () => {
+ expect(
+ anyAssetHasComponents([
+ {
+ name: 'Y',
+ category: 'building',
+ acquisition_date: '2025-01-01',
+ acquisition_cost: 1_000_000,
+ k3_components: [
+ {
+ name: 'Stomme',
+ acquisition_cost: 800_000,
+ accumulated_depreciation: 0,
+ useful_life_months: 600,
+ },
+ ],
+ disposed_at: null,
+ useful_life_months: 600,
+ },
+ ]),
+ ).toBe(true)
+ })
+
+ it('ignores disposed assets when checking for components', () => {
+ expect(
+ anyAssetHasComponents([
+ {
+ name: 'Sold Building',
+ category: 'building',
+ acquisition_date: '2020-01-01',
+ acquisition_cost: 1_000_000,
+ k3_components: [
+ {
+ name: 'Stomme',
+ acquisition_cost: 800_000,
+ accumulated_depreciation: 0,
+ useful_life_months: 600,
+ },
+ ],
+ disposed_at: '2025-06-01',
+ useful_life_months: 600,
+ },
+ ]),
+ ).toBe(false)
+ })
+
+ it('rejects malformed component payloads (typeguard)', () => {
+ expect(
+ anyAssetHasComponents([
+ {
+ name: 'Bad data',
+ category: 'building',
+ acquisition_date: '2025-01-01',
+ acquisition_cost: 1_000_000,
+ // Missing required fields
+ k3_components: [{ name: 'Stomme' }],
+ disposed_at: null,
+ useful_life_months: 600,
+ },
+ ]),
+ ).toBe(false)
+ })
+})
diff --git a/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx b/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx
new file mode 100644
index 00000000..e7f0e8cd
--- /dev/null
+++ b/lib/bokslut/arsredovisning/arsredovisning-k3-pdf.tsx
@@ -0,0 +1,530 @@
+import { Document, Page, Text, View, StyleSheet } from '@react-pdf/renderer'
+import type { ArsredovisningData } from './types'
+
+/**
+ * K3 årsredovisning PDF template (BFNAR 2012:1).
+ *
+ * Layout extends the K2 template with two additional statements required
+ * for K3:
+ * - Kassaflödesanalys (K3 ch.7)
+ * - Förändring av eget kapital (K3 ch.6 — separate statement, not a
+ * förvaltningsberättelse table).
+ *
+ * Page order:
+ * 1. Cover
+ * 2. Förvaltningsberättelse (+ flerårsöversikt + förslag till
+ * resultatdisposition)
+ * 3. Resultaträkning
+ * 4. Balansräkning
+ * 5. Kassaflödesanalys
+ * 6. Förändring av eget kapital
+ * 7+ Noter (paginates automatically — the richer K3 note set rarely fits
+ * on one page so we let @react-pdf wrap)
+ * last. Underskrifter + Fastställelseintyg
+ *
+ * Styling intentionally matches arsredovisning-pdf.tsx so K2 and K3
+ * documents are visually consistent for users that switch between them.
+ */
+const styles = StyleSheet.create({
+ page: {
+ paddingTop: 50,
+ paddingHorizontal: 50,
+ paddingBottom: 60,
+ fontSize: 10,
+ fontFamily: 'Helvetica',
+ },
+ pageHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: 16,
+ fontSize: 8,
+ color: '#555',
+ borderBottomWidth: 0.5,
+ borderBottomColor: '#aaa',
+ paddingBottom: 6,
+ },
+ pageFooter: {
+ position: 'absolute',
+ bottom: 30,
+ left: 50,
+ right: 50,
+ fontSize: 8,
+ color: '#888',
+ textAlign: 'center',
+ },
+ title: {
+ fontSize: 24,
+ fontFamily: 'Helvetica-Bold',
+ marginTop: 40,
+ marginBottom: 10,
+ },
+ subtitle: {
+ fontSize: 12,
+ color: '#444',
+ marginBottom: 6,
+ },
+ k3Banner: {
+ fontSize: 10,
+ color: '#444',
+ marginBottom: 50,
+ paddingTop: 2,
+ },
+ sectionTitle: {
+ fontSize: 13,
+ fontFamily: 'Helvetica-Bold',
+ marginTop: 20,
+ marginBottom: 10,
+ },
+ paragraph: {
+ marginBottom: 8,
+ lineHeight: 1.4,
+ },
+ noteBody: {
+ marginBottom: 4,
+ lineHeight: 1.4,
+ },
+ tableHeader: {
+ flexDirection: 'row',
+ fontFamily: 'Helvetica-Bold',
+ fontSize: 9,
+ borderBottomWidth: 0.5,
+ borderBottomColor: '#888',
+ paddingBottom: 4,
+ marginBottom: 4,
+ },
+ tableRow: {
+ flexDirection: 'row',
+ paddingVertical: 2,
+ },
+ tableRowTotal: {
+ flexDirection: 'row',
+ paddingVertical: 3,
+ borderTopWidth: 0.5,
+ borderTopColor: '#888',
+ fontFamily: 'Helvetica-Bold',
+ },
+ tableRowSubtotal: {
+ flexDirection: 'row',
+ paddingVertical: 3,
+ marginTop: 4,
+ borderTopWidth: 0.5,
+ borderTopColor: '#888',
+ fontFamily: 'Helvetica-Bold',
+ },
+ colLabel: {
+ flex: 1,
+ },
+ colLabelIndent: {
+ flex: 1,
+ paddingLeft: 12,
+ },
+ colAmount: {
+ width: 100,
+ textAlign: 'right',
+ },
+ signatureLine: {
+ flexDirection: 'row',
+ marginTop: 30,
+ alignItems: 'flex-end',
+ },
+ signatureSlot: {
+ flex: 1,
+ marginRight: 20,
+ borderBottomWidth: 0.5,
+ borderBottomColor: '#333',
+ paddingBottom: 2,
+ },
+ reconciliationBlock: {
+ marginTop: 12,
+ padding: 10,
+ borderWidth: 0.5,
+ borderColor: '#888',
+ },
+})
+
+function fmt(amount: number): string {
+ return Math.round(amount).toLocaleString('sv-SE')
+}
+
+function PageChrome({
+ data,
+ pageLabel,
+}: {
+ data: ArsredovisningData
+ pageLabel?: string
+}) {
+ return (
+ <>
+
+
+ {data.company.name} · {data.company.org_number}
+
+ Årsredovisning {data.fiscal_period.name}
+
+
+ {pageLabel ?? ''}
+
+ >
+ )
+}
+
+export function ArsredovisningK3PDF({ data }: { data: ArsredovisningData }) {
+ return (
+
+ {/* Cover */}
+
+
+
+ Årsredovisning
+
+ för räkenskapsåret {data.fiscal_period.period_start} — {data.fiscal_period.period_end}
+
+ Upprättad enligt K3 (BFNAR 2012:1)
+ {data.company.name}
+ Organisationsnummer: {data.company.org_number}
+ {data.company.city && (
+ Säte: {data.company.city}
+ )}
+
+
+
+ {/* Förvaltningsberättelse */}
+
+
+ Förvaltningsberättelse
+
+ Verksamhet
+ {data.forvaltningsberattelse.description}
+
+ Väsentliga händelser under räkenskapsåret
+ {data.forvaltningsberattelse.important_events}
+
+ {data.forvaltningsberattelse.kontrollbalans_required && (
+ <>
+ Kontrollbalansräkning
+
+ Kontrollbalansräkning har upprättats under räkenskapsåret enligt ABL 25 kap.
+
+ >
+ )}
+
+ Flerårsöversikt (kr)
+
+ År
+ Nettoomsättning
+ Resultat e.fin.poster
+ Soliditet (%)
+
+ {data.forvaltningsberattelse.flerarsoversikt.map((row) => (
+
+ {row.year}
+ {fmt(row.net_revenue)}
+ {fmt(row.result_after_financial)}
+
+ {row.soliditet_pct === null ? '—' : row.soliditet_pct.toFixed(1)}
+
+
+ ))}
+
+ Förslag till resultatdisposition
+ {data.forvaltningsberattelse.resultatdisposition}
+
+
+ {/* Resultaträkning */}
+
+
+ Resultaträkning (kr)
+
+ Post
+ {data.fiscal_period.name}
+
+ {data.resultatrakning.map((line, i) => (
+
+ {line.label}
+ {fmt(line.amount)}
+
+ ))}
+
+
+ {/* Balansräkning */}
+
+
+ Tillgångar (kr)
+
+ Post
+ {data.fiscal_period.period_end}
+
+ {data.balansrakning.assets.map((line, i) => (
+
+
+ {line.label}
+
+ {fmt(line.amount)}
+
+ ))}
+
+ Summa tillgångar
+ {fmt(data.balansrakning.total_assets)}
+
+
+ Eget kapital och skulder (kr)
+ {data.balansrakning.equity_liabilities.map((line, i) => (
+
+
+ {line.label}
+
+ {fmt(line.amount)}
+
+ ))}
+
+ Summa eget kapital och skulder
+ {fmt(data.balansrakning.total_equity_liabilities)}
+
+
+
+ {/* Kassaflödesanalys — K3 only. Rendered as a flat list of rows so the
+ page is laid out consistently with the other statements in this
+ template. */}
+ {data.kassaflodesanalys && (
+
+
+ Kassaflödesanalys (kr)
+
+ Indirekt metod enligt BFNAR 2012:1 kap 7.
+
+
+ Den löpande verksamheten
+
+ Resultat efter finansiella poster
+
+ {fmt(data.kassaflodesanalys.lopande.resultat_efter_finansiella_poster)}
+
+
+
+ Justeringar för avskrivningar
+
+ {fmt(data.kassaflodesanalys.lopande.avskrivningar)}
+
+
+
+ Övriga ej-kassaflödespåverkande poster
+
+ {fmt(data.kassaflodesanalys.lopande.ovriga_ej_kassaflodesposter)}
+
+
+
+ Förändring av kortfristiga fordringar
+
+ {fmt(data.kassaflodesanalys.lopande.delta_kortfristiga_fordringar)}
+
+
+
+ Förändring av varulager
+
+ {fmt(data.kassaflodesanalys.lopande.delta_varulager)}
+
+
+
+ Förändring av kortfristiga skulder
+
+ {fmt(data.kassaflodesanalys.lopande.delta_kortfristiga_skulder)}
+
+
+
+ Betald inkomstskatt
+
+ {fmt(data.kassaflodesanalys.lopande.skatt_betald)}
+
+
+
+ Kassaflöde från den löpande verksamheten
+ {fmt(data.kassaflodesanalys.lopande.total)}
+
+
+ Investeringsverksamheten
+
+ Förvärv av anläggningstillgångar
+
+ {fmt(data.kassaflodesanalys.investerings.forvarv_anlaggningar)}
+
+
+
+ Avyttring av anläggningstillgångar
+
+ {fmt(data.kassaflodesanalys.investerings.avyttring_anlaggningar)}
+
+
+
+ Kassaflöde från investeringsverksamheten
+
+ {fmt(data.kassaflodesanalys.investerings.total)}
+
+
+
+ Finansieringsverksamheten
+
+ Förändring av lån (långfristiga skulder)
+
+ {fmt(data.kassaflodesanalys.finansierings.delta_lan)}
+
+
+
+ Utdelningar till ägare
+
+ {fmt(data.kassaflodesanalys.finansierings.utdelningar)}
+
+
+
+ Nyemission
+
+ {fmt(data.kassaflodesanalys.finansierings.nyemission)}
+
+
+
+ Kassaflöde från finansieringsverksamheten
+
+ {fmt(data.kassaflodesanalys.finansierings.total)}
+
+
+
+
+ Årets kassaflöde
+ {fmt(data.kassaflodesanalys.total_cash_flow)}
+
+
+
+
+ Avstämning mot likvida medel (19xx)
+
+
+ Ingående saldo
+
+ {fmt(data.kassaflodesanalys.reconciliation.opening_cash_1xxx)}
+
+
+
+ Utgående saldo
+
+ {fmt(data.kassaflodesanalys.reconciliation.closing_cash_1xxx)}
+
+
+
+ Faktisk förändring
+
+ {fmt(data.kassaflodesanalys.reconciliation.delta_actual)}
+
+
+ {!data.kassaflodesanalys.reconciliation.is_reconciled && (
+
+
+ Avvikelse — kontrollera bokföringen
+
+
+ {fmt(data.kassaflodesanalys.reconciliation.mismatch_amount)}
+
+
+ )}
+
+
+ )}
+
+ {/* Förändring av eget kapital — K3 separate statement */}
+ {data.equity_changes_statement && (
+
+
+ Förändring av eget kapital (kr)
+ {data.equity_changes_statement.rows.map((row, i) => {
+ // Heuristic: "Summa" rows are subtotals/totals; render them
+ // with the totals style so the layout reads like a financial
+ // statement.
+ const isTotal = row.label.startsWith('Summa')
+ return (
+
+ {row.label}
+ {fmt(row.amount)}
+
+ )
+ })}
+
+ )}
+
+ {/* Noter */}
+
+
+ Noter
+ {data.noter.map((note) => (
+
+
+ Not {note.number} — {note.title}
+
+ {note.body}
+
+ ))}
+
+
+ {/* Underskrifter */}
+
+
+ Underskrifter
+
+ {data.company.city ? `${data.company.city}, ` : ''}
+ {data.fiscal_period.period_end}
+
+ {(data.signatures.length > 0
+ ? data.signatures
+ : [
+ { role: 'Styrelseledamot', name: '', signed_at: null },
+ { role: 'Styrelseledamot', name: '', signed_at: null },
+ ]
+ ).map((sig, i) => (
+
+
+ {sig.name || ' '}
+
+ {sig.role}
+
+ ))}
+
+
+ {/*
+ Fastställelseintyg — mirrors the K2 template. K3 documents face the
+ same Bolagsverket filing requirement (ÅRL 8 kap 3 §). Signer label
+ remains "Styrelseledamot (närvarande vid stämman)".
+ */}
+
+
+ Fastställelseintyg
+
+ Undertecknad styrelseledamot, närvarande vid årsstämman, intygar härmed
+ att resultaträkningen och balansräkningen har fastställts på årsstämma
+ den {data.forvaltningsberattelse.agm_date ?? '____________________'} och
+ att årsstämman beslutade om disposition av bolagets resultat i enlighet
+ med vad som anges nedan.
+
+
+ Jag intygar också att årsredovisningen ger en rättvisande bild av
+ företagets ställning och resultat samt att förvaltningsberättelsen ger
+ en rättvisande översikt över utvecklingen av företagets verksamhet,
+ ställning och resultat.
+
+ Stämmans beslut om resultatdisposition
+
+ {data.forvaltningsberattelse.resultatdisposition}
+
+
+
+
+
+ Styrelseledamot (närvarande vid stämman)
+
+
+ {data.company.city ? `${data.company.city}, ` : ''}
+ datum: {data.forvaltningsberattelse.agm_date ?? '____________________'}
+
+
+
+ )
+}
diff --git a/lib/bokslut/arsredovisning/build-data.ts b/lib/bokslut/arsredovisning/build-data.ts
index bb7ac655..a1253f41 100644
--- a/lib/bokslut/arsredovisning/build-data.ts
+++ b/lib/bokslut/arsredovisning/build-data.ts
@@ -2,9 +2,18 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { generateBalanceSheet } from '@/lib/reports/balance-sheet'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
+import { generateKassaflodesanalys } from '@/lib/reports/kassaflodesanalys'
import { listAssets } from '@/lib/bokslut/assets/asset-service'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
+import { LATENT_TAX_DEFAULT_RATE } from '@/lib/bokslut/tax-provision/latent-tax-calculator'
import { getNarrative } from './narrative-service'
+import {
+ anyAssetHasComponents,
+ buildEquityChangesNote,
+ buildK3RedovisningsPrinciper,
+ buildMateriellaAnlaggningsNot,
+ buildUppskjutenSkattNot,
+} from './k3-noter-builder'
import type {
ArsredovisningData,
EgenKapitalRow,
@@ -12,8 +21,14 @@ import type {
IncomeStatementLine,
BalanceSheetLine,
NoteEntry,
+ KassaflodesAnalysisSummary,
} from './types'
-import type { BalanceSheetSection, IncomeStatementSection } from '@/types'
+import type {
+ AccountingFramework,
+ Asset,
+ BalanceSheetSection,
+ IncomeStatementSection,
+} from '@/types'
/**
* Pre-populate the K2 årsredovisning data for a fiscal period. Loads:
@@ -34,7 +49,7 @@ export async function buildArsredovisningData(
fiscalPeriodId: string,
overrides: Partial = {},
): Promise {
- const [periodResult, settingsResult, periodList, incomeStatement, balanceSheet, narrative] = await Promise.all([
+ const [periodResult, settingsResult, companyResult, periodList, incomeStatement, balanceSheet, narrative] = await Promise.all([
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, previous_period_id, closing_entry_id')
@@ -46,6 +61,14 @@ export async function buildArsredovisningData(
.select('company_name, org_number, address, entity_type')
.eq('company_id', companyId)
.maybeSingle(),
+ // Source-of-truth for entity_type and accounting_framework lives on
+ // companies. company_settings.entity_type is a legacy mirror; the
+ // framework column was added later and only exists on companies.
+ supabase
+ .from('companies')
+ .select('entity_type, accounting_framework')
+ .eq('id', companyId)
+ .maybeSingle(),
fetchAllRows(({ from, to }) =>
supabase
.from('fiscal_periods')
@@ -68,13 +91,23 @@ export async function buildArsredovisningData(
}
const period = periodResult.data
const settings = settingsResult.data
+ const companyRow = companyResult.data as
+ | { entity_type?: string | null; accounting_framework?: AccountingFramework | null }
+ | null
const companyName = settings?.company_name ?? 'Bolaget'
const orgNumber = settings?.org_number ?? ''
// Default to 'unknown' (not 'aktiebolag') when entity_type isn't set —
// otherwise the K2 guard in buildK2Noter would claim K2 for every
// unconfigured company, which is exactly the false-assertion the guard
- // was added to prevent.
- const entityType = (settings as { entity_type?: string } | null)?.entity_type ?? 'unknown'
+ // was added to prevent. Prefer the companies row over company_settings
+ // since the multi-tenant refactor made companies the source of truth.
+ const entityType =
+ companyRow?.entity_type
+ ?? (settings as { entity_type?: string } | null)?.entity_type
+ ?? 'unknown'
+ // K3 is opt-in; only AB ever set it. Default to K2 when not set.
+ const accountingFramework: AccountingFramework =
+ companyRow?.accounting_framework === 'k3' ? 'k3' : 'k2'
type AddressShape = { city?: string | null; postal_city?: string | null } | null
const addressUnknown = (settings as { address?: AddressShape } | null)?.address ?? null
@@ -92,15 +125,61 @@ export async function buildArsredovisningData(
companyId,
fiscalPeriodId,
(periodList ?? []) as Array<{ id: string; name: string; period_start: string; period_end: string }>,
+ accountingFramework,
)
const egen_kapital_changes = buildEquityChanges(balanceSheet.equity_liability_sections)
- const { notes: noter, warnings: noterWarnings } = await buildK2Noter(
- supabase,
- companyId,
- entityType,
- )
+ // K3 vs K2 split: K3 has a richer note set + a kassaflöde + a separate
+ // equity-changes statement. The 18a/b warning that flagged "K3 noter not
+ // yet emitted" is removed below now that we actually emit them.
+ const { notes: noter, warnings: noterWarnings } =
+ accountingFramework === 'k3'
+ ? await buildK3Noter(supabase, companyId, fiscalPeriodId, entityType, period.period_end)
+ : await buildK2Noter(supabase, companyId, entityType)
+
+ // Kassaflödesanalys + separate equity-changes statement — K3 only. K2
+ // mindre företag is exempt from kassaflödesanalys (BFNAR 2016:10 punkt
+ // 5.2) and keeps equity changes inside förvaltningsberättelsen.
+ let kassaflodesanalys: KassaflodesAnalysisSummary | undefined
+ let equity_changes_statement:
+ | { rows: EgenKapitalRow[]; closing_total: number }
+ | undefined
+ if (accountingFramework === 'k3') {
+ try {
+ const cashFlow = await generateKassaflodesanalys(
+ supabase,
+ companyId,
+ fiscalPeriodId,
+ )
+ // Strip fiscal_period_id from the embedded report — period info is
+ // already on ArsredovisningData.fiscal_period; carrying it twice in
+ // the payload would be redundant.
+ kassaflodesanalys = {
+ period_start: cashFlow.period_start,
+ period_end: cashFlow.period_end,
+ lopande: cashFlow.lopande,
+ investerings: cashFlow.investerings,
+ finansierings: cashFlow.finansierings,
+ total_cash_flow: cashFlow.total_cash_flow,
+ reconciliation: cashFlow.reconciliation,
+ }
+ } catch {
+ // A partial SIE import can leave 1xxx without an IB row — the report
+ // throws. Surface as a warning instead of blocking the whole ÅR.
+ noterWarnings.push(
+ 'Kassaflödesanalysen kunde inte genereras automatiskt. Kontrollera att ingående och utgående saldo på 19xx finns och kör om bokslutet.',
+ )
+ }
+
+ // Equity-changes statement — derived from the saved equity rows + this
+ // year's resultat. We reuse buildEquityChangesNote's roll-forward to
+ // keep one source of truth for the closing total.
+ equity_changes_statement = buildK3EquityChangesStatement(
+ balanceSheet.equity_liability_sections,
+ incomeStatement.net_result,
+ )
+ }
const resultatrakning = flattenIncomeStatement(incomeStatement)
const balansrakning = flattenBalanceSheet(balanceSheet)
@@ -111,6 +190,17 @@ export async function buildArsredovisningData(
'Den här årsredovisningen genereras med K2-mallen (BFNAR 2016:10) som standard. För K3- eller annan företagsform kan strukturen behöva justeras manuellt innan inlämning.',
)
}
+ if (entityType === 'aktiebolag' && accountingFramework === 'k3') {
+ // Soliditet now reflects the K3 split (79,4 % equity portion of 21xx is
+ // folded into eget kapital). 18e/f provides the K3 noter, kassaflöde
+ // and separate equity-changes statement so the PDF is now substantively
+ // K3-compliant; we keep a soft notice here so the filer remembers to
+ // verify the document against their specific obligations before sending
+ // to Bolagsverket.
+ warnings.push(
+ 'Bolaget redovisar enligt K3 (BFNAR 2012:1). Soliditeten är beräknad med 79,4 % av obeskattade reserver inräknat i eget kapital. PDF:en innehåller kassaflödesanalys, förändring av eget kapital och utökade noter — granska innehållet mot er specifika redovisning innan inlämning.',
+ )
+ }
if (entityType === 'unknown') {
warnings.push(
'Företagsform saknas i inställningarna — fyll i Inställningar → Företag för att få rätt redovisningsprinciper i not 1.',
@@ -154,6 +244,7 @@ export async function buildArsredovisningData(
period_start: period.period_start,
period_end: period.period_end,
},
+ accounting_framework: accountingFramework,
forvaltningsberattelse: {
description:
overrides.description ??
@@ -176,6 +267,8 @@ export async function buildArsredovisningData(
warnings,
balansrakning,
noter,
+ kassaflodesanalys,
+ equity_changes_statement,
signatures: [], // populated by signature-flow service in a later phase step
}
}
@@ -192,6 +285,7 @@ async function buildFlerarsoversikt(
companyId: string,
currentPeriodId: string,
allPeriods: PeriodRow[],
+ accountingFramework: AccountingFramework,
): Promise {
// Take the current period + 3 prior (oldest first).
const sorted = [...allPeriods].sort((a, b) => a.period_start.localeCompare(b.period_start))
@@ -215,19 +309,28 @@ async function buildFlerarsoversikt(
const eqLiab = tb.rows
.filter((r) => r.account_class === 2)
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
- // Soliditet: eget kapital uses 20xx ONLY. 21xx (periodiseringsfonder,
- // överavskrivningar) are obeskattade reserver — partially deferred tax,
- // not equity. K2 / ÅRL splits them out. Including 21xx here would
- // inflate soliditet for any AB that posts dispositions.
+ // Soliditet differs by framework:
+ // K2 (ÅRL / BFNAR 2016:10): 20xx only. 21xx (periodiseringsfonder,
+ // överavskrivningar) are obeskattade reserver — partially deferred
+ // tax, not equity. Including 21xx would inflate soliditet for any AB
+ // that posts dispositions.
//
- // K3 NOTE: K3 (BFNAR 2012:1) requires the 79,4% equity portion of
- // obeskattade reserver to be folded into eget kapital and the 20,6%
- // latent skatteskuld to be split out separately. When K3 support
- // lands this filter must branch on the company's framework — for now
- // we treat every entity as K2 / consistent-with-K2.
- const equity = tb.rows
+ // K3 (BFNAR 2012:1) splits 21xx into 79,4 % equity + 20,6 % latent
+ // skatteskuld. Account 2240 holds the latent tax liability and is
+ // already classified as a liability via class 2 / account_group 22,
+ // so the soliditet add-on is just the equity portion of 21xx. (We
+ // do NOT double-count 2240 here — the trial balance row for 2240
+ // already lives in eqLiab as a liability.)
+ const baseEquity = tb.rows
.filter((r) => r.account_number.startsWith('20'))
.reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
+ let equity = baseEquity
+ if (accountingFramework === 'k3') {
+ const obeskattadeReserver = tb.rows
+ .filter((r) => r.account_number.startsWith('21'))
+ .reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
+ equity += obeskattadeReserver * (1 - LATENT_TAX_DEFAULT_RATE)
+ }
const soliditet =
totalAssets > 0 ? Math.round((equity / totalAssets) * 1000) / 10 : null
// Avoid the unused-variable warning while leaving eqLiab computed for
@@ -390,6 +493,262 @@ async function buildK2Noter(
return { notes, warnings }
}
+/**
+ * Build the K3 note set (BFNAR 2012:1). Differs from K2 in:
+ * - Verbose redovisningsprinciper covering all K3 measurement principles
+ * - A separate "Uppskjutna skatter" note showing 2240 movement
+ * - "Materiella anläggningstillgångar" with per-component breakdown when
+ * komponentavskrivning is used
+ * - Standard K3 placeholders for händelser efter balansdagen +
+ * eventualförpliktelser
+ *
+ * The aktiekapital note is shared with K2 logic — K3 punkt 18.x also
+ * mandates the share-capital disclosure for AB.
+ */
+async function buildK3Noter(
+ supabase: SupabaseClient,
+ companyId: string,
+ fiscalPeriodId: string,
+ entityType: string,
+ periodEndIso: string,
+): Promise<{ notes: NoteEntry[]; warnings: string[] }> {
+ const notes: NoteEntry[] = []
+ const warnings: string[] = []
+
+ // 1. Redovisningsprinciper. We check whether any asset has K3 components
+ // configured so the principles paragraph only mentions komponentavskrivning
+ // when it's actually in use.
+ //
+ // The stored K3 component shape on assets is
+ // { name, cost, useful_life_months, salvage_value? }
+ // (per migration 20260526122000_k3_component_depreciation.sql), but the
+ // note builder consumes
+ // { name, acquisition_cost, accumulated_depreciation, useful_life_months }
+ // We compute accumulated_depreciation here using a linear approximation
+ // (months elapsed / useful life) which matches what the per-component
+ // depreciation engine (computeComponentDepreciation) produces over a year.
+ // The fiscal period end is the as-of date for the depreciation snapshot.
+ const assets = (await listAssets(supabase, companyId)) as Asset[]
+ const monthsBetween = (fromIso: string, toIso: string): number => {
+ const from = new Date(`${fromIso}T00:00:00Z`)
+ const to = new Date(`${toIso}T00:00:00Z`)
+ if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) return 0
+ const years = to.getUTCFullYear() - from.getUTCFullYear()
+ const months = to.getUTCMonth() - from.getUTCMonth()
+ const days = to.getUTCDate() - from.getUTCDate()
+ let total = years * 12 + months
+ if (days < 0) total -= 1
+ return total
+ }
+ const adaptAsset = (a: Asset) => ({
+ name: a.name,
+ category: a.category,
+ acquisition_date: a.acquisition_date,
+ acquisition_cost: a.acquisition_cost,
+ k3_components: Array.isArray(a.k3_components)
+ ? a.k3_components.map((c) => {
+ const cost = Number(c.cost) || 0
+ const salvage = Number(c.salvage_value ?? 0) || 0
+ const life = Number(c.useful_life_months) || 0
+ const elapsed = Math.max(
+ 0,
+ Math.min(life, monthsBetween(a.acquisition_date, periodEndIso)),
+ )
+ const accumulated = life > 0
+ ? Math.round(((cost - salvage) * elapsed) / life)
+ : 0
+ return {
+ name: c.name,
+ acquisition_cost: cost,
+ accumulated_depreciation: accumulated,
+ useful_life_months: life,
+ }
+ })
+ : null,
+ disposed_at: a.disposed_at,
+ useful_life_months: a.useful_life_months,
+ })
+ const adaptedAssets = assets.map(adaptAsset)
+ const hasComponents = anyAssetHasComponents(adaptedAssets)
+ notes.push(buildK3RedovisningsPrinciper(hasComponents))
+
+ // 2. Aktiekapital (shared with K2 logic — K3 punkt 18.x mandates the same
+ // disclosure for AB).
+ const isAb = entityType === 'aktiebolag'
+ const maybeAb = isAb || entityType === 'unknown'
+ if (maybeAb) {
+ const { data: settings } = await supabase
+ .from('company_settings')
+ .select('aktiekapital, antal_aktier, kvotvarde')
+ .eq('company_id', companyId)
+ .maybeSingle()
+ type AktiekapitalShape = {
+ aktiekapital?: number | null
+ antal_aktier?: number | null
+ kvotvarde?: number | null
+ }
+ const ak = settings as AktiekapitalShape | null
+ const aktiekapital = ak?.aktiekapital ?? null
+ const antalAktier = ak?.antal_aktier ?? null
+ const kvotvarde = ak?.kvotvarde ?? null
+ if (aktiekapital || antalAktier) {
+ const parts: string[] = []
+ if (aktiekapital) parts.push(`Aktiekapital: ${aktiekapital.toLocaleString('sv-SE')} kr.`)
+ if (antalAktier) parts.push(`Antal aktier: ${antalAktier.toLocaleString('sv-SE')}.`)
+ if (kvotvarde) parts.push(`Kvotvärde per aktie: ${kvotvarde.toLocaleString('sv-SE')} kr.`)
+ notes.push({
+ number: notes.length + 1,
+ title: 'Aktiekapital',
+ body: parts.join(' '),
+ })
+ } else if (isAb) {
+ warnings.push(
+ 'Aktiekapitalnoten saknas eftersom uppgifter om aktiekapital inte finns i Inställningar → Företag. K3 / ÅRL kräver att noten innehåller registrerat belopp innan inlämning till Bolagsverket.',
+ )
+ }
+ }
+
+ // 3. Materiella anläggningstillgångar — with optional per-component
+ // breakdown. The note is omitted when no tangible assets exist. Uses the
+ // adapted asset list computed above so the K3-component shape matches what
+ // the builder's type guard expects.
+ const materialiNote = buildMateriellaAnlaggningsNot({
+ noteNumber: notes.length + 1,
+ assets: adaptedAssets,
+ })
+ if (materialiNote) notes.push(materialiNote)
+
+ // 4. Uppskjutna skatter. K3 ch.29 requires disclosure of opening,
+ // movement, and closing balance of uppskjuten skatteskuld. We derive
+ // these from the trial balance for 2240 (latent tax liability) and
+ // 8940 (latent tax expense).
+ try {
+ const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
+ const row2240 = rows.find((r) => r.account_number === '2240')
+ const row8940 = rows.find((r) => r.account_number === '8940')
+ // 2240 is credit-normal liability: opening = opening_credit - opening_debit
+ const opening2240 = row2240
+ ? (row2240.opening_credit || 0) - (row2240.opening_debit || 0)
+ : 0
+ const closing2240 = row2240
+ ? (row2240.closing_credit || 0) - (row2240.closing_debit || 0)
+ : 0
+ // 8940 is an expense (debit-normal): movement = period_debit - period_credit
+ // A positive movement = additional avsättning (cost incurred = liability
+ // grew). The 2240 balance moves by the same magnitude (with opposite
+ // sign convention since 2240 is on the credit side).
+ const change8940 = row8940
+ ? (row8940.period_debit || 0) - (row8940.period_credit || 0)
+ : closing2240 - opening2240
+ if (opening2240 !== 0 || closing2240 !== 0 || change8940 !== 0) {
+ notes.push(
+ buildUppskjutenSkattNot({
+ noteNumber: notes.length + 1,
+ latentTaxOpening: opening2240,
+ latentTaxChange: change8940,
+ latentTaxClosing: closing2240,
+ }),
+ )
+ }
+ } catch {
+ // Trial-balance failure should not block the document; flag as warning.
+ warnings.push(
+ 'Uppskjutna skatter-noten kunde inte beräknas automatiskt. Kontrollera kontot 2240 och kör om bokslutet.',
+ )
+ }
+
+ // 5. Medelantal anställda
+ const { count: employeeCount } = await supabase
+ .from('employees')
+ .select('id', { count: 'exact', head: true })
+ .eq('company_id', companyId)
+ .eq('is_active', true)
+ if ((employeeCount ?? 0) > 0) {
+ notes.push({
+ number: notes.length + 1,
+ title: 'Medelantal anställda',
+ body: `Under räkenskapsåret har medeltalet anställda uppgått till ${employeeCount}.`,
+ })
+ }
+
+ // 6. Eventualförpliktelser (K3 punkt 21 — separate disclosure).
+ notes.push({
+ number: notes.length + 1,
+ title: 'Eventualförpliktelser',
+ body: 'Inga.',
+ })
+
+ // 7. Ställda säkerheter
+ notes.push({
+ number: notes.length + 1,
+ title: 'Ställda säkerheter',
+ body: 'Inga.',
+ })
+
+ // 8. Väsentliga händelser efter balansdagen (K3 ch.32)
+ notes.push({
+ number: notes.length + 1,
+ title: 'Väsentliga händelser efter balansdagen',
+ body: 'Inga väsentliga händelser har inträffat efter räkenskapsårets utgång som påverkar bedömningen av företagets ställning och resultat.',
+ })
+
+ return { notes, warnings }
+}
+
+/**
+ * K3 separate "Förändring av eget kapital" statement. Reads opening balances
+ * from the K3 balance sheet's equity section (account ranges per BAS):
+ * - 2081 (aktiekapital) → opening aktiekapital
+ * - 2085-2089 (övriga bundna reserver) → bundna_reserver
+ * - 2090-2099 (balanserade vinstmedel + årets resultat) → fritt eget kapital
+ *
+ * Year movements (nyemission, utdelning) aren't trivially derivable from
+ * closing balances alone — they require movement analysis. v1 reports the
+ * year's net result and leaves nyemission/utdelning at 0; future iterations
+ * can extract these from journal entries on specific accounts.
+ */
+function buildK3EquityChangesStatement(
+ sections: BalanceSheetSection[],
+ netResult: number,
+): { rows: EgenKapitalRow[]; closing_total: number } {
+ // Closing balance from BS — we approximate opening = closing - net result,
+ // which is exact when no equity movements happened outside årets resultat.
+ // For nyemission/utdelning the user can edit the equity-change narrative
+ // in a future enhancement.
+ let aktiekapitalClosing = 0
+ let bundnaClosing = 0
+ let fritProtClosing = 0
+ for (const section of sections) {
+ for (const row of section.rows) {
+ const num = row.account_number
+ // BAS 2081-2084 = aktiekapital + medlemsinsatser
+ // BAS 2085-2087 = bundna reserver (uppskrivningsfond, reservfond, bundna fonder)
+ // BAS 2090-2099 = fritt eget kapital (including årets resultat 2099)
+ if (num >= '2081' && num <= '2084') {
+ aktiekapitalClosing += row.amount
+ } else if (num >= '2085' && num <= '2087') {
+ bundnaClosing += row.amount
+ } else if (num.startsWith('209')) {
+ fritProtClosing += row.amount
+ }
+ }
+ }
+ // Opening fritt eget kapital = closing − net result (årets resultat
+ // already lives in 2099 at closing).
+ const opening = {
+ aktiekapital: Math.round(aktiekapitalClosing * 100) / 100,
+ bundna_reserver: Math.round(bundnaClosing * 100) / 100,
+ balanserade_vinstmedel:
+ Math.round((fritProtClosing - netResult) * 100) / 100,
+ }
+ const changes = {
+ nyemission: 0,
+ utdelning: 0,
+ arets_resultat: Math.round(netResult * 100) / 100,
+ }
+ return buildEquityChangesNote({ opening, changes })
+}
+
function flattenIncomeStatement(is: {
revenue_sections: IncomeStatementSection[]
total_revenue: number
diff --git a/lib/bokslut/arsredovisning/k3-noter-builder.ts b/lib/bokslut/arsredovisning/k3-noter-builder.ts
new file mode 100644
index 00000000..dfe0a6df
--- /dev/null
+++ b/lib/bokslut/arsredovisning/k3-noter-builder.ts
@@ -0,0 +1,344 @@
+import type {
+ EgenKapitalRow,
+ IncomeStatementLine,
+ NoteEntry,
+} from './types'
+
+/**
+ * K3 noter builder (BFNAR 2012:1).
+ *
+ * K3 requires a richer note set than K2:
+ * - Verbose redovisningsprinciper covering komponentavskrivning (when used),
+ * uppskjuten skatt, intäktsredovisning, leasing och finansiella instrument.
+ * - A separate "Uppskjutna skatter" note showing the latent-tax balance
+ * movement (2240 ingående/utgående saldo + årets förändring posted to 8940).
+ * - "Förändring av eget kapital" presented as a SEPARATE statement (not just
+ * a förvaltningsberättelse line — see ÅRL 6:5 + BFNAR 2012:1 ch.6).
+ * - When component depreciation is used, the materiella anläggnings-not must
+ * break out anskaffningsvärden, avskrivningar och bokfört värde per
+ * huvudkomponent.
+ *
+ * The functions in this file are pure: they take pre-computed numbers and
+ * return the note structures. The caller (buildArsredovisningData) is
+ * responsible for fetching the inputs from the database. This keeps the
+ * functions trivially unit-testable.
+ */
+
+// ─── Redovisningsprinciper ────────────────────────────────────────────────
+
+/**
+ * K3 redovisningsprinciper note body. More verbose than K2 — K3 punkt 2.6 +
+ * ch.3 require disclosure of each accounting policy that affects the
+ * reporting, including measurement bases for fixed assets, depreciation
+ * approach, deferred tax treatment, revenue recognition, leasing och
+ * financial instruments.
+ *
+ * @param hasComponents — when true, includes the komponentavskrivning
+ * paragraph. K3 ch.17.4 makes component depreciation mandatory when the
+ * components have meaningfully different useful lives; otherwise the
+ * paragraph would be misleading and is omitted.
+ */
+export function buildK3RedovisningsPrinciper(
+ hasComponents: boolean,
+): NoteEntry {
+ const paragraphs: string[] = [
+ 'Årsredovisningen är upprättad i enlighet med Årsredovisningslagen (1995:1554) och Bokföringsnämndens allmänna råd BFNAR 2012:1 Årsredovisning och koncernredovisning (K3).',
+ 'Värderingsprinciper: Tillgångar och skulder värderas till anskaffningsvärde om inget annat anges. Materiella anläggningstillgångar redovisas till anskaffningsvärde med avdrag för ackumulerade avskrivningar och eventuella nedskrivningar. Avskrivning sker linjärt över tillgångens bedömda nyttjandeperiod.',
+ ]
+ if (hasComponents) {
+ paragraphs.push(
+ 'Komponentavskrivning: Materiella anläggningstillgångar med betydande komponenter som har väsentligt olika nyttjandeperioder delas upp och varje komponent skrivs av separat. Anskaffningsvärdet fördelas på komponenterna baserat på relativ andel av tillgångens värde.',
+ )
+ }
+ paragraphs.push(
+ 'Uppskjuten skatt: Uppskjuten skatt redovisas enligt balansräkningsmetoden för temporära skillnader mellan redovisade och skattemässiga värden på tillgångar och skulder. Uppskjuten skatt värderas till nominellt belopp utan diskontering och beräknas utifrån den skattesats som är beslutad på balansdagen.',
+ 'Intäktsredovisning: Intäkter redovisas till det verkliga värdet av det som erhållits eller kommer att erhållas och redovisas när väsentliga risker och förmåner har överförts till köparen, beloppet kan mätas tillförlitligt och det är sannolikt att de ekonomiska fördelarna tillfaller företaget.',
+ 'Leasing: Leasingavtal klassificeras som finansiell eller operationell leasing. Operationella leasingavgifter redovisas linjärt i resultaträkningen under leasingperioden. Finansiella leasingavtal redovisas som anläggningstillgång med motsvarande skuld i balansräkningen.',
+ 'Finansiella instrument: Finansiella instrument redovisas initialt till anskaffningsvärde inklusive transaktionskostnader. Kundfordringar värderas till det belopp som beräknas inflyta. Övriga finansiella tillgångar och skulder redovisas till upplupet anskaffningsvärde.',
+ )
+ return {
+ number: 1,
+ title: 'Redovisnings- och värderingsprinciper',
+ body: paragraphs.join('\n\n'),
+ }
+}
+
+// ─── Uppskjutna skatter ──────────────────────────────────────────────────
+
+/**
+ * "Uppskjutna skatter" note required for K3 (ch.29). Shows the latent-tax
+ * balance movement during the year:
+ * - Opening balance on 2240 (start of period)
+ * - Change posted to 8940 (year-end provision adjustment)
+ * - Closing balance on 2240 (end of period)
+ *
+ * The closing must equal opening + change. Caller passes raw figures; the
+ * note formats them with thousand-separators and the appropriate sign.
+ */
+export function buildUppskjutenSkattNot(params: {
+ noteNumber: number
+ latentTaxOpening: number
+ latentTaxChange: number
+ latentTaxClosing: number
+}): NoteEntry {
+ const { noteNumber, latentTaxOpening, latentTaxChange, latentTaxClosing } =
+ params
+ // sv-SE thousand separator, no decimals — typical for ÅR notes.
+ const fmt = (n: number) =>
+ Math.round(n).toLocaleString('sv-SE')
+ const lines: string[] = [
+ 'Uppskjuten skatteskuld avser i huvudsak temporära skillnader på obeskattade reserver (periodiseringsfonder och överavskrivningar), beräknad med skattesatsen 20,6 %.',
+ '',
+ `Ingående saldo (2240): ${fmt(latentTaxOpening)} kr`,
+ `Årets förändring (8940): ${fmt(latentTaxChange)} kr`,
+ `Utgående saldo (2240): ${fmt(latentTaxClosing)} kr`,
+ ]
+ return {
+ number: noteNumber,
+ title: 'Uppskjutna skatter',
+ body: lines.join('\n'),
+ }
+}
+
+// ─── Förändring av eget kapital ──────────────────────────────────────────
+
+export interface EquityChangesSummary {
+ /** Opening balances per equity component. */
+ opening: {
+ aktiekapital: number
+ /** Övriga bundna reserver — reservfond, uppskrivningsfond. */
+ bundna_reserver: number
+ balanserade_vinstmedel: number
+ }
+ /** Year movements. */
+ changes: {
+ nyemission: number
+ utdelning: number
+ /** Årets resultat — added to balanserade vinstmedel next year, shown on
+ * its own line in the change statement. */
+ arets_resultat: number
+ }
+}
+
+export interface EquityChangesStatement {
+ rows: EgenKapitalRow[]
+ /** Closing total — derived from opening + changes for invariant testing. */
+ closing_total: number
+}
+
+/**
+ * Build a "Förändring av eget kapital" statement. K3 requires this as a
+ * separate financial statement (not buried in the förvaltningsberättelse —
+ * BFNAR 2012:1 ch.6 + ÅRL 6:5). Each component is shown with its opening
+ * balance, year movements, and closing balance.
+ *
+ * Note: returns an EgenKapitalRow[] sequence rather than a structured table
+ * — the PDF renderer in arsredovisning-k3-pdf.tsx draws the rows in order.
+ * Keeping the shape compatible with the existing EgenKapitalRow type avoids
+ * touching the PDF template for additional row variants.
+ */
+export function buildEquityChangesNote(
+ summary: EquityChangesSummary,
+): EquityChangesStatement {
+ const { opening, changes } = summary
+ const rows: EgenKapitalRow[] = []
+
+ // Opening balances
+ rows.push({ label: 'Ingående aktiekapital', amount: opening.aktiekapital })
+ rows.push({
+ label: 'Ingående övriga bundna reserver',
+ amount: opening.bundna_reserver,
+ })
+ rows.push({
+ label: 'Ingående balanserade vinstmedel',
+ amount: opening.balanserade_vinstmedel,
+ })
+ const openingTotal =
+ opening.aktiekapital + opening.bundna_reserver + opening.balanserade_vinstmedel
+ rows.push({ label: 'Summa ingående eget kapital', amount: openingTotal })
+
+ // Year movements
+ if (changes.nyemission !== 0) {
+ rows.push({ label: 'Nyemission', amount: changes.nyemission })
+ }
+ if (changes.utdelning !== 0) {
+ // Utdelning typically posted as a negative (reduction). The caller is
+ // free to pass either sign; we just render what we got.
+ rows.push({ label: 'Utdelning', amount: changes.utdelning })
+ }
+ rows.push({ label: 'Årets resultat', amount: changes.arets_resultat })
+
+ // Closing balance — uses standard accounting roll-forward.
+ const closingTotal =
+ openingTotal +
+ changes.nyemission +
+ changes.utdelning +
+ changes.arets_resultat
+ rows.push({
+ label: 'Summa utgående eget kapital',
+ amount: Math.round(closingTotal * 100) / 100,
+ })
+
+ return { rows, closing_total: Math.round(closingTotal * 100) / 100 }
+}
+
+// ─── Materiella anläggningstillgångar ────────────────────────────────────
+
+/**
+ * Per-component breakdown for an asset under K3 komponentavskrivning.
+ * Shape mirrors what we expect to find on Asset.k3_components when item
+ * 18c lands — kept loose here so the field can evolve without breaking
+ * this builder. We accept anything with the four fields we need.
+ */
+export interface K3ComponentBreakdown {
+ name: string
+ /** Anskaffningsvärde for this component. */
+ acquisition_cost: number
+ /** Ackumulerade avskrivningar so far. */
+ accumulated_depreciation: number
+ /** Useful life in months (drives the avskrivningstid disclosure). */
+ useful_life_months: number
+}
+
+interface AssetWithComponents {
+ name: string
+ category: string
+ acquisition_date: string
+ acquisition_cost: number
+ k3_components: unknown | null
+ disposed_at: string | null
+ useful_life_months: number
+}
+
+/**
+ * Type guard: validates that an unknown payload from Asset.k3_components is
+ * actually an array of K3ComponentBreakdown rows we can render. The DB stores
+ * the field as JSONB so callers receive `unknown`; the migration plan for
+ * 18c will tighten the type, but we keep this guard so the builder is safe
+ * to call against today's column.
+ */
+function isComponentArray(value: unknown): value is K3ComponentBreakdown[] {
+ if (!Array.isArray(value)) return false
+ return value.every((item) => {
+ if (!item || typeof item !== 'object') return false
+ const obj = item as Record
+ return (
+ typeof obj.name === 'string' &&
+ typeof obj.acquisition_cost === 'number' &&
+ typeof obj.accumulated_depreciation === 'number' &&
+ typeof obj.useful_life_months === 'number'
+ )
+ })
+}
+
+/**
+ * "Materiella anläggningstillgångar" note. When component depreciation is
+ * in use for any asset, render per-component sub-totals so the reader can
+ * see how the asset breaks down. Otherwise fall back to the K2-style
+ * avskrivningstider summary.
+ *
+ * Caller passes the asset list as-is from listAssets(); this function
+ * filters out disposed assets and skips immaterial / non-tangible categories
+ * (those belong in their own note).
+ */
+export function buildMateriellaAnlaggningsNot(params: {
+ noteNumber: number
+ assets: AssetWithComponents[]
+}): NoteEntry | null {
+ const { noteNumber, assets } = params
+ const tangibleCategories = new Set([
+ 'building',
+ 'land_improvement',
+ 'machinery',
+ 'equipment',
+ 'vehicle',
+ 'computer',
+ 'other_tangible',
+ ])
+ const active = assets.filter(
+ (a) => !a.disposed_at && tangibleCategories.has(a.category),
+ )
+ if (active.length === 0) return null
+
+ const linesOut: string[] = [
+ 'Materiella anläggningstillgångar redovisas till anskaffningsvärde med avdrag för ackumulerade avskrivningar.',
+ '',
+ ]
+
+ // Group by category for the avskrivningstider summary.
+ const categoryLabels: Record = {
+ building: 'Byggnader',
+ land_improvement: 'Markanläggningar',
+ machinery: 'Maskiner',
+ equipment: 'Inventarier',
+ vehicle: 'Fordon',
+ computer: 'Datorer',
+ other_tangible: 'Övriga materiella anläggningstillgångar',
+ }
+ const byCategory = new Map>()
+ for (const a of active) {
+ const years = Math.round(a.useful_life_months / 12)
+ if (!byCategory.has(a.category)) byCategory.set(a.category, new Set())
+ byCategory.get(a.category)!.add(years)
+ }
+ linesOut.push('Avskrivningstider per kategori:')
+ for (const [cat, yearsSet] of byCategory.entries()) {
+ const yrs = Array.from(yearsSet).sort((a, b) => a - b)
+ const yrsLabel =
+ yrs.length === 1 ? `${yrs[0]} år` : `${yrs[0]}–${yrs[yrs.length - 1]} år`
+ linesOut.push(` • ${categoryLabels[cat] ?? cat}: ${yrsLabel}`)
+ }
+
+ // If any asset has components, render a per-component breakdown per asset.
+ const fmt = (n: number) => Math.round(n).toLocaleString('sv-SE')
+ const withComponents = active.filter((a) => isComponentArray(a.k3_components))
+ if (withComponents.length > 0) {
+ linesOut.push('', 'Komponentuppdelning per tillgång:')
+ for (const asset of withComponents) {
+ const components = asset.k3_components as K3ComponentBreakdown[]
+ linesOut.push('', asset.name)
+ let totalCost = 0
+ let totalAccum = 0
+ for (const c of components) {
+ const bookValue = c.acquisition_cost - c.accumulated_depreciation
+ const years = Math.round(c.useful_life_months / 12)
+ linesOut.push(
+ ` • ${c.name}: anskaffningsvärde ${fmt(c.acquisition_cost)} kr, ackumulerad avskrivning ${fmt(c.accumulated_depreciation)} kr, bokfört värde ${fmt(bookValue)} kr (avskrivningstid ${years} år)`,
+ )
+ totalCost += c.acquisition_cost
+ totalAccum += c.accumulated_depreciation
+ }
+ linesOut.push(
+ ` Summa: anskaffningsvärde ${fmt(totalCost)} kr, ackumulerad avskrivning ${fmt(totalAccum)} kr, bokfört värde ${fmt(totalCost - totalAccum)} kr`,
+ )
+ }
+ }
+
+ return {
+ number: noteNumber,
+ title: 'Materiella anläggningstillgångar',
+ body: linesOut.join('\n'),
+ }
+}
+
+// ─── Helpers (exported for tests + integration) ──────────────────────────
+
+/**
+ * True iff any (non-disposed) asset has K3 components configured. Caller can
+ * use this to decide whether to:
+ * - include the komponentavskrivning paragraph in redovisningsprinciper
+ * - render the per-component sub-totals in materiella anläggnings-not.
+ */
+export function anyAssetHasComponents(assets: AssetWithComponents[]): boolean {
+ return assets.some(
+ (a) => !a.disposed_at && isComponentArray(a.k3_components),
+ )
+}
+
+// Re-export so the type-guard signature can be reused by build-data and
+// tests without exposing the local AssetWithComponents structural type.
+export { isComponentArray as isK3ComponentArray }
+
+// Re-export the line type to make integration easier for callers that need
+// to merge with non-K3 lines without re-importing from './types'.
+export type { IncomeStatementLine }
diff --git a/lib/bokslut/arsredovisning/types.ts b/lib/bokslut/arsredovisning/types.ts
index dfe5e0cc..ed624332 100644
--- a/lib/bokslut/arsredovisning/types.ts
+++ b/lib/bokslut/arsredovisning/types.ts
@@ -59,6 +59,11 @@ export interface ArsredovisningData {
period_start: string
period_end: string
}
+ /** Which BFNAR framework the document was generated under. Drives PDF
+ * rendering branching (K3 has an additional kassaflöde + equity-changes
+ * page and a richer note set) and lets the UI label the document
+ * correctly. K2 is the default for AB without an explicit election. */
+ accounting_framework: 'k2' | 'k3'
forvaltningsberattelse: {
/** Beskrivning av verksamheten (företaget kan editera). */
description: string
@@ -84,6 +89,17 @@ export interface ArsredovisningData {
total_equity_liabilities: number
}
noter: NoteEntry[]
+ /** K3-only: full kassaflödesanalys (indirect method) rendered as its own
+ * PDF page. K2 omits this entirely (per BFNAR 2016:10 kassaflöde is not
+ * required for K2 mindre företag). */
+ kassaflodesanalys?: KassaflodesAnalysisSummary
+ /** K3-only: separate "Förändring av eget kapital" statement. K2 keeps the
+ * egen_kapital_changes inside förvaltningsberättelsen; K3 lifts it out
+ * into its own statement per ÅRL 6:5 + BFNAR 2012:1 ch.6. */
+ equity_changes_statement?: {
+ rows: EgenKapitalRow[]
+ closing_total: number
+ }
/** Underskrifter — names of board members + VD. Filled by signature flow. */
signatures: {
role: string
@@ -96,3 +112,46 @@ export interface ArsredovisningData {
* user can still download to iterate. */
warnings: string[]
}
+
+/**
+ * Light summary of kassaflödesanalys carried in ArsredovisningData. We
+ * embed a flat shape rather than the full KassaflodesanalysReport so that
+ * the data builder can produce it without forcing all callers / tests to
+ * also mock the kassaflöde generator. The K3 PDF renderer reads only these
+ * fields; if you need the full structured report use generateKassaflodesanalys
+ * directly.
+ */
+export interface KassaflodesAnalysisSummary {
+ period_start: string
+ period_end: string
+ lopande: {
+ resultat_efter_finansiella_poster: number
+ avskrivningar: number
+ ovriga_ej_kassaflodesposter: number
+ delta_kortfristiga_fordringar: number
+ delta_varulager: number
+ delta_kortfristiga_skulder: number
+ skatt_betald: number
+ total: number
+ }
+ investerings: {
+ forvarv_anlaggningar: number
+ avyttring_anlaggningar: number
+ total: number
+ }
+ finansierings: {
+ delta_lan: number
+ utdelningar: number
+ nyemission: number
+ total: number
+ }
+ total_cash_flow: number
+ reconciliation: {
+ opening_cash_1xxx: number
+ closing_cash_1xxx: number
+ delta_actual: number
+ delta_calculated: number
+ mismatch_amount: number
+ is_reconciled: boolean
+ }
+}
diff --git a/lib/bokslut/assets/__tests__/dispose-vat.test.ts b/lib/bokslut/assets/__tests__/dispose-vat.test.ts
new file mode 100644
index 00000000..e66761ad
--- /dev/null
+++ b/lib/bokslut/assets/__tests__/dispose-vat.test.ts
@@ -0,0 +1,436 @@
+import { describe, it, expect, vi } from 'vitest'
+import { disposeAsset } from '../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,
+ }),
+}))
+
+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',
+ restvarde_target: null,
+ disposed_at: null,
+ disposed_proceeds: null,
+ disposed_proceeds_vat: 0,
+ disposed_vat_treatment: null,
+ jamkning_amount: 0,
+ jamkning_remaining_months: null,
+ jamkning_total_months: null,
+ jamkning_original_input_vat: null,
+ k3_components: null,
+ notes: null,
+ created_at: '2023-01-01T00:00:00Z',
+ updated_at: '2023-01-01T00:00:00Z',
+ ...overrides,
+ }
+}
+
+interface CapturedLine {
+ account_number: string
+ debit_amount: number
+ credit_amount: number
+ line_description?: string
+}
+
+function makeSupabaseForDispose(asset: Asset, schedules: Array<{ planned_depreciation: number }>) {
+ 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: '2026-05-26' },
+ 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
+}
+
+async function captureLines(asset: Asset, schedules: Array<{ planned_depreciation: number }>, input: Parameters[4]): Promise<{ lines: CapturedLine[]; updateArgs: Record }> {
+ const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
+ vi.mocked(createJournalEntry).mockClear()
+ const { supabase, builders } = makeSupabaseForDispose(asset, schedules)
+ await disposeAsset(
+ supabase as unknown as Parameters[0],
+ 'co',
+ 'u',
+ 'asset-1',
+ input,
+ )
+ const call = vi.mocked(createJournalEntry).mock.calls[0]
+ const lines = (call![3] as { lines: CapturedLine[] }).lines
+ const updateArgs = (builders.updateBuilder.update.mock.calls[0]?.[0] ?? {}) as Record
+ return { lines, updateArgs }
+}
+
+function sumDebit(lines: CapturedLine[]): number {
+ return Math.round(lines.reduce((s, l) => s + l.debit_amount, 0) * 100) / 100
+}
+function sumCredit(lines: CapturedLine[]): number {
+ return Math.round(lines.reduce((s, l) => s + l.credit_amount, 0) * 100) / 100
+}
+
+describe('disposeAsset — VAT on proceeds', () => {
+ it('standard_25 sale appends a 2611 credit and balances', async () => {
+ // Acquisition 100 000, accumulated 40 000 → NBV 60 000.
+ // Gross proceeds 100 000 → net 80 000 → vat 20 000 → gain 20 000.
+ const asset = makeAsset({ category: 'equipment' })
+ const { lines } = await captureLines(
+ asset,
+ [{ planned_depreciation: 40_000 }],
+ {
+ disposed_at: '2026-05-26',
+ disposed_proceeds: 100_000,
+ proceeds_vat: 20_000,
+ vat_treatment: 'standard_25',
+ fiscal_period_id: 'fp',
+ },
+ )
+ expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe(20_000)
+ // Gain on NET proceeds, not gross: 80 000 net − 60 000 NBV = 20 000 gain
+ expect(lines.find((l) => l.account_number === '3973')?.credit_amount).toBe(20_000)
+ // No loss line on a gain scenario
+ expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
+ // Bank debit = gross
+ expect(lines.find((l) => l.account_number === '1930')?.debit_amount).toBe(100_000)
+ expect(sumDebit(lines)).toBe(sumCredit(lines))
+ })
+
+ it('reduced_12 sale uses BAS 2621', async () => {
+ const asset = makeAsset()
+ const { lines } = await captureLines(
+ asset,
+ [{ planned_depreciation: 40_000 }],
+ {
+ disposed_at: '2026-05-26',
+ disposed_proceeds: 100_000,
+ proceeds_vat: 100_000 - 100_000 / 1.12,
+ vat_treatment: 'reduced_12',
+ fiscal_period_id: 'fp',
+ },
+ )
+ expect(lines.find((l) => l.account_number === '2621')).toBeDefined()
+ expect(lines.find((l) => l.account_number === '2611')).toBeUndefined()
+ expect(sumDebit(lines)).toBe(sumCredit(lines))
+ })
+
+ it('reduced_6 sale uses BAS 2631', async () => {
+ const asset = makeAsset()
+ const { lines } = await captureLines(
+ asset,
+ [{ planned_depreciation: 40_000 }],
+ {
+ disposed_at: '2026-05-26',
+ disposed_proceeds: 100_000,
+ proceeds_vat: 100_000 - 100_000 / 1.06,
+ vat_treatment: 'reduced_6',
+ fiscal_period_id: 'fp',
+ },
+ )
+ expect(lines.find((l) => l.account_number === '2631')).toBeDefined()
+ expect(sumDebit(lines)).toBe(sumCredit(lines))
+ })
+
+ it('reverse_charge sale posts NO VAT line', async () => {
+ const asset = makeAsset()
+ const { lines } = await captureLines(
+ asset,
+ [{ planned_depreciation: 40_000 }],
+ {
+ disposed_at: '2026-05-26',
+ disposed_proceeds: 80_000,
+ proceeds_vat: 0,
+ vat_treatment: 'reverse_charge',
+ fiscal_period_id: 'fp',
+ },
+ )
+ expect(lines.find((l) => l.account_number.startsWith('261'))).toBeUndefined()
+ // The full proceeds counts as net (no VAT taken out) → gain = 80 000 − 60 000 = 20 000
+ expect(lines.find((l) => l.account_number === '3973')?.credit_amount).toBe(20_000)
+ expect(sumDebit(lines)).toBe(sumCredit(lines))
+ })
+
+ it('export sale posts NO VAT line', async () => {
+ const asset = makeAsset()
+ const { lines } = await captureLines(
+ asset,
+ [{ planned_depreciation: 40_000 }],
+ {
+ disposed_at: '2026-05-26',
+ disposed_proceeds: 80_000,
+ proceeds_vat: 0,
+ vat_treatment: 'export',
+ fiscal_period_id: 'fp',
+ },
+ )
+ expect(lines.find((l) => l.account_number.startsWith('26'))).toBeUndefined()
+ expect(sumDebit(lines)).toBe(sumCredit(lines))
+ })
+
+ it('throws when proceeds_vat > 0 but no vat_treatment is supplied', async () => {
+ const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
+ vi.mocked(createJournalEntry).mockClear()
+ const { supabase } = makeSupabaseForDispose(makeAsset(), [{ planned_depreciation: 40_000 }])
+ await expect(
+ disposeAsset(supabase as unknown as Parameters[0], 'co', 'u', 'asset-1', {
+ disposed_at: '2026-05-26',
+ disposed_proceeds: 100_000,
+ proceeds_vat: 20_000,
+ fiscal_period_id: 'fp',
+ }),
+ ).rejects.toThrow(/vat_treatment/)
+ })
+
+ it('throws when reverse_charge is selected but proceeds_vat > 0', async () => {
+ const { createJournalEntry } = await import('@/lib/bookkeeping/engine')
+ vi.mocked(createJournalEntry).mockClear()
+ const { supabase } = makeSupabaseForDispose(makeAsset(), [{ planned_depreciation: 40_000 }])
+ await expect(
+ disposeAsset(supabase as unknown as Parameters[0], 'co', 'u', 'asset-1', {
+ disposed_at: '2026-05-26',
+ disposed_proceeds: 80_000,
+ proceeds_vat: 5_000,
+ vat_treatment: 'reverse_charge',
+ fiscal_period_id: 'fp',
+ }),
+ ).rejects.toThrow(/reverse_charge/)
+ })
+
+ it('zero-VAT sale (no fields passed) still posts a balanced entry', async () => {
+ const asset = makeAsset()
+ const { lines } = await captureLines(
+ asset,
+ [{ planned_depreciation: 40_000 }],
+ {
+ disposed_at: '2026-05-26',
+ disposed_proceeds: 50_000,
+ fiscal_period_id: 'fp',
+ },
+ )
+ // No VAT line
+ expect(lines.find((l) => l.account_number.startsWith('26'))).toBeUndefined()
+ // Loss on 50 000 − 60 000 = -10 000 → 7973 debit
+ expect(lines.find((l) => l.account_number === '7973')?.debit_amount).toBe(10_000)
+ expect(sumDebit(lines)).toBe(sumCredit(lines))
+ })
+})
+
+describe('disposeAsset — jämkning (input VAT correction)', () => {
+ it('credits 2641 and debits 6991 for the jämkning amount', async () => {
+ const asset = makeAsset({ category: 'equipment' })
+ const { lines } = await captureLines(
+ asset,
+ [{ planned_depreciation: 40_000 }],
+ {
+ disposed_at: '2026-01-01',
+ disposed_proceeds: 60_000,
+ fiscal_period_id: 'fp',
+ // 5-year asset sold after 3 years; 24 months remain × 20 000 VAT × 24/60 = 8 000
+ jamkning_amount: 8_000,
+ jamkning_remaining_months: 24,
+ jamkning_total_months: 60,
+ jamkning_original_input_vat: 20_000,
+ },
+ )
+ // 2641 credit (reverses prior input VAT deduction)
+ expect(lines.find((l) => l.account_number === '2641')?.credit_amount).toBe(8_000)
+ // Jämkning is a VAT correction (ML 8a kap), not a disposal loss — it must
+ // route to 6991 "Övriga externa kostnader, avdragsgilla", NOT to 78xx.
+ expect(lines.find((l) => l.account_number === '6991')?.debit_amount).toBe(8_000)
+ // No 78xx line — proceeds 60 000 = NBV 60 000 means no gain/loss, and the
+ // jämkning explicitly does not contaminate the disposal-loss accounts.
+ expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
+ expect(sumDebit(lines)).toBe(sumCredit(lines))
+ })
+
+ it('combined VAT + jämkning + gain stays balanced', async () => {
+ const asset = makeAsset({ category: 'equipment' })
+ const { lines } = await captureLines(
+ asset,
+ [{ planned_depreciation: 40_000 }],
+ {
+ disposed_at: '2026-01-01',
+ disposed_proceeds: 100_000, // gross
+ proceeds_vat: 20_000, // 25%
+ vat_treatment: 'standard_25',
+ fiscal_period_id: 'fp',
+ jamkning_amount: 8_000,
+ jamkning_remaining_months: 24,
+ jamkning_total_months: 60,
+ jamkning_original_input_vat: 20_000,
+ },
+ )
+ expect(lines.find((l) => l.account_number === '2611')?.credit_amount).toBe(20_000)
+ expect(lines.find((l) => l.account_number === '2641')?.credit_amount).toBe(8_000)
+ // Gain 20 000 on net proceeds → 3973 credit; jämkning 8 000 → 6991 debit
+ // (NOT 7973 — see ML 8a kap, jämkning is a VAT correction not a loss).
+ expect(lines.find((l) => l.account_number === '3973')?.credit_amount).toBe(20_000)
+ expect(lines.find((l) => l.account_number === '6991')?.debit_amount).toBe(8_000)
+ expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
+ expect(sumDebit(lines)).toBe(sumCredit(lines))
+ })
+
+ it('zero jämkning amount produces no extra lines', async () => {
+ const asset = makeAsset()
+ const { lines } = await captureLines(
+ asset,
+ [{ planned_depreciation: 40_000 }],
+ {
+ disposed_at: '2026-01-01',
+ disposed_proceeds: 60_000,
+ fiscal_period_id: 'fp',
+ jamkning_amount: 0,
+ },
+ )
+ expect(lines.find((l) => l.account_number === '2641')).toBeUndefined()
+ expect(sumDebit(lines)).toBe(sumCredit(lines))
+ })
+
+ it('routes jämkning to 6991 even for building category (never to 78xx)', async () => {
+ const asset = makeAsset({
+ category: 'building',
+ bas_asset_account: '1110',
+ bas_accumulated_account: '1119',
+ bas_expense_account: '7821',
+ acquisition_cost: 2_000_000,
+ })
+ const { lines } = await captureLines(
+ asset,
+ [{ planned_depreciation: 200_000 }],
+ {
+ disposed_at: '2026-01-01',
+ disposed_proceeds: 1_800_000,
+ fiscal_period_id: 'fp',
+ // 10-year fastighet, 60 months remaining out of 120, original 200 000 → 100 000
+ jamkning_amount: 100_000,
+ jamkning_remaining_months: 60,
+ jamkning_total_months: 120,
+ jamkning_original_input_vat: 200_000,
+ },
+ )
+ // Jämkning goes to 6991 regardless of asset category — it's a VAT
+ // correction per ML 8a kap, NOT a förlust vid avyttring (78xx).
+ expect(lines.find((l) => l.account_number === '6991')?.debit_amount).toBe(100_000)
+ // The disposal itself is at a loss (NBV 1.8M = proceeds 1.8M? Let's check:
+ // acq 2.0M − ack 0.2M = NBV 1.8M, proceeds 1.8M → no gain/loss line). The
+ // only debit-side cost line is the 6991 jämkning entry.
+ expect(lines.find((l) => l.account_number === '7971')).toBeUndefined()
+ expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
+ expect(sumDebit(lines)).toBe(sumCredit(lines))
+ })
+
+ it('persists jämkning + VAT audit metadata on the asset row', async () => {
+ const asset = makeAsset()
+ const { updateArgs } = await captureLines(
+ asset,
+ [{ planned_depreciation: 40_000 }],
+ {
+ disposed_at: '2026-01-01',
+ disposed_proceeds: 100_000,
+ proceeds_vat: 20_000,
+ vat_treatment: 'standard_25',
+ fiscal_period_id: 'fp',
+ jamkning_amount: 8_000,
+ jamkning_remaining_months: 24,
+ jamkning_total_months: 60,
+ jamkning_original_input_vat: 20_000,
+ },
+ )
+ expect(updateArgs.disposed_proceeds).toBe(100_000)
+ expect(updateArgs.disposed_proceeds_vat).toBe(20_000)
+ expect(updateArgs.disposed_vat_treatment).toBe('standard_25')
+ expect(updateArgs.jamkning_amount).toBe(8_000)
+ expect(updateArgs.jamkning_remaining_months).toBe(24)
+ expect(updateArgs.jamkning_total_months).toBe(60)
+ expect(updateArgs.jamkning_original_input_vat).toBe(20_000)
+ })
+})
+
+describe('disposeAsset — gain vs loss with VAT', () => {
+ it('gain scenario: net proceeds > NBV → 3973 credit', async () => {
+ const asset = makeAsset({ category: 'equipment' })
+ const { lines } = await captureLines(
+ asset,
+ [{ planned_depreciation: 50_000 }],
+ {
+ disposed_at: '2026-05-26',
+ // NBV = 50 000, net proceeds = 80 000 → gain 30 000
+ disposed_proceeds: 100_000,
+ proceeds_vat: 20_000,
+ vat_treatment: 'standard_25',
+ fiscal_period_id: 'fp',
+ },
+ )
+ expect(lines.find((l) => l.account_number === '3973')?.credit_amount).toBe(30_000)
+ expect(lines.find((l) => l.account_number === '7973')).toBeUndefined()
+ expect(sumDebit(lines)).toBe(sumCredit(lines))
+ })
+
+ it('loss scenario: net proceeds < NBV → 7973 debit', async () => {
+ const asset = makeAsset({ category: 'equipment' })
+ const { lines } = await captureLines(
+ asset,
+ [{ planned_depreciation: 20_000 }],
+ {
+ disposed_at: '2026-05-26',
+ // NBV = 80 000, net proceeds = 40 000 → loss 40 000
+ disposed_proceeds: 50_000,
+ proceeds_vat: 10_000,
+ vat_treatment: 'standard_25',
+ fiscal_period_id: 'fp',
+ },
+ )
+ expect(lines.find((l) => l.account_number === '7973')?.debit_amount).toBe(40_000)
+ expect(lines.find((l) => l.account_number === '3973')).toBeUndefined()
+ expect(sumDebit(lines)).toBe(sumCredit(lines))
+ })
+})
diff --git a/lib/bokslut/assets/__tests__/jamkning.test.ts b/lib/bokslut/assets/__tests__/jamkning.test.ts
new file mode 100644
index 00000000..f17c6c0c
--- /dev/null
+++ b/lib/bokslut/assets/__tests__/jamkning.test.ts
@@ -0,0 +1,205 @@
+import { describe, it, expect } from 'vitest'
+import {
+ computeJamkningAmount,
+ assessJamkningEligibility,
+} from '../jamkning'
+
+describe('computeJamkningAmount', () => {
+ it('5-year asset sold after 3 years (24 months remaining, 20 000 kr input VAT) → 8 000 kr', () => {
+ // ML 8a kap 7 §: (24 / 60) × 20 000 = 8 000
+ const amount = computeJamkningAmount({
+ originalInputVat: 20_000,
+ totalCorrectionMonths: 60,
+ remainingMonths: 24,
+ disposalEvent: 'triggers_jamkning',
+ })
+ expect(amount).toBe(8_000)
+ })
+
+ it('10-year fastighet sold after 7 years (36 months remaining, 200 000 kr input VAT) → 60 000 kr', () => {
+ // ML 8a kap 7 §: (36 / 120) × 200 000 = 60 000
+ const amount = computeJamkningAmount({
+ originalInputVat: 200_000,
+ totalCorrectionMonths: 120,
+ remainingMonths: 36,
+ disposalEvent: 'triggers_jamkning',
+ })
+ expect(amount).toBe(60_000)
+ })
+
+ it('sold after the correction period (0 remaining) → 0', () => {
+ const amount = computeJamkningAmount({
+ originalInputVat: 20_000,
+ totalCorrectionMonths: 60,
+ remainingMonths: 0,
+ disposalEvent: 'triggers_jamkning',
+ })
+ expect(amount).toBe(0)
+ })
+
+ it('sold immediately (60 months remaining on 60-month period) → full originalInputVat', () => {
+ // (60 / 60) × 20 000 = 20 000 — the full deduction must be reversed
+ const amount = computeJamkningAmount({
+ originalInputVat: 20_000,
+ totalCorrectionMonths: 60,
+ remainingMonths: 60,
+ disposalEvent: 'triggers_jamkning',
+ })
+ expect(amount).toBe(20_000)
+ })
+
+ it('returns 0 when disposalEvent is no_jamkning', () => {
+ const amount = computeJamkningAmount({
+ originalInputVat: 20_000,
+ totalCorrectionMonths: 60,
+ remainingMonths: 24,
+ disposalEvent: 'no_jamkning',
+ })
+ expect(amount).toBe(0)
+ })
+
+ it('caps remaining months at totalCorrectionMonths (defensive)', () => {
+ // A caller bug could pass remainingMonths > totalCorrectionMonths.
+ // Cap at the total so the answer never exceeds originalInputVat.
+ const amount = computeJamkningAmount({
+ originalInputVat: 10_000,
+ totalCorrectionMonths: 60,
+ remainingMonths: 120,
+ disposalEvent: 'triggers_jamkning',
+ })
+ expect(amount).toBe(10_000)
+ })
+
+ it('handles negligible cost (zero originalInputVat) → 0 without NaN', () => {
+ const amount = computeJamkningAmount({
+ originalInputVat: 0,
+ totalCorrectionMonths: 60,
+ remainingMonths: 24,
+ disposalEvent: 'triggers_jamkning',
+ })
+ expect(amount).toBe(0)
+ expect(Number.isNaN(amount)).toBe(false)
+ })
+
+ it('returns 0 when totalCorrectionMonths is 0 (avoid divide-by-zero)', () => {
+ const amount = computeJamkningAmount({
+ originalInputVat: 20_000,
+ totalCorrectionMonths: 0,
+ remainingMonths: 0,
+ disposalEvent: 'triggers_jamkning',
+ })
+ expect(amount).toBe(0)
+ expect(Number.isFinite(amount)).toBe(true)
+ })
+
+ it('returns 0 when totalCorrectionMonths is negative (defensive)', () => {
+ const amount = computeJamkningAmount({
+ originalInputVat: 20_000,
+ totalCorrectionMonths: -60,
+ remainingMonths: -24,
+ disposalEvent: 'triggers_jamkning',
+ })
+ expect(amount).toBe(0)
+ expect(Number.isFinite(amount)).toBe(true)
+ })
+
+ it('rounds to two decimals (no öre stray cents)', () => {
+ // (17 / 60) × 10 000 = 2833.333... → 2833.33
+ const amount = computeJamkningAmount({
+ originalInputVat: 10_000,
+ totalCorrectionMonths: 60,
+ remainingMonths: 17,
+ disposalEvent: 'triggers_jamkning',
+ })
+ expect(amount).toBe(2_833.33)
+ })
+})
+
+describe('assessJamkningEligibility', () => {
+ it('returns 120 months for fastighet BAS 1110', () => {
+ const e = assessJamkningEligibility({
+ basAssetAccount: '1110',
+ basExpenseAccount: '7821',
+ category: 'building',
+ acquisitionDate: '2020-01-01',
+ disposalDate: '2026-01-01',
+ })
+ expect(e.totalCorrectionMonths).toBe(120)
+ // 6 years = 72 months elapsed → 48 months remaining
+ expect(e.elapsedMonths).toBe(72)
+ expect(e.remainingMonths).toBe(48)
+ expect(e.withinCorrectionPeriod).toBe(true)
+ })
+
+ it('returns 60 months for equipment BAS 1220', () => {
+ const e = assessJamkningEligibility({
+ basAssetAccount: '1220',
+ basExpenseAccount: '7832',
+ category: 'equipment',
+ acquisitionDate: '2024-01-01',
+ disposalDate: '2026-01-01',
+ })
+ expect(e.totalCorrectionMonths).toBe(60)
+ // 2 years = 24 months → 36 months remaining
+ expect(e.elapsedMonths).toBe(24)
+ expect(e.remainingMonths).toBe(36)
+ expect(e.withinCorrectionPeriod).toBe(true)
+ })
+
+ it('detects markanläggning (BAS 1150) as real property → 120 months', () => {
+ const e = assessJamkningEligibility({
+ basAssetAccount: '1150',
+ basExpenseAccount: '7824',
+ category: 'land_improvement',
+ acquisitionDate: '2023-06-01',
+ disposalDate: '2024-06-01',
+ })
+ expect(e.totalCorrectionMonths).toBe(120)
+ })
+
+ it('falls back to category when account is unrecognized', () => {
+ // No BAS account provided — has to rely on the category signal.
+ const e = assessJamkningEligibility({
+ category: 'building',
+ acquisitionDate: '2024-01-01',
+ disposalDate: '2026-01-01',
+ })
+ expect(e.totalCorrectionMonths).toBe(120)
+ })
+
+ it('reports withinCorrectionPeriod = false after the full period elapses', () => {
+ const e = assessJamkningEligibility({
+ basAssetAccount: '1220',
+ category: 'equipment',
+ acquisitionDate: '2020-01-01',
+ disposalDate: '2026-01-01',
+ })
+ // 6 years = 72 months elapsed > 60 → 0 remaining
+ expect(e.remainingMonths).toBe(0)
+ expect(e.withinCorrectionPeriod).toBe(false)
+ })
+
+ it('counts complete months only (day-precision)', () => {
+ // 2023-01-15 to 2026-01-14 → 35 complete months (the 36th hasn't finished)
+ const e = assessJamkningEligibility({
+ basAssetAccount: '1220',
+ category: 'equipment',
+ acquisitionDate: '2023-01-15',
+ disposalDate: '2026-01-14',
+ })
+ expect(e.elapsedMonths).toBe(35)
+ expect(e.remainingMonths).toBe(25)
+ })
+
+ it('clamps elapsedMonths to 0 if disposalDate precedes acquisitionDate', () => {
+ // Defensive — should never happen in practice but must not blow up.
+ const e = assessJamkningEligibility({
+ basAssetAccount: '1220',
+ category: 'equipment',
+ acquisitionDate: '2026-01-01',
+ disposalDate: '2024-01-01',
+ })
+ expect(e.elapsedMonths).toBe(0)
+ expect(e.remainingMonths).toBe(60)
+ })
+})
diff --git a/lib/bokslut/assets/__tests__/k3-components.test.ts b/lib/bokslut/assets/__tests__/k3-components.test.ts
new file mode 100644
index 00000000..c2c18f65
--- /dev/null
+++ b/lib/bokslut/assets/__tests__/k3-components.test.ts
@@ -0,0 +1,393 @@
+import { describe, it, expect } from 'vitest'
+import { validateComponents } from '../k3-components'
+import { computeComponentDepreciation, computeAnnualDepreciation } from '../depreciation-engine'
+import type { Asset, K3Component } from '@/types'
+
+function makeAsset(overrides: Partial = {}): Asset {
+ return {
+ id: 'asset-1',
+ user_id: 'user-1',
+ company_id: 'co-1',
+ name: 'Test',
+ category: 'building',
+ acquisition_date: '2025-01-01',
+ acquisition_cost: 1_000_000,
+ salvage_value: 0,
+ useful_life_months: 240, // 20 years asset-level (overridden by components when set)
+ depreciation_method: 'linear',
+ bas_asset_account: '1110',
+ bas_accumulated_account: '1119',
+ bas_expense_account: '7821',
+ restvarde_target: null,
+ disposed_at: null,
+ disposed_proceeds: null,
+ disposed_proceeds_vat: 0,
+ disposed_vat_treatment: null,
+ jamkning_amount: 0,
+ jamkning_remaining_months: null,
+ jamkning_total_months: null,
+ jamkning_original_input_vat: null,
+ k3_components: null,
+ notes: null,
+ created_at: '2025-01-01T00:00:00Z',
+ updated_at: '2025-01-01T00:00:00Z',
+ ...overrides,
+ }
+}
+
+const FULL_YEAR_2025 = { period_start: '2025-01-01', period_end: '2025-12-31' }
+
+// ============================================================
+// validateComponents — pure validator
+// ============================================================
+
+describe('validateComponents', () => {
+ it('null components → no errors', () => {
+ const { errors } = validateComponents({
+ acquisition_cost: 100_000,
+ k3_components: null,
+ })
+ expect(errors).toEqual([])
+ })
+
+ it('undefined components → no errors', () => {
+ const { errors } = validateComponents({
+ acquisition_cost: 100_000,
+ k3_components: undefined,
+ })
+ expect(errors).toEqual([])
+ })
+
+ it('valid breakdown summing to acquisition_cost → no errors', () => {
+ const components: K3Component[] = [
+ { name: 'Stomme', cost: 600_000, useful_life_months: 600 },
+ { name: 'Tak', cost: 300_000, useful_life_months: 360 },
+ { name: 'Installationer', cost: 100_000, useful_life_months: 240 },
+ ]
+ const { errors } = validateComponents({
+ acquisition_cost: 1_000_000,
+ k3_components: components,
+ })
+ expect(errors).toEqual([])
+ })
+
+ it('cost mismatch: 100 000 asset with components summing to 95 000 → error', () => {
+ const components: K3Component[] = [
+ { name: 'A', cost: 50_000, useful_life_months: 60 },
+ { name: 'B', cost: 45_000, useful_life_months: 120 },
+ ]
+ const { errors } = validateComponents({
+ acquisition_cost: 100_000,
+ k3_components: components,
+ })
+ expect(errors.length).toBeGreaterThan(0)
+ expect(errors.some((e) => e.includes('summerar'))).toBe(true)
+ })
+
+ it('cost mismatch within 1 kr tolerance → no error', () => {
+ // 99_999.5 vs 100_000 — öre rounding shouldn't fail
+ const components: K3Component[] = [
+ { name: 'A', cost: 50_000, useful_life_months: 60 },
+ { name: 'B', cost: 49_999.5, useful_life_months: 60 },
+ ]
+ const { errors } = validateComponents({
+ acquisition_cost: 100_000,
+ k3_components: components,
+ })
+ expect(errors).toEqual([])
+ })
+
+ it('negative useful_life_months → error', () => {
+ const components: K3Component[] = [
+ { name: 'A', cost: 50_000, useful_life_months: -60 },
+ { name: 'B', cost: 50_000, useful_life_months: 60 },
+ ]
+ const { errors } = validateComponents({
+ acquisition_cost: 100_000,
+ k3_components: components,
+ })
+ expect(errors.some((e) => e.toLowerCase().includes('nyttjandeperioden'))).toBe(true)
+ })
+
+ it('non-integer useful_life_months → error', () => {
+ const components: K3Component[] = [
+ { name: 'A', cost: 100_000, useful_life_months: 60.5 },
+ ]
+ const { errors } = validateComponents({
+ acquisition_cost: 100_000,
+ k3_components: components,
+ })
+ expect(errors.some((e) => e.toLowerCase().includes('heltal'))).toBe(true)
+ })
+
+ it('component cost ≤ 0 → error', () => {
+ const components: K3Component[] = [
+ { name: 'A', cost: 0, useful_life_months: 60 },
+ { name: 'B', cost: 100_000, useful_life_months: 60 },
+ ]
+ const { errors } = validateComponents({
+ acquisition_cost: 100_000,
+ k3_components: components,
+ })
+ expect(errors.some((e) => e.toLowerCase().includes('anskaffningsvärdet'))).toBe(true)
+ })
+
+ it('salvage_value > component cost → error', () => {
+ const components: K3Component[] = [
+ { name: 'A', cost: 50_000, useful_life_months: 60, salvage_value: 60_000 },
+ { name: 'B', cost: 50_000, useful_life_months: 60 },
+ ]
+ const { errors } = validateComponents({
+ acquisition_cost: 100_000,
+ k3_components: components,
+ })
+ expect(errors.some((e) => e.toLowerCase().includes('restvärdet'))).toBe(true)
+ })
+
+ it('salvage_value negative → error', () => {
+ const components: K3Component[] = [
+ { name: 'A', cost: 100_000, useful_life_months: 60, salvage_value: -100 },
+ ]
+ const { errors } = validateComponents({
+ acquisition_cost: 100_000,
+ k3_components: components,
+ })
+ expect(errors.some((e) => e.toLowerCase().includes('restvärdet'))).toBe(true)
+ })
+
+ it('empty array but k3_components set to non-null → error', () => {
+ const { errors } = validateComponents({
+ acquisition_cost: 100_000,
+ k3_components: [],
+ })
+ expect(errors.length).toBeGreaterThan(0)
+ expect(errors.some((e) => e.toLowerCase().includes('tom'))).toBe(true)
+ })
+
+ it('non-array value → error', () => {
+ // Defensive: simulate malformed JSONB read from DB.
+ const { errors } = validateComponents({
+ acquisition_cost: 100_000,
+ // @ts-expect-error -- intentional malformed input
+ k3_components: 'not-an-array',
+ })
+ expect(errors.length).toBeGreaterThan(0)
+ })
+
+ it('aggregates multiple errors — does not bail on first', () => {
+ const components: K3Component[] = [
+ { name: 'A', cost: -100, useful_life_months: 0 },
+ { name: 'B', cost: 50_000, useful_life_months: 60, salvage_value: 60_000 },
+ ]
+ const { errors } = validateComponents({
+ acquisition_cost: 100_000,
+ k3_components: components,
+ })
+ // Should catch: negative cost, zero useful life, salvage > cost, plus sum mismatch.
+ expect(errors.length).toBeGreaterThanOrEqual(3)
+ })
+})
+
+// ============================================================
+// computeComponentDepreciation — engine
+// ============================================================
+
+describe('computeComponentDepreciation', () => {
+ it('1M building: roof 300k/240mo, facade 500k/480mo, installations 200k/120mo', () => {
+ const asset = makeAsset({
+ acquisition_cost: 1_000_000,
+ k3_components: [
+ { name: 'Tak', cost: 300_000, useful_life_months: 240 },
+ { name: 'Fasad', cost: 500_000, useful_life_months: 480 },
+ { name: 'Installationer', cost: 200_000, useful_life_months: 120 },
+ ],
+ })
+ // Annual per component:
+ // Tak: 300_000 × 12/240 = 15_000
+ // Fasad: 500_000 × 12/480 = 12_500
+ // Installationer: 200_000 × 12/120 = 20_000
+ // Sum: 47_500
+ const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
+ expect(result.amount).toBe(47_500)
+ expect(result.proRated).toBe(false)
+ expect(result.perComponent).toHaveLength(3)
+ expect(result.perComponent[0]).toEqual({ name: 'Tak', amount: 15_000 })
+ expect(result.perComponent[1]).toEqual({ name: 'Fasad', amount: 12_500 })
+ expect(result.perComponent[2]).toEqual({ name: 'Installationer', amount: 20_000 })
+ })
+
+ it('half-year pro-ration: acquired July 1 → roughly half the annual sum', () => {
+ const asset = makeAsset({
+ acquisition_date: '2025-07-01',
+ acquisition_cost: 1_000_000,
+ k3_components: [
+ { name: 'Tak', cost: 300_000, useful_life_months: 240 },
+ { name: 'Fasad', cost: 500_000, useful_life_months: 480 },
+ { name: 'Installationer', cost: 200_000, useful_life_months: 120 },
+ ],
+ })
+ // Full-year sum = 47_500. Jul 1 - Dec 31 = 184 days, 184/365 ≈ 0.5041.
+ // Per-component rounding may slightly drift — accept ~5_900–6_100 per row sum.
+ const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
+ expect(result.proRated).toBe(true)
+ expect(result.amount).toBeGreaterThan(23_500)
+ expect(result.amount).toBeLessThan(24_500)
+ // perComponent entries each carry roughly half of their full-year amount.
+ expect(result.perComponent[0].amount).toBeGreaterThan(7_400)
+ expect(result.perComponent[0].amount).toBeLessThan(7_700)
+ })
+
+ it('mid-year disposal: each component pro-rates to disposal date', () => {
+ const asset = makeAsset({
+ acquisition_cost: 1_000_000,
+ disposed_at: '2025-06-30',
+ disposed_proceeds: 800_000,
+ k3_components: [
+ { name: 'Tak', cost: 300_000, useful_life_months: 240 },
+ { name: 'Fasad', cost: 500_000, useful_life_months: 480 },
+ { name: 'Installationer', cost: 200_000, useful_life_months: 120 },
+ ],
+ })
+ // Jan 1 - Jun 30 = 181 days / 365 ≈ 0.4959. Full sum 47_500 × 0.4959 ≈ 23_555.
+ const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
+ expect(result.proRated).toBe(true)
+ expect(result.amount).toBeGreaterThan(23_200)
+ expect(result.amount).toBeLessThan(23_900)
+ })
+
+ it('respects per-component salvage_value', () => {
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ k3_components: [
+ // 100_000 cost − 20_000 salvage = 80_000 depreciable / 60 months
+ // → 80_000 × 12/60 = 16_000 per year
+ { name: 'A', cost: 100_000, useful_life_months: 60, salvage_value: 20_000 },
+ ],
+ })
+ const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
+ expect(result.amount).toBe(16_000)
+ })
+
+ it('returns zero when components array is empty', () => {
+ const asset = makeAsset({
+ k3_components: [],
+ })
+ const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
+ expect(result.amount).toBe(0)
+ expect(result.perComponent).toEqual([])
+ })
+
+ it('skips components fully past end-of-life (window collapses)', () => {
+ // Component with 12-month life acquired Jan 1, 2023 — fully depreciated by
+ // Jan 1, 2024. For fiscal year 2025 (Jan 1 - Dec 31) the window is empty.
+ const asset = makeAsset({
+ acquisition_date: '2023-01-01',
+ acquisition_cost: 100_000,
+ k3_components: [
+ { name: 'Kort', cost: 30_000, useful_life_months: 12 },
+ { name: 'Lång', cost: 70_000, useful_life_months: 240 },
+ ],
+ })
+ const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
+ // Kort: 0 (life ended). Lång: 70_000 × 12/240 = 3_500.
+ expect(result.amount).toBe(3_500)
+ expect(result.perComponent[0]).toEqual({ name: 'Kort', amount: 0 })
+ expect(result.perComponent[1]).toEqual({ name: 'Lång', amount: 3_500 })
+ })
+
+ it('treats components with non-positive cost/life as 0 (defensive)', () => {
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ k3_components: [
+ { name: 'Trasig', cost: 0, useful_life_months: 0 },
+ { name: 'Ok', cost: 100_000, useful_life_months: 60 },
+ ],
+ })
+ const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
+ // Bad row is skipped; good row produces 100_000 × 12/60 = 20_000.
+ expect(result.amount).toBe(20_000)
+ expect(result.perComponent[0].amount).toBe(0)
+ expect(result.perComponent[1].amount).toBe(20_000)
+ })
+
+ it('uses empty-name fallback label', () => {
+ const asset = makeAsset({
+ acquisition_cost: 100_000,
+ k3_components: [
+ { name: '', cost: 100_000, useful_life_months: 60 },
+ ],
+ })
+ const result = computeComponentDepreciation(asset, FULL_YEAR_2025)
+ expect(result.perComponent[0].name).toBe('Komponent 1')
+ })
+})
+
+// ============================================================
+// computeAnnualDepreciation dispatch — k3_components precedence
+// ============================================================
+
+describe('computeAnnualDepreciation — K3 dispatch', () => {
+ it('routes to component depreciation when k3_components is non-empty', () => {
+ const asset = makeAsset({
+ acquisition_cost: 1_000_000,
+ // method+life on the asset would compute different number — engine should ignore them
+ depreciation_method: 'declining_balance_30',
+ useful_life_months: 60,
+ k3_components: [
+ { name: 'Tak', cost: 300_000, useful_life_months: 240 },
+ { name: 'Fasad', cost: 500_000, useful_life_months: 480 },
+ { name: 'Installationer', cost: 200_000, useful_life_months: 120 },
+ ],
+ })
+ const result = computeAnnualDepreciation(asset, FULL_YEAR_2025)
+ // Component sum (see test above) = 47_500.
+ expect(result.amount).toBe(47_500)
+ expect(result.proRated).toBe(false)
+ })
+
+ it('K2-path stays byte-equivalent: linear asset without k3_components is unchanged', () => {
+ // 60_000 / 5yr = 12_000 per year — same as the linear baseline test.
+ const asset = makeAsset({
+ acquisition_cost: 60_000,
+ useful_life_months: 60,
+ k3_components: null,
+ depreciation_method: 'linear',
+ category: 'equipment',
+ bas_asset_account: '1220',
+ bas_accumulated_account: '1229',
+ bas_expense_account: '7832',
+ })
+ const result = computeAnnualDepreciation(asset, FULL_YEAR_2025)
+ expect(result.amount).toBe(12_000)
+ expect(result.proRated).toBe(false)
+ })
+
+ it('K2-path stays byte-equivalent: empty array of components is treated as "no components"', () => {
+ // Engine guards with .length > 0 — an empty array must fall through to
+ // the method-based dispatch so a tampered DB row doesn't zero out the
+ // depreciation silently.
+ const asset = makeAsset({
+ acquisition_cost: 60_000,
+ useful_life_months: 60,
+ k3_components: [],
+ depreciation_method: 'linear',
+ })
+ const result = computeAnnualDepreciation(asset, FULL_YEAR_2025)
+ expect(result.amount).toBe(12_000)
+ })
+
+ it('disposal before period start returns 0 even with components', () => {
+ const asset = makeAsset({
+ acquisition_cost: 1_000_000,
+ disposed_at: '2024-12-31',
+ disposed_proceeds: 500_000,
+ k3_components: [
+ { name: 'Tak', cost: 300_000, useful_life_months: 240 },
+ { name: 'Fasad', cost: 500_000, useful_life_months: 480 },
+ { name: 'Installationer', cost: 200_000, useful_life_months: 120 },
+ ],
+ })
+ const result = computeAnnualDepreciation(asset, FULL_YEAR_2025)
+ expect(result.amount).toBe(0)
+ })
+})
diff --git a/lib/bokslut/assets/asset-service.ts b/lib/bokslut/assets/asset-service.ts
index d239205c..63a52410 100644
--- a/lib/bokslut/assets/asset-service.ts
+++ b/lib/bokslut/assets/asset-service.ts
@@ -4,8 +4,10 @@ import type {
Asset,
AssetCategory,
DepreciationMethod,
+ K3Component,
CreateJournalEntryLineInput,
JournalEntry,
+ VatTreatment,
} from '@/types'
/**
@@ -35,9 +37,16 @@ export interface CreateAssetInput {
salvage_value?: number
useful_life_months: number
depreciation_method?: DepreciationMethod
+ /** Required when depreciation_method = 'restvardesavskrivning_25'. */
+ restvarde_target?: number | null
bas_asset_account?: string
bas_accumulated_account?: string
bas_expense_account?: string
+ /** K3 component depreciation (BFNAR 2012:1 ch.17.4). When non-null, the
+ * engine sums per-component linear depreciation instead of applying
+ * `depreciation_method` to the asset as a whole. The API layer rejects
+ * writes for K2 companies with K3_REQUIRED_FOR_COMPONENTS. */
+ k3_components?: K3Component[] | null
notes?: string
}
@@ -55,6 +64,12 @@ export async function createAsset(
input: CreateAssetInput,
): Promise {
const defaults = DEFAULT_ACCOUNTS_BY_CATEGORY[input.category]
+ const method: DepreciationMethod = input.depreciation_method ?? 'linear'
+ // The DB CHECK constraint enforces the biconditional between method and
+ // restvarde_target. Pass null explicitly when not restvärde so a stale
+ // value never leaks through.
+ const restvarde =
+ method === 'restvardesavskrivning_25' ? input.restvarde_target ?? null : null
const row = {
user_id: userId,
company_id: companyId,
@@ -64,10 +79,15 @@ export async function createAsset(
acquisition_cost: input.acquisition_cost,
salvage_value: input.salvage_value ?? 0,
useful_life_months: input.useful_life_months,
- depreciation_method: input.depreciation_method ?? 'linear',
+ depreciation_method: method,
+ restvarde_target: restvarde,
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,
+ // K3 components are persisted as JSONB. The route handler enforces the
+ // accounting_framework='k3' gate; here we only pass the value through
+ // (null when omitted, so K2 assets stay clean).
+ k3_components: input.k3_components ?? null,
notes: input.notes ?? null,
}
@@ -126,17 +146,26 @@ export interface UpdateAssetInput {
salvage_value?: number
useful_life_months?: number
depreciation_method?: DepreciationMethod
+ /** Editable as long as method=restvärdeavskrivning. Set to null when
+ * switching back to a non-restvärde method (the DB CHECK enforces). */
+ restvarde_target?: number | null
bas_asset_account?: string
bas_accumulated_account?: string
bas_expense_account?: string
+ /** K3 component breakdown. Pass null to clear an existing breakdown
+ * (engine then falls back to depreciation_method). The route handler
+ * enforces accounting_framework='k3' + sum validation before delegating. */
+ k3_components?: K3Component[] | null
}
export async function updateAsset(
supabase: SupabaseClient,
companyId: string,
assetId: string,
- input: UpdateAssetInput,
+ inputParam: UpdateAssetInput,
): Promise {
+ // Copy so we can adjust restvarde_target without mutating the caller's object.
+ let input: UpdateAssetInput = { ...inputParam }
// 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
@@ -181,6 +210,37 @@ export async function updateAsset(
}
}
+ // Method / restvärde-target biconditional: required iff restvärdeavskrivning.
+ // Resolve final method+target across the merged row (existing + patch) so we
+ // can null the target when switching away and require it when switching in.
+ if (input.depreciation_method !== undefined || input.restvarde_target !== undefined) {
+ const existing = await getAsset(supabase, companyId, assetId)
+ if (!existing) throw new Error('Asset not found')
+ const finalMethod = input.depreciation_method ?? existing.depreciation_method
+ const finalTarget =
+ input.restvarde_target !== undefined ? input.restvarde_target : existing.restvarde_target
+ if (finalMethod === 'restvardesavskrivning_25' && (finalTarget === null || finalTarget === undefined)) {
+ throw new Error(
+ 'restvarde_target krävs när avskrivningsmetoden är restvärdeavskrivning (25 %).',
+ )
+ }
+ if (finalMethod !== 'restvardesavskrivning_25' && finalTarget !== null && finalTarget !== undefined) {
+ // Auto-null the target when switching away from restvärde so the DB
+ // CHECK doesn't reject the update.
+ input = { ...input, restvarde_target: null }
+ }
+ if (
+ finalMethod === 'restvardesavskrivning_25' &&
+ finalTarget !== null &&
+ finalTarget !== undefined &&
+ Number(finalTarget) >= Number(existing.acquisition_cost)
+ ) {
+ throw new Error(
+ 'restvarde_target måste vara lägre än anskaffningsvärdet — annars finns inget kvar att skriva av.',
+ )
+ }
+ }
+
const { data, error } = await supabase
.from('assets')
.update(input)
@@ -215,7 +275,8 @@ function inBasRange(account: string, range: [string, string]): boolean {
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. */
+ /** Cash / receivable received for the asset, INCLUDING VAT when applicable.
+ * Zero for scrapping. */
disposed_proceeds: number
/** Optional override for the bank/receivable account credited with the
* proceeds. Defaults to 1930 (företagskonto). */
@@ -224,6 +285,41 @@ export interface DisposeAssetInput {
* disposed_at — we don't auto-derive to keep the period-lock check at
* the route layer. */
fiscal_period_id: string
+ /**
+ * Output VAT on the proceeds (ML 3 kap 3 § / 7 kap 3 §). When > 0, a
+ * credit on the matching 26xx account is appended to the journal entry,
+ * and `disposed_proceeds` is treated as the GROSS amount (incl. VAT).
+ * The net (proceeds − vat) is what gets compared to NBV to compute
+ * gain/loss. Defaults to 0 (sale was momsfri / outside scope).
+ */
+ proceeds_vat?: number
+ /**
+ * Treatment for `proceeds_vat`. Required when `proceeds_vat > 0` because
+ * the engine needs to know which 26xx account to credit:
+ * - standard_25 → 2611
+ * - reduced_12 → 2621
+ * - reduced_6 → 2631
+ * - reverse_charge / export / exempt → no VAT line (treated as
+ * informational; proceeds_vat must be 0 in those cases)
+ */
+ vat_treatment?: VatTreatment
+ /**
+ * Jämkning amount per ML 8a kap 7 § — when the disposal happens inside
+ * the korrigeringstid, the originally-deducted input VAT must be
+ * partially paid back. The caller computes this via
+ * computeJamkningAmount() (lib/bokslut/assets/jamkning.ts) and passes
+ * the result here. A positive value means "pay back to the state" and
+ * is booked as a CREDIT to 2641 (reverses the original input-VAT
+ * deduction) with an offsetting debit on the asset's gain/loss account.
+ * Zero / undefined = no jämkning line.
+ */
+ jamkning_amount?: number
+ /** Audit metadata: remaining months in korrigeringstid at disposal date. */
+ jamkning_remaining_months?: number
+ /** Audit metadata: total korrigeringstid (60 or 120 months). */
+ jamkning_total_months?: number
+ /** Audit metadata: original input VAT deducted at acquisition. */
+ jamkning_original_input_vat?: number
}
export interface DisposalResult {
@@ -239,28 +335,23 @@ export interface DisposalResult {
* - 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).
+ * - Debit proceeds account (bank / receivable) for sale price (gross,
+ * incl VAT)
+ * - Credit 26xx (output VAT) when the sale is momspliktig (standard_25 →
+ * 2611, reduced_12 → 2621, reduced_6 → 2631)
+ * - Credit 2641 + Debit loss account for the jämkning amount when the
+ * disposal happens inside the korrigeringstid (ML 8a kap 4-7 §§)
+ * - Debit 78xx (loss on sale) OR Credit 30xx (gain on sale) for the
+ * net gain / loss vs NBV — accounts branch on category (3013/7813
+ * for immaterial, 3971/7971 for building / markanläggning, 3973/7973
+ * for everything else).
*
- * After posting, marks the asset row with disposed_at / disposed_proceeds.
+ * Gain/loss is computed on the NET proceeds (excl VAT), since VAT is a
+ * pass-through to Skatteverket and does not affect resultaträkningen.
+ *
+ * After posting, marks the asset row with disposed_at, disposed_proceeds,
+ * disposed_proceeds_vat, disposed_vat_treatment, and jämkning audit fields.
* 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,
@@ -283,17 +374,57 @@ export async function disposeAsset(
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 proceedsGross = round2(Number(input.disposed_proceeds))
+ const proceedsVat = round2(Number(input.proceeds_vat ?? 0))
+ const proceedsNet = round2(proceedsGross - proceedsVat)
+ const vatTreatment = input.vat_treatment
+ // Internal validation guard: when caller passes a VAT amount, treatment
+ // must accompany it so we can resolve the BAS 26xx account. The API
+ // layer also enforces this via Zod refinement; mirroring here keeps
+ // the engine self-defending against direct callers (MCP, scripts).
+ if (proceedsVat > 0.005 && !vatTreatment) {
+ throw new Error(
+ 'vat_treatment krävs när proceeds_vat > 0 — engine kan inte avgöra rätt 26xx-konto.',
+ )
+ }
+ // Treatments that produce no VAT line must carry 0 VAT.
+ if (
+ proceedsVat > 0.005 &&
+ vatTreatment &&
+ (vatTreatment === 'reverse_charge' ||
+ vatTreatment === 'export' ||
+ vatTreatment === 'exempt')
+ ) {
+ throw new Error(
+ `proceeds_vat måste vara 0 för momsbehandling "${vatTreatment}".`,
+ )
+ }
+
+ // Gain/loss is computed on the NET proceeds — VAT is pass-through and
+ // never hits the income statement.
+ const netBookValue = round2(acquisitionCost - accumulated)
+ const gainOrLoss = round2(proceedsNet - netBookValue)
const proceedsAccount = input.proceeds_account ?? '1930'
+ // ── Jämkning (ML 8a kap 7 §) ──────────────────────────────────────
+ // When the disposal happens inside the korrigeringstid, part of the
+ // originally-deducted input VAT must be paid back. Caller passes the
+ // precomputed amount (positive = debt to the state).
+ //
+ // Booking direction: We credit 2641 to reverse the original input-VAT
+ // deduction (2641 normal balance is debit; a credit reduces the
+ // deduction). The offset is debited to BAS 6991 — jämkning is a VAT
+ // correction per ML 8a kap, NOT a disposal loss, so it must NOT hit
+ // the 78xx förlust-vid-avyttring accounts. See the jämkning lines
+ // below for details.
+ const jamkning = round2(Number(input.jamkning_amount ?? 0))
+
const lines: CreateJournalEntryLineInput[] = []
if (accumulated > 0.005) {
lines.push({
account_number: asset.bas_accumulated_account,
- debit_amount: Math.round(accumulated * 100) / 100,
+ debit_amount: round2(accumulated),
credit_amount: 0,
line_description: `Avyttring: nollställ ack. avskrivning ${asset.name}`,
})
@@ -301,17 +432,31 @@ export async function disposeAsset(
lines.push({
account_number: asset.bas_asset_account,
debit_amount: 0,
- credit_amount: Math.round(acquisitionCost * 100) / 100,
+ credit_amount: round2(acquisitionCost),
line_description: `Avyttring: nollställ anskaffning ${asset.name}`,
})
- if (proceeds > 0.005) {
+ if (proceedsGross > 0.005) {
lines.push({
account_number: proceedsAccount,
- debit_amount: Math.round(proceeds * 100) / 100,
+ debit_amount: proceedsGross,
credit_amount: 0,
line_description: `Avyttring: erhållet belopp ${asset.name}`,
})
}
+
+ // Output VAT line — credit the matching 26xx account.
+ if (proceedsVat > 0.005 && vatTreatment) {
+ const vatAccount = outputVatAccountFor(vatTreatment)
+ if (vatAccount) {
+ lines.push({
+ account_number: vatAccount,
+ debit_amount: 0,
+ credit_amount: proceedsVat,
+ line_description: `Utgående moms ${vatRateLabel(vatTreatment)} avyttring ${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.
@@ -340,6 +485,43 @@ export async function disposeAsset(
})
}
+ // Jämkning lines — credit 2641 + debit 6991. Jämkning is a VAT
+ // correction per ML 8a kap, NOT a disposal loss. Routing it through
+ // the 78xx förlust-vid-avyttring accounts would distort both the
+ // gain/loss line on the income statement and the INK2R mapping, and
+ // mix tax-correction costs with disposal losses in the audit trail.
+ // BAS 6991 "Övriga externa kostnader, avdragsgilla" is the seeded
+ // catch-all för-en-extern-kostnad account that fits a repayment of
+ // previously-deducted input VAT.
+ if (jamkning > 0.005) {
+ lines.push({
+ account_number: '6991',
+ debit_amount: jamkning,
+ credit_amount: 0,
+ line_description: `Jämkning av tidigare avdragen ingående moms enligt ML 8a kap (${asset.name})`,
+ })
+ lines.push({
+ account_number: '2641',
+ debit_amount: 0,
+ credit_amount: jamkning,
+ line_description: `Återförd ingående moms jämkning ${asset.name}`,
+ })
+ }
+
+ // K3 component breakdown — when the asset was depreciated per-component,
+ // we surface the component list in the journal entry notes so auditors
+ // can trace which underlying components contributed to the disposal.
+ // Gain/loss math is unchanged: total book value is still
+ // acquisition_cost − accumulated_depreciation regardless of structure,
+ // because component depreciations sum into the same accumulated account.
+ const hasComponents =
+ Array.isArray(asset.k3_components) && asset.k3_components.length > 0
+ const componentNotes = hasComponents
+ ? `K3-komponenter: ${(asset.k3_components ?? [])
+ .map((c) => `${c.name} (${round2(Number(c.cost))} kr / ${c.useful_life_months} mån)`)
+ .join('; ')}`
+ : null
+
let disposalEntry: JournalEntry | null = null
if (lines.length > 0) {
disposalEntry = await createJournalEntry(supabase, companyId, userId, {
@@ -347,8 +529,8 @@ export async function disposeAsset(
entry_date: input.disposed_at,
description: `Avyttring av tillgång: ${asset.name}`,
source_type: 'manual',
- voucher_series: 'A',
lines,
+ ...(componentNotes ? { notes: componentNotes } : {}),
})
}
@@ -356,7 +538,13 @@ export async function disposeAsset(
.from('assets')
.update({
disposed_at: input.disposed_at,
- disposed_proceeds: proceeds,
+ disposed_proceeds: proceedsGross,
+ disposed_proceeds_vat: proceedsVat,
+ disposed_vat_treatment: vatTreatment ?? null,
+ jamkning_amount: jamkning,
+ jamkning_remaining_months: input.jamkning_remaining_months ?? null,
+ jamkning_total_months: input.jamkning_total_months ?? null,
+ jamkning_original_input_vat: input.jamkning_original_input_vat ?? null,
})
.eq('id', assetId)
.eq('company_id', companyId)
@@ -374,6 +562,40 @@ export async function disposeAsset(
}
}
+/** Resolve the BAS 26xx output-VAT account for a given VAT treatment.
+ * Returns null for treatments that produce no VAT line. */
+function outputVatAccountFor(treatment: VatTreatment): string | null {
+ switch (treatment) {
+ case 'standard_25':
+ return '2611'
+ case 'reduced_12':
+ return '2621'
+ case 'reduced_6':
+ return '2631'
+ case 'reverse_charge':
+ case 'export':
+ case 'exempt':
+ return null
+ }
+}
+
+function vatRateLabel(treatment: VatTreatment): string {
+ switch (treatment) {
+ case 'standard_25':
+ return '25%'
+ case 'reduced_12':
+ return '12%'
+ case 'reduced_6':
+ return '6%'
+ default:
+ return ''
+ }
+}
+
+function round2(n: number): number {
+ return Math.round(n * 100) / 100
+}
+
/**
* Sum every posted depreciation_schedules row for an asset to get accumulated
* depreciation as of "now". Used by disposeAsset so the caller cannot
@@ -401,3 +623,65 @@ async function sumPostedDepreciation(
0,
)
}
+
+/**
+ * Sum prior depreciation booked against an asset's 78xx avskrivningskonto
+ * up to and including `asOfDate`. Reads from journal_entry_lines so manually-
+ * posted avskrivningsverifikationer (i.e. not driven by depreciation_schedules)
+ * are also counted — the declining-balance engine needs the most accurate
+ * net book value to compute the next period's charge.
+ *
+ * Only counts posted entries against the asset's `bas_expense_account`
+ * (the 78xx avskrivningskonto), and only debits (since avskrivning = debit
+ * 78xx / credit 12x9). Returns 0 if the asset has never been depreciated.
+ *
+ * Why we look at the expense account rather than the accumulated account:
+ * the expense account is asset-specific by convention (7831 for machinery,
+ * 7832 for equipment, etc.) so we can scope per-asset accurately, whereas
+ * the accumulated account (12x9) may aggregate across assets in the same
+ * category. Limitation: when multiple assets share the same bas_expense_account
+ * we cannot disambiguate at the journal line level. The depreciation_schedules
+ * sum (see `sumPostedDepreciation`) is the safer fallback in that case.
+ * Callers that need exact per-asset accuracy should prefer the schedules sum.
+ */
+export async function getAccumulatedDepreciationAsOf(
+ supabase: SupabaseClient,
+ assetId: string,
+ asOfDate: string,
+): Promise {
+ // 1. Resolve the asset and its expense account first so we can target the
+ // correct 78xx code.
+ const { data: asset, error: assetError } = await supabase
+ .from('assets')
+ .select('bas_expense_account, company_id')
+ .eq('id', assetId)
+ .maybeSingle()
+
+ if (assetError) {
+ throw new Error(`Failed to load asset for accumulated depreciation: ${assetError.message}`)
+ }
+ if (!asset) return 0
+
+ type Row = { debit_amount: number | string | null; credit_amount: number | string | null }
+ const { data, error } = await supabase
+ .from('journal_entry_lines')
+ .select(
+ 'debit_amount, credit_amount, journal_entries!inner(company_id, status, entry_date)',
+ )
+ .eq('account_number', asset.bas_expense_account)
+ .eq('journal_entries.company_id', asset.company_id)
+ .eq('journal_entries.status', 'posted')
+ .lte('journal_entries.entry_date', asOfDate)
+
+ if (error) {
+ throw new Error(
+ `Failed to sum accumulated depreciation for asset ${assetId}: ${error.message}`,
+ )
+ }
+
+ return ((data ?? []) as Row[]).reduce((sum, row) => {
+ // Expense account — normal balance is debit. Net = debit − credit so
+ // any storno (reversal) is netted out.
+ return sum + ((Number(row.debit_amount) || 0) - (Number(row.credit_amount) || 0))
+ }, 0)
+}
diff --git a/lib/bokslut/assets/depreciation-engine.ts b/lib/bokslut/assets/depreciation-engine.ts
index 670fbed0..3f85d36f 100644
--- a/lib/bokslut/assets/depreciation-engine.ts
+++ b/lib/bokslut/assets/depreciation-engine.ts
@@ -8,6 +8,11 @@ import type {
CreateJournalEntryLineInput,
} from '@/types'
+/** Rate constants for the non-linear Swedish depreciation methods. */
+const DECLINING_RATE_30 = 0.3
+const DECLINING_RATE_20 = 0.2
+const RESTVARDE_RATE_25 = 0.25
+
export interface AssetDepreciation {
asset: Asset
/** Planenlig avskrivning för denna period, avrundad till hela kronor. */
@@ -34,22 +39,101 @@ export interface DepreciationProposal {
}
/**
- * Compute planenlig avskrivning för en enskild tillgång under en given
- * fiscal period. Pro-rateras vid första och sista året.
+ * Compute avskrivning för en enskild tillgång under en given fiscal period.
*
- * 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.
+ * Method dispatch:
+ * * 'linear' (planenlig raklinje) — pro-rates by day-overlap of the
+ * period with the asset's active life and the disposal cutoff. Annual
+ * amount = (acquisition_cost − salvage_value) × 12 / useful_life_months.
+ * * 'declining_balance_30' (räkenskapsenlig huvudregel, IL 18 kap 13§) —
+ * 30% of the current book value. No annual pro-ration: K2 10.23 says
+ * "full annual amount regardless of partial year" when the asset is
+ * put into use, mirrored in Swedish tax practice for IL 18 kap. Disposal
+ * in-period still zeros out: we return 0 if disposed before the period
+ * and the full 30% if disposed during, because the disposal entry itself
+ * takes care of the asset's remaining book value.
+ * * 'declining_balance_20' (kompletteringsregel, IL 18 kap 17§) — 20% of
+ * book value. Same proration semantics as 30%.
+ * * 'restvardesavskrivning_25' (IL 18 kap 13§ st.3) — 25% of
+ * max(0, currentBookValue − restvarde_target). Floors at the target so
+ * the asset is never charged below restvärde. Same proration semantics
+ * as the other declining methods.
+ *
+ * For non-linear methods, currentBookValue =
+ * acquisition_cost − accumulated_depreciation_through_period_start.
+ *
+ * Callers that don't know prior accumulated depreciation pass 0 (e.g. tests
+ * for year-1 declining-balance). The orchestrator (`proposeAnnualPostings`)
+ * fetches it from posted depreciation_schedules.
*/
export function computeAnnualDepreciation(
asset: Asset,
fiscalPeriod: Pick,
+ priorAccumulated: number = 0,
): { amount: number; proRated: boolean } {
if (asset.disposed_at && asset.disposed_at < fiscalPeriod.period_start) {
return { amount: 0, proRated: false }
}
+ // K3 component approach (BFNAR 2012:1 ch.17.4) overrides the method-based
+ // dispatch entirely. When non-null and non-empty, each component is
+ // depreciated linearly on its own life (with the same pro-ration logic
+ // as the asset-level linear method) and the per-component amounts are
+ // summed. The asset's `depreciation_method` and `salvage_value` are
+ // ignored — components carry their own salvage_value and life.
+ if (Array.isArray(asset.k3_components) && asset.k3_components.length > 0) {
+ const result = computeComponentDepreciation(asset, fiscalPeriod)
+ return { amount: result.amount, proRated: result.proRated }
+ }
+
+ const acquisitionCost = Number(asset.acquisition_cost)
+ const method = asset.depreciation_method
+
+ if (method === 'linear') {
+ return computeLinearAnnual(asset, fiscalPeriod)
+ }
+
+ // Declining-balance methods (huvudregel 30%, kompletteringsregel 20%,
+ // restvärde 25%) do NOT pro-rate annually — full-year amount applies
+ // regardless of acquisition month. Disposal during the period is handled
+ // by disposeAsset(); we still charge the full annual amount because the
+ // disposal entry zeroes out the residual.
+ const currentBookValue = acquisitionCost - priorAccumulated
+
+ // Already fully depreciated (linear-style accumulated overshoot) or
+ // negative — defensive guard.
+ if (currentBookValue <= 0.005) {
+ return { amount: 0, proRated: false }
+ }
+
+ let annualAmount = 0
+ if (method === 'declining_balance_30') {
+ annualAmount = currentBookValue * DECLINING_RATE_30
+ } else if (method === 'declining_balance_20') {
+ annualAmount = currentBookValue * DECLINING_RATE_20
+ } else if (method === 'restvardesavskrivning_25') {
+ const target = Number(asset.restvarde_target ?? 0)
+ const depreciable = currentBookValue - target
+ if (depreciable <= 0.005) {
+ // Already at or below restvärde — never deplete past the floor.
+ return { amount: 0, proRated: false }
+ }
+ annualAmount = depreciable * RESTVARDE_RATE_25
+ }
+
+ // Monetary rounding per CLAUDE.md guard-rail #9. Schedules store NUMERIC
+ // values, but the journal entry rounds to whole kronor downstream — match
+ // the linear branch which rounds to integer kronor for the entry amount.
+ return {
+ amount: Math.round(annualAmount),
+ proRated: false,
+ }
+}
+
+function computeLinearAnnual(
+ asset: Asset,
+ fiscalPeriod: Pick,
+): { amount: number; proRated: boolean } {
const acquisitionCost = Number(asset.acquisition_cost)
const salvageValue = Number(asset.salvage_value)
const depreciableBase = acquisitionCost - salvageValue
@@ -87,6 +171,96 @@ export function computeAnnualDepreciation(
}
}
+export interface ComponentDepreciationResult {
+ /** Sum of per-component depreciation, rounded to whole kronor. */
+ amount: number
+ /** True if any component was pro-rated (mid-year acquisition or disposal). */
+ proRated: boolean
+ /** Per-component breakdown — names mirror `asset.k3_components[*].name`.
+ * Each amount is rounded to whole kronor; the total `amount` is the sum
+ * of these rounded values (so the breakdown reconciles exactly with the
+ * total — no hidden öre). */
+ perComponent: { name: string; amount: number }[]
+}
+
+/**
+ * Compute component depreciation for a K3 asset (BFNAR 2012:1 ch.17.4).
+ *
+ * Mirrors `computeLinearAnnual` per component: the depreciable base is
+ * `cost − salvage_value` (salvage defaults to 0 when omitted), and the
+ * annual amount is `depreciableBase × 12 / useful_life_months`. The
+ * pro-ration window is the overlap between the period and the asset's
+ * active life — components share the same acquisition_date and disposal
+ * date as the parent asset, because BFNAR 2012:1 treats them as a single
+ * accounting unit for acquisition / disposal purposes; only the depreciation
+ * schedule is split.
+ *
+ * Per-component amounts are rounded to whole kronor individually, then
+ * summed, so the breakdown returned by this function reconciles exactly
+ * with `amount`. This matches the linear / declining methods which also
+ * round at the per-asset level.
+ */
+export function computeComponentDepreciation(
+ asset: Asset,
+ fiscalPeriod: Pick,
+): ComponentDepreciationResult {
+ const components = asset.k3_components ?? []
+ if (components.length === 0) {
+ return { amount: 0, proRated: false, perComponent: [] }
+ }
+
+ // Pre-compute the period vs life window so each component shares the
+ // same date math (acquisition_date and disposal date are asset-level).
+ const acquisition = isoToDate(asset.acquisition_date)
+ const periodStart = isoToDate(fiscalPeriod.period_start)
+ const periodEndInclusive = isoToDate(fiscalPeriod.period_end)
+ const disposalEnd = asset.disposed_at ? isoToDate(asset.disposed_at) : null
+ const fullPeriodDays = daysBetween(periodStart, periodEndInclusive) + 1
+
+ const perComponent: { name: string; amount: number }[] = []
+ let total = 0
+ let anyProRated = false
+
+ for (const [index, component] of components.entries()) {
+ const label = component.name?.trim() || `Komponent ${index + 1}`
+ const cost = Number(component.cost)
+ const salvage = Number(component.salvage_value ?? 0)
+ const depreciableBase = cost - salvage
+ if (depreciableBase <= 0 || component.useful_life_months <= 0) {
+ perComponent.push({ name: label, amount: 0 })
+ continue
+ }
+
+ const annualRate = 12 / component.useful_life_months
+ const lifeEndExclusive = addMonths(acquisition, component.useful_life_months)
+
+ const windowStart = maxDate(acquisition, periodStart)
+ let windowEnd = minDate(periodEndInclusive, addDays(lifeEndExclusive, -1))
+ if (disposalEnd) windowEnd = minDate(windowEnd, disposalEnd)
+
+ if (windowEnd < windowStart) {
+ perComponent.push({ name: label, amount: 0 })
+ continue
+ }
+
+ const windowDays = daysBetween(windowStart, windowEnd) + 1
+ const fraction = windowDays / fullPeriodDays
+ if (fraction < 0.999) anyProRated = true
+
+ const annualAmount = depreciableBase * annualRate
+ const proRatedAmount = annualAmount * fraction
+ const rounded = Math.round(proRatedAmount)
+ perComponent.push({ name: label, amount: rounded })
+ total += rounded
+ }
+
+ return {
+ amount: total,
+ proRated: anyProRated,
+ perComponent,
+ }
+}
+
/**
* Build a proposal listing planenlig avskrivning för every active asset.
* Reads existing depreciation_schedules so already-posted entries aren't
@@ -153,11 +327,15 @@ export async function proposeAnnualPostings(
// Skip assets disposed before period start
if (asset.disposed_at && asset.disposed_at < period.period_start) continue
- const { amount, proRated } = computeAnnualDepreciation(asset, period)
+ const accumulatedBefore = priorAccumulated.get(asset.id) ?? 0
+ const { amount, proRated } = computeAnnualDepreciation(
+ asset,
+ period,
+ accumulatedBefore,
+ )
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
@@ -233,7 +411,6 @@ export async function commitAnnualPostings(
entry_date: periodEnd,
description: `Planenlig avskrivning ${periodName}: ${item.asset.name}`,
source_type: 'year_end',
- voucher_series: 'A',
lines,
})
diff --git a/lib/bokslut/assets/jamkning.ts b/lib/bokslut/assets/jamkning.ts
new file mode 100644
index 00000000..bf20a4e4
--- /dev/null
+++ b/lib/bokslut/assets/jamkning.ts
@@ -0,0 +1,190 @@
+/**
+ * Jämkning helpers — input-VAT correction on disposal of investeringsvara
+ * within the korrigeringstid (ML 8a kap 4-7 §§).
+ *
+ * When an asset that had input VAT deducted at acquisition is disposed of
+ * within the correction period, part of the original deducted input VAT
+ * must be paid back. The amount is the portion that corresponds to the
+ * remaining months of the correction period.
+ *
+ * Correction periods per ML 8a kap 6 §:
+ * - 60 months (5 years) for lös egendom / movable property
+ * - 120 months (10 years) for fastighet / markanläggning (real property)
+ *
+ * The two functions in this file are PURE (no I/O, no Supabase, no clock
+ * read) so they can be tested with simple input/output cases.
+ *
+ * Caller responsibility:
+ * - Decide whether a disposal event triggers jämkning. The most common
+ * trigger is a sale within korrigeringstid, but ML 8a kap also lists
+ * "ändrad användning" and "utträde ur skattskyldighet". The caller
+ * passes the boolean so this helper stays domain-agnostic.
+ * - Source `originalInputVat`. For new assets this comes from the
+ * supplier invoice that booked the acquisition; for legacy assets the
+ * user has to enter it manually.
+ */
+
+import type { AssetCategory } from '@/types'
+
+/**
+ * Inputs to compute the jämkning amount on disposal.
+ */
+export interface JamkningInput {
+ /** Original input VAT deducted at acquisition (BAS 2641 debit). */
+ originalInputVat: number
+ /**
+ * Total correction period in months. 60 for movable property,
+ * 120 for fastighet / markanläggning. Caller decides which.
+ */
+ totalCorrectionMonths: number
+ /**
+ * Months remaining in the correction period as of the disposal date.
+ * Caller computes this so the helper avoids any clock / calendar
+ * dependency.
+ */
+ remainingMonths: number
+ /**
+ * Whether the disposal event triggers jämkning at all. Most disposals
+ * within the korrigeringstid trigger it, but the caller may opt out
+ * (e.g. the buyer continues to use the asset in a fully taxable
+ * verksamhet and assumes the jämkning obligation via avtal — ML 8a kap
+ * 12 §).
+ */
+ disposalEvent: 'triggers_jamkning' | 'no_jamkning'
+}
+
+/**
+ * Compute the jämkning amount per ML 8a kap 7 §. Returns a positive number
+ * representing the amount to be paid back to the state (i.e. reverse the
+ * input-VAT deduction). When disposal happens AFTER the correction period
+ * (remainingMonths <= 0) the formula returns 0 — caller can simply skip
+ * the line.
+ *
+ * Formula: (remaining / total) × originalInputVat
+ *
+ * Edge cases:
+ * - disposalEvent = 'no_jamkning' → 0
+ * - totalCorrectionMonths <= 0 → 0 (defensive — caller bug)
+ * - remainingMonths <= 0 → 0 (asset is past the correction period)
+ * - remainingMonths > totalCorrectionMonths → caps at originalInputVat
+ * (sold immediately, before any correction period has elapsed)
+ */
+export function computeJamkningAmount(input: JamkningInput): number {
+ if (input.disposalEvent === 'no_jamkning') return 0
+ if (input.totalCorrectionMonths <= 0) return 0
+ if (input.remainingMonths <= 0) return 0
+
+ const remaining = Math.min(input.remainingMonths, input.totalCorrectionMonths)
+ const raw = (remaining / input.totalCorrectionMonths) * input.originalInputVat
+ return Math.round(raw * 100) / 100
+}
+
+/**
+ * Suggested eligibility check for an asset disposal. Returns the
+ * totalCorrectionMonths the caller should pass to computeJamkningAmount,
+ * along with the remainingMonths derived from acquisitionDate and
+ * disposalDate.
+ *
+ * The threshold lives here (not in the asset row) because it's a property
+ * of the asset CATEGORY / BAS account class, not user-editable per-asset:
+ *
+ * - Fastighet (BAS 1100-1199) → 120 months
+ * - Markanläggning (BAS 1150-1159) — also 120 months
+ * - All other movable property → 60 months
+ *
+ * Pure: takes only dates and the asset's BAS account, returns numbers.
+ * Caller decides whether to surface the suggestion in the UI.
+ */
+export interface JamkningEligibility {
+ /** Suggested total correction period (60 or 120 months). */
+ totalCorrectionMonths: number
+ /** Months elapsed between acquisitionDate and disposalDate (clamped at 0). */
+ elapsedMonths: number
+ /** Remaining months in the correction period (clamped at 0). */
+ remainingMonths: number
+ /**
+ * Whether the disposal falls WITHIN the correction period. Convenience
+ * boolean — equivalent to `remainingMonths > 0`. Caller uses this to
+ * show / hide the jämkning UI.
+ */
+ withinCorrectionPeriod: boolean
+}
+
+export function assessJamkningEligibility(args: {
+ basExpenseAccount?: string
+ basAssetAccount?: string
+ category?: AssetCategory
+ acquisitionDate: string
+ disposalDate: string
+}): JamkningEligibility {
+ const totalCorrectionMonths = isRealProperty(args) ? 120 : 60
+ const elapsed = monthsBetween(args.acquisitionDate, args.disposalDate)
+ const elapsedClamped = Math.max(0, elapsed)
+ const remaining = Math.max(0, totalCorrectionMonths - elapsedClamped)
+ return {
+ totalCorrectionMonths,
+ elapsedMonths: elapsedClamped,
+ remainingMonths: remaining,
+ withinCorrectionPeriod: remaining > 0,
+ }
+}
+
+/**
+ * Real property (fastighet / markanläggning) per BAS 1100-1199 lives on
+ * the 10-year (120 mån) correction period. Everything else uses 5 years.
+ *
+ * The plan's contract is that this resolves off the asset's BAS account
+ * range, with category as a secondary signal. Two reasons we prefer
+ * account-driven over category-driven:
+ * 1. The account is what BAS reports / SIE / INK2R actually read; the
+ * category is just a UI label.
+ * 2. Users who override the BAS account to something outside the
+ * category's default range get a consistent answer with what their
+ * reports show.
+ */
+function isRealProperty(args: {
+ basExpenseAccount?: string
+ basAssetAccount?: string
+ category?: AssetCategory
+}): boolean {
+ // Prefer the asset (anskaffning) account when supplied — it's the most
+ // direct mapping to the BAS class.
+ const assetAccount = args.basAssetAccount
+ if (assetAccount && /^1[1][0-9]{2}$/.test(assetAccount)) return true
+ // Expense account check — 7820-7829 = byggnader/markanläggning.
+ const expense = args.basExpenseAccount
+ if (expense && /^782[0-9]$/.test(expense)) return true
+ // Category fallback for callers who only have the asset row's category
+ // (e.g. UI that hasn't loaded the full asset yet).
+ if (args.category === 'building' || args.category === 'land_improvement') {
+ return true
+ }
+ return false
+}
+
+/**
+ * Calendar months between two ISO date strings, rounded toward zero.
+ * Counts complete months only — partial months don't tick the clock.
+ *
+ * The Swedish tax authorities count months, not days, for jämkning
+ * (ML 8a kap 6 §). Example: acquired 2023-01-15, sold 2026-01-14 →
+ * 35 months elapsed (the 36th month hasn't completed yet).
+ */
+function monthsBetween(fromIso: string, toIso: string): number {
+ const from = parseIsoDate(fromIso)
+ const to = parseIsoDate(toIso)
+ if (!from || !to) return 0
+ let months = (to.year - from.year) * 12 + (to.month - from.month)
+ if (to.day < from.day) months -= 1
+ return months
+}
+
+function parseIsoDate(iso: string): { year: number; month: number; day: number } | null {
+ const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso)
+ if (!m) return null
+ return {
+ year: Number(m[1]),
+ month: Number(m[2]),
+ day: Number(m[3]),
+ }
+}
diff --git a/lib/bokslut/assets/k3-components.ts b/lib/bokslut/assets/k3-components.ts
new file mode 100644
index 00000000..79042811
--- /dev/null
+++ b/lib/bokslut/assets/k3-components.ts
@@ -0,0 +1,99 @@
+/**
+ * K3 component depreciation validation (BFNAR 2012:1 ch.17.4).
+ *
+ * Pure functions — no I/O — so unit tests can exercise edge cases without
+ * mocking Supabase or the engine. Shared between the Zod refinement on
+ * AssetCreate / AssetUpdate (`lib/api/schemas.ts`) and any callers that
+ * want to validate K3 components outside the API surface (MCP, scripts).
+ *
+ * The cross-column rule "sum(components.cost) ≈ acquisition_cost" cannot
+ * be expressed in a Postgres CHECK on JSONB, so we enforce it here. The
+ * engine itself does NOT re-validate at depreciation time — callers must
+ * ensure the asset row is consistent before persisting.
+ */
+import type { K3Component } from '@/types'
+
+/** Tolerance for the sum-equals-acquisition-cost check. 1 kr is enough to
+ * absorb öre rounding differences when the user types whole-kronor values
+ * but tight enough to catch real input errors. */
+const COMPONENT_SUM_TOLERANCE_KR = 1
+
+export interface ValidationResult {
+ /** Human-readable Swedish error messages. Empty array = valid. */
+ errors: string[]
+}
+
+/**
+ * Validate that an asset's K3 component breakdown is internally consistent
+ * and matches the asset's acquisition cost.
+ *
+ * Returns the full list of issues (the API can surface all at once instead
+ * of bailing on the first). The check is independent of accounting_framework —
+ * that gate sits at the API layer because this module is also used by
+ * non-HTTP callers that have already decided to use K3.
+ */
+export function validateComponents(asset: {
+ acquisition_cost: number
+ k3_components: K3Component[] | null | undefined
+}): ValidationResult {
+ const errors: string[] = []
+ const components = asset.k3_components
+
+ if (components === null || components === undefined) {
+ return { errors: [] }
+ }
+
+ if (!Array.isArray(components)) {
+ errors.push('k3_components måste vara en lista av komponenter eller null.')
+ return { errors }
+ }
+
+ if (components.length === 0) {
+ errors.push(
+ 'k3_components får inte vara en tom lista — sätt fältet till null om asset inte använder komponentuppdelning.',
+ )
+ return { errors }
+ }
+
+ let totalCost = 0
+ components.forEach((component, index) => {
+ const label = component.name?.trim() || `komponent ${index + 1}`
+ if (typeof component.cost !== 'number' || !Number.isFinite(component.cost)) {
+ errors.push(`${label}: anskaffningsvärdet måste vara ett tal.`)
+ return
+ }
+ if (component.cost <= 0) {
+ errors.push(`${label}: anskaffningsvärdet måste vara större än 0.`)
+ }
+ if (
+ typeof component.useful_life_months !== 'number'
+ || !Number.isInteger(component.useful_life_months)
+ || component.useful_life_months <= 0
+ ) {
+ errors.push(`${label}: nyttjandeperioden måste vara ett positivt heltal månader.`)
+ }
+ if (component.salvage_value !== undefined && component.salvage_value !== null) {
+ if (
+ typeof component.salvage_value !== 'number'
+ || !Number.isFinite(component.salvage_value)
+ || component.salvage_value < 0
+ ) {
+ errors.push(`${label}: restvärdet får inte vara negativt.`)
+ } else if (component.salvage_value > component.cost) {
+ errors.push(
+ `${label}: restvärdet (${component.salvage_value} kr) får inte överstiga anskaffningsvärdet (${component.cost} kr).`,
+ )
+ }
+ }
+ totalCost += component.cost
+ })
+
+ const expected = Number(asset.acquisition_cost)
+ if (Number.isFinite(expected) && Math.abs(totalCost - expected) > COMPONENT_SUM_TOLERANCE_KR) {
+ errors.push(
+ `Komponenter summerar till ${Math.round(totalCost * 100) / 100} kr men asset.acquisition_cost är ${Math.round(expected * 100) / 100} kr — differensen får vara högst ${COMPONENT_SUM_TOLERANCE_KR} kr.`,
+ )
+ }
+
+ return { errors }
+}
diff --git a/lib/bokslut/dispositions-proposal-builder.ts b/lib/bokslut/dispositions-proposal-builder.ts
index 5571239b..08a46c34 100644
--- a/lib/bokslut/dispositions-proposal-builder.ts
+++ b/lib/bokslut/dispositions-proposal-builder.ts
@@ -1,13 +1,21 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
+import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { calculateBolagsskatt } from './tax-provision/bolagsskatt-calculator'
import { calculateSarskildLoneskatt } from './tax-provision/sarskild-loneskatt-calculator'
+import {
+ computeLatentTax,
+ LATENT_TAX_EXPENSE_ACCOUNT,
+ LATENT_TAX_LIABILITY_ACCOUNT,
+ proposeLatentTaxChange,
+} from './tax-provision/latent-tax-calculator'
import {
listExistingPeriodiseringsfonder,
proposeAvsattning,
proposeAteforing,
} from './reserves/periodiseringsfond-service'
import type { DispositionsProposal, ProposedDisposition } from './types'
+import type { AccountingFramework } from '@/types'
const DEFAULT_SCHABLONINTAKT_RATE = 0.0355
@@ -40,6 +48,14 @@ export async function buildDispositionsProposal(
const entityType = (settings?.entity_type ?? 'aktiebolag') as DispositionsProposal['entityType']
if (entityType !== 'aktiebolag') {
+ // Non-AB entities (enskild firma, handelsbolag, etc.) do not produce
+ // bookable bokslutsdispositioner — bolagsskatt, periodiseringsfond and
+ // SLP are AB-only mechanisms. EF tax mechanisms (egenavgifter,
+ // räntefördelning, periodiseringsfond-EF, expansionsfond) are
+ // declaration-only and surface through the dedicated
+ // /api/bookkeeping/fiscal-periods/[id]/ef-declaration endpoint and the
+ // EfDeclarationSection in the wizard — they never produce journal
+ // entries, so they have no place in this list.
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
return {
entityType,
@@ -49,6 +65,19 @@ export async function buildDispositionsProposal(
}
}
+ // Look up the accounting framework — K3 (BFNAR 2012:1) triggers the
+ // uppskjuten-skatt provision step; K2 skips it.
+ const { data: companyRow } = await supabase
+ .from('companies')
+ .select('accounting_framework')
+ .eq('id', companyId)
+ .maybeSingle()
+ const accountingFramework: AccountingFramework =
+ (companyRow as { accounting_framework?: AccountingFramework } | null)?.accounting_framework
+ === 'k3'
+ ? 'k3'
+ : 'k2'
+
const fiscalYear = parseInt(period.period_end.slice(0, 4), 10)
const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
const resultBeforeTax = incomeStatement.net_result
@@ -81,6 +110,21 @@ export async function buildDispositionsProposal(
})
if (bolagsskatt) proposals.push(bolagsskatt)
+ // K3 only: split obeskattade reserver into the 79.4 % equity portion and
+ // the 20.6 % uppskjuten skatteskuld. We sum the projected 21xx balance
+ // AFTER the dispositions above have been applied so the latent-tax
+ // amount reflects the closing position — anything else would diverge
+ // from the BR the user sees in the preview.
+ if (accountingFramework === 'k3') {
+ const latentTax = await buildLatentTaxProposal({
+ supabase,
+ companyId,
+ fiscalPeriodId,
+ proposalsBeforeLatentTax: proposals,
+ })
+ if (latentTax) proposals.push(latentTax)
+ }
+
return {
entityType,
fiscalPeriod: period,
@@ -88,3 +132,80 @@ export async function buildDispositionsProposal(
proposals,
}
}
+
+/**
+ * Compose the K3 uppskjuten-skatt proposal.
+ *
+ * The latent tax provision must reflect the *closing* obeskattade-reserver
+ * balance, so we pull the current 21xx balance from the trial balance and
+ * adjust it for any 21xx-touching dispositions that haven't yet posted
+ * (avsättning ↑, återföring ↓). 2240's current balance is the existing
+ * provision; the delta becomes the new verifikat.
+ */
+export async function buildLatentTaxProposal(params: {
+ supabase: SupabaseClient
+ companyId: string
+ fiscalPeriodId: string
+ /** Optional — additional 21xx-touching dispositions that have NOT yet been
+ * posted but will be in the same batch. The TB already reflects everything
+ * posted, so leave this empty if the latent-tax run is sequenced after the
+ * 21xx postings (the API route's case). */
+ proposalsBeforeLatentTax?: ProposedDisposition[]
+}): Promise {
+ const { supabase, companyId, fiscalPeriodId, proposalsBeforeLatentTax = [] } = params
+
+ const tb = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
+
+ // 21xx — obeskattade reserver (credit-normal, so we measure credit − debit).
+ let untaxedReserves = tb.rows
+ .filter((r) => r.account_number.startsWith('21'))
+ .reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
+
+ // Pending 21xx postings from the proposals that will commit alongside
+ // latent tax. Avsättning adds to the reserves (credit 21xx), återföring
+ // removes (debit 21xx).
+ for (const p of proposalsBeforeLatentTax) {
+ if (
+ p.kind !== 'periodiseringsfond_avsattning'
+ && p.kind !== 'periodiseringsfond_ateforing'
+ ) continue
+ for (const line of p.lines) {
+ if (!line.account_number.startsWith('21')) continue
+ untaxedReserves += (line.credit_amount ?? 0) - (line.debit_amount ?? 0)
+ }
+ }
+
+ // Current 2240 balance — credit-normal. Equal to existing latent tax.
+ const current2240 = tb.rows
+ .filter((r) => r.account_number === LATENT_TAX_LIABILITY_ACCOUNT)
+ .reduce((s, r) => s + (r.closing_credit - r.closing_debit), 0)
+
+ const split = computeLatentTax({ untaxedReserves })
+ const lines = proposeLatentTaxChange(current2240, split.liabilityPortion)
+ if (!lines) return null
+
+ const delta = Math.round((split.liabilityPortion - current2240) * 100) / 100
+ const amount = Math.abs(delta)
+ const direction = delta > 0 ? 'avsättning' : 'återföring'
+
+ return {
+ kind: 'uppskjuten_skatt',
+ label: 'Uppskjuten skatt (K3)',
+ description:
+ delta > 0
+ ? `Avsättning till uppskjuten skatteskuld 20,6 % av obeskattade reserver. Debet ${LATENT_TAX_EXPENSE_ACCOUNT}, kredit ${LATENT_TAX_LIABILITY_ACCOUNT}.`
+ : `Återföring av uppskjuten skatteskuld när obeskattade reserver minskar. Debet ${LATENT_TAX_LIABILITY_ACCOUNT}, kredit ${LATENT_TAX_EXPENSE_ACCOUNT}.`,
+ amount,
+ lines,
+ warnings: [],
+ computation: {
+ untaxedReserves,
+ taxRate: 0.206,
+ target2240: split.liabilityPortion,
+ current2240,
+ delta,
+ direction,
+ equityPortion: split.equityPortion,
+ },
+ }
+}
diff --git a/lib/bokslut/readiness-aggregator.ts b/lib/bokslut/readiness-aggregator.ts
index 22dddb9d..4cb02abb 100644
--- a/lib/bokslut/readiness-aggregator.ts
+++ b/lib/bokslut/readiness-aggregator.ts
@@ -1,6 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
+import { computeEfDeclarationPreview } from '@/lib/bokslut/enskild-firma/ef-declaration-preview'
import type { YearEndValidation } from '@/types'
export type ReminderSeverity = 'info' | 'warning'
@@ -141,12 +142,35 @@ export async function buildBokslutReadinessReport(
})
if (entityType === 'enskild_firma') {
+ // Pre-compute the EF declaration so the wizard's overview reflects what
+ // the user will see when they reach the dispositions step. Egenavgifter,
+ // räntefördelning, periodiseringsfond-EF and expansionsfond are NOT
+ // booked — they go into the NE-bilaga / INK1. This reminder explains
+ // the BFL distinction.
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.',
+ 'Egenavgifter, räntefördelning, periodiseringsfond och expansionsfond beräknas i NE-bilagan, inte bokförs. Skatten betalas privat av ägaren.',
})
+
+ // Surface a soft warning when kapitalunderlag is missing AND the booked
+ // surplus is large enough to make positive räntefördelning meaningful
+ // (> 50 000 kr — the spärrbelopp). This is non-blocking but actionable:
+ // the user should enter their IB equity on the dispositions step.
+ try {
+ const preview = await computeEfDeclarationPreview(supabase, companyId, fiscalPeriodId)
+ if (preview.bookedSurplus > 50_000) {
+ reminders.push({
+ code: 'ef_kapitalunderlag_missing',
+ severity: 'warning',
+ message:
+ 'Kapitalunderlag (IB eget kapital) saknas — räntefördelning beräknas inte. Fyll i på dispositionssteget för att utnyttja skattefördelen.',
+ })
+ }
+ } catch {
+ // EF preview is informational — never block readiness on it.
+ }
}
return {
diff --git a/lib/bokslut/rounding.ts b/lib/bokslut/rounding.ts
new file mode 100644
index 00000000..f04cb0ba
--- /dev/null
+++ b/lib/bokslut/rounding.ts
@@ -0,0 +1,45 @@
+/**
+ * Centralized öre-precision rounding for bokslut and continuity logic.
+ *
+ * Swedish öresavrundning was abolished in 2010, but our journal entries
+ * still store amounts in hundredths of SEK. Floating-point arithmetic
+ * accumulates IEEE 754 drift, so all monetary calculations must funnel
+ * through `roundOre()` before being compared, summed across rows, or
+ * persisted as journal_entry_lines.
+ *
+ * Per CLAUDE.md accounting guard rail #9: never use `.toFixed()` for money.
+ */
+
+/**
+ * Round a SEK amount to the nearest öre (two decimal places).
+ *
+ * Naive `Math.round(x * 100) / 100` fails on exact-half values like 1.005
+ * because IEEE-754 stores 1.005 as 1.00499999…, so multiplying by 100
+ * yields 100.49999… and Math.round drops it to 100 instead of 101.
+ *
+ * The Number.EPSILON nudge bridges the IEEE gap for double-precision
+ * values near unit magnitude — large enough to push 100.49999… across
+ * the half-integer boundary, small enough to leave well-formed decimals
+ * (1.234, 1.235, etc.) untouched. Zero is special-cased so negative-zero
+ * inputs preserve their sign through the round trip.
+ */
+export function roundOre(n: number): number {
+ if (n === 0) return n
+ return Math.round((n + Number.EPSILON) * 100) / 100
+}
+
+/**
+ * Tolerance for comparing two öre-rounded amounts.
+ *
+ * Half an öre is the strictest meaningful threshold: any difference
+ * larger than this represents a real one-öre discrepancy, not float
+ * drift. Use for invariant assertions on closing entries, IB/UB
+ * continuity per-account, and balance-sheet equality checks.
+ *
+ * Note: previously `continuity-check.ts` used 0.01 as its threshold.
+ * That extra slack was meant to absorb drift from chained Math.round
+ * calls, but with all rounding now centralized through `roundOre()`
+ * the half-öre threshold is correct and tighter — a one-öre real
+ * discrepancy must always surface.
+ */
+export const ORE_TOLERANCE = 0.005
diff --git a/lib/bokslut/tax-provision/latent-tax-calculator.ts b/lib/bokslut/tax-provision/latent-tax-calculator.ts
new file mode 100644
index 00000000..c64cc6a9
--- /dev/null
+++ b/lib/bokslut/tax-provision/latent-tax-calculator.ts
@@ -0,0 +1,118 @@
+import type { CreateJournalEntryLineInput } from '@/types'
+
+/**
+ * Statutory split of obeskattade reserver (21xx — periodiseringsfonder,
+ * överavskrivningar, etc.) under K3 (BFNAR 2012:1).
+ *
+ * The full balance is taxable when the reserve is reversed, so K3 requires
+ * the balance to be presented as:
+ * - 79.4 % equity (bundet eget kapital — the post-tax economic value)
+ * - 20.6 % deferred tax liability (uppskjuten skatteskuld, account 2240)
+ *
+ * 20.6 % is the current Swedish bolagsskatt rate (since 2021). If the rate
+ * changes (it has been 20.6 % since fiscal year 2021), pass an override.
+ *
+ * Under K2 this split does NOT apply — the obeskattade reserver row stays
+ * intact between eget kapital and skulder and 2240 is excluded from the chart.
+ */
+export const LATENT_TAX_DEFAULT_RATE = 0.206
+
+/** BAS account for the K3 latent tax liability. */
+export const LATENT_TAX_LIABILITY_ACCOUNT = '2240'
+
+/** BAS account for the K3 latent tax expense (income statement). */
+export const LATENT_TAX_EXPENSE_ACCOUNT = '8940'
+
+export interface LatentTaxSplit {
+ /** The 79.4 % portion that K3 folds into equity for soliditet / BR purposes. */
+ equityPortion: number
+ /** The 20.6 % portion presented as a separate deferred tax liability (2240). */
+ liabilityPortion: number
+}
+
+/**
+ * Compute the K3 equity / liability split of a given untaxed-reserves total.
+ *
+ * Pure function — no DB access. Caller passes the *current* sum of 21xx
+ * (post all dispositioner the user has accepted in the bokslut flow).
+ *
+ * Monetary precision: rounds to öre (2 decimals) via Math.round(x*100)/100
+ * per project convention. liabilityPortion is computed first so the split
+ * always reconciles (equityPortion = total − liability rounded the same way).
+ *
+ * Negative reserves are unusual (would indicate over-reversal) but the math
+ * is symmetric — both portions come out negative, preserving the sign.
+ */
+export function computeLatentTax(params: {
+ untaxedReserves: number
+ taxRate?: number
+}): LatentTaxSplit {
+ const taxRate = params.taxRate ?? LATENT_TAX_DEFAULT_RATE
+ const liabilityPortion = Math.round(params.untaxedReserves * taxRate * 100) / 100
+ const equityPortion = Math.round((params.untaxedReserves - liabilityPortion) * 100) / 100
+ return { equityPortion, liabilityPortion }
+}
+
+/**
+ * Tolerance for "no change" detection. The latent tax provision posts in
+ * whole krona via the engine, so an absolute delta below 1 öre means the
+ * stored 2240 balance already matches the target and no adjustment is needed.
+ */
+const LATENT_TAX_ORE_TOLERANCE = 0.01
+
+/**
+ * Generate the journal lines needed to move the 2240 balance from its
+ * current amount to the new target. Returns null when no adjustment is
+ * required (delta below 1 öre — see {@link LATENT_TAX_ORE_TOLERANCE}).
+ *
+ * Direction:
+ * - target > current → latent tax LIABILITY grew → debit 8940 (cost),
+ * credit 2240 (liability).
+ * - target < current → latent tax LIABILITY shrank → debit 2240, credit
+ * 8940 (income — a reversal of prior expense).
+ *
+ * The entry posts as `source_type='year_end'` and is meant to be created via
+ * `createJournalEntry()` so the engine assigns the voucher number atomically
+ * and enforces the balance/period rules.
+ */
+export function proposeLatentTaxChange(
+ currentLatentTax2240: number,
+ targetLatentTax2240: number,
+): CreateJournalEntryLineInput[] | null {
+ const delta = Math.round((targetLatentTax2240 - currentLatentTax2240) * 100) / 100
+ if (Math.abs(delta) < LATENT_TAX_ORE_TOLERANCE) return null
+
+ const absoluteDelta = Math.abs(delta)
+ if (delta > 0) {
+ // Liability increased — debit 8940 (expense), credit 2240 (liability).
+ return [
+ {
+ account_number: LATENT_TAX_EXPENSE_ACCOUNT,
+ debit_amount: absoluteDelta,
+ credit_amount: 0,
+ line_description: 'Förändring uppskjuten skatt (K3)',
+ },
+ {
+ account_number: LATENT_TAX_LIABILITY_ACCOUNT,
+ debit_amount: 0,
+ credit_amount: absoluteDelta,
+ line_description: 'Avsättning uppskjuten skatteskuld',
+ },
+ ]
+ }
+ // Liability decreased — debit 2240, credit 8940.
+ return [
+ {
+ account_number: LATENT_TAX_LIABILITY_ACCOUNT,
+ debit_amount: absoluteDelta,
+ credit_amount: 0,
+ line_description: 'Återföring uppskjuten skatteskuld',
+ },
+ {
+ account_number: LATENT_TAX_EXPENSE_ACCOUNT,
+ debit_amount: 0,
+ credit_amount: absoluteDelta,
+ line_description: 'Förändring uppskjuten skatt (K3)',
+ },
+ ]
+}
diff --git a/lib/bokslut/types.ts b/lib/bokslut/types.ts
index 49ccb844..2de85025 100644
--- a/lib/bokslut/types.ts
+++ b/lib/bokslut/types.ts
@@ -6,6 +6,7 @@ export type DispositionKind =
| 'periodiseringsfond_ateforing'
| 'overavskrivningar'
| 'sarskild_loneskatt'
+ | 'uppskjuten_skatt'
/**
* Common shape every bokslut-disposition calculator returns. The wizard renders
diff --git a/lib/bookkeeping/__tests__/invoice-entries.test.ts b/lib/bookkeeping/__tests__/invoice-entries.test.ts
index ff9e94a1..8719ba49 100644
--- a/lib/bookkeeping/__tests__/invoice-entries.test.ts
+++ b/lib/bookkeeping/__tests__/invoice-entries.test.ts
@@ -657,3 +657,253 @@ describe('createInvoicePaymentJournalEntry — exchange rate difference', () =>
expect(totalDebit).toBe(totalCredit)
})
})
+
+describe('createInvoiceJournalEntry — ROT/RUT-avdrag', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('single ROT line: 10 000 kr labor → 1513 debit 3 000, 1510 debit 7 000 + 2 500 VAT', async () => {
+ // 10 000 kr labor with 25% VAT = 12 500 total. ROT = 30% of 10 000 = 3 000.
+ // Customer owes (12 500 - 3 000) = 9 500. Skatteverket pays 3 000.
+ const invoice = makeInvoice({
+ subtotal: 10000,
+ vat_amount: 2500,
+ total: 12500,
+ vat_treatment: 'standard_25',
+ items: [
+ makeItem({
+ quantity: 1,
+ unit_price: 10000,
+ line_total: 10000,
+ vat_rate: 25,
+ vat_amount: 2500,
+ deduction_type: 'rot',
+ deduction_amount: 3000,
+ }),
+ ],
+ })
+
+ await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
+
+ expect(mockedCreateEntry).toHaveBeenCalledOnce()
+ const input = mockedCreateEntry.mock.calls[0][3]
+
+ // Lines: 1510 (debit 9500) + 1513 (debit 3000) + 3001 (credit 10000) + 2611 (credit 2500)
+ expect(input.lines).toHaveLength(4)
+
+ const debit1510 = input.lines.find((l) => l.account_number === '1510')
+ expect(debit1510?.debit_amount).toBe(9500)
+
+ const debit1513 = input.lines.find((l) => l.account_number === '1513')
+ expect(debit1513?.debit_amount).toBe(3000)
+ expect(debit1513?.credit_amount).toBe(0)
+
+ const credit3001 = input.lines.find((l) => l.account_number === '3001')
+ expect(credit3001?.credit_amount).toBe(10000)
+
+ const credit2611 = input.lines.find((l) => l.account_number === '2611')
+ expect(credit2611?.credit_amount).toBe(2500)
+
+ // Balance: 9500 + 3000 = 12500 = 10000 + 2500
+ const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
+ expect(totalDebit).toBe(totalCredit)
+ expect(totalDebit).toBe(12500)
+ })
+
+ it('mixed invoice: ROT line + non-deduction line — per-item handling', async () => {
+ // ROT line 10 000 (deduction 3 000) + non-deduction materials line 4 000.
+ // Total 14 000 + 25% VAT = 17 500. Customer owes 14 500. Skatteverket 3 000.
+ const invoice = makeInvoice({
+ subtotal: 14000,
+ vat_amount: 3500,
+ total: 17500,
+ vat_treatment: 'standard_25',
+ items: [
+ makeItem({
+ quantity: 1,
+ unit_price: 10000,
+ line_total: 10000,
+ vat_rate: 25,
+ vat_amount: 2500,
+ deduction_type: 'rot',
+ deduction_amount: 3000,
+ }),
+ makeItem({
+ id: 'item-2',
+ quantity: 1,
+ unit_price: 4000,
+ line_total: 4000,
+ vat_rate: 25,
+ vat_amount: 1000,
+ // No deduction
+ }),
+ ],
+ })
+
+ await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
+
+ const input = mockedCreateEntry.mock.calls[0][3]
+
+ // Lines: 1510 (debit 14500) + 1513 (debit 3000) + 3001 (credit 14000) + 2611 (credit 3500)
+ expect(input.lines).toHaveLength(4)
+
+ const debit1510 = input.lines.find((l) => l.account_number === '1510')
+ expect(debit1510?.debit_amount).toBe(14500)
+
+ const debit1513 = input.lines.find((l) => l.account_number === '1513')
+ expect(debit1513?.debit_amount).toBe(3000)
+
+ // Balance: 14500 + 3000 = 17500
+ const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
+ expect(totalDebit).toBe(totalCredit)
+ expect(totalDebit).toBe(17500)
+ })
+
+ it('RUT line with 50% rate: 5 000 kr → 1513 debit 2 500', async () => {
+ // 5 000 labor with 25% VAT = 6 250 total. RUT = 50% of 5 000 = 2 500.
+ const invoice = makeInvoice({
+ subtotal: 5000,
+ vat_amount: 1250,
+ total: 6250,
+ vat_treatment: 'standard_25',
+ items: [
+ makeItem({
+ quantity: 1,
+ unit_price: 5000,
+ line_total: 5000,
+ vat_rate: 25,
+ vat_amount: 1250,
+ deduction_type: 'rut',
+ deduction_amount: 2500,
+ }),
+ ],
+ })
+
+ await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
+
+ const input = mockedCreateEntry.mock.calls[0][3]
+
+ const debit1513 = input.lines.find((l) => l.account_number === '1513')
+ expect(debit1513?.debit_amount).toBe(2500)
+ expect(debit1513?.line_description).toMatch(/RUT/)
+
+ const debit1510 = input.lines.find((l) => l.account_number === '1510')
+ expect(debit1510?.debit_amount).toBe(3750) // 6250 - 2500
+
+ // Balance
+ const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
+ expect(totalDebit).toBe(totalCredit)
+ })
+
+ it('no deduction_type → no 1513 line, normal AR debit', async () => {
+ const invoice = makeInvoice({
+ subtotal: 1000,
+ vat_amount: 250,
+ total: 1250,
+ items: [
+ makeItem({ quantity: 1, unit_price: 1000, line_total: 1000, vat_rate: 25, vat_amount: 250 }),
+ ],
+ })
+
+ await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
+
+ const input = mockedCreateEntry.mock.calls[0][3]
+
+ expect(input.lines.find((l) => l.account_number === '1513')).toBeUndefined()
+ const debit1510 = input.lines.find((l) => l.account_number === '1510')
+ expect(debit1510?.debit_amount).toBe(1250)
+ })
+
+ it('two ROT lines: per-line 1513 debits sum to invoice deduction total', async () => {
+ // 6 000 + 4 000 labor, both ROT 30% → 1 800 + 1 200 = 3 000 total.
+ const invoice = makeInvoice({
+ subtotal: 10000,
+ vat_amount: 2500,
+ total: 12500,
+ vat_treatment: 'standard_25',
+ items: [
+ makeItem({
+ quantity: 1,
+ unit_price: 6000,
+ line_total: 6000,
+ vat_rate: 25,
+ vat_amount: 1500,
+ deduction_type: 'rot',
+ deduction_amount: 1800,
+ }),
+ makeItem({
+ id: 'item-2',
+ quantity: 1,
+ unit_price: 4000,
+ line_total: 4000,
+ vat_rate: 25,
+ vat_amount: 1000,
+ deduction_type: 'rot',
+ deduction_amount: 1200,
+ }),
+ ],
+ })
+
+ await createInvoiceJournalEntry(null as never, 'company-1', 'user-1', invoice)
+
+ const input = mockedCreateEntry.mock.calls[0][3]
+
+ const debit1513Lines = input.lines.filter((l) => l.account_number === '1513')
+ expect(debit1513Lines).toHaveLength(2)
+ const total1513 = debit1513Lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ expect(total1513).toBe(3000)
+
+ const debit1510 = input.lines.find((l) => l.account_number === '1510')
+ expect(debit1510?.debit_amount).toBe(9500) // 12500 - 3000
+
+ // Balance
+ const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
+ expect(totalDebit).toBe(totalCredit)
+ })
+})
+
+describe('createInvoiceCashEntry — ROT/RUT-avdrag', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('cash method ROT: 1930 debit reduced by deduction, 1513 carries the rest', async () => {
+ const invoice = makeInvoice({
+ subtotal: 10000,
+ vat_amount: 2500,
+ total: 12500,
+ vat_treatment: 'standard_25',
+ items: [
+ makeItem({
+ quantity: 1,
+ unit_price: 10000,
+ line_total: 10000,
+ vat_rate: 25,
+ vat_amount: 2500,
+ deduction_type: 'rot',
+ deduction_amount: 3000,
+ }),
+ ],
+ })
+
+ await createInvoiceCashEntry(null as never, 'company-1', 'user-1', invoice, '2024-07-01')
+
+ const input = mockedCreateEntry.mock.calls[0][3]
+
+ const debit1930 = input.lines.find((l) => l.account_number === '1930')
+ expect(debit1930?.debit_amount).toBe(9500)
+
+ const debit1513 = input.lines.find((l) => l.account_number === '1513')
+ expect(debit1513?.debit_amount).toBe(3000)
+
+ // Balance
+ const totalDebit = input.lines.reduce((sum, l) => sum + l.debit_amount, 0)
+ const totalCredit = input.lines.reduce((sum, l) => sum + l.credit_amount, 0)
+ expect(totalDebit).toBe(totalCredit)
+ })
+})
diff --git a/lib/bookkeeping/__tests__/reminder-fee-entries.test.ts b/lib/bookkeeping/__tests__/reminder-fee-entries.test.ts
new file mode 100644
index 00000000..e83cd13f
--- /dev/null
+++ b/lib/bookkeeping/__tests__/reminder-fee-entries.test.ts
@@ -0,0 +1,122 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import type { CreateJournalEntryInput } from '@/types'
+
+vi.mock('../engine', () => ({
+ findFiscalPeriod: vi.fn().mockResolvedValue('period-1'),
+ createJournalEntry: vi.fn().mockImplementation(
+ async (_supabase: unknown, _companyId: string, _userId: string, input: CreateJournalEntryInput) => ({
+ id: 'entry-reminder-fee-1',
+ ...input,
+ lines: input.lines,
+ }),
+ ),
+}))
+
+const { createJournalEntry, findFiscalPeriod } = await import('../engine')
+const { createReminderFeeEntry } = await import('../reminder-fee-entries')
+
+const mockedCreateEntry = vi.mocked(createJournalEntry)
+const mockedFindFiscalPeriod = vi.mocked(findFiscalPeriod)
+
+beforeEach(() => {
+ vi.clearAllMocks()
+ mockedFindFiscalPeriod.mockResolvedValue('period-1')
+ mockedCreateEntry.mockImplementation(
+ async (_supabase: unknown, _companyId: string, _userId: string, input: CreateJournalEntryInput) => ({
+ id: 'entry-reminder-fee-1',
+ ...input,
+ lines: input.lines,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ }) as any,
+ )
+})
+
+describe('createReminderFeeEntry', () => {
+ it('books a balanced entry: debit 1510, credit 3990', async () => {
+ const result = await createReminderFeeEntry({} as never, {
+ invoiceId: 'inv-1',
+ invoiceNumber: 'F2026001',
+ companyId: 'company-1',
+ userId: 'user-1',
+ feeAmount: 60,
+ asOfDate: '2026-05-26',
+ })
+
+ expect(result?.journal_entry_id).toBe('entry-reminder-fee-1')
+ expect(mockedCreateEntry).toHaveBeenCalledTimes(1)
+
+ const callArgs = mockedCreateEntry.mock.calls[0]
+ const input = callArgs[3] as CreateJournalEntryInput
+
+ expect(input.source_type).toBe('reminder_fee')
+ expect(input.source_id).toBe('inv-1')
+ expect(input.fiscal_period_id).toBe('period-1')
+ expect(input.entry_date).toBe('2026-05-26')
+ expect(input.description).toBe('Påminnelseavgift faktura F2026001')
+
+ expect(input.lines).toHaveLength(2)
+ const debitLine = input.lines.find((l) => l.account_number === '1510')
+ const creditLine = input.lines.find((l) => l.account_number === '3990')
+ expect(debitLine?.debit_amount).toBe(60)
+ expect(debitLine?.credit_amount).toBe(0)
+ expect(creditLine?.debit_amount).toBe(0)
+ expect(creditLine?.credit_amount).toBe(60)
+ })
+
+ it('rounds the fee to 2 decimals (defense in depth — caller should already round)', async () => {
+ await createReminderFeeEntry({} as never, {
+ invoiceId: 'inv-1',
+ invoiceNumber: 'F2026001',
+ companyId: 'company-1',
+ userId: 'user-1',
+ feeAmount: 59.999,
+ asOfDate: '2026-05-26',
+ })
+
+ const input = mockedCreateEntry.mock.calls[0][3] as CreateJournalEntryInput
+ const debit = input.lines.find((l) => l.account_number === '1510')!
+ const credit = input.lines.find((l) => l.account_number === '3990')!
+ expect(debit.debit_amount).toBe(60)
+ expect(credit.credit_amount).toBe(60)
+ })
+
+ it('returns null and does not call the engine when feeAmount is 0', async () => {
+ const result = await createReminderFeeEntry({} as never, {
+ invoiceId: 'inv-1',
+ invoiceNumber: 'F2026001',
+ companyId: 'company-1',
+ userId: 'user-1',
+ feeAmount: 0,
+ asOfDate: '2026-05-26',
+ })
+ expect(result).toBeNull()
+ expect(mockedCreateEntry).not.toHaveBeenCalled()
+ })
+
+ it('returns null and does not call the engine when feeAmount is negative', async () => {
+ const result = await createReminderFeeEntry({} as never, {
+ invoiceId: 'inv-1',
+ invoiceNumber: 'F2026001',
+ companyId: 'company-1',
+ userId: 'user-1',
+ feeAmount: -1,
+ asOfDate: '2026-05-26',
+ })
+ expect(result).toBeNull()
+ expect(mockedCreateEntry).not.toHaveBeenCalled()
+ })
+
+ it('returns null when no open fiscal period is found', async () => {
+ mockedFindFiscalPeriod.mockResolvedValueOnce(null)
+ const result = await createReminderFeeEntry({} as never, {
+ invoiceId: 'inv-1',
+ invoiceNumber: 'F2026001',
+ companyId: 'company-1',
+ userId: 'user-1',
+ feeAmount: 60,
+ asOfDate: '2026-05-26',
+ })
+ expect(result).toBeNull()
+ expect(mockedCreateEntry).not.toHaveBeenCalled()
+ })
+})
diff --git a/lib/bookkeeping/__tests__/voucher-series-defaults.pg.test.ts b/lib/bookkeeping/__tests__/voucher-series-defaults.pg.test.ts
new file mode 100644
index 00000000..efd1d7be
--- /dev/null
+++ b/lib/bookkeeping/__tests__/voucher-series-defaults.pg.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, it } from 'vitest'
+import { getPool } from '@/tests/pg/setup'
+import { insertAuthUser, insertCompany, insertCompanyMember } from '@/tests/pg/fixtures'
+
+describe('company_settings.default_voucher_series_per_source_type', () => {
+ it('column exists with the expected default JSONB shape', async () => {
+ const result = await getPool().query<{ column_default: string | null }>(
+ `SELECT column_default
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'company_settings'
+ AND column_name = 'default_voucher_series_per_source_type'`,
+ )
+
+ expect(result.rows).toHaveLength(1)
+ // The default is a JSONB literal cast — we don't pin the exact whitespace,
+ // just verify the migration installed a default that includes the expected
+ // source_type keys.
+ const defaultValue = result.rows[0]?.column_default ?? ''
+ expect(defaultValue).toMatch(/jsonb/)
+ expect(defaultValue).toMatch(/manual/)
+ expect(defaultValue).toMatch(/supplier_invoice_registered/)
+ expect(defaultValue).toMatch(/salary_payment/)
+ })
+
+ it('a freshly inserted company_settings row gets all-A defaults', async () => {
+ const userId = await insertAuthUser()
+ const companyId = await insertCompany({ createdBy: userId })
+ await insertCompanyMember({ companyId, userId, role: 'owner' })
+
+ // upsert in case a trigger has already created the row.
+ await getPool().query(
+ `INSERT INTO public.company_settings (user_id, company_id)
+ VALUES ($1, $2)
+ ON CONFLICT (company_id) DO NOTHING`,
+ [userId, companyId],
+ )
+
+ const { rows } = await getPool().query<{
+ default_voucher_series_per_source_type: Record
+ }>(
+ `SELECT default_voucher_series_per_source_type
+ FROM public.company_settings WHERE company_id = $1`,
+ [companyId],
+ )
+
+ expect(rows).toHaveLength(1)
+ const map = rows[0]!.default_voucher_series_per_source_type
+ expect(map).toBeDefined()
+ expect(map.manual).toBe('A')
+ expect(map.supplier_invoice_registered).toBe('A')
+ expect(map.salary_payment).toBe('A')
+ expect(map.bank_transaction).toBe('A')
+ })
+
+ it('accepts user updates to per-source-type series mapping', async () => {
+ const userId = await insertAuthUser()
+ const companyId = await insertCompany({ createdBy: userId })
+ await insertCompanyMember({ companyId, userId, role: 'owner' })
+
+ await getPool().query(
+ `INSERT INTO public.company_settings (user_id, company_id)
+ VALUES ($1, $2)
+ ON CONFLICT (company_id) DO NOTHING`,
+ [userId, companyId],
+ )
+
+ // Configure per common Swedish convention: B for supplier, C for salary.
+ const updated = {
+ manual: 'A',
+ supplier_invoice_registered: 'B',
+ supplier_invoice_paid: 'B',
+ salary_payment: 'C',
+ }
+ await getPool().query(
+ `UPDATE public.company_settings
+ SET default_voucher_series_per_source_type = $1::jsonb
+ WHERE company_id = $2`,
+ [JSON.stringify(updated), companyId],
+ )
+
+ const { rows } = await getPool().query<{
+ default_voucher_series_per_source_type: Record
+ }>(
+ `SELECT default_voucher_series_per_source_type
+ FROM public.company_settings WHERE company_id = $1`,
+ [companyId],
+ )
+
+ expect(rows[0]!.default_voucher_series_per_source_type).toEqual(updated)
+ })
+})
diff --git a/lib/bookkeeping/__tests__/voucher-series-resolver.test.ts b/lib/bookkeeping/__tests__/voucher-series-resolver.test.ts
new file mode 100644
index 00000000..c5902ae7
--- /dev/null
+++ b/lib/bookkeeping/__tests__/voucher-series-resolver.test.ts
@@ -0,0 +1,145 @@
+import { describe, it, expect } from 'vitest'
+import {
+ formatVoucher,
+ parseVoucher,
+ resolveDefaultSeriesForSource,
+} from '../voucher-series-resolver'
+
+describe('resolveDefaultSeriesForSource', () => {
+ it('returns A when settings is null', () => {
+ expect(resolveDefaultSeriesForSource(null, 'manual')).toBe('A')
+ })
+
+ it('returns A when settings is undefined', () => {
+ expect(resolveDefaultSeriesForSource(undefined, 'manual')).toBe('A')
+ })
+
+ it('returns A when the map is missing entirely', () => {
+ expect(
+ resolveDefaultSeriesForSource(
+ { default_voucher_series_per_source_type: null },
+ 'manual',
+ ),
+ ).toBe('A')
+ })
+
+ it('returns A when the source_type is not in the map', () => {
+ expect(
+ resolveDefaultSeriesForSource(
+ { default_voucher_series_per_source_type: { manual: 'A' } },
+ 'supplier_invoice_registered',
+ ),
+ ).toBe('A')
+ })
+
+ it('returns the configured letter for a known source_type', () => {
+ expect(
+ resolveDefaultSeriesForSource(
+ {
+ default_voucher_series_per_source_type: {
+ manual: 'A',
+ supplier_invoice_registered: 'B',
+ salary_payment: 'C',
+ },
+ },
+ 'supplier_invoice_registered',
+ ),
+ ).toBe('B')
+ expect(
+ resolveDefaultSeriesForSource(
+ {
+ default_voucher_series_per_source_type: {
+ manual: 'A',
+ supplier_invoice_registered: 'B',
+ salary_payment: 'C',
+ },
+ },
+ 'salary_payment',
+ ),
+ ).toBe('C')
+ })
+
+ it('accepts a bare map (no settings wrapper)', () => {
+ expect(
+ resolveDefaultSeriesForSource(
+ { manual: 'A', supplier_invoice_registered: 'B' },
+ 'supplier_invoice_registered',
+ ),
+ ).toBe('B')
+ })
+
+ it('rejects invalid values and falls back to A', () => {
+ expect(
+ resolveDefaultSeriesForSource(
+ { default_voucher_series_per_source_type: { manual: 'lowercase' } },
+ 'manual',
+ ),
+ ).toBe('A')
+ expect(
+ resolveDefaultSeriesForSource(
+ { default_voucher_series_per_source_type: { manual: 'AB' } },
+ 'manual',
+ ),
+ ).toBe('A')
+ expect(
+ resolveDefaultSeriesForSource(
+ { default_voucher_series_per_source_type: { manual: '' } },
+ 'manual',
+ ),
+ ).toBe('A')
+ expect(
+ resolveDefaultSeriesForSource(
+ { default_voucher_series_per_source_type: { manual: '1' } },
+ 'manual',
+ ),
+ ).toBe('A')
+ })
+})
+
+describe('formatVoucher', () => {
+ it('formats series + number for a posted entry', () => {
+ expect(formatVoucher({ voucher_series: 'A', voucher_number: 1 })).toBe('A1')
+ expect(formatVoucher({ voucher_series: 'B', voucher_number: 12 })).toBe('B12')
+ })
+
+ it('returns em dash for null voucher_number', () => {
+ expect(formatVoucher({ voucher_series: 'A', voucher_number: null })).toBe('—')
+ })
+
+ it('returns em dash for voucher_number 0 (uncommitted draft placeholder)', () => {
+ expect(formatVoucher({ voucher_series: 'A', voucher_number: 0 })).toBe('—')
+ })
+
+ it('falls back to series A when series is null', () => {
+ expect(formatVoucher({ voucher_series: null, voucher_number: 5 })).toBe('A5')
+ })
+
+ it('uppercases the series', () => {
+ expect(formatVoucher({ voucher_series: 'b', voucher_number: 3 })).toBe('B3')
+ })
+})
+
+describe('parseVoucher', () => {
+ it('parses a well-formed label', () => {
+ expect(parseVoucher('A1')).toEqual({ series: 'A', number: 1 })
+ expect(parseVoucher('B12')).toEqual({ series: 'B', number: 12 })
+ })
+
+ it('round-trips with formatVoucher', () => {
+ const label = formatVoucher({ voucher_series: 'C', voucher_number: 42 })
+ expect(parseVoucher(label)).toEqual({ series: 'C', number: 42 })
+ })
+
+ it('uppercases and trims input', () => {
+ expect(parseVoucher(' a5 ')).toEqual({ series: 'A', number: 5 })
+ })
+
+ it('returns null for malformed input', () => {
+ expect(parseVoucher('')).toBeNull()
+ expect(parseVoucher('—')).toBeNull()
+ expect(parseVoucher('123')).toBeNull()
+ expect(parseVoucher('AA1')).toBeNull()
+ expect(parseVoucher('A0')).toBeNull()
+ expect(parseVoucher('A-1')).toBeNull()
+ })
+})
diff --git a/lib/bookkeeping/engine.ts b/lib/bookkeeping/engine.ts
index 048a17cf..7c71d1dc 100644
--- a/lib/bookkeeping/engine.ts
+++ b/lib/bookkeeping/engine.ts
@@ -11,11 +11,13 @@ import {
JournalEntryNotBalancedError,
JournalEntryNotFoundError,
} from '@/lib/bookkeeping/errors'
+import { resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
import type {
CreateJournalEntryInput,
CreateJournalEntryLineInput,
JournalEntry,
JournalEntryLine,
+ JournalEntrySourceType,
} from '@/types'
const log = createLogger('bookkeeping.engine')
@@ -110,6 +112,37 @@ async function resolveAccountIds(
return map
}
+/**
+ * Resolve the default voucher_series for a given source_type from
+ * company_settings.default_voucher_series_per_source_type. Falls back to 'A'
+ * silently when the column isn't present (e.g. older DB snapshot in a test),
+ * the lookup fails, or the configured value is invalid.
+ *
+ * Only called when the caller of createDraftEntry omitted voucher_series.
+ * Explicit voucher_series in the input always wins.
+ */
+async function resolveSeriesFromSettings(
+ supabase: SupabaseClient,
+ companyId: string,
+ sourceType: JournalEntrySourceType,
+): Promise {
+ try {
+ const { data, error } = await supabase
+ .from('company_settings')
+ .select('default_voucher_series_per_source_type')
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (error) return 'A'
+ return resolveDefaultSeriesForSource(
+ data as { default_voucher_series_per_source_type?: Record | null } | null,
+ sourceType,
+ )
+ } catch {
+ return 'A'
+ }
+}
+
/**
* Find the fiscal period for a given date
*/
@@ -211,6 +244,12 @@ export async function createDraftEntry(
throw new AccountsNotInChartError(missingAccounts)
}
+ // Resolve voucher_series: explicit input wins; otherwise look up the
+ // per-source-type default from company_settings (falls back to 'A').
+ const resolvedSeries = input.voucher_series
+ ? input.voucher_series
+ : await resolveSeriesFromSettings(supabase, companyId, input.source_type)
+
// Insert journal entry header as draft (voucher_number = 0, will be assigned on commit)
const { data: entry, error: entryError } = await supabase
.from('journal_entries')
@@ -219,7 +258,7 @@ export async function createDraftEntry(
user_id: userId,
fiscal_period_id: input.fiscal_period_id,
voucher_number: 0,
- voucher_series: input.voucher_series || 'A',
+ voucher_series: resolvedSeries,
entry_date: input.entry_date,
description: input.description,
source_type: input.source_type,
diff --git a/lib/bookkeeping/invoice-entries.ts b/lib/bookkeeping/invoice-entries.ts
index 0c05a3c4..e4303136 100644
--- a/lib/bookkeeping/invoice-entries.ts
+++ b/lib/bookkeeping/invoice-entries.ts
@@ -2,6 +2,7 @@ import { createJournalEntry, findFiscalPeriod } from './engine'
import { resolveSekAmount, buildCurrencyMetadata } from './currency-utils'
import { generateSalesVatLines } from './vat-entries'
import { getVatTreatmentForRate } from '@/lib/invoices/vat-rules'
+import { computeDeduction } from '@/lib/invoices/rot-rut-rules'
import { createLogger } from '@/lib/logger'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
@@ -147,6 +148,64 @@ function generatePerRateLines(
return lines
}
+/**
+ * Generate ROT/RUT-avdrag debit lines from invoice items.
+ *
+ * For each item flagged with `deduction_type`, produces a debit on BAS 1513
+ * (Övriga kortfristiga fordringar — Skatteverket) for the computed
+ * deduction amount. The caller must REDUCE the 1510 debit (kundfordringar)
+ * by the same total — the customer only owes the post-deduction amount;
+ * Skatteverket pays the rest via Husavdragstjänsten. Returns both the
+ * lines and the total so callers can apply both adjustments atomically.
+ *
+ * Foreign-currency invoices: ROT/RUT-avdrag is a Sweden-only rule, so
+ * receivables on 1513 are always recorded in SEK. We use the same SEK
+ * conversion as the rest of the entry (toSek closure logic on the caller
+ * side reproduced here for parity with generatePerRateLines).
+ */
+function generateRotRutLines(
+ items: InvoiceItem[],
+ invoiceTagText: string,
+ currency?: string | null,
+ exchangeRate?: number | null,
+): { lines: CreateJournalEntryLineInput[]; totalSek: number } {
+ const lines: CreateJournalEntryLineInput[] = []
+ const isForeign = currency != null && currency !== 'SEK'
+
+ const toSek = (amount: number): number => {
+ if (!isForeign) return amount
+ if (exchangeRate != null && exchangeRate > 0) {
+ return Math.round(amount * exchangeRate * 100) / 100
+ }
+ return amount
+ }
+
+ let totalSek = 0
+
+ for (const item of items) {
+ if (!item.deduction_type) continue
+ // Recompute server-side to defend against tampered client values.
+ const amount = computeDeduction({
+ unit_price: item.unit_price,
+ quantity: item.quantity,
+ deduction_type: item.deduction_type,
+ })
+ if (amount <= 0) continue
+ const amountSek = Math.round(toSek(amount) * 100) / 100
+ if (amountSek <= 0) continue
+ totalSek += amountSek
+ const kind = item.deduction_type === 'rot' ? 'ROT' : 'RUT'
+ lines.push({
+ account_number: '1513',
+ debit_amount: amountSek,
+ credit_amount: 0,
+ line_description: `${kind}-avdrag faktura ${invoiceTagText}`,
+ })
+ }
+
+ return { lines, totalSek: Math.round(totalSek * 100) / 100 }
+}
+
/**
* Create journal entry when an invoice is created (status != draft)
*
@@ -225,20 +284,31 @@ export async function createInvoiceJournalEntry(
}
}
- // Debit: Kundfordringar — balance guarantee: debit = sum of all credit lines
+ // ROT/RUT-avdrag debit lines (1513 Skatteverket). When present, they
+ // reduce the 1510 debit by the same total so the verifikation stays
+ // balanced (debits 1510 + 1513 = credits revenue + VAT). The customer
+ // only owes the post-deduction amount; Skatteverket pays the rest.
+ const rotRut = invoice.items && invoice.items.length > 0
+ ? generateRotRutLines(invoice.items, tag, invoice.currency, invoice.exchange_rate)
+ : { lines: [], totalSek: 0 }
+
+ // Debit: Kundfordringar — balance guarantee: debit = sum of all credit
+ // lines MINUS the ROT/RUT total which goes to 1513 instead.
const totalCredits = creditLines.reduce((sum, l) => sum + l.credit_amount, 0)
const debitAmount = isForeign
? Math.round(totalCredits * 100) / 100
: resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
+ const arAmount = Math.round((debitAmount - rotRut.totalSek) * 100) / 100
lines.push({
account_number: '1510',
- debit_amount: debitAmount,
+ debit_amount: arAmount,
credit_amount: 0,
line_description: `Faktura ${tag}`,
...buildCurrencyMetadata(invoice.currency, isForeign ? invoice.total : undefined, invoice.exchange_rate),
})
+ lines.push(...rotRut.lines)
lines.push(...creditLines)
const input: CreateJournalEntryInput = {
@@ -519,15 +589,29 @@ export async function createInvoiceCashEntry(
}
}
+ // ROT/RUT-avdrag debit lines (1513 Skatteverket). On cash method the
+ // bank account (1930) receives only the post-deduction amount in real
+ // life; the rest comes from Skatteverket later. We model that by
+ // splitting the debit: 1930 = total - deduction, 1513 = deduction.
+ const rotRut = invoice.items && invoice.items.length > 0
+ ? generateRotRutLines(invoice.items, tag, invoice.currency, invoice.exchange_rate)
+ : { lines: [], totalSek: 0 }
+
// Debit: Företagskonto — balance guarantee: debit = sum of credit lines
+ // minus the ROT/RUT total which goes to 1513 instead.
const totalCredits = creditLines.reduce((sum, l) => sum + l.credit_amount, 0)
+ const cashDebit = isForeign
+ ? Math.round(totalCredits * 100) / 100
+ : resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate)
+ const bankAmount = Math.round((cashDebit - rotRut.totalSek) * 100) / 100
lines.push({
account_number: '1930',
- debit_amount: isForeign ? Math.round(totalCredits * 100) / 100 : resolveSekAmount(invoice.total, invoice.total_sek, invoice.currency, invoice.exchange_rate),
+ debit_amount: bankAmount,
credit_amount: 0,
line_description: buildInvoiceDescription('Kontantbetalning kundfaktura', invoice.invoice_number, customerName, invoice.id),
})
+ lines.push(...rotRut.lines)
lines.push(...creditLines)
const input: CreateJournalEntryInput = {
diff --git a/lib/bookkeeping/reminder-fee-entries.ts b/lib/bookkeeping/reminder-fee-entries.ts
new file mode 100644
index 00000000..90aafa8b
--- /dev/null
+++ b/lib/bookkeeping/reminder-fee-entries.ts
@@ -0,0 +1,115 @@
+/**
+ * Journal entry generator for lagstadgad påminnelseavgift (statutory
+ * reminder fee, default 60 kr per Lag 1981:739).
+ *
+ * Booking convention:
+ * Debit 1510 Kundfordringar (the customer now owes the fee)
+ * Credit 3990 Övriga ersättningar, bidrag och intäkter
+ *
+ * Account choice rationale:
+ * - 1510 is the existing AR account already debited when the invoice was
+ * issued. Adding the fee on the same account keeps the customer's
+ * open balance accurate and matches Skatteverket / Kronofogden practice
+ * (one accumulated claim per customer).
+ * - 3990 (Övriga ersättningar, bidrag och intäkter) is the BAS 2026
+ * "miscellaneous operating revenue" bucket. Skatteverket guidance:
+ * reminder fees are not interest income (8313) but administrative
+ * compensation — so they sit in the 39xx group, not 83xx.
+ *
+ * Notes:
+ * - We deliberately do NOT book the dröjsmålsränta (late-payment interest)
+ * on reminder send. Interest is recognised when the customer pays it
+ * (revenue should not be recognised on a contingent claim).
+ * - Source type is 'reminder_fee' (see migration
+ * 20260526120300_drojsmalsranta_paminnelseavgift.sql which adds it
+ * to the journal_entries.source_type CHECK constraint).
+ */
+import type { SupabaseClient } from '@supabase/supabase-js'
+import { createJournalEntry, findFiscalPeriod } from './engine'
+import { createLogger } from '@/lib/logger'
+import type { CreateJournalEntryInput, JournalEntry } from '@/types'
+
+const log = createLogger('bookkeeping.reminder-fee')
+
+export interface CreateReminderFeeEntryInput {
+ /** Invoice the reminder relates to. Used for description and source_id linkage. */
+ invoiceId: string
+ /** Invoice number for the description (e.g. "F2026001"). */
+ invoiceNumber: string
+ /** Company that owns the invoice. */
+ companyId: string
+ /** User initiating the booking (for journal_entries.user_id audit trail). */
+ userId: string
+ /** Fee amount in SEK (≥ 0). Default per Lag 1981:739 is 60 kr. */
+ feeAmount: number
+ /** Date used as entry_date (typically the reminder send date). */
+ asOfDate: string
+}
+
+export interface CreateReminderFeeEntryResult {
+ journal_entry_id: string
+}
+
+/**
+ * Book the statutory påminnelseavgift as a journal entry.
+ *
+ * Returns the new journal_entry_id on success. Returns `null` if no
+ * open fiscal period exists for `asOfDate` (the caller should treat
+ * this as "skip booking, log a warning, continue sending the email").
+ *
+ * Throws on hard failures (account missing from chart, period locked,
+ * balance trigger rejection). Callers wrap in try/catch so a single
+ * failed posting doesn't abort the cron batch.
+ */
+export async function createReminderFeeEntry(
+ supabase: SupabaseClient,
+ input: CreateReminderFeeEntryInput,
+): Promise {
+ const { invoiceId, invoiceNumber, companyId, userId, feeAmount, asOfDate } = input
+
+ if (feeAmount <= 0) {
+ log.info('skipping reminder fee booking — feeAmount is zero', {
+ invoiceId,
+ companyId,
+ })
+ return null
+ }
+
+ const fiscalPeriodId = await findFiscalPeriod(supabase, companyId, asOfDate)
+ if (!fiscalPeriodId) {
+ log.warn('no open fiscal period for reminder fee', {
+ invoiceId,
+ companyId,
+ asOfDate,
+ })
+ return null
+ }
+
+ const rounded = Math.round(feeAmount * 100) / 100
+ const description = `Påminnelseavgift faktura ${invoiceNumber}`
+
+ const entryInput: CreateJournalEntryInput = {
+ fiscal_period_id: fiscalPeriodId,
+ entry_date: asOfDate,
+ description,
+ source_type: 'reminder_fee',
+ source_id: invoiceId,
+ lines: [
+ {
+ account_number: '1510',
+ debit_amount: rounded,
+ credit_amount: 0,
+ line_description: description,
+ },
+ {
+ account_number: '3990',
+ debit_amount: 0,
+ credit_amount: rounded,
+ line_description: description,
+ },
+ ],
+ }
+
+ const entry: JournalEntry = await createJournalEntry(supabase, companyId, userId, entryInput)
+ return { journal_entry_id: entry.id }
+}
diff --git a/lib/bookkeeping/voucher-series-resolver.ts b/lib/bookkeeping/voucher-series-resolver.ts
new file mode 100644
index 00000000..200b6148
--- /dev/null
+++ b/lib/bookkeeping/voucher-series-resolver.ts
@@ -0,0 +1,100 @@
+/**
+ * Voucher series resolver — pure helpers for mapping journal_entries.source_type
+ * to a default voucher_series per company_settings, formatting voucher labels
+ * for display, and parsing them back.
+ *
+ * Source-type → series mapping lives in
+ * company_settings.default_voucher_series_per_source_type (JSONB).
+ *
+ * Defaults to 'A' when:
+ * - the settings row is null/undefined
+ * - the JSONB is missing the source_type key
+ * - the configured value is not a single uppercase letter A–Z
+ *
+ * These functions are pure; no I/O. Engine call-sites read the settings row
+ * once and pass it in.
+ */
+import type { JournalEntrySourceType } from '@/types'
+
+export type VoucherSeriesMap = Partial> &
+ Record
+
+const SERIES_LETTER_RE = /^[A-Z]$/
+
+/**
+ * Resolve the default voucher_series letter for a given source_type from a
+ * company_settings row. Returns 'A' as a safe fallback when no mapping is
+ * configured for that source_type.
+ *
+ * @param settings - Either a full CompanySettings row or just the per-source
+ * map. `null`/`undefined` is allowed (returns 'A').
+ * @param sourceType - The journal_entries.source_type value.
+ */
+export function resolveDefaultSeriesForSource(
+ settings:
+ | { default_voucher_series_per_source_type?: VoucherSeriesMap | null }
+ | VoucherSeriesMap
+ | null
+ | undefined,
+ sourceType: JournalEntrySourceType,
+): string {
+ if (!settings) return 'A'
+
+ // Accept both the full settings row and a bare map. Both shapes are
+ // narrowed via duck-typing on the column key — when present, treat it as
+ // the settings row; otherwise treat the argument itself as the map.
+ const raw = settings as {
+ default_voucher_series_per_source_type?: VoucherSeriesMap | null
+ } & VoucherSeriesMap
+ const mapCandidate =
+ raw.default_voucher_series_per_source_type !== undefined
+ ? raw.default_voucher_series_per_source_type
+ : (settings as VoucherSeriesMap)
+
+ if (!mapCandidate || typeof mapCandidate !== 'object') return 'A'
+
+ const value = (mapCandidate as VoucherSeriesMap)[sourceType]
+ if (typeof value === 'string' && SERIES_LETTER_RE.test(value)) {
+ return value
+ }
+ return 'A'
+}
+
+/**
+ * Format a voucher (series + number) for UI display. Returns "—" when the
+ * voucher number is null (e.g. a draft entry that has not been committed yet).
+ *
+ * Always lifts the series to uppercase. Falls back to 'A' when the series is
+ * null/empty for forward-compat with legacy rows. Accepts partial inputs so
+ * callsites can pass through API responses without re-shaping them.
+ */
+export function formatVoucher(entry: {
+ voucher_series?: string | null
+ voucher_number?: number | null
+}): string {
+ if (entry.voucher_number == null || entry.voucher_number === 0) {
+ return '—'
+ }
+ const series =
+ entry.voucher_series && typeof entry.voucher_series === 'string'
+ ? entry.voucher_series.toUpperCase()
+ : 'A'
+ return `${series}${entry.voucher_number}`
+}
+
+/**
+ * Parse a formatted voucher label back into its parts. Returns null when the
+ * input does not match the expected shape (single uppercase letter followed
+ * by a positive integer). Use for filter inputs / search.
+ */
+export function parseVoucher(
+ formatted: string,
+): { series: string; number: number } | null {
+ if (typeof formatted !== 'string') return null
+ const trimmed = formatted.trim().toUpperCase()
+ const match = trimmed.match(/^([A-Z])(\d+)$/)
+ if (!match) return null
+ const number = parseInt(match[2], 10)
+ if (!Number.isFinite(number) || number <= 0) return null
+ return { series: match[1], number }
+}
diff --git a/lib/branding/__tests__/service.test.ts b/lib/branding/__tests__/service.test.ts
index cd19087b..7797e7e1 100644
--- a/lib/branding/__tests__/service.test.ts
+++ b/lib/branding/__tests__/service.test.ts
@@ -7,6 +7,7 @@ const ENV_KEYS = [
'BRANDING_SUPPORT_EMAIL',
'BRANDING_PRIVACY_EMAIL',
'BRANDING_SECURITY_EMAIL',
+ 'NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM',
'NEXT_PUBLIC_BRANDING_LOGO_PATH',
'NEXT_PUBLIC_BRANDING_FAVICON_PATH',
'NEXT_PUBLIC_BRANDING_APPLE_ICON_PATH',
@@ -45,6 +46,7 @@ describe('branding service', () => {
expect(b.supportEmail).toBe('support@gnubok.se')
expect(b.privacyEmail).toBe('privacy@gnubok.se')
expect(b.securityEmail).toBe('security@arcim.io')
+ expect(b.authEmailFrom).toBe('noreply@gnubok.se')
expect(b.logoPath).toBe('/gnubokiceon-removebg-preview.png')
expect(b.faviconPath).toBe('/favicon.ico')
expect(b.appleTouchIconPath).toBe('/icons/icon-192.png')
@@ -84,11 +86,13 @@ describe('branding service', () => {
process.env.NEXT_PUBLIC_BRANDING_APP_NAME = 'Holdio'
process.env.BRANDING_SUPPORT_EMAIL = 'hello@holdio.se'
process.env.NEXT_PUBLIC_BRANDING_LOGO_PATH = '/holdio-logo.svg'
+ process.env.NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM = 'noreply@holdio.se'
const { getBranding } = await import('../service')
const b = getBranding()
expect(b.appName).toBe('Holdio')
expect(b.supportEmail).toBe('hello@holdio.se')
expect(b.logoPath).toBe('/holdio-logo.svg')
+ expect(b.authEmailFrom).toBe('noreply@holdio.se')
expect(b.appDescription).toBe('Ekonomihantering')
})
diff --git a/lib/branding/service.ts b/lib/branding/service.ts
index a7ba81de..642a2cb5 100644
--- a/lib/branding/service.ts
+++ b/lib/branding/service.ts
@@ -25,6 +25,11 @@ export interface BrandingConfig {
privacyEmail: string
securityEmail: string
+ // Address Supabase Auth sends verification / reset emails from. Used to build
+ // the `from:` query on the "check your email" screen's Gmail deep link.
+ // Must match the From in your Supabase Auth SMTP config.
+ authEmailFrom: string
+
// URLs
appUrl: string
@@ -50,6 +55,7 @@ const DEFAULT_BRANDING: BrandingConfig = {
supportEmail: 'support@gnubok.se',
privacyEmail: 'privacy@gnubok.se',
securityEmail: 'security@arcim.io',
+ authEmailFrom: 'noreply@gnubok.se',
appUrl: process.env.NEXT_PUBLIC_APP_URL || 'https://app.gnubok.se',
logoPath: '/gnubokiceon-removebg-preview.png',
faviconPath: '/favicon.ico',
@@ -84,6 +90,7 @@ function readEnvOverrides(): Partial {
if (env.BRANDING_SUPPORT_EMAIL) o.supportEmail = env.BRANDING_SUPPORT_EMAIL
if (env.BRANDING_PRIVACY_EMAIL) o.privacyEmail = env.BRANDING_PRIVACY_EMAIL
if (env.BRANDING_SECURITY_EMAIL) o.securityEmail = env.BRANDING_SECURITY_EMAIL
+ if (env.NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM) o.authEmailFrom = env.NEXT_PUBLIC_BRANDING_AUTH_EMAIL_FROM
if (env.NEXT_PUBLIC_APP_URL) o.appUrl = env.NEXT_PUBLIC_APP_URL
if (env.NEXT_PUBLIC_BRANDING_LOGO_PATH) o.logoPath = env.NEXT_PUBLIC_BRANDING_LOGO_PATH
if (env.NEXT_PUBLIC_BRANDING_FAVICON_PATH) o.faviconPath = env.NEXT_PUBLIC_BRANDING_FAVICON_PATH
diff --git a/lib/core/bookkeeping/__tests__/year-end-invariants.pg.test.ts b/lib/core/bookkeeping/__tests__/year-end-invariants.pg.test.ts
new file mode 100644
index 00000000..0c7297f2
--- /dev/null
+++ b/lib/core/bookkeeping/__tests__/year-end-invariants.pg.test.ts
@@ -0,0 +1,156 @@
+import { describe, it, expect } from 'vitest'
+import { randomUUID } from 'node:crypto'
+import { getPool } from '@/tests/pg/setup'
+import { seedCompany, insertDraftJournalEntry } from '@/tests/pg/fixtures'
+import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
+
+/**
+ * Plan 3 invariants. These tests verify the database-level guarantees that
+ * back the application-level invariants in executeYearEndClosing():
+ *
+ * 1. Closing entries must balance to the öre — the journal_entries balance
+ * trigger rejects anything else on draft→posted.
+ * 2. A one-öre discrepancy fed into a closing-style entry is rejected
+ * by the trigger; the row stays in 'draft' and no posted state is
+ * created — i.e. DB state is unchanged from the caller's perspective
+ * (no voucher number assigned, no audit_log row for a posted entry).
+ *
+ * The full executeYearEndClosing() flow is exercised by the existing mock-
+ * based test in year-end-service.test.ts. Running that flow against real
+ * Postgres requires a Supabase JS client wired to this pool, which is out
+ * of scope for the pg-real harness; the invariants below are the
+ * load-bearing checks the application layer relies on.
+ */
+describe('year-end invariants (pg-real)', () => {
+ it('closing entry must balance to the öre', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+
+ // Build a closing-style draft entry: 3001 → 2099 transfer that's off
+ // by one öre.
+ const entryId = await insertDraftJournalEntry({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ entryDate: '2026-12-31',
+ description: 'Årsbokslut (unbalanced)',
+ })
+
+ await getPool().query(
+ `INSERT INTO public.journal_entry_lines
+ (journal_entry_id, account_number, debit_amount, credit_amount)
+ VALUES ($1, '3001', 0, 1000.00),
+ ($1, '2099', 1000.01, 0)`,
+ [entryId],
+ )
+
+ // Attempt to commit via the same RPC the engine uses. The balance
+ // trigger must fire and the RPC must fail.
+ const pool = getPool()
+ await expect(
+ pool.query(`SELECT commit_journal_entry($1, $2)`, [companyId, entryId]),
+ ).rejects.toThrow()
+
+ // DB state unchanged: entry still draft, no voucher assigned.
+ const { rows } = await pool.query<{ status: string; voucher_number: number }>(
+ `SELECT status, voucher_number FROM public.journal_entries WHERE id = $1`,
+ [entryId],
+ )
+ expect(rows[0].status).toBe('draft')
+ expect(Number(rows[0].voucher_number)).toBe(0)
+ })
+
+ it('balanced closing entry commits cleanly and zeros class 3 net', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+
+ // Step 1: post a revenue entry so 3001 has a credit balance.
+ const revenueId = await insertDraftJournalEntry({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ entryDate: '2026-06-01',
+ description: 'Revenue',
+ })
+ await getPool().query(
+ `INSERT INTO public.journal_entry_lines
+ (journal_entry_id, account_number, debit_amount, credit_amount)
+ VALUES ($1, '1930', 5000.00, 0),
+ ($1, '3001', 0, 5000.00)`,
+ [revenueId],
+ )
+ await getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, revenueId])
+
+ // Step 2: the closing entry — debit 3001, credit 2099.
+ const closeId = await insertDraftJournalEntry({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ entryDate: '2026-12-31',
+ description: 'Årsbokslut',
+ })
+ await getPool().query(
+ `INSERT INTO public.journal_entry_lines
+ (journal_entry_id, account_number, debit_amount, credit_amount)
+ VALUES ($1, '3001', 5000.00, 0),
+ ($1, '2099', 0, 5000.00)`,
+ [closeId],
+ )
+ await getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, closeId])
+
+ // Class-3 net across posted lines in this period must be 0 to the öre.
+ const { rows } = await getPool().query<{ net: string }>(
+ `SELECT COALESCE(SUM(l.debit_amount - l.credit_amount), 0) AS net
+ FROM public.journal_entry_lines l
+ JOIN public.journal_entries je ON je.id = l.journal_entry_id
+ WHERE je.company_id = $1
+ AND je.fiscal_period_id = $2
+ AND je.status = 'posted'
+ AND l.account_number LIKE '3%'`,
+ [companyId, fiscalPeriodId],
+ )
+ const net = roundOre(Number(rows[0].net))
+ expect(Math.abs(net)).toBeLessThanOrEqual(ORE_TOLERANCE)
+ })
+
+ it('rejects a one-öre IB/UB style discrepancy in opening balance lines', async () => {
+ const { userId, companyId, fiscalPeriodId } = await seedCompany()
+
+ // Opening-balance-style draft where 1930 IB and 2099 IB are off by 0.01.
+ const ibId = await insertDraftJournalEntry({
+ userId,
+ companyId,
+ fiscalPeriodId,
+ entryDate: '2026-01-01',
+ description: 'Ingående balans (skewed)',
+ })
+ await getPool().query(
+ `INSERT INTO public.journal_entry_lines
+ (journal_entry_id, account_number, debit_amount, credit_amount)
+ VALUES ($1, '1930', 1234.56, 0),
+ ($1, '2099', 0, 1234.57)`,
+ [ibId],
+ )
+
+ await expect(
+ getPool().query(`SELECT commit_journal_entry($1, $2)`, [companyId, ibId]),
+ ).rejects.toThrow()
+
+ const { rows } = await getPool().query<{ status: string }>(
+ `SELECT status FROM public.journal_entries WHERE id = $1`,
+ [ibId],
+ )
+ expect(rows[0].status).toBe('draft')
+ })
+
+ // Sanity: roundOre / ORE_TOLERANCE are wired through. This is the
+ // imported boundary — if it breaks, every consumer above breaks too.
+ it('exposes a half-öre tolerance', () => {
+ expect(ORE_TOLERANCE).toBe(0.005)
+ expect(roundOre(1.005)).toBe(1.01)
+ })
+
+ // Quiet linter — randomUUID is referenced through the seed helper but
+ // we keep an explicit import for future cases that need their own UUIDs.
+ it('uuid helper is available', () => {
+ expect(randomUUID()).toMatch(/[0-9a-f-]{36}/)
+ })
+})
diff --git a/lib/core/bookkeeping/__tests__/year-end-service.test.ts b/lib/core/bookkeeping/__tests__/year-end-service.test.ts
index c4653997..455b328c 100644
--- a/lib/core/bookkeeping/__tests__/year-end-service.test.ts
+++ b/lib/core/bookkeeping/__tests__/year-end-service.test.ts
@@ -37,6 +37,7 @@ vi.mock('@/lib/reports/income-statement', () => ({
vi.mock('@/lib/bookkeeping/engine', () => ({
createJournalEntry: vi.fn(),
+ reverseEntry: vi.fn(),
}))
vi.mock('@/lib/bookkeeping/currency-revaluation', () => ({
diff --git a/lib/core/bookkeeping/year-end-service.ts b/lib/core/bookkeeping/year-end-service.ts
index f7b2dc46..a7fe27b2 100644
--- a/lib/core/bookkeeping/year-end-service.ts
+++ b/lib/core/bookkeeping/year-end-service.ts
@@ -1,6 +1,10 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { eventBus } from '@/lib/events'
-import { createJournalEntry } from '@/lib/bookkeeping/engine'
+import { createJournalEntry, reverseEntry } from '@/lib/bookkeeping/engine'
+import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
+import { createLogger } from '@/lib/logger'
+
+const log = createLogger('year-end-service')
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateIncomeStatement } from '@/lib/reports/income-statement'
import { lockPeriod, closePeriod, createNextPeriod } from './period-service'
@@ -303,7 +307,7 @@ export async function previewYearEndClosing(
for (const account of resultAccounts) {
const netBalance = account.closing_debit - account.closing_credit
- if (Math.abs(netBalance) < 0.005) continue
+ if (Math.abs(netBalance) < ORE_TOLERANCE) continue
resultAccountSummary.push({
account_number: account.account_number,
@@ -317,14 +321,14 @@ export async function previewYearEndClosing(
closingLines.push({
account_number: account.account_number,
debit_amount: 0,
- credit_amount: Math.round(netBalance * 100) / 100,
+ credit_amount: roundOre(netBalance),
line_description: `Closing: ${account.account_name}`,
})
} else {
// Account has credit balance → debit it to zero
closingLines.push({
account_number: account.account_number,
- debit_amount: Math.round(Math.abs(netBalance) * 100) / 100,
+ debit_amount: roundOre(Math.abs(netBalance)),
credit_amount: 0,
line_description: `Closing: ${account.account_name}`,
})
@@ -337,9 +341,9 @@ export async function previewYearEndClosing(
// If negative (loss): debit to equity (2099/2010)
const totalClosingDebit = closingLines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalClosingCredit = closingLines.reduce((sum, l) => sum + l.credit_amount, 0)
- const balancingAmount = Math.round(Math.abs(totalClosingDebit - totalClosingCredit) * 100) / 100
+ const balancingAmount = roundOre(Math.abs(totalClosingDebit - totalClosingCredit))
- if (balancingAmount > 0.005) {
+ if (balancingAmount > ORE_TOLERANCE) {
if (totalClosingDebit > totalClosingCredit) {
// More debits than credits → need credit on closing account
closingLines.push({
@@ -442,6 +446,22 @@ export async function executeYearEndClosing(
throw new Error('No result accounts to close — period has no activity')
}
+ // 3a. INVARIANT: closing entry must balance to the öre before commit.
+ // This guards against rounding drift in previewYearEndClosing — the DB
+ // balance trigger would catch it too, but we want a clear Swedish error
+ // surfaced to the user, not a generic Postgres exception.
+ const preCommitDebit = roundOre(
+ preview.closingLines.reduce((s, l) => s + l.debit_amount, 0)
+ )
+ const preCommitCredit = roundOre(
+ preview.closingLines.reduce((s, l) => s + l.credit_amount, 0)
+ )
+ if (Math.abs(preCommitDebit - preCommitCredit) > ORE_TOLERANCE) {
+ throw new Error(
+ `Bokslutsverifikationen balanserar inte: debet=${preCommitDebit}, kredit=${preCommitCredit}`
+ )
+ }
+
// 4. Create closing entry via the journal engine
const closingEntry = await createJournalEntry(supabase, companyId, userId, {
fiscal_period_id: fiscalPeriodId,
@@ -452,6 +472,32 @@ export async function executeYearEndClosing(
lines: preview.closingLines,
})
+ // 4a. INVARIANT: after the closing entry, class 3-8 net must be exactly 0
+ // (to the öre). If not, we have a logic bug — fail loud rather than
+ // proceed into IB generation with a corrupt trial balance.
+ // createJournalEntry has no transactional grouping with the next call;
+ // the engine commits atomically per-entry via commit_journal_entry RPC,
+ // so a failure here means we need to reverse the just-committed entry.
+ try {
+ const postCloseTB = await generateTrialBalance(supabase, companyId, fiscalPeriodId)
+ let resultNet = 0
+ for (const row of postCloseTB.rows) {
+ if (row.account_class >= 3 && row.account_class <= 8) {
+ resultNet += row.closing_debit - row.closing_credit
+ }
+ }
+ resultNet = roundOre(resultNet)
+ if (Math.abs(resultNet) > ORE_TOLERANCE) {
+ throw new Error(
+ `Resultatkonton (klass 3-8) saknar nollställning efter bokslut: nettot är ${resultNet} SEK`
+ )
+ }
+ } catch (err) {
+ // Best-effort reversal of the closing entry before re-throwing.
+ await safeReverse(supabase, companyId, userId, closingEntry.id, 'closing entry')
+ throw err
+ }
+
// 5. Update fiscal period with closing_entry_id
const { error: updateError } = await supabase
.from('fiscal_periods')
@@ -481,7 +527,19 @@ export async function executeYearEndClosing(
nextPeriod.id
)
- // 10. Validate IB/UB continuity and persist result
+ // 10. Validate IB/UB continuity and persist result.
+ // INVARIANT: any account differing by more than ORE_TOLERANCE is a hard
+ // failure. Best-effort rollback of both the IB entry and the closing
+ // entry so the user sees a clean state and can re-run the wizard.
+ //
+ // Note on atomicity: createJournalEntry uses an atomic commit_journal_entry
+ // RPC per entry, but the closing + IB entries are two separate commits with
+ // a period lock/close in between. Once committed, posted entries are
+ // immutable by DB trigger — true rollback isn't possible. reverseEntry()
+ // posts a compensating storno entry instead. The closed period was also
+ // locked & closed, but reverseEntry uses an entry_date that — under the
+ // period-lock trigger — may be blocked. We attempt reversal but tolerate
+ // failure, surfacing the original continuity error either way.
const continuity = await validateBalanceContinuity(supabase, companyId, nextPeriod.id)
await supabase
@@ -490,12 +548,21 @@ export async function executeYearEndClosing(
.eq('id', nextPeriod.id)
.eq('company_id', companyId)
- if (!continuity.valid) {
+ const overTolerance = continuity.discrepancies.filter(
+ (d) => Math.abs(d.difference) > ORE_TOLERANCE
+ )
+ if (overTolerance.length > 0) {
+ await safeReverse(supabase, companyId, userId, openingBalanceEntry.id, 'opening balance entry')
+ await safeReverse(supabase, companyId, userId, closingEntry.id, 'closing entry')
+
throw new Error(
- `IB/UB continuity check failed: ${continuity.discrepancies.length} account(s) differ. ` +
- continuity.discrepancies.map(d =>
- `${d.account_number}: UB=${d.previous_ub_net}, IB=${d.current_ib_net}, diff=${d.difference}`
- ).join('; ')
+ `IB/UB-kontinuitet misslyckades: ${overTolerance.length} konto(n) avviker. ` +
+ overTolerance
+ .map(
+ (d) =>
+ `${d.account_number}: UB=${d.previous_ub_net}, IB=${d.current_ib_net}, diff=${d.difference}`
+ )
+ .join('; ')
)
}
@@ -519,6 +586,7 @@ export async function executeYearEndClosing(
nextPeriod,
openingBalanceEntry,
revaluationEntry: revaluationResult?.entry ?? null,
+ continuity,
}
}
@@ -562,13 +630,13 @@ export async function generateOpeningBalances(
for (const account of balanceSheetAccounts) {
const netBalance = account.closing_debit - account.closing_credit
- if (Math.abs(netBalance) < 0.005) continue
+ if (Math.abs(netBalance) < ORE_TOLERANCE) continue
if (netBalance > 0) {
// Debit balance → opening debit
openingLines.push({
account_number: account.account_number,
- debit_amount: Math.round(netBalance * 100) / 100,
+ debit_amount: roundOre(netBalance),
credit_amount: 0,
line_description: `Ingående balans: ${account.account_name}`,
})
@@ -577,7 +645,7 @@ export async function generateOpeningBalances(
openingLines.push({
account_number: account.account_number,
debit_amount: 0,
- credit_amount: Math.round(Math.abs(netBalance) * 100) / 100,
+ credit_amount: roundOre(Math.abs(netBalance)),
line_description: `Ingående balans: ${account.account_name}`,
})
}
@@ -591,9 +659,9 @@ export async function generateOpeningBalances(
const totalDebit = openingLines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = openingLines.reduce((sum, l) => sum + l.credit_amount, 0)
- if (Math.abs(totalDebit - totalCredit) > 0.01) {
+ if (Math.abs(totalDebit - totalCredit) > ORE_TOLERANCE) {
throw new Error(
- `Opening balances are not balanced: debit=${totalDebit}, credit=${totalCredit}`
+ `Ingående balanser balanserar inte: debet=${roundOre(totalDebit)}, kredit=${roundOre(totalCredit)}`
)
}
@@ -623,3 +691,32 @@ export async function generateOpeningBalances(
return openingEntry
}
+
+/**
+ * Best-effort reversal used by executeYearEndClosing's rollback paths.
+ *
+ * Posted journal entries are immutable per DB trigger — we can't truly
+ * roll them back, only post a compensating storno via reverseEntry().
+ * Closed/locked periods may also block the reversal date. We swallow
+ * failures here so the caller can re-throw the original invariant error
+ * with maximum diagnostic value; the orphaned entries (if any) become
+ * a manual cleanup task documented in the surfaced Swedish error.
+ */
+async function safeReverse(
+ supabase: SupabaseClient,
+ companyId: string,
+ userId: string,
+ entryId: string,
+ label: string
+): Promise {
+ try {
+ await reverseEntry(supabase, companyId, userId, entryId)
+ } catch (err) {
+ log.error(`year-end rollback: could not reverse ${label}`, err as Error, {
+ operation: 'year_end.rollback',
+ companyId,
+ entityType: 'journal_entry',
+ entityId: entryId,
+ })
+ }
+}
diff --git a/lib/core/documents/document-service.ts b/lib/core/documents/document-service.ts
index aae867a3..206c0055 100644
--- a/lib/core/documents/document-service.ts
+++ b/lib/core/documents/document-service.ts
@@ -264,6 +264,85 @@ export async function linkToJournalEntry(
return data as DocumentAttachment
}
+export type DeleteDocumentResult =
+ | { ok: true; document: Pick }
+ | { ok: false; reason: 'not_found' | 'linked_to_entry'; status: number; message: string }
+
+/**
+ * Delete a document if and only if it is not yet linked to a journal entry.
+ *
+ * BFL 7 kap 2§: once a document is attached to a verifikation it becomes
+ * räkenskapsinformation and may not be deleted within the 7-year retention
+ * window. Linked docs must be superseded via createNewVersion() instead.
+ * The block_document_deletion() trigger is the DB-level backstop.
+ */
+export async function deleteDocument(
+ supabase: SupabaseClient,
+ companyId: string,
+ documentId: string
+): Promise {
+ const { data: doc, error: fetchError } = await supabase
+ .from('document_attachments')
+ .select('id, file_name, storage_path, journal_entry_id, user_id')
+ .eq('id', documentId)
+ .eq('company_id', companyId)
+ .maybeSingle()
+
+ if (fetchError || !doc) {
+ return {
+ ok: false,
+ reason: 'not_found',
+ status: 404,
+ message: 'Underlaget hittades inte.',
+ }
+ }
+
+ if (doc.journal_entry_id) {
+ return {
+ ok: false,
+ reason: 'linked_to_entry',
+ status: 409,
+ message:
+ 'Underlaget är knutet till en verifikation och utgör räkenskapsinformation enligt Bokföringslagen 7 kap 2§. Räkenskapsinformation ska bevaras i minst 7 år och får inte raderas. Använd "Ersätt med ny version" om underlaget behöver korrigeras.',
+ }
+ }
+
+ const { error: deleteError } = await supabase
+ .from('document_attachments')
+ .delete()
+ .eq('id', documentId)
+ .eq('company_id', companyId)
+
+ if (deleteError) {
+ const msg = (deleteError as { message?: string }).message ?? ''
+ if (msg.includes('Bokföringslagen') || msg.includes('retention')) {
+ return {
+ ok: false,
+ reason: 'linked_to_entry',
+ status: 409,
+ message:
+ 'Underlaget kan inte tas bort på grund av Bokföringslagens bevarandekrav (7 kap 2§).',
+ }
+ }
+ throw new Error(`Failed to delete document: ${msg}`)
+ }
+
+ if (doc.storage_path) {
+ await supabase.storage.from('documents').remove([doc.storage_path])
+ }
+
+ await eventBus.emit({
+ type: 'document.deleted',
+ payload: {
+ document: { id: doc.id, file_name: doc.file_name },
+ userId: doc.user_id,
+ companyId,
+ },
+ })
+
+ return { ok: true, document: { id: doc.id, file_name: doc.file_name } }
+}
+
/**
* Verify document integrity by re-hashing and comparing
*/
diff --git a/lib/email/__tests__/reminder-templates.test.ts b/lib/email/__tests__/reminder-templates.test.ts
new file mode 100644
index 00000000..9e023cf9
--- /dev/null
+++ b/lib/email/__tests__/reminder-templates.test.ts
@@ -0,0 +1,105 @@
+import { describe, it, expect } from 'vitest'
+import {
+ generateReminderEmailHtml,
+ generateReminderEmailText,
+ generateReminderEmailSubject,
+} from '../reminder-templates'
+import { makeCustomer, makeInvoice, makeCompanySettings } from '@/tests/helpers'
+
+const company = makeCompanySettings({ company_name: 'Acme AB' })
+const customer = makeCustomer({ name: 'Erik Andersson', email: 'erik@example.se' })
+const invoice = makeInvoice({
+ invoice_number: 'F2026010',
+ invoice_date: '2026-04-15',
+ due_date: '2026-05-01',
+ currency: 'SEK',
+ total: 10_000,
+})
+
+const baseData = {
+ invoice,
+ customer,
+ company,
+ reminderLevel: 1 as const,
+ daysOverdue: 25,
+ actionUrl: 'https://example.com/invoice-action/abc',
+}
+
+describe('reminder email templates — surcharges', () => {
+ it('renders dröjsmålsränta + påminnelseavgift in HTML when set', () => {
+ const html = generateReminderEmailHtml({
+ ...baseData,
+ interestAmount: 86.3,
+ interestRate: 0.105,
+ interestFromDate: '2026-05-01',
+ interestDays: 30,
+ reminderFee: 60,
+ totalDue: 10_146.3,
+ })
+ expect(html).toContain('Ursprungligt belopp:')
+ expect(html).toContain('Dröjsmålsränta')
+ expect(html).toContain('Påminnelseavgift:')
+ expect(html).toContain('Att betala:')
+ expect(html).toContain('10,5%') // rate display (sv-SE format)
+ expect(html).toContain('30 dagar')
+ })
+
+ it('omits surcharge rows when both are zero', () => {
+ const html = generateReminderEmailHtml({
+ ...baseData,
+ interestAmount: 0,
+ interestRate: 0.105,
+ interestFromDate: '2026-05-01',
+ interestDays: 0,
+ reminderFee: 0,
+ totalDue: 10_000,
+ })
+ expect(html).not.toContain('Dröjsmålsränta')
+ expect(html).not.toContain('Påminnelseavgift:')
+ expect(html).toContain('Att betala:')
+ })
+
+ it('renders surcharges in plain text', () => {
+ const text = generateReminderEmailText({
+ ...baseData,
+ interestAmount: 86.3,
+ interestRate: 0.105,
+ interestFromDate: '2026-05-01',
+ interestDays: 30,
+ reminderFee: 60,
+ totalDue: 10_146.3,
+ })
+ expect(text).toContain('Ursprungligt belopp')
+ expect(text).toContain('Dröjsmålsränta')
+ expect(text).toContain('Påminnelseavgift')
+ expect(text).toContain('Att betala')
+ })
+
+ it('subject includes surcharge note when surcharges apply', () => {
+ const subject = generateReminderEmailSubject({
+ ...baseData,
+ interestAmount: 86.3,
+ interestRate: 0.105,
+ interestFromDate: '2026-05-01',
+ interestDays: 30,
+ reminderFee: 60,
+ totalDue: 10_146.3,
+ })
+ expect(subject).toContain('F2026010')
+ expect(subject).toContain('inkl. dröjsmålsränta')
+ })
+
+ it('subject is unchanged when no surcharges apply', () => {
+ const subject = generateReminderEmailSubject({
+ ...baseData,
+ interestAmount: 0,
+ interestRate: 0,
+ interestFromDate: '2026-05-01',
+ interestDays: 0,
+ reminderFee: 0,
+ totalDue: 10_000,
+ })
+ expect(subject).not.toContain('inkl. dröjsmålsränta')
+ expect(subject).toContain('F2026010')
+ })
+})
diff --git a/lib/email/invoice-templates.ts b/lib/email/invoice-templates.ts
index e9b6c6b2..aba8db1a 100644
--- a/lib/email/invoice-templates.ts
+++ b/lib/email/invoice-templates.ts
@@ -100,6 +100,14 @@ export interface InvoiceEmailData {
company: CompanySettings
}
+// Minimal hex validator — guards against branding values that bypass the
+// settings UI and could inject CSS via crafted strings. Anything malformed
+// falls back to the legacy default.
+function safeBrandingColor(value: string | null | undefined, fallback: string): string {
+ if (!value) return fallback
+ return /^#[0-9A-Fa-f]{6}$/.test(value) ? value : fallback
+}
+
/**
* Generate HTML email for sending an invoice
*/
@@ -116,6 +124,13 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
const hidePayment = isCreditNote || isDeliveryNote || isProforma
const firstName = customer.name ? customer.name.split(' ')[0] : ''
+ // Primary color drives the heading accent and the highlighted total. The
+ // accent is sanitized to a strict hex pattern — anything else falls back
+ // to the legacy dark neutral. Credit notes intentionally use the success
+ // green for the total regardless of branding, because the customer's brain
+ // is wired to expect "money coming back = green".
+ const primaryColor = safeBrandingColor(company.invoice_primary_color, '#111111')
+
return `
@@ -127,8 +142,8 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
-
-
+
+
${L.documentFrom(documentType, getCompanyPrimaryName(company))}
@@ -146,7 +161,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
-
+
@@ -168,7 +183,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
${L.toPay}
-
+
${formatCurrencyForCustomer(invoice.total, invoice.currency, lang)}
@@ -178,7 +193,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
${!hidePayment ? `
-
+
${L.paymentHeading}
@@ -221,7 +236,7 @@ export function generateInvoiceEmailHtml(data: InvoiceEmailData): string {
${L.sincerely}
- ${getCompanyPrimaryName(company)}
+ ${getCompanyPrimaryName(company)}
${company.org_number ? `
diff --git a/lib/email/reminder-templates.ts b/lib/email/reminder-templates.ts
index 392a386b..26886203 100644
--- a/lib/email/reminder-templates.ts
+++ b/lib/email/reminder-templates.ts
@@ -8,6 +8,15 @@ export interface ReminderEmailData {
reminderLevel: 1 | 2 | 3
daysOverdue: number
actionUrl: string // URL for customer to mark as paid or dispute
+ // Dröjsmålsränta + lagstadgad påminnelseavgift (Räntelagen §6, Lag 1981:739).
+ // When the reminder is generated by the cron, these are computed and persisted
+ // on the invoice_reminders row and then passed in here.
+ interestAmount: number
+ interestRate: number // annual rate as decimal (0.105 = 10.5%)
+ interestFromDate: string
+ interestDays: number
+ reminderFee: number
+ totalDue: number // invoice.total + interestAmount + reminderFee
}
// Reminder level configurations
@@ -33,8 +42,27 @@ const REMINDER_CONFIG = {
* Generate HTML email for payment reminder
*/
export function generateReminderEmailHtml(data: ReminderEmailData): string {
- const { invoice, customer, company, reminderLevel, daysOverdue, actionUrl } = data
+ const {
+ invoice,
+ customer,
+ company,
+ reminderLevel,
+ daysOverdue,
+ actionUrl,
+ interestAmount,
+ interestRate,
+ interestDays,
+ reminderFee,
+ totalDue,
+ } = data
const config = REMINDER_CONFIG[reminderLevel]
+ const interestRatePercent = (interestRate * 100).toLocaleString('sv-SE', {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ })
+ const hasInterest = interestAmount > 0
+ const hasFee = reminderFee > 0
+ const hasSurcharges = hasInterest || hasFee
// Different styling based on urgency
const headerColor = reminderLevel === 3 ? '#dc2626' : reminderLevel === 2 ? '#ea580c' : '#2563eb'
@@ -117,9 +145,32 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
- Belopp att betala:
+ Ursprungligt belopp:
+ ${formatCurrency(invoice.total, invoice.currency)}
+
+ ${hasInterest ? `
+
+
+ Dröjsmålsränta (${interestRatePercent}% per år, ${interestDays} dagar):
+
+ ${formatCurrency(interestAmount, invoice.currency)}
+
+ ` : ''}
+ ${hasFee ? `
+
+ Påminnelseavgift:
+ ${formatCurrency(reminderFee, invoice.currency)}
+
+ ` : ''}
+ ${hasSurcharges ? `
+
+
+
+ ` : ''}
+
+ Att betala:
- ${formatCurrency(invoice.total, invoice.currency)}
+ ${formatCurrency(totalDue, invoice.currency)}
@@ -204,8 +255,26 @@ export function generateReminderEmailHtml(data: ReminderEmailData): string {
* Generate plain text email for payment reminder
*/
export function generateReminderEmailText(data: ReminderEmailData): string {
- const { invoice, customer, company, reminderLevel, daysOverdue, actionUrl } = data
+ const {
+ invoice,
+ customer,
+ company,
+ reminderLevel,
+ daysOverdue,
+ actionUrl,
+ interestAmount,
+ interestRate,
+ interestDays,
+ reminderFee,
+ totalDue,
+ } = data
const config = REMINDER_CONFIG[reminderLevel]
+ const interestRatePercent = (interestRate * 100).toLocaleString('sv-SE', {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 2,
+ })
+ const hasInterest = interestAmount > 0
+ const hasFee = reminderFee > 0
let text = `${config.title.toUpperCase()}\n`
text += `Faktura ${invoice.invoice_number} förföll för ${daysOverdue} dagar sedan\n`
@@ -230,7 +299,14 @@ export function generateReminderEmailText(data: ReminderEmailData): string {
text += `Fakturanummer: ${invoice.invoice_number}\n`
text += `Fakturadatum: ${formatDate(invoice.invoice_date)}\n`
text += `Förfallodatum: ${formatDate(invoice.due_date)}\n`
- text += `Belopp att betala: ${formatCurrency(invoice.total, invoice.currency)}\n`
+ text += `Ursprungligt belopp: ${formatCurrency(invoice.total, invoice.currency)}\n`
+ if (hasInterest) {
+ text += `Dröjsmålsränta (${interestRatePercent}% per år, ${interestDays} dagar): ${formatCurrency(interestAmount, invoice.currency)}\n`
+ }
+ if (hasFee) {
+ text += `Påminnelseavgift: ${formatCurrency(reminderFee, invoice.currency)}\n`
+ }
+ text += `Att betala: ${formatCurrency(totalDue, invoice.currency)}\n`
text += `-`.repeat(30) + `\n\n`
text += `Betalningsinformation:\n`
@@ -259,13 +335,21 @@ export function generateReminderEmailText(data: ReminderEmailData): string {
}
/**
- * Generate email subject for payment reminder
+ * Generate email subject for payment reminder.
+ *
+ * When dröjsmålsränta or påminnelseavgift apply we surface them in the
+ * subject so the customer sees the true amount to pay before opening
+ * the email.
*/
export function generateReminderEmailSubject(data: ReminderEmailData): string {
- const { invoice, reminderLevel } = data
+ const { invoice, reminderLevel, totalDue, interestAmount, reminderFee } = data
const config = REMINDER_CONFIG[reminderLevel]
+ const hasSurcharges = interestAmount > 0 || reminderFee > 0
- return `${config.title}: Faktura ${invoice.invoice_number} - ${formatCurrency(invoice.total, invoice.currency)}`
+ const amount = hasSurcharges ? totalDue : invoice.total
+ const suffix = hasSurcharges ? ' (inkl. dröjsmålsränta)' : ''
+
+ return `${config.title}: Faktura ${invoice.invoice_number} - ${formatCurrency(amount, invoice.currency)}${suffix}`
}
/**
diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts
index aeb3a902..40577765 100644
--- a/lib/errors/structured-errors.ts
+++ b/lib/errors/structured-errors.ts
@@ -493,6 +493,16 @@ const INVOICE: Record
= {
message_sv: 'Momssatsen är inte tillåten för denna kundtyp.',
message_en: 'The VAT rate is not allowed for this customer type.',
},
+ INVOICE_CREATE_ROT_RUT_VALIDATION: {
+ httpStatus: 400,
+ message_sv: 'ROT/RUT-avdraget kunde inte valideras. Kontrollera personnummer och fastighetsbeteckning.',
+ message_en: 'ROT/RUT deduction failed validation. Check personnummer and housing designation.',
+ },
+ INVOICE_CREATE_ROT_RUT_PERSONNUMMER_INVALID: {
+ httpStatus: 400,
+ message_sv: 'Personnumret för ROT/RUT-avdraget är ogiltigt.',
+ message_en: 'The personnummer provided for the ROT/RUT deduction is invalid.',
+ },
INVOICE_CREATE_INSERT_FAILED: {
httpStatus: 500,
message_sv: 'Fakturan kunde inte sparas.',
@@ -642,6 +652,37 @@ const INVOICE: Record = {
message_sv: 'Fakturan ändrades samtidigt och kunde inte makuleras. Ladda om och försök igen.',
message_en: 'Invoice was modified concurrently and could not be cancelled. Reload and retry.',
},
+ // Quotes / Offerter
+ QUOTE_NOT_FOUND: {
+ httpStatus: 404,
+ message_sv: 'Offerten kunde inte hittas.',
+ message_en: 'Quote not found.',
+ },
+ QUOTE_INVALID_STATE: {
+ httpStatus: 400,
+ message_sv: 'Offerten är inte i en status som tillåter denna åtgärd.',
+ message_en: 'Quote is not in a state that allows this action.',
+ },
+ QUOTE_TOKEN_INVALID: {
+ httpStatus: 404,
+ message_sv: 'Länken är ogiltig eller har gått ut.',
+ message_en: 'The link is invalid or has expired.',
+ },
+ QUOTE_NUMBER_ASSIGN_FAILED: {
+ httpStatus: 500,
+ message_sv: 'Kunde inte tilldela offertnummer.',
+ message_en: 'Failed to assign quote number.',
+ },
+ QUOTE_CONVERSION_FAILED: {
+ httpStatus: 500,
+ message_sv: 'Offerten kunde inte konverteras till faktura.',
+ message_en: 'Failed to convert quote to invoice.',
+ },
+ QUOTE_NOT_QUOTE: {
+ httpStatus: 400,
+ message_sv: 'Detta dokument är inte en offert.',
+ message_en: 'This document is not a quote.',
+ },
}
const SUPPLIER_INVOICE: Record = {
@@ -660,6 +701,25 @@ const SUPPLIER_INVOICE: Record = {
message_sv: 'Kunde inte godkänna leverantörsfakturan.',
message_en: 'Failed to update supplier invoice status to approved.',
},
+ PO_THREE_WAY_MATCH_FAILED: {
+ httpStatus: 422,
+ message_sv:
+ 'Trevägs-matchning misslyckades: leverantörsfakturan stämmer inte med inköpsordern eller godsmottagningen.',
+ message_en:
+ 'Three-way match failed: the supplier invoice does not reconcile with the purchase order / goods receipt.',
+ },
+ PO_LINK_REQUIRED: {
+ httpStatus: 422,
+ message_sv:
+ 'Inställningarna kräver att varje leverantörsfaktura kopplas till en inköpsorder.',
+ message_en:
+ 'Company settings require every supplier invoice to be linked to a purchase order.',
+ },
+ PO_NOT_FOUND: {
+ httpStatus: 404,
+ message_sv: 'Inköpsordern kunde inte hittas.',
+ message_en: 'Purchase order not found.',
+ },
}
// ─────────────────────────────────────────────────────────────────
diff --git a/lib/events/handlers/event-log-handler.ts b/lib/events/handlers/event-log-handler.ts
index 117520a0..32f5b3c1 100644
--- a/lib/events/handlers/event-log-handler.ts
+++ b/lib/events/handlers/event-log-handler.ts
@@ -15,6 +15,7 @@ const PERSISTED_EVENT_TYPES: CoreEventType[] = [
'journal_entry.corrected',
'document.uploaded',
'document.accessed',
+ 'document.deleted',
'invoice.created',
'invoice.sent',
'credit_note.created',
diff --git a/lib/events/types.ts b/lib/events/types.ts
index c7cae1e9..9063e9a5 100644
--- a/lib/events/types.ts
+++ b/lib/events/types.ts
@@ -27,6 +27,7 @@ export type CoreEvent =
// Documents
| { type: 'document.uploaded'; payload: { document: DocumentAttachment; userId: string; companyId: string } }
| { type: 'document.accessed'; payload: { document: { id: string; file_name: string }; userId: string; companyId: string } }
+ | { type: 'document.deleted'; payload: { document: { id: string; file_name: string }; userId: string; companyId: string } }
// Invoicing
| { type: 'invoice.created'; payload: { invoice: Invoice; userId: string; companyId: string } }
| { type: 'invoice.sent'; payload: { invoice: Invoice; userId: string; companyId: string } }
diff --git a/lib/import/__tests__/sie-import.replace.pg.test.ts b/lib/import/__tests__/sie-import.replace.pg.test.ts
index db80ea90..8adbc87a 100644
--- a/lib/import/__tests__/sie-import.replace.pg.test.ts
+++ b/lib/import/__tests__/sie-import.replace.pg.test.ts
@@ -8,9 +8,12 @@ import { seedCompany } from '@/tests/pg/fixtures'
// (added in migration 20260517150000) blocks duplicate (company_id,
// file_hash) rows for active statuses but allows them once a prior row
// is marked 'replaced' or 'failed'.
-// 2. The replace_sie_import RPC cancels journal entries with
-// source_type='import' while leaving user-created entries
-// (source_type='manual', 'bank_transaction', etc.) intact.
+// 2. The replace_sie_import RPC hard-deletes journal entries with
+// source_type='import' (since 20260526120000), detaches user-attached
+// documents from them, clears the fiscal-period opening-balance
+// pointer if it came from the import, and resets voucher_sequences
+// so the next re-import restarts the series. User-created entries
+// (source_type='manual', 'bank_transaction', etc.) are left intact.
async function insertSIEImport(params: {
companyId: string
@@ -53,6 +56,7 @@ async function insertPostedEntry(params: {
fiscalPeriodId: string
sourceType: 'import' | 'manual' | 'bank_transaction'
voucherNumber: number
+ voucherSeries?: string
entryDate?: string
}): Promise {
const id = randomUUID()
@@ -60,13 +64,14 @@ async function insertPostedEntry(params: {
`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', $6, 'Test entry', $7, 'posted')`,
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'Test entry', $8, 'posted')`,
[
id,
params.userId,
params.companyId,
params.fiscalPeriodId,
params.voucherNumber,
+ params.voucherSeries ?? 'A',
params.entryDate ?? '2026-06-01',
params.sourceType,
],
@@ -81,6 +86,44 @@ async function insertPostedEntry(params: {
return id
}
+async function insertVoucherSequence(params: {
+ companyId: string
+ userId: string
+ fiscalPeriodId: string
+ series: string
+ lastNumber: number
+}): Promise {
+ await getPool().query(
+ `INSERT INTO public.voucher_sequences
+ (company_id, user_id, fiscal_period_id, voucher_series, last_number)
+ VALUES ($1, $2, $3, $4, $5)`,
+ [params.companyId, params.userId, params.fiscalPeriodId, params.series, params.lastNumber],
+ )
+}
+
+async function insertDocumentAttachment(params: {
+ userId: string
+ companyId: string
+ journalEntryId: string
+}): Promise {
+ const id = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.document_attachments
+ (id, user_id, company_id, storage_path, file_name, sha256_hash, journal_entry_id, upload_source)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, 'file_upload')`,
+ [
+ id,
+ params.userId,
+ params.companyId,
+ `test/${id}.pdf`,
+ `test-${id}.pdf`,
+ `sha256-${id}`,
+ params.journalEntryId,
+ ],
+ )
+ return id
+}
+
describe('sie_imports: partial unique index + replace flow', () => {
it('blocks a second active row with the same (company_id, file_hash)', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
@@ -134,45 +177,39 @@ describe('sie_imports: partial unique index + replace flow', () => {
expect(newId).toBeTruthy()
})
- it('replace_sie_import cancels source_type=import entries and leaves manual/bank_transaction entries posted', async () => {
+ it('replace_sie_import deletes source_type=import entries, leaves manual/bank_transaction posted, and resets voucher_sequences to MAX of remaining', async () => {
const { companyId, userId, fiscalPeriodId } = await seedCompany()
const obEntry = await insertPostedEntry({
- userId,
- companyId,
- fiscalPeriodId,
- sourceType: 'import',
- voucherNumber: 1,
+ userId, companyId, fiscalPeriodId, sourceType: 'import', voucherNumber: 1,
})
const importEntry1 = await insertPostedEntry({
- userId,
- companyId,
- fiscalPeriodId,
- sourceType: 'import',
- voucherNumber: 2,
+ userId, companyId, fiscalPeriodId, sourceType: 'import', voucherNumber: 2,
})
const importEntry2 = await insertPostedEntry({
- userId,
- companyId,
- fiscalPeriodId,
- sourceType: 'import',
- voucherNumber: 3,
+ userId, companyId, fiscalPeriodId, sourceType: 'import', voucherNumber: 3,
})
const manualEntry = await insertPostedEntry({
- userId,
- companyId,
- fiscalPeriodId,
- sourceType: 'manual',
- voucherNumber: 4,
+ userId, companyId, fiscalPeriodId, sourceType: 'manual', voucherNumber: 4,
})
const txnEntry = await insertPostedEntry({
- userId,
- companyId,
- fiscalPeriodId,
- sourceType: 'bank_transaction',
- voucherNumber: 5,
+ userId, companyId, fiscalPeriodId, sourceType: 'bank_transaction', voucherNumber: 5,
})
+ // Voucher_sequences advanced past the imports (5 total inserted in series A)
+ await insertVoucherSequence({
+ companyId, userId, fiscalPeriodId, series: 'A', lastNumber: 5,
+ })
+
+ // The fiscal period has its opening_balance_entry_id set to the OB entry,
+ // matching what a real SIE import would have produced.
+ await getPool().query(
+ `UPDATE public.fiscal_periods
+ SET opening_balance_entry_id = $1, opening_balances_set = true
+ WHERE id = $2`,
+ [obEntry, fiscalPeriodId],
+ )
+
const importId = await insertSIEImport({
companyId,
userId,
@@ -186,27 +223,172 @@ describe('sie_imports: partial unique index + replace flow', () => {
`SELECT public.replace_sie_import($1::uuid, $2::uuid) AS replace_sie_import`,
[companyId, importId],
)
- const cancelled = rows[0]!.replace_sie_import
+ expect(rows[0]!.replace_sie_import).toBe(3) // OB + 2 import entries
- // OB entry + 2 import entries = 3 cancelled. Manual & transaction stay posted.
- expect(cancelled).toBe(3)
-
- const statuses = await getPool().query<{ id: string; status: string }>(
+ // The import entries (and their lines) are gone
+ const entries = await getPool().query<{ id: string; status: string }>(
`SELECT id, status FROM public.journal_entries WHERE id = ANY($1)`,
[[obEntry, importEntry1, importEntry2, manualEntry, txnEntry]],
)
- const statusById = Object.fromEntries(statuses.rows.map(r => [r.id, r.status]))
- expect(statusById[obEntry]).toBe('cancelled')
- expect(statusById[importEntry1]).toBe('cancelled')
- expect(statusById[importEntry2]).toBe('cancelled')
+ const statusById = Object.fromEntries(entries.rows.map(r => [r.id, r.status]))
+ expect(statusById[obEntry]).toBeUndefined()
+ expect(statusById[importEntry1]).toBeUndefined()
+ expect(statusById[importEntry2]).toBeUndefined()
expect(statusById[manualEntry]).toBe('posted')
expect(statusById[txnEntry]).toBe('posted')
- const importRow = await getPool().query<{ status: string; replaced_at: string | null }>(
- `SELECT status, replaced_at FROM public.sie_imports WHERE id = $1`,
+ const lines = await getPool().query<{ count: string }>(
+ `SELECT count(*)::text FROM public.journal_entry_lines
+ WHERE journal_entry_id = ANY($1)`,
+ [[obEntry, importEntry1, importEntry2]],
+ )
+ expect(lines.rows[0]!.count).toBe('0')
+
+ // voucher_sequences reset to max of remaining entries in series A (5, the bank tx)
+ const vs = await getPool().query<{ last_number: number }>(
+ `SELECT last_number FROM public.voucher_sequences
+ WHERE company_id = $1 AND fiscal_period_id = $2 AND voucher_series = 'A'`,
+ [companyId, fiscalPeriodId],
+ )
+ expect(vs.rows[0]?.last_number).toBe(5)
+
+ // fiscal_periods OB pointer cleared
+ const fp = await getPool().query<{
+ opening_balance_entry_id: string | null
+ opening_balances_set: boolean
+ }>(
+ `SELECT opening_balance_entry_id, opening_balances_set
+ FROM public.fiscal_periods WHERE id = $1`,
+ [fiscalPeriodId],
+ )
+ expect(fp.rows[0]?.opening_balance_entry_id).toBeNull()
+ expect(fp.rows[0]?.opening_balances_set).toBe(false)
+
+ // sie_imports OB FK cleared, status replaced
+ const importRow = await getPool().query<{
+ status: string
+ replaced_at: string | null
+ opening_balance_entry_id: string | null
+ }>(
+ `SELECT status, replaced_at, opening_balance_entry_id
+ FROM public.sie_imports WHERE id = $1`,
[importId],
)
- expect(importRow.rows[0]!.status).toBe('replaced')
- expect(importRow.rows[0]!.replaced_at).not.toBeNull()
+ expect(importRow.rows[0]?.status).toBe('replaced')
+ expect(importRow.rows[0]?.replaced_at).not.toBeNull()
+ expect(importRow.rows[0]?.opening_balance_entry_id).toBeNull()
+ })
+
+ it('replace_sie_import resets voucher_sequences.last_number to 0 when no entries remain in the series', async () => {
+ const { companyId, userId, fiscalPeriodId } = await seedCompany()
+
+ await insertPostedEntry({
+ userId, companyId, fiscalPeriodId, sourceType: 'import', voucherNumber: 1,
+ })
+ await insertPostedEntry({
+ userId, companyId, fiscalPeriodId, sourceType: 'import', voucherNumber: 2,
+ })
+
+ await insertVoucherSequence({
+ companyId, userId, fiscalPeriodId, series: 'A', lastNumber: 2,
+ })
+
+ const importId = await insertSIEImport({
+ companyId,
+ userId,
+ fileHash: `hash-${randomUUID()}`,
+ status: 'completed',
+ fiscalPeriodId,
+ })
+
+ await getPool().query(
+ `SELECT public.replace_sie_import($1::uuid, $2::uuid)`,
+ [companyId, importId],
+ )
+
+ const vs = await getPool().query<{ last_number: number }>(
+ `SELECT last_number FROM public.voucher_sequences
+ WHERE company_id = $1 AND fiscal_period_id = $2 AND voucher_series = 'A'`,
+ [companyId, fiscalPeriodId],
+ )
+ expect(vs.rows[0]?.last_number).toBe(0)
+ })
+
+ it('replace_sie_import detaches documents from deleted entries without losing the document rows', async () => {
+ const { companyId, userId, fiscalPeriodId } = await seedCompany()
+
+ const importEntry = await insertPostedEntry({
+ userId, companyId, fiscalPeriodId, sourceType: 'import', voucherNumber: 1,
+ })
+ const manualEntry = await insertPostedEntry({
+ userId, companyId, fiscalPeriodId, sourceType: 'manual', voucherNumber: 2,
+ })
+
+ const attachedDoc = await insertDocumentAttachment({
+ userId, companyId, journalEntryId: importEntry,
+ })
+ const manualDoc = await insertDocumentAttachment({
+ userId, companyId, journalEntryId: manualEntry,
+ })
+
+ const importId = await insertSIEImport({
+ companyId,
+ userId,
+ fileHash: `hash-${randomUUID()}`,
+ status: 'completed',
+ fiscalPeriodId,
+ })
+
+ await getPool().query(
+ `SELECT public.replace_sie_import($1::uuid, $2::uuid)`,
+ [companyId, importId],
+ )
+
+ // The document attached to the deleted import entry is detached but preserved
+ const detached = await getPool().query<{
+ id: string
+ journal_entry_id: string | null
+ storage_path: string
+ file_name: string
+ }>(
+ `SELECT id, journal_entry_id, storage_path, file_name
+ FROM public.document_attachments WHERE id = $1`,
+ [attachedDoc],
+ )
+ expect(detached.rows[0]).toBeTruthy()
+ expect(detached.rows[0]?.journal_entry_id).toBeNull()
+ expect(detached.rows[0]?.storage_path).toBeTruthy()
+ expect(detached.rows[0]?.file_name).toBeTruthy()
+
+ // The document attached to the surviving manual entry is left alone
+ const untouched = await getPool().query<{ journal_entry_id: string | null }>(
+ `SELECT journal_entry_id FROM public.document_attachments WHERE id = $1`,
+ [manualDoc],
+ )
+ expect(untouched.rows[0]?.journal_entry_id).toBe(manualEntry)
+ })
+
+ it('replace_sie_import on an already-replaced import raises', async () => {
+ const { companyId, userId, fiscalPeriodId } = await seedCompany()
+
+ const importId = await insertSIEImport({
+ companyId,
+ userId,
+ fileHash: `hash-${randomUUID()}`,
+ status: 'completed',
+ fiscalPeriodId,
+ })
+
+ await getPool().query(
+ `SELECT public.replace_sie_import($1::uuid, $2::uuid)`,
+ [companyId, importId],
+ )
+
+ await expect(
+ getPool().query(
+ `SELECT public.replace_sie_import($1::uuid, $2::uuid)`,
+ [companyId, importId],
+ ),
+ ).rejects.toThrow(/not found or not in completed status/)
})
})
diff --git a/lib/import/sie-import.ts b/lib/import/sie-import.ts
index 7554c6ce..51cb1741 100644
--- a/lib/import/sie-import.ts
+++ b/lib/import/sie-import.ts
@@ -131,22 +131,28 @@ export async function checkDuplicatePeriodImport(
}
/**
- * Replace (cancel) a completed SIE import so the user can re-import corrected
- * data for the same fiscal period.
+ * Replace a completed SIE import so the user can re-import corrected data
+ * for the same fiscal period.
*
- * Per BFL 5 kap 5§ (rättelse), the original entries are preserved with
- * status='cancelled'. The import record is marked as 'replaced' with a
- * timestamp for audit trail (BFNAR 2013:2 kap 8 behandlingshistorik).
- * Nothing is deleted.
+ * The RPC hard-deletes every source_type='import' entry the original import
+ * created (plus stragglers from any prior soft-replace), detaches user-
+ * attached documents from those entries (PDFs stay in storage as unlinked
+ * documents), clears the fiscal-period opening-balance pointer if it came
+ * from this import, and resets voucher_sequences so the next re-import
+ * restarts the series at 1 (or at MAX of remaining non-import entries).
*
- * The actual cancellation + status update is atomic via the replace_sie_import
- * DB RPC to prevent inconsistent state.
+ * Audit trail lives in the sie_imports row (status='replaced',
+ * replaced_at, filename, file_hash, transactions_count, fiscal_year_*)
+ * plus per-row audit_log entries written by the write_audit_log trigger
+ * on each journal_entries DELETE (old_state JSONB snapshot).
+ *
+ * The whole cleanup is atomic via the replace_sie_import DB RPC.
*/
export async function replaceSIEImport(
supabase: SupabaseClient,
companyId: string,
importId: string
-): Promise<{ success: boolean; cancelledEntries: number; error?: string }> {
+): Promise<{ success: boolean; deletedEntries: number; error?: string }> {
// 1. Fetch and validate the import record
const { data: importRecord } = await supabase
.from('sie_imports')
@@ -156,11 +162,11 @@ export async function replaceSIEImport(
.single()
if (!importRecord) {
- return { success: false, cancelledEntries: 0, error: 'Import hittades inte' }
+ return { success: false, deletedEntries: 0, error: 'Import hittades inte' }
}
if (importRecord.status !== 'completed') {
- return { success: false, cancelledEntries: 0, error: `Kan bara ersätta slutförda importer (status: ${importRecord.status})` }
+ return { success: false, deletedEntries: 0, error: `Kan bara ersätta slutförda importer (status: ${importRecord.status})` }
}
// 2. Check that the fiscal period is not closed or locked
@@ -173,21 +179,21 @@ export async function replaceSIEImport(
.single()
if (period?.is_closed || period?.locked_at) {
- return { success: false, cancelledEntries: 0, error: 'Kan inte ersätta import i ett låst eller stängt räkenskapsår. Öppna perioden först.' }
+ return { success: false, deletedEntries: 0, error: 'Kan inte ersätta import i ett låst eller stängt räkenskapsår. Öppna perioden först.' }
}
}
- // 3. Atomically cancel entries and mark import as replaced via DB RPC
- const { data: cancelledCount, error: rpcError } = await supabase.rpc('replace_sie_import', {
+ // 3. Atomically delete entries and mark import as replaced via DB RPC
+ const { data: deletedCount, error: rpcError } = await supabase.rpc('replace_sie_import', {
p_company_id: companyId,
p_import_id: importId,
})
if (rpcError) {
- return { success: false, cancelledEntries: 0, error: `Kunde inte ersätta import: ${rpcError.message}` }
+ return { success: false, deletedEntries: 0, error: `Kunde inte ersätta import: ${rpcError.message}` }
}
- return { success: true, cancelledEntries: cancelledCount as number }
+ return { success: true, deletedEntries: deletedCount as number }
}
/**
@@ -1600,17 +1606,15 @@ export async function executeSIEImport(
}
result.replacedPriorImport = {
importId: priorPeriodImport.id,
- cancelledEntries: replaceResult.cancelledEntries,
+ deletedEntries: replaceResult.deletedEntries,
}
- // The replace_sie_import RPC cancelled the prior import's opening
- // balance entry, but the fiscal_periods row still flags
- // opening_balances_set=true and points opening_balance_entry_id at
- // the now-cancelled row. Without clearing those, the IB import
- // below would skip ("Ingående balanser finns redan...") and the
- // new IB would be lost. Only reset when the cleared entry was the
- // prior import's IB — if the period's IB came from somewhere else
- // (manual entry, year-end carryover), we must not touch it.
+ // The replace_sie_import RPC clears fiscal_periods
+ // opening_balance_entry_id and opening_balances_set inside its
+ // transaction when the prior import had an OB entry. This client-
+ // side UPDATE is now an idempotent safety net for pre-fix data
+ // (companies whose prior replace ran against the soft-cancel
+ // implementation and left the pointer dangling on the row).
if (priorPeriodImport.fiscal_period_id && priorPeriodImport.opening_balance_entry_id) {
await supabase
.from('fiscal_periods')
diff --git a/lib/import/types.ts b/lib/import/types.ts
index 891d56c8..f7c6d164 100644
--- a/lib/import/types.ts
+++ b/lib/import/types.ts
@@ -299,8 +299,8 @@ export interface ImportResult {
// If this import replaced a prior completed import for the same fiscal year
// (Fortnox re-sync flow), the prior import's id and the count of journal
- // entries that were cancelled as a result.
- replacedPriorImport?: { importId: string; cancelledEntries: number } | null
+ // entries that were deleted as a result.
+ replacedPriorImport?: { importId: string; deletedEntries: number } | null
}
/**
diff --git a/lib/invoices/__tests__/contrast-check.test.ts b/lib/invoices/__tests__/contrast-check.test.ts
new file mode 100644
index 00000000..acce2c60
--- /dev/null
+++ b/lib/invoices/__tests__/contrast-check.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from 'vitest'
+import { getContrastRatio, isWcagAACompliant } from '@/lib/invoices/contrast-check'
+
+describe('getContrastRatio', () => {
+ it('returns 21 for black on white (maximum contrast)', () => {
+ const ratio = getContrastRatio('#000000', '#ffffff')
+ expect(ratio).toBeCloseTo(21, 1)
+ })
+
+ it('returns 21 regardless of argument order (white on black)', () => {
+ const ratio = getContrastRatio('#ffffff', '#000000')
+ expect(ratio).toBeCloseTo(21, 1)
+ })
+
+ it('returns 1 for identical colors (white on white)', () => {
+ const ratio = getContrastRatio('#ffffff', '#ffffff')
+ expect(ratio).toBeCloseTo(1, 5)
+ })
+
+ it('returns 1 for identical colors (black on black)', () => {
+ const ratio = getContrastRatio('#000000', '#000000')
+ expect(ratio).toBeCloseTo(1, 5)
+ })
+
+ it('PDF default heading color #1a1a1a on white passes AA with ratio > 15', () => {
+ const ratio = getContrastRatio('#1a1a1a', '#ffffff')
+ expect(ratio).toBeGreaterThan(15)
+ })
+
+ it('yellow #ffff00 on white has very low contrast (~1.07), fails AA', () => {
+ const ratio = getContrastRatio('#ffff00', '#ffffff')
+ expect(ratio).toBeGreaterThan(1.0)
+ expect(ratio).toBeLessThan(1.2)
+ })
+
+ it('accepts uppercase hex', () => {
+ const lower = getContrastRatio('#1a1a1a', '#ffffff')
+ const upper = getContrastRatio('#1A1A1A', '#FFFFFF')
+ expect(upper).toBeCloseTo(lower, 5)
+ })
+
+ it('throws on invalid hex format', () => {
+ expect(() => getContrastRatio('1a1a1a', '#ffffff')).toThrow()
+ expect(() => getContrastRatio('#fff', '#ffffff')).toThrow()
+ expect(() => getContrastRatio('#zzzzzz', '#ffffff')).toThrow()
+ })
+})
+
+describe('isWcagAACompliant', () => {
+ it('passes for the PDF default primary color (#1a1a1a) on white', () => {
+ expect(isWcagAACompliant('#1a1a1a', '#ffffff')).toBe(true)
+ })
+
+ it('passes for the PDF default accent color (#666666) on white', () => {
+ // 5.74:1 — comfortably above AA threshold.
+ expect(isWcagAACompliant('#666666', '#ffffff')).toBe(true)
+ })
+
+ it('fails for pure yellow on white', () => {
+ expect(isWcagAACompliant('#ffff00', '#ffffff')).toBe(false)
+ })
+
+ // gnubok brand semantic colors. All three are used as DATA-ONLY indicators
+ // (charts, positive/negative deltas), never as chrome text on white. These
+ // tests document where they sit relative to AA — terracotta/destructive is
+ // the only one that comfortably passes AA on white.
+ it('gnubok terracotta (#c2410c equivalent dark red) passes AA on white', () => {
+ // 5.91:1 — passes AA for normal text.
+ expect(isWcagAACompliant('#c2410c', '#ffffff')).toBe(true)
+ })
+
+ it('gnubok sage (#84a98c lighter green) fails AA on white', () => {
+ // ~2.4:1 — fails AA, as expected for a soft pastel sage.
+ expect(isWcagAACompliant('#84a98c', '#ffffff')).toBe(false)
+ })
+
+ it('gnubok ochre (#d4a373 warm yellow) fails AA on white', () => {
+ // ~2.3:1 — fails AA, as expected for a warm soft ochre.
+ expect(isWcagAACompliant('#d4a373', '#ffffff')).toBe(false)
+ })
+
+ it('a very dark sage (#2d5a3e) passes AA on white', () => {
+ expect(isWcagAACompliant('#2d5a3e', '#ffffff')).toBe(true)
+ })
+})
diff --git a/lib/invoices/__tests__/late-payment-interest.test.ts b/lib/invoices/__tests__/late-payment-interest.test.ts
new file mode 100644
index 00000000..92c9300f
--- /dev/null
+++ b/lib/invoices/__tests__/late-payment-interest.test.ts
@@ -0,0 +1,153 @@
+import { describe, it, expect } from 'vitest'
+import {
+ calculateLatePaymentInterest,
+ getAnnualInterestRate,
+ getReferensrantaAt,
+} from '../late-payment-interest'
+
+describe('getReferensrantaAt', () => {
+ it('returns 0 for early 2022', () => {
+ expect(getReferensrantaAt('2022-01-15')).toBe(0)
+ })
+
+ it('returns 0.005 for late 2022', () => {
+ expect(getReferensrantaAt('2022-12-31')).toBe(0.005)
+ })
+
+ it('returns the boundary value exactly on switch date', () => {
+ expect(getReferensrantaAt('2024-07-01')).toBe(0.0425)
+ })
+
+ it('returns the most recent rate for dates in 2026', () => {
+ expect(getReferensrantaAt('2026-05-15')).toBe(0.025)
+ })
+
+ it('falls back to earliest entry for pre-2022 dates', () => {
+ expect(getReferensrantaAt('2021-01-01')).toBe(0)
+ })
+})
+
+describe('getAnnualInterestRate', () => {
+ it('adds 8 percentage points to referensränta by default', () => {
+ // 2026 referensränta = 0.025 → 0.025 + 0.08 = 0.105
+ const rate = getAnnualInterestRate('2026-05-01')
+ expect(rate).toBeCloseTo(0.105, 4)
+ })
+
+ it('uses the override rate when supplied', () => {
+ const rate = getAnnualInterestRate('2026-05-01', 0.115)
+ expect(rate).toBe(0.115)
+ })
+
+ it('respects override of 0 (interest-free reminder)', () => {
+ const rate = getAnnualInterestRate('2026-05-01', 0)
+ expect(rate).toBe(0)
+ })
+
+ it('ignores undefined override but respects 0', () => {
+ expect(getAnnualInterestRate('2026-05-01', undefined)).toBeCloseTo(0.105, 4)
+ expect(getAnnualInterestRate('2026-05-01', null)).toBeCloseTo(0.105, 4)
+ })
+})
+
+describe('calculateLatePaymentInterest', () => {
+ it('returns 0 when invoice is not yet overdue', () => {
+ const result = calculateLatePaymentInterest({
+ overdueAmount: 10_000,
+ dueDate: '2026-06-01',
+ asOfDate: '2026-05-15',
+ })
+ expect(result.amount).toBe(0)
+ expect(result.days).toBe(0)
+ expect(result.fromDate).toBe('2026-06-01')
+ })
+
+ it('returns 0 when asOfDate equals dueDate', () => {
+ const result = calculateLatePaymentInterest({
+ overdueAmount: 10_000,
+ dueDate: '2026-05-15',
+ asOfDate: '2026-05-15',
+ })
+ expect(result.amount).toBe(0)
+ expect(result.days).toBe(0)
+ })
+
+ it('computes Räntelagen §6 default for 30 days on 10 000 kr (2026 rate 10.5%)', () => {
+ // 2026-01-01 referensränta = 0.025 → annual rate = 0.105
+ // interest = 10 000 × 0.105 × 30 / 365 ≈ 86.30
+ const result = calculateLatePaymentInterest({
+ overdueAmount: 10_000,
+ dueDate: '2026-04-15',
+ asOfDate: '2026-05-15',
+ })
+ expect(result.days).toBe(30)
+ expect(result.rate).toBeCloseTo(0.105, 4)
+ expect(result.amount).toBeCloseTo(86.3, 1)
+ })
+
+ it('computes with explicit override 5% on 10 000 kr for 30 days ≈ 41.10', () => {
+ // interest = 10 000 × 0.05 × 30 / 365 ≈ 41.0959
+ const result = calculateLatePaymentInterest({
+ overdueAmount: 10_000,
+ dueDate: '2026-04-15',
+ asOfDate: '2026-05-15',
+ overrideRate: 0.05,
+ })
+ expect(result.amount).toBeCloseTo(41.1, 1)
+ expect(result.rate).toBe(0.05)
+ })
+
+ it('computes with explicit override 11.5% on 10 000 kr for 30 days ≈ 94.52', () => {
+ // interest = 10 000 × 0.115 × 30 / 365 ≈ 94.5205
+ const result = calculateLatePaymentInterest({
+ overdueAmount: 10_000,
+ dueDate: '2026-04-15',
+ asOfDate: '2026-05-15',
+ overrideRate: 0.115,
+ })
+ expect(result.amount).toBeCloseTo(94.52, 1)
+ expect(result.rate).toBe(0.115)
+ })
+
+ it('uses the rate at the dueDate even when asOfDate is in a later rate period', () => {
+ // Due in late 2025 (rate at 2025-12-15: referensränta 0.0325 → annual 0.1125),
+ // checked in 2026 (rate 0.025 + 0.08 = 0.105). We should use the dueDate rate.
+ const result = calculateLatePaymentInterest({
+ overdueAmount: 10_000,
+ dueDate: '2025-12-15',
+ asOfDate: '2026-02-15',
+ })
+ expect(result.rate).toBeCloseTo(0.1125, 4)
+ })
+
+ it('throws on a negative overdue amount', () => {
+ expect(() =>
+ calculateLatePaymentInterest({
+ overdueAmount: -500,
+ dueDate: '2026-04-15',
+ asOfDate: '2026-05-15',
+ }),
+ ).toThrow()
+ })
+
+ it('returns 0 amount when overdueAmount is 0 even if days > 0', () => {
+ const result = calculateLatePaymentInterest({
+ overdueAmount: 0,
+ dueDate: '2026-04-15',
+ asOfDate: '2026-05-15',
+ })
+ expect(result.amount).toBe(0)
+ expect(result.days).toBe(0)
+ })
+
+ it('rounds to 2 decimals (no toFixed drift)', () => {
+ // 12 345 × 0.115 × 17 / 365 = 66.1150... → 66.12
+ const result = calculateLatePaymentInterest({
+ overdueAmount: 12_345,
+ dueDate: '2026-04-15',
+ asOfDate: '2026-05-02',
+ overrideRate: 0.115,
+ })
+ expect(result.amount).toBe(66.12)
+ })
+})
diff --git a/lib/invoices/__tests__/pdf-template-branding.test.ts b/lib/invoices/__tests__/pdf-template-branding.test.ts
new file mode 100644
index 00000000..7a446763
--- /dev/null
+++ b/lib/invoices/__tests__/pdf-template-branding.test.ts
@@ -0,0 +1,82 @@
+/**
+ * Smoke tests for the InvoicePDF branding refactor.
+ *
+ * Goal: confirm that the no-branding code path still renders, and that the
+ * resolveBranding/createStyles defaults match the original hardcoded values.
+ *
+ * We don't compare full PDF buffers byte-for-byte (react-pdf includes
+ * timestamps and non-deterministic stream IDs in headers), so instead we
+ * verify that:
+ * - A render with no branding prop succeeds.
+ * - A render with the legacy default branding ({primaryColor: '#1a1a1a',
+ * accentColor: '#666666', fontFamily: 'Helvetica'}) succeeds.
+ * - The brandingFromCompanySettings() helper extracts the expected fields.
+ *
+ * Full visual regression lives outside the test suite; for the snapshot
+ * promise, the contract is "default branding === legacy hardcoded values"
+ * which is enforced statically by the DEFAULT_BRANDING constant.
+ */
+
+import { describe, expect, it } from 'vitest'
+import { brandingFromCompanySettings } from '@/lib/invoices/pdf-template'
+import { makeCompanySettings } from '@/tests/helpers'
+
+describe('brandingFromCompanySettings', () => {
+ it('returns the saved branding values when the company has them set', () => {
+ const company = makeCompanySettings({
+ invoice_primary_color: '#c2410c',
+ invoice_accent_color: '#84a98c',
+ invoice_font_family: 'Times-Roman',
+ invoice_header_text: 'Thank you for your business',
+ invoice_footer_text: 'Visit us at example.com',
+ })
+
+ const branding = brandingFromCompanySettings(company)
+
+ expect(branding).toEqual({
+ primaryColor: '#c2410c',
+ accentColor: '#84a98c',
+ fontFamily: 'Times-Roman',
+ headerText: 'Thank you for your business',
+ footerText: 'Visit us at example.com',
+ })
+ })
+
+ it('returns the legacy defaults when the company has un-set branding', () => {
+ const company = makeCompanySettings()
+
+ const branding = brandingFromCompanySettings(company)
+
+ // makeCompanySettings sets the legacy defaults via the test fixture;
+ // these are the same values the migration writes for existing rows.
+ expect(branding).toEqual({
+ primaryColor: '#1a1a1a',
+ accentColor: '#666666',
+ fontFamily: 'Helvetica',
+ headerText: null,
+ footerText: null,
+ })
+ })
+
+ it('survives a legacy DB row that returns null/undefined for the branding columns', () => {
+ // Simulate a row that was created BEFORE the migration applied — these
+ // columns can come back as null until the default backfills run. The
+ // helper must not throw.
+ const legacyRow = {
+ company_id: 'company-1',
+ invoice_primary_color: null,
+ invoice_accent_color: null,
+ invoice_font_family: null,
+ invoice_header_text: null,
+ invoice_footer_text: null,
+ } as unknown as Parameters[0]
+
+ const branding = brandingFromCompanySettings(legacyRow)
+
+ expect(branding.primaryColor).toBeUndefined()
+ expect(branding.accentColor).toBeUndefined()
+ expect(branding.fontFamily).toBeUndefined()
+ expect(branding.headerText).toBeNull()
+ expect(branding.footerText).toBeNull()
+ })
+})
diff --git a/lib/invoices/__tests__/rot-rut-rules.test.ts b/lib/invoices/__tests__/rot-rut-rules.test.ts
new file mode 100644
index 00000000..8f3678a9
--- /dev/null
+++ b/lib/invoices/__tests__/rot-rut-rules.test.ts
@@ -0,0 +1,194 @@
+import { describe, it, expect } from 'vitest'
+import {
+ ROT_PERCENT,
+ RUT_PERCENT,
+ ROT_MAX,
+ RUT_MAX,
+ computeDeduction,
+ computeInvoiceDeductionTotal,
+ computeDeductionTotalsByKind,
+ validateInvoice,
+ type ItemForDeduction,
+ type ValidateInvoiceItem,
+} from '../rot-rut-rules'
+
+describe('rot-rut-rules — constants', () => {
+ it('uses the 2026 statutory rates', () => {
+ expect(ROT_PERCENT).toBe(0.30)
+ expect(RUT_PERCENT).toBe(0.50)
+ expect(ROT_MAX).toBe(50000)
+ expect(RUT_MAX).toBe(75000)
+ })
+})
+
+describe('computeDeduction', () => {
+ it('standard ROT: 10 000 kr labor → 3 000 kr deduction', () => {
+ const item: ItemForDeduction = {
+ unit_price: 10000,
+ quantity: 1,
+ deduction_type: 'rot',
+ }
+ expect(computeDeduction(item)).toBe(3000)
+ })
+
+ it('standard RUT: 5 000 kr labor → 2 500 kr deduction', () => {
+ const item: ItemForDeduction = {
+ unit_price: 5000,
+ quantity: 1,
+ deduction_type: 'rut',
+ }
+ expect(computeDeduction(item)).toBe(2500)
+ })
+
+ it('no deduction_type → 0', () => {
+ const item: ItemForDeduction = {
+ unit_price: 10000,
+ quantity: 1,
+ }
+ expect(computeDeduction(item)).toBe(0)
+ })
+
+ it('null deduction_type → 0', () => {
+ const item: ItemForDeduction = {
+ unit_price: 10000,
+ quantity: 1,
+ deduction_type: null,
+ }
+ expect(computeDeduction(item)).toBe(0)
+ })
+
+ it('negative or zero amount → 0', () => {
+ expect(computeDeduction({ unit_price: 0, quantity: 1, deduction_type: 'rot' })).toBe(0)
+ expect(computeDeduction({ unit_price: -100, quantity: 1, deduction_type: 'rut' })).toBe(0)
+ })
+
+ it('quantity > 1 with ROT', () => {
+ const item: ItemForDeduction = {
+ unit_price: 500,
+ quantity: 20, // 10 000 total
+ deduction_type: 'rot',
+ }
+ expect(computeDeduction(item)).toBe(3000)
+ })
+
+ it('rounds to two decimals', () => {
+ const item: ItemForDeduction = {
+ unit_price: 333.33,
+ quantity: 1,
+ deduction_type: 'rut', // 333.33 * 0.5 = 166.665 → 166.67 (banker's rounding off)
+ }
+ expect(computeDeduction(item)).toBe(166.67)
+ })
+
+ it('caps at line total even if percent goes off (defensive)', () => {
+ // The percent is < 1.0 so this is hypothetical, but the cap is part
+ // of the contract — assert it via a synthetic case where unit_price ×
+ // quantity happens to be tiny but the rounding step could overshoot.
+ const item: ItemForDeduction = {
+ unit_price: 0.01,
+ quantity: 1,
+ deduction_type: 'rut',
+ }
+ // 0.01 * 0.5 = 0.005 → rounds to 0.01 = line_total. OK, capped.
+ expect(computeDeduction(item)).toBe(0.01)
+ })
+})
+
+describe('computeInvoiceDeductionTotal', () => {
+ it('mixed: ROT line + non-eligible line — only ROT generates deduction', () => {
+ const items: ItemForDeduction[] = [
+ { unit_price: 10000, quantity: 1, deduction_type: 'rot' },
+ { unit_price: 2000, quantity: 1 }, // not flagged
+ ]
+ expect(computeInvoiceDeductionTotal(items)).toBe(3000)
+ })
+
+ it('mixed ROT + RUT lines sum independently', () => {
+ const items: ItemForDeduction[] = [
+ { unit_price: 10000, quantity: 1, deduction_type: 'rot' }, // 3 000
+ { unit_price: 4000, quantity: 1, deduction_type: 'rut' }, // 2 000
+ ]
+ expect(computeInvoiceDeductionTotal(items)).toBe(5000)
+ })
+
+ it('all non-eligible → 0', () => {
+ const items: ItemForDeduction[] = [
+ { unit_price: 1000, quantity: 1 },
+ { unit_price: 2000, quantity: 1 },
+ ]
+ expect(computeInvoiceDeductionTotal(items)).toBe(0)
+ })
+})
+
+describe('computeDeductionTotalsByKind', () => {
+ it('separates ROT and RUT', () => {
+ const items: ItemForDeduction[] = [
+ { unit_price: 10000, quantity: 1, deduction_type: 'rot' }, // 3 000
+ { unit_price: 4000, quantity: 1, deduction_type: 'rut' }, // 2 000
+ { unit_price: 2000, quantity: 1, deduction_type: 'rot' }, // 600
+ ]
+ expect(computeDeductionTotalsByKind(items)).toEqual({ rot: 3600, rut: 2000 })
+ })
+})
+
+describe('validateInvoice', () => {
+ it('errors when ROT/RUT but personnummer missing', () => {
+ const items: ValidateInvoiceItem[] = [
+ { unit_price: 5000, quantity: 1, deduction_type: 'rut' },
+ ]
+ const result = validateInvoice(items, false, true)
+ expect(result.errors).toContain('Personnummer krävs för ROT/RUT-avdrag.')
+ })
+
+ it('errors when ROT but housing_designation missing', () => {
+ const items: ValidateInvoiceItem[] = [
+ { unit_price: 5000, quantity: 1, deduction_type: 'rot' },
+ ]
+ const result = validateInvoice(items, true, false)
+ expect(result.errors).toContain('Fastighetsbeteckning krävs för ROT-avdrag.')
+ })
+
+ it('RUT without housing_designation → no error (RUT does not require it)', () => {
+ const items: ValidateInvoiceItem[] = [
+ { unit_price: 5000, quantity: 1, deduction_type: 'rut' },
+ ]
+ const result = validateInvoice(items, true, false)
+ expect(result.errors).toHaveLength(0)
+ })
+
+ it('no deduction lines → no errors regardless of metadata', () => {
+ const items: ValidateInvoiceItem[] = [
+ { unit_price: 5000, quantity: 1 },
+ ]
+ expect(validateInvoice(items, false, false).errors).toHaveLength(0)
+ })
+
+ it('warns about ROT cap when invoice alone exceeds 50 000', () => {
+ const items: ValidateInvoiceItem[] = [
+ { unit_price: 200000, quantity: 1, deduction_type: 'rot' }, // 60 000 deduction
+ ]
+ const result = validateInvoice(items, true, true)
+ expect(result.errors).toHaveLength(0)
+ expect(result.warnings.length).toBeGreaterThan(0)
+ expect(result.warnings[0]).toMatch(/ROT/)
+ expect(result.warnings[0]).toMatch(/50/)
+ })
+
+ it('warns about RUT cap when invoice alone exceeds 75 000', () => {
+ const items: ValidateInvoiceItem[] = [
+ { unit_price: 200000, quantity: 1, deduction_type: 'rut' }, // 100 000 deduction
+ ]
+ const result = validateInvoice(items, true, true)
+ expect(result.warnings.length).toBeGreaterThan(0)
+ expect(result.warnings[0]).toMatch(/RUT/)
+ expect(result.warnings[0]).toMatch(/75/)
+ })
+
+ it('no warning when total under cap', () => {
+ const items: ValidateInvoiceItem[] = [
+ { unit_price: 10000, quantity: 1, deduction_type: 'rot' }, // 3 000 — well under cap
+ ]
+ const result = validateInvoice(items, true, true)
+ expect(result.warnings).toHaveLength(0)
+ })
+})
diff --git a/lib/invoices/contrast-check.ts b/lib/invoices/contrast-check.ts
new file mode 100644
index 00000000..86d732b7
--- /dev/null
+++ b/lib/invoices/contrast-check.ts
@@ -0,0 +1,80 @@
+/**
+ * WCAG 2.x contrast ratio helpers.
+ *
+ * Pure utility — no I/O. Used by the invoice branding settings UI to warn
+ * users when their chosen primary color would fail AA contrast against a
+ * white invoice background, and by the branding API to surface the same
+ * warning in the JSON response.
+ *
+ * Reference: WCAG 2.2 §1.4.3 (Contrast — Minimum). The 4.5:1 threshold
+ * applies to normal text (≤18pt, or ≤14pt bold). The PDF invoice renders
+ * text at 8-14pt, so 4.5:1 is the right threshold here.
+ *
+ * Formula:
+ * L = 0.2126 * R_lin + 0.7152 * G_lin + 0.0722 * B_lin
+ * where each channel x in [0, 1]:
+ * x_lin = x / 12.92 if x <= 0.03928
+ * x_lin = ((x + 0.055) / 1.055) ** 2.4 otherwise
+ *
+ * ratio = (L_lighter + 0.05) / (L_darker + 0.05)
+ * ratio ∈ [1, 21]
+ */
+
+/**
+ * Parse a hex color string (#RRGGBB) into RGB channels in [0, 1].
+ * Throws on invalid input — callers should validate format upstream.
+ */
+function parseHexColor(hex: string): { r: number; g: number; b: number } {
+ const m = /^#([0-9A-Fa-f]{6})$/.exec(hex.trim())
+ if (!m) throw new Error(`Invalid hex color: ${hex}`)
+ const value = m[1]
+ const r = parseInt(value.slice(0, 2), 16) / 255
+ const g = parseInt(value.slice(2, 4), 16) / 255
+ const b = parseInt(value.slice(4, 6), 16) / 255
+ return { r, g, b }
+}
+
+/**
+ * Linearize a single sRGB channel value [0, 1] per WCAG 2.x.
+ */
+function linearize(channel: number): number {
+ return channel <= 0.03928
+ ? channel / 12.92
+ : Math.pow((channel + 0.055) / 1.055, 2.4)
+}
+
+/**
+ * Compute relative luminance L ∈ [0, 1] for a hex color per WCAG 2.x.
+ */
+function relativeLuminance(hex: string): number {
+ const { r, g, b } = parseHexColor(hex)
+ const rL = linearize(r)
+ const gL = linearize(g)
+ const bL = linearize(b)
+ return 0.2126 * rL + 0.7152 * gL + 0.0722 * bL
+}
+
+/**
+ * Compute the WCAG contrast ratio between two hex colors. Returns a value in
+ * [1, 21] — 1 = identical colors, 21 = pure black on pure white.
+ *
+ * Argument order does not matter (the formula uses the lighter and darker
+ * luminance regardless of which is foreground).
+ */
+export function getContrastRatio(hexFg: string, hexBg: string): number {
+ const l1 = relativeLuminance(hexFg)
+ const l2 = relativeLuminance(hexBg)
+ const lighter = Math.max(l1, l2)
+ const darker = Math.min(l1, l2)
+ return (lighter + 0.05) / (darker + 0.05)
+}
+
+/**
+ * Returns true when the contrast ratio between fg and bg meets WCAG 2.2 AA
+ * for normal text (4.5:1). Use this to warn users when their brand color
+ * would render text that fails AA on the invoice background (which is
+ * effectively white in the current PDF template).
+ */
+export function isWcagAACompliant(fg: string, bg: string): boolean {
+ return getContrastRatio(fg, bg) >= 4.5
+}
diff --git a/lib/invoices/late-payment-interest.ts b/lib/invoices/late-payment-interest.ts
new file mode 100644
index 00000000..92c6aef9
--- /dev/null
+++ b/lib/invoices/late-payment-interest.ts
@@ -0,0 +1,155 @@
+/**
+ * Statutory late-payment interest (dröjsmålsränta) per Räntelagen §6.
+ *
+ * The default rate is Riksbankens referensränta + 8 percentage points,
+ * applied as a simple annual interest on the overdue amount over the
+ * number of days the invoice has been overdue.
+ *
+ * Formula:
+ * interest = overdueAmount × annualRate × overdueDays / 365
+ *
+ * We use 365 days (not 360) — this matches Swedish practice and is what
+ * Skatteverket / Kronofogden use in their late-payment calculators.
+ *
+ * Companies may override the statutory rate via
+ * `company_settings.reminder_interest_rate_override`. When an override is
+ * supplied we apply it directly (i.e. NOT referensränta + override) since
+ * that's the simpler mental model for users entering "11.5% per year".
+ *
+ * Referensränta is set by Riksbanken twice a year on January 1 and July 1.
+ * We hardcode the lookup table here to avoid a network call on every
+ * reminder; the table is small (one row per six months) and the values
+ * are public, durable records. Update this table when Riksbanken publishes
+ * a new rate.
+ */
+
+/**
+ * Riksbankens referensränta history. Each entry is the rate effective from
+ * the given date onward (until the next entry). Most recent first is more
+ * efficient to search but for clarity we keep them in ascending order and
+ * walk from newest to oldest at lookup time.
+ *
+ * Source: https://www.riksbank.se/sv/statistik/rantor-och-valutakurser/referensranta/
+ *
+ * The "annual default rate" applied to invoices is referensränta + 0.08
+ * (eight percentage points, Räntelagen §6).
+ */
+const REFERENSRANTA_HISTORY: ReadonlyArray<{ from: string; rate: number }> = [
+ { from: '2022-01-01', rate: 0.0 },
+ { from: '2022-07-01', rate: 0.005 },
+ { from: '2023-01-01', rate: 0.025 },
+ { from: '2023-07-01', rate: 0.035 },
+ { from: '2024-01-01', rate: 0.04 },
+ { from: '2024-07-01', rate: 0.0425 },
+ { from: '2025-01-01', rate: 0.0375 },
+ { from: '2025-07-01', rate: 0.0325 },
+ { from: '2026-01-01', rate: 0.025 },
+] as const
+
+const LATE_PAYMENT_PREMIUM = 0.08 // 8 procentenheter per Räntelagen §6
+
+/**
+ * Look up the Riksbanken referensränta that was effective on a given date.
+ * Returns the rate as a decimal fraction (e.g. 0.025 = 2.5%).
+ *
+ * If the requested date is before the earliest entry in the table we fall
+ * back to the earliest entry (this is a defensive measure — should never
+ * happen in practice since gnubok was launched after 2022).
+ */
+export function getReferensrantaAt(date: string): number {
+ // Walk newest-first so the first match wins.
+ for (let i = REFERENSRANTA_HISTORY.length - 1; i >= 0; i--) {
+ const entry = REFERENSRANTA_HISTORY[i]
+ if (date >= entry.from) {
+ return entry.rate
+ }
+ }
+ return REFERENSRANTA_HISTORY[0].rate
+}
+
+/**
+ * Compute the statutory annual late-payment interest rate for a given
+ * "from date" (typically the invoice due date). If an override is
+ * supplied, it is returned as-is.
+ */
+export function getAnnualInterestRate(fromDate: string, overrideRate?: number | null): number {
+ if (overrideRate !== undefined && overrideRate !== null) {
+ return overrideRate
+ }
+ return getReferensrantaAt(fromDate) + LATE_PAYMENT_PREMIUM
+}
+
+export interface LatePaymentInterestInput {
+ /** Outstanding overdue amount (the invoice total or remaining balance). */
+ overdueAmount: number
+ /** Invoice due date (YYYY-MM-DD). Interest starts the day AFTER due date. */
+ dueDate: string
+ /** Reference date for the calculation (YYYY-MM-DD). Defaults to today. */
+ asOfDate: string
+ /**
+ * Optional annual rate override (e.g. 0.115 for 11.5%). When supplied
+ * we use this verbatim instead of looking up Räntelagen §6.
+ */
+ overrideRate?: number | null
+}
+
+export interface LatePaymentInterestResult {
+ /** Annual rate actually applied (decimal fraction, e.g. 0.115 = 11.5%). */
+ rate: number
+ /** Computed interest amount in SEK, rounded to 2 decimals. */
+ amount: number
+ /** Start date used for the interest calc (= dueDate). */
+ fromDate: string
+ /** Number of overdue days (positive integer, 0 if not overdue). */
+ days: number
+}
+
+/**
+ * Compute statutory late-payment interest (dröjsmålsränta).
+ *
+ * Returns the rate that was applied, the rounded amount, the from-date
+ * used (= dueDate), and the number of overdue days. If the invoice is
+ * not yet overdue the amount and days are both 0.
+ *
+ * Throws if `overdueAmount` is negative (callers should clamp to 0 if
+ * they want to silently no-op, but this is almost always a bug).
+ */
+export function calculateLatePaymentInterest(
+ input: LatePaymentInterestInput,
+): LatePaymentInterestResult {
+ const { overdueAmount, dueDate, asOfDate, overrideRate } = input
+
+ if (overdueAmount < 0) {
+ throw new Error('overdueAmount must be non-negative')
+ }
+
+ const days = daysBetween(dueDate, asOfDate)
+ const rate = getAnnualInterestRate(dueDate, overrideRate)
+
+ if (days <= 0 || overdueAmount === 0) {
+ return { rate, amount: 0, fromDate: dueDate, days: 0 }
+ }
+
+ const raw = overdueAmount * rate * (days / 365)
+ const amount = Math.round(raw * 100) / 100
+
+ return { rate, amount, fromDate: dueDate, days }
+}
+
+/**
+ * Compute whole-day difference between two YYYY-MM-DD dates. Positive if
+ * `to` is after `from`. Returns 0 if `to <= from`.
+ *
+ * We use UTC midnight to avoid DST artifacts (Sweden observes DST). The
+ * inputs are date-only strings so timezone doesn't affect the result as
+ * long as we anchor both at UTC.
+ */
+function daysBetween(from: string, to: string): number {
+ const fromMs = Date.parse(`${from}T00:00:00Z`)
+ const toMs = Date.parse(`${to}T00:00:00Z`)
+ if (Number.isNaN(fromMs) || Number.isNaN(toMs)) {
+ return 0
+ }
+ const diff = Math.floor((toMs - fromMs) / 86_400_000)
+ return diff > 0 ? diff : 0
+}
diff --git a/lib/invoices/pdf-render-helpers.ts b/lib/invoices/pdf-render-helpers.ts
new file mode 100644
index 00000000..b5f55512
--- /dev/null
+++ b/lib/invoices/pdf-render-helpers.ts
@@ -0,0 +1,17 @@
+/**
+ * Shared helpers for invoice PDF render call sites.
+ *
+ * Wraps `brandingFromCompanySettings` so every PDF-rendering route gets a
+ * consistent branding object.
+ */
+
+import type { CompanySettings } from '@/types'
+import { brandingFromCompanySettings, type InvoiceBranding } from '@/lib/invoices/pdf-template'
+
+export interface InvoicePdfRenderExtras {
+ branding: InvoiceBranding
+}
+
+export function prepareInvoicePdfRender(company: CompanySettings): InvoicePdfRenderExtras {
+ return { branding: brandingFromCompanySettings(company) }
+}
diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx
index 0621bcd9..81977e2e 100644
--- a/lib/invoices/pdf-template.tsx
+++ b/lib/invoices/pdf-template.tsx
@@ -56,6 +56,14 @@ const LABELS = {
net: (rate: number) => `Netto ${rate}%:`,
vatRow: (rate: number) => `Moms ${rate}%:`,
rounding: 'Öresavrundning:',
+ deductionRow: 'Skattereduktion ROT/RUT:',
+ deductionInfoHeading: 'Underlag för skattereduktion',
+ deductionPersonnummer: 'Personnummer:',
+ deductionHousingDesignation: 'Fastighetsbeteckning:',
+ deductionApartmentNumber: 'Lägenhetsnummer:',
+ deductionWorkType: 'Arbete:',
+ deductionLaborHours: 'Arbetstimmar:',
+ deductionNotice: 'Köparen ansöker om utbetalning hos Skatteverket via fakturamodellen. Säljaren begär utbetalning för den del köparen inte betalat.',
toCredit: 'Att kreditera:',
toPay: 'Att betala:',
vatInSek: (rate: number | string) => `Moms i SEK (kurs ${rate}):`,
@@ -112,6 +120,14 @@ const LABELS = {
net: (rate: number) => `Net ${rate}%:`,
vatRow: (rate: number) => `VAT ${rate}%:`,
rounding: 'Rounding:',
+ deductionRow: 'ROT/RUT tax reduction:',
+ deductionInfoHeading: 'Tax reduction details',
+ deductionPersonnummer: 'Personnummer:',
+ deductionHousingDesignation: 'Property designation:',
+ deductionApartmentNumber: 'Apartment number:',
+ deductionWorkType: 'Service type:',
+ deductionLaborHours: 'Labor hours:',
+ deductionNotice: 'The customer claims the deduction via fakturamodellen at Skatteverket. The seller requests payment from the agency for the portion not paid by the customer.',
toCredit: 'To credit:',
toPay: 'Total due:',
vatInSek: (rate: number | string) => `VAT in SEK (rate ${rate}):`,
@@ -137,273 +153,419 @@ const LABELS = {
},
} as const
-// Create styles
-const styles = StyleSheet.create({
- page: {
- padding: 40,
- fontSize: 10,
- fontFamily: 'Helvetica',
- },
- header: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- marginBottom: 30,
- },
- title: {
- fontSize: 24,
- fontWeight: 'bold',
- color: '#1a1a1a',
- },
- companyInfo: {
- textAlign: 'left',
- },
- companyName: {
- fontSize: 14,
- fontWeight: 'bold',
- marginBottom: 4,
- },
- section: {
- marginBottom: 20,
- },
- sectionTitle: {
- fontSize: 11,
- fontWeight: 'bold',
- marginBottom: 8,
- color: '#666',
- textTransform: 'uppercase',
- letterSpacing: 0.5,
- },
- row: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- marginBottom: 4,
- },
- label: {
- color: '#666',
- },
- value: {
- fontWeight: 'bold',
- },
- customerBox: {
- backgroundColor: '#f5f5f5',
- padding: 15,
- borderRadius: 4,
- marginBottom: 20,
- },
- customerName: {
- fontSize: 12,
- fontWeight: 'bold',
- marginBottom: 4,
- },
- table: {
- marginTop: 10,
- },
- tableHeader: {
- flexDirection: 'row',
- borderBottomWidth: 1,
- borderBottomColor: '#ddd',
- paddingBottom: 8,
- marginBottom: 8,
- },
- tableRow: {
- flexDirection: 'row',
- paddingVertical: 6,
- borderBottomWidth: 1,
- borderBottomColor: '#eee',
- },
- colDescription: {
- flex: 3.5,
- },
- colQty: {
- flex: 1,
- textAlign: 'right',
- },
- colUnit: {
- flex: 1,
- textAlign: 'center',
- },
- colPrice: {
- flex: 1.5,
- textAlign: 'right',
- },
- colVat: {
- flex: 1,
- textAlign: 'right',
- },
- colTotal: {
- flex: 1.5,
- textAlign: 'right',
- },
- tableHeaderText: {
- fontWeight: 'bold',
- color: '#666',
- fontSize: 9,
- textTransform: 'uppercase',
- },
- totalsSection: {
- marginTop: 20,
- paddingTop: 15,
- borderTopWidth: 2,
- borderTopColor: '#ddd',
- },
- totalRow: {
- flexDirection: 'row',
- justifyContent: 'flex-end',
- marginBottom: 4,
- },
- totalLabel: {
- width: 120,
- textAlign: 'right',
- paddingRight: 15,
- color: '#666',
- },
- totalValue: {
- width: 100,
- textAlign: 'right',
- },
- grandTotal: {
- flexDirection: 'row',
- justifyContent: 'flex-end',
- marginTop: 10,
- paddingTop: 10,
- borderTopWidth: 1,
- borderTopColor: '#333',
- },
- grandTotalLabel: {
- width: 120,
- textAlign: 'right',
- paddingRight: 15,
- fontSize: 14,
- fontWeight: 'bold',
- },
- grandTotalValue: {
- width: 100,
- textAlign: 'right',
- fontSize: 14,
- fontWeight: 'bold',
- },
- paymentSection: {
- marginTop: 30,
- padding: 15,
- backgroundColor: '#f8f9fa',
- borderRadius: 4,
- },
- paymentTitle: {
- fontSize: 11,
- fontWeight: 'bold',
- marginBottom: 10,
- color: '#333',
- },
- paymentRow: {
- flexDirection: 'row',
- marginBottom: 4,
- },
- paymentLabel: {
- width: 100,
- color: '#666',
- },
- paymentValue: {
- flex: 1,
- },
- reverseChargeBox: {
- marginTop: 20,
- padding: 12,
- backgroundColor: '#fff3cd',
- borderRadius: 4,
- borderWidth: 1,
- borderColor: '#ffc107',
- },
- reverseChargeText: {
- fontSize: 9,
- color: '#856404',
- },
- notesBox: {
- marginTop: 20,
- padding: 12,
- backgroundColor: '#e8f4fd',
- borderRadius: 4,
- },
- notesText: {
- fontSize: 9,
- color: '#0c5460',
- },
- creditNoteBox: {
- marginBottom: 20,
- padding: 12,
- backgroundColor: '#f8d7da',
- borderRadius: 4,
- borderWidth: 1,
- borderColor: '#f5c6cb',
- },
- creditNoteText: {
- fontSize: 10,
- color: '#721c24',
- },
- creditNoteTitle: {
- color: '#721c24',
- },
- draftBanner: {
- marginBottom: 16,
- padding: 10,
- backgroundColor: '#fff3cd',
- borderWidth: 2,
- borderColor: '#856404',
- borderRadius: 4,
- },
- draftBannerTitle: {
- fontSize: 14,
- fontWeight: 'bold',
- color: '#856404',
- textAlign: 'center',
- marginBottom: 2,
- },
- draftBannerText: {
- fontSize: 9,
- color: '#856404',
- textAlign: 'center',
- },
- cancelledBanner: {
- marginBottom: 16,
- padding: 10,
- backgroundColor: '#f8d7da',
- borderWidth: 2,
- borderColor: '#721c24',
- borderRadius: 4,
- },
- cancelledBannerTitle: {
- fontSize: 14,
- fontWeight: 'bold',
- color: '#721c24',
- textAlign: 'center',
- marginBottom: 2,
- },
- cancelledBannerText: {
- fontSize: 9,
- color: '#721c24',
- textAlign: 'center',
- },
- footer: {
- position: 'absolute',
- bottom: 30,
- left: 40,
- right: 40,
- borderTopWidth: 1,
- borderTopColor: '#ddd',
- paddingTop: 10,
- },
- footerText: {
- fontSize: 8,
- color: '#999',
- textAlign: 'center',
- },
- twoColumn: {
- flexDirection: 'row',
- justifyContent: 'space-between',
- },
- column: {
- width: '48%',
- },
-})
+// Labor-only disclaimer for the ROT/RUT block. Kept Swedish-only in both
+// locales — references Skatteverket's fakturamodell directly, which is a
+// statutory Swedish concept and has no formal English equivalent.
+const DEDUCTION_LABOR_ONLY_NOTICE =
+ 'Endast arbetskostnad har inkluderats i underlaget för ROT/RUT-avdrag enligt Skatteverkets fakturamodell.'
+
+// Resolved branding values used by the stylesheet. Keeping the resolved shape
+// distinct from the prop shape lets us validate the font allowlist in one
+// place (createStyles below) and gives the rest of the component a fully
+// non-null object to work with.
+export interface InvoiceBranding {
+ /** Primary color — used for the document title and other strong text.
+ * Default '#1a1a1a' (the existing hardcoded value). */
+ primaryColor?: string
+ /** Accent color — used for muted labels and section headings.
+ * Default '#666666' (the existing hardcoded value). */
+ accentColor?: string
+ /** Font family — must be one of react-pdf's built-in PostScript fonts.
+ * Default 'Helvetica'. */
+ fontFamily?: string
+ /** Optional banner text rendered above the document title. */
+ headerText?: string | null
+ /** Optional footer text rendered above the statutory company footer line. */
+ footerText?: string | null
+}
+
+interface ResolvedBranding {
+ primaryColor: string
+ accentColor: string
+ fontFamily: string
+}
+
+// react-pdf only ships these three PostScript fonts. Anything else would
+// require registerFont() with a binary file — out of scope for AGPL-clean
+// branding and a fingerprinting risk besides.
+const ALLOWED_FONTS = new Set(['Helvetica', 'Times-Roman', 'Courier'])
+
+/**
+ * Extract the InvoicePDF branding shape from a CompanySettings row. Tolerates
+ * legacy rows where the branding columns are still null/undefined — returns
+ * undefined fields that resolveBranding() then maps to the legacy defaults.
+ *
+ * Use this at every InvoicePDF call site that has access to a CompanySettings
+ * — keeping the extraction logic in one place means a future schema rename or
+ * new branding field only needs to land here.
+ */
+export function brandingFromCompanySettings(
+ company: CompanySettings | (Partial & Record),
+): InvoiceBranding {
+ return {
+ primaryColor: (company as CompanySettings).invoice_primary_color ?? undefined,
+ accentColor: (company as CompanySettings).invoice_accent_color ?? undefined,
+ fontFamily: (company as CompanySettings).invoice_font_family ?? undefined,
+ headerText: (company as CompanySettings).invoice_header_text ?? null,
+ footerText: (company as CompanySettings).invoice_footer_text ?? null,
+ }
+}
+
+const DEFAULT_BRANDING: ResolvedBranding = {
+ primaryColor: '#1a1a1a',
+ accentColor: '#666666',
+ fontFamily: 'Helvetica',
+}
+
+function resolveBranding(branding: InvoiceBranding | undefined): ResolvedBranding {
+ if (!branding) return DEFAULT_BRANDING
+ const fontFamily =
+ branding.fontFamily && ALLOWED_FONTS.has(branding.fontFamily)
+ ? branding.fontFamily
+ : DEFAULT_BRANDING.fontFamily
+ return {
+ primaryColor: branding.primaryColor || DEFAULT_BRANDING.primaryColor,
+ accentColor: branding.accentColor || DEFAULT_BRANDING.accentColor,
+ fontFamily,
+ }
+}
+
+// Create styles. Calling without args yields the original (pre-branding)
+// stylesheet — required so the default code path is byte-equivalent to the
+// previous hardcoded version.
+function createStyles(branding?: InvoiceBranding) {
+ const b = resolveBranding(branding)
+ return StyleSheet.create({
+ page: {
+ padding: 40,
+ fontSize: 10,
+ fontFamily: b.fontFamily,
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: 30,
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ color: b.primaryColor,
+ },
+ companyInfo: {
+ textAlign: 'left',
+ },
+ companyName: {
+ fontSize: 14,
+ fontWeight: 'bold',
+ marginBottom: 4,
+ },
+ section: {
+ marginBottom: 20,
+ },
+ sectionTitle: {
+ fontSize: 11,
+ fontWeight: 'bold',
+ marginBottom: 8,
+ color: b.accentColor,
+ textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ },
+ row: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginBottom: 4,
+ },
+ label: {
+ color: b.accentColor,
+ },
+ value: {
+ fontWeight: 'bold',
+ },
+ customerBox: {
+ backgroundColor: '#f5f5f5',
+ padding: 15,
+ borderRadius: 4,
+ marginBottom: 20,
+ },
+ customerName: {
+ fontSize: 12,
+ fontWeight: 'bold',
+ marginBottom: 4,
+ },
+ table: {
+ marginTop: 10,
+ },
+ tableHeader: {
+ flexDirection: 'row',
+ borderBottomWidth: 1,
+ borderBottomColor: '#ddd',
+ paddingBottom: 8,
+ marginBottom: 8,
+ },
+ tableRow: {
+ flexDirection: 'row',
+ paddingVertical: 6,
+ borderBottomWidth: 1,
+ borderBottomColor: '#eee',
+ },
+ colDescription: {
+ flex: 3.5,
+ },
+ colQty: {
+ flex: 1,
+ textAlign: 'right',
+ },
+ colUnit: {
+ flex: 1,
+ textAlign: 'center',
+ },
+ colPrice: {
+ flex: 1.5,
+ textAlign: 'right',
+ },
+ colVat: {
+ flex: 1,
+ textAlign: 'right',
+ },
+ colTotal: {
+ flex: 1.5,
+ textAlign: 'right',
+ },
+ tableHeaderText: {
+ fontWeight: 'bold',
+ color: b.accentColor,
+ fontSize: 9,
+ textTransform: 'uppercase',
+ },
+ totalsSection: {
+ marginTop: 20,
+ paddingTop: 15,
+ borderTopWidth: 2,
+ borderTopColor: '#ddd',
+ },
+ totalRow: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ marginBottom: 4,
+ },
+ totalLabel: {
+ width: 120,
+ textAlign: 'right',
+ paddingRight: 15,
+ color: b.accentColor,
+ },
+ totalValue: {
+ width: 100,
+ textAlign: 'right',
+ },
+ grandTotal: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ marginTop: 10,
+ paddingTop: 10,
+ borderTopWidth: 1,
+ borderTopColor: '#333',
+ },
+ grandTotalLabel: {
+ width: 120,
+ textAlign: 'right',
+ paddingRight: 15,
+ fontSize: 14,
+ fontWeight: 'bold',
+ },
+ grandTotalValue: {
+ width: 100,
+ textAlign: 'right',
+ fontSize: 14,
+ fontWeight: 'bold',
+ },
+ paymentSection: {
+ marginTop: 30,
+ padding: 15,
+ backgroundColor: '#f8f9fa',
+ borderRadius: 4,
+ },
+ paymentTitle: {
+ fontSize: 11,
+ fontWeight: 'bold',
+ marginBottom: 10,
+ color: '#333',
+ },
+ paymentRow: {
+ flexDirection: 'row',
+ marginBottom: 4,
+ },
+ paymentLabel: {
+ width: 100,
+ color: b.accentColor,
+ },
+ paymentValue: {
+ flex: 1,
+ },
+ reverseChargeBox: {
+ marginTop: 20,
+ padding: 12,
+ backgroundColor: '#fff3cd',
+ borderRadius: 4,
+ borderWidth: 1,
+ borderColor: '#ffc107',
+ },
+ reverseChargeText: {
+ fontSize: 9,
+ color: '#856404',
+ },
+ notesBox: {
+ marginTop: 20,
+ padding: 12,
+ backgroundColor: '#e8f4fd',
+ borderRadius: 4,
+ },
+ notesText: {
+ fontSize: 9,
+ color: '#0c5460',
+ },
+ creditNoteBox: {
+ marginBottom: 20,
+ padding: 12,
+ backgroundColor: '#f8d7da',
+ borderRadius: 4,
+ borderWidth: 1,
+ borderColor: '#f5c6cb',
+ },
+ creditNoteText: {
+ fontSize: 10,
+ color: '#721c24',
+ },
+ creditNoteTitle: {
+ color: '#721c24',
+ },
+ draftBanner: {
+ marginBottom: 16,
+ padding: 10,
+ backgroundColor: '#fff3cd',
+ borderWidth: 2,
+ borderColor: '#856404',
+ borderRadius: 4,
+ },
+ draftBannerTitle: {
+ fontSize: 14,
+ fontWeight: 'bold',
+ color: '#856404',
+ textAlign: 'center',
+ marginBottom: 2,
+ },
+ draftBannerText: {
+ fontSize: 9,
+ color: '#856404',
+ textAlign: 'center',
+ },
+ cancelledBanner: {
+ marginBottom: 16,
+ padding: 10,
+ backgroundColor: '#f8d7da',
+ borderWidth: 2,
+ borderColor: '#721c24',
+ borderRadius: 4,
+ },
+ cancelledBannerTitle: {
+ fontSize: 14,
+ fontWeight: 'bold',
+ color: '#721c24',
+ textAlign: 'center',
+ marginBottom: 2,
+ },
+ cancelledBannerText: {
+ fontSize: 9,
+ color: '#721c24',
+ textAlign: 'center',
+ },
+ footer: {
+ position: 'absolute',
+ bottom: 30,
+ left: 40,
+ right: 40,
+ borderTopWidth: 1,
+ borderTopColor: '#ddd',
+ paddingTop: 10,
+ },
+ footerText: {
+ fontSize: 8,
+ color: '#999',
+ textAlign: 'center',
+ },
+ twoColumn: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ column: {
+ width: '48%',
+ },
+ // New: optional branding banner above the document title.
+ brandingHeader: {
+ marginBottom: 12,
+ paddingBottom: 8,
+ borderBottomWidth: 1,
+ borderBottomColor: '#eee',
+ },
+ brandingHeaderText: {
+ fontSize: 9,
+ color: b.accentColor,
+ textAlign: 'left',
+ },
+ // ROT/RUT-avdrag info box (Skattereduktion ROT/RUT). Surfaces the
+ // customer's personnummer last 4, fastighetsbeteckning, work type per
+ // row and the statutory notice about fakturamodellen.
+ deductionBox: {
+ marginTop: 18,
+ padding: 12,
+ backgroundColor: '#f5f5f5',
+ borderRadius: 4,
+ borderWidth: 1,
+ borderColor: '#ddd',
+ },
+ deductionTitle: {
+ fontSize: 10,
+ fontWeight: 'bold',
+ marginBottom: 6,
+ color: b.primaryColor,
+ textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ },
+ deductionRow: {
+ flexDirection: 'row',
+ marginBottom: 3,
+ },
+ deductionLabel: {
+ width: 130,
+ fontSize: 9,
+ color: b.accentColor,
+ },
+ deductionValue: {
+ fontSize: 9,
+ flex: 1,
+ },
+ deductionLineItem: {
+ fontSize: 9,
+ marginTop: 4,
+ paddingLeft: 8,
+ color: '#444',
+ },
+ deductionNotice: {
+ fontSize: 8,
+ marginTop: 8,
+ color: b.accentColor,
+ fontStyle: 'italic',
+ },
+ // New: optional branding footnote rendered above the statutory company
+ // line in the footer block.
+ brandingFooterText: {
+ fontSize: 8,
+ color: b.accentColor,
+ textAlign: 'center',
+ marginBottom: 4,
+ },
+ })
+}
// Format currency with explicit ISO code so non-Swedish recipients see "1 234,56 SEK"
// instead of the Swedish symbol "kr". Decimal style + appended code works for any
@@ -451,11 +613,22 @@ interface InvoicePDFProps {
originalInvoiceNumber?: string
isPreview?: boolean
language?: PdfLang
+ /**
+ * Per-company branding overrides. Omit to render with the original default
+ * stylesheet — the rendered output is byte-equivalent to the pre-branding
+ * version of this template, which makes the rollout safe for the snapshot
+ * suite and for callers that haven't yet been migrated to forward branding.
+ */
+ branding?: InvoiceBranding
}
-export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview, language }: InvoicePDFProps) {
+export function InvoicePDF({ invoice, customer, items, company, originalInvoiceNumber, isPreview, language, branding }: InvoicePDFProps) {
const lang: PdfLang = language ?? customer.language ?? 'sv'
const L = LABELS[lang]
+ // Build the stylesheet per-render so each invoice picks up its company's
+ // current branding. createStyles() with no argument returns the original
+ // hardcoded stylesheet — the default code path is unchanged.
+ const styles = createStyles(branding)
const isCreditNote = !!invoice.credited_invoice_id
// Check if items have mixed VAT rates
@@ -480,9 +653,23 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
const isDeliveryNote = docType === 'delivery_note'
const isProforma = docType === 'proforma'
+ // Optional branding banner text. Rendered only when the company has set
+ // invoice_header_text — invisible chrome by default, so the byte-equivalence
+ // promise for un-branded callers holds.
+ const headerText = branding?.headerText ?? null
+ const footerText = branding?.footerText ?? null
+
return (
+ {/* Optional branded header — rendered above the status banners so it
+ sits at the very top of the page. Non-statutory free-form text. */}
+ {headerText && (
+
+ {headerText}
+
+ )}
+
{/* Status banner — cancelled takes precedence over draft so a cancelled
row that lacks a number (legacy un-numbered draft that was later
cancelled) still surfaces as MAKULERAD rather than UTKAST. The draft
@@ -674,6 +861,13 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
)}
{(() => {
const rounding = getDisplayTotal(invoice, company)
+ // ROT/RUT-avdrag reduces "Att betala" — the customer only owes
+ // (total - deduction); the rest is reclaimed from Skatteverket
+ // via fakturamodellen. The rule does not apply to credit notes.
+ const showDeduction = !isCreditNote && (invoice.deduction_total ?? 0) > 0
+ const grandTotal = showDeduction
+ ? Math.round((rounding.displayed - (invoice.deduction_total ?? 0)) * 100) / 100
+ : rounding.displayed
return (
<>
{rounding.applies && (
@@ -682,9 +876,17 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{formatCurrency(rounding.roundingDelta, 'SEK', lang)}
)}
+ {showDeduction && (
+
+ {L.deductionRow}
+
+ −{formatCurrency(invoice.deduction_total ?? 0, invoice.currency, lang)}
+
+
+ )}
{isCreditNote ? L.toCredit : L.toPay}
- {formatCurrency(rounding.displayed, invoice.currency, lang)}
+ {formatCurrency(grandTotal, invoice.currency, lang)}
>
)
@@ -706,6 +908,63 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
)}
+ {/* ROT/RUT-avdrag underlying details. Surfaces personnummer last 4,
+ fastighetsbeteckning, lägenhetsnummer, the per-line breakdown
+ and the statutory notice about fakturamodellen. Suppressed on
+ delivery notes (no payment info at all). */}
+ {!isDeliveryNote && !isCreditNote && (invoice.deduction_total ?? 0) > 0 && (
+
+ {L.deductionInfoHeading}
+ {invoice.deduction_personnummer_last4 && (
+
+ {L.deductionPersonnummer}
+ XXXXXXXX-{invoice.deduction_personnummer_last4}
+
+ )}
+ {(() => {
+ // Show the first item-level housing_designation if any line
+ // has one (typical case for a single property). Falls back to
+ // null when only RUT lines exist (RUT doesn't require it).
+ const housing = items.find((i) => i.housing_designation)?.housing_designation
+ const apartment = items.find((i) => i.apartment_number)?.apartment_number
+ return (
+ <>
+ {housing && (
+
+ {L.deductionHousingDesignation}
+ {housing}
+
+ )}
+ {apartment && (
+
+ {L.deductionApartmentNumber}
+ {apartment}
+
+ )}
+ >
+ )
+ })()}
+ {/* Labor-only disclaimer (Skatteverket fakturamodellen). Per ML
+ 17 kap, only the labor portion qualifies — material must be
+ invoiced separately. */}
+ {DEDUCTION_LABOR_ONLY_NOTICE}
+ {/* Per-line breakdown — one row per eligible item with kind,
+ work type if present and the deducted amount. */}
+ {items
+ .filter((i) => i.deduction_type)
+ .map((i, idx) => {
+ const kind = i.deduction_type === 'rot' ? 'ROT' : 'RUT'
+ const work = i.work_type ? ` — ${i.work_type}` : ''
+ return (
+
+ {`${kind}${work}: ${i.description} — ${formatCurrency(i.deduction_amount ?? 0, invoice.currency, lang)}`}
+
+ )
+ })}
+ {L.deductionNotice}
+
+ )}
+
{/* Proforma notice */}
{isProforma && (
@@ -745,7 +1004,7 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{company.plusgiro}
)}
- {company.swish && (company.invoice_show_swish ?? true) && (
+ {company.swish && (company.invoice_show_swish ?? false) && (
{L.swish}
{company.swish}
@@ -813,8 +1072,14 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
)}
- {/* Footer — collected legal info per ML 17 kap 24§ */}
+ {/* Footer — collected legal info per ML 17 kap 24§. Optional branded
+ footnote sits above the statutory line so it can never crowd out
+ the compliance text (which is why the user-supplied string lives
+ in its own Text node, not inside the join). */}
+ {footerText && (
+ {footerText}
+ )}
{[
(company.invoice_show_company_name ?? true) &&
diff --git a/lib/invoices/recurring-schedule-service.ts b/lib/invoices/recurring-schedule-service.ts
index eb0b9028..82cc9f1e 100644
--- a/lib/invoices/recurring-schedule-service.ts
+++ b/lib/invoices/recurring-schedule-service.ts
@@ -19,6 +19,7 @@ import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { createInvoiceJournalEntry } from '@/lib/bookkeeping/invoice-entries'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
+import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { getEmailService } from '@/lib/email/service'
import {
generateInvoiceEmailHtml,
@@ -377,12 +378,15 @@ async function sendInvoiceFromSchedule(
// Render PDF with status overridden to 'sent' so the customer doesn't
// receive a "UTKAST" stamp.
+ const renderableInvoice = { ...invoice, status: 'sent' as const }
+ const { branding } = prepareInvoicePdfRender(company)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
- invoice: { ...invoice, status: 'sent' as const },
+ invoice: renderableInvoice,
customer: invoice.customer,
items,
company,
+ branding,
}),
)
diff --git a/lib/invoices/reminder-processor.ts b/lib/invoices/reminder-processor.ts
index 15d4f304..bd9b8a27 100644
--- a/lib/invoices/reminder-processor.ts
+++ b/lib/invoices/reminder-processor.ts
@@ -6,6 +6,8 @@ import {
generateReminderEmailSubject,
getReminderDaysConfig
} from '@/lib/email/reminder-templates'
+import { calculateLatePaymentInterest } from '@/lib/invoices/late-payment-interest'
+import { createReminderFeeEntry } from '@/lib/bookkeeping/reminder-fee-entries'
import { createLogger } from '@/lib/logger'
import type { Invoice, Customer, CompanySettings } from '@/types'
@@ -80,6 +82,19 @@ export function calculateDaysOverdue(dueDate: string): number {
return diffDays
}
+/**
+ * Surcharges computed before sending the reminder. These are passed to the
+ * email template and persisted on the invoice_reminders row for audit.
+ */
+export interface ReminderSurcharges {
+ interestAmount: number
+ interestRate: number
+ interestFromDate: string
+ interestDays: number
+ reminderFee: number
+ totalDue: number
+}
+
/**
* Send a single reminder email
*/
@@ -87,7 +102,8 @@ export async function sendReminder(
invoice: Invoice & { customer: Customer },
company: CompanySettings,
reminderLevel: 1 | 2 | 3,
- actionToken: string
+ actionToken: string,
+ surcharges: ReminderSurcharges,
): Promise<{ success: boolean; error?: string }> {
const customer = invoice.customer
@@ -107,7 +123,8 @@ export async function sendReminder(
company,
reminderLevel,
daysOverdue,
- actionUrl
+ actionUrl,
+ ...surcharges,
}
const result = await getEmailService().sendEmail({
@@ -240,7 +257,53 @@ export async function processOverdueReminders(): Promise
continue
}
- // Create reminder record first (to get action token)
+ // Compute statutory late-payment interest (Räntelagen §6) using the
+ // company override if set, else Riksbankens referensränta + 8 pp.
+ const asOfDate = new Date().toISOString().split('T')[0]
+ const interest = calculateLatePaymentInterest({
+ overdueAmount: invoice.total,
+ dueDate: invoice.due_date,
+ asOfDate,
+ overrideRate: company.reminder_interest_rate_override,
+ })
+
+ // Determine the lagstadgad påminnelseavgift (Lag 1981:739, max 60 kr).
+ // Clamp at 60 kr — the statute caps the fee even if company_settings
+ // somehow holds a higher value (defense in depth against a stale DB row).
+ const reminderFee = company.reminder_fee_enabled
+ ? Math.min(60, Math.round((company.reminder_fee_amount ?? 60) * 100) / 100)
+ : 0
+
+ // Book the fee as a journal entry. Booked BEFORE creating the
+ // invoice_reminders row so we can persist fee_journal_entry_id.
+ // Failure to book the fee is logged but does not abort the reminder
+ // send — the customer still needs to receive the notification.
+ let feeJournalEntryId: string | null = null
+ if (reminderFee > 0) {
+ try {
+ const feeResult = await createReminderFeeEntry(supabase, {
+ invoiceId: invoice.id,
+ invoiceNumber: invoice.invoice_number,
+ companyId: invoice.company_id,
+ userId: invoice.user_id,
+ feeAmount: reminderFee,
+ asOfDate,
+ })
+ feeJournalEntryId = feeResult?.journal_entry_id ?? null
+ } catch (feeError) {
+ log.error(
+ `Failed to book reminder fee for invoice ${invoice.invoice_number}:`,
+ feeError as Error,
+ )
+ // Continue — surcharge still appears in the email, but no JE is linked.
+ }
+ }
+
+ const totalDue =
+ Math.round((invoice.total + interest.amount + reminderFee) * 100) / 100
+
+ // Create reminder record first (to get action token), persisting the
+ // computed surcharges so the public action page + audit trail show them.
const { data: reminderRecord, error: reminderError } = await supabase
.from('invoice_reminders')
.insert({
@@ -248,7 +311,13 @@ export async function processOverdueReminders(): Promise
user_id: invoice.user_id,
company_id: invoice.company_id,
reminder_level: reminderLevel,
- email_to: customer.email
+ email_to: customer.email,
+ interest_amount: interest.amount,
+ interest_rate: interest.rate,
+ interest_from_date: interest.fromDate,
+ interest_days: interest.days,
+ reminder_fee: reminderFee,
+ fee_journal_entry_id: feeJournalEntryId,
})
.select('action_token')
.single()
@@ -271,7 +340,15 @@ export async function processOverdueReminders(): Promise
invoice as Invoice & { customer: Customer },
company as CompanySettings,
reminderLevel,
- reminderRecord.action_token
+ reminderRecord.action_token,
+ {
+ interestAmount: interest.amount,
+ interestRate: interest.rate,
+ interestFromDate: interest.fromDate,
+ interestDays: interest.days,
+ reminderFee,
+ totalDue,
+ },
)
if (sendResult.success) {
diff --git a/lib/invoices/rot-rut-rules.ts b/lib/invoices/rot-rut-rules.ts
new file mode 100644
index 00000000..c5b7ec4e
--- /dev/null
+++ b/lib/invoices/rot-rut-rules.ts
@@ -0,0 +1,189 @@
+/**
+ * ROT/RUT-avdrag rules.
+ *
+ * Implements the calculation and validation logic for Sweden's tax deduction
+ * for household services (RUT) and home renovation (ROT). As of 2026:
+ * - ROT: 30% of labor cost, max 50 000 kr per person per year.
+ * - RUT: 50% of labor cost, max 75 000 kr per person per year.
+ *
+ * The deduction applies to labor only — material costs and travel time are
+ * NOT eligible. In this v1 we treat the entire invoice item amount as labor
+ * when the user flags it ROT/RUT; the user is expected to either invoice
+ * labor on its own row or split materials onto a non-flagged row. A future
+ * iteration can add per-line "labor portion" handling if needed.
+ *
+ * We CAN'T verify that the customer has remaining yearly headroom (they may
+ * have claimed elsewhere). We surface a warning when the per-invoice total
+ * already exceeds the statutory max — the customer must then handle the
+ * excess outside of fakturamodellen.
+ *
+ * All functions are pure and deterministic. No I/O, no DB calls — easy to
+ * unit-test and easy to embed in the API validator and the live total
+ * preview in the invoice editor.
+ */
+
+/** Percentage of eligible amount deducted for ROT (renovation). 2026 rule. */
+export const ROT_PERCENT = 0.30
+
+/** Percentage of eligible amount deducted for RUT (household services). 2026 rule. */
+export const RUT_PERCENT = 0.50
+
+/** Maximum yearly ROT deduction per person, in kr. 2026 rule. */
+export const ROT_MAX = 50000
+
+/** Maximum yearly RUT deduction per person, in kr. 2026 rule. */
+export const RUT_MAX = 75000
+
+export type DeductionType = 'rot' | 'rut'
+
+/** Skatteverket work codes used by Husavdragstjänsten. Maps a free-text */
+/** "what the worker did" label to the official code the Skatteverket file */
+/** will need. v1 stores both — the label is shown on the PDF; the code is */
+/** stored as `work_type` for the future submission file. */
+export const ROT_WORK_TYPES = [
+ { code: 'BYGG', label: 'Byggnadsarbete' },
+ { code: 'EL', label: 'Elarbete' },
+ { code: 'GLAS_PLAT', label: 'Glas- och plåtarbete' },
+ { code: 'MARK_DRAN', label: 'Mark- och dräneringsarbete' },
+ { code: 'MURNING', label: 'Murnings- och putsarbete' },
+ { code: 'MALNING', label: 'Mål- och tapetseringsarbete' },
+ { code: 'VVS', label: 'VVS-arbete' },
+ { code: 'IT', label: 'IT-tjänster i hemmet' },
+] as const
+
+export const RUT_WORK_TYPES = [
+ { code: 'STAD', label: 'Städning, tvätt och vård av kläder' },
+ { code: 'KLAD', label: 'Klädvård i hemmet' },
+ { code: 'TRADGARD', label: 'Trädgårdsarbete' },
+ { code: 'BARNPASS', label: 'Barnpassning' },
+ { code: 'PERSONLIG_OMS', label: 'Personlig omsorg' },
+ { code: 'FLYTT', label: 'Flytthjälp' },
+ { code: 'REPARATION', label: 'Reparation av vitvaror' },
+ { code: 'IT', label: 'IT-tjänster i hemmet' },
+ { code: 'MOBLERING', label: 'Möblering och tillsyn av bostad' },
+ { code: 'TRANSPORT', label: 'Transport till och från återvinning' },
+] as const
+
+export interface ItemForDeduction {
+ /** Unit price (per `quantity`). Same field as invoice_items.unit_price. */
+ unit_price: number
+ /** Quantity. Same field as invoice_items.quantity. */
+ quantity: number
+ /** 'rot' | 'rut' | null. Drives whether the deduction kicks in at all. */
+ deduction_type?: DeductionType | null
+ /**
+ * Optional. Reserved for a future iteration where the eligible portion of
+ * the row is just the labor hours × hourly rate. v1 ignores this and
+ * deducts on the full line total; we still take the field so the API
+ * schema accepts it without rejecting future-shaped payloads.
+ */
+ labor_hours?: number | null
+}
+
+/**
+ * Compute the deduction amount for a single invoice item. Returns 0 when
+ * the item has no deduction_type. The result is always >= 0 and <= line
+ * total (no over-deduction even if percentages are tweaked).
+ */
+export function computeDeduction(item: ItemForDeduction): number {
+ if (!item.deduction_type) return 0
+ const lineTotal = item.unit_price * item.quantity
+ if (lineTotal <= 0) return 0
+ const percent = item.deduction_type === 'rot' ? ROT_PERCENT : RUT_PERCENT
+ const raw = lineTotal * percent
+ // Cap at line total — defensive against future rule changes that would
+ // push percent past 1.0.
+ const capped = Math.min(raw, lineTotal)
+ return Math.round(capped * 100) / 100
+}
+
+/**
+ * Sum the per-item deduction over an invoice. Returns the total to store
+ * on invoices.deduction_total and to use as the 1513 debit amount.
+ */
+export function computeInvoiceDeductionTotal(items: ItemForDeduction[]): number {
+ let total = 0
+ for (const item of items) {
+ total += computeDeduction(item)
+ }
+ return Math.round(total * 100) / 100
+}
+
+/**
+ * Sum per deduction kind. Used to surface separate cap warnings.
+ */
+export function computeDeductionTotalsByKind(items: ItemForDeduction[]): {
+ rot: number
+ rut: number
+} {
+ let rot = 0
+ let rut = 0
+ for (const item of items) {
+ const amount = computeDeduction(item)
+ if (item.deduction_type === 'rot') rot += amount
+ else if (item.deduction_type === 'rut') rut += amount
+ }
+ return {
+ rot: Math.round(rot * 100) / 100,
+ rut: Math.round(rut * 100) / 100,
+ }
+}
+
+export interface ValidateInvoiceItem extends ItemForDeduction {
+ housing_designation?: string | null
+}
+
+export interface ValidationResult {
+ errors: string[]
+ warnings: string[]
+}
+
+/**
+ * Validate ROT/RUT prerequisites against a draft invoice.
+ *
+ * Errors block invoice creation; warnings surface in the UI but don't
+ * block (we can't verify a customer's yearly headroom across providers,
+ * but we can surface a "this invoice alone exceeds the cap" warning).
+ *
+ * The function takes invoice-level metadata as separate arguments rather
+ * than reading them off the items array so callers can compose it from
+ * either a HTTP request body or the form state without restructuring.
+ */
+export function validateInvoice(
+ items: ValidateInvoiceItem[],
+ personnummerProvided: boolean,
+ housingDesignationProvided: boolean,
+): ValidationResult {
+ const errors: string[] = []
+ const warnings: string[] = []
+
+ const hasAnyDeduction = items.some((item) => item.deduction_type)
+ const hasAnyRot = items.some((item) => item.deduction_type === 'rot')
+
+ if (hasAnyDeduction && !personnummerProvided) {
+ errors.push('Personnummer krävs för ROT/RUT-avdrag.')
+ }
+
+ // ROT requires fastighetsbeteckning per Skatteverket's Husavdragstjänst.
+ // RUT does not (in 2026 the Skatteverket file accepts RUT without it).
+ if (hasAnyRot && !housingDesignationProvided) {
+ errors.push('Fastighetsbeteckning krävs för ROT-avdrag.')
+ }
+
+ const { rot, rut } = computeDeductionTotalsByKind(items)
+
+ if (rot > ROT_MAX) {
+ warnings.push(
+ `ROT-avdraget på denna faktura (${rot.toFixed(2)} kr) överstiger årsmaximum ${ROT_MAX.toLocaleString('sv-SE')} kr. ` +
+ 'Kunden behöver kontrollera sitt återstående utrymme själv.',
+ )
+ }
+ if (rut > RUT_MAX) {
+ warnings.push(
+ `RUT-avdraget på denna faktura (${rut.toFixed(2)} kr) överstiger årsmaximum ${RUT_MAX.toLocaleString('sv-SE')} kr. ` +
+ 'Kunden behöver kontrollera sitt återstående utrymme själv.',
+ )
+ }
+
+ return { errors, warnings }
+}
diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts
index 00232209..f34279f1 100644
--- a/lib/pending-operations/commit.ts
+++ b/lib/pending-operations/commit.ts
@@ -52,6 +52,7 @@ import {
import { uploadDocument, linkToJournalEntry } from '@/lib/core/documents/document-service'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
+import { prepareInvoicePdfRender } from '@/lib/invoices/pdf-render-helpers'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
import { createLogger } from '@/lib/logger'
import { appendProcessingHistory } from '@/lib/processing-history/append'
@@ -667,13 +668,16 @@ async function commitSendInvoice(
// Override `status` to 'sent' on the in-memory copy. The DB flip happens
// after email delivery (line ~625); rendering with the stale 'draft' status
// would stamp the customer's PDF with "UTKAST – inte en giltig faktura".
+ const renderableInvoice = { ...(invoice as Invoice), status: 'sent' as const }
+ const { branding } = prepareInvoicePdfRender(company as CompanySettings)
const pdfBuffer = await renderToBuffer(
InvoicePDF({
- invoice: { ...(invoice as Invoice), status: 'sent' as const },
+ invoice: renderableInvoice,
customer,
items,
company: company as CompanySettings,
originalInvoiceNumber,
+ branding,
})
)
@@ -2537,3 +2541,4 @@ export async function commitPendingOperation(
data: result.data,
}
}
+
diff --git a/lib/reports/__tests__/continuity-check.test.ts b/lib/reports/__tests__/continuity-check.test.ts
index 125197f9..6a9a1661 100644
--- a/lib/reports/__tests__/continuity-check.test.ts
+++ b/lib/reports/__tests__/continuity-check.test.ts
@@ -233,7 +233,7 @@ describe('validateBalanceContinuity', () => {
expect(result.discrepancies[0].current_ib_net).toBe(5000)
})
- it('treats differences within 0.01 SEK tolerance as valid', async () => {
+ it('treats sub-öre float drift as valid (ORE_TOLERANCE = 0.005 SEK)', async () => {
mockResults = {
fiscal_periods: [
{ data: { id: 'p2', name: 'FY2025', period_start: '2025-01-01', previous_period_id: 'p1', opening_balance_entry_id: 'ob-1' } },
@@ -243,7 +243,7 @@ describe('validateBalanceContinuity', () => {
journal_entry_lines: [
{
data: [
- { account_number: '1930', debit_amount: 50000.005, credit_amount: 0 },
+ { account_number: '1930', debit_amount: 50000.001, credit_amount: 0 },
],
},
{
diff --git a/lib/reports/__tests__/kassaflodesanalys.test.ts b/lib/reports/__tests__/kassaflodesanalys.test.ts
new file mode 100644
index 00000000..774e3a43
--- /dev/null
+++ b/lib/reports/__tests__/kassaflodesanalys.test.ts
@@ -0,0 +1,390 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+
+// Mock trial-balance and income-statement so we can plant deterministic
+// inputs into the cash-flow generator. The generator is pure logic over
+// (TB rows, IS totals) — testing it in isolation avoids re-creating the
+// Supabase mock surface for two layered generators.
+vi.mock('../trial-balance', () => ({
+ generateTrialBalance: vi.fn(),
+}))
+
+vi.mock('../income-statement', () => ({
+ generateIncomeStatement: vi.fn(),
+}))
+
+import { generateKassaflodesanalys } from '../kassaflodesanalys'
+import { generateTrialBalance } from '../trial-balance'
+import { generateIncomeStatement } from '../income-statement'
+import type { TrialBalanceRow, IncomeStatementReport } from '@/types'
+
+const mockTrialBalance = vi.mocked(generateTrialBalance)
+const mockIncomeStatement = vi.mocked(generateIncomeStatement)
+
+function makeSupabase(period: { period_start: string; period_end: string } | null) {
+ // Lightweight chainable mock — kassaflodesanalys only calls
+ // supabase.from('fiscal_periods').select().eq().eq().single()
+ const builder: Record = {}
+ for (const m of ['select', 'eq']) {
+ builder[m] = vi.fn().mockReturnValue(builder)
+ }
+ builder.single = vi.fn().mockResolvedValue(
+ period ? { data: period, error: null } : { data: null, error: null }
+ )
+ return {
+ from: vi.fn().mockReturnValue(builder),
+ } as unknown as Parameters[0]
+}
+
+function makeRow(overrides: Partial): TrialBalanceRow {
+ return {
+ account_number: '0000',
+ account_name: 'X',
+ account_class: 0,
+ opening_debit: 0,
+ opening_credit: 0,
+ period_debit: 0,
+ period_credit: 0,
+ closing_debit: 0,
+ closing_credit: 0,
+ ...overrides,
+ }
+}
+
+function makeIs(overrides: Partial = {}): IncomeStatementReport {
+ return {
+ revenue_sections: [],
+ total_revenue: 0,
+ expense_sections: [],
+ total_expenses: 0,
+ financial_sections: [],
+ total_financial: 0,
+ net_result: 0,
+ period: { start: '2024-01-01', end: '2024-12-31' },
+ ...overrides,
+ }
+}
+
+beforeEach(() => {
+ vi.clearAllMocks()
+})
+
+describe('generateKassaflodesanalys', () => {
+ const PERIOD = { period_start: '2024-01-01', period_end: '2024-12-31' }
+
+ it('returns all-zero sections for an empty period', async () => {
+ mockTrialBalance.mockResolvedValue({
+ rows: [],
+ totalDebit: 0,
+ totalCredit: 0,
+ isBalanced: true,
+ })
+ mockIncomeStatement.mockResolvedValue(makeIs())
+
+ const report = await generateKassaflodesanalys(
+ makeSupabase(PERIOD),
+ 'company-1',
+ 'period-1'
+ )
+
+ expect(report.lopande.total).toBe(0)
+ expect(report.investerings.total).toBe(0)
+ expect(report.finansierings.total).toBe(0)
+ expect(report.total_cash_flow).toBe(0)
+ expect(report.reconciliation.is_reconciled).toBe(true)
+ expect(report.reconciliation.opening_cash_1xxx).toBe(0)
+ expect(report.reconciliation.closing_cash_1xxx).toBe(0)
+ expect(report.reconciliation.delta_actual).toBe(0)
+ expect(report.reconciliation.delta_calculated).toBe(0)
+ })
+
+ it('adds back avskrivningar (100 000 kr) to löpande verksamhet', async () => {
+ // Setup: 100 000 kr depreciation booked: debit 7832, credit 1219 (ack avskr).
+ // Expected: avskrivningar = 100 000, added back to resultat efter finansiella.
+ // For reconciliation: bank moved 0 because depreciation is non-cash; the
+ // fixed-asset NET delta is 1219 going up in credit (i.e. asset side down),
+ // which surfaces as avyttring (debit-side negative) -> +100 000 in investing.
+ //
+ // To keep this test focused on the "add back" behavior, we plant zero
+ // movement on classes 1-3,4-6,8 except 78xx (depreciation expense) and
+ // the offsetting 1219 (ack avskr). Result before fin: -100 000 (only
+ // expense). Add back +100 000. Net cash flow from operating: 0.
+ mockTrialBalance.mockResolvedValue({
+ rows: [
+ makeRow({
+ account_number: '7832',
+ account_class: 7,
+ period_debit: 100000,
+ closing_debit: 100000,
+ }),
+ // 1219 = ack avskr inventarier (contra-asset, credit-normal). Increases
+ // by 100 000 over the period.
+ makeRow({
+ account_number: '1219',
+ account_class: 1,
+ period_credit: 100000,
+ closing_credit: 100000,
+ }),
+ ],
+ totalDebit: 100000,
+ totalCredit: 100000,
+ isBalanced: true,
+ })
+ mockIncomeStatement.mockResolvedValue(
+ makeIs({
+ total_expenses: 100000,
+ net_result: -100000,
+ })
+ )
+
+ const report = await generateKassaflodesanalys(
+ makeSupabase(PERIOD),
+ 'company-1',
+ 'period-1'
+ )
+
+ expect(report.lopande.resultat_efter_finansiella_poster).toBe(-100000)
+ expect(report.lopande.avskrivningar).toBe(100000)
+ // Result + add-back depreciation = 0 löpande
+ expect(report.lopande.total).toBe(0)
+ // 1219 sits in 12xx range (investing), credit went up = debit-side delta
+ // is negative -> avyttring path. This is acceptable behavior; the
+ // reconciliation invariant is what protects us. Verify it holds:
+ // total_cash_flow should equal delta_actual on 19xx (which is 0).
+ expect(report.reconciliation.delta_actual).toBe(0)
+ // The mock setup ensures investing offsets to make the reconciliation
+ // balance against 0 cash movement.
+ expect(report.reconciliation.is_reconciled).toBe(true)
+ })
+
+ it('reconciles when a 50 000 kr deposit hits the bank (1930)', async () => {
+ // Setup: customer pays an invoice 50 000 net of VAT for simplicity.
+ // 1930 (bank) debit 50 000; 1510 (kundfordringar) credit 50 000.
+ // No P&L impact (already booked at invoice creation).
+ //
+ // Expected:
+ // Δ kortfristiga fordringar = -(-50 000) = +50 000 (receivables down → cash in)
+ // Result efter fin = 0
+ // Lopande total = +50 000
+ // delta_actual = 50 000 (closing 19xx = 50 000)
+ // delta_calculated = 50 000
+ // is_reconciled = true
+ mockTrialBalance.mockResolvedValue({
+ rows: [
+ makeRow({
+ account_number: '1930',
+ account_class: 1,
+ period_debit: 50000,
+ closing_debit: 50000,
+ }),
+ makeRow({
+ account_number: '1510',
+ account_class: 1,
+ opening_debit: 50000,
+ period_credit: 50000,
+ closing_debit: 50000,
+ closing_credit: 50000,
+ }),
+ ],
+ totalDebit: 100000,
+ totalCredit: 50000,
+ isBalanced: false,
+ })
+ mockIncomeStatement.mockResolvedValue(makeIs())
+
+ const report = await generateKassaflodesanalys(
+ makeSupabase(PERIOD),
+ 'company-1',
+ 'period-1'
+ )
+
+ // 1510 net debit-side delta = (50000-50000) - (50000-0) = -50000
+ // delta_kortfristiga_fordringar = -(-50000) = +50000
+ expect(report.lopande.delta_kortfristiga_fordringar).toBe(50000)
+ expect(report.lopande.total).toBe(50000)
+ expect(report.reconciliation.opening_cash_1xxx).toBe(0)
+ expect(report.reconciliation.closing_cash_1xxx).toBe(50000)
+ expect(report.reconciliation.delta_actual).toBe(50000)
+ expect(report.reconciliation.delta_calculated).toBe(50000)
+ expect(report.reconciliation.is_reconciled).toBe(true)
+ })
+
+ it('records asset purchase (200 000 kr) as investing outflow', async () => {
+ // Setup: buy inventarie for 200 000: debit 1220, credit 1930.
+ // 1930 (bank): credit 200 000 → closing -200 000
+ // 1220 (inventarier): debit 200 000 → closing +200 000
+ //
+ // Expected:
+ // forvarv_anlaggningar = -200 000
+ // delta_actual = -200 000 (bank went down)
+ // delta_calculated = -200 000
+ // is_reconciled = true
+ mockTrialBalance.mockResolvedValue({
+ rows: [
+ makeRow({
+ account_number: '1930',
+ account_class: 1,
+ period_credit: 200000,
+ closing_credit: 200000,
+ }),
+ makeRow({
+ account_number: '1220',
+ account_class: 1,
+ period_debit: 200000,
+ closing_debit: 200000,
+ }),
+ ],
+ totalDebit: 200000,
+ totalCredit: 200000,
+ isBalanced: true,
+ })
+ mockIncomeStatement.mockResolvedValue(makeIs())
+
+ const report = await generateKassaflodesanalys(
+ makeSupabase(PERIOD),
+ 'company-1',
+ 'period-1'
+ )
+
+ expect(report.investerings.forvarv_anlaggningar).toBe(-200000)
+ expect(report.investerings.avyttring_anlaggningar).toBe(0)
+ expect(report.investerings.total).toBe(-200000)
+ expect(report.reconciliation.delta_actual).toBe(-200000)
+ expect(report.reconciliation.delta_calculated).toBe(-200000)
+ expect(report.reconciliation.is_reconciled).toBe(true)
+ })
+
+ it('records loan increase (500 000 kr) as financing inflow', async () => {
+ // Setup: take out a 500 000 long-term loan: debit 1930, credit 2350.
+ // 1930 (bank): debit 500 000 → closing +500 000
+ // 2350 (långfristiga lån): credit 500 000 → closing -500 000 on debit side
+ //
+ // Expected:
+ // delta_lan = 500 000 (loans went up, cash in)
+ // delta_actual = 500 000
+ // is_reconciled = true
+ mockTrialBalance.mockResolvedValue({
+ rows: [
+ makeRow({
+ account_number: '1930',
+ account_class: 1,
+ period_debit: 500000,
+ closing_debit: 500000,
+ }),
+ makeRow({
+ account_number: '2350',
+ account_class: 2,
+ period_credit: 500000,
+ closing_credit: 500000,
+ }),
+ ],
+ totalDebit: 500000,
+ totalCredit: 500000,
+ isBalanced: true,
+ })
+ mockIncomeStatement.mockResolvedValue(makeIs())
+
+ const report = await generateKassaflodesanalys(
+ makeSupabase(PERIOD),
+ 'company-1',
+ 'period-1'
+ )
+
+ expect(report.finansierings.delta_lan).toBe(500000)
+ expect(report.finansierings.total).toBe(500000)
+ expect(report.reconciliation.delta_actual).toBe(500000)
+ expect(report.reconciliation.delta_calculated).toBe(500000)
+ expect(report.reconciliation.is_reconciled).toBe(true)
+ })
+
+ it('detects mismatch when a cash movement has no balancing classification', async () => {
+ // Plant an invariant violation: 1930 went up by 10 000 but no offsetting
+ // entry on any tracked account class. This is the kind of bug a real
+ // bookkeeping error would surface as.
+ mockTrialBalance.mockResolvedValue({
+ rows: [
+ makeRow({
+ account_number: '1930',
+ account_class: 1,
+ period_debit: 10000,
+ closing_debit: 10000,
+ }),
+ // The "offset" is in account 9999 (out-of-range). The cash flow
+ // generator doesn't see it. is_reconciled must flag false.
+ makeRow({
+ account_number: '9999',
+ account_class: 9,
+ period_credit: 10000,
+ closing_credit: 10000,
+ }),
+ ],
+ totalDebit: 10000,
+ totalCredit: 10000,
+ isBalanced: true,
+ })
+ mockIncomeStatement.mockResolvedValue(makeIs())
+
+ const report = await generateKassaflodesanalys(
+ makeSupabase(PERIOD),
+ 'company-1',
+ 'period-1'
+ )
+
+ expect(report.reconciliation.delta_actual).toBe(10000)
+ expect(report.reconciliation.delta_calculated).toBe(0)
+ expect(report.reconciliation.mismatch_amount).toBe(10000)
+ expect(report.reconciliation.is_reconciled).toBe(false)
+ })
+
+ it('throws when fiscal period is not found', async () => {
+ mockTrialBalance.mockResolvedValue({
+ rows: [],
+ totalDebit: 0,
+ totalCredit: 0,
+ isBalanced: true,
+ })
+ mockIncomeStatement.mockResolvedValue(makeIs())
+
+ await expect(
+ generateKassaflodesanalys(makeSupabase(null), 'company-1', 'period-1')
+ ).rejects.toThrow('Fiscal period not found')
+ })
+
+ it('uses Math.round for monetary precision (no toFixed)', async () => {
+ // Plant fractional cents in the inputs; result must be rounded to 2dp,
+ // never via toFixed which would return a string.
+ mockTrialBalance.mockResolvedValue({
+ rows: [
+ makeRow({
+ account_number: '1930',
+ account_class: 1,
+ period_debit: 33.337,
+ closing_debit: 33.337,
+ }),
+ makeRow({
+ account_number: '7832',
+ account_class: 7,
+ period_debit: 33.337,
+ closing_debit: 33.337,
+ }),
+ ],
+ totalDebit: 66.674,
+ totalCredit: 0,
+ isBalanced: false,
+ })
+ mockIncomeStatement.mockResolvedValue(
+ makeIs({ total_expenses: 33.337, net_result: -33.337 })
+ )
+
+ const report = await generateKassaflodesanalys(
+ makeSupabase(PERIOD),
+ 'company-1',
+ 'period-1'
+ )
+
+ // resultat = revenue - expenses + non-tax-financial = 0 - 33.337 + 0 = -33.34
+ expect(report.lopande.resultat_efter_finansiella_poster).toBe(-33.34)
+ expect(report.lopande.avskrivningar).toBe(33.34)
+ // No bare toFixed return values — these must be numbers, not strings.
+ expect(typeof report.lopande.total).toBe('number')
+ })
+})
diff --git a/lib/reports/__tests__/source-lines.test.ts b/lib/reports/__tests__/source-lines.test.ts
new file mode 100644
index 00000000..2a295650
--- /dev/null
+++ b/lib/reports/__tests__/source-lines.test.ts
@@ -0,0 +1,146 @@
+/**
+ * Compile-time checks for the report drilldown source-line contract plus
+ * runtime tests for the createSourceLoader helper used by the expansion UI.
+ */
+import { describe, it, expect, vi } from 'vitest'
+import type {
+ ReportSourceLine,
+ ReportSourceFetcher,
+ ReportSourceResponse,
+ SourceLoaderState,
+} from '@/lib/reports/source-lines'
+import { createSourceLoader } from '@/lib/reports/source-lines'
+
+describe('source-lines types', () => {
+ it('ReportSourceLine has the documented fields', () => {
+ const line: ReportSourceLine = {
+ journal_entry_id: 'je-1',
+ voucher_number: 1,
+ voucher_series: 'A',
+ date: '2026-01-01',
+ description: 'desc',
+ debit: 0,
+ credit: 100,
+ }
+ expect(line.journal_entry_id).toBe('je-1')
+ expect(line.voucher_series + line.voucher_number).toBe('A1')
+ })
+
+ it('ReportSourceFetcher resolves a lines+cursor envelope', async () => {
+ const fetcher: ReportSourceFetcher = async () => ({
+ lines: [],
+ next_cursor: null,
+ })
+ const result = await fetcher()
+ expect(result.lines).toEqual([])
+ expect(result.next_cursor).toBeNull()
+ })
+
+ it('ReportSourceResponse accepts every keyed report variant', () => {
+ const tb: ReportSourceResponse = {
+ account_number: '1930',
+ account_name: 'Företagskonto',
+ lines: [],
+ next_cursor: null,
+ }
+ const vat: ReportSourceResponse = {
+ ruta: 'ruta10',
+ lines: [],
+ next_cursor: null,
+ }
+ const ar: ReportSourceResponse = {
+ customer_id: 'c-1',
+ lines: [],
+ next_cursor: null,
+ }
+ const sup: ReportSourceResponse = {
+ supplier_id: 's-1',
+ lines: [],
+ next_cursor: null,
+ }
+ expect(tb.lines).toEqual(vat.lines)
+ expect(ar.lines).toEqual(sup.lines)
+ })
+})
+
+describe('createSourceLoader', () => {
+ const makeLine = (
+ overrides: Partial = {}
+ ): ReportSourceLine => ({
+ journal_entry_id: 'je-1',
+ voucher_number: 1,
+ voucher_series: 'A',
+ date: '2026-01-01',
+ description: 'desc',
+ debit: 100,
+ credit: 0,
+ ...overrides,
+ })
+
+ it('starts in idle state and transitions through loading → success', async () => {
+ const fetcher: ReportSourceFetcher = vi
+ .fn()
+ .mockResolvedValueOnce({ lines: [makeLine()], next_cursor: null })
+
+ const states: SourceLoaderState[] = []
+ const loader = createSourceLoader(fetcher, (s) => states.push({ ...s }))
+
+ expect(loader.getState()).toEqual({ lines: null, loading: false, error: null })
+
+ await loader.load()
+
+ expect(states[0]).toEqual({ lines: null, loading: true, error: null })
+ const last = states[states.length - 1]
+ expect(last.loading).toBe(false)
+ expect(last.error).toBeNull()
+ expect(last.lines).toHaveLength(1)
+ expect(last.lines?.[0].voucher_number).toBe(1)
+ })
+
+ it('captures fetcher errors and surfaces them as Swedish messages', async () => {
+ const fetcher: ReportSourceFetcher = vi
+ .fn()
+ .mockRejectedValueOnce(new Error('boom'))
+ const loader = createSourceLoader(fetcher, () => {})
+ await loader.load()
+ const state = loader.getState()
+ expect(state.loading).toBe(false)
+ expect(state.error).toBe('boom')
+ expect(state.lines).toBeNull()
+ })
+
+ it('falls back to a Swedish error when the rejection is not an Error', async () => {
+ const fetcher: ReportSourceFetcher = vi.fn().mockRejectedValueOnce('nope')
+ const loader = createSourceLoader(fetcher, () => {})
+ await loader.load()
+ expect(loader.getState().error).toBe('Kunde inte hämta verifikat')
+ })
+
+ it('caches results: a second load() is a no-op', async () => {
+ const fetcher: ReportSourceFetcher = vi
+ .fn()
+ .mockResolvedValue({ lines: [makeLine()], next_cursor: null })
+ const loader = createSourceLoader(fetcher, () => {})
+ await loader.load()
+ await loader.load()
+ await loader.load()
+ expect(fetcher).toHaveBeenCalledTimes(1)
+ })
+
+ it('does not start a second concurrent load while one is in flight', async () => {
+ let resolveFetch: (value: { lines: ReportSourceLine[]; next_cursor: null }) => void = () => {}
+ const pending = new Promise<{ lines: ReportSourceLine[]; next_cursor: null }>(
+ (r) => { resolveFetch = r }
+ )
+ const fetcher: ReportSourceFetcher = vi.fn().mockReturnValueOnce(pending)
+ const loader = createSourceLoader(fetcher, () => {})
+
+ const first = loader.load()
+ // Second invocation before resolution should be a no-op.
+ const second = loader.load()
+ resolveFetch({ lines: [], next_cursor: null })
+ await Promise.all([first, second])
+
+ expect(fetcher).toHaveBeenCalledTimes(1)
+ })
+})
diff --git a/lib/reports/__tests__/xlsx-export.test.ts b/lib/reports/__tests__/xlsx-export.test.ts
new file mode 100644
index 00000000..cd547819
--- /dev/null
+++ b/lib/reports/__tests__/xlsx-export.test.ts
@@ -0,0 +1,235 @@
+import { describe, it, expect } from 'vitest'
+import * as XLSX from 'xlsx'
+import {
+ reportToWorkbook,
+ textColumn,
+ currencyColumn,
+ dateColumn,
+ integerColumn,
+ percentColumn,
+ slugifyCompanyName,
+ xlsxFilename,
+ type SheetSpec,
+} from '../xlsx-export'
+
+/**
+ * Helper: parse a workbook buffer back into a usable shape so we can assert on
+ * cell values, number formats, and per-sheet content.
+ *
+ * `cellDates: true` so dates round-trip as Date objects rather than serial
+ * numbers, matching how Excel/Numbers will read the file.
+ */
+function parseBuffer(buffer: Buffer): XLSX.WorkBook {
+ return XLSX.read(buffer, { type: 'buffer', cellNF: true, cellDates: true })
+}
+
+describe('reportToWorkbook', () => {
+ it('writes a single sheet with header row and body cells', () => {
+ type Row = { account: string; amount: number }
+ const spec: SheetSpec = {
+ name: 'Test',
+ columns: [textColumn('Konto'), currencyColumn('Belopp')],
+ rows: [
+ { account: '1930', amount: 1500.5 },
+ { account: '2440', amount: -750.25 },
+ ],
+ mapRow: (r) => [r.account, r.amount],
+ }
+
+ const buffer = reportToWorkbook([spec])
+ expect(buffer).toBeInstanceOf(Buffer)
+ expect(buffer.byteLength).toBeGreaterThan(0)
+
+ const wb = parseBuffer(buffer)
+ expect(wb.SheetNames).toEqual(['Test'])
+ const sheet = wb.Sheets['Test']
+ expect(sheet['A1'].v).toBe('Konto')
+ expect(sheet['B1'].v).toBe('Belopp')
+ expect(sheet['A2'].v).toBe('1930')
+ expect(sheet['B2'].v).toBe(1500.5)
+ expect(sheet['A3'].v).toBe('2440')
+ expect(sheet['B3'].v).toBe(-750.25)
+ })
+
+ it('applies currency format to currency columns', () => {
+ type Row = { name: string; total: number }
+ const buffer = reportToWorkbook([
+ {
+ name: 'Currency',
+ columns: [textColumn('Name'), currencyColumn('Total')],
+ rows: [{ name: 'Foo', total: 1234.56 }],
+ mapRow: (r) => [r.name, r.total],
+ },
+ ])
+
+ const wb = parseBuffer(buffer)
+ const sheet = wb.Sheets['Currency']
+ expect(sheet['B2'].z).toBe('#,##0.00 " kr"')
+ // The text column does not get our custom format applied. xlsx may add
+ // a default 'General' format on read, so we only assert it isn't ours.
+ expect(sheet['A2'].z).not.toBe('#,##0.00 " kr"')
+ })
+
+ it('applies date format to date columns', () => {
+ type Row = { date: Date }
+ const buffer = reportToWorkbook([
+ {
+ name: 'Dates',
+ columns: [dateColumn('Datum')],
+ rows: [{ date: new Date('2026-03-15T00:00:00Z') }],
+ mapRow: (r) => [r.date],
+ },
+ ])
+
+ const wb = parseBuffer(buffer)
+ const sheet = wb.Sheets['Dates']
+ expect(sheet['A2'].z).toBe('yyyy-mm-dd')
+ expect(sheet['A2'].t).toBe('d')
+ })
+
+ it('applies integer and percent formats', () => {
+ const buffer = reportToWorkbook([
+ {
+ name: 'Numeric',
+ columns: [integerColumn('Antal'), percentColumn('Andel')],
+ rows: [{ count: 42, share: 0.255 }],
+ mapRow: (r) => [r.count, r.share],
+ },
+ ])
+
+ const wb = parseBuffer(buffer)
+ const sheet = wb.Sheets['Numeric']
+ expect(sheet['A2'].z).toBe('#,##0')
+ expect(sheet['B2'].z).toBe('0.00%')
+ expect(sheet['A2'].v).toBe(42)
+ expect(sheet['B2'].v).toBe(0.255)
+ })
+
+ it('produces multiple sheets with independent column shapes', () => {
+ const buffer = reportToWorkbook([
+ {
+ name: 'Saldo',
+ columns: [textColumn('Konto'), currencyColumn('Belopp')],
+ rows: [{ account: '1930', amount: 100 }],
+ mapRow: (r) => [r.account, r.amount],
+ },
+ {
+ name: 'Period',
+ columns: [dateColumn('Datum'), integerColumn('Antal')],
+ rows: [{ date: new Date('2026-01-15'), count: 5 }],
+ mapRow: (r) => [r.date, r.count],
+ },
+ ])
+
+ const wb = parseBuffer(buffer)
+ expect(wb.SheetNames).toEqual(['Saldo', 'Period'])
+ expect(wb.Sheets['Saldo']['B1'].v).toBe('Belopp')
+ expect(wb.Sheets['Period']['A1'].v).toBe('Datum')
+ expect(wb.Sheets['Period']['A2'].z).toBe('yyyy-mm-dd')
+ })
+
+ it('handles empty row arrays (header-only sheet)', () => {
+ const buffer = reportToWorkbook([
+ {
+ name: 'Empty',
+ columns: [textColumn('Konto'), currencyColumn('Belopp')],
+ rows: [],
+ mapRow: () => ['unused', 0],
+ },
+ ])
+
+ expect(buffer).toBeInstanceOf(Buffer)
+ const wb = parseBuffer(buffer)
+ const sheet = wb.Sheets['Empty']
+ expect(sheet['A1'].v).toBe('Konto')
+ expect(sheet['B1'].v).toBe('Belopp')
+ // No body cells emitted.
+ expect(sheet['A2']).toBeUndefined()
+ expect(sheet['B2']).toBeUndefined()
+ })
+
+ it('truncates sheet names to Excel 31-char limit', () => {
+ const longName = 'A'.repeat(40)
+ const buffer = reportToWorkbook([
+ {
+ name: longName,
+ columns: [textColumn('x')],
+ rows: [],
+ mapRow: () => [''],
+ },
+ ])
+
+ const wb = parseBuffer(buffer)
+ expect(wb.SheetNames[0]).toHaveLength(31)
+ })
+
+ it('treats undefined cells as blank', () => {
+ const buffer = reportToWorkbook([
+ {
+ name: 'Sparse',
+ columns: [textColumn('A'), textColumn('B')],
+ rows: [{ a: 'x', b: undefined }],
+ mapRow: (r) => [r.a, r.b ?? null],
+ },
+ ])
+
+ const wb = parseBuffer(buffer)
+ const sheet = wb.Sheets['Sparse']
+ expect(sheet['A2'].v).toBe('x')
+ expect(sheet['B2']).toBeUndefined()
+ })
+
+ it('throws when row length does not match column count', () => {
+ expect(() =>
+ reportToWorkbook([
+ {
+ name: 'Bad',
+ columns: [textColumn('A'), textColumn('B')],
+ rows: [{ x: 1 }],
+ mapRow: () => ['only one'],
+ },
+ ]),
+ ).toThrow(/row length 1 does not match column count 2/)
+ })
+
+ it('throws when given zero sheets', () => {
+ expect(() => reportToWorkbook([])).toThrow(/at least one sheet/)
+ })
+})
+
+describe('slugifyCompanyName', () => {
+ it('lowercases and dasherizes', () => {
+ expect(slugifyCompanyName('Acme Bookkeeping AB')).toBe('acme-bookkeeping-ab')
+ })
+
+ it('replaces Swedish characters', () => {
+ expect(slugifyCompanyName('Räksmörgås & Co')).toBe('raksmorgas-co')
+ })
+
+ it('falls back to "foretag" when empty', () => {
+ expect(slugifyCompanyName('')).toBe('foretag')
+ expect(slugifyCompanyName('!!!')).toBe('foretag')
+ })
+
+ it('collapses repeated separators', () => {
+ expect(slugifyCompanyName('Foo Bar___Baz')).toBe('foo-bar-baz')
+ })
+})
+
+describe('xlsxFilename', () => {
+ it('combines slug, company, and compact period', () => {
+ expect(xlsxFilename('trial-balance', 'Acme AB', '2026-03-31')).toBe(
+ 'trial-balance-acme-ab-20260331.xlsx',
+ )
+ })
+
+ it('handles missing period gracefully', () => {
+ expect(xlsxFilename('kpi', 'Test', '')).toBe('kpi-test.xlsx')
+ })
+
+ it('slugifies Swedish company names', () => {
+ expect(xlsxFilename('vat-declaration', 'Räk AB', '2026-12-31')).toBe(
+ 'vat-declaration-rak-ab-20261231.xlsx',
+ )
+ })
+})
diff --git a/lib/reports/continuity-check.ts b/lib/reports/continuity-check.ts
index a1f8bcfd..7720b3e8 100644
--- a/lib/reports/continuity-check.ts
+++ b/lib/reports/continuity-check.ts
@@ -2,6 +2,7 @@ import type { SupabaseClient } from '@supabase/supabase-js'
import type { ContinuityCheckResult, ContinuityDiscrepancy } from '@/types'
import { generateTrialBalance } from './trial-balance'
import { getOpeningBalances } from './opening-balances'
+import { roundOre, ORE_TOLERANCE } from '@/lib/bokslut/rounding'
/**
* Validate that a fiscal period's opening balances (IB) match the previous
@@ -11,8 +12,11 @@ import { getOpeningBalances } from './opening-balances'
* UB and getOpeningBalances() for IB, so a passing check proves the reports
* are consistent.
*
- * Tolerance: 0.01 SEK per account — covers IEEE 754 rounding drift from
- * intermediate Math.round() calls, NOT Swedish öresavrundning (abolished 2010).
+ * Tolerance: ORE_TOLERANCE (0.005 SEK) per account. All monetary values
+ * funnel through roundOre() first, so a half-öre threshold is sufficient
+ * to absorb float drift and any larger difference is a real discrepancy.
+ * (Swedish öresavrundning was abolished 2010 — this is purely IEEE 754
+ * hygiene, not a regulatory rounding.)
*/
export async function validateBalanceContinuity(
supabase: SupabaseClient,
@@ -64,8 +68,8 @@ export async function validateBalanceContinuity(
const previousUB = new Map()
for (const row of trialRows) {
if (row.account_class >= 1 && row.account_class <= 2) {
- const net = Math.round((row.closing_debit - row.closing_credit) * 100) / 100
- if (Math.abs(net) >= 0.005) {
+ const net = roundOre(row.closing_debit - row.closing_credit)
+ if (Math.abs(net) >= ORE_TOLERANCE) {
previousUB.set(row.account_number, { net, name: row.account_name })
}
}
@@ -79,8 +83,8 @@ export async function validateBalanceContinuity(
// Only check balance sheet accounts
const accountClass = parseInt(accountNumber[0]) || 0
if (accountClass >= 1 && accountClass <= 2) {
- const net = Math.round((bal.debit - bal.credit) * 100) / 100
- if (Math.abs(net) >= 0.005) {
+ const net = roundOre(bal.debit - bal.credit)
+ if (Math.abs(net) >= ORE_TOLERANCE) {
currentIB.set(accountNumber, net)
}
}
@@ -99,9 +103,9 @@ export async function validateBalanceContinuity(
for (const accountNumber of allAccounts) {
const ubNet = previousUB.get(accountNumber)?.net ?? 0
const ibNet = currentIB.get(accountNumber) ?? 0
- const difference = Math.round((ubNet - ibNet) * 100) / 100
+ const difference = roundOre(ubNet - ibNet)
- if (Math.abs(difference) > 0.01) {
+ if (Math.abs(difference) > ORE_TOLERANCE) {
discrepancies.push({
account_number: accountNumber,
account_name: accountNames.get(accountNumber) ?? `Konto ${accountNumber}`,
diff --git a/lib/reports/kassaflodesanalys-pdf-template.tsx b/lib/reports/kassaflodesanalys-pdf-template.tsx
new file mode 100644
index 00000000..5fdc5a89
--- /dev/null
+++ b/lib/reports/kassaflodesanalys-pdf-template.tsx
@@ -0,0 +1,393 @@
+import {
+ Document,
+ Page,
+ Text,
+ View,
+ StyleSheet,
+} from '@react-pdf/renderer'
+import type { KassaflodesanalysReport } from './kassaflodesanalys'
+import type { CompanySettings } from '@/types'
+
+// Single-file PDF template — the kassaflödesanalys layout is distinct enough
+// from the BR/RR template (no per-account rows, three labelled sections, a
+// reconciliation footer) that a dedicated template keeps things readable.
+// Typography mirrors CLAUDE.md design system: Times-Roman as the serif
+// (closest stock @react-pdf font to Hedvig Letters Serif, which @react-pdf
+// can't ship by default), Helvetica for body, Courier for tabular numbers
+// so columns align without us shipping a custom font.
+
+const styles = StyleSheet.create({
+ page: {
+ paddingTop: 40,
+ paddingHorizontal: 40,
+ paddingBottom: 110,
+ fontSize: 10,
+ fontFamily: 'Helvetica',
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+ marginBottom: 24,
+ paddingBottom: 14,
+ borderBottomWidth: 1,
+ borderBottomColor: '#d4d4d4',
+ },
+ titleBlock: { flex: 1 },
+ title: {
+ fontSize: 22,
+ fontFamily: 'Times-Roman',
+ color: '#1a1a1a',
+ marginBottom: 4,
+ },
+ subtitle: { fontSize: 11, color: '#333', marginBottom: 2 },
+ period: { fontSize: 10, color: '#666' },
+ companyInfo: { textAlign: 'right' },
+ companyName: { fontSize: 11, fontWeight: 'bold', marginBottom: 2 },
+ companyMeta: { fontSize: 9, color: '#666' },
+ section: { marginBottom: 18 },
+ sectionHeading: {
+ fontSize: 12,
+ fontFamily: 'Times-Roman',
+ color: '#1a1a1a',
+ marginBottom: 8,
+ paddingBottom: 4,
+ borderBottomWidth: 1,
+ borderBottomColor: '#1a1a1a',
+ },
+ row: {
+ flexDirection: 'row',
+ paddingVertical: 3,
+ },
+ label: {
+ flex: 1,
+ color: '#1a1a1a',
+ },
+ amount: {
+ width: 130,
+ textAlign: 'right',
+ fontFamily: 'Courier',
+ color: '#1a1a1a',
+ },
+ subtotalRow: {
+ flexDirection: 'row',
+ paddingVertical: 5,
+ marginTop: 4,
+ borderTopWidth: 1,
+ borderTopColor: '#1a1a1a',
+ },
+ subtotalLabel: {
+ flex: 1,
+ fontWeight: 'bold',
+ fontSize: 11,
+ },
+ subtotalAmount: {
+ width: 130,
+ textAlign: 'right',
+ fontFamily: 'Courier',
+ fontWeight: 'bold',
+ fontSize: 11,
+ },
+ totalBlock: {
+ marginTop: 20,
+ paddingTop: 12,
+ borderTopWidth: 2,
+ borderTopColor: '#1a1a1a',
+ },
+ totalLabel: {
+ flex: 1,
+ fontWeight: 'bold',
+ fontFamily: 'Times-Roman',
+ fontSize: 13,
+ },
+ totalAmount: {
+ width: 130,
+ textAlign: 'right',
+ fontFamily: 'Courier',
+ fontWeight: 'bold',
+ fontSize: 13,
+ },
+ reconciliationBlock: {
+ marginTop: 24,
+ padding: 12,
+ borderWidth: 1,
+ borderColor: '#d4d4d4',
+ borderRadius: 4,
+ backgroundColor: '#fafafa',
+ },
+ reconciliationOk: {
+ borderColor: '#16a34a',
+ backgroundColor: '#f0fdf4',
+ },
+ reconciliationBad: {
+ // Terracotta destructive-color analogue for PDFs (since CSS vars
+ // aren't available). Surfaces a clear "mismatch" signal without
+ // depending on the chrome design tokens.
+ borderColor: '#b91c1c',
+ backgroundColor: '#fef2f2',
+ },
+ reconciliationTitle: {
+ fontSize: 11,
+ fontWeight: 'bold',
+ color: '#1a1a1a',
+ marginBottom: 6,
+ },
+ reconciliationRow: {
+ flexDirection: 'row',
+ paddingVertical: 2,
+ },
+ reconciliationLabel: { flex: 1, color: '#444' },
+ reconciliationAmount: {
+ width: 130,
+ textAlign: 'right',
+ fontFamily: 'Courier',
+ color: '#1a1a1a',
+ },
+ reconciliationMismatch: {
+ color: '#b91c1c',
+ fontWeight: 'bold',
+ },
+ footer: {
+ position: 'absolute',
+ bottom: 24,
+ left: 40,
+ right: 40,
+ borderTopWidth: 0.5,
+ borderTopColor: '#d4d4d4',
+ paddingTop: 6,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ footerText: { fontSize: 8, color: '#888' },
+})
+
+function formatAmount(n: number): string {
+ return new Intl.NumberFormat('sv-SE', {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(n)
+}
+
+function formatOrgNumber(orgNumber: string): string {
+ const cleaned = orgNumber.replace(/\D/g, '')
+ if (cleaned.length === 10) {
+ return `${cleaned.slice(0, 6)}-${cleaned.slice(6)}`
+ }
+ return orgNumber
+}
+
+function formatDateSv(iso: string): string {
+ if (!iso) return ''
+ return new Date(iso).toLocaleDateString('sv-SE')
+}
+
+interface KassaflodePDFProps {
+ report: KassaflodesanalysReport
+ company: CompanySettings
+ generatedAt: string
+}
+
+export function KassaflodesanalysPDF({
+ report,
+ company,
+ generatedAt,
+}: KassaflodePDFProps) {
+ const companyName = company.company_name || ''
+ const periodLabel = `${formatDateSv(report.period_start)} – ${formatDateSv(report.period_end)}`
+ const recon = report.reconciliation
+ const reconStyles = [
+ styles.reconciliationBlock,
+ recon.is_reconciled ? styles.reconciliationOk : styles.reconciliationBad,
+ ]
+
+ return (
+
+
+
+
+ Kassaflödesanalys
+ {companyName && {companyName} }
+ Period: {periodLabel}
+ Indirekt metod enligt BFNAR 2012:1 kap 7
+
+
+ {company.company_name && (
+ {company.company_name}
+ )}
+ {company.org_number && (
+
+ Org.nr: {formatOrgNumber(company.org_number)}
+
+ )}
+ {company.vat_number && (
+ VAT: {company.vat_number}
+ )}
+
+
+
+ {/* Section 1: Löpande verksamhet */}
+
+ Den löpande verksamheten
+
+ Resultat efter finansiella poster
+
+ {formatAmount(report.lopande.resultat_efter_finansiella_poster)}
+
+
+
+ Justeringar för avskrivningar
+ {formatAmount(report.lopande.avskrivningar)}
+
+
+ Övriga ej-kassaflödespåverkande poster
+
+ {formatAmount(report.lopande.ovriga_ej_kassaflodesposter)}
+
+
+
+ Förändring av kortfristiga fordringar
+
+ {formatAmount(report.lopande.delta_kortfristiga_fordringar)}
+
+
+
+ Förändring av varulager
+ {formatAmount(report.lopande.delta_varulager)}
+
+
+ Förändring av kortfristiga skulder
+
+ {formatAmount(report.lopande.delta_kortfristiga_skulder)}
+
+
+
+ Betald inkomstskatt
+ {formatAmount(report.lopande.skatt_betald)}
+
+
+
+ Kassaflöde från den löpande verksamheten
+
+ {formatAmount(report.lopande.total)}
+
+
+
+ {/* Section 2: Investeringsverksamhet */}
+
+ Investeringsverksamheten
+
+ Förvärv av anläggningstillgångar
+
+ {formatAmount(report.investerings.forvarv_anlaggningar)}
+
+
+
+ Avyttring av anläggningstillgångar
+
+ {formatAmount(report.investerings.avyttring_anlaggningar)}
+
+
+
+
+ Kassaflöde från investeringsverksamheten
+
+
+ {formatAmount(report.investerings.total)}
+
+
+
+
+ {/* Section 3: Finansieringsverksamhet */}
+
+ Finansieringsverksamheten
+
+ Förändring av lån (långfristiga skulder)
+ {formatAmount(report.finansierings.delta_lan)}
+
+
+ Utdelningar till ägare
+
+ {formatAmount(report.finansierings.utdelningar)}
+
+
+
+ Nyemission
+ {formatAmount(report.finansierings.nyemission)}
+
+
+
+ Kassaflöde från finansieringsverksamheten
+
+
+ {formatAmount(report.finansierings.total)}
+
+
+
+
+ {/* Total */}
+
+ Årets kassaflöde
+ {formatAmount(report.total_cash_flow)}
+
+
+ {/* Reconciliation */}
+
+
+ Avstämning mot likvida medel (19xx)
+
+
+ Ingående saldo
+
+ {formatAmount(recon.opening_cash_1xxx)}
+
+
+
+ Utgående saldo
+
+ {formatAmount(recon.closing_cash_1xxx)}
+
+
+
+ Faktisk förändring
+
+ {formatAmount(recon.delta_actual)}
+
+
+
+ Beräknad förändring
+
+ {formatAmount(recon.delta_calculated)}
+
+
+ {!recon.is_reconciled && (
+
+
+ Avvikelse — kontrollera bokföringen
+
+
+ {formatAmount(recon.mismatch_amount)}
+
+
+ )}
+
+
+
+
+ {companyName}
+ {company.org_number ? ` · ${formatOrgNumber(company.org_number)}` : ''}
+
+
+ `Genererad ${formatDateSv(generatedAt)} · Sida ${pageNumber} av ${totalPages}`
+ }
+ />
+
+
+
+ )
+}
diff --git a/lib/reports/kassaflodesanalys.ts b/lib/reports/kassaflodesanalys.ts
new file mode 100644
index 00000000..062aa0a5
--- /dev/null
+++ b/lib/reports/kassaflodesanalys.ts
@@ -0,0 +1,347 @@
+import type { SupabaseClient } from '@supabase/supabase-js'
+import { generateTrialBalance } from './trial-balance'
+import { generateIncomeStatement } from './income-statement'
+import type { TrialBalanceRow } from '@/types'
+
+/**
+ * Kassaflödesanalys (Cash Flow Statement) — indirect method per BFNAR 2012:1 ch 7.
+ *
+ * Three sections:
+ * - Löpande verksamhet (Operating activities)
+ * - Investeringsverksamhet (Investing activities)
+ * - Finansieringsverksamhet (Financing activities)
+ *
+ * The indirect method starts from "Resultat efter finansiella poster", adds
+ * back non-cash items (avskrivningar, periodiseringar), and adjusts for
+ * working-capital movements. The sum across all three sections must equal
+ * the actual change in cash & bank (19xx) balance over the period.
+ *
+ * Account-class mapping (BAS 2026):
+ * 14xx Lager / varulager → operating (Δ inventory)
+ * 15xx Kortfristiga fordringar (kundfordringar) → operating (Δ receivables)
+ * 24xx Kortfristiga skulder (leverantörsskulder) → operating (Δ payables)
+ * 26xx Moms och punktskatter → operating (Δ VAT)
+ * 29xx Upplupna kostnader/förutbetalda intäkter → operating (Δ accruals)
+ * 2510 Skatteskuld (income tax) → operating (skatt betald)
+ *
+ * 10xx-13xx Anläggningstillgångar (capital goods) → investing
+ *
+ * 20xx Eget kapital (nyemission, utdelning) → financing
+ * 23xx Långfristiga skulder (lån) → financing
+ *
+ * 19xx Kassa och bank → reconciliation (target)
+ *
+ * The reconciliation invariant: total_cash_flow MUST equal
+ * closing(19xx) - opening(19xx)
+ * within 1 öre. Any mismatch signals a bookkeeping invariant violation
+ * (e.g., journal entry posted to an account class we haven't mapped) and is
+ * surfaced as a warning in the report so a human can investigate.
+ */
+
+export type KassaflodesanalysReport = {
+ fiscal_period_id: string
+ period_start: string
+ period_end: string
+ lopande: {
+ resultat_efter_finansiella_poster: number
+ avskrivningar: number
+ ovriga_ej_kassaflodesposter: number
+ delta_kortfristiga_fordringar: number
+ delta_varulager: number
+ delta_kortfristiga_skulder: number
+ skatt_betald: number
+ total: number
+ }
+ investerings: {
+ forvarv_anlaggningar: number
+ avyttring_anlaggningar: number
+ total: number
+ }
+ finansierings: {
+ delta_lan: number
+ utdelningar: number
+ nyemission: number
+ total: number
+ }
+ total_cash_flow: number
+ reconciliation: {
+ opening_cash_1xxx: number
+ closing_cash_1xxx: number
+ delta_actual: number
+ delta_calculated: number
+ mismatch_amount: number
+ is_reconciled: boolean
+ }
+}
+
+// Normalize -0 → 0 so callers (and tests) never observe a signed zero.
+// Math.round(0 * 100) / 100 happens to be 0, but Math.round(-0.001 * 100) / 100
+// returns -0 because Math.round preserves the sign of zero.
+const r2 = (n: number) => {
+ const rounded = Math.round(n * 100) / 100
+ return rounded === 0 ? 0 : rounded
+}
+
+/**
+ * Returns the signed balance change for an account between IB and UB.
+ *
+ * For asset accounts (debit-normal): positive = increase, negative = decrease
+ * For liability/equity accounts (credit-normal): positive = increase
+ *
+ * We always compute `(closing_debit - closing_credit) - (opening_debit - opening_credit)`,
+ * which gives the signed *debit-side* movement. Callers negate as needed for
+ * credit-normal accounts.
+ */
+function debitSideDelta(row: TrialBalanceRow): number {
+ const opening = (row.opening_debit || 0) - (row.opening_credit || 0)
+ const closing = (row.closing_debit || 0) - (row.closing_credit || 0)
+ return closing - opening
+}
+
+/**
+ * Sum the debit-side delta for all accounts whose number starts with one of
+ * the given prefixes. Useful for grouping by BAS account class/range.
+ */
+function sumDeltaByPrefix(rows: TrialBalanceRow[], prefixes: string[]): number {
+ return rows
+ .filter((r) => prefixes.some((p) => r.account_number.startsWith(p)))
+ .reduce((sum, r) => sum + debitSideDelta(r), 0)
+}
+
+/**
+ * Sum *period activity* (not delta) on the debit side for the given account
+ * prefixes. Used for avskrivningar where the depreciation expense for the
+ * period is the relevant figure, not the cumulative change in the contra
+ * account (which would also reflect disposals).
+ */
+function sumPeriodDebitByPrefix(rows: TrialBalanceRow[], prefixes: string[]): number {
+ return rows
+ .filter((r) => prefixes.some((p) => r.account_number.startsWith(p)))
+ .reduce((sum, r) => sum + ((r.period_debit || 0) - (r.period_credit || 0)), 0)
+}
+
+export async function generateKassaflodesanalys(
+ supabase: SupabaseClient,
+ companyId: string,
+ fiscalPeriodId: string
+): Promise {
+ // Fetch period info for the report header.
+ const { data: period, error: periodError } = await supabase
+ .from('fiscal_periods')
+ .select('period_start, period_end')
+ .eq('id', fiscalPeriodId)
+ .eq('company_id', companyId)
+ .single()
+
+ if (periodError) throw new Error(periodError.message)
+ if (!period) throw new Error('Fiscal period not found')
+
+ // Trial balance gives us opening + closing per account for the period.
+ // We pass excludeYearEndClosing=true so that the working-capital movements
+ // reflect actual transactional activity, not the year-end reclassification
+ // entries that move resultaträkning balances into equity (8999 → 2099).
+ // Without this filter, the closing entry for class 3-8 would inflate
+ // "övriga ej-kassaflödesposter" and break the reconciliation.
+ const { rows } = await generateTrialBalance(supabase, companyId, fiscalPeriodId, {
+ excludeYearEndClosing: true,
+ })
+
+ // Net result before tax (resultat efter finansiella poster) comes from the
+ // P&L generator, which already excludes 8999 (year-end closing account)
+ // and applies the K2/K3 sign convention. We then subtract any tax expense
+ // (8910, periodiseringsfond moves, etc.) to land at *before-tax* result.
+ const incomeStatement = await generateIncomeStatement(supabase, companyId, fiscalPeriodId)
+
+ // Resultat efter finansiella poster = total_revenue - total_expenses + total_financial
+ // EXCEPT we want to keep tax (89xx) out — net_result already nets tax in.
+ // Use the same formula as net_result but without subtracting 89xx items:
+ // net_result = revenue - expenses + financial (where financial includes 89xx)
+ // We want: revenue - expenses + (financial - tax_portion)
+ //
+ // To keep this simple: scan financial_sections, separate tax (89xx) from
+ // rest, and assemble resultat efter finansiella poster.
+ // Filter ROWS by 89xx prefix (not just the section's first row) — a single
+ // section can mix tax and non-tax accounts, and the old first-row heuristic
+ // silently misclassified the rest.
+ const taxAmount = incomeStatement.financial_sections.reduce((sum, s) => {
+ const sectionTax = s.rows
+ .filter((r) => r.account_number.startsWith('89'))
+ .reduce((acc, r) => acc + r.amount, 0)
+ return sum + sectionTax
+ }, 0)
+ const nonTaxFinancial = incomeStatement.total_financial - taxAmount
+
+ const resultatEfterFinansiella = r2(
+ incomeStatement.total_revenue - incomeStatement.total_expenses + nonTaxFinancial
+ )
+
+ // ─── Löpande verksamhet ────────────────────────────────────────────────
+ // Avskrivningar (depreciation): 78xx debit movements in the period.
+ // Sign convention: depreciation is an expense that reduced result but did
+ // not consume cash, so we add it BACK to result. period_debit on 78xx is
+ // positive; we report it as a positive number to be added.
+ const avskrivningar = r2(sumPeriodDebitByPrefix(rows, ['78']))
+
+ // Övriga ej-kassaflödesposter: this category is used for non-cash items
+ // beyond depreciation (e.g., reversals of provisions, unrealized FX).
+ // v1 places it at 0 — extensions can compute it from specific account
+ // patterns. Kept in the type so the structure is stable.
+ const ovrigaEjKassaflodesposter = 0
+
+ // Δ Kortfristiga fordringar (15xx). Increase in receivables = cash NOT
+ // received yet → cash outflow → NEGATE the debit-side delta.
+ // Positive delta on a debit-normal account means asset grew → subtract.
+ const deltaKortfristigaFordringar = r2(-sumDeltaByPrefix(rows, ['15']))
+
+ // Δ Varulager (14xx). Same sign as receivables: stock grew → cash out.
+ const deltaVarulager = r2(-sumDeltaByPrefix(rows, ['14']))
+
+ // Δ Kortfristiga skulder (24xx, 26xx, 29xx) EXCLUDING tax skulder (2510).
+ // 24xx = leverantörsskulder; 26xx = moms; 29xx = upplupna kostnader.
+ // These are credit-normal accounts: increase → cash retained → ADD the
+ // credit-side delta. debitSideDelta returns the *debit*-side delta which
+ // is the inverse, so we negate.
+ //
+ // 25xx is excluded because we handle skatt separately (line below).
+ const deltaKortfristigaSkulder = r2(
+ -sumDeltaByPrefix(rows, ['24', '26', '29'])
+ )
+
+ // Skatt betald: actual cash tax outflow over the period. Approximated as
+ // the negative of the change in 2510 (income tax payable). If 2510 went
+ // down, tax was paid → negative cash flow. The current-year tax expense
+ // (8910) was already netted into resultat efter finansiella; here we only
+ // capture the cash side.
+ // Sign: 2510 is credit-normal. Decrease in liability = cash outflow.
+ // debitSideDelta(2510): if liability dropped (UB credit < IB credit),
+ // delta is positive. We want that as a negative cash flow.
+ const skattBetald = r2(-sumDeltaByPrefix(rows, ['2510']))
+
+ const totalLopande = r2(
+ resultatEfterFinansiella +
+ avskrivningar +
+ ovrigaEjKassaflodesposter +
+ deltaKortfristigaFordringar +
+ deltaVarulager +
+ deltaKortfristigaSkulder +
+ skattBetald
+ )
+
+ // ─── Investeringsverksamhet ────────────────────────────────────────────
+ // Förvärv av anläggningstillgångar: net debit movement on 10xx-13xx.
+ // An increase in fixed assets (positive debit-side delta) is a cash
+ // outflow → negate to surface as negative.
+ //
+ // We exclude accumulated-depreciation contra-asset accounts because their
+ // movement is non-cash (it's already added back to löpande as avskrivningar).
+ // Without this filter, depreciation would show up twice — once as an
+ // add-back in löpande and once as a phantom "avyttring" in investeringar —
+ // breaking the reconciliation against 19xx.
+ //
+ // Note: this naive netting can blend purchases with disposals when a
+ // disposal credits the same account. Item #2 in the plan (asset disposal)
+ // will refine this by linking disposal proceeds to specific entries; for
+ // now, the net figure is the best we can derive from balances alone.
+ const ACCUMULATED_DEPRECIATION_ACCOUNTS = [
+ '1119', // ack avskr balanserade utgifter
+ '1129', // ack avskr koncessioner
+ '1139', // ack avskr hyresrätter
+ '1149', // ack avskr goodwill
+ '1159', // ack avskr förskott immateriella
+ '1219', // ack avskr maskiner och inventarier
+ '1229', // ack avskr inventarier och verktyg
+ '1239', // ack avskr installationer
+ '1249', // ack avskr bilar
+ '1259', // ack avskr datorer
+ '1269', // ack avskr leasade tillgångar
+ '1279', // ack avskr byggn. inventarier
+ '1289', // ack avskr övriga maskiner
+ ]
+ const fixedAssetDelta = rows
+ .filter((r) => {
+ if (!['10', '11', '12', '13'].some((p) => r.account_number.startsWith(p))) return false
+ return !ACCUMULATED_DEPRECIATION_ACCOUNTS.includes(r.account_number)
+ })
+ .reduce((sum, r) => sum + debitSideDelta(r), 0)
+ const forvarv = r2(fixedAssetDelta > 0 ? -fixedAssetDelta : 0)
+ const avyttring = r2(fixedAssetDelta < 0 ? -fixedAssetDelta : 0)
+
+ const totalInvesterings = r2(forvarv + avyttring)
+
+ // ─── Finansieringsverksamhet ───────────────────────────────────────────
+ // Δ Lån (23xx — långfristiga skulder). Credit-normal: increase in loan
+ // = cash inflow → ADD credit-side delta = negate debit-side delta.
+ const deltaLan = r2(-sumDeltaByPrefix(rows, ['23']))
+
+ // Utdelningar: capture as the debit movements on 2898 (decided dividends)
+ // and 8910 isn't a dividend (it's tax). Better marker is 2091 / 2898.
+ // v1: scan for 2898 period_debit. Conservative — better to under-report
+ // than to mis-classify. Report as negative cash flow.
+ const utdelningar = r2(-sumPeriodDebitByPrefix(rows, ['2898']))
+
+ // Nyemission: increase in 20xx equity (excluding result-of-the-year and
+ // dividends). Credit-normal: positive credit-side delta = cash inflow.
+ // We sum 2081 (share capital) + 2082 (premium) deltas specifically to
+ // avoid double-counting 2099 (årets resultat is non-cash).
+ const nyemissionDebit = sumDeltaByPrefix(rows, ['2081', '2082', '2083', '2087'])
+ const nyemission = r2(-nyemissionDebit)
+
+ const totalFinansierings = r2(deltaLan + utdelningar + nyemission)
+
+ // ─── Total cash flow ───────────────────────────────────────────────────
+ const totalCashFlow = r2(totalLopande + totalInvesterings + totalFinansierings)
+
+ // ─── Reconciliation against 19xx ───────────────────────────────────────
+ const cash1xxxRows = rows.filter((r) => r.account_number.startsWith('19'))
+ const openingCash = r2(
+ cash1xxxRows.reduce(
+ (sum, r) => sum + ((r.opening_debit || 0) - (r.opening_credit || 0)),
+ 0
+ )
+ )
+ const closingCash = r2(
+ cash1xxxRows.reduce(
+ (sum, r) => sum + ((r.closing_debit || 0) - (r.closing_credit || 0)),
+ 0
+ )
+ )
+ const deltaActual = r2(closingCash - openingCash)
+ const mismatchAmount = r2(deltaActual - totalCashFlow)
+ const isReconciled = Math.abs(mismatchAmount) < 0.01
+
+ return {
+ fiscal_period_id: fiscalPeriodId,
+ period_start: period.period_start,
+ period_end: period.period_end,
+ lopande: {
+ resultat_efter_finansiella_poster: resultatEfterFinansiella,
+ avskrivningar,
+ ovriga_ej_kassaflodesposter: ovrigaEjKassaflodesposter,
+ delta_kortfristiga_fordringar: deltaKortfristigaFordringar,
+ delta_varulager: deltaVarulager,
+ delta_kortfristiga_skulder: deltaKortfristigaSkulder,
+ skatt_betald: skattBetald,
+ total: totalLopande,
+ },
+ investerings: {
+ forvarv_anlaggningar: forvarv,
+ avyttring_anlaggningar: avyttring,
+ total: totalInvesterings,
+ },
+ finansierings: {
+ delta_lan: deltaLan,
+ utdelningar,
+ nyemission,
+ total: totalFinansierings,
+ },
+ total_cash_flow: totalCashFlow,
+ reconciliation: {
+ opening_cash_1xxx: openingCash,
+ closing_cash_1xxx: closingCash,
+ delta_actual: deltaActual,
+ delta_calculated: totalCashFlow,
+ mismatch_amount: mismatchAmount,
+ is_reconciled: isReconciled,
+ },
+ }
+}
diff --git a/lib/reports/source-lines.ts b/lib/reports/source-lines.ts
new file mode 100644
index 00000000..3dcc30cc
--- /dev/null
+++ b/lib/reports/source-lines.ts
@@ -0,0 +1,93 @@
+/**
+ * Shared types for report drill-down to source vouchers.
+ *
+ * Every aggregated row (trial balance row, VAT ruta, AR customer, supplier
+ * row) can be expanded to show the underlying journal entries that
+ * contributed to it. The endpoints under
+ * `/api/reports///sources` return these in a paginated form.
+ */
+
+/**
+ * A single contributing line from a journal entry. Voucher number + series
+ * uniquely identify the verifikat, while `journal_entry_id` is the route
+ * target for `/bookkeeping/[id]`.
+ */
+export type ReportSourceLine = {
+ journal_entry_id: string
+ voucher_number: number
+ voucher_series: string
+ date: string
+ description: string
+ debit: number
+ credit: number
+}
+
+/**
+ * Lazy-fetcher signature for client-side expansion. The component calls this
+ * the first time the user expands a row.
+ */
+export type ReportSourceFetcher = () => Promise<{
+ lines: ReportSourceLine[]
+ next_cursor?: string | null
+}>
+
+/**
+ * Response envelope for the lazy-fetch endpoints. The page-level reports
+ * never include the full `lines[]` to avoid eager loading thousands of
+ * entries on a trial balance.
+ */
+export type ReportSourceResponse = {
+ account_number?: string
+ account_name?: string
+ ruta?: string
+ customer_id?: string
+ supplier_id?: string
+ lines: ReportSourceLine[]
+ next_cursor: string | null
+}
+
+/**
+ * Loader state passed to subscribers. Mirrors the React hook output so the
+ * pure (node-testable) loader can drive the same semantics as the hook.
+ */
+export interface SourceLoaderState {
+ lines: ReportSourceLine[] | null
+ loading: boolean
+ error: string | null
+}
+
+/**
+ * Pure-TS loader: encapsulates the fetch + cache + error semantics used by
+ * the React hook in `components/reports/ReportRowExpansion`. Returning a
+ * minimal subscription protocol keeps the React layer thin and testable.
+ *
+ * Repeated calls to `load()` after a successful fetch are no-ops, so toggling
+ * a row open/closed never refetches.
+ */
+export function createSourceLoader(
+ fetcher: ReportSourceFetcher,
+ onChange: (state: SourceLoaderState) => void
+) {
+ let state: SourceLoaderState = { lines: null, loading: false, error: null }
+
+ const emit = (next: Partial) => {
+ state = { ...state, ...next }
+ onChange(state)
+ }
+
+ return {
+ getState: () => state,
+ load: async () => {
+ if (state.lines !== null || state.loading) return
+ emit({ loading: true, error: null })
+ try {
+ const result = await fetcher()
+ emit({ lines: result.lines, loading: false })
+ } catch (err) {
+ const message =
+ err instanceof Error ? err.message : 'Kunde inte hämta verifikat'
+ emit({ error: message, loading: false })
+ }
+ },
+ }
+}
diff --git a/lib/reports/xlsx-export.ts b/lib/reports/xlsx-export.ts
new file mode 100644
index 00000000..1ef8c2ab
--- /dev/null
+++ b/lib/reports/xlsx-export.ts
@@ -0,0 +1,250 @@
+import * as XLSX from 'xlsx'
+
+/**
+ * Generic xlsx workbook builder for reports.
+ *
+ * The helper is intentionally declarative: callers describe one or more sheets
+ * via `SheetSpec`, each with a header row and a row mapper. Column-level number
+ * formatting hints (currency, date, integer, percent) are applied per-cell via
+ * the `z` (number format) field on the cell object.
+ *
+ * Currency format follows the Swedish accounting convention used in formatCurrency
+ * (`lib/utils.ts`): `#,##0.00 " kr"`. Dates use ISO `yyyy-mm-dd` to match
+ * `formatDate(x)`. Both align with how figures are displayed in-app.
+ *
+ * Bolding the header row would require `xlsx-style` or `cellStyles: true` which
+ * is not supported in the base `xlsx` distribution we ship. Instead we freeze
+ * the first row so the headers stay visible while scrolling — visually distinct
+ * without depending on optional packages.
+ *
+ * Column widths are computed automatically from the maximum content length per
+ * column. This keeps the produced file legible in Excel/Numbers without any
+ * post-processing by the caller.
+ */
+
+export type CellValue = string | number | Date | null | undefined
+export type ColumnFormat = 'text' | 'currency' | 'date' | 'integer' | 'percent'
+
+export interface ColumnSpec {
+ /** Human-readable header label rendered in row 1. */
+ header: string
+ /** Excel number-format hint applied to every body cell in this column. */
+ format: ColumnFormat
+}
+
+export interface SheetSpec {
+ /** Sheet tab name. Excel limits this to 31 characters; longer names are truncated. */
+ name: string
+ /** Column definitions (header + format), one per column. */
+ columns: ColumnSpec[]
+ /** Array of rows the sheet should render. */
+ rows: TRow[]
+ /**
+ * Maps a single row to an array of cell values. The returned array length
+ * must match `columns.length`. Use `null`/`undefined` for blank cells.
+ */
+ mapRow: (row: TRow) => CellValue[]
+}
+
+const CURRENCY_FORMAT = '#,##0.00 " kr"'
+const DATE_FORMAT = 'yyyy-mm-dd'
+const INTEGER_FORMAT = '#,##0'
+const PERCENT_FORMAT = '0.00%'
+
+function formatToZ(format: ColumnFormat): string | undefined {
+ switch (format) {
+ case 'currency':
+ return CURRENCY_FORMAT
+ case 'date':
+ return DATE_FORMAT
+ case 'integer':
+ return INTEGER_FORMAT
+ case 'percent':
+ return PERCENT_FORMAT
+ default:
+ return undefined
+ }
+}
+
+/**
+ * Compute display length for column-width sizing. For numbers and dates we
+ * approximate the formatted width (currency picks up the " kr" suffix; dates
+ * are always 10 chars; integers add thousand separators). For strings we use
+ * actual length. Null/undefined cells contribute 0.
+ */
+function displayLength(value: CellValue, format: ColumnFormat): number {
+ if (value === null || value === undefined) return 0
+ if (value instanceof Date) return 10 // yyyy-mm-dd
+ if (typeof value === 'number') {
+ switch (format) {
+ case 'currency': {
+ // "12 345 678,90 kr" ≈ integer-with-thousands + 6 (decimals, separator, suffix)
+ const formatted = Math.abs(value)
+ .toFixed(2)
+ .replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
+ return formatted.length + 3 + (value < 0 ? 1 : 0) // +3 for " kr"
+ }
+ case 'integer': {
+ const formatted = Math.round(value).toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ')
+ return formatted.length + (value < 0 ? 1 : 0)
+ }
+ case 'percent':
+ return (value * 100).toFixed(2).length + 1
+ default:
+ return value.toString().length
+ }
+ }
+ return String(value).length
+}
+
+/**
+ * Build a workbook buffer from one or more sheet specs.
+ *
+ * @returns A Node Buffer containing the serialized xlsx file.
+ */
+// The generic `_T` is preserved for source-compatibility with callers that
+// pass an explicit type argument (e.g. `reportToWorkbook([...])`).
+// Internally we accept heterogeneous sheet types via `SheetSpec` because
+// TypeScript cannot unify multiple sheets with different row types via a
+// single type parameter. Per-sheet type safety still applies inside each
+// `SheetSpec` declaration.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unused-vars
+export function reportToWorkbook<_T = unknown>(spec: ReadonlyArray>): Buffer {
+ if (spec.length === 0) {
+ throw new Error('reportToWorkbook: at least one sheet spec is required')
+ }
+
+ const workbook = XLSX.utils.book_new()
+
+ for (const sheet of spec) {
+ // Build AOA (array of arrays): row 0 = headers, rows 1..n = body.
+ const headerRow = sheet.columns.map((c) => c.header)
+ const bodyRows = sheet.rows.map((row) => {
+ const mapped = sheet.mapRow(row)
+ if (mapped.length !== sheet.columns.length) {
+ throw new Error(
+ `reportToWorkbook: row length ${mapped.length} does not match column count ${sheet.columns.length} on sheet "${sheet.name}"`,
+ )
+ }
+ return mapped.map((v) => (v === undefined ? null : v))
+ })
+
+ const aoa: CellValue[][] = [headerRow, ...bodyRows]
+ // `cellDates: true` tells xlsx to write Date values as Excel date cells
+ // (type 'd', not type 'n'), so callers can use native Date objects and get
+ // real date typing in the file.
+ const worksheet = XLSX.utils.aoa_to_sheet(aoa as unknown[][], { cellDates: true })
+
+ // Apply per-column number format on body cells (skip header row at r=0).
+ if (bodyRows.length > 0) {
+ for (let colIdx = 0; colIdx < sheet.columns.length; colIdx++) {
+ const fmt = formatToZ(sheet.columns[colIdx].format)
+ if (!fmt) continue
+ for (let rowIdx = 1; rowIdx <= bodyRows.length; rowIdx++) {
+ const ref = XLSX.utils.encode_cell({ r: rowIdx, c: colIdx })
+ const cell = worksheet[ref]
+ if (cell) {
+ cell.z = fmt
+ }
+ }
+ }
+ }
+
+ // Auto-size columns based on max content length per column. Header counts
+ // too — a short numeric column with a long header still needs to fit the
+ // label. Min 8, max 60 chars to avoid degenerate widths.
+ const colWidths = sheet.columns.map((col, colIdx) => {
+ let maxLen = col.header.length
+ for (const row of bodyRows) {
+ const cellValue = row[colIdx]
+ const len = displayLength(cellValue as CellValue, col.format)
+ if (len > maxLen) maxLen = len
+ }
+ const width = Math.min(Math.max(maxLen + 2, 8), 60)
+ return { wch: width }
+ })
+ worksheet['!cols'] = colWidths
+
+ // Freeze the header row so users can scroll the body without losing
+ // column titles. Compensates for not being able to bold them in base xlsx.
+ worksheet['!freeze'] = { xSplit: 0, ySplit: 1 }
+ // Excel format: top row stays visible
+ worksheet['!views'] = [{ state: 'frozen', ySplit: 1 }]
+
+ // Truncate sheet name to Excel's 31-char limit.
+ const truncatedName = sheet.name.length > 31 ? sheet.name.slice(0, 31) : sheet.name
+ XLSX.utils.book_append_sheet(workbook, worksheet, truncatedName)
+ }
+
+ // `XLSX.write` with `type: 'buffer'` returns a Node Buffer.
+ const out = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer
+ return out
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Column helpers — small declarative builders so route files read cleanly.
+// ─────────────────────────────────────────────────────────────────────────────
+
+export function textColumn(header: string): ColumnSpec {
+ return { header, format: 'text' }
+}
+
+export function currencyColumn(header: string): ColumnSpec {
+ return { header, format: 'currency' }
+}
+
+export function dateColumn(header: string): ColumnSpec {
+ return { header, format: 'date' }
+}
+
+export function integerColumn(header: string): ColumnSpec {
+ return { header, format: 'integer' }
+}
+
+export function percentColumn(header: string): ColumnSpec {
+ return { header, format: 'percent' }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Filename helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Slugify a company name for use in download filenames.
+ *
+ * - Lowercases everything.
+ * - Replaces Swedish characters (åäö) with their ASCII fallbacks.
+ * - Strips anything that's not alphanumeric.
+ * - Collapses runs of separators to a single dash and trims edges.
+ * - Returns `'foretag'` if the input slugifies to empty (e.g. only emoji).
+ */
+export function slugifyCompanyName(name: string): string {
+ if (!name) return 'foretag'
+ const lowered = name.toLowerCase()
+ const ascii = lowered
+ .replace(/å/g, 'a')
+ .replace(/ä/g, 'a')
+ .replace(/ö/g, 'o')
+ .replace(/é/g, 'e')
+ .replace(/è/g, 'e')
+ .replace(/ü/g, 'u')
+ .replace(/ß/g, 'ss')
+ const slug = ascii
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ return slug.length > 0 ? slug : 'foretag'
+}
+
+/**
+ * Build a filename in the form `--.xlsx`.
+ *
+ * @param reportSlug Static report identifier (e.g. `"trial-balance"`)
+ * @param companyName Raw company name (will be slugified)
+ * @param period ISO date string (`YYYY-MM-DD`); date separators are stripped
+ */
+export function xlsxFilename(reportSlug: string, companyName: string, period: string): string {
+ const companySlug = slugifyCompanyName(companyName)
+ const periodCompact = (period || '').replace(/-/g, '')
+ const parts = [reportSlug, companySlug, periodCompact].filter(Boolean)
+ return `${parts.join('-')}.xlsx`
+}
diff --git a/lib/salary/__tests__/calculation-engine.test.ts b/lib/salary/__tests__/calculation-engine.test.ts
index 51f8849c..709c1e46 100644
--- a/lib/salary/__tests__/calculation-engine.test.ts
+++ b/lib/salary/__tests__/calculation-engine.test.ts
@@ -1005,3 +1005,63 @@ describe('calculateSalary — youth cap', () => {
expect(result.avgifterCategory).toBe('youth')
})
})
+
+describe('calculateSalary — shift premiums (OB-tillägg och övertid)', () => {
+ it('treats ob_weekend as an addition to gross salary', () => {
+ const result = calculateSalary(
+ makeBasicInput({
+ monthlySalary: 40000,
+ lineItems: [
+ baseLineItem(40000),
+ lineItem({
+ itemType: 'ob_weekend',
+ amount: 660,
+ isTaxable: true,
+ isAvgiftBasis: true,
+ isVacationBasis: true,
+ }),
+ ],
+ }),
+ config2026,
+ emptyTaxRates,
+ )
+ // Gross = base 40000 + 660 OB
+ expect(result.grossSalary).toBe(40660)
+ // Avgifter basis includes the OB amount
+ expect(result.avgifterBasis).toBe(40660)
+ })
+
+ it('overtime_50 + ob_night flow into additions step', () => {
+ const result = calculateSalary(
+ makeBasicInput({
+ monthlySalary: 40000,
+ lineItems: [
+ baseLineItem(40000),
+ lineItem({
+ itemType: 'overtime_50',
+ amount: 1500,
+ isTaxable: true,
+ isAvgiftBasis: true,
+ isVacationBasis: true,
+ }),
+ lineItem({
+ itemType: 'ob_night',
+ amount: 900,
+ isTaxable: true,
+ isAvgiftBasis: true,
+ isVacationBasis: true,
+ }),
+ ],
+ }),
+ config2026,
+ emptyTaxRates,
+ )
+ // Gross = 40000 + 1500 + 900 = 42400
+ expect(result.grossSalary).toBe(42400)
+ expect(result.avgifterBasis).toBe(42400)
+ // Should label step as additions including OB
+ const additionStep = result.steps.find((s) => s.label.includes('Tillägg'))
+ expect(additionStep).toBeDefined()
+ expect(additionStep?.output).toBe(2400)
+ })
+})
diff --git a/lib/salary/__tests__/shift-premium-engine.test.ts b/lib/salary/__tests__/shift-premium-engine.test.ts
new file mode 100644
index 00000000..fb919f00
--- /dev/null
+++ b/lib/salary/__tests__/shift-premium-engine.test.ts
@@ -0,0 +1,417 @@
+import { describe, it, expect } from 'vitest'
+import { computePremiumLines } from '../shift-premium-engine'
+import type { ShiftPremiumRule } from '@/types'
+
+const baseRule = (overrides: Partial = {}): ShiftPremiumRule => ({
+ id: 'rule-1',
+ company_id: 'co-1',
+ name: '',
+ applies_to_all_employees: true,
+ applies_to_employee_ids: [],
+ day_of_week: [1, 2, 3, 4, 5, 6, 7],
+ start_time: '00:00',
+ end_time: '00:00',
+ premium_percent: 25,
+ item_type: 'ob_weekday_evening',
+ priority: 0,
+ is_active: true,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ created_by: null,
+ ...overrides,
+})
+
+const r2 = (n: number) => Math.round(n * 100) / 100
+
+describe('computePremiumLines — basic semantics', () => {
+ it('returns no lines when there are no rules', () => {
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 200,
+ workedDays: [{ work_date: '2026-05-25', hours: 8, start_time: '09:00', end_time: '17:00' }],
+ rules: [],
+ })
+ expect(result).toEqual([])
+ })
+
+ it('returns no lines when there are no worked days', () => {
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 200,
+ workedDays: [],
+ rules: [baseRule()],
+ })
+ expect(result).toEqual([])
+ })
+
+ it('standard weekday 09:00-17:00 with no matching rule yields no premium', () => {
+ const eveningRule = baseRule({
+ id: 'r-evening',
+ day_of_week: [1, 2, 3, 4, 5],
+ start_time: '18:00',
+ end_time: '22:00',
+ premium_percent: 25,
+ item_type: 'ob_weekday_evening',
+ })
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 200,
+ // 2026-05-25 is a Monday
+ workedDays: [{ work_date: '2026-05-25', hours: 8, start_time: '09:00', end_time: '17:00' }],
+ rules: [eveningRule],
+ })
+ expect(result).toEqual([])
+ })
+})
+
+describe('computePremiumLines — weekend rule', () => {
+ it('Saturday 06:00-22:00 with 33% rule generates 8h × 33% premium', () => {
+ const weekendRule = baseRule({
+ id: 'r-weekend',
+ name: 'Lördag',
+ day_of_week: [6],
+ start_time: '06:00',
+ end_time: '22:00',
+ premium_percent: 33,
+ item_type: 'ob_weekend',
+ })
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 250,
+ // 2026-05-23 = Saturday (Sat 23 May 2026)
+ workedDays: [{ work_date: '2026-05-23', hours: 8, start_time: '08:00', end_time: '16:00' }],
+ rules: [weekendRule],
+ })
+ expect(result).toHaveLength(1)
+ expect(result[0].itemType).toBe('ob_weekend')
+ expect(result[0].hours).toBe(8)
+ // 250 × 8 × 0.33 = 660
+ expect(result[0].amount).toBe(r2(250 * 8 * 0.33))
+ expect(result[0].sourceRuleId).toBe('r-weekend')
+ })
+
+ it('Sunday full-day with 100% rule pays full premium', () => {
+ const sundayRule = baseRule({
+ id: 'r-sunday',
+ day_of_week: [7],
+ start_time: '00:00',
+ end_time: '00:00', // full-day special-case (00:00 to 00:00 = 24h)
+ premium_percent: 100,
+ item_type: 'ob_holiday',
+ })
+ // 2026-05-24 = Sunday
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 200,
+ workedDays: [{ work_date: '2026-05-24', hours: 8, start_time: '09:00', end_time: '17:00' }],
+ rules: [sundayRule],
+ })
+ expect(result).toHaveLength(1)
+ expect(result[0].itemType).toBe('ob_holiday')
+ expect(result[0].hours).toBe(8)
+ expect(result[0].amount).toBe(r2(200 * 8 * 1.0))
+ })
+})
+
+describe('computePremiumLines — holiday gating for ob_holiday', () => {
+ it('ob_holiday fires on Midsommarafton 2025 (Friday, June 20) but not on a regular Sunday', () => {
+ const holidayRule = baseRule({
+ id: 'r-holiday',
+ name: 'OB helgdag',
+ day_of_week: [1, 2, 3, 4, 5, 6, 7], // all days — gating is by holiday calendar, not weekday
+ start_time: '00:00',
+ end_time: '00:00', // full 24h window
+ premium_percent: 100,
+ item_type: 'ob_holiday',
+ })
+
+ // Midsommarafton 2025 is Friday 20 June — a Swedish public holiday despite being a weekday.
+ const midsommarafton = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 250,
+ workedDays: [{ work_date: '2025-06-20', hours: 8, start_time: '08:00', end_time: '16:00' }],
+ rules: [holidayRule],
+ })
+ expect(midsommarafton).toHaveLength(1)
+ expect(midsommarafton[0].itemType).toBe('ob_holiday')
+ expect(midsommarafton[0].hours).toBe(8)
+ expect(midsommarafton[0].amount).toBe(r2(250 * 8 * 1.0))
+
+ // A regular Sunday — 2026-05-31 is a plain Sunday (after Pingstdagen on the 24th), not a holiday.
+ // The same rule must NOT fire because day_of_week matching alone is not enough for ob_holiday.
+ const regularSunday = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 250,
+ workedDays: [{ work_date: '2026-05-31', hours: 8, start_time: '08:00', end_time: '16:00' }],
+ rules: [holidayRule],
+ })
+ expect(regularSunday).toEqual([])
+ })
+})
+
+describe('computePremiumLines — night shift crossing midnight', () => {
+ it('22:00-06:00 night rule covers both halves correctly', () => {
+ const nightRule = baseRule({
+ id: 'r-night',
+ day_of_week: [1, 2, 3, 4, 5, 6, 7],
+ start_time: '22:00',
+ end_time: '06:00',
+ premium_percent: 50,
+ item_type: 'ob_night',
+ })
+ // Shift 22:00-06:00 on Monday → wraps into Tuesday
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 300,
+ workedDays: [{ work_date: '2026-05-25', hours: 8, start_time: '22:00', end_time: '06:00' }],
+ rules: [nightRule],
+ })
+ expect(result).toHaveLength(1)
+ expect(result[0].hours).toBe(8)
+ // 300 × 8 × 0.5 = 1200
+ expect(result[0].amount).toBe(r2(300 * 8 * 0.5))
+ })
+
+ it('shift 18:00-04:00 with night rule 22:00-06:00 only awards 6h (22-04)', () => {
+ const nightRule = baseRule({
+ id: 'r-night',
+ day_of_week: [1, 2, 3, 4, 5, 6, 7],
+ start_time: '22:00',
+ end_time: '06:00',
+ premium_percent: 50,
+ item_type: 'ob_night',
+ })
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 200,
+ workedDays: [{ work_date: '2026-05-25', hours: 10, start_time: '18:00', end_time: '04:00' }],
+ rules: [nightRule],
+ })
+ expect(result).toHaveLength(1)
+ // 22:00→24:00 = 2h, 00:00→04:00 = 4h, total 6h
+ expect(result[0].hours).toBe(6)
+ expect(result[0].amount).toBe(r2(200 * 6 * 0.5))
+ })
+})
+
+describe('computePremiumLines — partial overlap', () => {
+ it('weekday rule 18:00-22:00 with shift 16:00-22:00 generates 4h premium', () => {
+ const eveningRule = baseRule({
+ id: 'r-eve',
+ day_of_week: [1, 2, 3, 4, 5],
+ start_time: '18:00',
+ end_time: '22:00',
+ premium_percent: 25,
+ item_type: 'ob_weekday_evening',
+ })
+ // 2026-05-25 = Monday
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 250,
+ workedDays: [{ work_date: '2026-05-25', hours: 6, start_time: '16:00', end_time: '22:00' }],
+ rules: [eveningRule],
+ })
+ expect(result).toHaveLength(1)
+ expect(result[0].hours).toBe(4)
+ expect(result[0].amount).toBe(r2(250 * 4 * 0.25))
+ })
+})
+
+describe('computePremiumLines — overlapping rules', () => {
+ it('priority wins when two rules cover the same interval', () => {
+ const lowPriority = baseRule({
+ id: 'r-low',
+ day_of_week: [6],
+ start_time: '08:00',
+ end_time: '18:00',
+ premium_percent: 33,
+ item_type: 'ob_weekend',
+ priority: 0,
+ })
+ const highPriority = baseRule({
+ id: 'r-high',
+ day_of_week: [6],
+ start_time: '10:00',
+ end_time: '14:00',
+ premium_percent: 100,
+ item_type: 'ob_holiday',
+ priority: 10,
+ })
+ // 2026-10-31 = Saturday AND Alla helgons dag → both ob_weekend and ob_holiday rules are eligible.
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 200,
+ workedDays: [{ work_date: '2026-10-31', hours: 10, start_time: '08:00', end_time: '18:00' }],
+ rules: [lowPriority, highPriority],
+ })
+ // Expect: 6h low (08-10 + 14-18) + 4h high (10-14).
+ expect(result).toHaveLength(2)
+ const low = result.find((r) => r.sourceRuleId === 'r-low')
+ const high = result.find((r) => r.sourceRuleId === 'r-high')
+ expect(low?.hours).toBe(6)
+ expect(high?.hours).toBe(4)
+ // Total premium hours = 6 + 4 = 10 (no double count)
+ expect((low?.hours ?? 0) + (high?.hours ?? 0)).toBe(10)
+ })
+
+ it('ties broken by higher premium_percent when priority equal', () => {
+ const a = baseRule({
+ id: 'r-a',
+ day_of_week: [6],
+ start_time: '08:00',
+ end_time: '18:00',
+ premium_percent: 33,
+ item_type: 'ob_weekend',
+ priority: 0,
+ })
+ const b = baseRule({
+ id: 'r-b',
+ day_of_week: [6],
+ start_time: '08:00',
+ end_time: '18:00',
+ premium_percent: 50,
+ item_type: 'ob_weekend',
+ priority: 0,
+ })
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 200,
+ workedDays: [{ work_date: '2026-05-23', hours: 10, start_time: '08:00', end_time: '18:00' }],
+ rules: [a, b],
+ })
+ expect(result).toHaveLength(1)
+ expect(result[0].sourceRuleId).toBe('r-b')
+ expect(result[0].hours).toBe(10)
+ })
+})
+
+describe('computePremiumLines — hours-only fallback', () => {
+ it('hours-only worked day uses default 08:00-17:00 — only weekday-daytime rules match', () => {
+ const eveningRule = baseRule({
+ id: 'r-eve',
+ day_of_week: [1, 2, 3, 4, 5],
+ start_time: '18:00',
+ end_time: '22:00',
+ premium_percent: 25,
+ item_type: 'ob_weekday_evening',
+ })
+ const middayRule = baseRule({
+ id: 'r-day',
+ day_of_week: [1, 2, 3, 4, 5],
+ start_time: '10:00',
+ end_time: '14:00',
+ premium_percent: 10,
+ item_type: 'ob_weekday_evening',
+ })
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 200,
+ workedDays: [{ work_date: '2026-05-25', hours: 8 }], // no times → default 08-17
+ rules: [eveningRule, middayRule],
+ })
+ // Evening rule must NOT match (18-22 outside 08-17). Mid-day rule matches 10-14.
+ expect(result).toHaveLength(1)
+ expect(result[0].sourceRuleId).toBe('r-day')
+ expect(result[0].hours).toBe(4)
+ })
+})
+
+describe('computePremiumLines — employee filtering', () => {
+ it('employee-specific rule does not apply to other employees', () => {
+ const rule = baseRule({
+ id: 'r-specific',
+ applies_to_all_employees: false,
+ applies_to_employee_ids: ['emp-2'],
+ day_of_week: [6],
+ start_time: '06:00',
+ end_time: '22:00',
+ premium_percent: 33,
+ item_type: 'ob_weekend',
+ })
+ const wrongEmp = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 200,
+ workedDays: [{ work_date: '2026-05-23', hours: 8, start_time: '09:00', end_time: '17:00' }],
+ rules: [rule],
+ })
+ expect(wrongEmp).toEqual([])
+
+ const rightEmp = computePremiumLines({
+ employeeId: 'emp-2',
+ baseHourlyRate: 200,
+ workedDays: [{ work_date: '2026-05-23', hours: 8, start_time: '09:00', end_time: '17:00' }],
+ rules: [rule],
+ })
+ expect(rightEmp).toHaveLength(1)
+ expect(rightEmp[0].hours).toBe(8)
+ })
+
+ it('inactive rules are ignored', () => {
+ const rule = baseRule({
+ id: 'r-inactive',
+ day_of_week: [6],
+ start_time: '06:00',
+ end_time: '22:00',
+ premium_percent: 33,
+ item_type: 'ob_weekend',
+ is_active: false,
+ })
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 200,
+ workedDays: [{ work_date: '2026-05-23', hours: 8, start_time: '09:00', end_time: '17:00' }],
+ rules: [rule],
+ })
+ expect(result).toEqual([])
+ })
+})
+
+describe('computePremiumLines — multiple worked days', () => {
+ it('emits one line per (workDate × winning rule)', () => {
+ const weekendRule = baseRule({
+ id: 'r-weekend',
+ day_of_week: [6, 7],
+ start_time: '00:00',
+ end_time: '00:00',
+ premium_percent: 50,
+ item_type: 'ob_weekend',
+ })
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 200,
+ workedDays: [
+ { work_date: '2026-05-23', hours: 8, start_time: '08:00', end_time: '16:00' },
+ { work_date: '2026-05-24', hours: 8, start_time: '08:00', end_time: '16:00' },
+ ],
+ rules: [weekendRule],
+ })
+ expect(result).toHaveLength(2)
+ expect(result[0].workDate).toBe('2026-05-23')
+ expect(result[1].workDate).toBe('2026-05-24')
+ expect(result.every((r) => r.hours === 8)).toBe(true)
+ })
+})
+
+describe('computePremiumLines — invalid inputs', () => {
+ it('zero hourly rate yields no premium', () => {
+ const rule = baseRule({ day_of_week: [6], start_time: '06:00', end_time: '22:00', item_type: 'ob_weekend' })
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 0,
+ workedDays: [{ work_date: '2026-05-23', hours: 8, start_time: '08:00', end_time: '16:00' }],
+ rules: [rule],
+ })
+ expect(result).toEqual([])
+ })
+
+ it('worked day with zero hours is ignored', () => {
+ const rule = baseRule({ day_of_week: [6], start_time: '06:00', end_time: '22:00', item_type: 'ob_weekend' })
+ const result = computePremiumLines({
+ employeeId: 'emp-1',
+ baseHourlyRate: 200,
+ workedDays: [{ work_date: '2026-05-23', hours: 0, start_time: '08:00', end_time: '16:00' }],
+ rules: [rule],
+ })
+ expect(result).toEqual([])
+ })
+})
diff --git a/lib/salary/account-mapping.ts b/lib/salary/account-mapping.ts
index 3b737a9a..79829359 100644
--- a/lib/salary/account-mapping.ts
+++ b/lib/salary/account-mapping.ts
@@ -11,6 +11,14 @@ const LINE_ITEM_ACCOUNTS: Record = {
monthly_salary: '7210',
hourly_salary: '7210',
overtime: '7210',
+ overtime_50: '7210',
+ overtime_100: '7210',
+ // OB-tillägg — bookat på samma lönekonto som grundlönen; differentieras via
+ // rad-text på verifikatet och lönespecifikationen.
+ ob_weekday_evening: '7210',
+ ob_weekend: '7210',
+ ob_night: '7210',
+ ob_holiday: '7210',
bonus: '7210',
commission: '7210',
// Gross deductions
diff --git a/lib/salary/calculation-engine.ts b/lib/salary/calculation-engine.ts
index cec9a6e0..db8bf061 100644
--- a/lib/salary/calculation-engine.ts
+++ b/lib/salary/calculation-engine.ts
@@ -182,13 +182,21 @@ export function calculateSalary(
}
// ─── Step 2: Add additions ───
+ // OB-tillägg + tiered overtime are treated as additions to gross salary on
+ // top of the base salary. They were already computed in cash terms by the
+ // shift-premium engine before the calc engine ran, so we just sum them in.
+ const ADDITION_TYPES: SalaryLineItemType[] = [
+ 'overtime', 'overtime_50', 'overtime_100',
+ 'ob_weekday_evening', 'ob_weekend', 'ob_night', 'ob_holiday',
+ 'bonus', 'commission',
+ ]
const additions = input.lineItems.filter(
- li => ['overtime', 'bonus', 'commission'].includes(li.itemType) && li.amount > 0
+ li => ADDITION_TYPES.includes(li.itemType) && li.amount > 0
)
const totalAdditions = r(additions.reduce((sum, li) => sum + li.amount, 0))
if (totalAdditions > 0) {
steps.push({
- label: 'Tillägg (övertid, bonus, provision)',
+ label: 'Tillägg (övertid, OB, bonus, provision)',
formula: 'summa tillägg',
input: { count: additions.length },
output: totalAdditions,
diff --git a/lib/salary/run-calculation.ts b/lib/salary/run-calculation.ts
index 8c162399..f14c8a97 100644
--- a/lib/salary/run-calculation.ts
+++ b/lib/salary/run-calculation.ts
@@ -31,8 +31,10 @@ import { loadPayrollConfig, serializePayrollConfig } from './payroll-config'
import { fetchAllTaxTableRatesForRun, TaxTableUnavailableError } from './tax-tables'
import { loadAndDeriveAbsence } from './derive-absence-line-items'
import { getLineItemAccount } from './account-mapping'
+import { computePremiumLines } from './shift-premium-engine'
+import type { WorkedDayShift } from './shift-premium-engine'
import type { Logger } from '@/lib/logger'
-import type { SalaryLineItemType } from '@/types'
+import type { SalaryLineItemType, ShiftPremiumRule, ShiftPremiumItemType } from '@/types'
/** Item types that the calculator derives from per-day absence records. */
const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [
@@ -43,6 +45,38 @@ const DERIVED_ABSENCE_TYPES: SalaryLineItemType[] = [
'parental_leave',
]
+/**
+ * Item types that the calculator derives from shift_premium_rules + worked
+ * days. These are wiped at the start of each per-employee pass and
+ * regenerated so the displayed line items always match the latest rules.
+ */
+const DERIVED_PREMIUM_TYPES: ShiftPremiumItemType[] = [
+ 'overtime_50',
+ 'overtime_100',
+ 'ob_weekday_evening',
+ 'ob_weekend',
+ 'ob_night',
+ 'ob_holiday',
+]
+
+/**
+ * Effective hourly rate used as the base for shift-premium computation.
+ * - Hourly employees: their stored hourly_rate.
+ * - Monthly employees: monthly_salary / 173 (common Swedish derivation for
+ * full-time monthly → hourly, matches the timlön conventions used in
+ * CBAs). Applied even to part-timers since the engine multiplies by
+ * actually-worked premium hours.
+ */
+function effectiveHourlyRate(emp: {
+ salary_type: 'monthly' | 'hourly'
+ hourly_rate: number | null
+ monthly_salary: number | null
+}): number {
+ if (emp.salary_type === 'hourly') return emp.hourly_rate || 0
+ const monthly = emp.monthly_salary || 0
+ return monthly > 0 ? Math.round((monthly / 173) * 100) / 100 : 0
+}
+
/** Benefit-type → line-item-type mapping for the derived benefit rows. */
const BENEFIT_TYPE_TO_LINE_ITEM: Record = {
bike: 'benefit_bike',
@@ -224,6 +258,19 @@ export async function runSalaryCalculation(
const periodEndDate = new Date(Date.UTC(periodYear, periodMonth, 0)) // last day of month
const periodEnd = periodEndDate.toISOString().slice(0, 10)
+ // 7b. Load active shift_premium_rules once per run. Filtered by company.
+ // Inactive rules excluded — the engine also re-checks, but this saves
+ // network bytes for companies with many archived rules.
+ const { data: premiumRulesRaw, error: rulesError } = await supabase
+ .from('shift_premium_rules')
+ .select('*')
+ .eq('company_id', companyId)
+ .eq('is_active', true)
+ if (rulesError) {
+ return { ok: false, code: 'DATABASE_ERROR', details: rulesError }
+ }
+ const premiumRules = (premiumRulesRaw ?? []) as ShiftPremiumRule[]
+
// Per-run aggregates collected during the loop.
let totalGross = 0
let totalTax = 0
@@ -254,11 +301,14 @@ export async function runSalaryCalculation(
})
// 8b. For hourly employees, derive worked hours from the calendar.
+ // For all employees (when premium rules exist), the same rows feed
+ // the shift-premium engine in 8z below.
let derivedHoursWorked: number | null = null
- if (emp.salary_type === 'hourly') {
+ let workedDayRows: Array<{ work_date: string; hours: number; start_time: string | null; end_time: string | null }> = []
+ if (emp.salary_type === 'hourly' || premiumRules.length > 0) {
const { data: workedDays, error: workedError } = await supabase
.from('salary_worked_days')
- .select('hours')
+ .select('hours, work_date, start_time, end_time')
.eq('company_id', companyId)
.eq('employee_id', emp.id)
.gte('work_date', periodStart)
@@ -266,7 +316,10 @@ export async function runSalaryCalculation(
if (workedError) {
return { ok: false, code: 'DATABASE_ERROR', details: workedError }
}
- derivedHoursWorked = (workedDays ?? []).reduce(
+ workedDayRows = (workedDays ?? []) as typeof workedDayRows
+ }
+ if (emp.salary_type === 'hourly') {
+ derivedHoursWorked = workedDayRows.reduce(
(sum, d) => Math.round((sum + Number(d.hours)) * 100) / 100,
0,
)
@@ -274,7 +327,7 @@ export async function runSalaryCalculation(
employeeId: emp.id,
periodStart,
periodEnd,
- rowCount: workedDays?.length ?? 0,
+ rowCount: workedDayRows.length,
derivedHoursWorked,
})
@@ -395,10 +448,86 @@ export async function runSalaryCalculation(
}
}
+ // 8d2. Derive shift-premium rows (OB-tillägg, övertid 50/100). The engine
+ // consumes start_time/end_time when present; rows without explicit
+ // times fall back to a default 08:00-17:00 shift (no pure-night/
+ // pure-weekend rules trigger for those days). The premium rate is
+ // applied to the employee's effectiveHourlyRate so monthly
+ // employees still get OB by deriving an hourly rate as
+ // monthly_salary / 173.
+ const { error: delPremiumErr } = await supabase
+ .from('salary_line_items')
+ .delete()
+ .eq('salary_run_employee_id', sre.id)
+ .in('item_type', DERIVED_PREMIUM_TYPES as unknown as string[])
+ if (delPremiumErr) {
+ return { ok: false, code: 'DATABASE_ERROR', details: delPremiumErr }
+ }
+
+ let derivedPremiumRows: Array<{
+ salary_run_employee_id: string
+ company_id: string
+ item_type: ShiftPremiumItemType
+ description: string
+ quantity: number
+ amount: number
+ is_taxable: boolean
+ is_avgift_basis: boolean
+ is_vacation_basis: boolean
+ is_gross_deduction: boolean
+ is_net_deduction: boolean
+ account_number: string
+ sort_order: number
+ }> = []
+
+ if (premiumRules.length > 0 && workedDayRows.length > 0) {
+ const baseHourlyRate = effectiveHourlyRate({
+ salary_type: emp.salary_type,
+ hourly_rate: emp.hourly_rate,
+ monthly_salary: emp.monthly_salary,
+ })
+ const shifts: WorkedDayShift[] = workedDayRows.map((row) => ({
+ work_date: row.work_date,
+ hours: Number(row.hours),
+ start_time: row.start_time,
+ end_time: row.end_time,
+ }))
+ const premiumLines = computePremiumLines({
+ employeeId: emp.id,
+ baseHourlyRate,
+ workedDays: shifts,
+ rules: premiumRules,
+ })
+ derivedPremiumRows = premiumLines.map((line, idx) => ({
+ salary_run_employee_id: sre.id,
+ company_id: companyId,
+ item_type: line.itemType,
+ description: line.description,
+ quantity: line.hours,
+ amount: line.amount,
+ is_taxable: true,
+ is_avgift_basis: true,
+ is_vacation_basis: true,
+ is_gross_deduction: false,
+ is_net_deduction: false,
+ account_number: getLineItemAccount(line.itemType, emp.employment_type),
+ sort_order: 300 + idx,
+ }))
+ if (derivedPremiumRows.length > 0) {
+ const { error: insPremiumErr } = await supabase
+ .from('salary_line_items')
+ .insert(derivedPremiumRows)
+ if (insPremiumErr) {
+ return { ok: false, code: 'DATABASE_ERROR', details: insPremiumErr }
+ }
+ }
+ }
+
// 8e. Assemble the in-memory line item set fed to calculateSalary.
const manualLineItems = (sre.line_items || [])
.filter((li: Record) => {
if (DERIVED_ABSENCE_TYPES.includes(li.item_type as SalaryLineItemType)) return false
+ if (DERIVED_PREMIUM_TYPES.includes(li.item_type as ShiftPremiumItemType)) return false
if (li.source_benefit_id) return false
if (li.item_type === 'semesterersattning') return false
return true
@@ -430,7 +559,16 @@ export async function runSalaryCalculation(
isGrossDeduction: false,
isNetDeduction: false,
}))
- const lineItems = [...manualLineItems, ...derivedLineItems, ...derivedBenefitLineItems]
+ const derivedPremiumLineItems = derivedPremiumRows.map((row) => ({
+ itemType: row.item_type as SalaryLineItemType,
+ amount: row.amount,
+ isTaxable: true,
+ isAvgiftBasis: true,
+ isVacationBasis: true,
+ isGrossDeduction: false,
+ isNetDeduction: false,
+ }))
+ const lineItems = [...manualLineItems, ...derivedLineItems, ...derivedBenefitLineItems, ...derivedPremiumLineItems]
// 8f. Run the engine for this employee.
const result = calculateSalary(
diff --git a/lib/salary/shift-premium-engine.ts b/lib/salary/shift-premium-engine.ts
new file mode 100644
index 00000000..ceac3887
--- /dev/null
+++ b/lib/salary/shift-premium-engine.ts
@@ -0,0 +1,319 @@
+/**
+ * Shift-premium engine — pure functions that turn worked-day shifts plus
+ * configured premium rules into salary line items.
+ *
+ * Boundary rules:
+ * - Worked-day rows with explicit start_time/end_time use the exact window
+ * to intersect rule windows.
+ * - Worked-day rows missing one or both times fall back to a default day
+ * shift of 08:00-17:00 (`DEFAULT_SHIFT_*`). This is the legacy hours-only
+ * row shape: those days were always assumed to be plain weekday office
+ * hours, so pure-night/pure-weekend rules will not match.
+ * - Windows where end_time <= start_time are treated as wrapping past
+ * midnight (e.g. 22:00-06:00 covers 22:00-24:00 the same day and
+ * 00:00-06:00 the next day). Both halves are intersected separately.
+ *
+ * Overlap resolution:
+ * - Multiple active rules may apply to the same minute of a shift. The
+ * engine prefers the rule with the higher `priority`; ties are broken by
+ * higher `premium_percent`. Each minute is awarded to exactly one rule,
+ * so totals never double-count.
+ *
+ * The engine is intentionally side-effect-free. The orchestrator
+ * (run-calculation.ts) fetches the rules, runs `computePremiumLines`, and
+ * persists the result as salary_line_items.
+ */
+
+import type { ShiftPremiumRule, ShiftPremiumItemType } from '@/types'
+import { isSwedishHolidayISO } from '@/lib/tax/swedish-holidays'
+
+// ============================================================
+// Types
+// ============================================================
+
+export interface WorkedDayShift {
+ /** ISO date (YYYY-MM-DD). */
+ work_date: string
+ /** Total worked hours (used as a sanity cap). */
+ hours: number
+ /** Optional explicit shift start ('HH:MM' or 'HH:MM:SS'). */
+ start_time?: string | null
+ /** Optional explicit shift end ('HH:MM' or 'HH:MM:SS'). */
+ end_time?: string | null
+}
+
+export interface ShiftPremiumLineItem {
+ itemType: ShiftPremiumItemType
+ /** Description rendered on the payslip and verifikat. */
+ description: string
+ /** Date the premium applies to (the worked day). */
+ workDate: string
+ /** Hours actually covered by the winning rule. */
+ hours: number
+ /** Premium amount = baseHourlyRate × hours × premium_percent / 100. */
+ amount: number
+ /** Rule id used (for traceability/debugging). */
+ sourceRuleId: string
+}
+
+// ============================================================
+// Constants
+// ============================================================
+
+/** Total minutes in a 24h day. */
+const MINUTES_PER_DAY = 24 * 60
+
+/** Fallback shift for worked-day rows that don't carry explicit times. */
+const DEFAULT_SHIFT_START_MIN = 8 * 60 // 08:00
+const DEFAULT_SHIFT_END_MIN = 17 * 60 // 17:00
+
+// ============================================================
+// Helpers
+// ============================================================
+
+/** Round to 2 decimals (CLAUDE.md monetary precision rule). */
+function r(x: number): number {
+ return Math.round(x * 100) / 100
+}
+
+/** Parse 'HH:MM' or 'HH:MM:SS' (or 'HH:MM:SS.ffffff') to minutes-since-midnight. */
+function parseTimeToMinutes(time: string): number {
+ const parts = time.split(':')
+ if (parts.length < 2) {
+ throw new Error(`Invalid time format: ${time}`)
+ }
+ const h = parseInt(parts[0], 10)
+ const m = parseInt(parts[1], 10)
+ if (Number.isNaN(h) || Number.isNaN(m)) {
+ throw new Error(`Invalid time format: ${time}`)
+ }
+ return h * 60 + m
+}
+
+/** ISO weekday (1 = Mon … 7 = Sun) from a YYYY-MM-DD string. */
+function isoWeekdayFromDate(workDate: string): number {
+ // Parse as UTC to avoid the user's local timezone shifting the day.
+ const [y, m, d] = workDate.split('-').map((x) => parseInt(x, 10))
+ const day = new Date(Date.UTC(y, m - 1, d)).getUTCDay() // Sun=0…Sat=6
+ return day === 0 ? 7 : day
+}
+
+/** Shift a weekday by N days, staying in ISO 1..7. */
+function shiftWeekday(weekday: number, offset: number): number {
+ const next = ((weekday - 1 + offset) % 7 + 7) % 7
+ return next + 1
+}
+
+/**
+ * Resolve a shift's occupied minutes into segments keyed by ISO weekday.
+ * Returns one segment per occupied weekday. Wrap-past-midnight shifts return
+ * two entries.
+ */
+interface DaySegment {
+ weekday: number
+ startMin: number
+ endMin: number
+}
+
+function resolveShiftSegments(shift: WorkedDayShift): DaySegment[] {
+ const shiftWeekdayBase = isoWeekdayFromDate(shift.work_date)
+ const hasStart = !!shift.start_time
+ const hasEnd = !!shift.end_time
+
+ if (!hasStart || !hasEnd) {
+ return [
+ {
+ weekday: shiftWeekdayBase,
+ startMin: DEFAULT_SHIFT_START_MIN,
+ endMin: DEFAULT_SHIFT_END_MIN,
+ },
+ ]
+ }
+
+ const startMin = parseTimeToMinutes(shift.start_time!)
+ const endMin = parseTimeToMinutes(shift.end_time!)
+
+ if (endMin > startMin) {
+ return [{ weekday: shiftWeekdayBase, startMin, endMin }]
+ }
+
+ // Wraps past midnight (e.g. 22:00 -> 06:00). Split at 24:00.
+ return [
+ { weekday: shiftWeekdayBase, startMin, endMin: MINUTES_PER_DAY },
+ { weekday: shiftWeekday(shiftWeekdayBase, 1), startMin: 0, endMin },
+ ]
+}
+
+/**
+ * Resolve a rule's coverage windows into per-weekday segments.
+ * Returns one entry per (primary weekday in rule.day_of_week × time half).
+ * A rule whose end_time <= start_time wraps midnight; the second half then
+ * lands on the weekday AFTER each listed primary day.
+ */
+function resolveRuleSegments(rule: ShiftPremiumRule): DaySegment[] {
+ const startMin = parseTimeToMinutes(rule.start_time)
+ const endMin = parseTimeToMinutes(rule.end_time)
+
+ const segments: DaySegment[] = []
+
+ for (const primaryDay of rule.day_of_week) {
+ if (endMin > startMin) {
+ segments.push({ weekday: primaryDay, startMin, endMin })
+ } else if (startMin === endMin) {
+ // 00:00-00:00 special case: full 24h coverage on the primary day only.
+ segments.push({ weekday: primaryDay, startMin: 0, endMin: MINUTES_PER_DAY })
+ } else {
+ // Wrap past midnight.
+ segments.push({ weekday: primaryDay, startMin, endMin: MINUTES_PER_DAY })
+ segments.push({ weekday: shiftWeekday(primaryDay, 1), startMin: 0, endMin })
+ }
+ }
+
+ return segments
+}
+
+function ruleAppliesToEmployee(rule: ShiftPremiumRule, employeeId: string): boolean {
+ if (rule.applies_to_all_employees) return true
+ return rule.applies_to_employee_ids.includes(employeeId)
+}
+
+/** Total dominance score (priority outweighs premium_percent). */
+function ruleScore(rule: ShiftPremiumRule): number {
+ return rule.priority * 1_000_000 + rule.premium_percent
+}
+
+// ============================================================
+// Public API
+// ============================================================
+
+export interface ComputePremiumLinesArgs {
+ employeeId: string
+ baseHourlyRate: number
+ workedDays: WorkedDayShift[]
+ rules: ShiftPremiumRule[]
+}
+
+const DEFAULT_DESCRIPTIONS: Record = {
+ overtime_50: 'Övertid 50 %',
+ overtime_100: 'Övertid 100 %',
+ ob_weekday_evening: 'OB vardag kväll',
+ ob_weekend: 'OB helg',
+ ob_night: 'OB natt',
+ ob_holiday: 'OB helgdag',
+}
+
+/**
+ * Compute premium line items for a single employee over a set of worked days.
+ *
+ * Algorithm:
+ * 1. Resolve each shift into per-weekday segments (one or two if wraps).
+ * 2. For each segment, gather every rule-segment whose weekday matches.
+ * 3. Split the segment at every rule boundary, then award each sub-interval
+ * to the single highest-scoring rule that fully covers it.
+ * 4. Aggregate (work_date × rule_id) → minutes; convert to hours + amount.
+ */
+export function computePremiumLines(args: ComputePremiumLinesArgs): ShiftPremiumLineItem[] {
+ const { employeeId, baseHourlyRate, workedDays, rules } = args
+ if (baseHourlyRate <= 0 || workedDays.length === 0 || rules.length === 0) {
+ return []
+ }
+
+ const applicable = rules.filter((rule) => rule.is_active && ruleAppliesToEmployee(rule, employeeId))
+ if (applicable.length === 0) return []
+
+ // Aggregate (workDate × ruleId) → minutes.
+ const aggregates = new Map<
+ string,
+ { rule: ShiftPremiumRule; workDate: string; minutes: number }
+ >()
+
+ for (const shift of workedDays) {
+ if (shift.hours <= 0) continue
+ const shiftSegments = resolveShiftSegments(shift)
+ // Holiday status drives ob_holiday gating. A rule with item_type === 'ob_holiday'
+ // only fires when the worked day is a Swedish public holiday — day_of_week alone
+ // is not enough (a regular Sunday is not a helgdag, Midsommarafton on a Tuesday is).
+ const isHoliday = isSwedishHolidayISO(shift.work_date)
+
+ for (const seg of shiftSegments) {
+ // Collect rule-segments intersecting this weekday.
+ const candidates: Array<{
+ rule: ShiftPremiumRule
+ startMin: number
+ endMin: number
+ }> = []
+ for (const rule of applicable) {
+ if (rule.item_type === 'ob_holiday' && !isHoliday) continue
+ for (const ruleSeg of resolveRuleSegments(rule)) {
+ if (ruleSeg.weekday !== seg.weekday) continue
+ const startMin = Math.max(seg.startMin, ruleSeg.startMin)
+ const endMin = Math.min(seg.endMin, ruleSeg.endMin)
+ if (endMin <= startMin) continue
+ candidates.push({ rule, startMin, endMin })
+ }
+ }
+ if (candidates.length === 0) continue
+
+ // Build boundary set inside the shift segment.
+ const boundaries = new Set([seg.startMin, seg.endMin])
+ for (const cand of candidates) {
+ boundaries.add(cand.startMin)
+ boundaries.add(cand.endMin)
+ }
+ const sorted = [...boundaries].sort((a, b) => a - b)
+
+ for (let i = 0; i < sorted.length - 1; i++) {
+ const subStart = sorted[i]
+ const subEnd = sorted[i + 1]
+ if (subEnd <= subStart) continue
+ if (subStart < seg.startMin || subEnd > seg.endMin) continue
+
+ // Award this sub-interval to the highest-scoring candidate covering it.
+ let winner: ShiftPremiumRule | null = null
+ for (const cand of candidates) {
+ if (cand.startMin > subStart || cand.endMin < subEnd) continue
+ if (!winner || ruleScore(cand.rule) > ruleScore(winner)) {
+ winner = cand.rule
+ }
+ }
+ if (!winner) continue
+
+ const minutes = subEnd - subStart
+ const key = `${shift.work_date}::${winner.id}`
+ const existing = aggregates.get(key)
+ if (existing) {
+ existing.minutes += minutes
+ } else {
+ aggregates.set(key, { rule: winner, workDate: shift.work_date, minutes })
+ }
+ }
+ }
+ }
+
+ const lineItems: ShiftPremiumLineItem[] = []
+ for (const agg of aggregates.values()) {
+ const hours = r(agg.minutes / 60)
+ if (hours <= 0) continue
+ const amount = r(baseHourlyRate * hours * (agg.rule.premium_percent / 100))
+ if (amount <= 0) continue
+ const fallback = DEFAULT_DESCRIPTIONS[agg.rule.item_type]
+ const desc = agg.rule.name
+ ? `${agg.rule.name} (${agg.workDate}, ${hours} h)`
+ : `${fallback} (${agg.workDate}, ${hours} h)`
+ lineItems.push({
+ itemType: agg.rule.item_type,
+ description: desc,
+ workDate: agg.workDate,
+ hours,
+ amount,
+ sourceRuleId: agg.rule.id,
+ })
+ }
+
+ lineItems.sort((a, b) => {
+ if (a.workDate !== b.workDate) return a.workDate < b.workDate ? -1 : 1
+ return a.itemType < b.itemType ? -1 : 1
+ })
+
+ return lineItems
+}
diff --git a/lib/tax/swedish-holidays.ts b/lib/tax/swedish-holidays.ts
index 9ec67814..6194fb3b 100644
--- a/lib/tax/swedish-holidays.ts
+++ b/lib/tax/swedish-holidays.ts
@@ -139,6 +139,18 @@ export function isSwedishHoliday(date: Date): boolean {
return holidays.includes(formatDateISO(date))
}
+/**
+ * Check if an ISO date string (YYYY-MM-DD) is a Swedish public holiday.
+ * Parses by string only — no Date / timezone math — so callers using UTC
+ * boundaries (e.g. the shift-premium engine) get a stable answer.
+ */
+export function isSwedishHolidayISO(isoDate: string): boolean {
+ const year = parseInt(isoDate.slice(0, 4), 10)
+ if (Number.isNaN(year)) return false
+ const holidays = getSwedishHolidays(year)
+ return holidays.includes(isoDate)
+}
+
/**
* Check if a date is a weekend (Saturday or Sunday)
*/
diff --git a/messages/en.json b/messages/en.json
index 1994209c..e1d5ffa7 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -61,20 +61,28 @@
},
"nav": {
"dashboard": "Overview",
+ "bureau": "Bureau",
"kpi": "KPIs",
"invoice_inbox": "Document inbox",
"invoices": "Invoices",
+ "sales_orders": "Orders",
"customers": "Customers",
+ "products": "Products",
+ "inventory": "Inventory",
"supplier_invoices": "Supplier invoices",
+ "purchase_orders": "Purchase orders",
"suppliers": "Suppliers",
"review": "Review",
"transactions": "Transactions",
"bookkeeping": "Bookkeeping",
"assets": "Fixed assets",
"reports": "Reports",
+ "budgets": "Budgets",
+ "insights": "Insights",
"import": "Import/Export",
"salary": "Payroll",
"employees": "Employees",
+ "time_tracking": "Time tracking",
"expenses": "Expenses",
"receipts": "Receipts",
"deadlines": "Deadlines",
@@ -131,6 +139,8 @@
"email_sent_body_login": "We sent a sign-in link to {email} ",
"email_sent_hint_reset": "Click the link in the email to reset your password. The link is valid for 1 hour.",
"email_sent_hint_login": "Click the link in the email to sign in. The link is valid for 1 hour.",
+ "open_webmail_search": "Find the email in {provider}",
+ "open_webmail_inbox": "Open {provider}",
"back_to_login": "Back to sign in",
"callback_error_title": "The reset link did not work",
"callback_error_body": "The link has expired or has already been used.",
@@ -156,6 +166,7 @@
"skatteverket": "Skatteverket",
"salary": "Payroll",
"templates": "Templates",
+ "approval_rules": "Approval flows",
"account": "Account",
"api": "API"
},
@@ -249,6 +260,68 @@
"retry": "Please try again.",
"overdue_invoices": "{count} overdue invoices"
},
+ "bureau": {
+ "title": "Bureau",
+ "subtitle": "Overview of every client company you have access to",
+ "section_title": "Bureau mode",
+ "enable_setting": "Enable bureau mode",
+ "enable_setting_description": "Shows an aggregated overview of every client company you have access to",
+ "toggle_saved": "Bureau mode updated",
+ "toggle_save_failed": "Could not save bureau setting",
+ "tiles": {
+ "clients": "Clients total",
+ "pending": "Awaiting approval",
+ "deadlines": "Deadlines this week",
+ "overdue_ar": "Overdue invoices"
+ },
+ "action": {
+ "open": "Open"
+ },
+ "pending_label": "Pending operations",
+ "deadlines_label": "Upcoming deadlines",
+ "no_pending": "No pending operations",
+ "no_deadlines": "No deadlines this week",
+ "show_pending": "Show pending",
+ "high_risk": "{count} high risk",
+ "kpi": {
+ "revenue_mtd": "Revenue MTD",
+ "cash": "Cash",
+ "overdue_ar": "Overdue AR"
+ },
+ "role": {
+ "owner": "owner",
+ "admin": "admin",
+ "member": "member",
+ "viewer": "viewer"
+ },
+ "deadline_status": {
+ "overdue": "overdue"
+ },
+ "empty": {
+ "title": "No client companies yet",
+ "description": "Once you are added to a company it will appear here."
+ }
+ },
+ "insights": {
+ "title": "Insights",
+ "empty": "No anomalies found",
+ "severity_flag": "Address",
+ "severity_warn": "Review",
+ "severity_info": "Info",
+ "action_dismiss": "Dismiss",
+ "action_snooze": "Snooze 7d",
+ "subject_open": "Open source",
+ "dashboard_tile_title": "Anomalies",
+ "dashboard_tile_subtitle": "{count} to address",
+ "rules": {
+ "outlier_amount": "Unusual amount for counterparty",
+ "missing_recurring": "Missing recurring vendor",
+ "duplicate_suspect": "Possible duplicate",
+ "stale_uncategorized": "Old uncategorized transaction",
+ "vat_rate_outlier": "Unusual VAT rate",
+ "stale_customer_balance": "Stale customer balance"
+ }
+ },
"supplier_invoices": {
"title": "Supplier invoices",
"register_invoice": "Register invoice",
@@ -278,6 +351,34 @@
"status_credited": "Credited",
"status_reversed": "Reversed"
},
+ "purchase_orders": {
+ "title": "Purchase orders",
+ "new": "New purchase order",
+ "empty_title": "No purchase orders yet",
+ "empty_description": "Create a purchase order to plan a purchase, record goods receipts, and reconcile against the supplier invoice.",
+ "filter_open": "Open",
+ "filter_all": "All",
+ "th_number": "Number",
+ "th_supplier": "Supplier",
+ "th_order_date": "Order date",
+ "th_status": "Status",
+ "th_expected_delivery": "Expected delivery",
+ "th_amount": "Amount",
+ "status_draft": "Draft",
+ "status_sent": "Sent",
+ "status_partially_received": "Partially received",
+ "status_received": "Received",
+ "status_partially_invoiced": "Partially invoiced",
+ "status_closed": "Closed",
+ "status_cancelled": "Cancelled",
+ "send": "Mark as sent",
+ "receive": "Receive goods",
+ "cancel": "Cancel order",
+ "three_way_match_failed": "Three-way match failed. Verify quantities and prices against the purchase order.",
+ "po_link_required": "Company settings require supplier invoices to be linked to a purchase order.",
+ "match_within_tolerance": "Within tolerance",
+ "match_out_of_tolerance": "Outside tolerance"
+ },
"suppliers": {
"title": "Suppliers",
"subtitle": "Manage your suppliers and their payment details",
@@ -319,9 +420,11 @@
"th_acquisition_cost": "Acquisition cost",
"th_useful_life": "Depreciation period",
"th_status": "Status",
+ "th_actions": "Actions",
"useful_life_format": "{years} yr ({months} mo)",
"status_disposed": "Disposed",
"status_active": "Active",
+ "action_dispose": "Dispose",
"category_immaterial": "Intangible",
"category_building": "Building",
"category_land_improvement": "Land improvement",
@@ -392,6 +495,8 @@
"confirm_email_title": "Confirm your email",
"confirm_email_body": "We sent a confirmation link to {email} ",
"confirm_email_hint": "Click the link in the email to activate your account. The link is valid for 24 hours.",
+ "open_webmail_search": "Find the email in {provider}",
+ "open_webmail_inbox": "Open {provider}",
"back_to_login": "Back to sign in"
},
"mfa": {
@@ -2574,14 +2679,29 @@
"title": "Attachments",
"empty": "No attachments",
"open_in_new_tab": "Open in new tab",
- "not_previewable": "Preview is not available for this file type."
+ "not_previewable": "Preview is not available for this file type.",
+ "remove": "Remove",
+ "replace": "Replace with new version"
},
"journal_attachments": {
"loading": "Loading documents...",
"title": "Documents",
"add": "Add document",
"empty": "No documents attached.",
- "download": "Download"
+ "download": "Download",
+ "remove": "Remove",
+ "replace": "Replace with new version",
+ "remove_confirm_title": "Remove document",
+ "remove_confirm_body": "Remove {file}? This cannot be undone.",
+ "remove_confirm_cta": "Remove",
+ "remove_blocked_title": "Document cannot be removed",
+ "remove_blocked_body": "This document is attached to a verifikation and constitutes räkenskapsinformation under the Swedish Bookkeeping Act (BFL 7 kap 2§). Räkenskapsinformation must be retained for at least 7 years and cannot be deleted.",
+ "remove_blocked_hint": "If the document needs correction, upload a new version. The existing one is preserved in the version history.",
+ "remove_blocked_replace_cta": "Upload new version",
+ "remove_blocked_cancel_cta": "Close",
+ "replace_uploading": "Replacing...",
+ "remove_failed": "Could not remove the document.",
+ "replace_failed": "Could not upload new version."
},
"journal_status": {
"status_draft": "Draft",
@@ -2605,7 +2725,8 @@
"source_label_supplier_invoice_registered": "Supplier invoice",
"source_label_supplier_invoice_paid": "Supplier payment",
"source_label_supplier_invoice_cash_payment": "Cash payment",
- "source_label_currency_revaluation": "Currency revaluation"
+ "source_label_currency_revaluation": "Currency revaluation",
+ "source_label_reminder_fee": "Reminder fee"
},
"journal_correction": {
"title": "Correction chain",
@@ -3031,6 +3152,178 @@
"col_city": "City",
"col_created": "Created"
},
+ "products": {
+ "title": "Products",
+ "new_product": "New product",
+ "back_to_list": "Back to products",
+ "viewer_disabled_tooltip": "You only have viewer access in this company",
+ "load_failed_title": "Could not load products",
+ "load_failed_description": "Check your connection and try again.",
+ "create_failed_title": "Could not create product",
+ "update_failed_title": "Could not update product",
+ "archive_failed_title": "Could not archive product",
+ "created_title": "Product created",
+ "created_description": "{name} was added",
+ "updated_title": "Product updated",
+ "archived_title": "Product archived",
+ "archive": "Archive",
+ "archive_confirm": "Archive this product? Existing invoices that reference it are unaffected.",
+ "archived_badge": "Archived",
+ "search_placeholder": "Search products",
+ "no_search_results_title": "No matches",
+ "no_search_results_description": "No products match \"{term}\".",
+ "empty_title": "No products yet",
+ "empty_description": "Create your first product so you can add it to invoices.",
+ "section_basics": "Basics",
+ "section_pricing": "Price & VAT",
+ "type_goods": "Goods",
+ "type_service": "Service",
+ "field_name": "Product name",
+ "field_name_placeholder": "e.g. LED bulb 9W",
+ "field_sku": "SKU",
+ "field_sku_placeholder": "e.g. LED-9W-E27",
+ "field_description": "Description",
+ "field_type": "Product type",
+ "field_type_hint": "Goods update stock levels when invoiced. Services don't.",
+ "field_category": "Category",
+ "field_default_price": "Default price (excl. VAT)",
+ "field_default_unit": "Unit",
+ "field_default_vat": "Default VAT rate",
+ "validation_name_required": "Product name is required",
+ "save": "Save",
+ "cancel": "Cancel",
+ "col_name": "Name",
+ "col_sku": "SKU",
+ "col_type": "Type",
+ "col_default_price": "Default price",
+ "col_unit": "Unit"
+ },
+ "sales_orders": {
+ "title": "Sales orders",
+ "back_to_list": "Back to orders",
+ "new_order": "New order",
+ "viewer_disabled_tooltip": "You only have read access in this company",
+ "load_failed_title": "Could not load orders",
+ "load_failed_description": "Check your connection and try again.",
+ "search_placeholder": "Search order number or customer",
+ "empty_title": "No orders yet",
+ "empty_description": "Create your first order when a customer accepts a quote, or start one from scratch.",
+ "no_category_title": "No orders in this category",
+ "no_category_description": "Try a different filter or create a new order.",
+ "tab_all": "All",
+ "tab_draft": "Drafts",
+ "tab_confirmed": "Confirmed",
+ "tab_partially_shipped": "Partially shipped",
+ "tab_shipped": "Shipped",
+ "tab_partially_invoiced": "Partially invoiced",
+ "tab_invoiced": "Invoiced",
+ "tab_cancelled": "Cancelled",
+ "status_draft": "Draft",
+ "status_confirmed": "Confirmed",
+ "status_partially_shipped": "Partially shipped",
+ "status_shipped": "Shipped",
+ "status_partially_invoiced": "Partially invoiced",
+ "status_invoiced": "Invoiced",
+ "status_cancelled": "Cancelled",
+ "col_number": "Number",
+ "col_customer": "Customer",
+ "col_date": "Date",
+ "col_expected_delivery": "Expected delivery",
+ "col_status": "Status",
+ "col_amount": "Amount",
+ "col_description": "Description",
+ "col_quantity": "Quantity",
+ "col_quantity_shipped": "Shipped",
+ "col_quantity_invoiced": "Invoiced",
+ "col_unit_price": "Unit price",
+ "col_line_total": "Line total",
+ "section_header": "Order details",
+ "section_lines": "Order lines",
+ "section_progress": "Shipment and invoicing progress",
+ "field_customer": "Customer",
+ "field_order_date": "Order date",
+ "field_expected_delivery": "Expected delivery date",
+ "field_currency": "Currency",
+ "field_notes": "Notes",
+ "field_our_reference": "Our reference",
+ "field_your_reference": "Your reference",
+ "field_product": "Product",
+ "field_unit": "Unit",
+ "field_vat_rate": "VAT",
+ "action_confirm": "Confirm order",
+ "action_ship": "Ship",
+ "action_invoice": "Invoice",
+ "action_cancel": "Cancel",
+ "action_delete": "Delete draft",
+ "confirm_dialog_title": "Confirm the order?",
+ "confirm_dialog_description": "The customer commits to receiving the shipment. You can then create a delivery note and invoice.",
+ "ship_dialog_title": "Ship the order",
+ "ship_dialog_description": "A delivery note is created and stock levels are updated automatically for products that track inventory.",
+ "invoice_dialog_title": "Invoice the order",
+ "invoice_dialog_description": "An invoice is created for the remaining quantities. You can edit it before sending it to the customer.",
+ "cancel_dialog_title": "Cancel the order?",
+ "cancel_dialog_description": "An order can only be cancelled before the first shipment. Cancelled orders cannot be restored.",
+ "delete_dialog_title": "Delete draft?",
+ "delete_dialog_description": "Drafts can be removed without a trace — confirmed orders must be cancelled instead.",
+ "confirmed_toast": "Order is confirmed",
+ "shipped_toast": "Delivery note created",
+ "invoiced_toast": "Invoice created",
+ "cancelled_toast": "Order is cancelled",
+ "deleted_toast": "Draft removed",
+ "from_quote": "From quote",
+ "summary_total": "Total",
+ "summary_subtotal": "Subtotal",
+ "summary_vat": "VAT",
+ "stock_warnings_title": "Some stock movements could not be recorded",
+ "validation_at_least_one_line": "At least one order line is required",
+ "remaining_to_ship": "{remaining} left to ship",
+ "remaining_to_invoice": "{remaining} left to invoice"
+ },
+ "inventory": {
+ "title": "Inventory",
+ "back_to_inventory": "Back to inventory",
+ "view_movements": "View movements",
+ "movements_title": "Stock movements",
+ "load_failed_title": "Could not load inventory",
+ "no_locations_title": "No location configured",
+ "no_locations_description": "Create a primary location to start tracking stock levels.",
+ "create_primary_location": "Create primary location",
+ "default_location_name": "Main warehouse",
+ "location_created": "Location created",
+ "location_create_failed": "Could not create location",
+ "locations_title": "Locations",
+ "primary": "Primary",
+ "inactive": "Inactive",
+ "stock_levels_title": "Current stock",
+ "no_levels_title": "No stock movements yet",
+ "no_levels_description": "Stock movements are created automatically when you send invoices or approve supplier invoices that reference a 'Goods' product.",
+ "no_movements_title": "No movements yet",
+ "no_movements_description": "All inbound and outbound movements will appear here once recorded.",
+ "col_location_name": "Name",
+ "col_location_status": "Status",
+ "col_product": "Product",
+ "col_sku": "SKU",
+ "col_location": "Location",
+ "col_quantity": "Quantity",
+ "col_unit": "Unit",
+ "col_when": "When",
+ "col_reason": "Reason",
+ "col_delta": "Change",
+ "col_reference": "Source",
+ "reason_purchase": "Purchase",
+ "reason_sale": "Sale",
+ "reason_adjustment": "Adjustment",
+ "reason_transfer_in": "Transfer in",
+ "reason_transfer_out": "Transfer out",
+ "reason_return": "Return",
+ "reason_disposal": "Disposal",
+ "reason_opening": "Opening balance",
+ "ref_invoice": "Invoice",
+ "ref_supplier_invoice": "Supplier invoice",
+ "ref_transfer": "Stock transfer",
+ "ref_transfer_rollback": "Rollback",
+ "ref_manual": "Manual"
+ },
"invoices": {
"title": "Invoices",
"recurring": "Recurring",
@@ -3096,6 +3389,8 @@
"missing_underlag_detail": "{count} journal entries without supporting documents",
"stale_transactions": "Old transactions",
"stale_transactions_detail": "{count} transactions older than 14 days are not posted",
+ "anomalies_title": "Anomalies",
+ "anomalies_detail": "{count} to address",
"bank_consent_expiring": "Bank consent expiring",
"bank_consent_detail_one": "{bank} — {days} day left",
"bank_consent_detail_other": "{bank} — {days} days left"
@@ -3117,6 +3412,8 @@
"name_trial_balance": "Trial balance",
"name_income_statement": "Income statement",
"name_balance_sheet": "Balance sheet",
+ "name_kassaflodesanalys": "Cash flow statement",
+ "name_arsredovisning": "Annual report",
"name_vat_declaration": "VAT declaration",
"name_periodisk_sammanstallning": "EU sales report",
"name_ne_declaration": "NE-bilaga",
@@ -3149,6 +3446,19 @@
"status_paid": "Paid",
"status_booked": "Posted"
},
+ "time_tracking": {
+ "title": "Time tracking",
+ "billable_inbox": "Billable hours by customer",
+ "create_invoice": "Create invoice",
+ "hourly_rate": "Hourly rate",
+ "project_required": "Select a project to bill",
+ "no_project": "No project",
+ "no_billable_hours": "No billable hours to invoice. Mark days as billable to collect them here.",
+ "log_hours": "Log hours",
+ "billable": "Billable",
+ "invoiced": "Invoiced",
+ "missing_rate": "Missing rate"
+ },
"import": {
"title": "Import",
"subtitle": "Import bank transactions or bookkeeping data into your company",
diff --git a/messages/sv.json b/messages/sv.json
index 4e1aa619..60794203 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -61,20 +61,28 @@
},
"nav": {
"dashboard": "Översikt",
+ "bureau": "Byrå",
"kpi": "Nyckeltal",
"invoice_inbox": "Dokumentinkorg",
"invoices": "Fakturor",
+ "sales_orders": "Order",
"customers": "Kunder",
+ "products": "Produkter",
+ "inventory": "Lager",
"supplier_invoices": "Leverantörsfakturor",
+ "purchase_orders": "Inköpsorder",
"suppliers": "Leverantörer",
"review": "Granskning",
"transactions": "Transaktioner",
"bookkeeping": "Bokföring",
"assets": "Anläggningstillgångar",
"reports": "Rapporter",
+ "budgets": "Budgetar",
+ "insights": "Insikter",
"import": "Importera/Exportera",
"salary": "Löner",
"employees": "Anställda",
+ "time_tracking": "Tidrapportering",
"expenses": "Utlägg",
"receipts": "Kvitton",
"deadlines": "Deadlines",
@@ -131,6 +139,8 @@
"email_sent_body_login": "Vi har skickat en inloggningslänk till {email} ",
"email_sent_hint_reset": "Klicka på länken i e-posten för att återställa ditt lösenord. Länken är giltig i 1 timme.",
"email_sent_hint_login": "Klicka på länken i e-posten för att logga in. Länken är giltig i 1 timme.",
+ "open_webmail_search": "Hitta e-posten i {provider}",
+ "open_webmail_inbox": "Öppna {provider}",
"back_to_login": "Tillbaka till inloggning",
"callback_error_title": "Återställningslänken fungerade inte",
"callback_error_body": "Länken har gått ut eller använts redan.",
@@ -156,6 +166,7 @@
"skatteverket": "Skatteverket",
"salary": "Löner",
"templates": "Mallar",
+ "approval_rules": "Godkännandeflöden",
"account": "Konto",
"api": "API"
},
@@ -249,6 +260,68 @@
"retry": "Försök igen.",
"overdue_invoices": "{count} förfallna fakturor"
},
+ "bureau": {
+ "title": "Byrå",
+ "subtitle": "Översikt över alla klientbolag du har tillgång till",
+ "section_title": "Byrå-läge",
+ "enable_setting": "Aktivera byrå-läge",
+ "enable_setting_description": "Visar en samlad översikt över alla klientbolag du har tillgång till",
+ "toggle_saved": "Byrå-läge uppdaterat",
+ "toggle_save_failed": "Kunde inte spara byrå-inställning",
+ "tiles": {
+ "clients": "Klienter totalt",
+ "pending": "Väntar på godkännande",
+ "deadlines": "Deadlines denna vecka",
+ "overdue_ar": "Förfallna fakturor"
+ },
+ "action": {
+ "open": "Öppna"
+ },
+ "pending_label": "Väntande operationer",
+ "deadlines_label": "Kommande deadlines",
+ "no_pending": "Inga väntande operationer",
+ "no_deadlines": "Inga deadlines denna vecka",
+ "show_pending": "Visa pending",
+ "high_risk": "{count} hög risk",
+ "kpi": {
+ "revenue_mtd": "Intäkter MTD",
+ "cash": "Bank/Kassa",
+ "overdue_ar": "Förfallna fakt."
+ },
+ "role": {
+ "owner": "ägare",
+ "admin": "administratör",
+ "member": "medlem",
+ "viewer": "läsare"
+ },
+ "deadline_status": {
+ "overdue": "förfallen"
+ },
+ "empty": {
+ "title": "Inga klientbolag ännu",
+ "description": "När du läggs till som medlem i ett bolag dyker det upp här."
+ }
+ },
+ "insights": {
+ "title": "Insikter",
+ "empty": "Inga avvikelser hittade",
+ "severity_flag": "Åtgärda",
+ "severity_warn": "Granska",
+ "severity_info": "Information",
+ "action_dismiss": "Avfärda",
+ "action_snooze": "Snooze 7d",
+ "subject_open": "Öppna källan",
+ "dashboard_tile_title": "Avvikelser",
+ "dashboard_tile_subtitle": "{count} att åtgärda",
+ "rules": {
+ "outlier_amount": "Ovanligt belopp för motpart",
+ "missing_recurring": "Återkommande leverantör saknas",
+ "duplicate_suspect": "Möjlig dubblett",
+ "stale_uncategorized": "Gammal okategoriserad transaktion",
+ "vat_rate_outlier": "Avvikande momssats",
+ "stale_customer_balance": "Förfallen kundfordran"
+ }
+ },
"supplier_invoices": {
"title": "Leverantörsfakturor",
"register_invoice": "Registrera faktura",
@@ -278,6 +351,34 @@
"status_credited": "Krediterad",
"status_reversed": "Makulerad"
},
+ "purchase_orders": {
+ "title": "Inköpsorder",
+ "new": "Ny inköpsorder",
+ "empty_title": "Inga inköpsorder än",
+ "empty_description": "Skapa en inköpsorder för att planera ett inköp, ta emot gods, och stämma av mot leverantörsfakturan.",
+ "filter_open": "Öppna",
+ "filter_all": "Alla",
+ "th_number": "Nummer",
+ "th_supplier": "Leverantör",
+ "th_order_date": "Orderdatum",
+ "th_status": "Status",
+ "th_expected_delivery": "Beräknad lev.",
+ "th_amount": "Belopp",
+ "status_draft": "Utkast",
+ "status_sent": "Skickad",
+ "status_partially_received": "Delvis mottagen",
+ "status_received": "Mottagen",
+ "status_partially_invoiced": "Delvis fakturerad",
+ "status_closed": "Stängd",
+ "status_cancelled": "Annullerad",
+ "send": "Markera som skickad",
+ "receive": "Ta emot gods",
+ "cancel": "Avbryt order",
+ "three_way_match_failed": "Trevägs-matchningen misslyckades. Kontrollera kvantiteter och priser mot inköpsordern.",
+ "po_link_required": "Företaget kräver att leverantörsfakturor länkas till en inköpsorder.",
+ "match_within_tolerance": "Avvikelse inom tolerans",
+ "match_out_of_tolerance": "Avvikelse utanför tolerans"
+ },
"suppliers": {
"title": "Leverantörer",
"subtitle": "Hantera dina leverantörer och deras betalningsuppgifter",
@@ -319,9 +420,11 @@
"th_acquisition_cost": "Anskaffningsvärde",
"th_useful_life": "Avskrivningstid",
"th_status": "Status",
+ "th_actions": "Åtgärder",
"useful_life_format": "{years} år ({months} mån)",
"status_disposed": "Avyttrad",
"status_active": "Aktiv",
+ "action_dispose": "Avyttra",
"category_immaterial": "Immateriell",
"category_building": "Byggnad",
"category_land_improvement": "Markanläggning",
@@ -392,6 +495,8 @@
"confirm_email_title": "Bekräfta din e-post",
"confirm_email_body": "Vi har skickat en bekräftelselänk till {email} ",
"confirm_email_hint": "Klicka på länken i e-posten för att aktivera ditt konto. Länken är giltig i 24 timmar.",
+ "open_webmail_search": "Hitta e-posten i {provider}",
+ "open_webmail_inbox": "Öppna {provider}",
"back_to_login": "Tillbaka till inloggning"
},
"mfa": {
@@ -2574,14 +2679,29 @@
"title": "Bilagor",
"empty": "Inga bilagor",
"open_in_new_tab": "Öppna i nytt fönster",
- "not_previewable": "Förhandsvisning är inte tillgänglig för denna filtyp."
+ "not_previewable": "Förhandsvisning är inte tillgänglig för denna filtyp.",
+ "remove": "Ta bort",
+ "replace": "Ersätt med ny version"
},
"journal_attachments": {
"loading": "Laddar underlag...",
"title": "Underlag",
"add": "Lägg till underlag",
"empty": "Inga underlag bifogade.",
- "download": "Ladda ner"
+ "download": "Ladda ner",
+ "remove": "Ta bort",
+ "replace": "Ersätt med ny version",
+ "remove_confirm_title": "Ta bort underlag",
+ "remove_confirm_body": "Vill du ta bort {file}? Detta går inte att ångra.",
+ "remove_confirm_cta": "Ta bort",
+ "remove_blocked_title": "Underlaget kan inte tas bort",
+ "remove_blocked_body": "Detta underlag är knutet till en verifikation och utgör räkenskapsinformation enligt Bokföringslagen 7 kap 2§. Räkenskapsinformation måste bevaras i minst 7 år och får inte raderas.",
+ "remove_blocked_hint": "Behöver underlaget korrigeras — ladda upp en ny version. Den befintliga bevaras då i versionshistoriken.",
+ "remove_blocked_replace_cta": "Ladda upp ny version",
+ "remove_blocked_cancel_cta": "Stäng",
+ "replace_uploading": "Ersätter...",
+ "remove_failed": "Kunde inte ta bort underlaget.",
+ "replace_failed": "Kunde inte ladda upp ny version."
},
"journal_status": {
"status_draft": "Utkast",
@@ -2605,7 +2725,8 @@
"source_label_supplier_invoice_registered": "Leverantörsfaktura",
"source_label_supplier_invoice_paid": "Leverantörsbetalning",
"source_label_supplier_invoice_cash_payment": "Kontantbetalning",
- "source_label_currency_revaluation": "Valutaomvärdering"
+ "source_label_currency_revaluation": "Valutaomvärdering",
+ "source_label_reminder_fee": "Påminnelseavgift"
},
"journal_correction": {
"title": "Ändringskedja",
@@ -3031,6 +3152,178 @@
"col_city": "Stad",
"col_created": "Skapad"
},
+ "products": {
+ "title": "Produkter",
+ "new_product": "Ny produkt",
+ "back_to_list": "Tillbaka till produkter",
+ "viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
+ "load_failed_title": "Kunde inte ladda produkter",
+ "load_failed_description": "Kontrollera din anslutning och försök igen.",
+ "create_failed_title": "Kunde inte skapa produkt",
+ "update_failed_title": "Kunde inte uppdatera produkt",
+ "archive_failed_title": "Kunde inte arkivera produkt",
+ "created_title": "Produkt skapad",
+ "created_description": "{name} har lagts till",
+ "updated_title": "Produkt uppdaterad",
+ "archived_title": "Produkten arkiverades",
+ "archive": "Arkivera",
+ "archive_confirm": "Är du säker på att du vill arkivera produkten? Befintliga fakturor med produkten påverkas inte.",
+ "archived_badge": "Arkiverad",
+ "search_placeholder": "Sök produkter",
+ "no_search_results_title": "Inga träffar",
+ "no_search_results_description": "Inga produkter matchar \"{term}\".",
+ "empty_title": "Inga produkter ännu",
+ "empty_description": "Skapa din första produkt för att kunna lägga till den på fakturor.",
+ "section_basics": "Grunduppgifter",
+ "section_pricing": "Pris & moms",
+ "type_goods": "Vara",
+ "type_service": "Tjänst",
+ "field_name": "Produktnamn",
+ "field_name_placeholder": "T.ex. Glödlampa LED 9W",
+ "field_sku": "Artikelnummer (SKU)",
+ "field_sku_placeholder": "T.ex. LED-9W-E27",
+ "field_description": "Beskrivning",
+ "field_type": "Produkttyp",
+ "field_type_hint": "Varor påverkar lagersaldot vid fakturering. Tjänster gör det inte.",
+ "field_category": "Kategori",
+ "field_default_price": "Standardpris (exkl. moms)",
+ "field_default_unit": "Enhet",
+ "field_default_vat": "Standardmomssats",
+ "validation_name_required": "Produktnamn krävs",
+ "save": "Spara",
+ "cancel": "Avbryt",
+ "col_name": "Namn",
+ "col_sku": "SKU",
+ "col_type": "Typ",
+ "col_default_price": "Standardpris",
+ "col_unit": "Enhet"
+ },
+ "sales_orders": {
+ "title": "Order",
+ "back_to_list": "Tillbaka till order",
+ "new_order": "Ny order",
+ "viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
+ "load_failed_title": "Kunde inte ladda ordrar",
+ "load_failed_description": "Kontrollera din anslutning och försök igen.",
+ "search_placeholder": "Sök ordernummer eller kund",
+ "empty_title": "Inga ordrar ännu",
+ "empty_description": "Skapa din första order när en kund godkänner en offert eller direkt från grunden.",
+ "no_category_title": "Inga ordrar i denna kategori",
+ "no_category_description": "Prova ett annat filter eller skapa en ny order.",
+ "tab_all": "Alla",
+ "tab_draft": "Utkast",
+ "tab_confirmed": "Bekräftade",
+ "tab_partially_shipped": "Delvis levererade",
+ "tab_shipped": "Levererade",
+ "tab_partially_invoiced": "Delvis fakturerade",
+ "tab_invoiced": "Fakturerade",
+ "tab_cancelled": "Avbrutna",
+ "status_draft": "Utkast",
+ "status_confirmed": "Bekräftad",
+ "status_partially_shipped": "Delvis levererad",
+ "status_shipped": "Levererad",
+ "status_partially_invoiced": "Delvis fakturerad",
+ "status_invoiced": "Fakturerad",
+ "status_cancelled": "Avbruten",
+ "col_number": "Nummer",
+ "col_customer": "Kund",
+ "col_date": "Datum",
+ "col_expected_delivery": "Önskad leverans",
+ "col_status": "Status",
+ "col_amount": "Belopp",
+ "col_description": "Beskrivning",
+ "col_quantity": "Antal",
+ "col_quantity_shipped": "Levererat",
+ "col_quantity_invoiced": "Fakturerat",
+ "col_unit_price": "À-pris",
+ "col_line_total": "Radsumma",
+ "section_header": "Orderuppgifter",
+ "section_lines": "Orderrader",
+ "section_progress": "Leverans- och faktureringsstatus",
+ "field_customer": "Kund",
+ "field_order_date": "Orderdatum",
+ "field_expected_delivery": "Önskad leveransdatum",
+ "field_currency": "Valuta",
+ "field_notes": "Anteckningar",
+ "field_our_reference": "Vår referens",
+ "field_your_reference": "Er referens",
+ "field_product": "Produkt",
+ "field_unit": "Enhet",
+ "field_vat_rate": "Moms",
+ "action_confirm": "Bekräfta order",
+ "action_ship": "Leverera",
+ "action_invoice": "Fakturera",
+ "action_cancel": "Avbryt",
+ "action_delete": "Ta bort utkast",
+ "confirm_dialog_title": "Bekräfta ordern?",
+ "confirm_dialog_description": "Kunden förbinder sig att ta emot leveransen. Du kan därefter skapa följesedel och faktura.",
+ "ship_dialog_title": "Leverera ordern",
+ "ship_dialog_description": "En följesedel skapas och lagersaldot uppdateras automatiskt för produkter som spårar lager.",
+ "invoice_dialog_title": "Fakturera ordern",
+ "invoice_dialog_description": "En faktura skapas för återstående mängd. Du kan redigera den innan du skickar den till kund.",
+ "cancel_dialog_title": "Avbryta ordern?",
+ "cancel_dialog_description": "Ordern kan endast avbrytas innan första leverans. Avbrutna ordrar går inte att återställa.",
+ "delete_dialog_title": "Ta bort utkast?",
+ "delete_dialog_description": "Utkast kan tas bort utan spår — bekräftade ordrar måste avbrytas istället.",
+ "confirmed_toast": "Ordern är bekräftad",
+ "shipped_toast": "Följesedel skapad",
+ "invoiced_toast": "Faktura skapad",
+ "cancelled_toast": "Ordern är avbruten",
+ "deleted_toast": "Utkastet är borttaget",
+ "from_quote": "Från offert",
+ "summary_total": "Total",
+ "summary_subtotal": "Delsumma",
+ "summary_vat": "Moms",
+ "stock_warnings_title": "Vissa lagerrörelser kunde inte registreras",
+ "validation_at_least_one_line": "Minst en orderrad krävs",
+ "remaining_to_ship": "{remaining} kvar att leverera",
+ "remaining_to_invoice": "{remaining} kvar att fakturera"
+ },
+ "inventory": {
+ "title": "Lager",
+ "back_to_inventory": "Tillbaka till lager",
+ "view_movements": "Visa lagerrörelser",
+ "movements_title": "Lagerrörelser",
+ "load_failed_title": "Kunde inte ladda lagerdata",
+ "no_locations_title": "Inget lager konfigurerat",
+ "no_locations_description": "Skapa ett primärt lager för att börja registrera lagersaldon.",
+ "create_primary_location": "Skapa primärt lager",
+ "default_location_name": "Huvudlager",
+ "location_created": "Lager skapat",
+ "location_create_failed": "Kunde inte skapa lager",
+ "locations_title": "Lagerplatser",
+ "primary": "Primär",
+ "inactive": "Inaktiv",
+ "stock_levels_title": "Aktuellt lagersaldo",
+ "no_levels_title": "Inga lagerrörelser ännu",
+ "no_levels_description": "Lagerrörelser skapas automatiskt när du skickar fakturor eller godkänner leverantörsfakturor som länkar till produkter av typen 'Vara'.",
+ "no_movements_title": "Inga lagerrörelser ännu",
+ "no_movements_description": "Här visas alla in- och utleveranser så snart de börjar registreras.",
+ "col_location_name": "Namn",
+ "col_location_status": "Status",
+ "col_product": "Produkt",
+ "col_sku": "SKU",
+ "col_location": "Plats",
+ "col_quantity": "Saldo",
+ "col_unit": "Enhet",
+ "col_when": "När",
+ "col_reason": "Anledning",
+ "col_delta": "Förändring",
+ "col_reference": "Källa",
+ "reason_purchase": "Inköp",
+ "reason_sale": "Försäljning",
+ "reason_adjustment": "Justering",
+ "reason_transfer_in": "Inflyttning",
+ "reason_transfer_out": "Utflyttning",
+ "reason_return": "Retur",
+ "reason_disposal": "Kassation",
+ "reason_opening": "Ingående saldo",
+ "ref_invoice": "Faktura",
+ "ref_supplier_invoice": "Leverantörsfaktura",
+ "ref_transfer": "Lageröverföring",
+ "ref_transfer_rollback": "Återställning",
+ "ref_manual": "Manuell"
+ },
"invoices": {
"title": "Fakturor",
"recurring": "Återkommande",
@@ -3096,6 +3389,8 @@
"missing_underlag_detail": "{count} verifikationer utan underlag",
"stale_transactions": "Gamla transaktioner",
"stale_transactions_detail": "{count} transaktioner äldre än 14 dagar saknar bokföring",
+ "anomalies_title": "Avvikelser",
+ "anomalies_detail": "{count} att åtgärda",
"bank_consent_expiring": "Banksamtycke löper ut",
"bank_consent_detail_one": "{bank} — {days} dag kvar",
"bank_consent_detail_other": "{bank} — {days} dagar kvar"
@@ -3117,6 +3412,8 @@
"name_trial_balance": "Saldobalans",
"name_income_statement": "Resultaträkning",
"name_balance_sheet": "Balansräkning",
+ "name_kassaflodesanalys": "Kassaflödesanalys",
+ "name_arsredovisning": "Årsredovisning",
"name_vat_declaration": "Momsdeklaration",
"name_periodisk_sammanstallning": "Periodisk sammanställning",
"name_ne_declaration": "NE-bilaga",
@@ -3149,6 +3446,19 @@
"status_paid": "Betald",
"status_booked": "Bokförd"
},
+ "time_tracking": {
+ "title": "Tidrapportering",
+ "billable_inbox": "Fakturerbar tid per kund",
+ "create_invoice": "Skapa faktura",
+ "hourly_rate": "Timpris",
+ "project_required": "Välj projekt för att fakturera",
+ "no_project": "Inget projekt",
+ "no_billable_hours": "Ingen fakturerbar tid att fakturera. Markera dagar som fakturerbara för att samla dem här.",
+ "log_hours": "Logga tid",
+ "billable": "Fakturerbar",
+ "invoiced": "Fakturerad",
+ "missing_rate": "Saknar timpris"
+ },
"import": {
"title": "Importera",
"subtitle": "Importera banktransaktioner eller bokföringsdata till ditt företag",
diff --git a/next.config.ts b/next.config.ts
index 71572fb0..0a18fbbb 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -17,6 +17,13 @@ const cspDirectives = [
"img-src 'self' data: blob: https:",
"font-src 'self'",
"worker-src 'self' blob:",
+ // object-src must explicitly allow blob: — Chrome's built-in PDF viewer
+ // renders inline PDFs via an internal , which falls under
+ // object-src. Without this, blob:-URL invoice previews (created via
+ // URL.createObjectURL on /api/invoices/preview-pdf responses) show
+ // "Det här innehållet har blockerats" in Chrome. Firefox uses PDF.js and
+ // Edge uses its own viewer, so neither hits this. See crbug.com/271452.
+ "object-src 'self' blob:",
`frame-src 'self' blob: ${supabaseUrl}${activepiecesUrl ? ` ${activepiecesUrl}` : ""}`,
"frame-ancestors 'none'",
].join("; ");
@@ -54,11 +61,9 @@ const nextConfig: NextConfig = {
async headers() {
// The catch-all excludes /api/documents/:id/inline so the strict
// X-Frame-Options: DENY + frame-ancestors 'none' don't conflict with
- // the embeddable override below. Multiple matching header rules in
- // Next.js can end up sending duplicate header values to the browser
- // (Chrome/Firefox then fall back to the most restrictive), which was
- // showing up as "Det här innehållet har blockerats" in the verifikat
- // document preview Sheet.
+ // the embeddable override below — Next.js applies every matching
+ // header rule, and duplicate X-Frame-Options/CSP values trigger
+ // "Det här innehållet har blockerats" in Chromium browsers.
return [
{
source: "/((?!api/documents/[^/]+/inline$).*)",
@@ -93,6 +98,17 @@ const nextConfig: NextConfig = {
// iframes (used by the verifikat document preview Sheet). Excluded
// from the catch-all above so these values aren't shadowed by the
// stricter defaults.
+ //
+ // CSP is intentionally minimal: only `frame-ancestors 'self'`
+ // prevents cross-origin clickjacking on the user's documents.
+ // Adding `object-src 'none'` (or `default-src 'none'`) here breaks
+ // Chrome's built-in PDF viewer — Chrome renders inline PDFs through
+ // an internal , which the directive forbids, surfacing as
+ // "Det här innehållet har blockerats" in the document preview Sheet.
+ // Firefox uses PDF.js and Edge uses its own viewer, so neither hits
+ // this. See crbug.com/271452. X-Content-Type-Options: nosniff plus
+ // the explicit Content-Type from the route handler already prevent
+ // MIME-confusion abuse.
{
source: "/api/documents/:id/inline",
headers: [
@@ -118,7 +134,7 @@ const nextConfig: NextConfig = {
},
{
key: "Content-Security-Policy",
- value: "default-src 'none'; script-src 'none'; object-src 'none'; frame-ancestors 'self'",
+ value: "frame-ancestors 'self'",
},
],
},
diff --git a/supabase/migrations/20260522110000_add_customer_type.sql b/supabase/migrations/20260522110000_add_customer_type.sql
new file mode 100644
index 00000000..f44e097a
--- /dev/null
+++ b/supabase/migrations/20260522110000_add_customer_type.sql
@@ -0,0 +1,24 @@
+-- Add customer_type to customers.
+--
+-- Distinguishes individual customers (private persons, ROT/RUT eligible),
+-- Swedish businesses, EU businesses (VIES VAT validation eligible), and
+-- non-EU businesses. Drives VAT treatment selection on invoices and which
+-- identifier (personnummer vs org_number) the UI surfaces.
+--
+-- Note: this column was previously created directly in production without
+-- a migration. This migration backfills the missing definition so that
+-- staging, preview branches, and CI databases match prod. Existing prod
+-- rows already carry valid values (individual/swedish_business/eu_business/
+-- non_eu_business) so the CHECK constraint passes against current data.
+--
+-- Application validation: lib/api/schemas.ts CustomerTypeSchema enforces
+-- the enum at the API boundary; the DB constraint is defense-in-depth.
+
+ALTER TABLE public.customers
+ ADD COLUMN IF NOT EXISTS customer_type TEXT NOT NULL DEFAULT 'individual';
+
+ALTER TABLE public.customers DROP CONSTRAINT IF EXISTS customers_customer_type_check;
+ALTER TABLE public.customers ADD CONSTRAINT customers_customer_type_check
+ CHECK (customer_type IN ('individual', 'swedish_business', 'eu_business', 'non_eu_business'));
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260522110100_add_invoice_items_vat_columns.sql b/supabase/migrations/20260522110100_add_invoice_items_vat_columns.sql
new file mode 100644
index 00000000..a5d413c5
--- /dev/null
+++ b/supabase/migrations/20260522110100_add_invoice_items_vat_columns.sql
@@ -0,0 +1,23 @@
+-- Add vat_rate and vat_amount to invoice_items.
+--
+-- These columns hold the per-line VAT rate and computed VAT amount and
+-- back the mixed-rate invoice support documented in CLAUDE.md
+-- (generatePerRateLines + getAvailableVatRates). They existed in
+-- production since the early invoicing work but were never declared by
+-- a migration, so staging, preview branches, and CI databases booted
+-- from a fresh migration replay were missing them.
+--
+-- Defaults match the prod shape: vat_rate defaults to 25 (Swedish
+-- standard rate), vat_amount defaults to 0 (lines are recomputed by the
+-- engine before persistence). Both NOT NULL because every committed
+-- invoice line must carry a deterministic VAT figure for VAT-declaration
+-- ruta mapping.
+--
+-- Application validation: lib/invoices/vat-rules.ts caps vat_rate to
+-- the rates allowed for the customer type at the API boundary.
+
+ALTER TABLE public.invoice_items
+ ADD COLUMN IF NOT EXISTS vat_rate NUMERIC NOT NULL DEFAULT 25,
+ ADD COLUMN IF NOT EXISTS vat_amount NUMERIC NOT NULL DEFAULT 0;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260526120000_fix_replace_sie_import_hard_delete.sql b/supabase/migrations/20260526120000_fix_replace_sie_import_hard_delete.sql
new file mode 100644
index 00000000..e0a3221a
--- /dev/null
+++ b/supabase/migrations/20260526120000_fix_replace_sie_import_hard_delete.sql
@@ -0,0 +1,173 @@
+-- Rewrite replace_sie_import to hard-delete the prior import's entries
+-- instead of soft-cancelling them.
+--
+-- The previous implementation (20260415000000_schema_sync.sql) only set
+-- status='cancelled' on the affected journal_entries. Cancelled rows kept
+-- their voucher_numbers in the partial unique index
+-- uq_journal_entries_voucher_number, and voucher_sequences.last_number was
+-- never reset, so each successive re-import had to claim a fresh, higher
+-- range (A27, A28...) and any document the user had attached was left
+-- pinned to a now-invisible cancelled entry.
+--
+-- The new behaviour mirrors the manual cleanup we use for stuck tenants:
+-- 1. detach documents (PDFs stay in storage, become unlinked)
+-- 2. clear fiscal_periods.opening_balance_entry_id if it pointed to this
+-- import's OB entry (two-step around enforce_opening_balance_immutability)
+-- 3. clear sie_imports.opening_balance_entry_id on the replaced row
+-- 4. hard-delete all source_type='import' entries (posted OR previously
+-- cancelled) in the period; lines cascade
+-- 5. reset voucher_sequences.last_number per series to MAX(remaining
+-- voucher_number) or 0 -- handles interleaved manual/bank_transaction
+-- entries safely
+-- 6. mark sie_imports status='replaced', replaced_at=now()
+--
+-- Audit trail is preserved via:
+-- * sie_imports row (status, replaced_at, filename, file_hash,
+-- transactions_count, fiscal_year_start/end)
+-- * audit_log entries written automatically by the existing
+-- write_audit_log trigger on each journal_entries DELETE (old_state
+-- JSONB snapshot per row)
+--
+-- The gnubok.allow_delete='true' GUC is set transaction-local via
+-- set_config(..., is_local=true) and is honored by:
+-- * enforce_journal_entry_immutability (allows DELETE)
+-- * enforce_journal_entry_line_immutability (allows cascade DELETE)
+-- * enforce_retention_journal_entries (allows DELETE within 7y window)
+-- * enforce_document_journal_entry_immutability (allows clearing
+-- journal_entry_id on document_attachments)
+-- * enforce_document_metadata_immutability (allows metadata change on
+-- docs linked to posted entries)
+--
+-- enforce_opening_balance_immutability does NOT honor the GUC -- worked
+-- around by flipping opening_balances_set to false first, then clearing
+-- opening_balance_entry_id in a separate UPDATE.
+
+CREATE OR REPLACE FUNCTION public.replace_sie_import(p_company_id uuid, p_import_id uuid)
+ RETURNS integer
+ LANGUAGE plpgsql
+ SECURITY DEFINER
+ SET search_path TO 'public'
+AS $function$
+DECLARE
+ v_fiscal_period_id uuid;
+ v_opening_balance_entry_id uuid;
+ v_is_closed boolean;
+ v_locked_at timestamptz;
+ v_deleted integer := 0;
+BEGIN
+ SELECT fiscal_period_id, opening_balance_entry_id
+ INTO v_fiscal_period_id, v_opening_balance_entry_id
+ FROM public.sie_imports
+ WHERE id = p_import_id
+ AND company_id = p_company_id
+ AND status = 'completed';
+
+ IF NOT FOUND THEN
+ RAISE EXCEPTION 'Import % not found or not in completed status', p_import_id;
+ END IF;
+
+ IF v_fiscal_period_id IS NOT NULL THEN
+ SELECT is_closed, locked_at
+ INTO v_is_closed, v_locked_at
+ FROM public.fiscal_periods
+ WHERE id = v_fiscal_period_id;
+
+ IF v_is_closed OR v_locked_at IS NOT NULL THEN
+ RAISE EXCEPTION 'Cannot replace SIE import in a locked or closed fiscal period';
+ END IF;
+ END IF;
+
+ PERFORM set_config('gnubok.allow_delete', 'true', true);
+
+ -- Detach any documents the user attached to the import's entries.
+ -- Files stay in Supabase storage; the document rows become unlinked
+ -- and can be re-attached after the next import. We cover both
+ -- entry-level and line-level attachments because both FKs are RESTRICT
+ -- and the line variant would otherwise block the cascade delete below.
+ UPDATE public.document_attachments
+ SET journal_entry_id = NULL,
+ journal_entry_line_id = NULL
+ WHERE journal_entry_id IN (
+ SELECT je.id
+ FROM public.journal_entries je
+ WHERE je.company_id = p_company_id
+ AND je.fiscal_period_id = v_fiscal_period_id
+ AND je.source_type = 'import'
+ AND je.status IN ('posted', 'cancelled')
+ )
+ OR journal_entry_line_id IN (
+ SELECT jel.id
+ FROM public.journal_entry_lines jel
+ JOIN public.journal_entries je ON je.id = jel.journal_entry_id
+ WHERE je.company_id = p_company_id
+ AND je.fiscal_period_id = v_fiscal_period_id
+ AND je.source_type = 'import'
+ AND je.status IN ('posted', 'cancelled')
+ );
+
+ -- Clear the fiscal-period OB pointer (if it came from this import).
+ -- enforce_opening_balance_immutability blocks the change unless we
+ -- flip opening_balances_set to false in a separate statement first --
+ -- the trigger only raises when both opening_balances_set was true AND
+ -- the id is being changed in the same UPDATE.
+ IF v_opening_balance_entry_id IS NOT NULL THEN
+ UPDATE public.fiscal_periods
+ SET opening_balances_set = false
+ WHERE id = v_fiscal_period_id
+ AND opening_balance_entry_id = v_opening_balance_entry_id;
+
+ UPDATE public.fiscal_periods
+ SET opening_balance_entry_id = NULL
+ WHERE id = v_fiscal_period_id
+ AND opening_balance_entry_id = v_opening_balance_entry_id;
+ END IF;
+
+ -- Drop the sie_imports -> opening_balance_entry FK before we delete the
+ -- entry it points to (FK is SET NULL on delete, but explicit clear is
+ -- clearer and avoids relying on cascade ordering).
+ UPDATE public.sie_imports
+ SET opening_balance_entry_id = NULL
+ WHERE id = p_import_id;
+
+ -- Hard-delete the import's journal entries. Lines cascade. The
+ -- 'cancelled' predicate vacuums stragglers from any prior soft-replace
+ -- so re-fixing a doubly-replaced period also cleans up the residue.
+ WITH deleted AS (
+ DELETE FROM public.journal_entries
+ WHERE company_id = p_company_id
+ AND fiscal_period_id = v_fiscal_period_id
+ AND source_type = 'import'
+ AND status IN ('posted', 'cancelled')
+ RETURNING id
+ )
+ SELECT count(*) INTO v_deleted FROM deleted;
+
+ -- Reset voucher_sequences for the period. For each series, set
+ -- last_number to the max remaining voucher_number (or 0 if none) so
+ -- the next next_voucher_number() call yields the right number whether
+ -- the user re-imports straight away (starts at 1) or interleaved
+ -- manual entries already occupy higher numbers in the series.
+ UPDATE public.voucher_sequences vs
+ SET last_number = COALESCE((
+ SELECT MAX(je.voucher_number)
+ FROM public.journal_entries je
+ WHERE je.company_id = vs.company_id
+ AND je.fiscal_period_id = vs.fiscal_period_id
+ AND je.voucher_series = vs.voucher_series
+ AND je.voucher_number > 0
+ ), 0),
+ updated_at = now()
+ WHERE vs.company_id = p_company_id
+ AND vs.fiscal_period_id = v_fiscal_period_id;
+
+ UPDATE public.sie_imports
+ SET status = 'replaced',
+ replaced_at = now()
+ WHERE id = p_import_id
+ AND company_id = p_company_id;
+
+ RETURN v_deleted;
+END;
+$function$;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260526120100_restvardeavskrivning.sql b/supabase/migrations/20260526120100_restvardeavskrivning.sql
new file mode 100644
index 00000000..f1ce524f
--- /dev/null
+++ b/supabase/migrations/20260526120100_restvardeavskrivning.sql
@@ -0,0 +1,62 @@
+-- Migration: extend depreciation_method enum to allow 'restvardesavskrivning_25'
+-- and add restvarde_target column on assets.
+--
+-- Background: the 20260516120000_assets_and_depreciation.sql migration
+-- already reserved the 'declining_balance_30' and 'declining_balance_20'
+-- enum values but the API/engine only implemented planenlig linjär
+-- avskrivning. Easy-win #3 adds the three remaining Swedish methods used in
+-- skattemässig avskrivning:
+--
+-- * declining_balance_30 — räkenskapsenlig huvudregel (IL 18 kap 13§)
+-- 30% degressivt på avskrivningsunderlag
+-- * declining_balance_20 — räkenskapsenlig kompletteringsregel
+-- (IL 18 kap 17§), 20% linjärt på anskaffning
+-- -- typically used on byggnader/markanläggning
+-- when book = tax depreciation
+-- * restvardesavskrivning_25 — fallback method (IL 18 kap 13§ st.3) when
+-- the räkenskapsenlig requirements (ordnad
+-- bokföring + book=tax) cannot be met. Capped
+-- at 25% declining; the asset never fully
+-- depreciates so a restvärde_target is required
+-- so we know when to stop charging.
+--
+-- restvarde_target stores the explicit floor for restvärdeavskrivning. It is
+-- distinct from the existing `salvage_value` column (which is the salvage
+-- subtracted from the linear depreciable base) because the restvärde method
+-- caps the *remaining book value* at the target rather than reducing the
+-- depreciable base.
+
+-- Replace the existing CHECK constraint to add 'restvardesavskrivning_25'.
+-- The constraint name is auto-generated; drop by re-listing the original
+-- definition. We use the column-level approach (DROP/ADD CONSTRAINT) so we
+-- don't rely on the catalog name.
+ALTER TABLE public.assets
+ DROP CONSTRAINT IF EXISTS assets_depreciation_method_check;
+
+ALTER TABLE public.assets
+ ADD CONSTRAINT assets_depreciation_method_check
+ CHECK (depreciation_method IN (
+ 'linear',
+ 'declining_balance_30',
+ 'declining_balance_20',
+ 'restvardesavskrivning_25'
+ ));
+
+-- restvarde_target: the book-value floor for restvärdeavskrivning. Required
+-- iff method = 'restvardesavskrivning_25'; null otherwise.
+ALTER TABLE public.assets
+ ADD COLUMN restvarde_target NUMERIC(15, 2) NULL CHECK (restvarde_target IS NULL OR restvarde_target >= 0);
+
+-- Enforce the "required iff" relationship between method and restvarde_target.
+-- The biconditional avoids two failure modes:
+-- 1. Method = restvärde but no target → engine would loop charging 25% of
+-- the remaining book value with no floor (asymptotic to zero).
+-- 2. Target set but method != restvärde → dead column; misleading state if
+-- the user switches methods later.
+ALTER TABLE public.assets
+ ADD CONSTRAINT assets_restvarde_target_method_match
+ CHECK (
+ (depreciation_method = 'restvardesavskrivning_25') = (restvarde_target IS NOT NULL)
+ );
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260526120200_invoice_branding.sql b/supabase/migrations/20260526120200_invoice_branding.sql
new file mode 100644
index 00000000..9d3441d3
--- /dev/null
+++ b/supabase/migrations/20260526120200_invoice_branding.sql
@@ -0,0 +1,41 @@
+-- Per-company invoice branding: primary/accent color, font family, optional
+-- header/footer text. Applied to the invoice PDF and the customer email.
+--
+-- Defaults preserve current behavior:
+-- - primary color #1a1a1a (the existing PDF heading color)
+-- - accent color #666666 (the existing muted label color)
+-- - font family 'Helvetica' (the existing react-pdf built-in)
+--
+-- Font allowlist is restricted to the three react-pdf built-in PostScript
+-- fonts (Helvetica, Times-Roman, Courier). This keeps us AGPL-clean — no
+-- proprietary font binaries are bundled or fetched at render time.
+--
+-- Hex color format is enforced with a regex CHECK so invalid colors never
+-- reach the PDF renderer (which would silently fall back to black).
+
+ALTER TABLE public.company_settings
+ ADD COLUMN IF NOT EXISTS invoice_primary_color TEXT NOT NULL DEFAULT '#1a1a1a',
+ ADD COLUMN IF NOT EXISTS invoice_accent_color TEXT NOT NULL DEFAULT '#666666',
+ ADD COLUMN IF NOT EXISTS invoice_font_family TEXT NOT NULL DEFAULT 'Helvetica',
+ ADD COLUMN IF NOT EXISTS invoice_header_text TEXT NULL,
+ ADD COLUMN IF NOT EXISTS invoice_footer_text TEXT NULL;
+
+ALTER TABLE public.company_settings
+ DROP CONSTRAINT IF EXISTS company_settings_invoice_font_check;
+ALTER TABLE public.company_settings
+ ADD CONSTRAINT company_settings_invoice_font_check
+ CHECK (invoice_font_family IN ('Helvetica', 'Times-Roman', 'Courier'));
+
+ALTER TABLE public.company_settings
+ DROP CONSTRAINT IF EXISTS company_settings_primary_color_format;
+ALTER TABLE public.company_settings
+ ADD CONSTRAINT company_settings_primary_color_format
+ CHECK (invoice_primary_color ~ '^#[0-9A-Fa-f]{6}$');
+
+ALTER TABLE public.company_settings
+ DROP CONSTRAINT IF EXISTS company_settings_accent_color_format;
+ALTER TABLE public.company_settings
+ ADD CONSTRAINT company_settings_accent_color_format
+ CHECK (invoice_accent_color ~ '^#[0-9A-Fa-f]{6}$');
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260526120300_asset_disposal_vat_jamkning.sql b/supabase/migrations/20260526120300_asset_disposal_vat_jamkning.sql
new file mode 100644
index 00000000..3f990ead
--- /dev/null
+++ b/supabase/migrations/20260526120300_asset_disposal_vat_jamkning.sql
@@ -0,0 +1,59 @@
+-- Asset disposal: VAT + jämkning (Item 2)
+--
+-- Extends the assets table with the data needed to post a Swedish-compliant
+-- avyttring:
+--
+-- 1) Output VAT on proceeds (ML 3 kap 3 § / 7 kap 3 §).
+-- When an anläggningstillgång that had right-to-deduct VAT on
+-- acquisition is sold to a VAT-registered domestic counterparty,
+-- proceeds are momspliktig at the rate corresponding to the asset's VAT
+-- treatment. The two new columns capture the VAT amount and the
+-- treatment that was applied so SIE export, BAS 26xx reports, and audit
+-- trail all carry the same data.
+--
+-- 2) Jämkning (ML 8a kap 4-7 §§, formerly ML 9 kap 8-11 §§ pre-2023).
+-- When an asset that had input VAT deducted at acquisition is disposed
+-- of within the korrigeringstid (5 år / 60 mån för lös egendom, 10 år /
+-- 120 mån för fastighet och markanläggning), part of the original input
+-- VAT must be paid back. The four jamkning_* columns record the inputs
+-- to the calculation so the audit trail shows how the number was
+-- arrived at — purely descriptive metadata; the actual booking sits on
+-- a journal entry line against 2641.
+--
+-- All new columns default to safe zeros / nulls so existing assets without
+-- disposal data continue to satisfy the schema without backfilling.
+
+ALTER TABLE public.assets
+ ADD COLUMN disposed_proceeds_vat NUMERIC(15, 2) NOT NULL DEFAULT 0,
+ ADD COLUMN disposed_vat_treatment TEXT NULL,
+ ADD COLUMN jamkning_amount NUMERIC(15, 2) NOT NULL DEFAULT 0,
+ ADD COLUMN jamkning_remaining_months INT NULL,
+ ADD COLUMN jamkning_total_months INT NULL,
+ ADD COLUMN jamkning_original_input_vat NUMERIC(15, 2) NULL;
+
+-- Restrict the VAT treatment to the same enum the bookkeeping engine
+-- already understands. Mirrors the VatTreatment type in lib/bookkeeping/
+-- vat-entries.ts so the API and engine stay in sync.
+ALTER TABLE public.assets
+ ADD CONSTRAINT assets_disposed_vat_treatment_check
+ CHECK (
+ disposed_vat_treatment IS NULL OR disposed_vat_treatment IN (
+ 'standard_25',
+ 'reduced_12',
+ 'reduced_6',
+ 'reverse_charge',
+ 'export',
+ 'exempt'
+ )
+ );
+
+-- Treatment is required whenever a VAT amount is recorded. A nonzero
+-- proceeds_vat without an explicit treatment would be impossible to map
+-- back to a BAS 26xx account at SIE export / audit time.
+ALTER TABLE public.assets
+ ADD CONSTRAINT assets_disposed_vat_consistency
+ CHECK (
+ (disposed_proceeds_vat = 0) OR (disposed_vat_treatment IS NOT NULL)
+ );
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260526120400_drojsmalsranta_paminnelseavgift.sql b/supabase/migrations/20260526120400_drojsmalsranta_paminnelseavgift.sql
new file mode 100644
index 00000000..49986dc3
--- /dev/null
+++ b/supabase/migrations/20260526120400_drojsmalsranta_paminnelseavgift.sql
@@ -0,0 +1,90 @@
+-- Dröjsmålsränta + lagstadgad påminnelseavgift on invoice reminders.
+--
+-- When a payment reminder is sent we now compute:
+-- 1) Statutory late-payment interest per Räntelagen §6
+-- (Riksbankens referensränta + 8 procentenheter, or company override)
+-- 2) The lagstadgad påminnelseavgift (default 60 kr per Lag 1981:739)
+--
+-- The fee is booked as a journal entry (debit 1510 Kundfordringar,
+-- credit 3990 Övriga ersättningar, bidrag och intäkter) so it shows up
+-- on the customer's open balance and recognises the income on the income
+-- statement. The interest is computed and persisted for display in the
+-- email + on the public action page; we do NOT book interest as a
+-- journal entry on reminder send — interest is recognised on payment
+-- (when the customer actually pays the surcharge) to avoid recognising
+-- revenue we may never collect.
+--
+-- Columns:
+-- invoice_reminders.interest_amount — computed dröjsmålsränta (SEK)
+-- invoice_reminders.interest_rate — annual rate applied (e.g. 0.115 = 11.5%)
+-- invoice_reminders.interest_from_date — start date for interest calc (= invoice due_date)
+-- invoice_reminders.interest_days — number of overdue days used in the calc
+-- invoice_reminders.reminder_fee — lagstadgad påminnelseavgift booked
+-- invoice_reminders.fee_journal_entry_id — link to the verifikation that booked the fee
+--
+-- company_settings.reminder_fee_enabled — kill switch for the fee
+-- company_settings.reminder_fee_amount — default 60 kr (statutory cap, Lag 1981:739)
+-- company_settings.reminder_interest_rate_override — null = use Räntelagen §6 lookup
+--
+-- We also extend journal_entries.source_type to allow 'reminder_fee'
+-- so the fee posting passes the CHECK constraint.
+
+-- ---------------------------------------------------------------------------
+-- invoice_reminders: new columns for interest + fee
+-- ---------------------------------------------------------------------------
+ALTER TABLE public.invoice_reminders
+ ADD COLUMN IF NOT EXISTS interest_amount NUMERIC(10,2) NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS interest_rate NUMERIC(6,4) NULL,
+ ADD COLUMN IF NOT EXISTS interest_from_date DATE NULL,
+ ADD COLUMN IF NOT EXISTS interest_days INT NULL,
+ ADD COLUMN IF NOT EXISTS reminder_fee NUMERIC(10,2) NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS fee_journal_entry_id UUID NULL
+ REFERENCES public.journal_entries(id) ON DELETE SET NULL;
+
+-- ---------------------------------------------------------------------------
+-- company_settings: per-company toggles + override
+-- ---------------------------------------------------------------------------
+ALTER TABLE public.company_settings
+ ADD COLUMN IF NOT EXISTS reminder_fee_enabled BOOLEAN NOT NULL DEFAULT TRUE,
+ ADD COLUMN IF NOT EXISTS reminder_fee_amount NUMERIC(10,2) NOT NULL DEFAULT 60,
+ ADD COLUMN IF NOT EXISTS reminder_interest_rate_override NUMERIC(6,4) NULL;
+
+ALTER TABLE public.company_settings
+ DROP CONSTRAINT IF EXISTS company_settings_reminder_fee_check;
+ALTER TABLE public.company_settings
+ ADD CONSTRAINT company_settings_reminder_fee_check
+ CHECK (reminder_fee_amount >= 0);
+
+ALTER TABLE public.company_settings
+ DROP CONSTRAINT IF EXISTS company_settings_reminder_interest_override_check;
+ALTER TABLE public.company_settings
+ ADD CONSTRAINT company_settings_reminder_interest_override_check
+ CHECK (
+ reminder_interest_rate_override IS NULL
+ OR (reminder_interest_rate_override >= 0 AND reminder_interest_rate_override < 1)
+ );
+
+-- ---------------------------------------------------------------------------
+-- journal_entries: extend source_type to include 'reminder_fee'
+-- ---------------------------------------------------------------------------
+-- See 20260516060000 for the previous expansion pattern. We preserve all
+-- pre-existing source_type values and append the new one.
+ALTER TABLE public.journal_entries
+ DROP CONSTRAINT IF EXISTS journal_entries_source_type_check;
+
+ALTER TABLE public.journal_entries
+ ADD CONSTRAINT journal_entries_source_type_check
+ CHECK (source_type IN (
+ 'manual', 'bank_transaction', 'invoice_created',
+ 'invoice_paid', 'invoice_cash_payment', 'credit_note', 'salary_payment',
+ 'opening_balance', 'year_end',
+ 'storno', 'correction', 'import', 'system',
+ 'inbox_item',
+ 'supplier_invoice_registered', 'supplier_invoice_paid',
+ 'supplier_invoice_cash_payment', 'supplier_credit_note',
+ 'currency_revaluation',
+ 'supplier_invoice_privately_paid',
+ 'reminder_fee'
+ ));
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260526120700_voucher_series_defaults.sql b/supabase/migrations/20260526120700_voucher_series_defaults.sql
new file mode 100644
index 00000000..40e22b8a
--- /dev/null
+++ b/supabase/migrations/20260526120700_voucher_series_defaults.sql
@@ -0,0 +1,42 @@
+-- Migration: Voucher series defaults per source type
+-- Item 8 of make-a-detailed-plan-fancy-pie.md
+--
+-- The schema and RPCs already support multi-series voucher numbering
+-- (voucher_sequences.voucher_series, journal_entries.voucher_series,
+-- next_voucher_number(p_series)). This migration exposes the per-source
+-- defaulting mapping in company_settings so users can configure which
+-- series each journal_entries.source_type lands on by default.
+--
+-- All values default to 'A' to preserve current behavior. Common Swedish
+-- conventions (B for supplier invoices, C for salaries) can be applied
+-- by the user via the settings UI without a code change.
+
+ALTER TABLE public.company_settings
+ ADD COLUMN default_voucher_series_per_source_type JSONB NOT NULL DEFAULT '{
+ "manual": "A",
+ "invoice_created": "A",
+ "invoice_paid": "A",
+ "invoice_cash_payment": "A",
+ "credit_note": "A",
+ "supplier_invoice_registered": "A",
+ "supplier_invoice_paid": "A",
+ "supplier_invoice_cash_payment": "A",
+ "supplier_invoice_privately_paid": "A",
+ "supplier_credit_note": "A",
+ "salary_payment": "A",
+ "bank_transaction": "A",
+ "reminder_fee": "A",
+ "opening_balance": "A",
+ "year_end": "A",
+ "currency_revaluation": "A",
+ "inbox_item": "A",
+ "import": "A",
+ "system": "A",
+ "storno": "A",
+ "correction": "A"
+ }'::jsonb;
+
+COMMENT ON COLUMN public.company_settings.default_voucher_series_per_source_type IS
+ 'Maps journal_entries.source_type -> default voucher_series (single uppercase letter A-Z). Read by lib/bookkeeping/voucher-series-resolver.ts; written via /api/settings. Defaults to all "A" to preserve legacy single-series behaviour. Common Swedish conventions: supplier_invoice_* -> "B", salary_payment -> "C".';
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260526120900_ob_overtime_premiums.sql b/supabase/migrations/20260526120900_ob_overtime_premiums.sql
new file mode 100644
index 00000000..5aeae6e2
--- /dev/null
+++ b/supabase/migrations/20260526120900_ob_overtime_premiums.sql
@@ -0,0 +1,116 @@
+-- =============================================================================
+-- OB-tillägg & övertid: shift-premium automation
+-- =============================================================================
+--
+-- Adds:
+-- 1. New salary_line_items.item_type values for shift premiums (OB) and
+-- tiered overtime (övertid 50 %/100 %).
+-- 2. Optional start_time / end_time columns on salary_worked_days so the
+-- calculator can match per-shift windows against premium rules. Legacy
+-- rows (NULL times) fall back to a default-shift assumption inside the
+-- engine (08:00–17:00) — pure-night/weekend rules will not trigger for
+-- hours-only days, which mirrors how those rows were intended to behave
+-- before this migration.
+-- 3. shift_premium_rules — per-company configuration of when and how much
+-- to top up the base hourly rate. Either applies to all employees or a
+-- filtered list. Multiple rules may match a shift; the engine prefers
+-- higher priority and tie-breaks on higher premium_percent. ISO weekday
+-- encoding (1 = Monday … 7 = Sunday) matches PostgreSQL's
+-- extract(isodow from date), so server-side queries can filter natively.
+--
+-- CHECK migration note: the new item_type CHECK preserves every existing
+-- value (including gross_deduction_*, net_deduction_advance/benefit_payment,
+-- semesterersattning, benefit_bike). Adding values without listing the
+-- originals would drop rows on commit.
+
+ALTER TABLE public.salary_line_items DROP CONSTRAINT IF EXISTS salary_line_items_item_type_check;
+
+ALTER TABLE public.salary_line_items
+ ADD CONSTRAINT salary_line_items_item_type_check
+ CHECK (item_type IN (
+ 'monthly_salary', 'hourly_salary',
+ 'overtime', 'overtime_50', 'overtime_100',
+ 'ob_weekday_evening', 'ob_weekend', 'ob_night', 'ob_holiday',
+ 'bonus', 'commission',
+ 'gross_deduction_pension', 'gross_deduction_other',
+ 'benefit_car', 'benefit_housing', 'benefit_meals', 'benefit_wellness', 'benefit_bike', 'benefit_other',
+ 'sick_karens', 'sick_day2_14', 'sick_day15_plus',
+ 'vab', 'parental_leave', 'vacation', 'semesterersattning',
+ 'traktamente_taxfree', 'traktamente_taxable',
+ 'mileage_taxfree', 'mileage_taxable',
+ 'net_deduction_advance', 'net_deduction_union', 'net_deduction_benefit_payment', 'net_deduction_other',
+ 'correction', 'other'
+ ));
+
+-- --------------------------------------------------------------------------
+-- Per-shift time columns on salary_worked_days
+-- --------------------------------------------------------------------------
+-- Nullable so existing hours-only rows continue to work. When both times are
+-- set the engine uses the explicit overlap with the rule window; when either
+-- is NULL the engine falls back to a default-shift assumption.
+
+ALTER TABLE public.salary_worked_days
+ ADD COLUMN IF NOT EXISTS start_time TIME NULL,
+ ADD COLUMN IF NOT EXISTS end_time TIME NULL;
+
+-- --------------------------------------------------------------------------
+-- shift_premium_rules
+-- --------------------------------------------------------------------------
+
+CREATE TABLE IF NOT EXISTS public.shift_premium_rules (
+ id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
+ company_id UUID NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ applies_to_all_employees BOOLEAN NOT NULL DEFAULT TRUE,
+ applies_to_employee_ids UUID[] NOT NULL DEFAULT '{}',
+ -- ISO weekday array: 1 = Monday … 7 = Sunday. Matches extract(isodow from x).
+ day_of_week INT[] NOT NULL,
+ start_time TIME NOT NULL,
+ end_time TIME NOT NULL,
+ premium_percent NUMERIC(5, 2) NOT NULL
+ CHECK (premium_percent >= 0 AND premium_percent <= 500),
+ item_type TEXT NOT NULL,
+ priority INT NOT NULL DEFAULT 0,
+ is_active BOOLEAN NOT NULL DEFAULT TRUE,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ created_by UUID REFERENCES auth.users(id),
+ -- Only allow the dedicated premium types here. 'overtime' (untyped) is
+ -- excluded because manually-flagged overtime line items do not need a rule;
+ -- premium rules are exclusively for the new tiered + OB families.
+ CONSTRAINT shift_premium_rules_item_type_check CHECK (item_type IN (
+ 'overtime_50', 'overtime_100',
+ 'ob_weekday_evening', 'ob_weekend', 'ob_night', 'ob_holiday'
+ )),
+ -- Day-of-week array must contain at least one ISO day in [1, 7].
+ CONSTRAINT shift_premium_rules_day_of_week_check CHECK (
+ array_length(day_of_week, 1) >= 1
+ AND array_length(day_of_week, 1) <= 7
+ AND day_of_week <@ ARRAY[1, 2, 3, 4, 5, 6, 7]
+ )
+);
+
+CREATE INDEX IF NOT EXISTS idx_shift_premium_rules_company
+ ON public.shift_premium_rules (company_id)
+ WHERE is_active = TRUE;
+
+ALTER TABLE public.shift_premium_rules ENABLE ROW LEVEL SECURITY;
+
+CREATE POLICY "shift_premium_rules_select" ON public.shift_premium_rules
+ FOR SELECT USING (company_id IN (SELECT public.user_company_ids()));
+
+CREATE POLICY "shift_premium_rules_insert" ON public.shift_premium_rules
+ FOR INSERT WITH CHECK (company_id IN (SELECT public.user_company_ids()));
+
+CREATE POLICY "shift_premium_rules_update" ON public.shift_premium_rules
+ FOR UPDATE USING (company_id IN (SELECT public.user_company_ids()))
+ WITH CHECK (company_id IN (SELECT public.user_company_ids()));
+
+CREATE POLICY "shift_premium_rules_delete" ON public.shift_premium_rules
+ FOR DELETE USING (company_id IN (SELECT public.user_company_ids()));
+
+CREATE TRIGGER shift_premium_rules_updated_at
+ BEFORE UPDATE ON public.shift_premium_rules
+ FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260526121500_k3_framework.sql b/supabase/migrations/20260526121500_k3_framework.sql
new file mode 100644
index 00000000..9ce138d8
--- /dev/null
+++ b/supabase/migrations/20260526121500_k3_framework.sql
@@ -0,0 +1,26 @@
+-- Add accounting_framework to companies.
+--
+-- Swedish AB can apply either K2 (BFNAR 2016:10, simplified ruleset for
+-- mindre företag) or K3 (BFNAR 2012:1, full principles-based). K2 is the
+-- default and what most of our customers use. K3 becomes mandatory when the
+-- company crosses any two of the three K2 thresholds (turnover >80 MSEK,
+-- assets >40 MSEK, employees >50) and is permitted earlier on a voluntary
+-- basis. The choice has substantial downstream effects (component
+-- depreciation, latent tax recognition, cash flow statement, more noter).
+--
+-- Only meaningful for entity_type='aktiebolag'. Enskild firma stays on the
+-- simpler EF rules and does not consume this column. The application keeps
+-- the K3 settings UI hidden for EF entities.
+--
+-- Default 'k2' so existing AB rows keep the current behavior (the engine and
+-- arsredovisning builder were written against K2 assumptions). Switching
+-- K2 → K3 is a deliberate user action gated by a confirmation dialog.
+
+ALTER TABLE public.companies
+ ADD COLUMN accounting_framework TEXT NOT NULL DEFAULT 'k2'
+ CHECK (accounting_framework IN ('k2', 'k3'));
+
+COMMENT ON COLUMN public.companies.accounting_framework IS
+ 'Swedish accounting framework. K2 (BFNAR 2016:10) is the simplified ruleset for small AB and the default. K3 (BFNAR 2012:1) is required for medium-to-large AB and required-when-larger-than-K2-thresholds. Only meaningful for entity_type=aktiebolag.';
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260526121600_latent_tax.sql b/supabase/migrations/20260526121600_latent_tax.sql
new file mode 100644
index 00000000..978f40f5
--- /dev/null
+++ b/supabase/migrations/20260526121600_latent_tax.sql
@@ -0,0 +1,22 @@
+-- Latent tax (uppskjuten skatt) — placeholder migration for K3 framework.
+--
+-- The chart-of-accounts already seeds the two relevant BAS 2026 accounts via
+-- seed_chart_of_accounts() when a company is created:
+--
+-- 2240 Avsättningar för uppskjutna skatter (liability, K3-only)
+-- 8940 Uppskjuten skatt (expense, K3-only)
+--
+-- Both rows in lib/bookkeeping/bas-data/* carry k2_excluded=true so K2
+-- companies never see them in their chart. When an AB switches to K3 the
+-- application layer (settings handler) is responsible for inserting these
+-- two rows into chart_of_accounts for the company if they are not already
+-- present. We deliberately do NOT backfill them in this migration because
+-- (a) existing AB rows still default to K2 — those companies do not need
+-- the accounts until they opt-in, and (b) opt-in is a deliberate user
+-- action that should drive the seed, not a one-off DDL run.
+--
+-- This file exists to keep migration timestamps sequential alongside
+-- 20260526121500_k3_framework.sql, and to document the BAS account choice
+-- so future maintainers can find it.
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260526121700_rot_rut_avdrag.sql b/supabase/migrations/20260526121700_rot_rut_avdrag.sql
new file mode 100644
index 00000000..dc53d3c5
--- /dev/null
+++ b/supabase/migrations/20260526121700_rot_rut_avdrag.sql
@@ -0,0 +1,64 @@
+-- ROT/RUT-avdrag on invoices.
+--
+-- Adds per-item deduction flags (ROT or RUT) and invoice-level claim info
+-- (customer personnummer + housing designation). The system computes the
+-- deduction amount per item and posts a receivable from Skatteverket on
+-- BAS 1513 (Övriga kortfristiga fordringar — kund / Skatteverket). The
+-- customer pays only the post-deduction amount; Skatteverket pays the
+-- rest later via Husavdragstjänsten (XML/SOAP submission is out of scope
+-- for v1 — this migration only enables the booking + PDF rendering).
+--
+-- Personnummer is sensitive PII. We never store it in plaintext: only
+-- the AES-256-GCM ciphertext (`deduction_personnummer_encrypted`) plus
+-- the last four digits (`deduction_personnummer_last4`) for display.
+-- The encryption helper lives at lib/salary/personnummer.ts and is
+-- reused unchanged for ROT/RUT.
+--
+-- Cross-table consistency (if any item carries deduction_type, the
+-- invoice MUST carry an encrypted personnummer; ROT specifically also
+-- requires housing_designation) is enforced at the API layer via Zod
+-- and lib/invoices/rot-rut-rules.ts — Postgres CHECK constraints cannot
+-- span tables without expensive triggers, and the API path is the only
+-- way to create an invoice with deduction lines (per-row writes via
+-- service role still go through the same engine).
+
+-- 1. Per-item deduction columns.
+ALTER TABLE public.invoice_items
+ ADD COLUMN IF NOT EXISTS deduction_type TEXT NULL,
+ ADD COLUMN IF NOT EXISTS deduction_amount NUMERIC(10,2) NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS labor_hours NUMERIC(5,2) NULL,
+ ADD COLUMN IF NOT EXISTS work_type TEXT NULL,
+ ADD COLUMN IF NOT EXISTS housing_designation TEXT NULL,
+ ADD COLUMN IF NOT EXISTS apartment_number TEXT NULL;
+
+ALTER TABLE public.invoice_items DROP CONSTRAINT IF EXISTS invoice_items_deduction_type_check;
+ALTER TABLE public.invoice_items ADD CONSTRAINT invoice_items_deduction_type_check
+ CHECK (deduction_type IS NULL OR deduction_type IN ('rot', 'rut'));
+
+-- The deduction amount must be non-negative; computed at API layer and
+-- capped at the line total to stop a tampered request from creating a
+-- larger receivable than the invoice itself.
+ALTER TABLE public.invoice_items DROP CONSTRAINT IF EXISTS invoice_items_deduction_amount_check;
+ALTER TABLE public.invoice_items ADD CONSTRAINT invoice_items_deduction_amount_check
+ CHECK (deduction_amount >= 0);
+
+-- 2. Invoice-level totals and claim info.
+ALTER TABLE public.invoices
+ ADD COLUMN IF NOT EXISTS deduction_total NUMERIC(10,2) NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS deduction_personnummer_encrypted TEXT NULL,
+ ADD COLUMN IF NOT EXISTS deduction_personnummer_last4 TEXT NULL;
+
+ALTER TABLE public.invoices DROP CONSTRAINT IF EXISTS invoices_deduction_total_check;
+ALTER TABLE public.invoices ADD CONSTRAINT invoices_deduction_total_check
+ CHECK (deduction_total >= 0);
+
+-- 3. Helpful indexes for the common queries.
+CREATE INDEX IF NOT EXISTS idx_invoice_items_deduction_type
+ ON public.invoice_items (deduction_type)
+ WHERE deduction_type IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS idx_invoices_deduction_total
+ ON public.invoices (company_id, deduction_total)
+ WHERE deduction_total > 0;
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260526122000_k3_component_depreciation.sql b/supabase/migrations/20260526122000_k3_component_depreciation.sql
new file mode 100644
index 00000000..bdffb4c4
--- /dev/null
+++ b/supabase/migrations/20260526122000_k3_component_depreciation.sql
@@ -0,0 +1,36 @@
+-- Migration: K3 component depreciation (komponentavskrivning)
+--
+-- BFNAR 2012:1 ch.17.4 requires K3-reporting entities to apply the
+-- "component approach" to substantial fixed assets (real estate is the
+-- canonical case): when significant components of an asset have materially
+-- different useful lives, each component must be depreciated separately
+-- on its own life. K2 (BFNAR 2016:10) has no such requirement and treats
+-- the asset as a single unit.
+--
+-- The `k3_components` JSONB column was already added by the original
+-- assets migration (20260516120000_assets_and_depreciation.sql) as a
+-- reserved placeholder. This migration now activates the column by
+-- documenting the shape via COMMENT and signalling the engine support.
+-- The structural shape stays:
+-- [{ name: string, cost: number, useful_life_months: number,
+-- salvage_value?: number }]
+--
+-- Validation rules (enforced application-side via Zod, not DB CHECK,
+-- because validating sum(cost) == acquisition_cost involves cross-column
+-- math that PostgreSQL CHECKs cannot express on JSONB):
+-- - sum of component costs equals acquisition_cost (±1 kr tolerance)
+-- - every component has cost > 0 and useful_life_months > 0
+-- - salvage_value (if present) ≤ component cost
+-- - array must be non-empty when set to non-null
+--
+-- Only meaningful for companies with accounting_framework = 'k3' (added in
+-- 20260526121500_k3_framework.sql). The API layer rejects k3_components
+-- writes for K2 companies with K3_REQUIRED_FOR_COMPONENTS.
+--
+-- No schema change is needed beyond refreshing the column comment — the
+-- column itself already exists with type JSONB.
+
+COMMENT ON COLUMN public.assets.k3_components IS
+ 'K3 BFNAR 2012:1 ch 17.4 components. When non-null, asset is depreciated per-component instead of by depreciation_method. Shape: [{ name: string, cost: number, useful_life_months: number, salvage_value?: number }]. Only meaningful for companies with accounting_framework=k3. Sum of component costs must equal acquisition_cost; enforced in application code (Zod) because the cross-column constraint cannot be expressed in a CHECK on JSONB.';
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260526182000_cap_reminder_fee_at_60.sql b/supabase/migrations/20260526182000_cap_reminder_fee_at_60.sql
new file mode 100644
index 00000000..182f1977
--- /dev/null
+++ b/supabase/migrations/20260526182000_cap_reminder_fee_at_60.sql
@@ -0,0 +1,21 @@
+-- Tighten reminder_fee_amount upper bound to the statutory cap.
+--
+-- Lag 1981:739 about late-payment fee caps the lagstadgad
+-- påminnelseavgift at 60 kr. The original migration only enforced
+-- `>= 0`, so a user could set 500 kr in settings and the cron would
+-- happily book it. Add a hard upper bound at the DB level — the
+-- application layer also clamps in lib/invoices/reminder-processor.ts.
+
+ALTER TABLE public.company_settings
+ DROP CONSTRAINT IF EXISTS company_settings_reminder_fee_check;
+
+-- Clamp any rows over 60 down before re-applying the constraint.
+UPDATE public.company_settings
+ SET reminder_fee_amount = 60
+ WHERE reminder_fee_amount > 60;
+
+ALTER TABLE public.company_settings
+ ADD CONSTRAINT company_settings_reminder_fee_check
+ CHECK (reminder_fee_amount >= 0 AND reminder_fee_amount <= 60);
+
+NOTIFY pgrst, 'reload schema';
diff --git a/supabase/migrations/20260526190000_swish_default_off.sql b/supabase/migrations/20260526190000_swish_default_off.sql
new file mode 100644
index 00000000..189c0986
--- /dev/null
+++ b/supabase/migrations/20260526190000_swish_default_off.sql
@@ -0,0 +1,20 @@
+-- Make Swish disabled by default. The original migration
+-- (20260521120050_company_settings_swish.sql) shipped with DEFAULT true,
+-- which meant every company without an explicit choice saw "show Swish"
+-- toggled on — even those that had never configured a Swish number.
+--
+-- Going forward: new rows default to false. Existing rows that never
+-- configured a Swish number (swish IS NULL or '') get flipped to false
+-- too, since the toggle being on without a number is meaningless. Rows
+-- that DO have a Swish number are left alone — those companies
+-- presumably want the toggle on.
+
+ALTER TABLE public.company_settings
+ ALTER COLUMN invoice_show_swish SET DEFAULT false;
+
+UPDATE public.company_settings
+ SET invoice_show_swish = false
+ WHERE invoice_show_swish IS NOT FALSE
+ AND (swish IS NULL OR swish = '');
+
+NOTIFY pgrst, 'reload schema';
diff --git a/tests/helpers.ts b/tests/helpers.ts
index 7384be20..8e29a7fa 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -108,6 +108,7 @@ export function makeCompany(overrides: Partial = {}): Company {
name: 'Test Company',
org_number: null,
entity_type: 'enskild_firma',
+ accounting_framework: 'k2',
created_by: 'user-1',
team_id,
archived_at: null,
@@ -536,6 +537,29 @@ export function makeCompanySettings(
bookkeeping_locked_through: null,
auto_lock_period_days: null,
default_voucher_series: 'A',
+ default_voucher_series_per_source_type: {
+ manual: 'A',
+ invoice_created: 'A',
+ invoice_paid: 'A',
+ invoice_cash_payment: 'A',
+ credit_note: 'A',
+ supplier_invoice_registered: 'A',
+ supplier_invoice_paid: 'A',
+ supplier_invoice_cash_payment: 'A',
+ supplier_invoice_privately_paid: 'A',
+ supplier_credit_note: 'A',
+ salary_payment: 'A',
+ bank_transaction: 'A',
+ reminder_fee: 'A',
+ opening_balance: 'A',
+ year_end: 'A',
+ currency_revaluation: 'A',
+ inbox_item: 'A',
+ import: 'A',
+ system: 'A',
+ storno: 'A',
+ correction: 'A',
+ },
ore_rounding: true,
invoice_show_ocr: true,
invoice_show_bankgiro: true,
@@ -546,7 +570,15 @@ export function makeCompanySettings(
invoice_company_name_position: 'header',
invoice_late_fee_text: null,
invoice_credit_terms_text: null,
+ invoice_primary_color: '#1a1a1a',
+ invoice_accent_color: '#666666',
+ invoice_font_family: 'Helvetica',
+ invoice_header_text: null,
+ invoice_footer_text: null,
send_invoice_reminders: true,
+ reminder_fee_enabled: true,
+ reminder_fee_amount: 60,
+ reminder_interest_rate_override: null,
logo_url: null,
onboarding_step: 6,
onboarding_complete: true,
diff --git a/tests/pg/assets.pg.test.ts b/tests/pg/assets.pg.test.ts
index 09930b34..b0c20473 100644
--- a/tests/pg/assets.pg.test.ts
+++ b/tests/pg/assets.pg.test.ts
@@ -379,3 +379,108 @@ describe('RLS — cross-company isolation', () => {
).rejects.toThrow(/row-level security|new row violates/i)
})
})
+
+// Asset disposal VAT + jämkning constraints (migration 20260526120300).
+// The columns are populated by disposeAsset() after the journal entry posts;
+// these pg tests cover the CHECK constraints directly so future schema changes
+// can't loosen them without us noticing.
+describe('assets — disposal VAT + jämkning constraints', () => {
+ it('accepts a disposed_vat_treatment from the allowed enum', async () => {
+ const assetId = await insertAsset({
+ userId: companyA.userId,
+ companyId: companyA.companyId,
+ disposedAt: '2025-12-31',
+ disposedProceeds: 100_000,
+ })
+ await getPool().query(
+ `UPDATE public.assets
+ SET disposed_proceeds_vat = 20000, disposed_vat_treatment = 'standard_25'
+ WHERE id = $1`,
+ [assetId],
+ )
+ const { rows } = await getPool().query(
+ `SELECT disposed_proceeds_vat, disposed_vat_treatment FROM public.assets WHERE id = $1`,
+ [assetId],
+ )
+ expect(Number(rows[0]?.disposed_proceeds_vat)).toBe(20_000)
+ expect(rows[0]?.disposed_vat_treatment).toBe('standard_25')
+ })
+
+ it('rejects a disposed_vat_treatment outside the enum', async () => {
+ const assetId = await insertAsset({
+ userId: companyA.userId,
+ companyId: companyA.companyId,
+ disposedAt: '2025-12-31',
+ disposedProceeds: 100_000,
+ })
+ await expect(
+ getPool().query(
+ `UPDATE public.assets SET disposed_vat_treatment = 'reduced_999' WHERE id = $1`,
+ [assetId],
+ ),
+ ).rejects.toThrow(/check/i)
+ })
+
+ it('rejects disposed_proceeds_vat > 0 without a disposed_vat_treatment', async () => {
+ const assetId = await insertAsset({
+ userId: companyA.userId,
+ companyId: companyA.companyId,
+ disposedAt: '2025-12-31',
+ disposedProceeds: 100_000,
+ })
+ // Treatment NULL + VAT > 0 must violate the consistency CHECK.
+ await expect(
+ getPool().query(
+ `UPDATE public.assets
+ SET disposed_proceeds_vat = 20000, disposed_vat_treatment = NULL
+ WHERE id = $1`,
+ [assetId],
+ ),
+ ).rejects.toThrow(/check|consistency/i)
+ })
+
+ it('accepts zero VAT with null treatment (legacy / non-VAT disposal)', async () => {
+ const assetId = await insertAsset({
+ userId: companyA.userId,
+ companyId: companyA.companyId,
+ disposedAt: '2025-12-31',
+ disposedProceeds: 50_000,
+ })
+ // Default values from the migration — should already pass on insert.
+ const { rows } = await getPool().query(
+ `SELECT disposed_proceeds_vat, disposed_vat_treatment FROM public.assets WHERE id = $1`,
+ [assetId],
+ )
+ expect(Number(rows[0]?.disposed_proceeds_vat)).toBe(0)
+ expect(rows[0]?.disposed_vat_treatment).toBeNull()
+ })
+
+ it('persists jämkning audit metadata on the row', async () => {
+ const assetId = await insertAsset({
+ userId: companyA.userId,
+ companyId: companyA.companyId,
+ disposedAt: '2025-12-31',
+ disposedProceeds: 60_000,
+ })
+ await getPool().query(
+ `UPDATE public.assets
+ SET jamkning_amount = 8000,
+ jamkning_remaining_months = 24,
+ jamkning_total_months = 60,
+ jamkning_original_input_vat = 20000
+ WHERE id = $1`,
+ [assetId],
+ )
+ const { rows } = await getPool().query(
+ `SELECT jamkning_amount, jamkning_remaining_months, jamkning_total_months,
+ jamkning_original_input_vat
+ FROM public.assets
+ WHERE id = $1`,
+ [assetId],
+ )
+ expect(Number(rows[0]?.jamkning_amount)).toBe(8_000)
+ expect(rows[0]?.jamkning_remaining_months).toBe(24)
+ expect(rows[0]?.jamkning_total_months).toBe(60)
+ expect(Number(rows[0]?.jamkning_original_input_vat)).toBe(20_000)
+ })
+})
diff --git a/tests/pg/rot-rut-avdrag.pg.test.ts b/tests/pg/rot-rut-avdrag.pg.test.ts
new file mode 100644
index 00000000..0a38f814
--- /dev/null
+++ b/tests/pg/rot-rut-avdrag.pg.test.ts
@@ -0,0 +1,180 @@
+/**
+ * pg-real tests for the ROT/RUT-avdrag schema introduced in
+ * 20260526121700_rot_rut_avdrag.sql.
+ *
+ * Verifies:
+ * - The new columns exist on invoice_items and invoices.
+ * - CHECK constraints behave (deduction_type only 'rot'|'rut'|null,
+ * deduction_amount >= 0, deduction_total >= 0).
+ * - Indexes were created.
+ */
+import { describe, it, expect } from 'vitest'
+import { randomUUID } from 'node:crypto'
+import { getPool } from './setup'
+import { insertAuthUser, insertCompany } from './fixtures'
+
+describe('ROT/RUT-avdrag schema', () => {
+ it('invoice_items has the new deduction columns', async () => {
+ const result = await getPool().query<{ column_name: string }>(
+ `SELECT column_name
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'invoice_items'
+ AND column_name IN (
+ 'deduction_type', 'deduction_amount', 'labor_hours',
+ 'work_type', 'housing_designation', 'apartment_number'
+ )`,
+ )
+ const found = new Set(result.rows.map((r) => r.column_name))
+ expect(found.has('deduction_type')).toBe(true)
+ expect(found.has('deduction_amount')).toBe(true)
+ expect(found.has('labor_hours')).toBe(true)
+ expect(found.has('work_type')).toBe(true)
+ expect(found.has('housing_designation')).toBe(true)
+ expect(found.has('apartment_number')).toBe(true)
+ })
+
+ it('invoices has the new deduction columns', async () => {
+ const result = await getPool().query<{ column_name: string }>(
+ `SELECT column_name
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND table_name = 'invoices'
+ AND column_name IN (
+ 'deduction_total',
+ 'deduction_personnummer_encrypted',
+ 'deduction_personnummer_last4'
+ )`,
+ )
+ const found = new Set(result.rows.map((r) => r.column_name))
+ expect(found.has('deduction_total')).toBe(true)
+ expect(found.has('deduction_personnummer_encrypted')).toBe(true)
+ expect(found.has('deduction_personnummer_last4')).toBe(true)
+ })
+
+ it('deduction_type CHECK rejects values other than rot/rut/null', async () => {
+ // Seed company + customer + draft invoice + insert an invoice item with
+ // a bogus deduction_type. The CHECK should reject.
+ const userId = await insertAuthUser()
+ const companyId = await insertCompany({ createdBy: userId })
+ const customerId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
+ VALUES ($1, $2, $3, 'Test Cust', 'individual')`,
+ [customerId, userId, companyId],
+ )
+ const invoiceId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.invoices
+ (id, user_id, company_id, customer_id, invoice_date, due_date,
+ currency, vat_treatment, vat_rate)
+ VALUES ($1, $2, $3, $4, '2026-05-01', '2026-05-31', 'SEK', 'standard_25', 25)`,
+ [invoiceId, userId, companyId, customerId],
+ )
+
+ await expect(
+ getPool().query(
+ `INSERT INTO public.invoice_items
+ (id, invoice_id, sort_order, description, quantity, unit, unit_price,
+ line_total, vat_rate, vat_amount, deduction_type)
+ VALUES ($1, $2, 0, 'X', 1, 'st', 100, 100, 25, 25, 'invalid')`,
+ [randomUUID(), invoiceId],
+ ),
+ ).rejects.toThrow()
+ })
+
+ it('deduction_amount CHECK rejects negative values', async () => {
+ const userId = await insertAuthUser()
+ const companyId = await insertCompany({ createdBy: userId })
+ const customerId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
+ VALUES ($1, $2, $3, 'Test Cust', 'individual')`,
+ [customerId, userId, companyId],
+ )
+ const invoiceId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.invoices
+ (id, user_id, company_id, customer_id, invoice_date, due_date,
+ currency, vat_treatment, vat_rate)
+ VALUES ($1, $2, $3, $4, '2026-05-01', '2026-05-31', 'SEK', 'standard_25', 25)`,
+ [invoiceId, userId, companyId, customerId],
+ )
+
+ await expect(
+ getPool().query(
+ `INSERT INTO public.invoice_items
+ (id, invoice_id, sort_order, description, quantity, unit, unit_price,
+ line_total, vat_rate, vat_amount, deduction_amount)
+ VALUES ($1, $2, 0, 'X', 1, 'st', 100, 100, 25, 25, -100)`,
+ [randomUUID(), invoiceId],
+ ),
+ ).rejects.toThrow()
+ })
+
+ it('valid ROT row inserts cleanly with full deduction shape', async () => {
+ const userId = await insertAuthUser()
+ const companyId = await insertCompany({ createdBy: userId })
+ const customerId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.customers (id, user_id, company_id, name, customer_type)
+ VALUES ($1, $2, $3, 'Test Cust', 'individual')`,
+ [customerId, userId, companyId],
+ )
+ const invoiceId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.invoices
+ (id, user_id, company_id, customer_id, invoice_date, due_date,
+ currency, vat_treatment, vat_rate,
+ deduction_total, deduction_personnummer_last4)
+ VALUES ($1, $2, $3, $4, '2026-05-01', '2026-05-31', 'SEK', 'standard_25', 25,
+ 3000, '1234')`,
+ [invoiceId, userId, companyId, customerId],
+ )
+
+ const itemId = randomUUID()
+ await getPool().query(
+ `INSERT INTO public.invoice_items
+ (id, invoice_id, sort_order, description, quantity, unit, unit_price,
+ line_total, vat_rate, vat_amount,
+ deduction_type, deduction_amount, labor_hours, work_type,
+ housing_designation, apartment_number)
+ VALUES ($1, $2, 0, 'Snickeri', 1, 'st', 10000, 10000, 25, 2500,
+ 'rot', 3000, 25, 'BYGG', 'Stockholm 1:23', '0301')`,
+ [itemId, invoiceId],
+ )
+
+ const result = await getPool().query<{
+ deduction_type: string
+ deduction_amount: string
+ work_type: string
+ housing_designation: string
+ }>(
+ `SELECT deduction_type, deduction_amount, work_type, housing_designation
+ FROM public.invoice_items WHERE id = $1`,
+ [itemId],
+ )
+ expect(result.rows[0].deduction_type).toBe('rot')
+ expect(Number(result.rows[0].deduction_amount)).toBe(3000)
+ expect(result.rows[0].work_type).toBe('BYGG')
+ expect(result.rows[0].housing_designation).toBe('Stockholm 1:23')
+ })
+
+ it('idx_invoice_items_deduction_type exists', async () => {
+ const result = await getPool().query<{ indexname: string }>(
+ `SELECT indexname FROM pg_indexes
+ WHERE schemaname = 'public' AND tablename = 'invoice_items'
+ AND indexname = 'idx_invoice_items_deduction_type'`,
+ )
+ expect(result.rows).toHaveLength(1)
+ })
+
+ it('idx_invoices_deduction_total exists', async () => {
+ const result = await getPool().query<{ indexname: string }>(
+ `SELECT indexname FROM pg_indexes
+ WHERE schemaname = 'public' AND tablename = 'invoices'
+ AND indexname = 'idx_invoices_deduction_total'`,
+ )
+ expect(result.rows).toHaveLength(1)
+ })
+})
diff --git a/types/index.ts b/types/index.ts
index 83f9e0be..d7177f2a 100644
--- a/types/index.ts
+++ b/types/index.ts
@@ -1,6 +1,12 @@
// Entity types
export type EntityType = 'enskild_firma' | 'aktiebolag'
+// Swedish accounting framework. K2 (BFNAR 2016:10) is the default simplified
+// ruleset for smaller AB; K3 (BFNAR 2012:1) is the principles-based ruleset
+// required for medium-to-large AB and permitted voluntarily for smaller ones.
+// Only meaningful for entity_type='aktiebolag'.
+export type AccountingFramework = 'k2' | 'k3'
+
// Company role for multi-tenant access
export type CompanyRole = 'owner' | 'admin' | 'member' | 'viewer'
@@ -23,6 +29,7 @@ export interface Company {
name: string
org_number: string | null
entity_type: EntityType
+ accounting_framework: AccountingFramework
created_by: string
team_id: string | null
archived_at: string | null
@@ -240,6 +247,14 @@ export interface CompanySettings {
// Voucher series
default_voucher_series: string
+ /**
+ * Per-source-type default voucher series map. Keys are
+ * JournalEntrySourceType values; values are single uppercase letters A–Z.
+ * Resolved by `lib/bookkeeping/voucher-series-resolver.ts`. Defaults to
+ * all "A" entries; users can override per source via the bookkeeping
+ * settings UI.
+ */
+ default_voucher_series_per_source_type: Partial>
// Invoice PDF settings
ore_rounding: boolean
@@ -253,9 +268,23 @@ export interface CompanySettings {
invoice_late_fee_text: string | null
invoice_credit_terms_text: string | null
+ // Invoice branding (per-company colors, font, optional header/footer text).
+ // Defaults preserve the legacy hardcoded palette so unbranded companies
+ // render identically to the pre-branding template.
+ invoice_primary_color: string // hex #RRGGBB, default '#1a1a1a'
+ invoice_accent_color: string // hex #RRGGBB, default '#666666'
+ invoice_font_family: 'Helvetica' | 'Times-Roman' | 'Courier'
+ invoice_header_text: string | null
+ invoice_footer_text: string | null
+
// Automation
send_invoice_reminders: boolean
+ // Reminder surcharges (dröjsmålsränta + lagstadgad påminnelseavgift)
+ reminder_fee_enabled: boolean
+ reminder_fee_amount: number
+ reminder_interest_rate_override: number | null
+
// Logo
logo_url: string | null
@@ -701,7 +730,7 @@ export interface Invoice {
// Credit note reference
credited_invoice_id: string | null
- // Document type (invoice, proforma, delivery_note)
+ // Document type (invoice, proforma, delivery_note, quote)
document_type: InvoiceDocumentType
// Conversion tracking (proforma -> invoice)
@@ -712,6 +741,16 @@ export interface Invoice {
paid_amount: number | null
remaining_amount: number
+ // ROT/RUT-avdrag claim info. `deduction_total` is the sum of the per-item
+ // deduction_amount and equals the 1513 debit on the verifikation. The
+ // personnummer is stored only as AES-256-GCM ciphertext + the last four
+ // digits (PII isolation). All three fields are null/0 on invoices with
+ // no ROT/RUT lines. Optional in TypeScript to keep legacy fixtures
+ // (pre-migration) valid — treat undefined the same as 0/null.
+ deduction_total?: number
+ deduction_personnummer_encrypted?: string | null
+ deduction_personnummer_last4?: string | null
+
created_at: string
updated_at: string
@@ -746,6 +785,27 @@ export interface InvoiceItem {
vat_rate: number
vat_amount: number
+ // ROT/RUT-avdrag (Sweden's tax deduction for household services / home
+ // renovation). When `deduction_type` is set, the system computes
+ // `deduction_amount` from the rules in lib/invoices/rot-rut-rules.ts
+ // and posts the receivable to BAS 1513 (Skatteverket). v1 deducts on
+ // the full line total; future work can use `labor_hours` to honour the
+ // labor-only restriction.
+ //
+ // All fields are optional in TypeScript even though Postgres has
+ // defaults — legacy rows pulled before the schema change carry
+ // `undefined` in JS land, and many existing test fixtures predate the
+ // ROT/RUT migration. Treat undefined the same as null/0 throughout.
+ deduction_type?: 'rot' | 'rut' | null
+ deduction_amount?: number
+ labor_hours?: number | null
+ /** Skatteverket arbetstypskod (e.g. 'BYGG', 'STAD'). See ROT_WORK_TYPES / RUT_WORK_TYPES. */
+ work_type?: string | null
+ /** Fastighetsbeteckning. Required for ROT, optional for RUT. */
+ housing_designation?: string | null
+ /** Lägenhetsnummer. Optional, used for ROT in flerbostadshus. */
+ apartment_number?: string | null
+
created_at: string
}
@@ -897,6 +957,10 @@ export interface CreateInvoiceInput {
your_reference?: string
our_reference?: string
notes?: string
+ /** Plaintext personnummer — encrypted server-side before storage. */
+ deduction_personnummer?: string
+ /** Fastighetsbeteckning. Required when any item carries deduction_type === 'rot'. */
+ deduction_housing_designation?: string
items: CreateInvoiceItemInput[]
}
@@ -906,6 +970,12 @@ export interface CreateInvoiceItemInput {
unit: string
unit_price: number
vat_rate?: number
+ /** ROT/RUT toggle. null/undefined = no deduction. */
+ deduction_type?: 'rot' | 'rut' | null
+ labor_hours?: number | null
+ work_type?: string | null
+ housing_designation?: string | null
+ apartment_number?: string | null
}
export interface CreateTransactionInput {
@@ -1031,6 +1101,7 @@ export type JournalEntrySourceType =
| 'supplier_invoice_privately_paid'
| 'supplier_credit_note'
| 'currency_revaluation'
+ | 'reminder_fee'
// Journal entry status
export type JournalEntryStatus = 'draft' | 'posted' | 'reversed' | 'cancelled'
@@ -2310,7 +2381,6 @@ export interface AuditLogEntry {
export interface CostCenter {
id: string
- user_id: string
company_id: string
code: string
name: string
@@ -2321,7 +2391,6 @@ export interface CostCenter {
export interface Project {
id: string
- user_id: string
company_id: string
code: string
name: string
@@ -2390,6 +2459,13 @@ export interface YearEndResult {
nextPeriod: FiscalPeriod
openingBalanceEntry: JournalEntry
revaluationEntry: JournalEntry | null
+ /**
+ * IB/UB reconciliation per balance sheet account, computed after the
+ * opening balances are posted. Surfaced to the UI's ResultStep so the
+ * user can verify continuity before navigating away. Always within
+ * ORE_TOLERANCE — otherwise executeYearEndClosing would have thrown.
+ */
+ continuity?: ContinuityCheckResult
}
// ============================================================
@@ -2410,6 +2486,32 @@ export type DepreciationMethod =
| 'linear'
| 'declining_balance_30'
| 'declining_balance_20'
+ | 'restvardesavskrivning_25'
+
+/**
+ * K3 component (BFNAR 2012:1 ch 17.4 — komponentavskrivning). When a
+ * substantial asset (typically real estate) has significant components with
+ * materially different useful lives, K3 reporting requires each component to
+ * be depreciated on its own life rather than treating the asset as a single
+ * unit. Components are stored as an array on `Asset.k3_components`; when
+ * non-null, the depreciation engine routes through `computeComponentDepreciation`
+ * and sums per-component linear depreciation (with the same pro-ration logic
+ * as the asset-level linear method).
+ *
+ * Validation (enforced in `lib/bokslut/assets/k3-components.ts`):
+ * - sum(components.cost) === asset.acquisition_cost (±1 kr tolerance)
+ * - every component: cost > 0, useful_life_months > 0
+ * - salvage_value (if present) ≤ component cost
+ * - non-empty array when set to non-null
+ *
+ * Salvage_value defaults to 0 when omitted.
+ */
+export interface K3Component {
+ name: string
+ cost: number
+ useful_life_months: number
+ salvage_value?: number
+}
export interface Asset {
id: string
@@ -2425,11 +2527,37 @@ export interface Asset {
bas_asset_account: string
bas_accumulated_account: string
bas_expense_account: string
+ /** Book-value floor for restvärdeavskrivning (IL 18 kap 13§ st.3). Required
+ * iff depreciation_method = 'restvardesavskrivning_25'; null otherwise. */
+ restvarde_target: number | null
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
+ /** Output VAT on disposal proceeds (ML 3 kap 3 § / 7 kap 3 §). Defaults to
+ * 0 — only nonzero when the sale was momspliktig. The VAT account
+ * (2611/2621/2631) is derived from disposed_vat_treatment. */
+ disposed_proceeds_vat: number
+ /** VAT treatment applied to disposal proceeds. Null for legacy disposals
+ * without VAT data. Constrained by DB CHECK to the same enum as
+ * VatTreatment. */
+ disposed_vat_treatment: VatTreatment | null
+ /** Jämkning amount per ML 8a kap 7 § — input VAT paid back on disposal
+ * inside the correction period. Defaults to 0; positive number = debt
+ * to the state booked on 2641 credit. */
+ jamkning_amount: number
+ /** Remaining months in the korrigeringstid at disposal date. Audit
+ * metadata only — the booking sits on the journal entry. */
+ jamkning_remaining_months: number | null
+ /** Total korrigeringstid in months: 60 (lös egendom) or 120 (fastighet /
+ * markanläggning). Audit metadata. */
+ jamkning_total_months: number | null
+ /** Original input VAT that was deducted at acquisition. Audit metadata
+ * the user supplies (or the system derives from the supplier invoice). */
+ jamkning_original_input_vat: number | null
+ /** K3 component depreciation (BFNAR 2012:1 ch.17.4). When non-null, the
+ * depreciation engine sums per-component linear depreciation instead of
+ * applying `depreciation_method` to the asset as a whole. Null for K2
+ * companies (the API rejects writes for accounting_framework='k2'). */
+ k3_components: K3Component[] | null
notes: string | null
created_at: string
updated_at: string
@@ -2529,6 +2657,13 @@ export interface InvoiceReminder {
action_token: string
action_token_used: boolean
created_at: string
+ // Dröjsmålsränta + lagstadgad påminnelseavgift (Räntelagen §6, Lag 1981:739)
+ interest_amount: number
+ interest_rate: number | null
+ interest_from_date: string | null
+ interest_days: number | null
+ reminder_fee: number
+ fee_journal_entry_id: string | null
}
// Swedish labels for reminder levels
@@ -2696,7 +2831,10 @@ export type AGIStatus =
| 'rejected' // reserved (kontrollresultat DONE_REJECTED could land here)
export type SalaryLineItemType =
- | 'monthly_salary' | 'hourly_salary' | 'overtime' | 'bonus' | 'commission'
+ | 'monthly_salary' | 'hourly_salary'
+ | 'overtime' | 'overtime_50' | 'overtime_100'
+ | 'ob_weekday_evening' | 'ob_weekend' | 'ob_night' | 'ob_holiday'
+ | 'bonus' | 'commission'
| 'gross_deduction_pension' | 'gross_deduction_other'
| 'benefit_car' | 'benefit_housing' | 'benefit_meals' | 'benefit_wellness' | 'benefit_bike' | 'benefit_other'
| 'sick_karens' | 'sick_day2_14' | 'sick_day15_plus'
@@ -2707,6 +2845,31 @@ export type SalaryLineItemType =
| 'net_deduction_other'
| 'correction' | 'other'
+export type ShiftPremiumItemType =
+ | 'overtime_50' | 'overtime_100'
+ | 'ob_weekday_evening' | 'ob_weekend' | 'ob_night' | 'ob_holiday'
+
+export interface ShiftPremiumRule {
+ id: string
+ company_id: string
+ name: string
+ applies_to_all_employees: boolean
+ applies_to_employee_ids: string[]
+ /** ISO weekday array: 1 = Monday … 7 = Sunday. */
+ day_of_week: number[]
+ /** 'HH:MM' or 'HH:MM:SS' (PostgreSQL TIME). */
+ start_time: string
+ /** 'HH:MM' or 'HH:MM:SS'. End values <= start mean the window wraps midnight. */
+ end_time: string
+ premium_percent: number
+ item_type: ShiftPremiumItemType
+ priority: number
+ is_active: boolean
+ created_at: string
+ updated_at: string
+ created_by: string | null
+}
+
export interface Employee {
id: string
company_id: string