Files
accounted/lib/salary/medelantal.ts
T
MattssonandClaude Opus 4.7 a2a556d837 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>
2026-05-27 11:01:53 +02:00

69 lines
2.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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)
}