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:
co-authored by
Claude Opus 4.7
parent
32d9978f1b
commit
a2a556d837
@@ -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 "Inga." 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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -166,7 +166,6 @@
|
||||
"skatteverket": "Skatteverket",
|
||||
"salary": "Payroll",
|
||||
"templates": "Templates",
|
||||
"approval_rules": "Approval flows",
|
||||
"account": "Account",
|
||||
"api": "API"
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user