* 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
353 lines
12 KiB
TypeScript
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,
|
|
}
|
|
}
|