Files
accounted/lib/bokslut/arsredovisning/completeness.ts
T
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00

353 lines
12 KiB
TypeScript

import type { ArsredovisningData } from './types'
import type {
AnnualReportComplianceIssue,
AnnualReportDisclosureState,
AnnualReportEligibilityResult,
AnnualReportProfile,
AnnualReportValidationResult,
AnnualReportValidationStage,
} from './compliance-types'
import { normalizeOrgNumber } from '@/lib/company-lookup/normalize-org-number'
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
function parseIsoDate(value: string | null | undefined): Date | null {
if (!value) return null
const datePart = value.slice(0, 10)
if (!ISO_DATE.test(datePart)) return null
const parsed = new Date(`${datePart}T00:00:00.000Z`)
return Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== datePart
? null
: parsed
}
function addCalendarMonths(date: Date, months: number): Date {
const year = date.getUTCFullYear()
const month = date.getUTCMonth() + months
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
return new Date(Date.UTC(year, month, Math.min(date.getUTCDate(), lastDay)))
}
function push(
issues: AnnualReportComplianceIssue[],
code: string,
severity: AnnualReportComplianceIssue['severity'],
section: AnnualReportComplianceIssue['section'],
message: string,
remediation?: string,
): void {
issues.push({ code, severity, section, message, remediation })
}
export interface ValidateAnnualReportInput {
report: ArsredovisningData
profile: AnnualReportProfile
disclosures: AnnualReportDisclosureState
eligibility: AnnualReportEligibilityResult
stage: AnnualReportValidationStage
todayIso?: string
}
export function validateAnnualReportCompleteness(
input: ValidateAnnualReportInput,
): AnnualReportValidationResult {
const { report, profile, disclosures, eligibility, stage } = input
const issues: AnnualReportComplianceIssue[] = [...eligibility.issues]
const today = parseIsoDate(input.todayIso ?? new Date().toISOString())
const periodStart = parseIsoDate(report.fiscal_period.period_start)
const periodEnd = parseIsoDate(report.fiscal_period.period_end)
if (!periodStart || !periodEnd || periodStart > periodEnd) {
push(
issues,
'AR-FISCAL-PERIOD-INVALID',
'error',
'company',
'Räkenskapsårets start- och slutdatum är ogiltiga.',
'Korrigera räkenskapsåret i företagsinställningarna.',
)
} else if (periodEnd >= addCalendarMonths(periodStart, 18)) {
push(
issues,
'AR-FISCAL-PERIOD-TOO-LONG',
'error',
'company',
'Räkenskapsåret är längre än 18 månader.',
'Korrigera räkenskapsåret innan årsredovisningen upprättas.',
)
}
if (report.accounting_framework === 'k2' && !profile.k2_assessment_confirmed_at) {
push(
issues,
'AR-K2-ASSESSMENT-UNCONFIRMED',
'error',
'scope',
'Bedömningen att bolaget får använda K2 är inte uttryckligen bekräftad.',
'Besvara frågorna om omfattning och regelverk och spara bedömningen.',
)
}
if (!report.company.name.trim()) {
push(issues, 'AR-COMPANY-NAME', 'error', 'company', 'Företagsnamn saknas.')
}
if (!normalizeOrgNumber(report.company.org_number)) {
push(
issues,
'AR-COMPANY-ORGNR',
'error',
'company',
'Ett giltigt organisationsnummer saknas.',
'Komplettera företagsinställningarna.',
)
}
if (!report.company.city?.trim()) {
push(
issues,
'AR-COMPANY-CITY',
'error',
'company',
'Bolagets registrerade säte saknas.',
'Komplettera företagsinställningarna eller hämta grunduppgifter från Bolagsverket.',
)
}
if (!report.forvaltningsberattelse.description.trim()) {
push(issues, 'AR-MANAGEMENT-DESCRIPTION', 'error', 'management_report', 'Verksamhetsbeskrivning saknas.')
}
if (!report.forvaltningsberattelse.important_events.trim()) {
push(issues, 'AR-MANAGEMENT-EVENTS', 'error', 'management_report', 'Uppgift om väsentliga händelser saknas.')
}
if (!report.forvaltningsberattelse.resultatdisposition.trim()) {
push(issues, 'AR-MANAGEMENT-DISPOSITION', 'error', 'management_report', 'Styrelsens förslag till resultatdisposition saknas.')
}
const disposition = report.forvaltningsberattelse.resultatdisposition_amounts
const proposedDividendCents = Math.round(disposition.proposed_dividend * 100)
const distributableEquityCents = Math.round(disposition.total * 100)
if (proposedDividendCents > 0 && proposedDividendCents > distributableEquityCents) {
push(
issues,
'AR-DIVIDEND-EXCEEDS-EQUITY',
'error',
'management_report',
'Föreslagen utdelning överstiger det fria egna kapitalet.',
'Sänk utdelningen och kontrollera resultatdispositionen.',
)
}
if (disposition.proposed_dividend > 0 && profile.dividend_prudence_confirmed !== true) {
push(
issues,
'AR-DIVIDEND-PRUDENCE-UNCONFIRMED',
'error',
'management_report',
'Försiktighetsregeln för föreslagen utdelning är inte bekräftad.',
'Bedöm bolagets kapitalbehov, likviditet, ställning och risker enligt ABL 17 kap. 3 §.',
)
}
if (!profile.narrative_confirmed_at) {
push(
issues,
'AR-MANAGEMENT-UNCONFIRMED',
'error',
'management_report',
'Förvaltningsberättelsens texter är inte uttryckligen granskade.',
'Granska texterna och markera dem som bekräftade.',
)
}
if (
Math.round(report.balansrakning.total_assets * 100) !==
Math.round(report.balansrakning.total_equity_liabilities * 100)
) {
push(
issues,
'AR-BALANCE-MISMATCH',
'error',
'statements',
'Balansräkningen balanserar inte i årsredovisningen.',
)
}
if (
report.previous_period &&
(report.balansrakning.total_assets_previous === null ||
report.balansrakning.total_equity_liabilities_previous === null)
) {
push(
issues,
'AR-COMPARATIVE-MISSING',
'error',
'statements',
'Jämförelsetal saknas trots att ett föregående räkenskapsår finns.',
)
}
if (report.resultatrakning.length === 0 || report.balansrakning.assets.length === 0) {
push(issues, 'AR-STATEMENTS-EMPTY', 'error', 'statements', 'Resultat- eller balansräkningen saknar rader.')
}
if (report.noter.length === 0) {
push(issues, 'AR-NOTES-EMPTY', 'error', 'notes', 'Årsredovisningen saknar noter.')
}
const disclosureChecks: Array<[boolean, string, string]> = [
[
disclosures.long_term_debt_over_five_years_confirmed,
'AR-NOTE-LONG-DEBT-UNCONFIRMED',
'Uppgiften om långfristiga skulder som förfaller efter mer än fem år är inte bekräftad.',
],
[
disclosures.securities_pledged_confirmed,
'AR-NOTE-SECURITIES-UNCONFIRMED',
'Uppgiften om ställda säkerheter är inte bekräftad.',
],
[
disclosures.contingent_liabilities_confirmed,
'AR-NOTE-CONTINGENT-UNCONFIRMED',
'Uppgiften om eventualförpliktelser är inte bekräftad.',
],
[
disclosures.parent_company_confirmed,
'AR-NOTE-PARENT-UNCONFIRMED',
'Uppgiften om koncern- och moderföretagsförhållanden är inte bekräftad.',
],
]
for (const [confirmed, code, message] of disclosureChecks) {
if (!confirmed) push(issues, code, 'error', 'notes', message)
}
if (report.accounting_framework === 'k3') {
push(
issues,
'AR-K3-DRAFT-ONLY',
'error',
'scope',
'Accounteds K3-dokument är ännu ett granskningsutkast och kan inte låsas som en komplett K3-årsredovisning.',
'Slutför K3:s upplysningsmatris och oberoende regelverksgranskning innan versionen låses.',
)
}
if (stage !== 'draft') {
if (report.signatures.length === 0) {
push(
issues,
'AR-SIGNERS-MISSING',
'error',
'signatures',
'Styrelsens och eventuell VD:s undertecknare är inte registrerade.',
)
}
if (!profile.signer_roster_confirmed_at) {
push(
issues,
'AR-SIGNER-ROSTER-UNCONFIRMED',
'error',
'signatures',
'Det är inte bekräftat att samtliga aktuella styrelseledamöter och eventuell VD finns med.',
'Kontrollera namn och roller mot Bolagsverket och bekräfta undertecknarlistan.',
)
}
}
if (stage === 'filing') {
if (report.signatures.some((signature) => !signature.signed_at)) {
push(
issues,
'AR-SIGNATURES-INCOMPLETE',
'error',
'signatures',
'Alla registrerade undertecknare har inte ett verifierat underskriftsdatum.',
)
}
if (!report.forvaltningsberattelse.agm_date) {
push(issues, 'AR-AGM-DATE', 'error', 'agm', 'Datum för årsstämman saknas.')
}
const signedDates = report.signatures
.map((signature) => parseIsoDate(signature.signed_at))
.filter((date): date is Date => date !== null)
if (report.signatures.some((signature) => signature.signed_at && !parseIsoDate(signature.signed_at))) {
push(issues, 'AR-SIGNATURE-DATE-INVALID', 'error', 'signatures', 'Ett underskriftsdatum är ogiltigt.')
}
if (periodEnd && signedDates.some((date) => date <= periodEnd)) {
push(
issues,
'AR-SIGNATURE-BEFORE-PERIOD-END',
'error',
'signatures',
'Årsredovisningen har undertecknats innan räkenskapsåret avslutades.',
)
}
if (today && signedDates.some((date) => date > today)) {
push(issues, 'AR-SIGNATURE-IN-FUTURE', 'error', 'signatures', 'Ett underskriftsdatum ligger i framtiden.')
}
const agmDate = parseIsoDate(report.forvaltningsberattelse.agm_date)
if (report.forvaltningsberattelse.agm_date && !agmDate) {
push(issues, 'AR-AGM-DATE-INVALID', 'error', 'agm', 'Datumet för årsstämman är ogiltigt.')
}
if (agmDate && periodEnd && agmDate <= periodEnd) {
push(issues, 'AR-AGM-BEFORE-PERIOD-END', 'error', 'agm', 'Årsstämman ligger före räkenskapsårets slut.')
}
if (agmDate && today && agmDate > today) {
push(issues, 'AR-AGM-IN-FUTURE', 'error', 'agm', 'Datumet för årsstämman ligger i framtiden.')
}
if (agmDate && signedDates.some((date) => date > agmDate)) {
push(
issues,
'AR-AGM-BEFORE-SIGNATURES',
'error',
'agm',
'Årsstämman har registrerats före den senaste underskriften av årsredovisningen.',
)
}
if (agmDate && periodEnd && agmDate > addCalendarMonths(periodEnd, 6)) {
push(
issues,
'AR-AGM-DEADLINE-PASSED',
'warning',
'agm',
'Årsstämman hölls senare än sex månader efter räkenskapsårets slut.',
'Dokumentera förseningen. En sen årsredovisning ska ändå lämnas in så snart som möjligt.',
)
}
if (!disclosures.agm_disposition_outcome) {
push(
issues,
'AR-AGM-DISPOSITION-OUTCOME',
'error',
'agm',
'Årsstämmans beslut om resultatdisposition är inte registrerat.',
)
}
if (
disclosures.agm_disposition_outcome === 'alternative_decision' &&
!disclosures.agm_disposition_decision?.trim()
) {
push(
issues,
'AR-AGM-ALTERNATIVE-MISSING',
'error',
'agm',
'Årsstämmans alternativa beslut om resultatdisposition saknar text.',
)
}
if (profile.auditor_report_required && !profile.auditor_report_included) {
push(
issues,
'AR-AUDITOR-REPORT-MISSING',
'error',
'filing',
'Revisionsberättelse krävs men är inte markerad som inkluderad i inlämningspaketet.',
)
}
}
for (const warning of report.warnings) {
push(issues, 'AR-SOURCE-WARNING', 'warning', 'statements', warning)
}
const errorCount = issues.filter((item) => item.severity === 'error').length
const warningCount = issues.filter((item) => item.severity === 'warning').length
return {
stage,
ok: errorCount === 0,
error_count: errorCount,
warning_count: warningCount,
issues,
}
}