Bug/UI wrong display (#573)

* 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) <noreply@anthropic.com>

* fix(documents): harden PDF preview and upload validation

- JournalEntryAttachments: switch inline PDF preview from <iframe> to
  <object type="application/pdf">. Mirrors the AttachmentPreviewSheet
  fix from #572 — Chrome's frame pipeline intermittently surfaced
  "Det här innehållet har blockerats" on iframes even with permissive
  CSP. <object> invokes the PDF plugin directly. crbug.com/271452.

- /api/documents/:id/inline: resolve Content-Type via file extension
  when mime_type is null or application/octet-stream. Legacy uploads
  landed with empty File.type from some drag sources; combined with
  the new X-Content-Type-Options: nosniff header on this route,
  Chrome refused to render valid PDFs. Extension fallback covers
  every legacy row without a DB backfill.

- /api/documents POST: surface DB-trigger period-lock errors as a
  400 DOC_UPLOAD_PERIOD_LOCKED with a Swedish reason. Previously
  every catch was bucketed into DOC_UPLOAD_STORAGE_FAILED (500 /
  "Filen kunde inte sparas") which hid the real cause from users
  attaching to verifikationer in closed/locked fiscal periods.

- document-service: add validateDocumentMagicBytes() that inspects
  the first bytes for valid PDF/PNG/JPEG/WebP headers (PDF tolerates
  a leading UTF-8 BOM). Wired into uploadDocument() and
  createNewVersion() so every upload path is protected — UI, MCP,
  and future email/webhook ingestion. Defends against agents that
  send a base64-encoded text placeholder instead of real binary
  bytes via the gnubok_upload_document MCP tool, which produced
  tiny (15-561 byte) "PDFs" that failed to render in Chrome and
  in external viewers.

Tests use a minimal valid PDF buffer (%PDF-1.4 … %%EOF).

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

* feat(arsredovisning): emit ÅRL-required notes and FTE-weighted medelantal

Five compliance gaps fixed in the K2 and K3 noter builders:

- Anläggningstillgångar roll-forward per ÅRL 5:8 § — per-category IB
  anskaffningsvärde, tillkommande, avgående, UB and accumulated
  avskrivningar movement (was only emitting avskrivningstider).
- Långfristiga skulder förfallande efter mer än fem år per ÅRL 5:13 §.
- Ställda säkerheter and Eventualförpliktelser as separate notes per
  ÅRL 5:14-15 § (K2 previously combined them).
- Koncernförhållanden per BFNAR 2016:10 kap. 19 / BFNAR 2012:1 kap. 8.

Replaces medelantal anställda — the old query filtered employees by an
is_active column that doesn't exist, so the note never emitted. Now
uses an FTE-weighted day-based average per ÅRL 5:20 §.

Six disclosure fields persist on arsredovisning_narratives as per-period
overrides; the UI extends the existing förvaltningsberättelse editor
with a "Lagstadgade upplysningar" subsection sharing the same Spara
button — no new pages, no settings changes.

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

* fix(invoices): respect vat_registered=false and hide personnummer for B2C

- PDF address block no longer prints org_number for individual customers
  (GDPR data minimization; ML 17 kap 24§ requires name + address only).
- Wire company_settings.vat_registered through the rule helpers, invoice
  creation API, preview-pdf API, and the new-invoice form so a non-VAT-
  registered seller cannot charge VAT (ML 1 kap. 1§). The PDF suppresses
  the empty "Moms 0%" row and shows a dedicated "Företaget är inte
  momsregistrerat" notice instead of the ML 3 kap. exempt notice.
- Engine unchanged: 'exempt' treatment already routes to 3004/3100 and
  skips VAT lines.

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

* fix(settings): remove approval rules from sidebar and routes

* fix(invoices): ensure vat_registered defaults to true for invoice previews and API

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-05-27 11:01:53 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 32d9978f1b
commit a2a556d837
29 changed files with 1475 additions and 73 deletions
@@ -39,6 +39,20 @@ export default function ArsredovisningPage() {
const [savedResultatdisposition, setSavedResultatdisposition] = useState('')
const [agmDate, setAgmDate] = useState('')
const [savedAgmDate, setSavedAgmDate] = useState('')
// Disclosure fields per ÅRL 5:13-15 § + BFNAR koncernförhållanden.
// Persisted via the same POST endpoint as the förvaltningsberättelse text.
const [longTermDebt, setLongTermDebt] = useState('')
const [savedLongTermDebt, setSavedLongTermDebt] = useState('')
const [securitiesPledged, setSecuritiesPledged] = useState('')
const [savedSecuritiesPledged, setSavedSecuritiesPledged] = useState('')
const [contingentLiabilities, setContingentLiabilities] = useState('')
const [savedContingentLiabilities, setSavedContingentLiabilities] = useState('')
const [parentName, setParentName] = useState('')
const [savedParentName, setSavedParentName] = useState('')
const [parentOrgNr, setParentOrgNr] = useState('')
const [savedParentOrgNr, setSavedParentOrgNr] = useState('')
const [parentCity, setParentCity] = useState('')
const [savedParentCity, setSavedParentCity] = useState('')
const [savingNarrative, setSavingNarrative] = useState(false)
const [savedAt, setSavedAt] = useState<number | null>(null)
@@ -75,6 +89,20 @@ export default function ArsredovisningPage() {
setSavedImportantEvents(d.forvaltningsberattelse.important_events)
setSavedResultatdisposition(d.forvaltningsberattelse.resultatdisposition)
setSavedAgmDate(d.forvaltningsberattelse.agm_date ?? '')
const ltd = d.disclosures.long_term_debt_over_five_years
const ltdStr = ltd != null ? String(ltd) : ''
setLongTermDebt(ltdStr)
setSavedLongTermDebt(ltdStr)
setSecuritiesPledged(d.disclosures.securities_pledged ?? '')
setSavedSecuritiesPledged(d.disclosures.securities_pledged ?? '')
setContingentLiabilities(d.disclosures.contingent_liabilities ?? '')
setSavedContingentLiabilities(d.disclosures.contingent_liabilities ?? '')
setParentName(d.disclosures.parent_company_name ?? '')
setSavedParentName(d.disclosures.parent_company_name ?? '')
setParentOrgNr(d.disclosures.parent_company_org_number ?? '')
setSavedParentOrgNr(d.disclosures.parent_company_org_number ?? '')
setParentCity(d.disclosures.parent_company_city ?? '')
setSavedParentCity(d.disclosures.parent_company_city ?? '')
setSignatures((sigBody.data ?? []) as SignatureRequest[])
})
.catch(() => {
@@ -92,10 +120,33 @@ export default function ArsredovisningPage() {
description !== savedDescription ||
importantEvents !== savedImportantEvents ||
resultatdisposition !== savedResultatdisposition ||
agmDate !== savedAgmDate
agmDate !== savedAgmDate ||
longTermDebt !== savedLongTermDebt ||
securitiesPledged !== savedSecuritiesPledged ||
contingentLiabilities !== savedContingentLiabilities ||
parentName !== savedParentName ||
parentOrgNr !== savedParentOrgNr ||
parentCity !== savedParentCity
const handleSaveNarrative = useCallback(async () => {
if (!periodId) return
// Parse the long-term debt input; empty string and zero both clear the
// override (the note then falls back to the "Inga." default). Reject
// non-numeric input with a toast so the API doesn't return a 400.
let longTermDebtParsed: number | null = null
if (longTermDebt.trim()) {
const parsed = Number(longTermDebt.replace(',', '.'))
if (!Number.isFinite(parsed) || parsed < 0) {
toast({
title: 'Ogiltigt belopp',
description:
'Långfristiga skulder förfallande efter mer än fem år måste vara ett positivt tal (eller lämnas tomt).',
variant: 'destructive',
})
return
}
longTermDebtParsed = parsed
}
setSavingNarrative(true)
try {
const res = await fetch(
@@ -108,6 +159,12 @@ export default function ArsredovisningPage() {
important_events: importantEvents,
resultatdisposition,
agm_date: agmDate || null,
long_term_debt_over_five_years: longTermDebtParsed,
securities_pledged: securitiesPledged.trim() || null,
contingent_liabilities: contingentLiabilities.trim() || null,
parent_company_name: parentName.trim() || null,
parent_company_org_number: parentOrgNr.trim() || null,
parent_company_city: parentCity.trim() || null,
}),
},
)
@@ -124,6 +181,12 @@ export default function ArsredovisningPage() {
setSavedImportantEvents(importantEvents)
setSavedResultatdisposition(resultatdisposition)
setSavedAgmDate(agmDate)
setSavedLongTermDebt(longTermDebt)
setSavedSecuritiesPledged(securitiesPledged)
setSavedContingentLiabilities(contingentLiabilities)
setSavedParentName(parentName)
setSavedParentOrgNr(parentOrgNr)
setSavedParentCity(parentCity)
setSavedAt(Date.now())
} catch (err) {
toast({
@@ -134,7 +197,20 @@ export default function ArsredovisningPage() {
} finally {
setSavingNarrative(false)
}
}, [periodId, description, importantEvents, resultatdisposition, agmDate, toast])
}, [
periodId,
description,
importantEvents,
resultatdisposition,
agmDate,
longTermDebt,
securitiesPledged,
contingentLiabilities,
parentName,
parentOrgNr,
parentCity,
toast,
])
const handleMarkSigned = useCallback(
async (signatureId: string) => {
@@ -352,6 +428,93 @@ export default function ArsredovisningPage() {
fastställelseintyget i PDF:en (krävs för inlämning till Bolagsverket).
</p>
</div>
<div className="pt-4 border-t border-border space-y-4">
<div>
<h3 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
Lagstadgade upplysningar
</h3>
<p className="text-xs text-muted-foreground mt-1">
Noter som krävs enligt ÅRL men inte kan härledas automatiskt. Tomma
fält visas som &quot;Inga.&quot; i PDF:en.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="ar-ltd">
Långfristiga skulder förfallande efter mer än fem år (kr)
</Label>
<Input
id="ar-ltd"
type="text"
inputMode="decimal"
value={longTermDebt}
onChange={(e) => setLongTermDebt(e.target.value)}
placeholder="0"
className="max-w-[220px] tabular-nums"
/>
<p className="text-xs text-muted-foreground">
ÅRL 5:13 §. Lämna tomt om inga skulder förfaller senare än fem år.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="ar-securities">Ställda säkerheter</Label>
<Textarea
id="ar-securities"
value={securitiesPledged}
onChange={(e) => setSecuritiesPledged(e.target.value)}
rows={2}
placeholder="t.ex. Företagsinteckning 500 000 kr som säkerhet för bankkredit."
/>
<p className="text-xs text-muted-foreground">ÅRL 5:14 §.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="ar-contingent">Eventualförpliktelser</Label>
<Textarea
id="ar-contingent"
value={contingentLiabilities}
onChange={(e) => setContingentLiabilities(e.target.value)}
rows={2}
placeholder="t.ex. Borgensåtagande för dotterbolags krediter 200 000 kr."
/>
<p className="text-xs text-muted-foreground">ÅRL 5:15 §.</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="ar-parent-name">
Moderföretag — namn (om koncerntillhörighet)
</Label>
<Input
id="ar-parent-name"
value={parentName}
onChange={(e) => setParentName(e.target.value)}
placeholder="t.ex. AB Koncernholding"
/>
<p className="text-xs text-muted-foreground">
BFNAR 2016:10 kap. 19 / BFNAR 2012:1 kap. 8. Lämna tomt om bolaget
inte ingår i en koncern — noten utelämnas då.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="ar-parent-orgnr">Moderföretagets org.nr</Label>
<Input
id="ar-parent-orgnr"
value={parentOrgNr}
onChange={(e) => setParentOrgNr(e.target.value)}
placeholder="556677-8899"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="ar-parent-city">Moderföretagets säte</Label>
<Input
id="ar-parent-city"
value={parentCity}
onChange={(e) => setParentCity(e.target.value)}
placeholder="Stockholm"
/>
</div>
</div>
</div>
<div className="flex items-center justify-between pt-2">
<div className="text-xs text-muted-foreground">
{hasUnsavedNarrative ? (
+12 -6
View File
@@ -105,6 +105,7 @@ export default function NewInvoicePage() {
const [showBankSetup, setShowBankSetup] = useState(false)
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
const [oreRounding, setOreRounding] = useState<boolean>(true)
const [vatRegistered, setVatRegistered] = useState<boolean>(true)
const [numberPreview, setNumberPreview] = useState<string | null>(null)
const [logoUrl, setLogoUrl] = useState<string | null>(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
+1 -1
View File
@@ -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 }),
-1
View File
@@ -14,7 +14,6 @@ const TAB_TO_ROUTE: Record<string, string> = {
team: '/settings/team',
banking: '/settings/banking',
templates: '/settings/templates',
'approval-rules': '/settings/approval-rules',
account: '/settings/account',
api: '/settings/api',
}
@@ -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(
+30 -1
View File
@@ -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<string, string> = {
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 <object type="application/pdf"> 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',
},
})
}
+10 -1
View File
@@ -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 },
})
}
},
+7
View File
@@ -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 })
+9 -2
View File
@@ -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',
+16 -2
View File
@@ -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)
@@ -327,11 +327,25 @@ export default function JournalEntryAttachments({
{expandedDoc === doc.id && doc.download_url && isPdfType(doc.mime_type) && (
<div className="px-2 py-2">
<iframe
src={`/api/documents/${doc.id}/inline`}
title={doc.file_name}
{/* <object> + type="application/pdf" invokes Chrome's PDF
plugin directly. <iframe> intermittently surfaced
"Det här innehållet har blockerats" in Chrome even
with a permissive CSP. See crbug.com/271452. */}
<object
data={`/api/documents/${doc.id}/inline`}
type="application/pdf"
aria-label={doc.file_name}
className="w-full h-[60vh] rounded-lg border"
/>
>
<a
href={doc.download_url}
target="_blank"
rel="noopener noreferrer"
className="block px-4 py-2 text-sm text-muted-foreground underline"
>
{t('download')}
</a>
</object>
</div>
)}
</div>
-1
View File
@@ -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)
@@ -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)
})
})
@@ -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,
},
}
}
@@ -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<string, string> = {
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<string, CategoryRollforward>()
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 }
+189 -24
View File
@@ -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<string, Set<number>>()
@@ -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',
@@ -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)
+11
View File
@@ -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
}
}
/**
@@ -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',
})
+60
View File
@@ -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<DocumentAttachment> {
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<DocumentAttachment> {
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)
+5
View File
@@ -1227,6 +1227,11 @@ const DOCUMENT: Record<string, StructuredErrorEntry> = {
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.',
+42
View File
@@ -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')
})
})
// ============================================================
+50 -14
View File
@@ -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' && (
<Text>{customer.country}</Text>
)}
{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 && (
<Text style={{ marginTop: 6 }}>{L.orgNo} {customer.org_number}</Text>
)}
{customer.vat_number && <Text>{L.vat} {customer.vat_number}</Text>}
{/* 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 && (
<Text>{L.vat} {customer.vat_number}</Text>
)}
</View>
</View>
</View>
@@ -854,10 +868,17 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
))
) : (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.vatRow(invoice.vat_rate ?? (vatByRate.size === 1 ? (vatByRate.keys().next().value ?? 0) : 0))}</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency, lang)}</Text>
</View>
// 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) && (
<View style={styles.totalRow}>
<Text style={styles.totalLabel}>{L.vatRow(invoice.vat_rate ?? (vatByRate.size === 1 ? (vatByRate.keys().next().value ?? 0) : 0))}</Text>
<Text style={styles.totalValue}>{formatCurrency(invoice.vat_amount, invoice.currency, lang)}</Text>
</View>
)
)}
{(() => {
const rounding = getDisplayTotal(invoice, company)
@@ -1041,16 +1062,31 @@ export function InvoicePDF({ invoice, customer, items, company, originalInvoiceN
</View>
)}
{/* 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 ? (
<View style={styles.reverseChargeBox}>
<Text style={styles.reverseChargeText}>{invoice.reverse_charge_text}</Text>
</View>
)}
{invoice.vat_treatment === 'exempt' && !invoice.reverse_charge_text && (
<View style={styles.reverseChargeBox}>
<Text style={styles.reverseChargeText}>{L.exemptNotice}</Text>
<Text style={styles.reverseChargeText}>{L.notVatRegisteredNotice}</Text>
</View>
) : (
<>
{invoice.reverse_charge_text && (
<View style={styles.reverseChargeBox}>
<Text style={styles.reverseChargeText}>{invoice.reverse_charge_text}</Text>
</View>
)}
{invoice.vat_treatment === 'exempt' && !invoice.reverse_charge_text && (
<View style={styles.reverseChargeBox}>
<Text style={styles.reverseChargeText}>{L.exemptNotice}</Text>
</View>
)}
</>
)}
{/* Notes */}
+27 -3
View File
@@ -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':
+154
View File
@@ -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)
})
})
+68
View File
@@ -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)
}
-1
View File
@@ -166,7 +166,6 @@
"skatteverket": "Skatteverket",
"salary": "Payroll",
"templates": "Templates",
"approval_rules": "Approval flows",
"account": "Account",
"api": "API"
},
-1
View File
@@ -166,7 +166,6 @@
"skatteverket": "Skatteverket",
"salary": "Löner",
"templates": "Mallar",
"approval_rules": "Godkännandeflöden",
"account": "Konto",
"api": "API"
},
@@ -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';