Bug/year end numbers (#744)

* fix(bookkeeping): allow creating a fiscal year that fills an interior gap

Fiscal-period creation only allowed chaining a new räkenskapsår before the
earliest or after the latest existing period, so a company with a gap between
years (e.g. 2024 + 2026 from an SIE import, missing 2025) could not create the
missing year — it failed with "New period must chain before the earliest or
after the latest existing period".

Generalise forward chaining onto the new period's immediate predecessor, which
covers both appending a new latest year and filling an interior gap. The
"prior year must be locked" guard now applies only to true appends, not gap
fills (a backfill, like backward chaining). previous_period_id is set to the
predecessor and the successor is relinked so the BFNAR 2013:2 continuity chain
stays intact. The create dialog suggests the missing year (capped so it never
overlaps the next period), the settings page seeds the dialog at the earliest
gap, and the default suggested name is now "Räkenskapsår <year>".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(bookkeeping): omföra föregående års resultat (2099 → 2098) at year-end

Year-end closing posts the result to 2099 "Årets resultat" and the opening
balance carried it forward on 2099 every year, so 2099 accumulated across
years and the prior result never moved off "Årets resultat".

executeYearEndClosing now posts a separate "Omföring av föregående års
resultat" verifikat (Dr 2099 / Cr 2098 for a profit, reversed for a loss)
into the new period after the continuity check passes, so 2099 starts each
year at zero. Kept as a standalone entry rather than folded into the opening
balance so the IB stays a faithful mirror of the prior UB and IB/UB
continuity still holds. Aktiebolag only; idempotent; no-op when 2099 is flat.
The 2098 → 2091/2898 disposition (bolagsstämma decision) is intentionally
left to a separate step.

- new source_type 'result_appropriation' (migration + type + Zod enum)
- generateResultAppropriation helper (planner + poster) wired as step 11
- ResultStep surfaces the omföring voucher
- unit tests + pg-real invariant
- scripts/repair-result-appropriation.ts: retroactive catch-up (dry-run default)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(transactions): shadow-detect date-drift duplicate bank transactions

The content-dedup bridge buckets on exact (date, ore), so the same
transaction re-imported with a booking date that drifted a day lands in
a different bucket and slips past every dedup layer. Add a measure-only
("shadow") detector that flags would-be +/-1-day duplicates and counts
them, without changing what is inserted - so the gap can be validated on
real data before any enforcement, mirroring the scope-drift shadow.

- shiftIsoDate(): pure, deterministic adjacent-date helper
- ingest: DEDUP_DATE_DRIFT_MODE flag (default on), pre-loop bucket
  snapshot, per-row gate with desc-bridge + cross-channel-symmetry
  signals; logs shadow_date_drift_candidates, never alters inserts
- fail-safe date guard so the measurement can never abort an import
- regression tests for both signals, account/window/distinct guards,
  no-double-count, and the malformed-date fail-safe

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(bookkeeping): anonymize a customer reference in fiscal-period tests

Remove a real customer name ("AXMD AB") from regression-test comments;
no logic change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(workflows): enhance Docker image scanning and caching mechanisms

* fix(bookkeeping): enhance year-end result appropriation handling and error reporting

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-16 18:22:09 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 5c40fa9aeb
commit 2a8bf9b42e
27 changed files with 1860 additions and 174 deletions
+99 -1
View File
@@ -7,7 +7,7 @@ import { findSupplierInvoiceMatch } from '@/lib/invoices/supplier-invoice-matchi
import { fetchExchangeRate } from '@/lib/currency/riksbanken'
import { logMatchEvent } from '@/lib/invoices/match-log'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { contentBucketKey, descriptionsBridge, normalizeImportedDescription } from '@/lib/transactions/external-id'
import { contentBucketKey, descriptionsBridge, normalizeImportedDescription, shiftIsoDate } from '@/lib/transactions/external-id'
import { isImportedTransaction } from '@/lib/transactions/origin'
import { createLogger } from '@/lib/logger'
import type { Transaction, RawTransaction, IngestResult, IngestOptions, SupplierInvoice, Currency, ExchangeRate } from '@/types'
@@ -206,6 +206,7 @@ export async function ingestTransactions(
errors: 0,
transaction_ids: [],
shadow_scope_drift_candidates: 0,
shadow_date_drift_candidates: 0,
}
const log = createLogger('transactions.ingest', { companyId })
@@ -218,6 +219,15 @@ export async function ingestTransactions(
// DEDUP_SCOPE_DRIFT_MODE=off to silence. There is deliberately NO 'enforce'
// branch yet: we validate on real fleet data first (see the plan).
const scopeDriftShadow = process.env.DEDUP_SCOPE_DRIFT_MODE !== 'off'
// SHADOW-ONLY instrumentation for the date-drift bridge: the content bridge
// buckets on EXACT (date, öre), so a booking date that drifts a day between
// syncs lands its twin in an ADJACENT bucket and every dedup layer misses it
// (this produced the observed EB↔EB and CSV↔EB 1-day-apart duplicates). When
// on, we LOG + COUNT which surviving rows a ±1-day-tolerant rule WOULD treat
// as re-imports, but never change what is inserted. Default on; set
// DEDUP_DATE_DRIFT_MODE=off to silence. No 'enforce' branch — same as
// scope-drift, we validate on real fleet data first.
const dateDriftShadow = process.env.DEDUP_DATE_DRIFT_MODE !== 'off'
// Pre-fetch existing transactions for content-based dedup (date+amount+
// description prefix, plus the cross-channel mirror below). Booked rows catch
@@ -397,6 +407,26 @@ export async function ingestTransactions(
}
}
// ── Shadow-mode date-drift precompute (measure only) ─────────────────────
// The content bridge matches only the EXACT (date, öre) bucket, so a twin
// whose booking date drifted a day is invisible to it. Snapshot the stored
// buckets BEFORE the dedup loop — a COPY of each bucket's entries, so Layer-2's
// splices don't mutate what the shadow reads — so each surviving row can look
// one day to either side for an account-compatible twin without disturbing
// real dedup. Window is ±1 day (the only gap observed); a named constant so
// widening to ±2 is one line if fleet data shows it.
const DATE_DRIFT_WINDOW_DAYS = 1
const storedByBucketForDrift = new Map<string, BucketEntry[]>()
if (batchIsImportFeed && dateDriftShadow) {
for (const bucket of [existingMaps.booked, existingMaps.unbookedImported]) {
for (const [k, entries] of bucket) {
const snapshot = storedByBucketForDrift.get(k)
if (snapshot) snapshot.push(...entries)
else storedByBucketForDrift.set(k, [...entries])
}
}
}
// Track already-matched invoice IDs within this ingestion batch
// to prevent suggesting the same invoice for multiple transactions
const matchedInvoiceIds = new Set<string>()
@@ -539,6 +569,74 @@ export async function ingestTransactions(
}
}
// SHADOW-ONLY: date-drift. This row survived Layer-1 + Layer-2 and WILL
// insert. The content bridge only matched its EXACT (date, öre) bucket, so a
// twin whose booking date drifted a day is invisible to it. Look ±1 day for
// an account-compatible stored twin that EITHER bridges by description
// (same/enriched title — the EB↔EB hotel/fee case) OR is a cross-feed
// count-symmetric mirror displaced by a day (the CSV↔EB case where the
// descriptions don't bridge). Record it for fleet validation, then insert
// unchanged — this block never affects result.imported/duplicates.
//
// Fail-safe date guard: measurement must NEVER abort a real import. raw.date
// is always ISO in practice, but a malformed value would make shiftIsoDate
// throw (new Date(NaN).toISOString()), so we skip detection rather than risk
// it. Any /^\d{4}-\d{2}-\d{2}$/ value is safe — Date.UTC normalizes
// out-of-range parts to a finite epoch, never NaN.
if (dateDriftShadow && batchIsImportFeed && /^\d{4}-\d{2}-\d{2}$/.test(raw.date)) {
let driftMatch: { entry: BucketEntry; gap: number; signal: 'desc' | 'cross-channel' } | undefined
const incomingHere = incomingByBucket.get(bucketKey) ?? 0
for (let delta = 1; delta <= DATE_DRIFT_WINDOW_DAYS && !driftMatch; delta++) {
for (const sign of [-1, 1] as const) {
const adjKey = contentBucketKey(shiftIsoDate(raw.date, sign * delta), raw.amount)
const entries = storedByBucketForDrift.get(adjKey)
if (!entries) continue
// Cross-feed count-symmetry across the drift: equal counts of incoming
// rows in THIS bucket and account-compatible cross-feed rows one day
// over — the cross-channel mirror, displaced by a date drift.
const adjCrossFeed = entries.filter(
(e) =>
e.isImportFeed &&
e.source !== batchSource &&
(cashAccountId === null || e.cashAccountId === null || e.cashAccountId === cashAccountId),
).length
const mirrorSymmetric = adjCrossFeed > 0 && incomingHere === adjCrossFeed
for (const entry of entries) {
const sameAccount =
cashAccountId === null || entry.cashAccountId === null || entry.cashAccountId === cashAccountId
if (!sameAccount) continue
if (descriptionsBridge(description, entry.desc)) {
driftMatch = { entry, gap: sign * delta, signal: 'desc' }
break
}
if (mirrorSymmetric && entry.isImportFeed && entry.source !== batchSource) {
driftMatch = { entry, gap: sign * delta, signal: 'cross-channel' }
break
}
}
if (driftMatch) break
}
}
if (driftMatch) {
result.shadow_date_drift_candidates = (result.shadow_date_drift_candidates ?? 0) + 1
log.info('import dedup shadow: date-drift candidate', {
decision: 'date-drift',
mode: 'shadow',
signal: driftMatch.signal,
dayGap: driftMatch.gap,
bucket: bucketKey,
incomingExternalId: raw.external_id,
incomingDescription: description,
incomingAmount: raw.amount,
incomingSource: raw.import_source ?? null,
cashAccountId,
matchedStoredExternalId: driftMatch.entry.externalId,
matchedStoredDescription: driftMatch.entry.desc,
matchedStoredCashAccountId: driftMatch.entry.cashAccountId,
})
}
}
// 2. Insert new transaction (with SEK conversion for foreign currencies)
const rateInfo = raw.currency && raw.currency !== 'SEK'
? exchangeRatesByDate.get(`${raw.currency}|${raw.date}`)