26e29f47bc
* feat(company): ideell förening as a third legal form, behind a flag (#2072 step 1) Why the problem occurred: the legal form was modelled as a binary flag in ~300 files. `EntityType` was a two-member union, but nothing dispatched on it exhaustively: 28 sites defaulted `?? 'enskild_firma'` (invoice, categorize, match, stripe, invoice-inbox) or `?? 'aktiebolag'` (year-end, bokslut, MCP), and every form-dependent choice was an `=== 'aktiebolag' ? A : B` ternary. Widening the union compiled everywhere and changed nothing, so a förening would have booked as an enskild firma in the app and as an aktiebolag in bokslut and MCP, with no error anywhere. The lookup refused föreningar at the door (mapEntityType returned null), which is what the tester hit. What was removed or simplified: the silent defaults. One module, lib/company/entity-type.ts, now holds the list (ENTITY_TYPES), the parser (never defaults), the resolver (settings hint, then companies.entity_type, then throw) and `byEntityType`, whose Record arms make the compiler refuse the next widening until each site has an answer. The form-dependent facts (closing account, owner settlement account, calendar-year lock, default method, K1/K2 label, personnummer vs 16-prefix) live there once instead of in the ternaries. On the SQL side supported_entity_types() replaces four copies of the literal list in the create RPCs. Why this shape and not the proposed one: the tracker asked for the enum widening plus a chart; that alone was the dangerous version (compiles, books wrong). Bundling stiftelse was considered and dropped: identical plumbing but no chart block. Creation sits behind NEXT_PUBLIC_IDEELL_FORENING_ENABLED so the CHECK, RPCs and seed can ship now and the first partner is switched on without a migration; the flag goes when Phase 2 (packs, INK3, årsbokslut, Swish) lands on the tracker. Domain choices (DECISIONS.md 2026-09-08, verify with an accountant before Phase 2): result closes to 2069 with 2068 as prior-year carry; no owner accounts, member settlement on 2890; accrual default; brutet räkenskapsår allowed; K1 label for the 5 000 kr accrual threshold (BFNAR 2010:1); org number gets the 16 prefix. Migration 20260908110835 widens the three CHECK constraints, adds supported_entity_types(), re-creates the three create RPCs with the widened guard and adds the förening block to seed_chart_of_accounts. Applied to staging and covered by ideell-forening-entity-type.pg.test.ts. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * fix(company): close the förening paths the skeptic refuted (#2072) Five refutations from the /skeptic pass on 7a05c54d2, each fixed at the shared definition rather than the reported site: 1. Privately paid supplier invoices and the utlägg dialog resolved the owner account in lib/expenses/payer.ts with its own AB/EF ternary, so a förening member's invoice was built on 2893 and then refused by the expense-claim service (which already said 2890), burning an ankomstnummer. The helper now uses ownerSettlementAccount. 2. Booking templates substitute their `_ab` accounts only for an aktiebolag; the `private_expense` template kept its base 2013 for a förening. Template accounts now resolve through templateAccountForForm: EF base, AB override, förening base with owner accounts translated to 2890 (booking-templates.ts and proposal-lines.ts share it). 3. A VAT-registered förening with helårsmoms got no momsdeklaration deadline: the annual VAT rule bailed on anything but AB/EF. A förening is a juridisk person and follows the räkenskapsår schedule (SFL 26 kap 33 §), so the rule now keys on fiscalYearLockedToCalendar instead of the two literals; same in the MCP VAT report. 4. 2069 would have accumulated across years: the year-open omföring was AB-only with 2099/2098 hard-coded. planResultAppropriation now takes the pair from resultClosingAccounts (AB 2099 -> 2098, förening 2069 -> 2068) and skips forms with no carry (EF). 5. With the flag off, a registry lookup that returned "Ideell förening" was prefilled into the onboarding journey, the form picker was skipped and the create step answered "Ogiltig företagsform" with no way back. The journey, the BankID picker, the onboarding page and the MCP lookup now use mapSetupEntityType, which maps only creatable forms, so a flagged-off form falls through to the picker as before. Also: form picker keeps its AB-first order; tests for each fix. Part of #2072 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(migrations): move ideell förening migration after main's latest version (20260908143051) Two migrations landed on main after the branch forked; a lower version would be skipped by the merge-time apply. Staging history row renamed to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh * chore(skills): regenerate accounted-api reference for the widened entity_type enum Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PdGafpUA7jVV1oYjkwfQCh --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1973 lines
72 KiB
TypeScript
1973 lines
72 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',
|
|
// Which bank account customer invoices pay to, per currency (migration
|
|
// 20260904010000).
|
|
'invoice_payee_defaults',
|
|
] 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,invoice_payee_defaults),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', ideell_forening: 'Ideell förening' },
|
|
}
|
|
|
|
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',
|
|
// Payee fields (migration 20260904010000): what customer invoices print.
|
|
bank_name: 'Bank',
|
|
clearing_number: 'Clearingnummer',
|
|
account_number: 'Kontonummer',
|
|
bankgiro: 'Bankgiro',
|
|
plusgiro: 'Plusgiro',
|
|
swish: 'Swish',
|
|
iban: 'IBAN',
|
|
bic: 'BIC/SWIFT',
|
|
bank_code: 'Bankkod',
|
|
foreign_account_number: 'Kontonummer (utländskt)',
|
|
invoice_payee: 'Visas på kundfakturor',
|
|
}
|
|
|
|
const INVOICE_PAYEE_DEFAULT_FIELDS: Record<string, string> = {
|
|
cash_account_id: 'Bankkonto',
|
|
}
|
|
|
|
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 'invoice_payee_defaults':
|
|
return genericAuditEvent(row, {
|
|
category: 'installningar',
|
|
codePrefix: 'invoice_payee_default',
|
|
noun: 'Standardkonto för kundfakturor',
|
|
fields: INVOICE_PAYEE_DEFAULT_FIELDS,
|
|
// Both keys on every action: a created or deleted default must name
|
|
// the account, not just the currency.
|
|
objectKeys: ['currency', 'cash_account_id'],
|
|
})
|
|
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,invoice_payee_defaults),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'),
|
|
}
|
|
}
|