Files
accounted/lib/reports/behandlingshistorik.ts
T
MattssonandClaude Fable 5 79013cf092 feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883) (#1897)
* feat(bookkeeping): guarded fiscal-year reset + findable Angra import (#1883)

Two deliverables from the community report where a bad SIE test import
left no way out short of deleting the company:

A) Discoverability: the voucher list shows one attn line linking to
   /import?history=sie whenever the page contains import-sourced
   vouchers, and /import?history=sie deep-links straight into the
   fold-open SIE import history where per-import Angra already lives.

B) Reset of an UNLOCKED fiscal year regardless of how the entries
   arrived: new reset_fiscal_year RPC (same gnubok.allow_delete escape
   hatch as undo_sie_import; no enforcement trigger touched) behind
   GET/POST /api/bookkeeping/fiscal-periods/[id]/reset and a typed
   type-the-year-name confirmation dialog on the fiscal years settings
   list. Refuses on: locked/closed year, company lock date over any part
   of the year, executed year-end, arsredovisning state, later year
   depending on this year's UB, VAT-declared evidence (vat_settlement
   verifikat, SKV lock/submit audit rows, extension workflow keys, fail
   closed) and AGI-declared months. Entries referenced by RESTRICT/NO
   ACTION FKs abort the whole reset (all-or-nothing). Documents are
   detached, never deleted (BFL 7 kap); every delete is audit-logged
   plus one behandlingshistorik summary row.

Fixes #1883

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(bookkeeping): harden fiscal-year reset after skeptic review (#1883)

Blocking skeptic findings on PR #1897, one consolidated pass:

- New snapshot blocker cross_year_reference: an entry outside the year whose
  correction_of_id / reverses_id / reversed_by_id points into the year made
  the delete crash with an uncaught P0001 (immutability trigger refusing the
  ON DELETE SET NULL referential UPDATE) after an eligible:true preview, and
  silently severed draft chains. 12 such chains exist in prod today.
- New snapshot blocker rot_rut_state: a begaran om utbetalning that reached
  Skatteverket (submitted/paid/partially_paid/rejected) was silently
  unlinked via SET NULL, erasing the bokforing behind a filed and possibly
  decided myndighetsarende.
- Rakenskapsinformation preservation (BFL 7 kap): line-level trigger audit
  rows carry no company_id and header rows no amounts, so a reset destroyed
  konton/belopp with no company-readable trace. The RPC now archives the
  full content of every verifikat in company-scoped RESET_SNAPSHOT audit
  rows before deleting (action added to audit_log_action_check, NOT VALID),
  and behandlingshistorik renders them.
- Dimension registry lockstep on reset (mirrors undo_sie_import): flipped
  imports can never be undone again, so their dimensions/values would have
  been orphaned forever.
- EXCEPTION WHEN raise_exception now returns a typed
  FISCAL_YEAR_RESET_LINKED_ENTRIES envelope instead of a bare 500;
  gnubok.allow_delete is cleared before leaving the guarded block.
- Voucher-list attn line fires only for source_type 'import':
  opening_balance is also written by year-end closing and the manual IB
  flows, which mislabelled every year-2+ company as SIE-imported.
- /import?history=sie now scrolls the SIE history into view.
- Reset dialog copy (sv+en) discloses that linked invoices, payments and
  bank transactions become unbooked; new blocker strings in both locales.
- pg fixture fix: document_attachments seeded without company_id (23502);
  new pg tests for both blockers, RESET_SNAPSHOT rows and the lockstep.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 14:36:18 +02:00

1642 lines
59 KiB
TypeScript

import type { SupabaseClient } from '@supabase/supabase-js'
import type { AuditLogEntry } from '@/types'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
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
}
// ============================================================
// 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',
] 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),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',
}
const DIMENSION_FIELDS: Record<string, string> = {
name: 'Namn',
code: 'Kod',
is_active: 'Aktiv',
dimension_type: 'Typ',
sie_dimension_number: 'SIE-dimension',
}
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),
})
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`)
}
const completed = row.status === 'completed'
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: 'sie_import.replaced',
event: '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,
}
}
// ============================================================
// 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),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] = await Promise.all([
fetchAuditRows(supabase, companyId, window, unionIds),
fetchRattelseRows(supabase, companyId, window, unionIds),
fetchMigrationResets(supabase, companyId),
fetchSieImports(supabase, companyId),
fetchBankFileImports(supabase, companyId),
])
const raw: RawBehandlingshistorikEvent[] = []
// (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'),
}
}