Files
accounted/lib/bokslut/arsredovisning/version-service.ts
T
Mattsson 466e55a015 Fix/invoice delivery and payment accounts (#1116)
* 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
2026-07-23 09:54:02 +02:00

191 lines
6.6 KiB
TypeScript

import { createHash } from 'node:crypto'
import type { SupabaseClient } from '@supabase/supabase-js'
import type {
AnnualReportVersionValidationSnapshot,
AnnualReportVersionSummary,
CanonicalAnnualReport,
} from './compliance-types'
import { getEntryPoint } from '@/lib/bokslut/ixbrl/taxonomy/entry-points'
import { validateStatementIntegrity } from './completeness'
export function hasStatementIntegrityErrors(model: CanonicalAnnualReport): boolean {
return validateStatementIntegrity(model.report, model.ixbrl).length > 0
}
function stableValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(stableValue)
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([, child]) => child !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, child]) => [key, stableValue(child)]),
)
}
return value
}
export function annualReportContentHash(model: CanonicalAnnualReport): string {
const reportForSigning = {
...model.report,
signatures: model.report.signatures.map((signature) => ({
role: signature.role,
name: signature.name,
signed_at: null,
})),
}
const ixbrlForSigning = model.ixbrl
? {
...model.ixbrl,
underskrifter: {
...model.ixbrl.underskrifter,
dateringsdatum: null,
signers: model.ixbrl.underskrifter.signers.map((signer) => ({
...signer,
signedDate: null,
})),
},
faststallelseintyg: {
...model.ixbrl.faststallelseintyg,
genereratDatum: null,
},
}
: null
const content = {
schema_version: model.schema_version,
company_id: model.company_id,
fiscal_period_id: model.fiscal_period_id,
entity_type: model.entity_type,
report: reportForSigning,
profile: model.profile,
disclosures: model.disclosures,
ixbrl: ixbrlForSigning,
}
return createHash('sha256').update(JSON.stringify(stableValue(content))).digest('hex')
}
export async function listAnnualReportVersions(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
): Promise<AnnualReportVersionSummary[]> {
const { data, error } = await supabase
.from('annual_report_versions')
.select(
'id, version_number, status, framework, content_hash, taxonomy_version, entry_point, finalized_at, created_at, ixbrl_data, validation_summary',
)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.order('version_number', { ascending: false })
if (error) throw new Error(`Failed to list annual report versions: ${error.message}`)
return (data ?? []).map((row) => {
const version = row as AnnualReportVersionSummary & {
ixbrl_data?: CanonicalAnnualReport['ixbrl']
validation_summary?: AnnualReportVersionValidationSnapshot
}
const signer = version.ixbrl_data?.faststallelseintyg
return {
id: version.id,
version_number: version.version_number,
status: version.status,
framework: version.framework,
content_hash: version.content_hash,
taxonomy_version: version.taxonomy_version,
entry_point: version.entry_point,
finalized_at: version.finalized_at,
created_at: version.created_at,
digital_filing_eligible:
version.validation_summary?.digital_filing_eligible === true,
certificate_signer: signer
? {
first_name: signer.signerFirstName,
last_name: signer.signerLastName,
role: signer.signerRole,
}
: null,
}
})
}
export async function getAnnualReportVersion(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
versionId: string,
): Promise<{
summary: AnnualReportVersionSummary
report_data: CanonicalAnnualReport['report']
ixbrl_data: CanonicalAnnualReport['ixbrl']
validation_summary: AnnualReportVersionValidationSnapshot
} | null> {
const { data, error } = await supabase
.from('annual_report_versions')
.select(
'id, version_number, status, framework, content_hash, taxonomy_version, entry_point, finalized_at, created_at, report_data, ixbrl_data, validation_summary',
)
.eq('id', versionId)
.eq('company_id', companyId)
.eq('fiscal_period_id', fiscalPeriodId)
.maybeSingle()
if (error) throw new Error(`Failed to load annual report version: ${error.message}`)
if (!data) return null
const row = data as AnnualReportVersionSummary & {
report_data: CanonicalAnnualReport['report']
ixbrl_data: CanonicalAnnualReport['ixbrl']
validation_summary: AnnualReportVersionValidationSnapshot
}
return {
summary: row,
report_data: row.report_data,
ixbrl_data: row.ixbrl_data,
validation_summary: row.validation_summary,
}
}
export async function createAnnualReportVersion(
supabase: SupabaseClient,
userId: string,
model: CanonicalAnnualReport,
finalize: boolean,
): Promise<AnnualReportVersionSummary> {
if (hasStatementIntegrityErrors(model)) {
throw new Error('Annual report has inconsistent financial statements and cannot be versioned')
}
if (finalize && !model.validation.ok) {
throw new Error('Annual report has blocking validation errors and cannot be finalized')
}
const hash = annualReportContentHash(model)
const entryPoint = model.ixbrl?.entryPointId ?? null
const taxonomyVersion = entryPoint ? getEntryPoint(entryPoint).taxonomyVersion : null
const rpcName = finalize
? 'create_annual_report_version_with_signatures'
: 'create_annual_report_version'
const { data, error } = await supabase.rpc(rpcName, {
p_company_id: model.company_id,
p_fiscal_period_id: model.fiscal_period_id,
p_schema_version: model.schema_version,
p_framework: model.report.accounting_framework,
p_status: finalize ? 'ready_for_signature' : 'draft',
p_report_data: model.report,
p_ixbrl_data: model.ixbrl,
p_content_hash: hash,
p_taxonomy_version: taxonomyVersion,
p_entry_point: entryPoint,
p_validation_summary: {
...model.validation,
digital_filing_eligible: model.eligibility.digital_filing_eligible,
digital_issues: model.eligibility.digital_issues,
profile: model.profile,
disclosures: model.disclosures,
eligibility: model.eligibility,
} satisfies AnnualReportVersionValidationSnapshot,
p_user_id: userId,
})
if (error || !data) {
throw new Error(`Failed to create annual report version: ${error?.message ?? 'unknown'}`)
}
const row = Array.isArray(data) ? data[0] : data
const version = row as AnnualReportVersionSummary
return version
}