* fix: reconcile annual reports with final closing entries * test: cover annual report depreciation and VAT balances * Merge remote-tracking branch 'origin/main' into fix/usr-fdbck-ch * fix: show exact invoice delivery details * fix: use currency account in invoice emails * fix: address invoice delivery review feedback * fix: harden invoice delivery and payment accounts * test: assert RLS-denied zero-row updates * fix: close remaining invoice compliance gaps * fix: harden invoice archive authorization * fix: close invoice delivery review findings * fix: verify delivery finalization results * fix: cap combined invoice email recipients * fix: close final invoice compliance findings * fix: prevent stale payment account saves * test: prove invoice delivery isolation * fix: close invoice privacy review findings * test: normalize delivery retention dates
456 lines
15 KiB
TypeScript
456 lines
15 KiB
TypeScript
import type { ArsredovisningData } from './types'
|
|
import type { IxbrlArsredovisningInput } from '@/lib/bokslut/ixbrl/types'
|
|
import { buildBrRows, buildRrRows } from './statement-rows'
|
|
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 })
|
|
}
|
|
|
|
function statementRowsEqual(
|
|
left: ArsredovisningData['resultatrakning'],
|
|
right: ArsredovisningData['resultatrakning'],
|
|
): boolean {
|
|
return left.length === right.length && left.every((row, index) => {
|
|
const other = right[index]
|
|
return (
|
|
row.label === other.label &&
|
|
row.semantic_key === other.semantic_key &&
|
|
row.current === other.current &&
|
|
row.previous === other.previous &&
|
|
Boolean(row.is_total) === Boolean(other.is_total) &&
|
|
Boolean(row.is_heading) === Boolean(other.is_heading) &&
|
|
(row.indent ?? 0) === (other.indent ?? 0)
|
|
)
|
|
})
|
|
}
|
|
|
|
export function validateStatementIntegrity(
|
|
report: ArsredovisningData,
|
|
ixbrl: IxbrlArsredovisningInput | null = null,
|
|
): AnnualReportComplianceIssue[] {
|
|
const issues: AnnualReportComplianceIssue[] = []
|
|
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.',
|
|
)
|
|
}
|
|
|
|
const incomeStatementResult = (
|
|
report.resultatrakning.find(
|
|
(row) => row.semantic_key === 'income_statement_result' && row.current !== null,
|
|
)
|
|
?? report.resultatrakning.find(
|
|
(row) => row.label === 'Årets resultat' && row.current !== null,
|
|
)
|
|
)?.current ?? undefined
|
|
const visibleBalanceSheetResult = (
|
|
report.balansrakning.equity_liabilities.find(
|
|
(row) => row.semantic_key === 'balance_sheet_current_year_result' && row.current !== null,
|
|
)
|
|
?? report.balansrakning.equity_liabilities.find(
|
|
(row) => row.label === 'Årets resultat' && row.current !== null,
|
|
)
|
|
)?.current ?? undefined
|
|
const dispositionResult =
|
|
report.forvaltningsberattelse.resultatdisposition_amounts.current_year_result
|
|
if (incomeStatementResult === undefined || visibleBalanceSheetResult === undefined) {
|
|
push(
|
|
issues,
|
|
'AR-RESULT-MISSING',
|
|
'error',
|
|
'statements',
|
|
'Resultat- eller balansräkningen saknar raden Årets resultat.',
|
|
)
|
|
} else if (
|
|
Math.round(incomeStatementResult * 100) !== Math.round(visibleBalanceSheetResult * 100) ||
|
|
Math.round(incomeStatementResult * 100) !== Math.round(dispositionResult * 100)
|
|
) {
|
|
push(
|
|
issues,
|
|
'AR-RESULT-MISMATCH',
|
|
'error',
|
|
'statements',
|
|
'Årets resultat i resultaträkningen stämmer inte med årets resultat i balansräkningen.',
|
|
'Kontrollera att årsredovisningen innehåller bokslutsdispositioner och skatt före resultatstängningen.',
|
|
)
|
|
}
|
|
|
|
if (
|
|
report.resultatrakning.length === 0 ||
|
|
report.balansrakning.assets.length === 0 ||
|
|
report.balansrakning.equity_liabilities.length === 0
|
|
) {
|
|
push(
|
|
issues,
|
|
'AR-STATEMENTS-EMPTY',
|
|
'error',
|
|
'statements',
|
|
'Resultat- eller balansräkningen saknar rader.',
|
|
)
|
|
}
|
|
|
|
if (ixbrl) {
|
|
const ixbrlMapping = { rr: ixbrl.rr, br: ixbrl.br, totals: ixbrl.totals }
|
|
const ixbrlIncomeRows = buildRrRows(ixbrlMapping)
|
|
const ixbrlBalanceRows = buildBrRows(ixbrlMapping)
|
|
if (
|
|
!statementRowsEqual(report.resultatrakning, ixbrlIncomeRows) ||
|
|
!statementRowsEqual(report.balansrakning.assets, ixbrlBalanceRows.assets) ||
|
|
!statementRowsEqual(
|
|
report.balansrakning.equity_liabilities,
|
|
ixbrlBalanceRows.equityLiabilities,
|
|
)
|
|
) {
|
|
push(
|
|
issues,
|
|
'AR-IXBRL-STATEMENT-MISMATCH',
|
|
'error',
|
|
'statements',
|
|
'Beloppen i PDF-underlaget och iXBRL-underlaget stämmer inte överens.',
|
|
'Skapa om årsredovisningen från ett oförändrat bokslut.',
|
|
)
|
|
}
|
|
}
|
|
return issues
|
|
}
|
|
|
|
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.',
|
|
)
|
|
}
|
|
|
|
issues.push(...validateStatementIntegrity(report))
|
|
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.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,
|
|
}
|
|
}
|