Files
accounted/lib/bokslut/readiness-aggregator.ts
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

193 lines
7.3 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 { computeEfDeclarationPreview } from '@/lib/bokslut/enskild-firma/ef-declaration-preview'
import type { YearEndValidation } from '@/types'
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[]
/** 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')
.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']
// 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 {
const status = await getReconciliationStatus(
supabase,
companyId,
period.period_start,
period.period_end,
)
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',
})
}
// 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,
warnings: validation.warnings,
reminders,
draftCount: validation.draftCount,
unexplainedGapCount: validation.unexplainedGaps.length,
trialBalanceBalanced: validation.trialBalanceBalanced,
reconciliation,
period,
entityType,
rawValidation: validation,
}
}