Files
accounted/lib/bokslut/readiness-aggregator.ts
T
Mattsson 73c63209f1 feat: stage kontantmetod year-end cutoff (#1586)
* feat: stage kontantmetod year-end cutoff

* fix: keep cutoff tool payload searchable

* fix: trim year-end tool metadata
2026-08-13 15:55:01 +02:00

266 lines
11 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import { validateYearEndReadiness } from '@/lib/core/bookkeeping/year-end-service'
import { getReconciliationStatus } from '@/lib/reconciliation/bank-reconciliation'
import { resolveCashAccountScope } from '@/lib/reconciliation/cash-account-scope'
import { generateARReconciliation } from '@/lib/reports/ar-reconciliation'
import { generateReconciliation as generateAPReconciliation } from '@/lib/reports/supplier-reconciliation'
import { computeEfDeclarationPreview } from '@/lib/bokslut/enskild-firma/ef-declaration-preview'
import { createLogger } from '@/lib/logger'
import type { YearEndBlocker, YearEndValidation } from '@/types'
const log = createLogger('bokslut-readiness')
export type ReminderSeverity = 'info' | 'warning'
export interface BokslutReminder {
/** Stable id so the UI can suppress duplicates and link to docs. */
code: string
severity: ReminderSeverity
/** Swedish, user-facing. */
message: string
/** Optional deep link to the relevant resolution surface. */
href?: string
}
export interface BokslutReadinessReport {
/** Mirrors validateYearEndReadiness.ready: true ⇔ no blocking errors. */
ready: boolean
/** Blocking errors that prevent year-end execution (from year-end-service). */
blockers: string[]
/** Same blockers with stable machine codes (same order as `blockers`).
* The wizard matches on `code` to attach remediation links; `blockers`
* stays as plain strings for existing consumers. */
blockerItems: YearEndBlocker[]
/** Non-blocking warnings (from year-end-service). */
warnings: string[]
/** Soft reminders (Phase 2+ features not yet shipped, manual steps the user
* should consider). Never blockers: surfaced so users know what's manual. */
reminders: BokslutReminder[]
/** Convenience counts for the UI header. */
draftCount: number
unexplainedGapCount: number
trialBalanceBalanced: boolean
/** Bank reconciliation snapshot for the period. */
reconciliation: {
is_reconciled: boolean
unmatched_transaction_count: number
unmatched_gl_line_count: number
difference: number
} | null
/** Period metadata so the UI can show name/dates without an extra fetch. */
period: {
id: string
name: string
period_start: string
period_end: string
is_closed: boolean
locked_at: string | null
closing_entry_id: string | null
}
/** Entity type drives which dispositions apply (e.g. bolagsskatt only for AB). */
entityType: 'aktiebolag' | 'enskild_firma' | 'handelsbolag' | 'kommanditbolag' | 'ekonomisk_forening'
/** The full raw validation, for callers that want every field. */
rawValidation: YearEndValidation
}
/**
* Single-fetch aggregator that drives the bokslut wizard's preflight step.
*
* Wraps validateYearEndReadiness (which owns the legally-required checks) and
* layers on:
* - bank reconciliation snapshot for the period (informational warning if
* unmatched transactions exist: not a legal blocker)
* - soft reminders for Phase 2+ features that ship later (depreciation,
* accruals, tax provision). These tell the user what's manual today.
*
* Phase 2 will replace each reminder with a concrete proposal once the
* relevant calculator ships.
*/
export async function buildBokslutReadinessReport(
supabase: SupabaseClient,
companyId: string,
userId: string,
fiscalPeriodId: string,
): Promise<BokslutReadinessReport> {
// Fetch period + entity type in parallel with the heavy validation.
const [periodResult, settingsResult, validation] = await Promise.all([
supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end, is_closed, locked_at, closing_entry_id')
.eq('id', fiscalPeriodId)
.eq('company_id', companyId)
.single(),
supabase
.from('company_settings')
.select('entity_type, accounting_method')
.eq('company_id', companyId)
.maybeSingle(),
validateYearEndReadiness(supabase, companyId, userId, fiscalPeriodId),
])
if (periodResult.error || !periodResult.data) {
throw new Error('Fiscal period not found')
}
const period = periodResult.data
const entityType = (settingsResult.data?.entity_type ?? 'aktiebolag') as BokslutReadinessReport['entityType']
const accountingMethod =
((settingsResult.data as { accounting_method?: string | null } | null)?.accounting_method ??
'accrual')
// Bank reconciliation snapshot for the period. Run after period fetch so we
// know the date range. Failure here must not break the report: fall back
// to null so the UI degrades gracefully.
let reconciliation: BokslutReadinessReport['reconciliation'] = null
try {
// Scope to the company's bank account. A 4-arg call leaves cashAccountId
// undefined and the bank side then sums every SEK cash account while the GL
// side stays on 1930 alone: the wizard surfaced that as "Bankavstämningen
// visar en differens" with nothing to match (#1290).
//
// resolveCashAccountScope fails CLOSED on a lookup error, so the catch below
// turns a failed lookup into "no reconciliation snapshot" rather than into
// the unscoped pooling path that produced the phantom difference.
const scope = await resolveCashAccountScope(supabase, companyId)
const status = await getReconciliationStatus(
supabase,
companyId,
period.period_start,
period.period_end,
scope.accountNumber,
scope.currency,
scope.cashAccountId,
scope.includeUnassigned,
)
reconciliation = {
is_reconciled: status.is_reconciled,
unmatched_transaction_count: status.unmatched_transaction_count,
unmatched_gl_line_count: status.unmatched_gl_line_count,
difference: status.difference,
}
} catch {
reconciliation = null
}
const reminders: BokslutReminder[] = []
if (reconciliation && !reconciliation.is_reconciled) {
reminders.push({
code: 'bank_reconciliation_incomplete',
severity: 'warning',
message:
reconciliation.unmatched_transaction_count > 0
? `${reconciliation.unmatched_transaction_count} banktransaktioner är inte matchade. Avstäm banken innan bokslut.`
: `Bankavstämningen visar en differens på ${reconciliation.difference.toFixed(2)} kr.`,
// Bankavstämning's real route: the earlier '/reconciliation/bank' href
// pointed at a page that has never existed, so the wizard's "Öppna"
// link 404ed.
href: '/reports/bank-reconciliation',
})
}
// AR/AP tie-outs: Phase 1 avstämningar per the bokslut process, open
// sub-ledger vs konto 1510 / 2440. Accrual companies only: under
// kontantmetoden open invoices are deliberately not on 1510/2440 until the
// cut-off entry below puts them there, so the tie-out is "unreconciled" by
// construction for the whole year and would only mislead.
// Warnings, never blockers: a difference can be legitimate (e.g. partial
// payments settled at a different FX rate than the invoice-date rate).
if (accountingMethod === 'accrual') {
const [arResult, apResult] = await Promise.allSettled([
generateARReconciliation(supabase, companyId, fiscalPeriodId),
generateAPReconciliation(supabase, companyId, fiscalPeriodId),
])
// A failed tie-out degrades to "no reminder" (these are advisory), but a
// silently swallowed failure is indistinguishable from "reconciled" in
// the report, so the rejection must at least be traceable in logs
// (compliance review on the avstämning controls, BFNAR 2013:2 kap 8).
if (arResult.status === 'rejected') {
log.warn('AR tie-out (kundreskontra vs 1510) failed; reminder omitted', arResult.reason)
}
if (apResult.status === 'rejected') {
log.warn('AP tie-out (leverantörsreskontra vs 2440) failed; reminder omitted', apResult.reason)
}
if (arResult.status === 'fulfilled' && !arResult.value.is_reconciled) {
reminders.push({
code: 'ar_reconciliation_mismatch',
severity: 'warning',
message:
arResult.value.unconverted_fx_count > 0
? `Kundreskontran kan inte stämmas av mot konto 1510: ${arResult.value.unconverted_fx_count} fakturor i utländsk valuta saknar valutakurs.`
: `Kundreskontran stämmer inte mot konto 1510: differens ${arResult.value.difference.toFixed(2)} kr. Kontrollera obetalda kundfakturor innan bokslut.`,
href: '/reports/kundreskontra',
})
}
if (apResult.status === 'fulfilled' && !apResult.value.is_reconciled) {
reminders.push({
code: 'ap_reconciliation_mismatch',
severity: 'warning',
message:
apResult.value.unconverted_fx_count > 0
? `Leverantörsreskontran kan inte stämmas av mot konto 2440: ${apResult.value.unconverted_fx_count} fakturor i utländsk valuta saknar valutakurs.`
: `Leverantörsreskontran stämmer inte mot konto 2440: differens ${apResult.value.difference.toFixed(2)} kr. Kontrollera obetalda leverantörsfakturor innan bokslut.`,
href: '/reports/supplier-ledger',
})
}
}
// Periodiseringar (accruals) are still manual: no wizard step ships in
// Phases 1-3. Depreciation, bolagsskatt and periodiseringsfond now have
// dedicated calculators (DepreciationPanel + DispositionsStep) so they're
// no longer surfaced as manual reminders.
reminders.push({
code: 'accruals_manual',
severity: 'info',
message:
'Periodiseringar (förutbetalda kostnader 17xx, upplupna kostnader 29xx) bokas manuellt. Tänk på att vända dem 1 januari nästa år.',
})
if (entityType === 'enskild_firma') {
// Pre-compute the EF declaration so the wizard's overview reflects what
// the user will see when they reach the dispositions step. Egenavgifter,
// räntefördelning, periodiseringsfond-EF and expansionsfond are NOT
// booked: they go into the NE-bilaga / INK1. This reminder explains
// the BFL distinction.
reminders.push({
code: 'ef_skatt_via_ne',
severity: 'info',
message:
'Egenavgifter, räntefördelning, periodiseringsfond och expansionsfond beräknas i NE-bilagan, inte bokförs. Skatten betalas privat av ägaren.',
})
// Surface a soft warning when kapitalunderlag is missing AND the booked
// surplus is large enough to make positive räntefördelning meaningful
// (> 50 000 kr: the spärrbelopp). This is non-blocking but actionable:
// the user should enter their IB equity on the dispositions step.
try {
const preview = await computeEfDeclarationPreview(supabase, companyId, fiscalPeriodId)
if (preview.bookedSurplus > 50_000) {
reminders.push({
code: 'ef_kapitalunderlag_missing',
severity: 'warning',
message:
'Kapitalunderlag (IB eget kapital) saknas: räntefördelning beräknas inte. Fyll i på dispositionssteget för att utnyttja skattefördelen.',
})
}
} catch {
// EF preview is informational: never block readiness on it.
}
}
return {
ready: validation.ready,
blockers: validation.errors,
blockerItems: validation.blockers,
warnings: validation.warnings,
reminders,
draftCount: validation.draftCount,
unexplainedGapCount: validation.unexplainedGaps.length,
trialBalanceBalanced: validation.trialBalanceBalanced,
reconciliation,
period,
entityType,
rawValidation: validation,
}
}