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:
co-authored by
Claude Opus 5
Jakob Wennberg
parent
169e7eaf4e
commit
a08bf51ced
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) ?? []
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user