feat(reports): log behandlingsregler changes and program versions (BFNAR 2013:2 p. 9.16) (#2097)

* feat(reports): log behandlingsregler changes and program versions (BFNAR 2013:2 p. 9.16)

Part 3 of the behandlingshistorik series (#1787 report, #1790 PDF). BFNAR
2013:2 punkt 9.16 second paragraph requires the behandlingshistorik to record
"forandringar i bokforingssystemet som paverkar bokforingsposternas behandling
samt nar dessa forandringar infordes", and BFN's commentary names
behandlingsregler (automatkonteringar, fasta procentsatser) and new program
versions as the examples. Until now both changed without a trace.

Audit triggers on the behandlingsregler tables and the import logs:
mapping_rules, booking_template_library, categorization_templates,
salary_payroll_config, sie_imports, bank_file_imports. categorization_templates
learns on every booking (occurrence_count, confidence, last_seen_date), so
those telemetry-only updates are excluded by a WHEN clause the same way the
api_keys request counters are (20260721115701): only real rule changes are
logged. Measured against prod that is roughly 3 800 new audit rows a month
against an audit_log already taking 371 688, so about +1 %.

app_releases is an append-only log of program versions seen in production,
written by the runtime the first time a build answers a request. Vercel exposes
no build hook we can trust to write the row, so /api/version records it inside
after(): the handler returns synchronously and a floating promise could be
frozen before the insert lands, which is how a version log ends up silently
empty. The service client is constructed lazily so the constantly polled public
probe pays nothing once the module guard is set.

Program versions are rolled up per Swedish calendar day in the report. main
takes ~570 merges a month, so one event per version would be on the order of
7 000 a fiscal year: enough to trip the PDF's own 4 000-event guard and bury the
~400 events a real company's year contains. The statutory unit is the date, and
the same sentence qualifies the requirement to changes that affect processing,
which a deploy list cannot distinguish anyway. app_releases keeps the
per-version truth for anyone who needs to go deeper.

AuditLogEntry.user_id becomes string | null. The column is nullable and
write_audit_log() falls back to auth.uid(), which is NULL for a service-role or
global write; the company-less salary_payroll_config rows are the first that
routinely hit it, and the read model already coded for it.

Also restores the point citations the 2026-07-27 pass removed while the chapter
was unverified: it is kapitel 9, not kapitel 8 (which is arkivering), verified
against BFN's consolidated text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY

* test(pg): fix two fixture bugs in the behandlingshistorik trigger tests

pg-real caught both, and neither is in the migration: the inserts fail
before the trigger is reached.

mapping_rules.rule_type is constrained to mcc_code / merchant_name /
description_pattern / amount_threshold / combined; the test used
'merchant'.

booking_template_library's btl_insert policy requires
current_user_can_write() and company_id = current_active_company_id(),
so the authenticated insert needs a company_members row and a
user_preferences.active_company_id, the same setup
booking-template-hidden.pg.test.ts uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY

* test(pg): assert the booking-template audit row inside the user transaction

withUserContext always rolls back, so the audit row the trigger writes
is gone before an outside connection can see it. The trigger fires in
the same transaction as the write, so the assertion belongs there too.
The other cases in this file write on the pool (autocommit) and are
unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY

* fix(reports): name every build id in the per-day program-version entry

Raised by the compliance review on #2097: the roll-up listed five ids
and a count, which leaves an auditor unable to reconstruct which
versions ran that day. app_releases keeps the full record, but the
report is the surface anyone actually reads. A day is bounded by the
deploy rate (~19), so the full list stays one readable cell.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3P2hr19PhQuCoTSGoegcY

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-01 20:31:10 +02:00
committed by GitHub
co-authored by Claude Opus 5 Jakob Wennberg
parent 169e7eaf4e
commit a08bf51ced
24 changed files with 18637 additions and 40 deletions
@@ -91,9 +91,9 @@ Förenklad faktura (max 4000 SEK inkl moms) has reduced requirements.
- Kontantmetod: omsättning normalt < 3 MSEK
- Revisionspliktig (AB): minst 2 av 3: >3 anställda, >1.5 MSEK balansomslutning, >3 MSEK nettoomsättning (two consecutive years)
### System documentation (BFNAR 2013:2 kap 8)
Bokföringssystem must have:
1. Systemdokumentation: describes the system, how it works, its controls
2. Behandlingshistorik: log of changes, who did what, when
### System documentation (BFL 5 kap 11 §, BFNAR 2013:2 kap 9)
Bokföringssystem must have (kapitel 8 is arkivering; this is kapitel 9, verified against BFN's consolidated text 2026-08-21):
1. Systemdokumentation (p. 9.2-9.15): kontoplan, samlingsplan, arkivplan, verifikationsnummerserier, verifieringskedjor, behandlingsregler, informationsflöden
2. Behandlingshistorik (p. 9.16): every bokföringspost with registreringsdatum, and changes to the system that affect processing (kontoplan, behandlingsregler such as automatkonteringar and percentages, program versions) with dates; p. 9.15 asks the systemdokumentation to say where and how it is produced
Your software must produce or support both. This is not optional.
@@ -213,7 +213,7 @@ A company is "större" if it exceeds at least 2 of 3 for each of the last 2 year
## 12. Systemdokumentation and behandlingshistorik
### Systemdokumentation (BFNAR 2013:2 kap 8)
### Systemdokumentation (BFNAR 2013:2 kap 9, punkt 9.1-9.15; kap 8 is arkivering)
Must describe:
- The bokföringssystem and how it works
- The kontoplan used
@@ -223,8 +223,8 @@ Must describe:
- Backup routines
- Integration with other systems
### Behandlingshistorik
Must log:
### Behandlingshistorik (BFNAR 2013:2 punkt 9.16; BFL 5 kap 11 §)
Must log (and show registreringsdatum for every bokföringspost):
- Automated processing (batch jobs, automated bokföring)
- Changes to system settings that affect bokföring
- User actions (who booked what, when)
+2
View File
@@ -1423,6 +1423,8 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-09-01] multi_user seat gate enforced app-side via a NEW gated RPC (resolve_active_company_gated) instead of editing resolve_active_company/current_active_company_id: the zero-arg RPC and the RLS twin also run on self-hosted DBs where the paywall must never bite, and the app picks the gated overload only when isMultiUserEnforced(). RLS convergence rides the existing used_fallback write-back. company_capability_config deliberately does not apply to multi_user (no expiry to hang the 20-day grace on).
[2026-09-01] EU reverse-charge packs book directly on 4515/4535 instead of adding a 45xx D / 4598 K basbelopp pair (Anders' literal suggestion): same ruta 20/21 outcome, standard BAS practice for a template that owns the cost account anyway, and a 3-business-line pack would return null from convertLibraryToBookingTemplate and silently vanish from the transaction picker. The 4598 motkonto pattern remains the right tool only where the user's own cost account must be preserved (engine-generated bookings, supplier invoices).
[2026-09-01] Floating supplier-invoice underlag gets a standing daily reanchor cron (/api/documents/reanchor/cron) instead of another one-off repair migration: prod case 2026-08-28 (kontantmetod payment verifikat, doc eligible on every static condition, inline anchor silently did nothing, no log line recorded why) is the second time a hand-written sweep (20260727180000, 20260824150000) was needed; the inline anchor is best-effort by design, so the retry belongs in infrastructure. anchorSupplierInvoiceDocument also stops claiming success on a zero-row guarded update and logs its silent bail branches.
[2026-08-21] BFNAR 2013:2 chapter for systemdokumentation/behandlingshistorik verified against BFN's consolidated PDF (bfnar13-2-kons2) and the vägledning: it is kapitel 9 (punkt 9.1-9.17; behandlingshistorik = 9.16, where/how = 9.15), kapitel 8 is arkivering. Code comments, the compliance skill and the public systemdokumentation template now cite kap 9 / the points; this reverses the 2026-07-27 removal of the point citations, which was right to do while unverified.
[2026-09-01] app_releases is written by the runtime from /api/version inside after(), not by a deploy hook or a floating promise: Vercel exposes no build hook we can trust to write a row, the handler returns synchronously so a bare void could be frozen before the insert lands (a silently empty version log is worse than none), and the service client is now constructed lazily so the constantly-polled public probe pays nothing once the module guard is set.
[2026-09-01] MCP worked examples ship in the inputSchema for five tools only, not the top twenty (#2066): the tools/list ceiling is the binding constraint, not the writing. After #2089 reclaimed 3 763 tokens the policy in payload-size.bench.test.ts required ratcheting the ceiling down with it, so real headroom was 317 tokens, not the 4 300 the #2089 PR body implied. Ten examples cost 199, leaving ~118. Tools were picked by 30 days of mcp.tool_called crossed with the combinations the descriptions already warn about (account_override without vat_treatment, representation without deltagare, confirmed on a high-risk approval, and get_kpi_report where a caller sent `metric` 604 times to a tool whose only parameter is period_id). Examples are also surfaced in the unknown-parameter error, which costs zero catalog budget and reaches the caller that already failed; a test validates every example against its own schema with the same guard the server runs, because an example our boundary would reject teaches exactly the mistake it then punishes.
[2026-09-01] SKV connector instance wiring (PR6b-2): system (CCG/ombud) auth is deliberately NOT brokered; it stays on the direct path and fails SYSTEM_AUTH_FAILED on a credential-less self-host, because the org certificate and ombud grants are hosted-only. getSkatteverketEnvironment() hard-reports 'prod' in connector mode: the upstream env is resolved from HOSTED's config, and the instance's unset base URLs would otherwise show a Testmiljo badge on real filings (reporting hosted's actual env via /api/connector/status is a #2090 follow-up). buildAuthorizeUrl stays direct-only rather than going async: the connector authorize needs to return the broker's redirect_uri + connector_state for persistence, so index.ts branches to startConnectorAuthorization instead of overloading one function. Connector-layer 4xx bodies (code CONNECTOR_*) are classified BEFORE the SKV-shaped 401/403 sniffing so a broker refusal never tells a self-host operator to check SKATTEVERKET_APIGW_CLIENT_ID; broker refresh 404 CONNECTOR_NOT_OWNED maps to SESSION_EXPIRED (terminal, reconnect fixes) while broker 502 stays a raw error (transient SKV outage must not re-arm the reconnect banner, #1155).
[2026-09-01] multi_user skeptic fixes: Stripe cancel EXPIRES the multi_user stripe grant instead of deleting it (grace anchor; other grants still deleted per freeze-and-retain); app-side state checks go RPC-first via SECURITY DEFINER company_multi_user_state (capability_grants RLS hides team rows from non-team users, byrå clients would misread as frozen); byra-kind teams get a standing team-scoped multi_user grant via backfill + teams trigger (WL-10 assumption made real; partner billing is out-of-band); PGRST202 on resolution fails OPEN (pre-migration DB has no multi_user rows: gated fallback would freeze all non-owners); /api/v1 got the same dormancy gate as MCP. RLS-level enforcement and the mid-session API fallback write-back window stay v2 follow-ups (documented, same class as pre-existing stale-preference fallback).
@@ -9,6 +9,7 @@ import { errorResponseFromCode } from '@/lib/errors/get-structured-error'
import { createServiceClient } from '@/lib/supabase/server'
import { parseReportDateRange } from '@/lib/reports/date-range'
import { currentAppVersion } from '@/lib/reports/app-version'
import { recordAppRelease } from '@/lib/reports/app-releases'
import { slugifyCompanyName } from '@/lib/reports/xlsx-export'
import {
buildBehandlingshistorikExport,
@@ -71,6 +72,9 @@ export const GET = withRouteContext('report.behandlingshistorik', async (request
try {
const serviceClient = createServiceClient()
// Date the running build in app_releases (p. 9.16: program versions). Fire
// and forget: the helper never throws and is a no-op once recorded.
void recordAppRelease(serviceClient)
const report = await generateBehandlingshistorik(
supabase,
companyId,
@@ -83,6 +87,7 @@ export const GET = withRouteContext('report.behandlingshistorik', async (request
{
resolveUserLabels: (ids) => resolveUserLabelsFromProfiles(serviceClient, ids),
appVersion: currentAppVersion(),
globalClient: serviceClient,
},
)
if (!report) {
+17 -6
View File
@@ -1,20 +1,31 @@
import { NextResponse } from 'next/server'
import { NextResponse, after } from 'next/server'
import { createServiceClient } from '@/lib/supabase/server'
import { recordAppRelease } from '@/lib/reports/app-releases'
/**
* Public, unauthenticated build-version probe.
*
* The client compares the id it was built with (NEXT_PUBLIC_BUILD_ID, inlined
* into its JS bundle at build time) against this value, which is read at
* request time from the currently running deployment. A mismatch means a newer
* deploy is live and the open tab is running a stale bundle, so the client
* offers a reload (see components/system/DeployReloadPrompt).
* Returns the commit SHA Vercel built from, so a deploy can be verified from
* outside without reading the dashboard. Empty string off Vercel.
*
* Kept deliberately free of anything else: this is the one endpoint used to
* answer "is the fix live?".
*
* force-dynamic + no-store so it always reflects the live deployment rather
* than a value baked in at build.
*
* Side effect (behandlingshistorik, BFNAR 2013:2 p. 9.16): the first request a
* new build answers dates that version in `app_releases`. Every open tab polls
* this route, so a new deploy is registered within seconds; the recorder is a
* guarded no-op afterwards and never throws. It runs in after() rather than as
* a floating promise because this handler returns synchronously: a bare void
* would race the response and could be frozen before the insert lands, which
* is how a version log ends up silently empty.
*/
export const dynamic = 'force-dynamic'
export function GET() {
const id = process.env.VERCEL_GIT_COMMIT_SHA ?? ''
if (id) after(() => recordAppRelease(createServiceClient, id))
return NextResponse.json({ id }, { headers: { 'Cache-Control': 'no-store' } })
}
+1 -1
View File
@@ -193,7 +193,7 @@ export async function buildBokslutReadinessReport(
// A failed tie-out degrades to "no reminder" (these are advisory), but a
// silently swallowed failure is indistinguishable from "reconciled" in
// the report, so the rejection must at least be traceable in logs
// (compliance review on the avstämning controls, BFNAR 2013:2 kap 8).
// (compliance review on the avstämning controls, BFNAR 2013:2 p. 9.16).
if (arResult.status === 'rejected') {
log.warn('AR tie-out (kundreskontra vs 1510) failed; reminder omitted', arResult.reason)
}
+2 -2
View File
@@ -323,7 +323,7 @@ export async function unlockPeriod(
const result = updated as FiscalPeriod
// BFNAR 2013:2 kap. 8 (behandlingshistorik): unlocking a locked period is a
// BFNAR 2013:2 p. 9.16 (behandlingshistorik): unlocking a locked period is a
// sensitive control change. Persist it to the immutable audit_log (not just
// event_log, which has 30-day TTL) so an auditor can reconstruct who
// unlocked which period and when, even years later.
@@ -416,7 +416,7 @@ export async function closePeriod(
*
* Sets locked_at too (when missing) so the period carries the full
* closed+locked state the enforcement triggers and readers expect, and writes
* the immutable audit_log entry (BFNAR 2013:2 kap. 8: this is a control
* the immutable audit_log entry (BFNAR 2013:2 p. 9.16: this is a control
* decision made by a person, not a year-end run).
*/
export async function markPeriodClosedExternally(
@@ -201,7 +201,7 @@ describe('findDuplicatePaymentCandidatesForInvoice', () => {
[],
])
// An unevaluated candidate set is not a clean "no duplicate": the blind
// spot must be visible in behandlingshistorik (BFNAR 2013:2 kap 8), the
// spot must be visible in behandlingshistorik (BFNAR 2013:2 p. 9.16), the
// same way the supplier-side twin logs it.
const warn = captureWarnings()
+1 -1
View File
@@ -141,7 +141,7 @@ export async function findDuplicatePaymentCandidatesForInvoice(
// compared raw (a raw compare reads 1 000 kr as 1 000 EUR). Same-currency
// rows are still swept. Logged for the same reason the supplier-side twin
// logs it: an unevaluated candidate set is not a clean "no duplicate", and
// the gap must be visible in behandlingshistorik (BFNAR 2013:2 kap 8)
// the gap must be visible in behandlingshistorik (BFNAR 2013:2 p. 9.16)
// rather than pass silently.
log.warn('duplicate-payment guard: cross-currency candidates not evaluated', {
reason: 'invoice_missing_sek_value',
@@ -115,7 +115,7 @@ describe('commit duplicate guard: categorize_transaction (reverse / book the ban
// The booking proceeds past the guard (not auto-rejected); the downstream
// booking is allowed to fail against the bare mock. Before that, the bypass
// must leave a durable BankTransactionDuplicateDismissed record so an
// auditor can reconstruct why the duplicate was allowed (BFNAR 2013:2 kap 8).
// auditor can reconstruct why the duplicate was allowed (BFNAR 2013:2 p. 9.16).
const supabase = queuedSupabase([
{ data: { id: 'op-1' } },
{ data: { id: 'tx-1', date: '2026-03-26', amount: 98565, cash_account_id: null, journal_entry_id: null } },
+1 -1
View File
@@ -2206,7 +2206,7 @@ async function commitMarkInvoicePaid(
} else {
// allow_duplicate=true bypassed the duplicate-payment guard. The decision
// to book a payment over a possible existing one must leave a durable
// behandlingshistorik record (BFNAR 2013:2 kap 8) so an auditor can see why
// behandlingshistorik record (BFNAR 2013:2 p. 9.16) so an auditor can see why
// the duplicate was allowed. Re-detect to capture the dismissed candidate;
// best-effort, never blocks the payment. Payload stays PII-safe
// (ids/amounts/dates only: no customer or merchant name).
+1 -1
View File
@@ -154,7 +154,7 @@ export async function appendProcessingHistory(
* Same append, on a caller-supplied service-role client. For standalone
* scripts (e.g. scripts/backfill-inbox-booked-underlag.ts) that cannot build
* the Next-bound service client but must still write behandlingshistorik
* through the one shared row shape and PII validation (BFNAR 2013:2 kap 8:
* through the one shared row shape and PII validation (BFNAR 2013:2 p. 9.16:
* the change log has to reconcile across writers, so scripts never hand-roll
* the insert).
*/
+1 -1
View File
@@ -189,7 +189,7 @@ export async function ingestMailCandidate(
if (dupErr) throw new Error(dupErr.message)
if (dupItem) {
// Behandlingshistorik, not just an app log: the dedupe decision is
// part of the auditable trail (BFNAR 2013:2 kap 8).
// part of the auditable trail (BFNAR 2013:2 p. 9.16).
try {
await appendProcessingHistory({
companyId,
+113
View File
@@ -0,0 +1,113 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import {
fetchAppReleases,
recordAppRelease,
resetAppReleaseGuardForTests,
type AppReleaseRow,
} from '../app-releases'
/**
* Program version log (BFNAR 2013:2 p. 9.16: "nya programversioner"). The
* recorder sits on the public /api/version probe, so the properties that
* matter are the ones that keep a hot public route cheap and unbreakable:
* it never throws, it constructs nothing once the guard is set, and a failed
* write is retried rather than remembered as done.
*/
function upsertSpy(result: { error: { message: string } | null } = { error: null }) {
const upsert = vi.fn().mockResolvedValue(result)
return { upsert, client: { from: vi.fn().mockReturnValue({ upsert }) } }
}
describe('recordAppRelease', () => {
beforeEach(() => {
vi.clearAllMocks()
resetAppReleaseGuardForTests()
})
it('records the running version, truncated to the 12-character build id', async () => {
const { upsert, client } = upsertSpy()
await expect(recordAppRelease(client, 'b643e6ce6d4448ccb10b985b1ff3872a77f0dd63')).resolves.toBe('b643e6ce6d44')
expect(client.from).toHaveBeenCalledWith('app_releases')
expect(upsert).toHaveBeenCalledWith(
{ version: 'b643e6ce6d44', source: 'runtime' },
{ onConflict: 'version', ignoreDuplicates: true },
)
})
it('writes once per instance and version, then stops touching the database', async () => {
const { upsert, client } = upsertSpy()
await recordAppRelease(client, 'aaaaaaaaaaaa')
await recordAppRelease(client, 'aaaaaaaaaaaa')
await recordAppRelease(client, 'aaaaaaaaaaaa')
expect(upsert).toHaveBeenCalledTimes(1)
// A different build is a different row: the guard is per version.
await recordAppRelease(client, 'bbbbbbbbbbbb')
expect(upsert).toHaveBeenCalledTimes(2)
})
it('never constructs the service client once the guard is set', async () => {
const { client } = upsertSpy()
const factory = vi.fn().mockReturnValue(client)
await recordAppRelease(factory, 'cccccccccccc')
expect(factory).toHaveBeenCalledTimes(1)
await recordAppRelease(factory, 'cccccccccccc')
// The point of the factory form: /api/version is polled constantly and
// must not build a Supabase client per probe.
expect(factory).toHaveBeenCalledTimes(1)
})
it('swallows a throwing client factory: a missing service key must not break the version probe', async () => {
const factory = vi.fn(() => {
throw new Error('SUPABASE_SERVICE_ROLE_KEY is not set')
})
await expect(recordAppRelease(factory, 'dddddddddddd')).resolves.toBe('dddddddddddd')
})
it('retries on the next request when the write failed, rather than remembering it as done', async () => {
const failing = upsertSpy({ error: { message: 'permission denied' } })
await recordAppRelease(failing.client, 'eeeeeeeeeeee')
await recordAppRelease(failing.client, 'eeeeeeeeeeee')
expect(failing.upsert).toHaveBeenCalledTimes(2)
})
it('does nothing when the build id is unknown (local dev, self-hosted without VERCEL_GIT_COMMIT_SHA)', async () => {
const { upsert, client } = upsertSpy()
await expect(recordAppRelease(client, null)).resolves.toBeNull()
await expect(recordAppRelease(client, '')).resolves.toBeNull()
expect(upsert).not.toHaveBeenCalled()
})
})
describe('fetchAppReleases', () => {
const rows: AppReleaseRow[] = [
{ version: 'aaaaaaaaaaaa', first_seen_at: '2026-03-01T08:00:00.000Z', source: 'runtime' },
]
function selectClient(result: { data: AppReleaseRow[] | null; error: { message: string } | null }) {
const chain = {
select: vi.fn().mockReturnThis(),
gte: vi.fn().mockReturnThis(),
lte: vi.fn().mockReturnThis(),
order: vi.fn().mockResolvedValue(result),
}
return { chain, client: { from: vi.fn().mockReturnValue(chain) } }
}
it('windows on first_seen_at and returns the versions oldest first', async () => {
const { chain, client } = selectClient({ data: rows, error: null })
const out = await fetchAppReleases(client, { fromTs: '2026-01-01T00:00:00.000Z', toTs: '2026-12-31T23:59:59.999Z' })
expect(out).toEqual(rows)
expect(chain.gte).toHaveBeenCalledWith('first_seen_at', '2026-01-01T00:00:00.000Z')
expect(chain.lte).toHaveBeenCalledWith('first_seen_at', '2026-12-31T23:59:59.999Z')
expect(chain.order).toHaveBeenCalledWith('first_seen_at', { ascending: true })
})
it('throws on a read failure: a report that silently drops the version log is not a behandlingshistorik', async () => {
const { client } = selectClient({ data: null, error: { message: 'relation does not exist' } })
await expect(
fetchAppReleases(client, { fromTs: '2026-01-01T00:00:00.000Z', toTs: '2026-12-31T23:59:59.999Z' }),
).rejects.toThrow('Failed to fetch app releases: relation does not exist')
})
})
@@ -6,6 +6,8 @@ import {
AUDITED_TABLES,
AUDIT_ROW_FILTER,
GLOBAL_ACTIONS,
appReleaseEvent,
appReleaseEvents,
auditRowToEvent,
buildBehandlingshistorikExport,
collapseBursts,
@@ -553,6 +555,7 @@ describe('generateBehandlingshistorik', () => {
],
journal_entry_rattelse_log: [{ data: [] }, { data: [] }],
company_migration_resets: [{ data: [] }, { data: [] }],
app_releases: [{ data: [] }],
sie_imports: [
{
data: [
@@ -611,6 +614,7 @@ describe('generateBehandlingshistorik', () => {
],
journal_entry_rattelse_log: [{ data: [] }],
company_migration_resets: [{ data: [] }, { data: [] }],
app_releases: [{ data: [] }],
sie_imports: [{ data: [] }],
bank_file_imports: [{ data: [] }],
}
@@ -679,3 +683,142 @@ describe('buildBehandlingshistorikExport', () => {
expect(out.buffer.length).toBeGreaterThan(100)
})
})
// ============================================================
// Behandlingsregler and program versions (BFNAR 2013:2 p. 9.16, 2nd paragraph)
// ============================================================
describe('auditRowToEvent: behandlingsregler', () => {
it('mapping_rules: a rule change names the rule and diffs the accounts', () => {
const ins = auditRowToEvent(
auditRow({ table_name: 'mapping_rules', action: 'INSERT', new_state: { rule_name: 'Spotify', debit_account: '6540', credit_account: '1930', is_active: true } }),
)!
expect(ins).toMatchObject({ category: 'installningar', code: 'mapping_rule.created', object: 'Spotify' })
const upd = auditRowToEvent(
auditRow({
table_name: 'mapping_rules',
action: 'UPDATE',
old_state: { rule_name: 'Spotify', debit_account: '6540', updated_at: 'x' },
new_state: { rule_name: 'Spotify', debit_account: '6212', updated_at: 'y' },
}),
)!
expect(upd.code).toBe('mapping_rule.updated')
expect(upd.details).toEqual(['Debetkonto: 6540 → 6212'])
})
it('categorization_templates: the learning columns never reach the report', () => {
// The DB trigger filters these already (migration 20260901103000); the read
// model must not resurrect them if a row slips through, or every booking
// would appear as a system change.
const learning = auditRowToEvent(
auditRow({
table_name: 'categorization_templates',
action: 'UPDATE',
old_state: { counterparty_name: 'Spotify AB', debit_account: '6540', occurrence_count: 4, confidence: 0.7 },
new_state: { counterparty_name: 'Spotify AB', debit_account: '6540', occurrence_count: 5, confidence: 0.9 },
}),
)
expect(learning).toBeNull()
const rule = auditRowToEvent(
auditRow({
table_name: 'categorization_templates',
action: 'UPDATE',
old_state: { counterparty_name: 'Spotify AB', debit_account: '6540' },
new_state: { counterparty_name: 'Spotify AB', debit_account: '6212' },
}),
)!
expect(rule).toMatchObject({ code: 'categorization_template.updated', object: 'Spotify AB' })
expect(rule.details).toEqual(['Debetkonto: 6540 → 6212'])
})
it('salary_payroll_config: statutory constants are BFN\'s own automatkontering example', () => {
const upd = auditRowToEvent(
auditRow({
table_name: 'salary_payroll_config',
company_id: null,
user_id: null,
actor_type: 'system',
action: 'UPDATE',
old_state: { config_year: 2026, employer_fee_rate: 0.3142, created_at: 'x' },
new_state: { config_year: 2026, employer_fee_rate: 0.3097, created_at: 'x' },
}),
)!
expect(upd).toMatchObject({ category: 'installningar', code: 'payroll_config.updated', object: 'Löneår 2026' })
expect(upd.details).toEqual(['employer_fee_rate: 0.3142 → 0.3097'])
expect(upd.actor.type).toBe('system')
})
it('import logs only add what the rows themselves can no longer show: a deletion', () => {
expect(auditRowToEvent(auditRow({ table_name: 'sie_imports', action: 'INSERT', new_state: { filename: 'bok.se' } }))).toBeNull()
const del = auditRowToEvent(auditRow({ table_name: 'sie_imports', action: 'DELETE', old_state: { filename: 'bok.se' } }))!
expect(del).toMatchObject({ category: 'import', code: 'sie_import.deleted', object: 'bok.se' })
expect(auditRowToEvent(auditRow({ table_name: 'bank_file_imports', action: 'UPDATE', new_state: { filename: 'kontoutdrag.csv' } }))).toBeNull()
const bankDel = auditRowToEvent(auditRow({ table_name: 'bank_file_imports', action: 'DELETE', old_state: { filename: 'kontoutdrag.csv' } }))!
expect(bankDel.code).toBe('bank_file_import.deleted')
})
})
describe('appReleaseEvent', () => {
it('dates a program version as a system event with no human actor', () => {
const ev = appReleaseEvent({ version: 'b643e6ce6d44', first_seen_at: '2026-03-10T10:00:00.000Z', source: 'runtime' })
expect(ev).toMatchObject({
category: 'ovrigt',
code: 'system.release',
object: 'b643e6ce6d44',
event: 'Ny programversion i drift',
})
expect(ev.actor).toEqual({ type: 'system', user_id: null, actor_label: null })
})
it('names a non-runtime source rather than claiming the system saw it', () => {
const ev = appReleaseEvent({ version: 'aaaaaaaaaaaa', first_seen_at: '2026-03-10T10:00:00.000Z', source: 'backfill' })
expect(ev.details).toEqual(['Källa: backfill'])
})
})
describe('appReleaseEvents: per-day roll-up', () => {
const day = (h: number, v: string) => ({ version: v, first_seen_at: `2026-03-10T0${h}:00:00.000Z`, source: 'runtime' })
it('keeps a single deploy as a single named version', () => {
const out = appReleaseEvents([day(8, 'aaaaaaaaaaaa')])
expect(out).toHaveLength(1)
expect(out[0]).toMatchObject({ code: 'system.release', object: 'aaaaaaaaaaaa' })
})
it('collapses a day of deploys into one dated event that still names every build id', () => {
// Truncating the list would defeat the entry: an auditor has to be able to
// reconstruct which versions ran that day.
const versions = ['a1', 'b2', 'c3', 'd4', 'e5', 'f6', 'g7']
const out = appReleaseEvents(versions.map((v, i) => day(i + 1, v)))
expect(out).toHaveLength(1)
expect(out[0]).toMatchObject({ code: 'system.release.bulk', object: '7 versioner', count: 7 })
expect(out[0].details).toEqual(['a1, b2, c3, d4, e5, f6, g7'])
})
it('never lets the deploy rate swamp the report: a year of merges stays one event per day', () => {
// main takes ~570 merges a month; per-version events would be ~7 000 a year
// and would trip the PDF's 4 000-event guard on their own.
const rows = []
for (let d = 0; d < 365; d++) {
const date = new Date(Date.UTC(2026, 0, 1 + d, 9))
for (let n = 0; n < 19; n++) rows.push({ version: `v${d}-${n}`, first_seen_at: date.toISOString(), source: 'runtime' })
}
expect(rows).toHaveLength(6935)
expect(appReleaseEvents(rows)).toHaveLength(365)
})
it('groups on the Swedish calendar day, not UTC', () => {
// Both timestamps are 2026-07-10 in UTC, but Stockholm is UTC+2 in July:
// 21:00Z is the 10th at 23:00 and 23:00Z is already the 11th at 01:00.
// Grouping on the raw ISO date would merge them into one event.
const out = appReleaseEvents([
{ version: 'beforemidnight', first_seen_at: '2026-07-10T21:00:00.000Z', source: 'runtime' },
{ version: 'aftermidnight', first_seen_at: '2026-07-10T23:00:00.000Z', source: 'runtime' },
])
expect(out).toHaveLength(2)
expect(out.map((e) => e.object)).toEqual(['beforemidnight', 'aftermidnight'])
})
})
+78
View File
@@ -0,0 +1,78 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { currentAppVersion } from '@/lib/reports/app-version'
/**
* Program version log (`app_releases`, migration 20260901103000).
*
* BFNAR 2013:2 p. 9.16 second paragraph: new program versions are changes to
* the bookkeeping system that must be dated in the behandlingshistorik. Vercel
* has no hook we can trust to write a row at deploy time, so the runtime
* records each build the first time it answers a request: an insert that is a
* no-op when the version is already known. A module-level guard keeps it to
* one round-trip per function instance and version.
*/
/**
* A service-role client, or a factory for one. The factory form exists for
* `/api/version`, which is public and polled constantly: building a client on
* every probe to then hit the module guard is pure waste, so the guard runs
* first and the client is only constructed on the one request that writes.
*/
type ServiceClientLike = Pick<SupabaseClient, 'from'>
type ServiceClientSource = ServiceClientLike | (() => ServiceClientLike)
let recordedVersion: string | null = null
export interface AppReleaseRow {
version: string
first_seen_at: string
source: string
}
/**
* Record the running version if this instance has not already. Never throws:
* a failed bookkeeping of the version must not fail the request carrying it.
* Returns the version recorded (or already known), null when unknown.
*/
export async function recordAppRelease(
serviceClient: ServiceClientSource,
rawVersion: string | null = currentAppVersion(),
): Promise<string | null> {
// Same 12-character form as currentAppVersion(), whatever the caller passes
// (the /api/version probe has the full commit SHA).
const version = rawVersion ? rawVersion.slice(0, 12) : null
if (!version) return null
if (recordedVersion === version) return version
try {
// Inside the try: a missing service key (local dev, self-hosted without
// one) throws on construction, and the version probe must survive that.
const client = typeof serviceClient === 'function' ? serviceClient() : serviceClient
const { error } = await client
.from('app_releases')
.upsert({ version, source: 'runtime' }, { onConflict: 'version', ignoreDuplicates: true })
if (!error) recordedVersion = version
} catch {
// swallowed on purpose, see above
}
return version
}
/** Test hook: forget the per-instance guard. */
export function resetAppReleaseGuardForTests(): void {
recordedVersion = null
}
/** Versions first seen inside [fromTs, toTs], oldest first. Readable by every signed-in user. */
export async function fetchAppReleases(
supabase: Pick<SupabaseClient, 'from'>,
window: { fromTs: string; toTs: string },
): Promise<AppReleaseRow[]> {
const { data, error } = await supabase
.from('app_releases')
.select('version, first_seen_at, source')
.gte('first_seen_at', window.fromTs)
.lte('first_seen_at', window.toTs)
.order('first_seen_at', { ascending: true })
if (error) throw new Error(`Failed to fetch app releases: ${error.message}`)
return (data as AppReleaseRow[] | null) ?? []
}
+279 -6
View File
@@ -1,6 +1,7 @@
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,
@@ -84,6 +85,12 @@ 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'>
}
// ============================================================
@@ -196,8 +203,21 @@ export const AUDITED_TABLES = [
'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',
] 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',
@@ -212,7 +232,7 @@ export const GLOBAL_ACTIONS = [
* 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)'
'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),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)'
const SOURCE_TYPE_LABELS: Record<string, string> = {
manual: 'Manuell',
@@ -370,6 +390,52 @@ const DIMENSION_FIELDS: Record<string, string> = {
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',
}
const CATEGORIZATION_TEMPLATE_FIELDS: Record<string, string> = {
counterparty_name: 'Motpart',
counterparty_aliases: 'Alias',
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',
@@ -1034,6 +1100,103 @@ export function auditRowToEvent(
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 '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
}
@@ -1074,7 +1237,11 @@ function sieImportEvents(row: SieImportRow): RawBehandlingshistorikEvent[] {
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'
// 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}`,
@@ -1093,8 +1260,8 @@ function sieImportEvents(row: SieImportRow): RawBehandlingshistorikEvent[] {
id: `sie:${row.id}:replaced`,
occurred_at: toIso(row.replaced_at)!,
category: 'import',
code: 'sie_import.replaced',
event: 'SIE-import ersatt av ny 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: [],
@@ -1131,6 +1298,102 @@ function bankFileImportEvent(row: BankFileImportRow): RawBehandlingshistorikEven
}
}
/** 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
// ============================================================
@@ -1318,7 +1581,7 @@ async function fetchAuditRows(
// 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)',
'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),action.in.(SECURITY_EVENT,INTEGRITY_FAILURE,RETENTION_BLOCK,DOCUMENT_DELETE_BLOCKED)',
)
.order('created_at', { ascending: true })
.order('id', { ascending: true })
@@ -1476,16 +1739,26 @@ export async function generateBehandlingshistorik(
const entryIds = entries.map((e) => e.id)
const unionIds = mode === 'fiscal_year' ? entryIds : []
const [auditRows, rattelseRows, resets, sieImports, bankImports] = await Promise.all([
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
+2 -2
View File
@@ -325,7 +325,7 @@ export async function categorizeMatchedTransaction(
} else {
// allowDuplicate=true bypassed the guard. Booking over a possible
// double-booking is a bookkeeping act that must leave a durable
// behandlingshistorik record (BFNAR 2013:2 kap 8). Re-detect to capture
// behandlingshistorik record (BFNAR 2013:2 p. 9.16). Re-detect to capture
// the dismissed candidate; best-effort, a logging failure must never block
// a legitimate booking.
try {
@@ -359,7 +359,7 @@ export async function categorizeMatchedTransaction(
// Dismissing a candidate whose amounts were never comparable is a
// materially different decision from dismissing a confirmed
// same-amount twin; behandlingshistorik has to record which one
// the user actually made (BFNAR 2013:2 kap 8).
// the user actually made (BFNAR 2013:2 p. 9.16).
amount_verified: dismissed.amount_verified,
unverified_reason: dismissed.unverified_reason,
via: 'allow_duplicate',
+5 -5
View File
@@ -39,7 +39,7 @@ Systemdokumentationen ska förvaras tillsammans med övrig räkenskapsinformatio
*Vid egen drift: ange var databasen driftas och vem som ansvarar för drift och säkerhetskopiering.*
## 3. Kontoplan
## 3. Kontoplan (BFNAR 2013:2 punkt 9.2 a, 9.3)
3.1. Kontoplanen bygger på BAS-kontoplanen (BAS 2026) utgiven av BAS-intressenternas Förening.
@@ -58,7 +58,7 @@ Systemdokumentationen ska förvaras tillsammans med övrig räkenskapsinformatio
3.4. Företagsspecifika anpassningar av kontoplanen:
[BESKRIV EVENTUELLA TILLAGDA ELLER BORTTAGNA KONTON, t.ex. "Konto 4010 Inköp varor, 5010 Lokalhyra har lagts till. Inga standardkonton har tagits bort."]
## 4. Samlingsplan
## 4. Samlingsplan (BFNAR 2013:2 punkt 9.2 c, 9.4, 9.11)
Samlingsplanen beskriver hur bokföringen är organiserad i form av delsystem, grundbokföring och huvudbokföring.
@@ -132,7 +132,7 @@ Bankkonto 1930 avstäms via bankavstämningsmodulen (flerstegs matchning: exakt
## 5. Verifikationer
### 5.1 Verifikationsnumrering
### 5.1 Verifikationsnumrering (BFNAR 2013:2 punkt 9.6)
Verifikationsnummer tilldelas sekventiellt av systemet vid bokföring. Numreringen är unik per företag, räkenskapsår och verifikationsserie. Numren tilldelas via en databasfunktion som är säker vid samtidiga anrop och kan inte sättas manuellt.
@@ -214,7 +214,7 @@ Dokument som är kopplade till bokförda verifikationer kan inte raderas, efters
8.4. Redovisningsmetod: [ ] Faktureringsmetod [ ] Kontantmetod
Momsperiod: [ ] Månad [ ] Kvartal [ ] Helår
## 9. Behandlingshistorik
## 9. Behandlingshistorik (BFL 5 kap. 11 §, BFNAR 2013:2 punkt 9.15-9.16)
9.1. Systemet registrerar automatiskt en behandlingshistorik som inkluderar:
- Registreringsdatum och tidpunkt för varje journalpost
@@ -304,7 +304,7 @@ Momsperiod: [ ] Månad [ ] Kvartal [ ] Helår
13.6. Ansvarig för att tilldela och granska behörigheter: [NAMN]
## 14. Säkerhetskopiering och arkivering
## 14. Säkerhetskopiering och arkivering (BFNAR 2013:2 punkt 8.3, 9.2 d, 9.12)
14.1. Räkenskapsinformationen lagras i EU och bevaras i minst 7 år enligt BFL 7 kap.
+4 -4
View File
@@ -1,15 +1,15 @@
{
"horizontal/swedish-accounting-compliance": {
"hash": "2f04bdd94e9523ef25cff4530cf8b24c6f153dee437bb54d1befacbfdabe5305",
"version": 4
"hash": "f63d1bbcda84007716ebcfb805f6399cc51875d4139097281c56b76942e62544",
"version": 5
},
"horizontal/swedish-accounting-compliance/bas-kontoplan": {
"hash": "0d1b05466568344be0afd4b360d582db23331ae4f1b4b05681cc92c50d099bdb",
"version": 2
},
"horizontal/swedish-accounting-compliance/bfl-bfnar": {
"hash": "676a6dac3ed2b5104c42d59f1e7d7a591498a5ad175192a8e24f83b3e1700c83",
"version": 2
"hash": "78744006a40cbaeb4b45510cd95b47b65e13f5c523503ed420a7187cd858c8b2",
"version": 3
},
"horizontal/swedish-accounting-compliance/changes-2025-2026": {
"hash": "ffc4c5d618bb8840f43fd48c8d4a3e464768e84abdaeb49da016e0051b260964",
@@ -0,0 +1,93 @@
-- Behandlingshistorik, part 3 (BFL 5 kap. 11 §, BFNAR 2013:2 punkt 9.16 andra
-- stycket): "förändringar i bokföringssystemet som påverkar bokföringsposternas
-- behandling samt när dessa förändringar infördes". BFN's commentary names
-- behandlingsregler (automatkonteringar, fasta procentsatser) and new program
-- versions as the examples. Until now the rule/template tables and the
-- statutory payroll constants changed without a trace, and software versions
-- had no history at all.
--
-- 1. write_audit_log() triggers on the behandlingsregler tables and the
-- import logs. The trigger derives user_id/company_id from the row itself
-- (NULL company_id for the global salary_payroll_config: those rows are
-- read through the service role by the behandlingshistorik report).
-- categorization_templates learn on every booking (occurrence_count,
-- confidence, last_seen_date): those telemetry-only updates are excluded
-- the same way api_keys request counters are (20260721115701), so only
-- rule changes (accounts, VAT, pattern, active flag) are logged.
-- 2. app_releases: append-only log of program versions seen in production,
-- written by the runtime the first time a build answers a request
-- (lib/reports/app-releases.ts). Readable by every authenticated user
-- (it is not company data), writable only by the service role.
--
-- pg-test: tests/pg/behandlingshistorik-audit-triggers.pg.test.ts
-- ── 1. Audit triggers ─────────────────────────────────────────────────────────
CREATE TRIGGER audit_mapping_rules
AFTER INSERT OR UPDATE OR DELETE ON public.mapping_rules
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
CREATE TRIGGER audit_booking_template_library
AFTER INSERT OR UPDATE OR DELETE ON public.booking_template_library
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
CREATE TRIGGER audit_categorization_templates
AFTER INSERT OR DELETE ON public.categorization_templates
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
CREATE TRIGGER audit_categorization_templates_update
AFTER UPDATE ON public.categorization_templates
FOR EACH ROW
WHEN (
(to_jsonb(OLD) - ARRAY[
'occurrence_count',
'confidence',
'last_seen_date',
'updated_at'
]::text[])
IS DISTINCT FROM
(to_jsonb(NEW) - ARRAY[
'occurrence_count',
'confidence',
'last_seen_date',
'updated_at'
]::text[])
)
EXECUTE FUNCTION public.write_audit_log();
CREATE TRIGGER audit_salary_payroll_config
AFTER INSERT OR UPDATE OR DELETE ON public.salary_payroll_config
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
CREATE TRIGGER audit_sie_imports
AFTER INSERT OR UPDATE OR DELETE ON public.sie_imports
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
CREATE TRIGGER audit_bank_file_imports
AFTER INSERT OR UPDATE OR DELETE ON public.bank_file_imports
FOR EACH ROW EXECUTE FUNCTION public.write_audit_log();
-- ── 2. Program version log ────────────────────────────────────────────────────
CREATE TABLE public.app_releases (
version text PRIMARY KEY,
first_seen_at timestamptz NOT NULL DEFAULT now(),
source text NOT NULL DEFAULT 'runtime'
);
COMMENT ON TABLE public.app_releases IS
'Program versions (build ids) observed in production and when they were first seen. Behandlingshistorik input per BFNAR 2013:2 p. 9.16: "nya programversioner" are system changes that must be dated. Append-only; written by the runtime via the service role (lib/reports/app-releases.ts).';
ALTER TABLE public.app_releases ENABLE ROW LEVEL SECURITY;
-- Not company data: every signed-in user may read it (it feeds every
-- company's behandlingshistorik). No INSERT/UPDATE/DELETE policy: only the
-- service role writes, and the immutability trigger stops edits even there.
CREATE POLICY app_releases_select ON public.app_releases
FOR SELECT TO authenticated USING (true);
CREATE TRIGGER app_releases_immutable
BEFORE UPDATE OR DELETE ON public.app_releases
FOR EACH ROW EXECUTE FUNCTION public.audit_log_immutable();
NOTIFY pgrst, 'reload schema';
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,207 @@
import { randomUUID } from 'node:crypto'
import { describe, expect, it } from 'vitest'
import { insertAuthUser, insertCompany, insertCompanyMember } from './fixtures'
import { getPool, withUserContext } from './setup'
/**
* Behandlingshistorik, part 3 (migration 20260901103000): the behandlingsregler
* tables and the import logs write to the immutable audit_log, learning-only
* updates on categorization_templates are filtered, the global payroll
* constants are logged without a company, and app_releases is an append-only,
* read-for-all, service-role-written version log.
*/
async function setActiveCompany(userId: string, companyId: string): Promise<void> {
await getPool().query(
`INSERT INTO public.user_preferences (user_id, active_company_id)
VALUES ($1, $2)
ON CONFLICT (user_id) DO UPDATE SET active_company_id = EXCLUDED.active_company_id`,
[userId, companyId],
)
}
async function auditActions(table: string, recordId: string): Promise<string[]> {
const res = await getPool().query<{ action: string }>(
`SELECT action FROM public.audit_log
WHERE table_name = $1 AND record_id = $2
ORDER BY created_at, id`,
[table, recordId],
)
return res.rows.map((r) => r.action)
}
describe('behandlingshistorik audit triggers (BFNAR 2013:2 p. 9.16)', () => {
it('logs mapping_rules insert, update and delete with the company id', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const ruleId = randomUUID()
await getPool().query(
`INSERT INTO public.mapping_rules (id, user_id, company_id, rule_name, rule_type, debit_account, credit_account)
VALUES ($1, $2, $3, 'Kontorsmaterial', 'merchant_name', '6110', '1930')`,
[ruleId, userId, companyId],
)
await getPool().query(`UPDATE public.mapping_rules SET debit_account = '6540' WHERE id = $1`, [ruleId])
await getPool().query(`DELETE FROM public.mapping_rules WHERE id = $1`, [ruleId])
const rows = await getPool().query<{ action: string; company_id: string | null; old_debit: string | null; new_debit: string | null }>(
`SELECT action, company_id, old_state->>'debit_account' AS old_debit, new_state->>'debit_account' AS new_debit
FROM public.audit_log WHERE table_name = 'mapping_rules' AND record_id = $1
ORDER BY created_at, id`,
[ruleId],
)
expect(rows.rows).toEqual([
{ action: 'INSERT', company_id: companyId, old_debit: null, new_debit: '6110' },
{ action: 'UPDATE', company_id: companyId, old_debit: '6110', new_debit: '6540' },
{ action: 'DELETE', company_id: companyId, old_debit: '6540', new_debit: null },
])
})
it('logs categorization_templates rule changes but not learning-only updates', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const templateId = randomUUID()
await getPool().query(
`INSERT INTO public.categorization_templates (id, user_id, company_id, counterparty_name, debit_account, credit_account)
VALUES ($1, $2, $3, 'Spotify AB', '6540', '1930')`,
[templateId, userId, companyId],
)
// Learning on every booking: occurrence_count / confidence / last_seen_date.
await getPool().query(
`UPDATE public.categorization_templates
SET occurrence_count = occurrence_count + 1, confidence = 0.9, last_seen_date = CURRENT_DATE
WHERE id = $1`,
[templateId],
)
expect(await auditActions('categorization_templates', templateId)).toEqual(['INSERT'])
// A rule change (the account) is logged.
await getPool().query(`UPDATE public.categorization_templates SET debit_account = '6212' WHERE id = $1`, [templateId])
expect(await auditActions('categorization_templates', templateId)).toEqual(['INSERT', 'UPDATE'])
await getPool().query(`DELETE FROM public.categorization_templates WHERE id = $1`, [templateId])
expect(await auditActions('categorization_templates', templateId)).toEqual(['INSERT', 'UPDATE', 'DELETE'])
})
it('logs booking_template_library changes (no user_id column: actor falls back to auth.uid())', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
// btl_insert requires current_user_can_write() and company_id =
// current_active_company_id(), so the membership and the active-company
// preference both have to exist before the authenticated insert.
await insertCompanyMember({ companyId, userId })
await setActiveCompany(userId, companyId)
const templateId = randomUUID()
// Asserted INSIDE the user transaction: withUserContext always rolls back,
// so the audit row the trigger writes is gone by the time an outside
// connection could look for it. The row is what is under test, not its
// persistence, and the trigger fires in the same transaction as the write.
const rows = await withUserContext(userId, async (client) => {
await client.query(
`INSERT INTO public.booking_template_library (id, company_id, created_by, name, lines)
VALUES ($1, $2, $3, 'Hyra', '[{"account":"5010","side":"debit"}]'::jsonb)`,
[templateId, companyId, userId],
)
const audit = await client.query<{ action: string; company_id: string | null; user_id: string | null }>(
`SELECT action, company_id, user_id FROM public.audit_log
WHERE table_name = 'booking_template_library' AND record_id = $1`,
[templateId],
)
return audit.rows
})
// booking_template_library has no user_id column, so write_audit_log()
// falls back to auth.uid(): the actor is the authenticated writer.
expect(rows).toEqual([{ action: 'INSERT', company_id: companyId, user_id: userId }])
})
it('logs the global payroll constants without a company (read via service role by the report)', async () => {
const id = randomUUID()
await getPool().query(
`INSERT INTO public.salary_payroll_config (
id, config_year, avgifter_total, avgifter_alderspension, avgifter_sjukforsakring,
avgifter_foraldraforsakring, avgifter_efterlevandepension, avgifter_arbetsmarknad,
avgifter_arbetsskada, avgifter_allman_loneavgift, avgifter_reduced_65plus,
avgifter_minimum_annual, egenavgifter_total, slp_rate, prisbasbelopp, inkomstbasbelopp,
max_pgi, sgi_ceiling, statlig_skatt_brytpunkt, traktamente_heldag, traktamente_halvdag,
traktamente_natt, milersattning_egen_bil, milersattning_formansbil_fossil,
milersattning_formansbil_el, kostforman_heldag, kostforman_lunch, kostforman_frukost,
friskvard_cap, bilforman_slr, reduced_avgift_age
) VALUES (
$1, 2099, 0.3142, 0.1021, 0.0355, 0.026, 0.006, 0.0264, 0.002, 0.1162, 0.1021,
1000, 0.2897, 0.2426, 60000, 85000, 600000, 500000, 650000, 290, 145, 145, 25, 12, 9.5,
110, 55, 55, 5000, 0.0196, 66
)`,
[id],
)
await getPool().query(`UPDATE public.salary_payroll_config SET prisbasbelopp = 61000 WHERE id = $1`, [id])
const rows = await getPool().query<{ action: string; company_id: string | null; new_pbb: string | null }>(
`SELECT action, company_id, new_state->>'prisbasbelopp' AS new_pbb FROM public.audit_log
WHERE table_name = 'salary_payroll_config' AND record_id = $1 ORDER BY created_at, id`,
[id],
)
expect(rows.rows.map((r) => [r.action, r.company_id])).toEqual([
['INSERT', null],
['UPDATE', null],
])
expect(Number(rows.rows[1].new_pbb)).toBe(61000)
await getPool().query(`DELETE FROM public.salary_payroll_config WHERE id = $1`, [id])
})
it('logs sie_imports and bank_file_imports rows incl. the undo status change', async () => {
const userId = await insertAuthUser()
const companyId = await insertCompany({ createdBy: userId })
const sieId = randomUUID()
const bankId = randomUUID()
await getPool().query(
`INSERT INTO public.sie_imports (id, user_id, company_id, filename, file_hash, sie_type, status)
VALUES ($1, $2, $3, 'bokio.se', $4, 4, 'completed')`,
[sieId, userId, companyId, randomUUID()],
)
await getPool().query(
`UPDATE public.sie_imports SET status = 'undone', replaced_at = now() WHERE id = $1`,
[sieId],
)
await getPool().query(
`INSERT INTO public.bank_file_imports (id, user_id, company_id, filename, file_hash, file_format)
VALUES ($1, $2, $3, 'seb.csv', $4, 'seb')`,
[bankId, userId, companyId, randomUUID()],
)
const sie = await getPool().query<{ action: string; new_status: string | null }>(
`SELECT action, new_state->>'status' AS new_status FROM public.audit_log
WHERE table_name = 'sie_imports' AND record_id = $1 ORDER BY created_at, id`,
[sieId],
)
expect(sie.rows).toEqual([
{ action: 'INSERT', new_status: 'completed' },
{ action: 'UPDATE', new_status: 'undone' },
])
expect(await auditActions('bank_file_imports', bankId)).toEqual(['INSERT'])
})
it('app_releases: readable by any signed-in user, writable only by the service role, immutable', async () => {
const userId = await insertAuthUser()
const version = `test-${randomUUID().slice(0, 8)}`
await getPool().query(`INSERT INTO public.app_releases (version) VALUES ($1)`, [version])
const seen = await withUserContext(userId, async (client) => {
const res = await client.query<{ version: string }>(
`SELECT version FROM public.app_releases WHERE version = $1`,
[version],
)
return res.rows.map((r) => r.version)
})
expect(seen).toEqual([version])
await expect(
withUserContext(userId, (client) =>
client.query(`INSERT INTO public.app_releases (version) VALUES ($1)`, [`${version}-user`]),
),
).rejects.toThrow(/row-level security/i)
await expect(
getPool().query(`UPDATE public.app_releases SET source = 'edited' WHERE version = $1`, [version]),
).rejects.toThrow()
await expect(
getPool().query(`DELETE FROM public.app_releases WHERE version = $1`, [version]),
).rejects.toThrow()
})
})
+4 -1
View File
@@ -3771,7 +3771,10 @@ export type AuditAction =
export interface AuditLogEntry {
id: string
user_id: string
// Nullable in the database and genuinely null in practice: write_audit_log()
// falls back to auth.uid(), which is NULL for a service-role or global write
// (the company-less salary_payroll_config rows are the standing example).
user_id: string | null
company_id: string | null
action: AuditAction
table_name: string | null