From a2a556d837fdc26881d88f8507ecd1517444b8eb Mon Sep 17 00:00:00 2001
From: Mattsson <111893710+mattssonn@users.noreply.github.com>
Date: Wed, 27 May 2026 11:01:53 +0200
Subject: [PATCH] Bug/UI wrong display (#573)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(dashboard): exclude credit notes from unpaid invoices widget
Credit notes (status='sent', negative total) were summed into the
"Att få betalt" widget, producing confusing negative totals like
"2 st, -38 625 kr". Filter them out via credited_invoice_id IS NULL,
matching the existing pattern in reminder-processor and the AR ledger.
Co-Authored-By: Claude Opus 4.7 (1M context)
* fix(documents): harden PDF preview and upload validation
- JournalEntryAttachments: switch inline PDF preview from
+
+
{hasUnsavedNarrative ? (
diff --git a/app/(dashboard)/invoices/new/page.tsx b/app/(dashboard)/invoices/new/page.tsx
index 7440cc0c..a0d71ff7 100644
--- a/app/(dashboard)/invoices/new/page.tsx
+++ b/app/(dashboard)/invoices/new/page.tsx
@@ -105,6 +105,7 @@ export default function NewInvoicePage() {
const [showBankSetup, setShowBankSetup] = useState(false)
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
const [oreRounding, setOreRounding] = useState
(true)
+ const [vatRegistered, setVatRegistered] = useState(true)
const [numberPreview, setNumberPreview] = useState(null)
const [logoUrl, setLogoUrl] = useState(null)
// True only when the user had zero invoices when this page loaded. The
@@ -182,7 +183,7 @@ export default function NewInvoicePage() {
if (!company?.id) return
const { data } = await supabase
.from('company_settings')
- .select('invoice_default_notes, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url')
+ .select('invoice_default_notes, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url, vat_registered')
.eq('company_id', company.id)
.single()
if (data?.invoice_default_notes) {
@@ -199,6 +200,9 @@ export default function NewInvoicePage() {
setOreRounding(data.ore_rounding)
}
setLogoUrl(data?.logo_url ?? null)
+ if (typeof data?.vat_registered === 'boolean') {
+ setVatRegistered(data.vat_registered)
+ }
}
// First-invoice detection (issue #520): captured at page load so the
@@ -257,9 +261,11 @@ export default function NewInvoicePage() {
)
}
- // When customer forces a single rate (reverse charge/export), update all lines
+ // When customer forces a single rate (reverse charge/export), or the
+ // seller isn't VAT-registered, update all lines so the picker can't
+ // leave stale 25% values behind.
if (customer) {
- const rates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
+ const rates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated, vatRegistered)
if (rates.length === 1) {
const forcedRate = rates[0].rate
watchItems.forEach((_, i) => {
@@ -268,7 +274,7 @@ export default function NewInvoicePage() {
}
}
}
- }, [watchCustomerId, customers, setValue])
+ }, [watchCustomerId, customers, setValue, vatRegistered])
async function fetchCustomers() {
if (!company?.id) return
@@ -325,11 +331,11 @@ export default function NewInvoicePage() {
}, 0)
const vatRules = selectedCustomer
- ? getVatRules(selectedCustomer.customer_type, selectedCustomer.vat_number_validated)
+ ? getVatRules(selectedCustomer.customer_type, selectedCustomer.vat_number_validated, vatRegistered)
: null
const availableRates = selectedCustomer
- ? getAvailableVatRates(selectedCustomer.customer_type, selectedCustomer.vat_number_validated)
+ ? getAvailableVatRates(selectedCustomer.customer_type, selectedCustomer.vat_number_validated, vatRegistered)
: []
const isRateLocked = availableRates.length === 1
diff --git a/app/(dashboard)/page.tsx b/app/(dashboard)/page.tsx
index 6b2ceded..24e173ee 100644
--- a/app/(dashboard)/page.tsx
+++ b/app/(dashboard)/page.tsx
@@ -88,7 +88,7 @@ export default async function DashboardPage() {
.eq('journal_entry.company_id', companyId)
.gte('journal_entry.entry_date', startOfYearStr),
supabase.from('transactions').select('amount, amount_sek, is_business').eq('company_id', companyId).gte('date', startOfYearStr),
- supabase.from('invoices').select('total, total_sek, vat_amount, vat_amount_sek, status').eq('company_id', companyId).in('status', ['sent', 'overdue']),
+ supabase.from('invoices').select('total, total_sek, vat_amount, vat_amount_sek, status').eq('company_id', companyId).in('status', ['sent', 'overdue']).is('credited_invoice_id', null),
supabase.from('bank_connections').select('id, accounts_data, status, consent_expires, bank_name').eq('company_id', companyId).eq('status', 'active'),
supabase.from('deadlines').select('*, customer:customers(id, name)').eq('company_id', companyId).eq('is_completed', false)
.or(`due_date.lt.${today},due_date.lte.${nextWeek}`).order('due_date', { ascending: true }),
diff --git a/app/(dashboard)/settings/layout.tsx b/app/(dashboard)/settings/layout.tsx
index 21a88cc3..a145e22f 100644
--- a/app/(dashboard)/settings/layout.tsx
+++ b/app/(dashboard)/settings/layout.tsx
@@ -14,7 +14,6 @@ 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/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts
index 3bf31493..89a9c58b 100644
--- a/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts
+++ b/app/api/bookkeeping/fiscal-periods/[id]/arsredovisning/narrative/route.ts
@@ -8,12 +8,28 @@ import {
upsertNarrative,
} from '@/lib/bokslut/arsredovisning/narrative-service'
+// Strip non-printable control characters that would corrupt PDF output or
+// mislead a human reader of the årsredovisning. Whitelist printable ASCII
+// + every byte ≥ 0x20 (covers Latin-1 + UTF-8 multi-byte sequences) while
+// allowing tab/LF/CR for legitimate line breaks.
+const stripControlChars = (s: string): string =>
+ s.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '')
+
+const sanitizedText = (max: number) =>
+ z
+ .string()
+ .max(max)
+ .transform(stripControlChars)
+
const PostSchema = z.object({
// Match the DB CHECK lengths exactly so a payload that would fail at the
- // storage layer instead returns a clean 400 here.
- description: z.string().max(4000).nullable().optional(),
- important_events: z.string().max(4000).nullable().optional(),
- resultatdisposition: z.string().max(2000).nullable().optional(),
+ // storage layer instead returns a clean 400 here. Free-text fields are
+ // rendered verbatim into the årsredovisning PDF, so we strip ASCII
+ // control bytes (NUL, ESC, etc.) at the schema layer — otherwise a
+ // tampered payload could corrupt PDF output or hide content from auditors.
+ description: sanitizedText(4000).nullable().optional(),
+ important_events: sanitizedText(4000).nullable().optional(),
+ resultatdisposition: sanitizedText(2000).nullable().optional(),
// ISO YYYY-MM-DD per the DATE column; null clears it. Validate as a
// real calendar date (not just regex) so '2024-13-99' returns 400 from
// the API instead of bubbling up as a Postgres 500.
@@ -29,6 +45,36 @@ const PostSchema = z.object({
)
.nullable()
.optional(),
+ // Disclosure fields per ÅRL 5:13-15 § + BFNAR koncernförhållanden. All
+ // optional; null clears the override and the builder falls back to
+ // boilerplate ("Inga." / "Inga skulder förfaller efter mer än fem år.").
+ // Cap at 1 trillion SEK — well above any realistic Swedish company's
+ // long-term debt (Volvo Group ~500 G SEK), prevents overflow in PDF
+ // formatting and downstream numeric handling.
+ long_term_debt_over_five_years: z
+ .number()
+ .min(0)
+ .max(1_000_000_000_000)
+ .nullable()
+ .optional(),
+ securities_pledged: sanitizedText(4000).nullable().optional(),
+ contingent_liabilities: sanitizedText(4000).nullable().optional(),
+ parent_company_name: sanitizedText(200).nullable().optional(),
+ // Swedish organisationsnummer NNNNNN-NNNN. Third digit ≥ 2 distinguishes
+ // legal-entity org numbers from personnummer (whose third digit forms part
+ // of a month, 0-1). ÅRL 5:13–15 disclosure is about parent legal entities,
+ // so personnummer-shaped values are out of scope and a GDPR Art.5(1)(c)
+ // data-minimisation concern if persisted. Empty string clears the override.
+ parent_company_org_number: z
+ .union([
+ z.literal(''),
+ z.string().regex(/^\d{2}[2-9]\d{3}-\d{4}$/, {
+ message: 'Ogiltigt organisationsnummer (NNNNNN-NNNN, ej personnummer)',
+ }),
+ ])
+ .nullable()
+ .optional(),
+ parent_company_city: sanitizedText(100).nullable().optional(),
})
export const GET = withRouteContext(
diff --git a/app/api/documents/[id]/inline/route.ts b/app/api/documents/[id]/inline/route.ts
index c66124b8..05c70eb5 100644
--- a/app/api/documents/[id]/inline/route.ts
+++ b/app/api/documents/[id]/inline/route.ts
@@ -17,6 +17,30 @@ import { createServiceClient } from '@/lib/supabase/server'
* (RLS + explicit company_id filter) before the service-role client
* fetches the file from the non-public `documents` bucket.
*/
+
+const EXTENSION_MIME_MAP: Record = {
+ pdf: 'application/pdf',
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ png: 'image/png',
+ webp: 'image/webp',
+}
+
+/**
+ * Resolve the response Content-Type. Some legacy uploads landed with
+ * `mime_type = null` or `application/octet-stream` (browsers sometimes
+ * report empty File.type for files dragged from certain sources). Combined
+ * with the new `X-Content-Type-Options: nosniff` header on this route,
+ * that broke Chrome's PDF viewer for older rows — the plugin would load
+ * via but refuse to parse a response
+ * served as octet-stream. Falling back to the file extension covers every
+ * legacy row without a DB backfill.
+ */
+function resolveContentType(fileName: string, dbMimeType: string | null): string {
+ if (dbMimeType && dbMimeType !== 'application/octet-stream') return dbMimeType
+ const ext = fileName.toLowerCase().split('.').pop() ?? ''
+ return EXTENSION_MIME_MAP[ext] ?? dbMimeType ?? 'application/octet-stream'
+}
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
@@ -68,9 +92,14 @@ export async function GET(
return new NextResponse(blob, {
status: 200,
headers: {
- 'Content-Type': doc.mime_type ?? 'application/octet-stream',
+ 'Content-Type': resolveContentType(doc.file_name, doc.mime_type),
'Content-Disposition': `inline; filename="${safeFileName}"`,
'Cache-Control': 'private, max-age=300',
+ // Block MIME sniffing — Content-Type is derived from DB metadata
+ // (with extension fallback for legacy rows), never from response
+ // content. Without nosniff a tampered file_name extension could
+ // serve a stored document under an attacker-chosen MIME type.
+ 'X-Content-Type-Options': 'nosniff',
},
})
}
diff --git a/app/api/documents/route.ts b/app/api/documents/route.ts
index a63e56cc..57bc9087 100644
--- a/app/api/documents/route.ts
+++ b/app/api/documents/route.ts
@@ -62,10 +62,19 @@ export const POST = withRouteContext(
return NextResponse.json({ data: document })
} catch (err) {
+ const message = err instanceof Error ? err.message : 'unknown'
+ // DB trigger rejects inserts whose journal_entry_id points at an entry
+ // in a closed/locked period. Surface as 400 with the real reason.
+ if (/locked\/closed fiscal period|Bokföringen är låst/i.test(message)) {
+ return errorResponseFromCode('DOC_UPLOAD_PERIOD_LOCKED', opLog, {
+ requestId,
+ details: { reason: message },
+ })
+ }
opLog.error('document upload failed', err as Error)
return errorResponseFromCode('DOC_UPLOAD_STORAGE_FAILED', opLog, {
requestId,
- details: { reason: err instanceof Error ? err.message : 'unknown' },
+ details: { reason: message },
})
}
},
diff --git a/app/api/invoices/__tests__/route.test.ts b/app/api/invoices/__tests__/route.test.ts
index 0d662f18..bdbeb29e 100644
--- a/app/api/invoices/__tests__/route.test.ts
+++ b/app/api/invoices/__tests__/route.test.ts
@@ -188,6 +188,9 @@ describe('POST /api/invoices (create invoice)', () => {
// Fetch customer
enqueue({ data: customer, error: null })
+ // Fetch company_settings.vat_registered — feeds the helpers so the
+ // allowed-rates set is correct for non-VAT-registered sellers.
+ enqueue({ data: { vat_registered: true }, error: null })
// Insert invoice (number is null on insert; allocated immediately after items)
enqueue({ data: createdInvoice, error: null })
// Insert items
@@ -238,6 +241,8 @@ describe('POST /api/invoices (create invoice)', () => {
])
enqueue({ data: customer, error: null })
+ // Fetch company_settings.vat_registered
+ enqueue({ data: { vat_registered: true }, error: null })
enqueue({ data: createdInvoice, error: null })
// Items insertion fails
enqueue({ data: null, error: { message: 'Items insert failed' } })
@@ -280,6 +285,8 @@ describe('POST /api/invoices (create invoice)', () => {
])
enqueue({ data: customer, error: null })
+ // Fetch company_settings.vat_registered
+ enqueue({ data: { vat_registered: true }, error: null })
enqueue({ data: createdInvoice, error: null })
// Items insertion succeeds
enqueue({ data: null, error: null })
diff --git a/app/api/invoices/preview-pdf/route.ts b/app/api/invoices/preview-pdf/route.ts
index 80739e5d..e864e36c 100644
--- a/app/api/invoices/preview-pdf/route.ts
+++ b/app/api/invoices/preview-pdf/route.ts
@@ -98,7 +98,14 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Företagsinställningar saknas' }, { status: 404 })
}
- const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
+ // Match the creation API: a non-VAT-registered seller may not charge VAT
+ // (ML 1 kap. 1§). Coerce instead of rejecting so the preview always renders
+ // — the form may still be carrying a stale 25% selection while the user
+ // hasn't yet noticed the rate picker locked itself. Default `true` mirrors
+ // the API and getVatRules' own default — a NULL column must not silently
+ // strip VAT from a preview the seller is about to send.
+ const vatRegistered = (company as CompanySettings).vat_registered ?? true
+ const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated, vatRegistered)
const docType: InvoiceDocumentType = document_type || 'invoice'
const isDeliveryNote = docType === 'delivery_note'
@@ -106,7 +113,7 @@ export async function POST(request: Request) {
// Build items with line totals and per-item VAT
const invoiceItems: InvoiceItem[] = items.map((item: { description: string; quantity: number; unit: string; unit_price: number; vat_rate?: number }, index: number) => {
const lineTotal = Math.round(item.quantity * item.unit_price * 100) / 100
- const rate = item.vat_rate ?? vatRules.rate
+ const rate = vatRegistered ? (item.vat_rate ?? vatRules.rate) : 0
return {
id: `preview-${index}`,
invoice_id: 'preview',
diff --git a/app/api/invoices/route.ts b/app/api/invoices/route.ts
index c6316742..d44e1a88 100644
--- a/app/api/invoices/route.ts
+++ b/app/api/invoices/route.ts
@@ -119,8 +119,22 @@ export const POST = withRouteContext(
})
}
- const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated)
- const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated)
+ // A non-VAT-registered seller may not charge output VAT (ML 1 kap. 1§).
+ // We pull vat_registered from company_settings and feed it into the rule
+ // helpers so the allowed-rates set collapses to {0} and any non-zero rate
+ // submitted from the client fails INVOICE_CREATE_VAT_RULE_VIOLATION below.
+ // Default to true when the settings row is absent or the column is NULL
+ // — matches the prior implicit behavior (getVatRules' own default) so a
+ // missing settings row never silently blocks legitimate VAT invoices.
+ const { data: companySettings } = await supabase
+ .from('company_settings')
+ .select('vat_registered')
+ .eq('company_id', companyId!)
+ .single()
+ const vatRegistered = companySettings?.vat_registered ?? true
+
+ const vatRules = getVatRules(customer.customer_type, customer.vat_number_validated, vatRegistered)
+ const availableRates = getAvailableVatRates(customer.customer_type, customer.vat_number_validated, vatRegistered)
const allowedRates = new Set(availableRates.map((r) => r.rate))
const subtotal = invoiceInput.items.reduce((sum, item) => sum + item.quantity * item.unit_price, 0)
diff --git a/components/bookkeeping/JournalEntryAttachments.tsx b/components/bookkeeping/JournalEntryAttachments.tsx
index a80ab70d..50063da4 100644
--- a/components/bookkeeping/JournalEntryAttachments.tsx
+++ b/components/bookkeeping/JournalEntryAttachments.tsx
@@ -327,11 +327,25 @@ export default function JournalEntryAttachments({
{expandedDoc === doc.id && doc.download_url && isPdfType(doc.mime_type) && (
)}
diff --git a/components/settings/SettingsSidebar.tsx b/components/settings/SettingsSidebar.tsx
index 7d3e02f5..309dfb51 100644
--- a/components/settings/SettingsSidebar.tsx
+++ b/components/settings/SettingsSidebar.tsx
@@ -35,7 +35,6 @@ 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/lib/bokslut/arsredovisning/__tests__/anlaggningstillgangar-note.test.ts b/lib/bokslut/arsredovisning/__tests__/anlaggningstillgangar-note.test.ts
new file mode 100644
index 00000000..50b6d7d6
--- /dev/null
+++ b/lib/bokslut/arsredovisning/__tests__/anlaggningstillgangar-note.test.ts
@@ -0,0 +1,243 @@
+import { describe, it, expect } from 'vitest'
+import {
+ buildAnlaggningstillgangarNote,
+ _buildRollforwardForTests,
+} from '../anlaggningstillgangar-note'
+
+const PERIOD_START = '2025-01-01'
+const PERIOD_END = '2025-12-31'
+
+describe('buildAnlaggningstillgangarNote — roll-forward', () => {
+ it('returns null when no assets fall in the period', () => {
+ expect(
+ buildAnlaggningstillgangarNote({
+ noteNumber: 5,
+ assets: [],
+ periodStart: PERIOD_START,
+ periodEnd: PERIOD_END,
+ }),
+ ).toBeNull()
+ })
+
+ it('skips assets disposed before the period', () => {
+ const rows = _buildRollforwardForTests(
+ [
+ {
+ category: 'equipment',
+ acquisition_date: '2020-01-01',
+ acquisition_cost: 100_000,
+ salvage_value: 0,
+ useful_life_months: 60,
+ disposed_at: '2024-12-31',
+ },
+ ],
+ PERIOD_START,
+ PERIOD_END,
+ )
+ expect(rows).toEqual([])
+ })
+
+ it('records IB anskaffningsvärde for assets acquired before the period', () => {
+ const rows = _buildRollforwardForTests(
+ [
+ {
+ category: 'equipment',
+ acquisition_date: '2024-01-01',
+ acquisition_cost: 60_000,
+ salvage_value: 0,
+ useful_life_months: 60,
+ disposed_at: null,
+ },
+ ],
+ PERIOD_START,
+ PERIOD_END,
+ )
+ expect(rows).toHaveLength(1)
+ const r = rows[0]
+ expect(r.ibAnskaffning).toBe(60_000)
+ expect(r.tillkommande).toBe(0)
+ expect(r.ubAnskaffning).toBe(60_000)
+ // 1 year of depreciation on the books at IB (Jan 1 2024 → Dec 31 2024)
+ expect(r.ibAck).toBeGreaterThan(11_500)
+ expect(r.ibAck).toBeLessThan(12_500)
+ // Year's depreciation ≈ 12,000 (60,000 / 5)
+ expect(r.aretsAvskrivning).toBeGreaterThan(11_500)
+ expect(r.aretsAvskrivning).toBeLessThan(12_500)
+ })
+
+ it('records tillkommande for assets acquired during the period', () => {
+ const rows = _buildRollforwardForTests(
+ [
+ {
+ category: 'equipment',
+ acquisition_date: '2025-07-01',
+ acquisition_cost: 60_000,
+ salvage_value: 0,
+ useful_life_months: 60,
+ disposed_at: null,
+ },
+ ],
+ PERIOD_START,
+ PERIOD_END,
+ )
+ expect(rows).toHaveLength(1)
+ const r = rows[0]
+ expect(r.ibAnskaffning).toBe(0)
+ expect(r.tillkommande).toBe(60_000)
+ expect(r.ibAck).toBe(0)
+ // ~6 months depreciation ≈ 6,000
+ expect(r.aretsAvskrivning).toBeGreaterThan(5_500)
+ expect(r.aretsAvskrivning).toBeLessThan(6_500)
+ })
+
+ it('records avgående for assets disposed during the period', () => {
+ const rows = _buildRollforwardForTests(
+ [
+ {
+ category: 'equipment',
+ acquisition_date: '2023-01-01',
+ acquisition_cost: 60_000,
+ salvage_value: 0,
+ useful_life_months: 60,
+ disposed_at: '2025-06-30',
+ },
+ ],
+ PERIOD_START,
+ PERIOD_END,
+ )
+ expect(rows).toHaveLength(1)
+ const r = rows[0]
+ expect(r.ibAnskaffning).toBe(60_000)
+ expect(r.avgaende).toBe(60_000)
+ expect(r.ubAnskaffning).toBe(0)
+ // Disposed asset: at IB it had 2 years of depreciation ≈ 24,000;
+ // at disposal it had ~2.5 years ≈ 30,000.
+ expect(r.avgaendeAck).toBeGreaterThan(29_000)
+ expect(r.avgaendeAck).toBeLessThan(31_000)
+ })
+
+ it('groups multiple assets in the same category', () => {
+ const rows = _buildRollforwardForTests(
+ [
+ {
+ category: 'equipment',
+ acquisition_date: '2024-01-01',
+ acquisition_cost: 60_000,
+ salvage_value: 0,
+ useful_life_months: 60,
+ disposed_at: null,
+ },
+ {
+ category: 'equipment',
+ acquisition_date: '2025-01-01',
+ acquisition_cost: 40_000,
+ salvage_value: 0,
+ useful_life_months: 60,
+ disposed_at: null,
+ },
+ ],
+ PERIOD_START,
+ PERIOD_END,
+ )
+ expect(rows).toHaveLength(1)
+ const r = rows[0]
+ expect(r.ibAnskaffning).toBe(60_000)
+ expect(r.tillkommande).toBe(40_000)
+ expect(r.ubAnskaffning).toBe(100_000)
+ })
+
+ it('separates categories', () => {
+ const rows = _buildRollforwardForTests(
+ [
+ {
+ category: 'equipment',
+ acquisition_date: '2024-01-01',
+ acquisition_cost: 60_000,
+ salvage_value: 0,
+ useful_life_months: 60,
+ disposed_at: null,
+ },
+ {
+ category: 'computer',
+ acquisition_date: '2024-01-01',
+ acquisition_cost: 20_000,
+ salvage_value: 0,
+ useful_life_months: 36,
+ disposed_at: null,
+ },
+ ],
+ PERIOD_START,
+ PERIOD_END,
+ )
+ expect(rows).toHaveLength(2)
+ expect(rows.find((r) => r.category === 'equipment')?.ibAnskaffning).toBe(60_000)
+ expect(rows.find((r) => r.category === 'computer')?.ibAnskaffning).toBe(20_000)
+ })
+
+ it('emits a note with all expected lines when assets exist', () => {
+ const note = buildAnlaggningstillgangarNote({
+ noteNumber: 5,
+ assets: [
+ {
+ category: 'equipment',
+ acquisition_date: '2024-01-01',
+ acquisition_cost: 60_000,
+ salvage_value: 0,
+ useful_life_months: 60,
+ disposed_at: null,
+ },
+ ],
+ periodStart: PERIOD_START,
+ periodEnd: PERIOD_END,
+ })
+ expect(note).not.toBeNull()
+ expect(note!.number).toBe(5)
+ expect(note!.title).toBe('Anläggningstillgångar')
+ expect(note!.body).toContain('Inventarier')
+ expect(note!.body).toContain('Ingående anskaffningsvärde')
+ expect(note!.body).toContain('Utgående anskaffningsvärde')
+ expect(note!.body).toContain('Ingående ackumulerade avskrivningar')
+ expect(note!.body).toContain('Utgående redovisat värde')
+ })
+
+ it('respects salvage_value when computing depreciation', () => {
+ const rows = _buildRollforwardForTests(
+ [
+ {
+ category: 'equipment',
+ acquisition_date: '2024-01-01',
+ acquisition_cost: 60_000,
+ salvage_value: 10_000,
+ useful_life_months: 60,
+ disposed_at: null,
+ },
+ ],
+ PERIOD_START,
+ PERIOD_END,
+ )
+ // Depreciable base 50,000 over 5 years → 10,000/yr (not 12,000)
+ expect(rows[0].aretsAvskrivning).toBeGreaterThan(9_500)
+ expect(rows[0].aretsAvskrivning).toBeLessThan(10_500)
+ })
+
+ it('caps accumulated depreciation at depreciable base after useful life', () => {
+ const rows = _buildRollforwardForTests(
+ [
+ {
+ category: 'equipment',
+ acquisition_date: '2010-01-01',
+ acquisition_cost: 60_000,
+ salvage_value: 0,
+ useful_life_months: 60,
+ disposed_at: null,
+ },
+ ],
+ PERIOD_START,
+ PERIOD_END,
+ )
+ expect(rows[0].ibAck).toBe(60_000)
+ expect(rows[0].aretsAvskrivning).toBe(0)
+ expect(rows[0].ubAck).toBe(60_000)
+ expect(rows[0].ubRedovisat).toBe(0)
+ })
+})
diff --git a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts
index 09639d51..b837e41c 100644
--- a/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts
+++ b/lib/bokslut/arsredovisning/__tests__/arsredovisning-k3-pdf.test.ts
@@ -119,6 +119,14 @@ function makeMinimalK3Data(): ArsredovisningData {
},
signatures: [],
warnings: [],
+ disclosures: {
+ long_term_debt_over_five_years: null,
+ securities_pledged: null,
+ contingent_liabilities: null,
+ parent_company_name: null,
+ parent_company_org_number: null,
+ parent_company_city: null,
+ },
}
}
diff --git a/lib/bokslut/arsredovisning/anlaggningstillgangar-note.ts b/lib/bokslut/arsredovisning/anlaggningstillgangar-note.ts
new file mode 100644
index 00000000..33aa476e
--- /dev/null
+++ b/lib/bokslut/arsredovisning/anlaggningstillgangar-note.ts
@@ -0,0 +1,233 @@
+/**
+ * Anläggningstillgångar roll-forward note (ÅRL 5:8 §).
+ *
+ * Required disclosure per category:
+ * - Ingående anskaffningsvärde
+ * + Årets inköp (tillkommande tillgångar)
+ * − Årets försäljningar/utrangeringar (avgående)
+ * = Utgående anskaffningsvärde
+ *
+ * - Ingående ackumulerade avskrivningar
+ * + Årets avskrivningar
+ * − Avskrivningar på avgående tillgångar
+ * = Utgående ackumulerade avskrivningar
+ *
+ * Utgående redovisat värde = utgående anskaffningsvärde − utgående ack. avskrivningar
+ *
+ * Driven entirely off the assets table. Accumulated depreciation is
+ * computed from the linear schedule (acquisition_date, useful_life_months,
+ * salvage_value) at the relevant as-of date — we do not depend on
+ * journal-derived avskrivningskonton because not all companies post
+ * monthly avskrivningar.
+ */
+
+import type { NoteEntry } from './types'
+
+export interface AnlaggningAsset {
+ category: string
+ acquisition_date: string
+ acquisition_cost: number
+ salvage_value: number
+ useful_life_months: number
+ disposed_at: string | null
+}
+
+interface CategoryRollforward {
+ category: string
+ ibAnskaffning: number
+ tillkommande: number
+ avgaende: number
+ ubAnskaffning: number
+ ibAck: number
+ aretsAvskrivning: number
+ avgaendeAck: number
+ ubAck: number
+ ubRedovisat: number
+}
+
+const CATEGORY_LABELS: Record
= {
+ immaterial: 'Immateriella anläggningstillgångar',
+ building: 'Byggnader',
+ land_improvement: 'Markanläggningar',
+ machinery: 'Maskiner',
+ equipment: 'Inventarier',
+ vehicle: 'Fordon',
+ computer: 'Datorer',
+ other_tangible: 'Övriga materiella anläggningstillgångar',
+}
+
+function daysBetween(startIso: string, endIso: string): number {
+ const start = new Date(`${startIso}T00:00:00Z`)
+ const end = new Date(`${endIso}T00:00:00Z`)
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return 0
+ if (end < start) return 0
+ return Math.floor((end.getTime() - start.getTime()) / 86400000)
+}
+
+/**
+ * Linear depreciation accumulated between acquisition_date and asOfIso.
+ * Caps at (cost − salvage) once useful life elapses. Day-based pro-rata
+ * matching how computeLinearDepreciation pro-rates the first/last year.
+ */
+function accumulatedDepreciation(
+ asset: AnlaggningAsset,
+ asOfIso: string,
+): number {
+ if (asOfIso < asset.acquisition_date) return 0
+ const lifeDays = asset.useful_life_months * (365.25 / 12)
+ const elapsedDays = Math.min(lifeDays, daysBetween(asset.acquisition_date, asOfIso))
+ if (lifeDays === 0) return 0
+ const depreciable = asset.acquisition_cost - asset.salvage_value
+ return Math.round((depreciable * elapsedDays) / lifeDays * 100) / 100
+}
+
+/** ISO date one day before a given ISO date. Used for "day before period start". */
+function isoMinusOneDay(iso: string): string {
+ const d = new Date(`${iso}T00:00:00Z`)
+ d.setUTCDate(d.getUTCDate() - 1)
+ return d.toISOString().slice(0, 10)
+}
+
+function buildRollforward(
+ assets: AnlaggningAsset[],
+ periodStart: string,
+ periodEnd: string,
+): CategoryRollforward[] {
+ const dayBeforeStart = isoMinusOneDay(periodStart)
+ const byCategory = new Map()
+
+ const getRow = (category: string): CategoryRollforward => {
+ let row = byCategory.get(category)
+ if (!row) {
+ row = {
+ category,
+ ibAnskaffning: 0,
+ tillkommande: 0,
+ avgaende: 0,
+ ubAnskaffning: 0,
+ ibAck: 0,
+ aretsAvskrivning: 0,
+ avgaendeAck: 0,
+ ubAck: 0,
+ ubRedovisat: 0,
+ }
+ byCategory.set(category, row)
+ }
+ return row
+ }
+
+ for (const asset of assets) {
+ const acquiredBeforePeriod = asset.acquisition_date < periodStart
+ const acquiredDuringPeriod =
+ asset.acquisition_date >= periodStart && asset.acquisition_date <= periodEnd
+ const disposedBeforePeriod =
+ asset.disposed_at != null && asset.disposed_at < periodStart
+ const disposedDuringPeriod =
+ asset.disposed_at != null &&
+ asset.disposed_at >= periodStart &&
+ asset.disposed_at <= periodEnd
+
+ // Skip assets entirely outside the period (acquired or disposed before)
+ if (disposedBeforePeriod) continue
+ if (!acquiredBeforePeriod && !acquiredDuringPeriod) continue
+
+ const row = getRow(asset.category)
+
+ if (acquiredBeforePeriod) {
+ // Was on the books at the start of the period
+ row.ibAnskaffning += asset.acquisition_cost
+ row.ibAck += accumulatedDepreciation(asset, dayBeforeStart)
+ }
+ if (acquiredDuringPeriod) {
+ row.tillkommande += asset.acquisition_cost
+ }
+ if (disposedDuringPeriod) {
+ row.avgaende += asset.acquisition_cost
+ row.avgaendeAck += accumulatedDepreciation(asset, asset.disposed_at!)
+ }
+
+ // Year's depreciation: from max(acquisition_date, period_start)
+ // to min(disposed_at ?? period_end, period_end). Computed as the
+ // delta in accumulated depreciation between those two dates.
+ const yearStart =
+ asset.acquisition_date > periodStart ? asset.acquisition_date : periodStart
+ const yearEnd =
+ asset.disposed_at != null && asset.disposed_at < periodEnd
+ ? asset.disposed_at
+ : periodEnd
+ if (yearStart <= yearEnd) {
+ const startAck = accumulatedDepreciation(asset, isoMinusOneDay(yearStart))
+ const endAck = accumulatedDepreciation(asset, yearEnd)
+ row.aretsAvskrivning += Math.max(0, endAck - startAck)
+ }
+ }
+
+ // Close out totals + ordering
+ const rows = Array.from(byCategory.values()).map((r) => {
+ const ub = Math.round((r.ibAnskaffning + r.tillkommande - r.avgaende) * 100) / 100
+ const ubAck =
+ Math.round((r.ibAck + r.aretsAvskrivning - r.avgaendeAck) * 100) / 100
+ return {
+ ...r,
+ ibAnskaffning: Math.round(r.ibAnskaffning * 100) / 100,
+ tillkommande: Math.round(r.tillkommande * 100) / 100,
+ avgaende: Math.round(r.avgaende * 100) / 100,
+ ubAnskaffning: ub,
+ ibAck: Math.round(r.ibAck * 100) / 100,
+ aretsAvskrivning: Math.round(r.aretsAvskrivning * 100) / 100,
+ avgaendeAck: Math.round(r.avgaendeAck * 100) / 100,
+ ubAck,
+ ubRedovisat: Math.round((ub - ubAck) * 100) / 100,
+ }
+ })
+
+ // Sort by BAS category order (same as CATEGORY_LABELS key order)
+ const order = Object.keys(CATEGORY_LABELS)
+ rows.sort((a, b) => order.indexOf(a.category) - order.indexOf(b.category))
+ return rows
+}
+
+const fmt = (n: number) => Math.round(n).toLocaleString('sv-SE')
+
+/**
+ * Build the anläggningstillgångar roll-forward note. Returns null when no
+ * assets fall within the period — the caller should skip the note.
+ */
+export function buildAnlaggningstillgangarNote(params: {
+ noteNumber: number
+ assets: AnlaggningAsset[]
+ periodStart: string
+ periodEnd: string
+}): NoteEntry | null {
+ const { noteNumber, assets, periodStart, periodEnd } = params
+ const rows = buildRollforward(assets, periodStart, periodEnd)
+ if (rows.length === 0) return null
+
+ const lines: string[] = []
+ for (const row of rows) {
+ const label = CATEGORY_LABELS[row.category] ?? row.category
+ lines.push(label)
+ lines.push(` Ingående anskaffningsvärde: ${fmt(row.ibAnskaffning)} kr`)
+ if (row.tillkommande !== 0)
+ lines.push(` Årets inköp: ${fmt(row.tillkommande)} kr`)
+ if (row.avgaende !== 0)
+ lines.push(` Årets försäljningar/utrangeringar: -${fmt(row.avgaende)} kr`)
+ lines.push(` Utgående anskaffningsvärde: ${fmt(row.ubAnskaffning)} kr`)
+ lines.push(` Ingående ackumulerade avskrivningar: -${fmt(row.ibAck)} kr`)
+ if (row.aretsAvskrivning !== 0)
+ lines.push(` Årets avskrivningar: -${fmt(row.aretsAvskrivning)} kr`)
+ if (row.avgaendeAck !== 0)
+ lines.push(` Återförda avskrivningar på avgående: ${fmt(row.avgaendeAck)} kr`)
+ lines.push(` Utgående ackumulerade avskrivningar: -${fmt(row.ubAck)} kr`)
+ lines.push(` Utgående redovisat värde: ${fmt(row.ubRedovisat)} kr`)
+ lines.push('')
+ }
+
+ return {
+ number: noteNumber,
+ title: 'Anläggningstillgångar',
+ body: lines.join('\n').trimEnd(),
+ }
+}
+
+export { buildRollforward as _buildRollforwardForTests }
diff --git a/lib/bokslut/arsredovisning/build-data.ts b/lib/bokslut/arsredovisning/build-data.ts
index a1253f41..6d1dada9 100644
--- a/lib/bokslut/arsredovisning/build-data.ts
+++ b/lib/bokslut/arsredovisning/build-data.ts
@@ -6,7 +6,7 @@ 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 { getNarrative, type NarrativeRow } from './narrative-service'
import {
anyAssetHasComponents,
buildEquityChangesNote,
@@ -14,6 +14,8 @@ import {
buildMateriellaAnlaggningsNot,
buildUppskjutenSkattNot,
} from './k3-noter-builder'
+import { buildAnlaggningstillgangarNote } from './anlaggningstillgangar-note'
+import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
import type {
ArsredovisningData,
EgenKapitalRow,
@@ -135,8 +137,23 @@ export async function buildArsredovisningData(
// 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)
+ ? await buildK3Noter(
+ supabase,
+ companyId,
+ fiscalPeriodId,
+ entityType,
+ period.period_start,
+ period.period_end,
+ narrative,
+ )
+ : await buildK2Noter(
+ supabase,
+ companyId,
+ entityType,
+ period.period_start,
+ period.period_end,
+ narrative,
+ )
// Kassaflödesanalys + separate equity-changes statement — K3 only. K2
// mindre företag is exempt from kassaflödesanalys (BFNAR 2016:10 punkt
@@ -270,6 +287,14 @@ export async function buildArsredovisningData(
kassaflodesanalys,
equity_changes_statement,
signatures: [], // populated by signature-flow service in a later phase step
+ disclosures: {
+ long_term_debt_over_five_years: narrative?.long_term_debt_over_five_years ?? null,
+ securities_pledged: narrative?.securities_pledged ?? null,
+ contingent_liabilities: narrative?.contingent_liabilities ?? null,
+ parent_company_name: narrative?.parent_company_name ?? null,
+ parent_company_org_number: narrative?.parent_company_org_number ?? null,
+ parent_company_city: narrative?.parent_company_city ?? null,
+ },
}
}
@@ -378,6 +403,9 @@ async function buildK2Noter(
supabase: SupabaseClient,
companyId: string,
entityType: string,
+ periodStart: string,
+ periodEnd: string,
+ narrative: NarrativeRow | null,
): Promise<{ notes: NoteEntry[]; warnings: string[] }> {
const notes: NoteEntry[] = []
const warnings: string[] = []
@@ -435,7 +463,8 @@ async function buildK2Noter(
}
}
- // Avskrivningstider — derive from asset register
+ // Avskrivningstider — derive from asset register (supplementary
+ // disclosure; the statutory ÅRL 5:8 § roll-forward follows below).
const assets = await listAssets(supabase, companyId)
if (assets.length > 0) {
const byCategory = new Map>()
@@ -463,33 +492,108 @@ async function buildK2Noter(
lines.push(`• ${categoryLabels[cat] ?? cat}: ${yrsLabel}`)
}
notes.push({
- number: 2,
+ number: notes.length + 1,
title: 'Avskrivningar',
body: lines.join('\n'),
})
}
}
- // Medelantal anställda — count active employees as a proxy
- const { count: employeeCount } = await supabase
+ // Anläggningstillgångar roll-forward (ÅRL 5:8 §). Per-category IB →
+ // tillkommande → avgående → UB anskaffningsvärde, same for ackumulerade
+ // avskrivningar, ending in utgående redovisat värde. Hard ÅR requirement
+ // for any company with assets on the books.
+ const rollforwardNote = buildAnlaggningstillgangarNote({
+ noteNumber: notes.length + 1,
+ assets: assets.map((a) => ({
+ category: a.category,
+ acquisition_date: a.acquisition_date,
+ acquisition_cost: a.acquisition_cost,
+ salvage_value: a.salvage_value,
+ useful_life_months: a.useful_life_months,
+ disposed_at: a.disposed_at,
+ })),
+ periodStart,
+ periodEnd,
+ })
+ if (rollforwardNote) notes.push(rollforwardNote)
+
+ // Medelantal anställda — FTE-weighted average per ÅRL 5:20 §. We fetch the
+ // full employment-window data because the column 'is_active' doesn't exist
+ // on the employees table; a count() filtered by it would always return 0.
+ // ÅRL 5:20 § requires the note for AB regardless of value — "0" must be
+ // disclosed as "Inga anställda". For enskild firma the disclosure is
+ // discretionary, so we still skip when medelantal === 0 there.
+ const { data: employeeRows } = await supabase
.from('employees')
- .select('id', { count: 'exact', head: true })
+ .select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId)
- .eq('is_active', true)
- if ((employeeCount ?? 0) > 0) {
+ const medelantal = computeMedelantalAnstallda(
+ (employeeRows ?? []) as Array<{
+ employment_start: string
+ employment_end: string | null
+ employment_degree: number
+ }>,
+ periodStart,
+ periodEnd,
+ )
+ if (medelantal > 0 || entityType === 'aktiebolag') {
notes.push({
number: notes.length + 1,
title: 'Medelantal anställda',
- body: `Under räkenskapsåret har medeltalet anställda uppgått till ${employeeCount}.`,
+ body:
+ medelantal > 0
+ ? `Under räkenskapsåret har medeltalet anställda uppgått till ${medelantal}.`
+ : 'Bolaget har inte haft några anställda under räkenskapsåret.',
})
}
+ // Långfristiga skulder förfallande efter mer än fem år (ÅRL 5:13 §).
+ // Disclosed amount lives on arsredovisning_narratives as a manual entry;
+ // loan-maturity data isn't tagged in journal lines so we can't derive it.
+ // A null/zero value defaults to "Inga." per Swedish ÅR convention.
+ const longTermDebtAmount = narrative?.long_term_debt_over_five_years ?? null
notes.push({
number: notes.length + 1,
- title: 'Ställda säkerheter och eventualförpliktelser',
- body: 'Inga.',
+ title: 'Långfristiga skulder',
+ body:
+ longTermDebtAmount && longTermDebtAmount > 0
+ ? `Av långfristiga skulder förfaller ${longTermDebtAmount.toLocaleString('sv-SE')} kr till betalning senare än fem år efter balansdagen.`
+ : 'Inga skulder förfaller till betalning senare än fem år efter balansdagen.',
})
+ // Ställda säkerheter (ÅRL 5:14 §) — separate disclosure from
+ // eventualförpliktelser. Manual override on arsredovisning_narratives,
+ // defaulting to "Inga.".
+ notes.push({
+ number: notes.length + 1,
+ title: 'Ställda säkerheter',
+ body: narrative?.securities_pledged?.trim() || 'Inga.',
+ })
+
+ // Eventualförpliktelser (ÅRL 5:15 §)
+ notes.push({
+ number: notes.length + 1,
+ title: 'Eventualförpliktelser',
+ body: narrative?.contingent_liabilities?.trim() || 'Inga.',
+ })
+
+ // Koncernförhållanden (BFNAR 2016:10 kap. 19). Emitted only when a parent
+ // company is configured — companies without a parent skip this note.
+ const parentName = narrative?.parent_company_name?.trim()
+ if (parentName) {
+ const parts: string[] = [`Moderföretag: ${parentName}.`]
+ if (narrative?.parent_company_org_number)
+ parts.push(`Organisationsnummer: ${narrative.parent_company_org_number}.`)
+ if (narrative?.parent_company_city)
+ parts.push(`Säte: ${narrative.parent_company_city}.`)
+ notes.push({
+ number: notes.length + 1,
+ title: 'Koncernförhållanden',
+ body: parts.join(' '),
+ })
+ }
+
return { notes, warnings }
}
@@ -510,7 +614,9 @@ async function buildK3Noter(
companyId: string,
fiscalPeriodId: string,
entityType: string,
+ periodStartIso: string,
periodEndIso: string,
+ narrative: NarrativeRow | null,
): Promise<{ notes: NoteEntry[]; warnings: string[] }> {
const notes: NoteEntry[] = []
const warnings: string[] = []
@@ -618,6 +724,25 @@ async function buildK3Noter(
})
if (materialiNote) notes.push(materialiNote)
+ // 3b. Anläggningstillgångar roll-forward (ÅRL 5:8 §). Required even under
+ // K3 — K3 ch.17 layers component depreciation on top, but the basic
+ // per-category roll-forward of anskaffningsvärde + ackumulerade
+ // avskrivningar is the statutory baseline.
+ const rollforwardNote = buildAnlaggningstillgangarNote({
+ noteNumber: notes.length + 1,
+ assets: assets.map((a) => ({
+ category: a.category,
+ acquisition_date: a.acquisition_date,
+ acquisition_cost: a.acquisition_cost,
+ salvage_value: a.salvage_value,
+ useful_life_months: a.useful_life_months,
+ disposed_at: a.disposed_at,
+ })),
+ periodStart: periodStartIso,
+ periodEnd: periodEndIso,
+ })
+ if (rollforwardNote) notes.push(rollforwardNote)
+
// 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
@@ -657,35 +782,75 @@ async function buildK3Noter(
)
}
- // 5. Medelantal anställda
- const { count: employeeCount } = await supabase
+ // 5. Medelantal anställda — FTE-weighted average per ÅRL 5:20 §. The note is
+ // statutory for AB regardless of value (disclose "0" explicitly); for non-AB
+ // entities we still skip when there are no employees.
+ const { data: employeeRows } = await supabase
.from('employees')
- .select('id', { count: 'exact', head: true })
+ .select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId)
- .eq('is_active', true)
- if ((employeeCount ?? 0) > 0) {
+ const medelantal = computeMedelantalAnstallda(
+ (employeeRows ?? []) as Array<{
+ employment_start: string
+ employment_end: string | null
+ employment_degree: number
+ }>,
+ periodStartIso,
+ periodEndIso,
+ )
+ if (medelantal > 0 || entityType === 'aktiebolag') {
notes.push({
number: notes.length + 1,
title: 'Medelantal anställda',
- body: `Under räkenskapsåret har medeltalet anställda uppgått till ${employeeCount}.`,
+ body:
+ medelantal > 0
+ ? `Under räkenskapsåret har medeltalet anställda uppgått till ${medelantal}.`
+ : 'Bolaget har inte haft några anställda under räkenskapsåret.',
})
}
- // 6. Eventualförpliktelser (K3 punkt 21 — separate disclosure).
+ // 6. Långfristiga skulder förfallande efter mer än fem år (ÅRL 5:13 §).
+ const longTermDebtAmount = narrative?.long_term_debt_over_five_years ?? null
+ notes.push({
+ number: notes.length + 1,
+ title: 'Långfristiga skulder',
+ body:
+ longTermDebtAmount && longTermDebtAmount > 0
+ ? `Av långfristiga skulder förfaller ${longTermDebtAmount.toLocaleString('sv-SE')} kr till betalning senare än fem år efter balansdagen.`
+ : 'Inga skulder förfaller till betalning senare än fem år efter balansdagen.',
+ })
+
+ // 7. Eventualförpliktelser (K3 punkt 21 — separate disclosure).
notes.push({
number: notes.length + 1,
title: 'Eventualförpliktelser',
- body: 'Inga.',
+ body: narrative?.contingent_liabilities?.trim() || 'Inga.',
})
- // 7. Ställda säkerheter
+ // 8. Ställda säkerheter (ÅRL 5:14 §).
notes.push({
number: notes.length + 1,
title: 'Ställda säkerheter',
- body: 'Inga.',
+ body: narrative?.securities_pledged?.trim() || 'Inga.',
})
- // 8. Väsentliga händelser efter balansdagen (K3 ch.32)
+ // 9. Koncernförhållanden (BFNAR 2012:1 kap. 8 — moderföretagets namn,
+ // organisationsnummer och säte). Emitted only when configured.
+ const parentName = narrative?.parent_company_name?.trim()
+ if (parentName) {
+ const parts: string[] = [`Moderföretag: ${parentName}.`]
+ if (narrative?.parent_company_org_number)
+ parts.push(`Organisationsnummer: ${narrative.parent_company_org_number}.`)
+ if (narrative?.parent_company_city)
+ parts.push(`Säte: ${narrative.parent_company_city}.`)
+ notes.push({
+ number: notes.length + 1,
+ title: 'Koncernförhållanden',
+ body: parts.join(' '),
+ })
+ }
+
+ // 10. Väsentliga händelser efter balansdagen (K3 ch.32)
notes.push({
number: notes.length + 1,
title: 'Väsentliga händelser efter balansdagen',
diff --git a/lib/bokslut/arsredovisning/narrative-service.ts b/lib/bokslut/arsredovisning/narrative-service.ts
index f5132467..56828dde 100644
--- a/lib/bokslut/arsredovisning/narrative-service.ts
+++ b/lib/bokslut/arsredovisning/narrative-service.ts
@@ -8,6 +8,22 @@ export interface NarrativeOverrides {
* Populates the fastställelseintyg date blank — without it the PDF
* cannot be filed at Bolagsverket without manual pen-and-ink edit. */
agm_date: string | null
+ /** ÅRL 5:13 § — andel av långfristiga skulder som förfaller senare än
+ * fem år efter balansdagen. Null/0 → "Inga skulder förfaller efter mer
+ * än fem år." rendered in the note. */
+ long_term_debt_over_five_years: number | null
+ /** ÅRL 5:14 § — ställda säkerheter (panter, företagsinteckningar). Null
+ * → "Inga." */
+ securities_pledged: string | null
+ /** ÅRL 5:15 § — eventualförpliktelser (borgensåtaganden, garantier).
+ * Null → "Inga." */
+ contingent_liabilities: string | null
+ /** BFNAR 2016:10 kap. 19 / BFNAR 2012:1 kap. 8 — moderföretagets namn.
+ * Note is emitted only when this is set; org_number and city are
+ * optional follow-up details. */
+ parent_company_name: string | null
+ parent_company_org_number: string | null
+ parent_company_city: string | null
}
/**
@@ -23,6 +39,12 @@ export interface NarrativeRow {
important_events: string | null
resultatdisposition: string | null
agm_date: string | null
+ long_term_debt_over_five_years: number | null
+ securities_pledged: string | null
+ contingent_liabilities: string | null
+ parent_company_name: string | null
+ parent_company_org_number: string | null
+ parent_company_city: string | null
updated_at: string
}
@@ -32,7 +54,7 @@ const TABLE = 'arsredovisning_narratives'
// of API responses. GDPR Art.25.2 / ISO A.8.3 data-minimization: callers
// only need the narrative content + last-updated timestamp.
const NARRATIVE_API_COLUMNS =
- 'id, company_id, fiscal_period_id, description, important_events, resultatdisposition, agm_date, updated_at'
+ 'id, company_id, fiscal_period_id, description, important_events, resultatdisposition, agm_date, long_term_debt_over_five_years, securities_pledged, contingent_liabilities, parent_company_name, parent_company_org_number, parent_company_city, updated_at'
/**
* Load persisted narrative overrides for a fiscal period. Returns null when
@@ -76,6 +98,12 @@ export async function upsertNarrative(
important_events: input.important_events ?? null,
resultatdisposition: input.resultatdisposition ?? null,
agm_date: input.agm_date ?? null,
+ long_term_debt_over_five_years: input.long_term_debt_over_five_years ?? null,
+ securities_pledged: input.securities_pledged ?? null,
+ contingent_liabilities: input.contingent_liabilities ?? null,
+ parent_company_name: input.parent_company_name ?? null,
+ parent_company_org_number: input.parent_company_org_number ?? null,
+ parent_company_city: input.parent_company_city ?? null,
}
const { data, error } = await supabase
.from(TABLE)
diff --git a/lib/bokslut/arsredovisning/types.ts b/lib/bokslut/arsredovisning/types.ts
index ed624332..db693aa1 100644
--- a/lib/bokslut/arsredovisning/types.ts
+++ b/lib/bokslut/arsredovisning/types.ts
@@ -111,6 +111,17 @@ export interface ArsredovisningData {
* uppgifter saknas, AGM-datum saknas, K3 entity. Never an error — the
* user can still download to iterate. */
warnings: string[]
+ /** Manual disclosure overrides persisted on arsredovisning_narratives.
+ * Drive the long-term debt, säkerheter, eventualförpliktelser, and
+ * koncernförhållanden notes. Null means "use the boilerplate". */
+ disclosures: {
+ long_term_debt_over_five_years: number | null
+ securities_pledged: string | null
+ contingent_liabilities: string | null
+ parent_company_name: string | null
+ parent_company_org_number: string | null
+ parent_company_city: string | null
+ }
}
/**
diff --git a/lib/core/documents/__tests__/document-service.test.ts b/lib/core/documents/__tests__/document-service.test.ts
index 651403af..2bd332d7 100644
--- a/lib/core/documents/__tests__/document-service.test.ts
+++ b/lib/core/documents/__tests__/document-service.test.ts
@@ -49,6 +49,11 @@ vi.mock('@/lib/auth/api-keys', () => ({
import { uploadDocument, createNewVersion, verifyIntegrity, _resetBucketVerified } from '../document-service'
+// A minimal valid PDF byte sequence (header + EOF) — passes magic-byte check.
+function pdfBuffer(payload = 'test'): ArrayBuffer {
+ return new TextEncoder().encode(`%PDF-1.4\n${payload}\n%%EOF\n`).buffer as ArrayBuffer
+}
+
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
@@ -73,10 +78,9 @@ describe('uploadDocument', () => {
eventBus.on('document.uploaded', handler)
const supabase = makeClient()
- const buffer = new TextEncoder().encode('test content').buffer
const result = await uploadDocument(supabase as never, 'user-1', 'company-1', {
name: 'test.pdf',
- buffer: buffer as ArrayBuffer,
+ buffer: pdfBuffer('test content'),
type: 'application/pdf',
})
@@ -114,10 +118,9 @@ describe('createNewVersion', () => {
]
const supabase = makeClient()
- const buffer = new TextEncoder().encode('new content').buffer
const result = await createNewVersion(supabase as never, 'user-1', 'doc-1', {
name: 'test-v2.pdf',
- buffer: buffer as ArrayBuffer,
+ buffer: pdfBuffer('new content'),
type: 'application/pdf',
})
diff --git a/lib/core/documents/document-service.ts b/lib/core/documents/document-service.ts
index 206c0055..eb87fe5e 100644
--- a/lib/core/documents/document-service.ts
+++ b/lib/core/documents/document-service.ts
@@ -57,6 +57,55 @@ export function validateDocumentFile(file: { size: number; type?: string }): str
return null
}
+/**
+ * Inspect the first bytes of a buffer to identify the actual file format.
+ * Defends against callers (typically MCP agents) that base64-encode a text
+ * placeholder or summary instead of the real binary file — those uploads
+ * succeed at the storage layer but the bytes are unreadable as a PDF/image.
+ */
+function detectFileMagic(bytes: Uint8Array): string | null {
+ if (bytes.length < 4) return null
+ // PDF: %PDF- (allow a leading UTF-8 BOM as some tools prepend one)
+ const offset = bytes[0] === 0xEF && bytes[1] === 0xBB && bytes[2] === 0xBF ? 3 : 0
+ if (
+ bytes.length >= offset + 5 &&
+ bytes[offset] === 0x25 &&
+ bytes[offset + 1] === 0x50 &&
+ bytes[offset + 2] === 0x44 &&
+ bytes[offset + 3] === 0x46 &&
+ bytes[offset + 4] === 0x2D
+ ) return 'application/pdf'
+ // PNG: 89 50 4E 47
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'image/png'
+ // JPEG: FF D8 FF
+ if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'image/jpeg'
+ // WebP: RIFF<4-byte size>WEBP
+ if (
+ bytes.length >= 12 &&
+ bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
+ bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
+ ) return 'image/webp'
+ return null
+}
+
+/**
+ * Verify the buffer actually contains a file of the declared type.
+ * Returns an error string or null if valid. HEIC has many ftyp brands so
+ * we skip the check for now — the UI path doesn't allow HEIC anyway, only
+ * the MCP upload tool does, and corrupted HEIC has not been observed.
+ */
+export function validateDocumentMagicBytes(buffer: ArrayBuffer, declaredMimeType: string): string | null {
+ if (declaredMimeType === 'image/heic') return null
+ const detected = detectFileMagic(new Uint8Array(buffer))
+ if (!detected) {
+ return `Filinnehållet kunde inte verifieras som ${declaredMimeType}. Filen verkar vara skadad eller inte en riktig binärfil — vid uppladdning via API, kontrollera att file_content_base64 är base64-kodade råbytes, inte en textrepresentation.`
+ }
+ if (detected !== declaredMimeType) {
+ return `Filinnehållet matchar inte den angivna filtypen (förväntade ${declaredMimeType}, hittade ${detected}).`
+ }
+ return null
+}
+
let bucketVerified = false
/** @internal Reset bucket verification flag — for testing only */
@@ -113,6 +162,12 @@ export async function uploadDocument(
): Promise {
await ensureDocumentsBucket()
+ // Reject corrupt uploads at the boundary — see validateDocumentMagicBytes.
+ if (file.type) {
+ const magicError = validateDocumentMagicBytes(file.buffer, file.type)
+ if (magicError) throw new Error(magicError)
+ }
+
// Compute SHA-256 hash
const sha256Hash = await computeSHA256(file.buffer)
@@ -186,6 +241,11 @@ export async function createNewVersion(
): Promise {
await ensureDocumentsBucket()
+ if (file.type) {
+ const magicError = validateDocumentMagicBytes(file.buffer, file.type)
+ if (magicError) throw new Error(magicError)
+ }
+
// Compute SHA-256 hash
const sha256Hash = await computeSHA256(file.buffer)
diff --git a/lib/errors/structured-errors.ts b/lib/errors/structured-errors.ts
index 40577765..e304e278 100644
--- a/lib/errors/structured-errors.ts
+++ b/lib/errors/structured-errors.ts
@@ -1227,6 +1227,11 @@ const DOCUMENT: Record = {
message_sv: 'Filen kunde inte sparas.',
message_en: 'Document storage failed.',
},
+ DOC_UPLOAD_PERIOD_LOCKED: {
+ httpStatus: 400,
+ message_sv: 'Det går inte att bifoga underlag till verifikationer i en låst eller stängd period.',
+ message_en: 'Cannot attach documents to entries in a locked or closed fiscal period.',
+ },
DOC_DOWNLOAD_FAILED: {
httpStatus: 500,
message_sv: 'Det gick inte att skapa nedladdningslänken.',
diff --git a/lib/invoices/__tests__/vat-rules.test.ts b/lib/invoices/__tests__/vat-rules.test.ts
index 8a080fe9..88b31c0c 100644
--- a/lib/invoices/__tests__/vat-rules.test.ts
+++ b/lib/invoices/__tests__/vat-rules.test.ts
@@ -66,6 +66,27 @@ describe('getAvailableVatRates', () => {
const rates = getAvailableVatRates('eu_business')
expect(rates).toHaveLength(4)
})
+
+ it('collapses to single 0% exempt option when seller is NOT VAT-registered', () => {
+ // ML 1 kap. 1§ — a non-skattskyldig seller may not charge VAT, so the
+ // picker must offer 0% only, regardless of customer type.
+ for (const ct of ['individual', 'swedish_business', 'eu_business', 'non_eu_business'] as const) {
+ const rates = getAvailableVatRates(ct, true, false)
+ expect(rates).toHaveLength(1)
+ expect(rates[0]).toEqual({
+ rate: 0,
+ label: '0% (ej momsregistrerad)',
+ treatment: 'exempt',
+ })
+ }
+ })
+
+ it('defaults vatRegistered to true (current behavior preserved)', () => {
+ // Existing callers omit the third arg — they must still see the full
+ // rate set for Swedish customers.
+ const rates = getAvailableVatRates('swedish_business')
+ expect(rates).toHaveLength(4)
+ })
})
// ============================================================
@@ -155,6 +176,27 @@ describe('getVatRules', () => {
momsRuta: '05',
})
})
+
+ it('short-circuits to exempt/0/empty momsRuta when seller is NOT VAT-registered', () => {
+ // ML 1 kap. 1§ — no output VAT, no momsdeklaration row, regardless of
+ // customer type. Verified for all four customer types.
+ for (const ct of ['individual', 'swedish_business', 'eu_business', 'non_eu_business'] as const) {
+ const rules = getVatRules(ct, true, false)
+ expect(rules).toEqual({
+ treatment: 'exempt',
+ rate: 0,
+ momsRuta: '',
+ })
+ }
+ })
+
+ it('defaults vatRegistered to true (current behavior preserved)', () => {
+ // Existing callers omit the third arg — they must still see standard_25
+ // for Swedish customers.
+ const rules = getVatRules('swedish_business')
+ expect(rules.rate).toBe(25)
+ expect(rules.treatment).toBe('standard_25')
+ })
})
// ============================================================
diff --git a/lib/invoices/pdf-template.tsx b/lib/invoices/pdf-template.tsx
index 81977e2e..b5fb9565 100644
--- a/lib/invoices/pdf-template.tsx
+++ b/lib/invoices/pdf-template.tsx
@@ -71,6 +71,7 @@ const LABELS = {
// Proforma / exempt
proformaNotice: 'Detta är en proformafaktura och utgör ingen betalningsanmodan.',
exemptNotice: 'Undantag från skatteplikt, ML 3 kap.',
+ notVatRegisteredNotice: 'Företaget är inte momsregistrerat. Mervärdesskatt redovisas ej.',
// Payment
paymentHeading: 'Betalningsinformation',
bank: 'Bank:',
@@ -134,6 +135,7 @@ const LABELS = {
totalInSek: 'Total in SEK:',
proformaNotice: 'This is a proforma invoice and is not a request for payment.',
exemptNotice: 'Exempt from VAT (ML 3 kap. — Swedish VAT Act).',
+ notVatRegisteredNotice: 'The seller is not VAT-registered. No VAT is charged on this invoice.',
paymentHeading: 'Payment information',
bank: 'Bank:',
account: 'Account number:',
@@ -781,10 +783,22 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
{customer.country && customer.country !== 'SE' && (
{customer.country}
)}
- {customer.org_number && (
+ {/* Suppress the identifier row for private customers — their
+ personnummer is not required on a B2C invoice (ML 17 kap 24§
+ asks for name + address only) and printing it is a GDPR
+ data-minimization regression. ROT/RUT-avdrag invoices surface
+ the masked personnummer in the dedicated deductionBox below
+ when Skatteverket needs it. */}
+ {customer.customer_type !== 'individual' && customer.org_number && (
{L.orgNo} {customer.org_number}
)}
- {customer.vat_number && {L.vat} {customer.vat_number} }
+ {/* Same data-minimisation guard as org_number above — for a
+ private customer a VAT number functions as a personal tax
+ identifier in some EU jurisdictions and is not required by
+ ML 17 kap 24§ on a B2C invoice. */}
+ {customer.customer_type !== 'individual' && customer.vat_number && (
+ {L.vat} {customer.vat_number}
+ )}
@@ -854,10 +868,17 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
))
) : (
-
- {L.vatRow(invoice.vat_rate ?? (vatByRate.size === 1 ? (vatByRate.keys().next().value ?? 0) : 0))}
- {formatCurrency(invoice.vat_amount, invoice.currency, lang)}
-
+ // Suppress the "Moms 0%" row entirely when the seller is not
+ // VAT-registered. ML 1 kap. 1§ — a non-skattskyldig may not
+ // charge output VAT, so a "Moms 0%" line would imply VAT
+ // accounting that doesn't exist. The notice block below the
+ // payment section explains the absence of VAT.
+ !(company.vat_registered === false && invoice.vat_amount === 0) && (
+
+ {L.vatRow(invoice.vat_rate ?? (vatByRate.size === 1 ? (vatByRate.keys().next().value ?? 0) : 0))}
+ {formatCurrency(invoice.vat_amount, invoice.currency, lang)}
+
+ )
)}
{(() => {
const rounding = getDisplayTotal(invoice, company)
@@ -1041,16 +1062,31 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
)}
- {/* Reverse charge / export / exempt notice */}
- {invoice.reverse_charge_text && (
+ {/* Reverse charge / export / exempt / not-registered notice.
+ "Not VAT-registered" trumps the others — when the seller is
+ outside the VAT system entirely (vat_registered=false in
+ company_settings), reverse-charge and ML 3 kap. exempt notices
+ don't apply, and a single dedicated notice is clearer for the
+ customer than reusing the exempt notice (which implies the sale
+ specifically is exempt while the seller is otherwise within the
+ VAT system). */}
+ {company.vat_registered === false ? (
- {invoice.reverse_charge_text}
-
- )}
- {invoice.vat_treatment === 'exempt' && !invoice.reverse_charge_text && (
-
- {L.exemptNotice}
+ {L.notVatRegisteredNotice}
+ ) : (
+ <>
+ {invoice.reverse_charge_text && (
+
+ {invoice.reverse_charge_text}
+
+ )}
+ {invoice.vat_treatment === 'exempt' && !invoice.reverse_charge_text && (
+
+ {L.exemptNotice}
+
+ )}
+ >
)}
{/* Notes */}
diff --git a/lib/invoices/vat-rules.ts b/lib/invoices/vat-rules.ts
index 5185ac34..97531fb3 100644
--- a/lib/invoices/vat-rules.ts
+++ b/lib/invoices/vat-rules.ts
@@ -11,11 +11,21 @@ export interface VatRateOption {
*
* Swedish/EU-unvalidated customers can choose between 25%, 12%, 6%, and 0% (exempt).
* Reverse charge and export customers are locked to 0%.
+ *
+ * When the seller is not VAT-registered (`vatRegistered=false`), every customer
+ * type collapses to a single 0% / exempt option. ML 1 kap. 1§ — only a
+ * skattskyldig person may charge VAT, so the picker must never offer non-zero
+ * rates in that mode.
*/
export function getAvailableVatRates(
customerType: CustomerType,
- vatNumberValidated: boolean = false
+ vatNumberValidated: boolean = false,
+ vatRegistered: boolean = true,
): VatRateOption[] {
+ if (!vatRegistered) {
+ return [{ rate: 0, label: '0% (ej momsregistrerad)', treatment: 'exempt' }]
+ }
+
// EU business with validated VAT → reverse charge, locked to 0%
if (customerType === 'eu_business' && vatNumberValidated) {
return [{ rate: 0, label: '0% (omvänd skattskyldighet)', treatment: 'reverse_charge' }]
@@ -61,18 +71,32 @@ export interface VatRule {
}
/**
- * Determine VAT treatment based on customer type and VAT validation status
+ * Determine VAT treatment based on customer type and VAT validation status.
*
* Rules:
* - Swedish customers: 25% VAT, moms ruta 05
* - EU business with validated VAT: 0% reverse charge, moms ruta 39
* - EU business without validated VAT: 25% VAT, moms ruta 05
* - Non-EU business: 0% export, moms ruta 40
+ *
+ * When the seller is not VAT-registered (`vatRegistered=false`), the rules
+ * short-circuit to `{ treatment: 'exempt', rate: 0, momsRuta: '' }` regardless
+ * of customer type — ML 1 kap. 1§ bars a non-skattskyldig from charging output
+ * VAT. `momsRuta` is empty so the invoice doesn't claim a momsdeklaration row.
*/
export function getVatRules(
customerType: CustomerType,
- vatNumberValidated: boolean = false
+ vatNumberValidated: boolean = false,
+ vatRegistered: boolean = true,
): VatRule {
+ if (!vatRegistered) {
+ return {
+ treatment: 'exempt',
+ rate: 0,
+ momsRuta: '',
+ }
+ }
+
switch (customerType) {
case 'individual':
case 'swedish_business':
diff --git a/lib/salary/__tests__/medelantal.test.ts b/lib/salary/__tests__/medelantal.test.ts
new file mode 100644
index 00000000..22a32893
--- /dev/null
+++ b/lib/salary/__tests__/medelantal.test.ts
@@ -0,0 +1,154 @@
+import { describe, it, expect } from 'vitest'
+import { computeMedelantalAnstallda } from '../medelantal'
+
+describe('computeMedelantalAnstallda', () => {
+ const START = '2025-01-01'
+ const END = '2025-12-31'
+
+ it('returns 0 for empty list', () => {
+ expect(computeMedelantalAnstallda([], START, END)).toBe(0)
+ })
+
+ it('counts a full-year, full-time employee as 1', () => {
+ expect(
+ computeMedelantalAnstallda(
+ [{ employment_start: '2024-01-01', employment_end: null, employment_degree: 100 }],
+ START,
+ END,
+ ),
+ ).toBe(1)
+ })
+
+ it('counts a half-year hire (Jul 1) as 0.5 → rounds to 1', () => {
+ // 184 / 365 ≈ 0.504 → rounds to 1
+ expect(
+ computeMedelantalAnstallda(
+ [{ employment_start: '2025-07-01', employment_end: null, employment_degree: 100 }],
+ START,
+ END,
+ ),
+ ).toBe(1)
+ })
+
+ it('counts a 50% full-year employee as 0.5 → rounds to 1', () => {
+ // 365 * 0.5 / 365 = 0.5 → Math.round(0.5) = 1 in JS (banker's round of .5 goes up via Math.round)
+ expect(
+ computeMedelantalAnstallda(
+ [{ employment_start: '2024-01-01', employment_end: null, employment_degree: 50 }],
+ START,
+ END,
+ ),
+ ).toBe(1)
+ })
+
+ it('counts a terminated employee correctly', () => {
+ // Mar 1 - Aug 31 = 184 days, 100% → 184/365 = 0.504 → 1
+ expect(
+ computeMedelantalAnstallda(
+ [
+ {
+ employment_start: '2025-03-01',
+ employment_end: '2025-08-31',
+ employment_degree: 100,
+ },
+ ],
+ START,
+ END,
+ ),
+ ).toBe(1)
+ })
+
+ it('weights by employment_degree on a partial year', () => {
+ // Mar 1 - Aug 31 (184 days) at 80% → 147.2/365 ≈ 0.403 → 0
+ expect(
+ computeMedelantalAnstallda(
+ [
+ {
+ employment_start: '2025-03-01',
+ employment_end: '2025-08-31',
+ employment_degree: 80,
+ },
+ ],
+ START,
+ END,
+ ),
+ ).toBe(0)
+ })
+
+ it('sums across multiple employees', () => {
+ // Two full-time employees all year + one half-year hire ≈ 2.5 → 3
+ expect(
+ computeMedelantalAnstallda(
+ [
+ { employment_start: '2024-01-01', employment_end: null, employment_degree: 100 },
+ { employment_start: '2024-01-01', employment_end: null, employment_degree: 100 },
+ { employment_start: '2025-07-01', employment_end: null, employment_degree: 100 },
+ ],
+ START,
+ END,
+ ),
+ ).toBe(3)
+ })
+
+ it('clamps employment_degree to 0..100', () => {
+ expect(
+ computeMedelantalAnstallda(
+ [
+ { employment_start: '2024-01-01', employment_end: null, employment_degree: 200 },
+ ],
+ START,
+ END,
+ ),
+ ).toBe(1)
+ })
+
+ it('ignores employees terminated before period start', () => {
+ expect(
+ computeMedelantalAnstallda(
+ [
+ {
+ employment_start: '2024-01-01',
+ employment_end: '2024-12-31',
+ employment_degree: 100,
+ },
+ ],
+ START,
+ END,
+ ),
+ ).toBe(0)
+ })
+
+ it('ignores employees hired after period end', () => {
+ expect(
+ computeMedelantalAnstallda(
+ [
+ { employment_start: '2026-01-01', employment_end: null, employment_degree: 100 },
+ ],
+ START,
+ END,
+ ),
+ ).toBe(0)
+ })
+
+ it('handles a brutet räkenskapsår (Jul 1 - Jun 30)', () => {
+ expect(
+ computeMedelantalAnstallda(
+ [
+ { employment_start: '2024-01-01', employment_end: null, employment_degree: 100 },
+ ],
+ '2025-07-01',
+ '2026-06-30',
+ ),
+ ).toBe(1)
+ })
+
+ it('returns 0 for period with zero length', () => {
+ expect(
+ computeMedelantalAnstallda(
+ [{ employment_start: '2024-01-01', employment_end: null, employment_degree: 100 }],
+ '2025-01-01',
+ '2024-12-31', // reversed
+ ),
+ ).toBe(0)
+ })
+})
diff --git a/lib/salary/medelantal.ts b/lib/salary/medelantal.ts
new file mode 100644
index 00000000..5dcc1647
--- /dev/null
+++ b/lib/salary/medelantal.ts
@@ -0,0 +1,68 @@
+/**
+ * Medelantal anställda — time-weighted FTE average across a fiscal period.
+ *
+ * Per ÅRL 5:20 § the medelantal disclosure is the average number of
+ * full-time-equivalent employees over the räkenskapsår, not a snapshot
+ * headcount. An employee hired on July 1 of a calendar-year FY counts as
+ * 0.5; an employee on 50 % degree employed all year counts as 0.5; an
+ * employee hired Mar 1 at 80 % and terminated Aug 31 counts as
+ * (184 days / 365 days) × 0.80 ≈ 0.40.
+ *
+ * Inputs come from public.employees:
+ * - employment_start: required (DATE NOT NULL)
+ * - employment_end: optional (DATE) — when null, employee is still active
+ * on the period end date
+ * - employment_degree: 0 < degree <= 100, default 100
+ *
+ * The result is rounded to the nearest whole employee per Swedish ÅR
+ * disclosure convention (Skatteverket / FAR practice; ÅRL doesn't specify
+ * a precision but whole numbers are universal in K2 ÅR for mindre företag).
+ */
+
+export interface EmployeePeriodInput {
+ employment_start: string
+ employment_end: string | null
+ /** 0 < degree <= 100. 100 = full-time. */
+ employment_degree: number
+}
+
+/** Inclusive day count between two ISO dates (UTC). 2025-01-01..2025-12-31 = 365. */
+function inclusiveDays(startIso: string, endIso: string): number {
+ const start = new Date(`${startIso}T00:00:00Z`)
+ const end = new Date(`${endIso}T00:00:00Z`)
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return 0
+ if (end < start) return 0
+ return Math.floor((end.getTime() - start.getTime()) / 86400000) + 1
+}
+
+/**
+ * Compute the FTE-weighted medelantal anställda for a fiscal period.
+ *
+ * @param employees rows with employment_start / employment_end / employment_degree
+ * @param periodStartIso fiscal period start (inclusive), ISO YYYY-MM-DD
+ * @param periodEndIso fiscal period end (inclusive), ISO YYYY-MM-DD
+ * @returns rounded whole-number medelantal, or 0 if no qualifying overlap
+ */
+export function computeMedelantalAnstallda(
+ employees: EmployeePeriodInput[],
+ periodStartIso: string,
+ periodEndIso: string,
+): number {
+ const periodDays = inclusiveDays(periodStartIso, periodEndIso)
+ if (periodDays === 0) return 0
+
+ let totalFteDays = 0
+ for (const e of employees) {
+ const overlapStart =
+ e.employment_start > periodStartIso ? e.employment_start : periodStartIso
+ const employmentEnd = e.employment_end ?? periodEndIso
+ const overlapEnd =
+ employmentEnd < periodEndIso ? employmentEnd : periodEndIso
+ if (overlapStart > overlapEnd) continue
+ const overlapDays = inclusiveDays(overlapStart, overlapEnd)
+ const degreeFactor = Math.min(100, Math.max(0, e.employment_degree)) / 100
+ totalFteDays += overlapDays * degreeFactor
+ }
+
+ return Math.round(totalFteDays / periodDays)
+}
diff --git a/messages/en.json b/messages/en.json
index e1d5ffa7..716d992a 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -166,7 +166,6 @@
"skatteverket": "Skatteverket",
"salary": "Payroll",
"templates": "Templates",
- "approval_rules": "Approval flows",
"account": "Account",
"api": "API"
},
diff --git a/messages/sv.json b/messages/sv.json
index 60794203..9e4b42f2 100644
--- a/messages/sv.json
+++ b/messages/sv.json
@@ -166,7 +166,6 @@
"skatteverket": "Skatteverket",
"salary": "Löner",
"templates": "Mallar",
- "approval_rules": "Godkännandeflöden",
"account": "Konto",
"api": "API"
},
diff --git a/supabase/migrations/20260527120000_arsredovisning_disclosure_fields.sql b/supabase/migrations/20260527120000_arsredovisning_disclosure_fields.sql
new file mode 100644
index 00000000..3cabac70
--- /dev/null
+++ b/supabase/migrations/20260527120000_arsredovisning_disclosure_fields.sql
@@ -0,0 +1,31 @@
+-- arsredovisning_narratives: add six disclosure fields so the K2/K3 note
+-- builder can emit statutorily-required notes that aren't derivable from
+-- journal data alone.
+--
+-- ÅRL 5:13 § -- långfristiga skulder förfallande efter mer än fem år.
+-- ÅRL 5:14 § -- ställda säkerheter.
+-- ÅRL 5:15 § -- eventualförpliktelser.
+-- BFNAR 2016:10 kap. 19 / BFNAR 2012:1 kap. 8 -- koncernförhållanden
+-- (moderföretagets namn, organisationsnummer, säte).
+--
+-- All six are per-fiscal-period (one row per period via the existing
+-- composite UNIQUE on (company_id, fiscal_period_id)). The columns are
+-- nullable so an unfilled disclosure falls back to the boilerplate
+-- ("Inga skulder förfaller efter mer än fem år.", "Inga." for säkerheter
+-- and eventualförpliktelser, omitted koncernnot when name is null).
+
+ALTER TABLE public.arsredovisning_narratives
+ ADD COLUMN long_term_debt_over_five_years NUMERIC(15, 2)
+ CHECK (long_term_debt_over_five_years IS NULL OR long_term_debt_over_five_years >= 0),
+ ADD COLUMN securities_pledged TEXT
+ CHECK (securities_pledged IS NULL OR length(securities_pledged) <= 4000),
+ ADD COLUMN contingent_liabilities TEXT
+ CHECK (contingent_liabilities IS NULL OR length(contingent_liabilities) <= 4000),
+ ADD COLUMN parent_company_name TEXT
+ CHECK (parent_company_name IS NULL OR length(parent_company_name) <= 200),
+ ADD COLUMN parent_company_org_number TEXT
+ CHECK (parent_company_org_number IS NULL OR length(parent_company_org_number) <= 20),
+ ADD COLUMN parent_company_city TEXT
+ CHECK (parent_company_city IS NULL OR length(parent_company_city) <= 100);
+
+NOTIFY pgrst, 'reload schema';