Files
accounted/lib/reports/behandlingshistorik.ts
T
MattssonandClaude Fable 5.1 f1230282a9 feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings (#2160)
* feat(bookkeeping): verifikationsserie per bankkonto for bank-transaction bookings

A company running several bank accounts (main bank on A, company card on M,
both imported via CSV) could not route each account's bookings into its own
series: every bank_transaction booking took the single company-wide default
from default_voucher_series_per_source_type.

- cash_accounts.voucher_series (nullable, single letter): per-account override,
  editable under Inställningar → Bokföring → Verifikationsserier per bankkonto
  (new PATCH /api/cash-accounts/[id]).
- resolveCashAccountVoucherSeries(): step 2 of the resolution order
  (explicit pick → account override → per-type map → A). Wired into the book
  route and createTransactionJournalEntry, which covers categorize, the agent,
  pending operations and the v1 API.
- Booking dialog gets the series picker, seeded from the server via
  /voucher-sequences/next?source_type&cash_account_id so dialog and route can
  never disagree. An unresolved embedded picker omits voucher_series so a
  stray 'A' never overrides the account's series.

Scope: bank_transaction bookings only. Invoice settlements matched from the
bank keep their payment series; bulk-book resolves inside its RPC (see
DECISIONS.md).

Migration applied to staging as 20260902121420.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh

* fix(bookkeeping): audit and document the per-bankkonto series, tighten preview and PATCH

Consolidated pass over the PR #2160 findings (skeptics, CodeRabbit, Swedish
compliance review):

- Behandlingshistorik (BFNAR 2013:2 p. 9.16): changing cash_accounts.voucher_series
  is a behandlingsregel that outranks the audited per-type map. New trigger
  audit_cash_accounts_voucher_series (UPDATE only, WHEN the series changes, so
  bank-sync churn never logs), cash_accounts added to AUDITED_TABLES and the
  audit_log filter, "Bankkonto ... Verifikationsserie: (tomt) -> M" events in
  the report, pg-real test. Applied to staging as 20260902124513.
- Systemdokumentation (p. 9.2-9.15): revision/systemdokumentation.json gains a
  verifikationsserier_regler block with the resolution order and the two
  exceptions (invoice settlements, samlingsverifikat); the per-account mapping
  itself is in data/cash_accounts.json.
- Settings picker uses the same closed list as the manual verifikat form
  (presets plus letters already in use) instead of all 26 letters; strings
  moved to messages/sv.json and messages/en.json.
- /voucher-sequences/next applies the account override only for
  source_type=bank_transaction (CodeRabbit), so a manual-entry preview cannot
  show a series the entry will not get.
- Book route resolves the series from the account the row ends up on after a
  stranded-row repoint, not the stale one.
- PATCH /api/cash-accounts/[id] answers 404 for a non-UUID id instead of a
  Postgres cast 500; the series lookup logs a warning when it fails open.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JWSLbQc3jgpfqnxWe6nteh

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 15:25:34 +02:00

1944 lines
71 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import type { AuditLogEntry } from '@/types'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchAppReleases, type AppReleaseRow } from '@/lib/reports/app-releases'
import {
reportToWorkbook,
textColumn,
integerColumn,
exportFilename,
UTF8_BOM,
type SheetSpec,
} from '@/lib/reports/xlsx-export'
/**
* Behandlingshistorik (BFL 5 kap. 11 §, BFNAR 2013:2 punkt 9.16).
*
* The statutory processing history has two limbs:
*
* (a) every bokföringspost that entered the system, with its
* registreringsdatum, in order, including corrections and who or what
* registered it; and
* (b) changes to the bookkeeping system that affect how posts are processed
* (kontoplan, settings such as redovisningsmetod or momsperiod, period
* locks, imports and migrations, access keys) and when they were made.
*
* This module is a read model over the stores that already record both limbs
* (`journal_entries.committed_at` + the trigger-written immutable `audit_log`,
* `journal_entry_rattelse_log`, `company_migration_resets`, `sie_imports`,
* `bank_file_imports`) and turns them into one chronological list of
* human-readable events. It never writes.
*
* Event labels are Swedish in both locales: the report is räkenskapsinformation
* that is archived for seven years and handed to revisorer, the same rule as
* the SIE export and the grundbok. UI chrome around it (column headers,
* filters) is translated in the view.
*
* Scope rules:
* - Fiscal-year mode (no date sub-range): the year's bokföringsposter are every
* posted/reversed entry of the fiscal period regardless of when it was
* committed (bokslut and storno entries land after period_end), plus every
* system change logged inside the period's dates. Audit rows touching the
* period's entries are included regardless of their timestamp.
* - Date-range mode (a sub-range inside the fiscal year): what happened between
* those dates: entries committed in the window, audit rows logged in the
* window. No record-id union.
*/
// ============================================================
// Public types (shared with the client view via behandlingshistorik-types)
// ============================================================
import {
BEHANDLINGSHISTORIK_CATEGORIES,
BEHANDLINGSHISTORIK_CATEGORY_LABELS,
type BehandlingshistorikActorType,
type BehandlingshistorikCategory,
type BehandlingshistorikEvent,
type BehandlingshistorikReport,
type BehandlingshistorikSource,
} from '@/lib/reports/behandlingshistorik-types'
export {
BEHANDLINGSHISTORIK_CATEGORIES,
BEHANDLINGSHISTORIK_CATEGORY_LABELS,
type BehandlingshistorikActor,
type BehandlingshistorikActorType,
type BehandlingshistorikCategory,
type BehandlingshistorikEvent,
type BehandlingshistorikReport,
type BehandlingshistorikSource,
} from '@/lib/reports/behandlingshistorik-types'
export interface BehandlingshistorikParams {
periodId: string
/** Inclusive ISO date; must lie inside the fiscal period (validated by the route). */
fromDate?: string
toDate?: string
categories?: BehandlingshistorikCategory[]
}
/** Maps user ids to display labels (e-mail). Injected so the lib stays client-agnostic. */
export type UserLabelResolver = (userIds: string[]) => Promise<Map<string, string>>
export interface GenerateBehandlingshistorikOptions {
resolveUserLabels?: UserLabelResolver
appVersion?: string | null
now?: Date
/**
* Service-role client for the global (company-less) system changes: the
* statutory payroll constants' audit rows, which RLS hides from session
* clients. Omit and those events are simply absent.
*/
globalClient?: Pick<SupabaseClient, 'from'>
}
// ============================================================
// Internal row shapes
// ============================================================
interface PeriodRow {
id: string
name: string
period_start: string
period_end: string
}
interface EntryRow {
id: string
voucher_series: string | null
voucher_number: number | null
entry_date: string
description: string | null
source_type: string | null
status: string
committed_at: string | null
user_id: string | null
committed_actor_type: string | null
committed_actor_label: string | null
commit_method: string | null
reverses_id: string | null
correction_of_id: string | null
}
interface RattelseRow {
id: string
journal_entry_id: string
rattelse_type: 'metadata' | 'lines'
old_description: string | null
new_description: string | null
old_entry_date: string | null
new_entry_date: string | null
struck_lines: unknown
added_lines: unknown
actor: string | null
created_at: string
}
interface MigrationResetRow {
id: string
source_company_id: string
replacement_company_id: string
actor_id: string | null
reason: string | null
source_counts: Record<string, unknown> | null
created_at: string
}
interface SieImportRow {
id: string
user_id: string | null
filename: string | null
sie_type: number | string | null
fiscal_year_start: string | null
fiscal_year_end: string | null
accounts_count: number | null
transactions_count: number | null
status: string | null
error_message: string | null
imported_at: string | null
created_at: string
replaced_at: string | null
}
interface BankFileImportRow {
id: string
user_id: string | null
filename: string | null
file_format: string | null
transaction_count: number | null
imported_count: number | null
duplicate_count: number | null
status: string | null
error_message: string | null
date_from: string | null
date_to: string | null
created_at: string
}
/** Event before user labels are resolved. */
interface RawActor {
type: BehandlingshistorikActorType
user_id: string | null
actor_label: string | null
}
export interface RawBehandlingshistorikEvent extends Omit<BehandlingshistorikEvent, 'actor'> {
actor: RawActor
}
// ============================================================
// Constants: tables, field labels, value labels
// ============================================================
/** audit_log tables the report reads. Everything else (registers) is out of scope per BFN's commentary. */
export const AUDITED_TABLES = [
'journal_entries',
'chart_of_accounts',
'company_settings',
'fiscal_periods',
'api_keys',
'dimensions',
'dimension_values',
'account_dimension_rules',
'accrual_schedules',
'document_attachments',
// Behandlingsregler and import logs (audited since migration 20260901103000).
'mapping_rules',
'categorization_templates',
'booking_template_library',
'sie_imports',
'bank_file_imports',
// Verifikationsserie per bankkonto (audited since migration 20260902124513):
// the per-account override outranks the per-source-type map above, so it is
// a behandlingsregel in the same sense.
'cash_accounts',
] as const
/**
* Global (company-less) audit rows: statutory payroll constants. RLS hides
* NULL-company rows from session clients, so these are fetched through the
* service role when the caller provides one (`globalClient`).
*/
export const GLOBAL_AUDITED_TABLES = ['salary_payroll_config'] as const
/** Actions that matter regardless of table (security / integrity / retention). */
export const GLOBAL_ACTIONS = [
'SECURITY_EVENT',
'INTEGRITY_FAILURE',
'RETENTION_BLOCK',
'DOCUMENT_DELETE_BLOCKED',
] as const
/**
* PostgREST `or` filter selecting the rows above. Written as one literal so the
* schema guard (tests/schema/no-phantom-columns.test.ts) can resolve the column
* names statically; a unit test pins it to AUDITED_TABLES / GLOBAL_ACTIONS.
*/
export const AUDIT_ROW_FILTER =
'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments,mapping_rules,categorization_templates,booking_template_library,sie_imports,bank_file_imports,cash_accounts),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)'
const SOURCE_TYPE_LABELS: Record<string, string> = {
manual: 'Manuell',
import: 'Import',
bank_transaction: 'Banktransaktion',
storno: 'Storno',
correction: 'Rättelse',
supplier_invoice_registered: 'Leverantörsfaktura registrerad',
supplier_invoice_paid: 'Leverantörsfaktura betald',
supplier_invoice_privately_paid: 'Leverantörsfaktura privat betald',
supplier_invoice_cash_payment: 'Leverantörsfaktura kontant',
supplier_credit_note: 'Leverantörskreditnota',
opening_balance: 'Ingående balans',
system: 'System',
salary_payment: 'Lön',
inbox_item: 'Underlag från inkorg',
invoice_created: 'Kundfaktura',
invoice_paid: 'Kundfaktura betald',
invoice_cash_payment: 'Kontantfaktura',
credit_note: 'Kreditfaktura',
result_appropriation: 'Resultatdisposition',
year_end: 'Bokslut',
vat_settlement: 'Momsavräkning',
accrual: 'Periodisering',
currency_revaluation: 'Valutaomvärdering',
webshop_order: 'Webshop',
stripe_payout: 'Stripe-utbetalning',
}
const COMMIT_METHOD_LABELS: Record<string, string> = {
user_accept: 'Godkänd av användare',
bulk_accept: 'Godkänd i massbokning',
api_key: 'Via API-nyckel',
agent_relay: 'Godkänd via assistenten',
}
const JOURNAL_ENTRY_FIELDS: Record<string, string> = {
description: 'Beskrivning',
entry_date: 'Datum',
notes: 'Notering',
attachment_urls: 'Underlag',
voucher_series: 'Verifikationsserie',
voucher_number: 'Verifikationsnummer',
fiscal_period_id: 'Räkenskapsår',
status: 'Status',
}
const ACCOUNT_FIELDS: Record<string, string> = {
account_name: 'Namn',
account_type: 'Typ',
is_active: 'Aktivt',
default_vat_code: 'Momskod',
default_vat_rate: 'Momssats',
default_vat_treatment: 'Momshantering',
sru_code: 'SRU-kod',
k2_excluded: 'Exkluderat i K2',
description: 'Beskrivning',
}
/**
* company_settings keys that affect how bokföringsposter are processed
* (BFNAR 2013:2 p. 9.16 second paragraph). Everything else on the row
* (invoice layout, onboarding state, running counters) is deliberately ignored:
* next_invoice_number alone would otherwise add a row per issued invoice.
*/
const SETTINGS_FIELDS: Record<string, string> = {
accounting_method: 'Redovisningsmetod',
moms_period: 'Momsperiod',
vat_registered: 'Momsregistrerad',
vat_filing_method: 'Momsdeklaration, inlämningssätt',
vat_has_eu_trade: 'EU-handel',
vat_taxable_base_over_40m: 'Beskattningsunderlag över 40 mkr',
tax_turnover_over_40m: 'Omsättning över 40 mkr',
fiscal_year_start_month: 'Räkenskapsårets startmånad',
default_voucher_series: 'Standardserie för verifikat',
default_voucher_series_per_source_type: 'Verifikationsserier per källa',
bookkeeping_locked_through: 'Bokföringen låst till och med',
auto_lock_period_days: 'Automatisk låsning (dagar)',
defer_invoice_booking: 'Bokför kundfakturor vid betalning',
ore_rounding: 'Öresavrundning',
rot_rut_enabled: 'ROT/RUT',
oss_enabled: 'OSS',
ioss_enabled: 'IOSS',
employer_registered: 'Registrerad arbetsgivare',
employer_seasonal: 'Säsongsarbetsgivare',
pays_salaries: 'Betalar löner',
has_employees: 'Har anställda',
employee_count: 'Antal anställda',
salary_pay_day: 'Löneutbetalningsdag',
salary_vacation_year_basis: 'Semesterår',
salary_net_rounding: 'Avrundning nettolön',
salary_default_bank: 'Standardbank för lön',
kontrolluppgifter_enabled: 'Kontrolluppgifter',
fyllnadsinbetalning_enabled: 'Fyllnadsinbetalning',
preliminary_tax_monthly: 'Preliminärskatt per månad',
entity_type: 'Företagsform',
org_number: 'Organisationsnummer',
company_name: 'Företagsnamn',
country: 'Land',
f_skatt: 'F-skatt',
periodisk_sammanstallning_enabled: 'Periodisk sammanställning',
periodisk_sammanstallning_period: 'Periodisk sammanställning, period',
periodisk_sammanstallning_filing_method: 'Periodisk sammanställning, inlämningssätt',
punktskatt_enabled: 'Punktskatt',
intrastat_enabled: 'Intrastat',
dimensions_enabled: 'Dimensioner',
invoice_payment_accounts: 'Betalkonton för kundfakturor',
last_supplier_payment_account: 'Standardkonto för leverantörsbetalning',
schablon_mileage_rate: 'Milersättning (schablon)',
mileage_enabled: 'Körjournal',
selected_modules: 'Aktiverade moduler',
uses_pos_system: 'Kassaregister',
aktiekapital: 'Aktiekapital',
antal_aktier: 'Antal aktier',
is_sandbox: 'Sandlåda',
}
const SETTINGS_VALUE_LABELS: Record<string, Record<string, string>> = {
// company_settings CHECK allows 'accrual' | 'cash'; 'invoice' is the legacy spelling.
accounting_method: { accrual: 'Faktureringsmetoden', invoice: 'Faktureringsmetoden', cash: 'Kontantmetoden' },
moms_period: { monthly: 'Månad', quarterly: 'Kvartal', yearly: 'Helår', none: 'Ingen' },
entity_type: { aktiebolag: 'Aktiebolag', enskild_firma: 'Enskild firma' },
}
const PERIOD_FIELDS: Record<string, string> = {
name: 'Namn',
period_start: 'Startdatum',
period_end: 'Slutdatum',
opening_balances_set: 'Ingående balanser satta',
continuity_verified: 'Kontinuitet verifierad',
tax_depreciation_method: 'Skattemässig avskrivning, metod',
tax_depreciation_rule: 'Skattemässig avskrivning, regel',
tax_depreciation_base: 'Skattemässig avskrivning, underlag',
tax_depreciation_deduction: 'Skattemässig avskrivning, avdrag',
}
const API_KEY_FIELDS: Record<string, string> = {
name: 'Namn',
scopes: 'Behörigheter',
rate_limit_per_minute: 'Anrop per minut',
expires_at: 'Giltig till',
is_active: 'Aktiv',
// How much the key may post without a human. A change here changes who
// approves the company's bookkeeping, so it belongs in behandlingshistorik
// (BFL 5 kap. 11 §) exactly like a scope change does. Without this line the
// UPDATE row exists in audit_log but renders zero diff lines and is dropped.
unattended_commit_limit: 'Belopp utan mänsklig granskning',
}
const DIMENSION_FIELDS: Record<string, string> = {
name: 'Namn',
code: 'Kod',
is_active: 'Aktiv',
dimension_type: 'Typ',
sie_dimension_number: 'SIE-dimension',
}
const MAPPING_RULE_FIELDS: Record<string, string> = {
rule_name: 'Namn',
rule_type: 'Typ',
priority: 'Prioritet',
merchant_pattern: 'Motpartsmönster',
description_pattern: 'Textmönster',
mcc_codes: 'MCC-koder',
amount_min: 'Belopp från',
amount_max: 'Belopp till',
debit_account: 'Debetkonto',
credit_account: 'Kreditkonto',
vat_treatment: 'Momshantering',
vat_debit_account: 'Momskonto debet',
vat_credit_account: 'Momskonto kredit',
capitalization_threshold: 'Aktiveringsgräns',
capitalized_debit_account: 'Konto vid aktivering',
requires_review: 'Kräver granskning',
default_private: 'Privat som standard',
is_active: 'Aktiv',
}
/**
* cash_accounts columns that are behandlingsregler. Only voucher_series: the
* trigger (20260902124513) fires on that column alone, and the read model
* must not resurrect balance/name churn from bank sync if a row slips through.
*/
const CASH_ACCOUNT_FIELDS: Record<string, string> = {
voucher_series: 'Verifikationsserie',
}
const CATEGORIZATION_TEMPLATE_FIELDS: Record<string, string> = {
counterparty_name: 'Motpart',
// counterparty_aliases deliberately absent: aliases grow in the same
// learning write as occurrence_count (migration 20260901200000 stopped
// logging them), and the pre-fix audit rows already in prod are alias-only
// noise that must render as no-ops, not as rule changes.
debit_account: 'Debetkonto',
credit_account: 'Kreditkonto',
vat_treatment: 'Momshantering',
vat_account: 'Momskonto',
category: 'Kategori',
line_pattern: 'Radmönster',
default_dimensions: 'Dimensioner',
is_active: 'Aktiv',
}
const BOOKING_TEMPLATE_FIELDS: Record<string, string> = {
name: 'Namn',
description: 'Beskrivning',
category: 'Kategori',
entity_type: 'Företagsform',
lines: 'Konteringsrader',
is_active: 'Aktiv',
}
/** Keys of salary_payroll_config that are bookkeeping constants (all but bookkeeping of the row itself). */
const PAYROLL_CONFIG_EXCLUDED_KEYS = new Set(['id', 'created_at'])
const ACCRUAL_FIELDS: Record<string, string> = {
description: 'Beskrivning',
status: 'Status',
total_amount: 'Belopp',
start_date: 'Startdatum',
end_date: 'Slutdatum',
periods: 'Antal perioder',
balance_account: 'Balanskonto',
result_account: 'Resultatkonto',
}
/**
* Event codes whose consecutive runs (same actor, short gap) collapse into one
* summary row: kontoplan seeding writes ~1 000 rows, and a receipt clean-up
* deletes dozens of underlag in one go.
*/
interface CollapseRule {
minSize: number
event: string
noun: string
category: BehandlingshistorikCategory
mode: 'accounts' | 'documents'
}
const COLLAPSIBLE: Record<string, CollapseRule> = {
'account.created': { minSize: 10, event: 'Kontoplan upplagd', noun: 'konton', category: 'kontoplan', mode: 'accounts' },
'account.updated': { minSize: 10, event: 'Kontoplan ändrad', noun: 'konton', category: 'kontoplan', mode: 'accounts' },
'account.deleted': { minSize: 10, event: 'Konton borttagna ur kontoplanen', noun: 'konton', category: 'kontoplan', mode: 'accounts' },
'document.deleted': { minSize: 3, event: 'Underlag borttagna', noun: 'underlag', category: 'ovrigt', mode: 'documents' },
}
const MAX_VALUE_LENGTH = 80
// ============================================================
// Small helpers
// ============================================================
function toIso(value: string | null | undefined): string | null {
if (!value) return null
const ms = Date.parse(value)
return Number.isNaN(ms) ? value : new Date(ms).toISOString()
}
function toMs(value: string | null | undefined): number {
if (!value) return Number.NaN
return Date.parse(value)
}
function isWithin(value: string | null | undefined, fromMs: number, toMs: number): boolean {
const ms = Date.parse(value ?? '')
return !Number.isNaN(ms) && ms >= fromMs && ms <= toMs
}
function truncate(value: string): string {
return value.length > MAX_VALUE_LENGTH ? `${value.slice(0, MAX_VALUE_LENGTH - 1)}…` : value
}
function fmtValue(value: unknown, key?: string): string {
if (value === null || value === undefined || value === '') return '(tomt)'
if (typeof value === 'boolean') return value ? 'Ja' : 'Nej'
if (typeof value === 'number') return String(value)
if (typeof value === 'string') {
const mapped = key ? SETTINGS_VALUE_LABELS[key]?.[value] : undefined
return truncate(mapped ?? value)
}
if (Array.isArray(value)) {
if (value.length === 0) return '(tomt)'
return truncate(value.map((v) => (typeof v === 'string' ? v : JSON.stringify(v))).join(', '))
}
if (typeof value === 'object') {
// Settings maps such as default_voucher_series_per_source_type read better
// as "import: A, manual: A" than as raw JSON.
const entries = Object.entries(value as Record<string, unknown>)
if (entries.length === 0) return '(tomt)'
return truncate(
entries.map(([k, v]) => `${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`).join(', '),
)
}
return truncate(JSON.stringify(value))
}
function same(a: unknown, b: unknown): boolean {
return JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
}
/**
* Field-level diff restricted to an allow-list of labelled keys. Returns one
* "Label: old → new" line per changed key, in allow-list order.
*/
export function diffFields(
oldState: Record<string, unknown> | null,
newState: Record<string, unknown> | null,
labels: Record<string, string>,
): { lines: string[]; keys: string[] } {
const lines: string[] = []
const keys: string[] = []
for (const key of Object.keys(labels)) {
const before = oldState ? oldState[key] : undefined
const after = newState ? newState[key] : undefined
if (same(before, after)) continue
keys.push(key)
lines.push(`${labels[key]}: ${fmtValue(before, key)} → ${fmtValue(after, key)}`)
}
return { lines, keys }
}
function voucherLabel(series: unknown, number: unknown): string | null {
if (number === null || number === undefined) return null
return `${typeof series === 'string' ? series : ''}${String(number)}`
}
function str(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function num(value: unknown): number | null {
return typeof value === 'number' ? value : null
}
function sourceTypeLabel(sourceType: string | null | undefined): string {
if (!sourceType) return 'Okänd'
return SOURCE_TYPE_LABELS[sourceType] ?? sourceType
}
function normaliseActorType(value: unknown): BehandlingshistorikActorType {
switch (value) {
case 'api_key':
case 'mcp_oauth':
case 'cron':
case 'agent_chat':
case 'system':
return value
default:
return 'user'
}
}
function actorKey(actor: RawActor): string {
return `${actor.type}|${actor.user_id ?? ''}|${actor.actor_label ?? ''}`
}
/** Resolve a raw actor into the display label shown in the report. */
export function formatActorLabel(actor: RawActor, userLabels: Map<string, string>): string {
const userLabel = actor.user_id ? userLabels.get(actor.user_id) : undefined
const fallbackUser = actor.user_id ? `Användare ${actor.user_id.slice(0, 8)}` : null
switch (actor.type) {
case 'api_key':
return actor.actor_label ? `API-nyckel: ${actor.actor_label}` : 'API-nyckel'
case 'mcp_oauth':
return actor.actor_label ? `MCP-anslutning: ${actor.actor_label}` : 'MCP-anslutning'
case 'agent_chat': {
const who = userLabel ?? fallbackUser
return who ? `Assistenten, på uppdrag av ${who}` : 'Assistenten'
}
case 'cron':
return actor.actor_label ? `Schemalagd körning: ${actor.actor_label}` : 'Schemalagd körning'
case 'system':
return actor.actor_label ? `Systemet: ${actor.actor_label}` : 'Systemet'
default:
return userLabel ?? fallbackUser ?? 'Okänd användare'
}
}
function finaliseEvent(
raw: RawBehandlingshistorikEvent,
userLabels: Map<string, string>,
): BehandlingshistorikEvent {
return {
...raw,
actor: {
type: raw.actor.type,
user_id: raw.actor.user_id,
label: formatActorLabel(raw.actor, userLabels),
},
}
}
// ============================================================
// Normalisers: one source row → zero or one event
// ============================================================
interface NormaliseContext {
/** journal_entry_id → committed metadata-rättelse timestamps (ms), to suppress the duplicate audit UPDATE row. */
rattelseMetadataAt: Map<string, number[]>
entryById: Map<string, EntryRow>
}
const RATTELSE_DUPLICATE_WINDOW_MS = 30_000
/** A posted or reversed entry, i.e. a real bokföringspost. */
function isBookedStatus(status: unknown): boolean {
return status === 'posted' || status === 'reversed'
}
export function commitEventFromEntry(entry: EntryRow): RawBehandlingshistorikEvent | null {
if (!isBookedStatus(entry.status) || !entry.committed_at) return null
const details: string[] = [`Datum: ${entry.entry_date}`]
if (entry.description) details.push(`Text: ${truncate(entry.description)}`)
details.push(`Källa: ${sourceTypeLabel(entry.source_type)}`)
if (entry.commit_method) {
details.push(`Bokföringssätt: ${COMMIT_METHOD_LABELS[entry.commit_method] ?? entry.commit_method}`)
}
if (entry.reverses_id) details.push('Vändningsverifikation (storno)')
if (entry.correction_of_id) details.push('Rättelseverifikation')
const actorType = entry.committed_actor_type
? normaliseActorType(entry.committed_actor_type)
: entry.commit_method === 'api_key'
? 'api_key'
: 'user'
return {
id: `entry:${entry.id}`,
occurred_at: toIso(entry.committed_at)!,
category: 'verifikation',
code: 'journal_entry.committed',
event: 'Verifikation bokförd',
object: voucherLabel(entry.voucher_series, entry.voucher_number),
actor: { type: actorType, user_id: entry.user_id, actor_label: entry.committed_actor_label },
details,
source: 'journal_entries',
count: 1,
}
}
function describeLine(line: unknown): string {
if (!line || typeof line !== 'object') return String(line)
const l = line as Record<string, unknown>
const account = str(l.account_number) ?? '?'
const debit = num(l.debit_amount) ?? 0
const credit = num(l.credit_amount) ?? 0
const amount = debit > 0 ? `D ${fmtAmount(debit)}` : `K ${fmtAmount(credit)}`
return `${account} ${amount}`
}
function fmtAmount(value: number): string {
return value.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function rattelseEvent(
row: RattelseRow,
entryById: Map<string, EntryRow>,
): RawBehandlingshistorikEvent {
const entry = entryById.get(row.journal_entry_id)
const details: string[] = []
if (row.rattelse_type === 'metadata') {
if (!same(row.old_description, row.new_description)) {
details.push(`Beskrivning: ${fmtValue(row.old_description)} → ${fmtValue(row.new_description)}`)
}
if (!same(row.old_entry_date, row.new_entry_date)) {
details.push(`Datum: ${fmtValue(row.old_entry_date)} → ${fmtValue(row.new_entry_date)}`)
}
} else {
const struck = Array.isArray(row.struck_lines) ? row.struck_lines : []
const added = Array.isArray(row.added_lines) ? row.added_lines : []
if (struck.length > 0) details.push(`Strukna rader (${struck.length}): ${struck.map(describeLine).join('; ')}`)
if (added.length > 0) details.push(`Tillagda rader (${added.length}): ${added.map(describeLine).join('; ')}`)
}
return {
id: `rattelse:${row.id}`,
occurred_at: toIso(row.created_at)!,
category: 'verifikation',
code: row.rattelse_type === 'lines' ? 'journal_entry.corrected_lines' : 'journal_entry.corrected_metadata',
event:
row.rattelse_type === 'lines'
? 'Verifikation rättad i samma verifikat (rader)'
: 'Verifikation rättad i samma verifikat (text/datum)',
object: entry ? voucherLabel(entry.voucher_series, entry.voucher_number) : null,
actor: { type: 'user', user_id: row.actor, actor_label: null },
details,
source: 'rattelse_log',
count: 1,
}
}
function baseAuditActor(row: AuditLogEntry): RawActor {
return {
type: normaliseActorType(row.actor_type),
user_id: row.user_id ?? row.actor_id ?? null,
actor_label: row.actor_label ?? null,
}
}
function auditEvent(
row: AuditLogEntry,
fields: {
category: BehandlingshistorikCategory
code: string
event: string
object: string | null
details?: string[]
actor?: RawActor
},
): RawBehandlingshistorikEvent {
return {
id: `audit:${row.id}`,
occurred_at: toIso(row.created_at)!,
category: fields.category,
code: fields.code,
event: fields.event,
object: fields.object,
actor: fields.actor ?? baseAuditActor(row),
details: fields.details ?? [],
source: 'audit_log',
count: 1,
}
}
function journalEntryAuditEvent(
row: AuditLogEntry,
ctx: NormaliseContext,
): RawBehandlingshistorikEvent | null {
const oldState = row.old_state
const newState = row.new_state
const state = newState ?? oldState
const object = voucherLabel(state?.voucher_series, state?.voucher_number)
switch (row.action) {
case 'COMMIT':
// The bokföringspost itself is emitted from journal_entries (complete,
// incl. entries predating the audit log). Nothing to add here.
return null
case 'REVERSE':
return auditEvent(row, {
category: 'verifikation',
code: 'journal_entry.reversed',
event: 'Verifikation makulerad (storno)',
object,
details: ['Bokförd verifikation vänd med en vändningsverifikation'],
})
case 'DELETE':
if (!isBookedStatus(oldState?.status)) return null
return auditEvent(row, {
category: 'verifikation',
code: 'journal_entry.deleted',
event: 'Bokförd verifikation raderad',
object,
details: [`Datum: ${fmtValue(oldState?.entry_date)}`, `Text: ${fmtValue(oldState?.description)}`],
})
case 'RESET_SNAPSHOT': {
// reset_fiscal_year archives the full verifikat content (accounts,
// amounts, line text) in a company-scoped row before deleting; the
// trigger's DELETE row that follows carries only the header.
const rawLines = oldState?.lines
const lineDetails = Array.isArray(rawLines)
? rawLines.map((raw) => {
const line = raw as Record<string, unknown>
return `${str(line.account_number) ?? '?'}: debet ${fmtValue(line.debit_amount)}, kredit ${fmtValue(line.credit_amount)}`
})
: []
return auditEvent(row, {
category: 'verifikation',
code: 'journal_entry.reset_snapshot',
event: 'Verifikationsinnehåll arkiverat inför nollställning',
object,
details: [
`Datum: ${fmtValue(oldState?.entry_date)}`,
`Text: ${fmtValue(oldState?.description)}`,
...lineDetails,
],
})
}
case 'COMMITTED_AT_OVERRIDE':
return auditEvent(row, {
category: 'verifikation',
code: 'journal_entry.committed_at_override',
event: 'Registreringstidpunkt förinställd av systemkörning',
object: object ?? (row.record_id ? `Verifikat ${row.record_id.slice(0, 8)}` : null),
details: [
`Förinställd tidpunkt: ${fmtValue(newState?.preset_committed_at)}`,
`Verklig tidpunkt: ${fmtValue(newState?.wall_clock)}`,
`Databasroll: ${fmtValue(newState?.jwt_role)}`,
],
})
case 'UPDATE': {
if (!isBookedStatus(oldState?.status) && !isBookedStatus(newState?.status)) return null
const { lines, keys } = diffFields(oldState, newState, JOURNAL_ENTRY_FIELDS)
if (lines.length === 0) return null
// A metadata rättelse writes both a rättelse-log row (true actor) and the
// trigger's UPDATE row (entry owner as user_id). Keep the rättelse row.
const onlyRattelseKeys = keys.every((k) => k === 'description' || k === 'entry_date')
if (onlyRattelseKeys && row.record_id) {
const stamps = ctx.rattelseMetadataAt.get(row.record_id) ?? []
const at = toMs(row.created_at)
if (stamps.some((s) => Math.abs(s - at) <= RATTELSE_DUPLICATE_WINDOW_MS)) return null
}
return auditEvent(row, {
category: 'verifikation',
code: 'journal_entry.updated',
event: 'Verifikation ändrad',
object,
details: lines,
})
}
default:
return null
}
}
function accountAuditEvent(row: AuditLogEntry): RawBehandlingshistorikEvent | null {
const state = row.new_state ?? row.old_state
const number = str(state?.account_number)
const name = str(state?.account_name)
const object = number ? `${number}${name ? ` ${name}` : ''}` : null
switch (row.action) {
case 'INSERT': {
const details: string[] = []
const type = str(state?.account_type)
if (type) details.push(`Typ: ${type}`)
const vat = str(state?.default_vat_code)
if (vat) details.push(`Momskod: ${vat}`)
return auditEvent(row, { category: 'kontoplan', code: 'account.created', event: 'Konto tillagt', object, details })
}
case 'UPDATE': {
const { lines } = diffFields(row.old_state, row.new_state, ACCOUNT_FIELDS)
if (lines.length === 0) return null
return auditEvent(row, { category: 'kontoplan', code: 'account.updated', event: 'Konto ändrat', object, details: lines })
}
case 'DELETE':
return auditEvent(row, { category: 'kontoplan', code: 'account.deleted', event: 'Konto borttaget', object })
default:
return null
}
}
function settingsAuditEvent(row: AuditLogEntry): RawBehandlingshistorikEvent | null {
switch (row.action) {
case 'INSERT': {
const s = row.new_state
const details: string[] = []
if (s?.entity_type) details.push(`Företagsform: ${fmtValue(s.entity_type, 'entity_type')}`)
if (s?.accounting_method) details.push(`Redovisningsmetod: ${fmtValue(s.accounting_method, 'accounting_method')}`)
if (s?.moms_period) details.push(`Momsperiod: ${fmtValue(s.moms_period, 'moms_period')}`)
return auditEvent(row, {
category: 'installningar',
code: 'settings.created',
event: 'Företagsinställningar skapade',
object: str(s?.company_name),
details,
})
}
case 'UPDATE': {
const { lines } = diffFields(row.old_state, row.new_state, SETTINGS_FIELDS)
if (lines.length === 0) return null
return auditEvent(row, {
category: 'installningar',
code: 'settings.updated',
event: 'Företagsinställningar ändrade',
object: null,
details: lines,
})
}
case 'DELETE':
return auditEvent(row, {
category: 'installningar',
code: 'settings.deleted',
event: 'Företagsinställningar raderade',
object: str(row.old_state?.company_name),
})
default:
return null
}
}
function periodAuditEvent(row: AuditLogEntry): RawBehandlingshistorikEvent | null {
const state = row.new_state ?? row.old_state
const name = str(state?.name)
const span =
str(state?.period_start) && str(state?.period_end)
? `${state?.period_start} till ${state?.period_end}`
: null
const object = name ?? span
switch (row.action) {
case 'INSERT':
return auditEvent(row, {
category: 'period',
code: 'period.created',
event: 'Räkenskapsår skapat',
object,
details: span ? [span] : [],
})
case 'LOCK_PERIOD':
return auditEvent(row, { category: 'period', code: 'period.locked', event: 'Räkenskapsår låst', object })
case 'CLOSE_PERIOD':
return auditEvent(row, {
category: 'period',
code: 'period.closed',
event: 'Räkenskapsår stängt (bokslut)',
object,
})
case 'DELETE':
return auditEvent(row, { category: 'period', code: 'period.deleted', event: 'Räkenskapsår raderat', object })
case 'UPDATE': {
const oldState = row.old_state
const newState = row.new_state
// App-written unlock rows carry only { locked_at } in both states.
if (oldState?.locked_at && !newState?.locked_at) {
return auditEvent(row, {
category: 'period',
code: 'period.unlocked',
event: 'Räkenskapsår upplåst',
object: object ?? (row.description?.replace(/^Period unlocked: /, '') || null),
})
}
if (newState?.closed_externally && !oldState?.closed_externally) {
return auditEvent(row, {
category: 'period',
code: 'period.closed_externally',
event: 'Räkenskapsår markerat som stängt i tidigare system',
object: object ?? (row.description?.replace(/^Period marked as closed in previous system: /, '') || null),
})
}
if (!oldState?.locked_at && newState?.locked_at) {
return auditEvent(row, { category: 'period', code: 'period.locked', event: 'Räkenskapsår låst', object })
}
if (!oldState?.is_closed && newState?.is_closed) {
return auditEvent(row, {
category: 'period',
code: 'period.closed',
event: 'Räkenskapsår stängt (bokslut)',
object,
})
}
const { lines } = diffFields(oldState, newState, PERIOD_FIELDS)
if (lines.length === 0) return null
return auditEvent(row, {
category: 'period',
code: 'period.updated',
event: 'Räkenskapsår ändrat',
object,
details: lines,
})
}
default:
return null
}
}
function apiKeyAuditEvent(row: AuditLogEntry): RawBehandlingshistorikEvent | null {
const state = row.new_state ?? row.old_state
const object = str(state?.name) ?? str(state?.key_prefix) ?? null
switch (row.action) {
case 'INSERT': {
const details: string[] = []
const scopes = state?.scopes
if (Array.isArray(scopes) && scopes.length > 0) details.push(`Behörigheter: ${fmtValue(scopes)}`)
if (state?.expires_at) details.push(`Giltig till: ${fmtValue(state.expires_at)}`)
return auditEvent(row, { category: 'atkomst', code: 'api_key.created', event: 'API-nyckel skapad', object, details })
}
case 'UPDATE': {
if (!row.old_state?.revoked_at && row.new_state?.revoked_at) {
return auditEvent(row, { category: 'atkomst', code: 'api_key.revoked', event: 'API-nyckel återkallad', object })
}
const { lines } = diffFields(row.old_state, row.new_state, API_KEY_FIELDS)
if (lines.length === 0) return null
return auditEvent(row, { category: 'atkomst', code: 'api_key.updated', event: 'API-nyckel ändrad', object, details: lines })
}
case 'DELETE':
return auditEvent(row, { category: 'atkomst', code: 'api_key.deleted', event: 'API-nyckel raderad', object })
default:
return null
}
}
function genericAuditEvent(
row: AuditLogEntry,
opts: { category: BehandlingshistorikCategory; codePrefix: string; noun: string; fields: Record<string, string>; objectKeys: string[] },
): RawBehandlingshistorikEvent | null {
const state = row.new_state ?? row.old_state
let object: string | null = null
for (const key of opts.objectKeys) {
const v = str(state?.[key])
if (v) {
object = object ? `${object} ${v}` : v
}
}
switch (row.action) {
case 'INSERT':
return auditEvent(row, { category: opts.category, code: `${opts.codePrefix}.created`, event: `${opts.noun} skapad`, object })
case 'UPDATE': {
const { lines } = diffFields(row.old_state, row.new_state, opts.fields)
if (lines.length === 0) return null
return auditEvent(row, { category: opts.category, code: `${opts.codePrefix}.updated`, event: `${opts.noun} ändrad`, object, details: lines })
}
case 'DELETE':
return auditEvent(row, { category: opts.category, code: `${opts.codePrefix}.deleted`, event: `${opts.noun} borttagen`, object })
default:
return null
}
}
function globalActionEvent(row: AuditLogEntry): RawBehandlingshistorikEvent | null {
const labels: Record<string, { code: string; event: string }> = {
SECURITY_EVENT: { code: 'security.event', event: 'Säkerhetshändelse' },
INTEGRITY_FAILURE: { code: 'integrity.failure', event: 'Integritetskontroll misslyckades' },
RETENTION_BLOCK: { code: 'retention.blocked', event: 'Radering stoppad av arkiveringskravet' },
DOCUMENT_DELETE_BLOCKED: { code: 'document.delete_blocked', event: 'Radering av underlag stoppad' },
}
const meta = labels[row.action]
if (!meta) return null
return auditEvent(row, {
category: 'ovrigt',
code: meta.code,
event: meta.event,
object: row.table_name ?? null,
details: row.description ? [row.description] : [],
})
}
/** One audit_log row → zero or one behandlingshistorik event. Exported for tests. */
export function auditRowToEvent(
row: AuditLogEntry,
ctx: NormaliseContext = { rattelseMetadataAt: new Map(), entryById: new Map() },
): RawBehandlingshistorikEvent | null {
if ((GLOBAL_ACTIONS as readonly string[]).includes(row.action)) return globalActionEvent(row)
switch (row.table_name) {
case 'journal_entries':
return journalEntryAuditEvent(row, ctx)
case 'chart_of_accounts':
return accountAuditEvent(row)
case 'company_settings':
return settingsAuditEvent(row)
case 'fiscal_periods':
return periodAuditEvent(row)
case 'api_keys':
return apiKeyAuditEvent(row)
case 'dimensions':
return genericAuditEvent(row, {
category: 'installningar',
codePrefix: 'dimension',
noun: 'Dimension',
fields: DIMENSION_FIELDS,
objectKeys: ['name'],
})
case 'dimension_values':
return genericAuditEvent(row, {
category: 'installningar',
codePrefix: 'dimension_value',
noun: 'Dimensionsvärde',
fields: DIMENSION_FIELDS,
objectKeys: ['code', 'name'],
})
case 'account_dimension_rules':
return genericAuditEvent(row, {
category: 'installningar',
codePrefix: 'dimension_rule',
noun: 'Dimensionsregel',
fields: { requirement: 'Krav', account_from: 'Konto från', account_to: 'Konto till' },
objectKeys: ['account_from', 'account_to'],
})
case 'accrual_schedules':
return genericAuditEvent(row, {
category: 'installningar',
codePrefix: 'accrual_schedule',
noun: 'Periodiseringsplan',
fields: ACCRUAL_FIELDS,
objectKeys: ['description'],
})
case 'document_attachments':
if (row.action !== 'DELETE') return null
return auditEvent(row, {
category: 'ovrigt',
code: 'document.deleted',
event: 'Underlag borttaget',
object: str(row.old_state?.file_name),
})
// Behandlingsregler (BFNAR 2013:2 p. 9.9 / 9.16 second paragraph).
case 'mapping_rules':
return genericAuditEvent(row, {
category: 'installningar',
codePrefix: 'mapping_rule',
noun: 'Konteringsregel',
fields: MAPPING_RULE_FIELDS,
objectKeys: ['rule_name'],
})
case 'categorization_templates':
return genericAuditEvent(row, {
category: 'installningar',
codePrefix: 'categorization_template',
noun: 'Konteringsmall för motpart',
fields: CATEGORIZATION_TEMPLATE_FIELDS,
objectKeys: ['counterparty_name'],
})
case 'booking_template_library':
return genericAuditEvent(row, {
category: 'installningar',
codePrefix: 'booking_template',
noun: 'Konteringsmall',
fields: BOOKING_TEMPLATE_FIELDS,
objectKeys: ['name'],
})
case 'cash_accounts':
return genericAuditEvent(row, {
category: 'installningar',
codePrefix: 'cash_account',
noun: 'Bankkonto',
fields: CASH_ACCOUNT_FIELDS,
objectKeys: ['name', 'ledger_account'],
})
case 'salary_payroll_config':
return payrollConfigAuditEvent(row)
// The import tables emit their own events from the rows themselves; the
// audit trail only adds what the rows can no longer show: a deletion.
case 'sie_imports':
if (row.action !== 'DELETE') return null
return auditEvent(row, {
category: 'import',
code: 'sie_import.deleted',
event: 'SIE-importlogg raderad',
object: str(row.old_state?.filename),
})
case 'bank_file_imports':
if (row.action !== 'DELETE') return null
return auditEvent(row, {
category: 'import',
code: 'bank_file_import.deleted',
event: 'Bankfilsimport raderad',
object: str(row.old_state?.filename),
})
default:
return null
}
}
/**
* Statutory payroll constants (arbetsgivaravgifter, prisbasbelopp, ...):
* exactly BFN's "procentsats för automatkontering av sociala avgifter"
* example. Global rows (no company), one per config year.
*/
function payrollConfigAuditEvent(row: AuditLogEntry): RawBehandlingshistorikEvent | null {
const state = row.new_state ?? row.old_state
const year = state?.config_year
const object = year !== undefined && year !== null ? `Löneår ${String(year)}` : null
const actor: RawActor = {
type: row.actor_type && row.actor_type !== 'user' ? normaliseActorType(row.actor_type) : row.user_id ? 'user' : 'system',
user_id: row.user_id ?? null,
actor_label: row.actor_label ?? null,
}
switch (row.action) {
case 'INSERT':
return auditEvent(row, {
category: 'installningar',
code: 'payroll_config.created',
event: 'Lönekonstanter tillagda (arbetsgivaravgifter, basbelopp, schabloner)',
object,
actor,
})
case 'UPDATE': {
const keys = Object.keys({ ...(row.old_state ?? {}), ...(row.new_state ?? {}) })
.filter((k) => !PAYROLL_CONFIG_EXCLUDED_KEYS.has(k))
.sort()
const labels = Object.fromEntries(keys.map((k) => [k, k]))
const { lines } = diffFields(row.old_state, row.new_state, labels)
if (lines.length === 0) return null
return auditEvent(row, {
category: 'installningar',
code: 'payroll_config.updated',
event: 'Lönekonstanter ändrade',
object,
details: lines,
actor,
})
}
case 'DELETE':
return auditEvent(row, {
category: 'installningar',
code: 'payroll_config.deleted',
event: 'Lönekonstanter borttagna',
object,
actor,
})
default:
return null
}
}
function migrationResetEvent(row: MigrationResetRow, companyId: string): RawBehandlingshistorikEvent {
const isSource = row.source_company_id === companyId
const details: string[] = []
if (row.reason) details.push(`Skäl: ${truncate(row.reason)}`)
if (row.source_counts && typeof row.source_counts === 'object') {
const parts = Object.entries(row.source_counts)
.filter(([, v]) => typeof v === 'number')
.map(([k, v]) => `${k}: ${v}`)
if (parts.length > 0) details.push(`Omfattning: ${truncate(parts.join(', '))}`)
}
return {
id: `reset:${row.id}`,
occurred_at: toIso(row.created_at)!,
category: 'import',
code: isSource ? 'migration.reset_archived' : 'migration.reset_started',
event: isSource
? 'Bokföringen arkiverad inför ny migrering'
: 'Nytt bokföringsunderlag startat efter återställning',
object: null,
actor: { type: 'user', user_id: row.actor_id, actor_label: null },
details,
source: 'migration_reset',
count: 1,
}
}
function sieImportEvents(row: SieImportRow): RawBehandlingshistorikEvent[] {
const events: RawBehandlingshistorikEvent[] = []
const actor: RawActor = { type: 'user', user_id: row.user_id, actor_label: null }
const details: string[] = []
if (row.sie_type !== null && row.sie_type !== undefined) details.push(`Filtyp: SIE${row.sie_type}`)
if (row.fiscal_year_start && row.fiscal_year_end) details.push(`Räkenskapsår: ${row.fiscal_year_start} till ${row.fiscal_year_end}`)
if (typeof row.accounts_count === 'number' || typeof row.transactions_count === 'number') {
details.push(`${row.accounts_count ?? 0} konton, ${row.transactions_count ?? 0} transaktioner`)
}
// undo_sie_import sets status = 'undone' + replaced_at: the import did
// complete, and was later reversed (the entry deletions carry their own
// audit rows).
const undone = row.status === 'undone'
const completed = row.status === 'completed' || undone
const failed = row.status === 'failed'
events.push({
id: `sie:${row.id}`,
occurred_at: toIso(completed ? (row.imported_at ?? row.created_at) : row.created_at)!,
category: 'import',
code: completed ? 'sie_import.completed' : failed ? 'sie_import.failed' : 'sie_import.started',
event: completed ? 'SIE-fil importerad' : failed ? 'SIE-import misslyckades' : 'SIE-import påbörjad',
object: row.filename,
actor,
details: failed && row.error_message ? [...details, `Fel: ${truncate(row.error_message)}`] : details,
source: 'sie_import',
count: 1,
})
if (row.replaced_at) {
events.push({
id: `sie:${row.id}:replaced`,
occurred_at: toIso(row.replaced_at)!,
category: 'import',
code: undone ? 'sie_import.undone' : 'sie_import.replaced',
event: undone ? 'SIE-import ångrad (importerade verifikationer raderade)' : 'SIE-import ersatt av ny import',
object: row.filename,
actor,
details: [],
source: 'sie_import',
count: 1,
})
}
return events
}
function bankFileImportEvent(row: BankFileImportRow): RawBehandlingshistorikEvent {
const failed = row.status === 'failed'
const details: string[] = []
if (row.file_format) details.push(`Format: ${row.file_format}`)
if (typeof row.transaction_count === 'number') {
details.push(
`${row.imported_count ?? 0} av ${row.transaction_count} transaktioner importerade` +
(row.duplicate_count ? `, ${row.duplicate_count} dubbletter` : ''),
)
}
if (row.date_from && row.date_to) details.push(`Kontoutdrag: ${row.date_from} till ${row.date_to}`)
if (failed && row.error_message) details.push(`Fel: ${truncate(row.error_message)}`)
return {
id: `bankfile:${row.id}`,
occurred_at: toIso(row.created_at)!,
category: 'import',
code: failed ? 'bank_file_import.failed' : 'bank_file_import.completed',
event: failed ? 'Bankfilsimport misslyckades' : 'Bankfil importerad',
object: row.filename,
actor: { type: 'user', user_id: row.user_id, actor_label: null },
details,
source: 'bank_file_import',
count: 1,
}
}
/** A program version first seen in production (app_releases): p. 9.16 "nya programversioner". */
export function appReleaseEvent(row: AppReleaseRow): RawBehandlingshistorikEvent {
return {
id: `release:${row.version}`,
occurred_at: toIso(row.first_seen_at)!,
category: 'ovrigt',
code: 'system.release',
event: 'Ny programversion i drift',
object: row.version,
actor: { type: 'system', user_id: null, actor_label: null },
details: [row.source === 'runtime' ? 'Registrerad av systemet när versionen började svara' : `Källa: ${row.source}`],
source: 'audit_log',
count: 1,
}
}
const STOCKHOLM_DAY = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Stockholm',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
/**
* Program versions, rolled up per calendar day.
*
* A single event per version is not viable: main takes ~570 merges a month, so
* a fiscal year is on the order of 7 000 deploys, which alone would exceed the
* PDF's 4 000-event guard and bury the ~400 events a real company's year
* actually contains. The statutory unit is the date ("när dessa förändringar
* infördes", p. 9.16), not the build id, and the qualifier in the same sentence
* is "förändringar ... som påverkar bokföringsposternas behandling", which a
* deploy list cannot distinguish anyway. So the day is the event and the build
* ids are its detail; app_releases keeps the per-version truth for anyone who
* needs to go deeper.
*/
export function appReleaseEvents(rows: AppReleaseRow[]): RawBehandlingshistorikEvent[] {
const byDay = new Map<string, AppReleaseRow[]>()
for (const row of rows) {
const iso = toIso(row.first_seen_at)
if (!iso) continue
const day = STOCKHOLM_DAY.format(new Date(iso))
const bucket = byDay.get(day)
if (bucket) bucket.push(row)
else byDay.set(day, [row])
}
const out: RawBehandlingshistorikEvent[] = []
for (const [day, group] of byDay) {
if (group.length === 1) {
out.push(appReleaseEvent(group[0]))
continue
}
// Every build id, not the usual five-plus-"och N till": the point of the
// entry is that an auditor can reconstruct which versions ran that day, and
// a truncated list defeats it. A day is bounded by the deploy rate (~19),
// so this stays one readable cell.
const versions = group.map((r) => r.version)
out.push({
id: `release:${day}:bulk`,
occurred_at: toIso(group[0].first_seen_at)!,
category: 'ovrigt',
code: 'system.release.bulk',
event: 'Nya programversioner i drift',
object: `${versions.length} versioner`,
actor: { type: 'system', user_id: null, actor_label: null },
details: [versions.join(', ')],
source: 'audit_log',
count: versions.length,
})
}
return out
}
/**
* Audit rows for the global tables (no company_id): only reachable with the
* service role, which the route supplies. Windowed on created_at.
*/
async function fetchGlobalAuditRows(
client: Pick<SupabaseClient, 'from'>,
window: { fromTs: string; toTs: string },
): Promise<AuditLogEntry[]> {
return fetchAllRows<AuditLogEntry>(({ from, to }) =>
client
.from('audit_log')
.select('*')
.is('company_id', null)
.in('table_name', [...GLOBAL_AUDITED_TABLES])
.gte('created_at', window.fromTs)
.lte('created_at', window.toTs)
.order('created_at', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
)
}
// ============================================================
// Ordering and burst collapse
// ============================================================
const SOURCE_ORDER: Record<BehandlingshistorikSource, number> = {
journal_entries: 0,
audit_log: 1,
rattelse_log: 2,
migration_reset: 3,
sie_import: 4,
bank_file_import: 5,
}
export function sortEvents<T extends { occurred_at: string; source: BehandlingshistorikSource; id: string }>(
events: T[],
): T[] {
return [...events].sort((a, b) => {
const ta = toMs(a.occurred_at)
const tb = toMs(b.occurred_at)
if (ta !== tb) return ta - tb
const sa = SOURCE_ORDER[a.source]
const sb = SOURCE_ORDER[b.source]
if (sa !== sb) return sa - sb
return a.id.localeCompare(b.id)
})
}
export interface CollapseOptions {
/** Runs shorter than this are kept as individual events. */
minSize?: number
/** Max gap between consecutive rows of one run. */
gapMs?: number
}
/**
* Chart-of-accounts seeding and bulk maintenance write one audit row per
* account (a BAS kontoplan is ~1 000 rows), and a receipt clean-up deletes
* many underlag at once. Consecutive events of a collapsible code by the same
* actor within a short gap collapse into one summary event ("Kontoplan
* upplagd: 41 konton") so the report reads as what happened, not as a wall of
* rows. Input must already be sorted by occurred_at.
*/
export function collapseBursts(
events: RawBehandlingshistorikEvent[],
options: CollapseOptions = {},
): RawBehandlingshistorikEvent[] {
const gapMs = options.gapMs ?? 120_000
const out: RawBehandlingshistorikEvent[] = []
let run: RawBehandlingshistorikEvent[] = []
const flush = () => {
if (run.length === 0) return
const rule = COLLAPSIBLE[run[0].code]
const minSize = options.minSize ?? rule.minSize
if (run.length < minSize) {
out.push(...run)
} else {
out.push(summariseRun(run, rule))
}
run = []
}
for (const ev of events) {
if (!COLLAPSIBLE[ev.code]) {
flush()
out.push(ev)
continue
}
const prev = run[run.length - 1]
const continues =
prev &&
prev.code === ev.code &&
actorKey(prev.actor) === actorKey(ev.actor) &&
toMs(ev.occurred_at) - toMs(prev.occurred_at) <= gapMs
if (!continues) flush()
run.push(ev)
}
flush()
return out
}
/** @deprecated name kept for readers of the first revision; use collapseBursts. */
export const collapseAccountBursts = collapseBursts
const MAX_LISTED_OBJECTS = 5
function summariseRun(run: RawBehandlingshistorikEvent[], rule: CollapseRule): RawBehandlingshistorikEvent {
const first = run[0]
const details: string[] = []
if (rule.mode === 'accounts') {
const numbers = run
.map((e) => (e.object ? e.object.split(' ')[0] : ''))
.filter((n) => n.length > 0)
.sort()
if (numbers.length > 0) details.push(`Konton ${numbers[0]} till ${numbers[numbers.length - 1]}`)
if (first.code === 'account.updated') {
const changed = new Set<string>()
for (const e of run) for (const d of e.details) changed.add(d.split(':')[0])
if (changed.size > 0) details.push(`Ändrade fält: ${[...changed].join(', ')}`)
}
} else {
const names = [...new Set(run.map((e) => e.object).filter((o): o is string => !!o))]
if (names.length > 0) {
const listed = names.slice(0, MAX_LISTED_OBJECTS).join(', ')
const rest = names.length - MAX_LISTED_OBJECTS
details.push(rest > 0 ? `${listed} och ${rest} till` : listed)
}
}
return {
id: `${first.id}:bulk`,
occurred_at: first.occurred_at,
category: rule.category,
code: `${first.code}.bulk`,
event: rule.event,
object: `${run.length} ${rule.noun}`,
actor: first.actor,
details,
source: 'audit_log',
count: run.length,
}
}
// ============================================================
// Fetchers
// ============================================================
const ID_CHUNK = 200
function chunk<T>(items: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size))
return out
}
async function fetchPeriod(supabase: SupabaseClient, companyId: string, periodId: string): Promise<PeriodRow | null> {
const { data, error } = await supabase
.from('fiscal_periods')
.select('id, name, period_start, period_end')
.eq('id', periodId)
.eq('company_id', companyId)
.maybeSingle()
if (error) throw new Error(`Failed to fetch fiscal period: ${error.message}`)
return (data as PeriodRow | null) ?? null
}
async function fetchCompany(supabase: SupabaseClient, companyId: string): Promise<{ name: string; org_number: string | null }> {
const { data } = await supabase
.from('company_settings')
.select('company_name, org_number')
.eq('company_id', companyId)
.maybeSingle()
const row = data as { company_name?: string | null; org_number?: string | null } | null
return { name: row?.company_name ?? '', org_number: row?.org_number ?? null }
}
async function fetchPeriodEntries(supabase: SupabaseClient, companyId: string, periodId: string): Promise<EntryRow[]> {
return fetchAllRows<EntryRow>(({ from, to }) =>
supabase
.from('journal_entries')
.select(
'id, voucher_series, voucher_number, entry_date, description, source_type, status, committed_at, user_id, committed_actor_type, committed_actor_label, commit_method, reverses_id, correction_of_id',
)
.eq('company_id', companyId)
.eq('fiscal_period_id', periodId)
.order('id', { ascending: true })
.range(from, to),
)
}
async function fetchAuditRows(
supabase: SupabaseClient,
companyId: string,
window: { fromTs: string; toTs: string },
recordIds: string[],
): Promise<AuditLogEntry[]> {
const byId = new Map<string, AuditLogEntry>()
const windowed = await fetchAllRows<AuditLogEntry>(({ from, to }) =>
supabase
.from('audit_log')
.select('*')
.eq('company_id', companyId)
.gte('created_at', window.fromTs)
.lte('created_at', window.toTs)
// Literal on purpose (not AUDIT_ROW_FILTER): the schema guard only
// resolves string literals here. A test pins the two to each other.
.or(
'table_name.in.(journal_entries,chart_of_accounts,company_settings,fiscal_periods,api_keys,dimensions,dimension_values,account_dimension_rules,accrual_schedules,document_attachments,mapping_rules,categorization_templates,booking_template_library,sie_imports,bank_file_imports,cash_accounts),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)',
)
.order('created_at', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
)
for (const row of windowed) byId.set(row.id, row)
for (const ids of chunk(recordIds, ID_CHUNK)) {
const rows = await fetchAllRows<AuditLogEntry>(({ from, to }) =>
supabase
.from('audit_log')
.select('*')
.eq('company_id', companyId)
.eq('table_name', 'journal_entries')
.in('record_id', ids)
.order('id', { ascending: true })
.range(from, to),
)
for (const row of rows) byId.set(row.id, row)
}
return [...byId.values()]
}
async function fetchRattelseRows(
supabase: SupabaseClient,
companyId: string,
window: { fromTs: string; toTs: string },
entryIds: string[],
): Promise<RattelseRow[]> {
const byId = new Map<string, RattelseRow>()
const windowed = await fetchAllRows<RattelseRow>(({ from, to }) =>
supabase
.from('journal_entry_rattelse_log')
.select('*')
.eq('company_id', companyId)
.gte('created_at', window.fromTs)
.lte('created_at', window.toTs)
.order('created_at', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
)
for (const row of windowed) byId.set(row.id, row)
for (const ids of chunk(entryIds, ID_CHUNK)) {
const rows = await fetchAllRows<RattelseRow>(({ from, to }) =>
supabase
.from('journal_entry_rattelse_log')
.select('*')
.eq('company_id', companyId)
.in('journal_entry_id', ids)
.order('id', { ascending: true })
.range(from, to),
)
for (const row of rows) byId.set(row.id, row)
}
return [...byId.values()]
}
async function fetchMigrationResets(supabase: SupabaseClient, companyId: string): Promise<MigrationResetRow[]> {
// Two equality queries instead of one `.or()`: the reset row is relevant to
// both the archived source company and its replacement, and the schema guard
// resolves plain column filters statically.
const byId = new Map<string, MigrationResetRow>()
const asSource = await supabase
.from('company_migration_resets')
.select('id, source_company_id, replacement_company_id, actor_id, reason, source_counts, created_at')
.eq('source_company_id', companyId)
.order('created_at', { ascending: true })
if (asSource.error) throw new Error(`Failed to fetch migration resets: ${asSource.error.message}`)
const asReplacement = await supabase
.from('company_migration_resets')
.select('id, source_company_id, replacement_company_id, actor_id, reason, source_counts, created_at')
.eq('replacement_company_id', companyId)
.order('created_at', { ascending: true })
if (asReplacement.error) throw new Error(`Failed to fetch migration resets: ${asReplacement.error.message}`)
for (const row of [
...((asSource.data as MigrationResetRow[] | null) ?? []),
...((asReplacement.data as MigrationResetRow[] | null) ?? []),
]) {
byId.set(row.id, row)
}
return [...byId.values()]
}
async function fetchSieImports(supabase: SupabaseClient, companyId: string): Promise<SieImportRow[]> {
return fetchAllRows<SieImportRow>(({ from, to }) =>
supabase
.from('sie_imports')
.select(
'id, user_id, filename, sie_type, fiscal_year_start, fiscal_year_end, accounts_count, transactions_count, status, error_message, imported_at, created_at, replaced_at',
)
.eq('company_id', companyId)
.order('id', { ascending: true })
.range(from, to),
)
}
async function fetchBankFileImports(supabase: SupabaseClient, companyId: string): Promise<BankFileImportRow[]> {
return fetchAllRows<BankFileImportRow>(({ from, to }) =>
supabase
.from('bank_file_imports')
.select(
'id, user_id, filename, file_format, transaction_count, imported_count, duplicate_count, status, error_message, date_from, date_to, created_at',
)
.eq('company_id', companyId)
.order('id', { ascending: true })
.range(from, to),
)
}
/**
* Resolve user ids to e-mail labels through `profiles`. `profiles` RLS is
* self-only, so the caller passes a service-role client; the lookup is scoped
* to exactly the ids that appear in the report.
*/
export async function resolveUserLabelsFromProfiles(
serviceClient: Pick<SupabaseClient, 'from'>,
userIds: string[],
): Promise<Map<string, string>> {
const labels = new Map<string, string>()
for (const ids of chunk(userIds, ID_CHUNK)) {
const { data } = await serviceClient.from('profiles').select('id, email, full_name').in('id', ids)
for (const row of (data as { id: string; email: string | null; full_name: string | null }[] | null) ?? []) {
const label = row.email || row.full_name
if (label) labels.set(row.id, label)
}
}
return labels
}
// ============================================================
// Generator
// ============================================================
export async function generateBehandlingshistorik(
supabase: SupabaseClient,
companyId: string,
params: BehandlingshistorikParams,
options: GenerateBehandlingshistorikOptions = {},
): Promise<BehandlingshistorikReport | null> {
const period = await fetchPeriod(supabase, companyId, params.periodId)
if (!period) return null
const company = await fetchCompany(supabase, companyId)
const mode: BehandlingshistorikReport['mode'] = params.fromDate || params.toDate ? 'date_range' : 'fiscal_year'
const from = params.fromDate ?? period.period_start
const to = params.toDate ?? period.period_end
const fromTs = `${from}T00:00:00.000Z`
const toTs = `${to}T23:59:59.999Z`
const fromMs = Date.parse(fromTs)
const toMsBound = Date.parse(toTs)
const window = { fromTs, toTs }
const entries = await fetchPeriodEntries(supabase, companyId, period.id)
const entryById = new Map(entries.map((e) => [e.id, e]))
const entryIds = entries.map((e) => e.id)
const unionIds = mode === 'fiscal_year' ? entryIds : []
const [auditRows, rattelseRows, resets, sieImports, bankImports, releases, globalAuditRows] = await Promise.all([
fetchAuditRows(supabase, companyId, window, unionIds),
fetchRattelseRows(supabase, companyId, window, unionIds),
fetchMigrationResets(supabase, companyId),
fetchSieImports(supabase, companyId),
fetchBankFileImports(supabase, companyId),
fetchAppReleases(supabase, window),
options.globalClient ? fetchGlobalAuditRows(options.globalClient, window) : Promise.resolve([] as AuditLogEntry[]),
])
const raw: RawBehandlingshistorikEvent[] = []
// System-wide changes (p. 9.16 second paragraph): program versions and the
// statutory payroll constants, dated by when they entered production.
raw.push(...appReleaseEvents(releases))
for (const row of globalAuditRows) {
const ev = auditRowToEvent(row)
if (ev) raw.push(ev)
}
// (a) bokföringsposter: from journal_entries, the complete source.
for (const entry of entries) {
if (mode === 'date_range' && !isWithin(entry.committed_at, fromMs, toMsBound)) continue
const ev = commitEventFromEntry(entry)
if (ev) raw.push(ev)
}
// Rättelser, with an index so the duplicate trigger row can be suppressed.
const rattelseMetadataAt = new Map<string, number[]>()
for (const row of rattelseRows) {
if (row.rattelse_type === 'metadata') {
const list = rattelseMetadataAt.get(row.journal_entry_id) ?? []
list.push(toMs(row.created_at))
rattelseMetadataAt.set(row.journal_entry_id, list)
}
raw.push(rattelseEvent(row, entryById))
}
const ctx: NormaliseContext = { rattelseMetadataAt, entryById }
for (const row of auditRows) {
const ev = auditRowToEvent(row, ctx)
if (ev) raw.push(ev)
}
// (b) imports and migrations: filtered to the window in code (small tables).
for (const row of resets) {
if (isWithin(row.created_at, fromMs, toMsBound)) raw.push(migrationResetEvent(row, companyId))
}
for (const row of sieImports) {
for (const ev of sieImportEvents(row)) {
if (isWithin(ev.occurred_at, fromMs, toMsBound)) raw.push(ev)
}
}
for (const row of bankImports) {
if (isWithin(row.created_at, fromMs, toMsBound)) raw.push(bankFileImportEvent(row))
}
let events = collapseBursts(sortEvents(raw))
if (params.categories && params.categories.length > 0) {
const wanted = new Set(params.categories)
events = events.filter((e) => wanted.has(e.category))
}
const userIds = [...new Set(events.map((e) => e.actor.user_id).filter((id): id is string => !!id))]
const userLabels = options.resolveUserLabels && userIds.length > 0
? await options.resolveUserLabels(userIds)
: new Map<string, string>()
const finalEvents = events.map((e) => finaliseEvent(e, userLabels))
const byCategory = Object.fromEntries(
BEHANDLINGSHISTORIK_CATEGORIES.map((c) => [c, 0]),
) as Record<BehandlingshistorikCategory, number>
for (const e of finalEvents) byCategory[e.category] += 1
return {
company,
period: { id: period.id, name: period.name, start: period.period_start, end: period.period_end },
range: { from, to },
mode,
generated_at: (options.now ?? new Date()).toISOString(),
app_version: options.appVersion ?? null,
total_events: finalEvents.length,
by_category: byCategory,
events: finalEvents,
category_filter: params.categories && params.categories.length > 0 ? [...params.categories] : null,
}
}
// ============================================================
// Export (xlsx / csv)
// ============================================================
const STOCKHOLM_FORMAT = new Intl.DateTimeFormat('sv-SE', {
timeZone: 'Europe/Stockholm',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
/** `2026-08-21 10:15:03` in Swedish local time. Falls back to the input when unparseable. */
export function formatStockholmTimestamp(iso: string): string {
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return STOCKHOLM_FORMAT.format(d).replace(',', '')
}
export function behandlingshistorikSheetSpecs(report: BehandlingshistorikReport): SheetSpec<unknown>[] {
const events: SheetSpec<BehandlingshistorikEvent> = {
name: 'Behandlingshistorik',
columns: [
textColumn('Tidpunkt'),
textColumn('Kategori'),
textColumn('Händelse'),
textColumn('Objekt'),
textColumn('Utförd av'),
textColumn('Detaljer'),
textColumn('Kod'),
integerColumn('Antal'),
],
rows: report.events,
mapRow: (e) => [
formatStockholmTimestamp(e.occurred_at),
BEHANDLINGSHISTORIK_CATEGORY_LABELS[e.category],
e.event,
e.object,
e.actor.label,
e.details.join(' | '),
e.code,
e.count,
],
}
const meta: SheetSpec<[string, string]> = {
name: 'Rapport',
columns: [textColumn('Uppgift'), textColumn('Värde')],
rows: [
['Rapport', 'Behandlingshistorik (BFL 5 kap. 11 §, BFNAR 2013:2 p. 9.16)'],
['Företag', report.company.name],
['Organisationsnummer', report.company.org_number ?? ''],
['Räkenskapsår', `${report.period.name} (${report.period.start} till ${report.period.end})`],
['Urval', report.mode === 'fiscal_year' ? 'Hela räkenskapsåret' : `${report.range.from} till ${report.range.to}`],
['Antal händelser', String(report.total_events)],
['Genererad', formatStockholmTimestamp(report.generated_at)],
['Programversion', report.app_version ?? 'okänd'],
['Tidszon', 'Europe/Stockholm'],
],
mapRow: (r) => [r[0], r[1]],
}
return [events as SheetSpec<unknown>, meta as SheetSpec<unknown>]
}
export function buildBehandlingshistorikExport(
report: BehandlingshistorikReport,
format: 'xlsx' | 'csv',
): { buffer: Buffer; contentType: string; filename: string } {
const specs = behandlingshistorikSheetSpecs(report)
const date = report.mode === 'fiscal_year' ? report.period.end : report.range.to
if (format === 'csv') {
// CSV carries the first sheet only; the metadata sheet is xlsx-only.
const buf = reportToWorkbook([specs[0]], { bookType: 'csv' })
// SheetJS already emits a UTF-8 BOM for csv in current versions; only add
// one when it is missing so Excel never sees a doubled mark in cell A1.
const hasBom = buf.length >= 3 && buf[0] === 0xef && buf[1] === 0xbb && buf[2] === 0xbf
return {
buffer: hasBom ? buf : Buffer.concat([Buffer.from(UTF8_BOM, 'utf-8'), buf]),
contentType: 'text/csv; charset=utf-8',
filename: exportFilename('behandlingshistorik', report.company.name, date, 'csv'),
}
}
return {
buffer: reportToWorkbook(specs),
contentType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
filename: exportFilename('behandlingshistorik', report.company.name, date, 'xlsx'),
}
}