Files
accounted/lib/bokslut/ixbrl/build-input.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

430 lines
18 KiB
TypeScript

/**
* Assemble IxbrlArsredovisningInput for a fiscal period.
*
* Reuses the same sources as the PDF builder (buildArsredovisningData) for
* narrative texts, noter and flerårsöversikt, and adds what iXBRL needs on
* top: trial balances for BOTH years mapped to risbs concepts
* (jämförelsesiffror: kontrollera 3006/3007), per-signer dates from the
* signature flow, and the fastställelseintyg undertecknare.
*/
import type { SupabaseClient } from '@supabase/supabase-js'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { buildArsredovisningData } from '@/lib/bokslut/arsredovisning/build-data'
import { listSignatureRequests } from '@/lib/bokslut/arsredovisning/signature-service'
import { computeMedelantalAnstallda } from '@/lib/salary/medelantal'
import { mapTrialBalancesToK2, type TrialBalancePair } from './k2-mapper'
import { resolveEntryPoint } from './taxonomy/entry-points'
import type {
EgetKapitalForandring,
FlerarsRow,
IxbrlArsredovisningInput,
IxbrlSigner,
Resultatdisposition,
} from './types'
import type { ArsredovisningData } from '@/lib/bokslut/arsredovisning/types'
/** TA §4.3.4-4.3.5: "<leverantör> - <produkt>", version "<huvud>.<revision>". */
export const PROGRAMVARA_NAMN = 'Accounted - Accounted'
export const PROGRAMVARA_VERSION = '2026.1'
export interface BuildIxbrlOptions {
/** Undertecknare of the fastställelseintyg (chosen in the wizard). When
* omitted, the first signed board member is used. */
undertecknare?: { firstName: string; lastName: string; role: string }
/** Proposed dividend in SEK (0 = balansera allt). */
proposedDividend?: number
/** Override "today" for deterministic tests (ISO date). */
todayIso?: string
/** Reuse a canonical report instance so PDF and iXBRL cannot diverge. */
reportData?: ArsredovisningData
/** Reuse the version-bound signer roster when a canonical model is built. */
signatureRequests?: Awaited<ReturnType<typeof listSignatureRequests>>
}
function splitName(fullName: string): { firstName: string; lastName: string } {
const parts = fullName.trim().split(/\s+/)
if (parts.length === 1) return { firstName: parts[0], lastName: parts[0] }
return { firstName: parts.slice(0, -1).join(' '), lastName: parts[parts.length - 1] }
}
export async function buildIxbrlInput(
supabase: SupabaseClient,
companyId: string,
fiscalPeriodId: string,
options: BuildIxbrlOptions = {},
): Promise<IxbrlArsredovisningInput> {
const warnings: string[] = []
// Two TB variants per year (see TrialBalancePair): the FULL trial balance
// (year-end closing included → 2099 booked, class 3-8 zeroed) drives the
// BR; the PRE-CLOSING trial balance (excludeFinalClosingEntry) drives the
// RR while retaining tax and appropriations. A single TB can
// never serve both: with bokslut booked every RR concept would map to 0,
// without it the BR would not tie.
const [pdfData, periodRow, currentTbFull, currentTbPreClosing, signatureRequests] =
await Promise.all([
options.reportData ?? buildArsredovisningData(supabase, companyId, fiscalPeriodId),
supabase
.from('fiscal_periods')
.select('id, period_start, period_end, previous_period_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
generateTrialBalance(supabase, companyId, fiscalPeriodId),
generateTrialBalance(supabase, companyId, fiscalPeriodId, { excludeFinalClosingEntry: true }),
options.signatureRequests ?? listSignatureRequests(supabase, companyId, fiscalPeriodId),
])
if (periodRow.error || !periodRow.data) throw new Error('Fiscal period not found')
const period = periodRow.data
if (pdfData.accounting_framework !== 'k2') {
throw new Error(
'Digital inlämning stöds ännu inte för K3: generera PDF eller vänta på K3-stödet.',
)
}
const entryPoint = resolveEntryPoint('k2')
// Previous period: trial balances for jämförelsesiffror (same full/
// pre-closing split as the current year).
let previousPeriod: { start: string; end: string } | null = null
let previousTb: TrialBalancePair | null = null
if (period.previous_period_id) {
const { data: prev } = await supabase
.from('fiscal_periods')
.select('id, period_start, period_end')
.eq('id', period.previous_period_id)
.eq('company_id', companyId)
.maybeSingle()
if (prev) {
previousPeriod = { start: prev.period_start, end: prev.period_end }
try {
const [prevFull, prevPreClosing] = await Promise.all([
generateTrialBalance(supabase, companyId, prev.id),
generateTrialBalance(supabase, companyId, prev.id, { excludeFinalClosingEntry: true }),
])
previousTb = { full: prevFull.rows, preClosing: prevPreClosing.rows }
} catch {
warnings.push(
'Jämförelsesiffror kunde inte hämtas för föregående räkenskapsår: balans- och resultaträkning visas utan jämförelseår (kontrollera-kod 3006/3007 kan utlösas).',
)
previousPeriod = null
}
}
}
const mapping = mapTrialBalancesToK2(
{ full: currentTbFull.rows, preClosing: currentTbPreClosing.rows },
previousTb,
)
warnings.push(...mapping.warnings)
// ---- flerårsöversikt (reuse PDF rows; whole SEK) -------------------------
// PDF rows are oldest-first; iXBRL columns newest-first. Each row needs the
// matching fiscal-period range so the document can declare period2/3 +
// balans2/3 contexts explicitly.
const { data: allPeriods } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('company_id', companyId)
.order('period_start', { ascending: false })
const periodByName = new Map(
((allPeriods ?? []) as Array<{ name: string; period_start: string; period_end: string }>).map(
(p) => [p.name, p],
),
)
const flerarsoversikt: FlerarsRow[] = []
const flerarsPerioder: Array<{ start: string; end: string }> = []
for (const row of [...pdfData.forvaltningsberattelse.flerarsoversikt].reverse()) {
const match = periodByName.get(row.year)
if (!match) continue // can't tag a year without a known period range
flerarsoversikt.push({
year: row.year,
nettoomsattning: Math.round(row.net_revenue),
resultatEfterFinansiellaPoster: Math.round(row.result_after_financial),
soliditetPct: row.soliditet_pct,
})
flerarsPerioder.push({ start: match.period_start, end: match.period_end })
}
// Column 0 must be the current period and column 1 the previous one:
// the document reuses period0/period1 contexts for them. Anything else
// (e.g. a missed periodByName lookup shifting the rows) means the period
// chain is inconsistent; drop the table rather than tag amounts against
// the wrong period.
const flerarsMisaligned =
(flerarsPerioder.length > 0 && flerarsPerioder[0].start !== period.period_start) ||
(flerarsPerioder.length > 1 &&
previousPeriod !== null &&
flerarsPerioder[1].start !== previousPeriod.start)
if (flerarsMisaligned) {
warnings.push(
'Flerårsöversikten kunde inte knytas till räkenskapsperioderna: tabellen utelämnas ur iXBRL-dokumentet.',
)
flerarsoversikt.length = 0
flerarsPerioder.length = 0
}
// Duplicate-fact consistency (TA §2.7.3): the flerårsöversikt repeats
// Nettoomsattning / ResultatEfterFinansiellaPoster in the same contexts
// (period0/period1) as the RR, and repeated facts must be value-identical
// or Bolagsverket rejects the filing. The PDF rows are computed from the
// income statement (ALL class-3 revenue), while nettoomsättning per ÅRL is
// strictly 3000-3799: so the current and previous year columns are
// overridden with the mapper outputs. Older years have no RR facts and
// keep the PDF values.
if (flerarsoversikt.length > 0) {
flerarsoversikt[0] = {
...flerarsoversikt[0],
nettoomsattning: mapping.rr['Nettoomsattning']?.current ?? 0,
resultatEfterFinansiellaPoster: mapping.totals.resultatEfterFinansiellaPoster.current,
}
if (flerarsoversikt.length > 1 && previousPeriod !== null) {
flerarsoversikt[1] = {
...flerarsoversikt[1],
nettoomsattning: mapping.rr['Nettoomsattning']?.previous ?? 0,
resultatEfterFinansiellaPoster:
mapping.totals.resultatEfterFinansiellaPoster.previous ?? 0,
}
}
}
// ---- eget kapital-förändring ---------------------------------------------
const br = mapping.br
const at = (concept: string): { ib: number; ub: number } => ({
ib: br[concept]?.previous ?? 0,
ub: br[concept]?.current ?? 0,
})
const aktiekapital = at('Aktiekapital')
const balanserat = at('BalanseratResultat')
const aretsRes = at('AretsResultatEgetKapital')
const totalt = {
ib: mapping.totals.egetKapital.previous ?? 0,
ub: mapping.totals.egetKapital.current,
}
const ovrigaPoster = {
ib:
totalt.ib - aktiekapital.ib - balanserat.ib - aretsRes.ib,
ub:
totalt.ub - aktiekapital.ub - balanserat.ub - aretsRes.ub,
}
// Movement derivation: föregående års resultat balanseras; vad som därutöver
// lämnat balanserat resultat antas vara utdelning (vanligaste fallet).
const balanserasINyRakning = aretsRes.ib
const balanseratResidual = balanserat.ub - (balanserat.ib + balanserasINyRakning)
const utdelning = balanseratResidual < 0 ? -balanseratResidual : 0
const ovrigForandringBalanserat = balanseratResidual > 0 ? balanseratResidual : 0
const egetKapital: EgetKapitalForandring = {
aktiekapital,
balanseratResultat: balanserat,
aretsResultat: aretsRes,
totalt,
ovrigaPoster,
balanserasINyRakning,
utdelning,
forandringAktiekapital: aktiekapital.ub - aktiekapital.ib,
ovrigForandringBalanserat,
aretsResultatRorelse: aretsRes.ub,
}
// ---- resultatdisposition --------------------------------------------------
// BalanseratResultat is tagged in BR and the eget kapital-table for the
// same context: the disposition row must carry the identical value
// (TA §2.7.3), so fri överkursfond (2097) is its own row tagged with the
// separate Overkursfond concept instead of being folded into balanserat.
const proposedDividend = Math.max(
0,
Math.round(
options.proposedDividend ?? pdfData.forvaltningsberattelse.proposed_dividend,
),
)
const dispositionAmounts = pdfData.forvaltningsberattelse.resultatdisposition_amounts
const dispBalanserat = dispositionAmounts.retained_earnings
const dispOverkursfond = dispositionAmounts.share_premium_reserve
const dispArets = dispositionAmounts.current_year_result
const dispSumma = dispositionAmounts.total
if (proposedDividend > dispSumma) {
warnings.push(
`Föreslagen utdelning (${proposedDividend} kr) överstiger fritt eget kapital (${dispSumma} kr).`,
)
}
const resultatdisposition: Resultatdisposition = {
balanseratResultat: dispBalanserat,
overkursfond: dispOverkursfond,
aretsResultat: dispArets,
summa: dispSumma,
utdelning: proposedDividend,
balanserasINyRakning: dispSumma - proposedDividend,
kommentar: pdfData.forvaltningsberattelse.resultatdisposition || null,
}
// ---- underskrifter ---------------------------------------------------------
// Every active signature request becomes a signer row (the board must appear
// in the document), but ONLY actually-signed requests get a date: an unsigned
// request keeps signedDate null. Legal dates are never fabricated: the
// missing date renders as an omitted fact in the preview and preflight 1214
// blocks the submission path until everyone has signed.
const activeSignatureRequests = signatureRequests.filter(
(request) => request.status !== 'declined',
)
const signedRequests = activeSignatureRequests.filter((request) => request.status === 'signed')
const today = options.todayIso ?? new Date().toISOString().slice(0, 10)
const signers: IxbrlSigner[] = activeSignatureRequests.map((request) => {
const { firstName, lastName } = splitName(request.signer_name)
return {
firstName,
lastName,
role: request.role || null,
signedDate: request.signed_at ? request.signed_at.slice(0, 10) : null,
}
})
if (signers.length === 0) {
warnings.push(
'Inga underskrifter är registrerade: årsredovisningen måste skrivas under av styrelsen (och ev. VD) innan inlämning (kontrollera-kod 1107/1201).',
)
}
if (signedRequests.length !== activeSignatureRequests.length) {
warnings.push('Alla underskriftsförfrågningar är inte signerade ännu.')
}
const harVd = signers.some((signer) => /verkställande direktör|^vd$/i.test(signer.role ?? ''))
const latestSignatureDate = signers.reduce<string | null>(
(latest, signer) =>
signer.signedDate !== null && (latest === null || signer.signedDate > latest)
? signer.signedDate
: latest,
null,
)
// ---- fastställelseintyg ----------------------------------------------------
// A missing AGM date is NEVER replaced with today's date: it stays null,
// the document renders a placeholder and preflight 1103 blocks filing
// (mirrors Bolagsverket kontrollera 1103).
const agmDate = pdfData.forvaltningsberattelse.agm_date
if (!agmDate) {
warnings.push(
'Datum för årsstämma saknas: fastställelseintyget kan inte fyllas i (kontrollera-kod 1103).',
)
}
const agmDispositionOutcome = pdfData.forvaltningsberattelse.agm_disposition_outcome
const agmDispositionDecision = pdfData.forvaltningsberattelse.agm_disposition_decision
if (!agmDispositionOutcome) {
warnings.push('Årsstämmans beslut om resultatdisposition saknas i fastställelseintyget.')
}
const fallbackSigner = signers[0] ?? { firstName: '', lastName: '', role: null }
const undertecknare = options.undertecknare ?? {
firstName: fallbackSigner.firstName,
lastName: fallbackSigner.lastName,
role: fallbackSigner.role ?? 'Styrelseledamot',
}
if (!undertecknare.firstName) {
warnings.push('Undertecknare av fastställelseintyget saknas (kontrollera-kod 1169).')
}
// ---- allmänt om verksamheten: ensure säte is mentioned ---------------------
let allmant = pdfData.forvaltningsberattelse.description
if (pdfData.company.city && !/säte/i.test(allmant)) {
allmant = `${allmant}\n\nBolaget har sitt säte i ${pdfData.company.city}.`
}
// ---- medelantal anställda ---------------------------------------------------
// Compute BOTH years with the real FTE helper (the same one the PDF note
// uses) over the employees table. The note-prose regex stays only as a
// last-resort fallback when the employees query fails.
let medelantalAnstallda: { current: number; previous: number | null }
const { data: employeeRows, error: employeesError } = await supabase
.from('employees')
.select('employment_start, employment_end, employment_degree')
.eq('company_id', companyId)
if (employeesError) {
medelantalAnstallda = extractMedelantal(pdfData.noter, null)
} else {
const employees = (employeeRows ?? []) as Array<{
employment_start: string
employment_end: string | null
employment_degree: number
}>
medelantalAnstallda = {
current: computeMedelantalAnstallda(employees, period.period_start, period.period_end),
previous: previousPeriod
? computeMedelantalAnstallda(employees, previousPeriod.start, previousPeriod.end)
: null,
}
}
return {
company: {
name: pdfData.company.name,
orgNumber: pdfData.company.org_number,
city: pdfData.company.city,
},
period: { start: period.period_start, end: period.period_end },
previousPeriod,
isFirstFiscalYear: previousPeriod === null,
rr: mapping.rr,
br: mapping.br,
totals: mapping.totals,
forvaltningsberattelse: {
allmantOmVerksamheten: allmant,
vasentligaHandelser: pdfData.forvaltningsberattelse.important_events,
flerarsoversikt,
flerarsPerioder,
egetKapital,
resultatdisposition,
},
noter: pdfData.noter.map((note) => ({ number: note.number, title: note.title, body: note.body })),
disclosures: {
longTermDebtOverFiveYears:
pdfData.disclosures.long_term_debt_over_five_years ?? 0,
securitiesPledged: pdfData.disclosures.securities_pledged?.trim() || 'Inga.',
contingentLiabilities:
pdfData.disclosures.contingent_liabilities?.trim() || 'Inga.',
parentCompany: pdfData.disclosures.parent_company_name
? [
`Moderföretag: ${pdfData.disclosures.parent_company_name}.`,
pdfData.disclosures.parent_company_org_number
? `Organisationsnummer: ${pdfData.disclosures.parent_company_org_number}.`
: '',
pdfData.disclosures.parent_company_city
? `Säte: ${pdfData.disclosures.parent_company_city}.`
: '',
]
.filter(Boolean)
.join(' ')
: null,
},
medelantalAnstallda,
underskrifter: {
ort: pdfData.company.city ?? '',
dateringsdatum: latestSignatureDate,
signers,
harVd,
},
faststallelseintyg: {
arsstammaDatum: agmDate ?? null,
resultatdispositionOutcome: agmDispositionOutcome,
resultatdispositionDecision: agmDispositionDecision,
signerFirstName: undertecknare.firstName,
signerLastName: undertecknare.lastName,
signerRole: undertecknare.role,
genereratDatum: today,
},
programvara: { namn: PROGRAMVARA_NAMN, version: PROGRAMVARA_VERSION },
entryPointId: entryPoint.id,
// Dedupe: buildArsredovisningData now runs the same K2 mapping for the
// PDF statements, so its warnings overlap with the ones produced here.
warnings: Array.from(new Set([...pdfData.warnings, ...warnings])),
}
}
/** Pull the FTE figure out of the medelantal note body ("…uppgått till X."). */
function extractMedelantal(
noter: Array<{ title: string; body: string }>,
previous: number | null,
): { current: number; previous: number | null } {
const note = noter.find((n) => /medelantal.*anst/i.test(n.title))
if (!note) return { current: 0, previous }
const match = note.body.match(/uppgått till\s+([\d,.]+)/i)
if (!match) return { current: 0, previous }
const value = Number(match[1].replace(',', '.'))
return { current: Number.isFinite(value) ? value : 0, previous }
}