Files
accounted/lib/reconciliation/skattekonto-reconciliation.ts
T
f266c386f3 chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers (#2150)
* chore: repo-wide bloat sweep, remove dead code and fold duplicate helpers

Remove 33 dead files, ~270 unreferenced exports/types, 13 dead i18n
namespaces and 4 unused dependencies; fold byte-identical helper copies
into one canonical home each (lib/utils chunk/sleep/utcDateStamp,
lib/dates/iso, lib/invariants/uuid, lib/xml/escape, lib/reports/sru/format,
lib/pdf/number-text, lib/browser/panel-request, lib/api/v1/body +
v1ValidationError rolled out to ~55 v1 routes, booking-template schemas).

No behaviour change: v1 bodies and status codes, MCP tool schemas, DB
writes and money math are untouched. Naive ore rounding was deliberately
not swapped for roundOre; see DECISIONS.md 2026-09-02 for the full list
of things left alone on purpose.

tsc, lint, 19588 unit tests and check:guards green; antipattern baseline
ratcheted (naive-ore-round 622 -> 620, hand-rolled-invariant 115 -> 113).

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

* test(transactions): import RawTransaction from @/types after the ingest re-export removal

CI's type ratchet (check:types, full tsconfig) caught the one test file
that still imported the type through lib/transactions/ingest.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-02 11:51:16 +02:00

567 lines
20 KiB
TypeScript

import { chunk } from '@/lib/utils'
import type { SupabaseClient } from '@supabase/supabase-js'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import { fetchEntryLines, type EntryLinesQuery } from '@/lib/bookkeeping/entry-lines'
import { SKATTEKONTO_ACCOUNT } from '@/lib/skatteverket/manual-verifikat-prefill'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { addDaysIso, daysBetweenIso, toIsoDate } from '@/lib/dates/iso'
import { LEDGER_BALANCE_STATUSES, sumAccountBalance } from './gl-balance'
import {
AWAITING_EXTERNAL_DAYS,
SKATTEKONTO_ACCOUNT_KEY,
STALE_AFTER_DAYS,
type BridgeLine,
type ReconciliationItem,
type ReconciliationProposal,
type ReconciliationStatus,
} from './schemas'
const log = createLogger('reconciliation/skattekonto')
/**
* Where the skatteverket extension caches the saldo it last fetched. The
* extension writes it through its settings accessor (extension_data keyed by
* company + extension + key); core reads the same row directly so this engine
* works without importing the extension (core must never import
* `@/extensions/*`).
*/
const SKATTEVERKET_EXTENSION_ID = 'skatteverket'
const BALANCE_SNAPSHOT_KEY = 'skattekonto_balance_snapshot'
/** Per-bucket cap on returned items; counts and totals are always complete. */
const MAX_ITEMS_PER_BUCKET = 500
const ENTRY_ID_CHUNK = 100
type EntryStatus = 'draft' | 'posted' | 'reversed'
interface SkattekontoRow {
id: string
transaktionsdatum: string
forfallodatum: string | null
transaktionstext: string
belopp_skatteverket: number | string
status: 'booked' | 'upcoming'
journal_entry_id: string | null
suggested_journal_entry_id: string | null
is_ignored: boolean | null
}
interface EntryHead {
id: string
status: EntryStatus
voucher_number: number | null
voucher_series: string | null
entry_date: string
description: string | null
source_type: string | null
}
interface LedgerLineRow {
debit_amount: number | string | null
credit_amount: number | string | null
journal_entries: EntryHead
}
export interface SkattekontoReconciliationOptions {
/** YYYY-MM-DD used when no snapshot exists and for staleness; defaults to now (UTC). */
today?: string
/**
* Optional window that scopes the ITEM LISTS (what the page shows). The
* bridge is anchored at the snapshot instant and is never windowed: the
* saldo is cumulative, so is the ledger. Unmatched rows older than the
* window are counted in `older_unmatched_count` so a window can never hide
* work.
*/
windowFrom?: string | null
windowTo?: string | null
}
export interface SkattekontoReconciliationItems {
proposed: ReconciliationItem[]
unmatched_external: ReconciliationItem[]
unmatched_ledger: ReconciliationItem[]
matched: ReconciliationItem[]
ignored: ReconciliationItem[]
upcoming: ReconciliationItem[]
}
export interface SkattekontoReconciliationResult extends ReconciliationStatus {
items: SkattekontoReconciliationItems
/** Buckets whose item list was capped at MAX_ITEMS_PER_BUCKET. */
items_truncated: Array<keyof SkattekontoReconciliationItems>
/** Unmatched rows (either side) dated before windowFrom, when a window was given. */
older_unmatched_count: number
/** Ledger read failed: balances and the residual are null, buckets are still listed. */
ledger_read_failed: boolean
}
function parseFetchedAt(value: unknown): Date | null {
if (typeof value === 'number' && Number.isFinite(value)) return new Date(value)
if (typeof value === 'string') {
const asNumber = Number(value)
const d = /^\d+$/.test(value) ? new Date(asNumber) : new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
return null
}
async function readSnapshot(
supabase: SupabaseClient,
companyId: string,
): Promise<{ saldo: number; fetchedAt: Date } | null> {
const { data, error } = await supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', SKATTEVERKET_EXTENSION_ID)
.eq('key', BALANCE_SNAPSHOT_KEY)
.maybeSingle()
if (error || !data?.value) return null
const value = data.value as { saldo?: { saldoSkatteverket?: unknown }; fetchedAt?: unknown }
const fetchedAt = parseFetchedAt(value.fetchedAt)
const saldo = Number(value.saldo?.saldoSkatteverket)
if (!fetchedAt || !Number.isFinite(saldo)) return null
return { saldo: roundOre(saldo), fetchedAt }
}
async function fetchRows(supabase: SupabaseClient, companyId: string): Promise<SkattekontoRow[]> {
return fetchAllRows<SkattekontoRow>(
({ from, to }) =>
supabase
.from('skattekonto_transactions')
.select(
'id, transaktionsdatum, forfallodatum, transaktionstext, belopp_skatteverket, status, journal_entry_id, suggested_journal_entry_id, is_ignored',
)
.eq('company_id', companyId)
.order('transaktionsdatum', { ascending: true })
.order('id', { ascending: true })
.range(from, to),
{ dedupeBy: (r) => r.id },
)
}
async function fetchEntryHeads(
supabase: SupabaseClient,
companyId: string,
ids: string[],
): Promise<Map<string, EntryHead>> {
const out = new Map<string, EntryHead>()
for (const part of chunk(Array.from(new Set(ids)), ENTRY_ID_CHUNK)) {
const { data, error } = await supabase
.from('journal_entries')
.select('id, status, voucher_number, voucher_series, entry_date, description, source_type')
.eq('company_id', companyId)
.in('id', part)
if (error) throw new Error(`Kunde inte läsa verifikat: ${error.message}`)
for (const e of (data ?? []) as EntryHead[]) out.set(e.id, e)
}
return out
}
/**
* All 1630 movement per journal entry in [fromDate, cutoffDate], over posted
* + reversed entries (the ledger-balance predicate). One item per entry: an
* entry with several 1630 lines nets them, which is also what a link settles.
*/
async function fetchLedgerEntries(
supabase: SupabaseClient,
companyId: string,
fromDate: string | null,
cutoffDate: string,
): Promise<Map<string, { head: EntryHead; amount: number }>> {
const lines = await fetchEntryLines<LedgerLineRow>({
supabase,
entryColumns: 'id, status, voucher_number, voucher_series, entry_date, description, source_type',
lineColumns: 'debit_amount, credit_amount',
filterEntries: (q: EntryLinesQuery) => {
let query = q
.eq('company_id', companyId)
.in('status', [...LEDGER_BALANCE_STATUSES])
.lte('entry_date', cutoffDate)
if (fromDate) query = query.gte('entry_date', fromDate)
return query
},
filterLines: (q: EntryLinesQuery) => q.eq('account_number', SKATTEKONTO_ACCOUNT),
})
const byEntry = new Map<string, { head: EntryHead; amount: number }>()
for (const line of lines) {
const head = line.journal_entries
if (!head) continue
const amount = Number(line.debit_amount || 0) - Number(line.credit_amount || 0)
const existing = byEntry.get(head.id)
if (existing) existing.amount = roundOre(existing.amount + amount)
else byEntry.set(head.id, { head, amount: roundOre(amount) })
}
return byEntry
}
function proposalFrom(head: EntryHead, row: SkattekontoRow): ReconciliationProposal {
return {
journal_entry_id: head.id,
voucher_number: head.voucher_number,
voucher_series: head.voucher_series,
entry_date: head.entry_date,
description: head.description ?? '',
entry_status: head.status,
confidence: head.status === 'posted' ? 0.95 : 0.8,
reasons: [
'exakt belopp på 1630',
`${Math.abs(daysBetweenIso(row.transaktionsdatum, head.entry_date))} dagars avstånd`,
],
}
}
function inWindow(date: string, from: string | null | undefined, to: string | null | undefined): boolean {
if (from && date < from) return false
if (to && date > to) return false
return true
}
/**
* The skattekonto (BAS 1630) reconciliation: what Skatteverket says, what the
* ledger says, and the rows that explain the gap.
*
* Identity (matched pairs cancel by construction, see dev design
* "Avstämningsmotorn"):
*
* saldo_at_start = saldo_skatteverket - sum(all SKV-posted rows we hold)
* opening_difference = saldo_at_start - ledger balance before history_start
* difference = saldo_skatteverket - ledger balance at the snapshot
* unexplained = difference - opening_difference
* - sum(unlinked SKV rows) - sum(ignored SKV rows)
* + sum(unlinked 1630 entries)
*
* Every link pairs equal amounts on the expected side, so `unexplained` is
* 0,00 whenever the data is consistent. A non-zero value is an integrity
* finding (a link to an entry whose 1630 line changed, a read that disagrees
* with the trial balance), never a user task. The user's work is the bridge.
*
* Anchored at the snapshot instant; the ledger is summed with
* entry_date <= the snapshot date so a verifikat booked later today cannot
* fabricate a gap. A link to a reversed or draft entry is a dead link: the
* row is treated as unlinked and flagged, because the ledger no longer counts
* that 1630 line the way the link assumed.
*
* Returns null when the company has neither a saldo snapshot nor any
* skattekonto rows (account not configured).
*/
export async function getSkattekontoReconciliationStatus(
supabase: SupabaseClient,
companyId: string,
options: SkattekontoReconciliationOptions = {},
): Promise<SkattekontoReconciliationResult | null> {
const today = options.today ?? toIsoDate(new Date())
const [snapshot, rows] = await Promise.all([
readSnapshot(supabase, companyId),
fetchRows(supabase, companyId),
])
if (!snapshot && rows.length === 0) return null
const cutoffDate = snapshot ? toIsoDate(snapshot.fetchedAt) : today
const asOf = snapshot ? snapshot.fetchedAt.toISOString() : new Date(today + 'T00:00:00Z').toISOString()
const stale = !snapshot || daysBetweenIso(cutoffDate, today) > STALE_AFTER_DAYS
const booked = rows.filter((r) => r.status === 'booked' && r.transaktionsdatum <= cutoffDate)
const upcoming = rows.filter((r) => r.status === 'upcoming' && !r.is_ignored)
const historyStart = booked.length > 0 ? booked[0].transaktionsdatum : null
// Linked and suggested entries: one chunked read gives link liveness and
// the voucher facts the proposals carry.
const referencedIds = booked.flatMap((r) =>
[r.journal_entry_id, r.suggested_journal_entry_id].filter((x): x is string => !!x),
)
const heads = await fetchEntryHeads(supabase, companyId, referencedIds)
// Ledger side. Both reads may fail independently of the row reads; a
// failed balance read yields null balances and a null residual rather than
// a fabricated 0 (same posture as the drift check).
let ledgerReadFailed = false
let ledgerEntries = new Map<string, { head: EntryHead; amount: number }>()
try {
ledgerEntries = await fetchLedgerEntries(supabase, companyId, historyStart, cutoffDate)
} catch (err) {
ledgerReadFailed = true
log.warn('ledger entries read failed', {
companyId,
error: err instanceof Error ? err.message : String(err),
})
}
const [ledgerBalance, ledgerBefore] = await Promise.all([
sumAccountBalance(supabase, companyId, SKATTEKONTO_ACCOUNT, { cutoffDate }),
historyStart
? sumAccountBalance(supabase, companyId, SKATTEKONTO_ACCOUNT, { beforeDate: historyStart })
: Promise.resolve<number | null>(0),
])
if (ledgerBalance === null || ledgerBefore === null) ledgerReadFailed = true
const liveLinkedEntryIds = new Set<string>()
const items: SkattekontoReconciliationItems = {
proposed: [],
unmatched_external: [],
unmatched_ledger: [],
matched: [],
ignored: [],
upcoming: [],
}
let unlinkedExternalTotal = 0
let ignoredTotal = 0
let olderUnmatched = 0
const counts = { proposed: 0, unmatched_external: 0, unmatched_ledger: 0, matched: 0, ignored: 0 }
const pushCapped = (bucket: keyof SkattekontoReconciliationItems, item: ReconciliationItem) => {
if (items[bucket].length < MAX_ITEMS_PER_BUCKET) items[bucket].push(item)
}
const visible = (date: string) => inWindow(date, options.windowFrom, options.windowTo)
const olderThanWindow = (date: string) => !!options.windowFrom && date < options.windowFrom
for (const row of booked) {
const amount = roundOre(Number(row.belopp_skatteverket))
const base = {
item_id: row.id,
item_type: 'skattekonto_transaction' as const,
side: 'external' as const,
date: row.transaktionsdatum,
description: row.transaktionstext,
amount,
currency: 'SEK',
}
if (row.is_ignored) {
ignoredTotal = roundOre(ignoredTotal + amount)
counts.ignored++
if (visible(row.transaktionsdatum)) {
pushCapped('ignored', { ...base, bucket: 'ignored', actions: ['unignore'] })
}
continue
}
const linkedHead = row.journal_entry_id ? heads.get(row.journal_entry_id) : undefined
const linkProblem: ReconciliationItem['link_problem'] = row.journal_entry_id
? !linkedHead
? 'entry_missing'
: linkedHead.status === 'reversed'
? 'entry_reversed'
: linkedHead.status === 'draft'
? 'entry_draft'
: null
: null
if (row.journal_entry_id && linkedHead && linkProblem === null) {
liveLinkedEntryIds.add(row.journal_entry_id)
counts.matched++
if (visible(row.transaktionsdatum)) {
pushCapped('matched', {
...base,
bucket: 'matched',
linked_journal_entry_id: row.journal_entry_id,
voucher_number: linkedHead.voucher_number,
voucher_series: linkedHead.voucher_series,
entry_status: linkedHead.status,
actions: ['unmatch'],
})
}
continue
}
// Unlinked (or dead link): counts toward the bridge either way.
unlinkedExternalTotal = roundOre(unlinkedExternalTotal + amount)
if (olderThanWindow(row.transaktionsdatum)) olderUnmatched++
const suggestedHead = row.suggested_journal_entry_id
? heads.get(row.suggested_journal_entry_id)
: undefined
const proposal =
suggestedHead && suggestedHead.status !== 'reversed' && !liveLinkedEntryIds.has(suggestedHead.id)
? proposalFrom(suggestedHead, row)
: null
if (proposal) {
counts.proposed++
if (visible(row.transaktionsdatum)) {
pushCapped('proposed', {
...base,
bucket: 'proposed',
linked_journal_entry_id: row.journal_entry_id,
link_problem: linkProblem,
proposal,
actions: ['match', 'book', 'ignore'],
})
}
} else {
counts.unmatched_external++
if (visible(row.transaktionsdatum)) {
pushCapped('unmatched_external', {
...base,
bucket: 'unmatched_external',
linked_journal_entry_id: row.journal_entry_id,
link_problem: linkProblem,
proposal: null,
actions: linkProblem ? ['match', 'book', 'unmatch'] : ['book', 'match', 'ignore'],
})
}
}
}
// Ledger entries in the comparable history that no live link settles.
let unlinkedLedgerTotal = 0
const awaitingFrom = addDaysIso(cutoffDate, -AWAITING_EXTERNAL_DAYS)
const sortedLedger = Array.from(ledgerEntries.values()).sort((a, b) =>
a.head.entry_date < b.head.entry_date ? -1 : a.head.entry_date > b.head.entry_date ? 1 : 0,
)
for (const { head, amount } of sortedLedger) {
if (liveLinkedEntryIds.has(head.id)) continue
if (amount === 0) continue
unlinkedLedgerTotal = roundOre(unlinkedLedgerTotal + amount)
counts.unmatched_ledger++
if (olderThanWindow(head.entry_date)) olderUnmatched++
if (!visible(head.entry_date)) continue
pushCapped('unmatched_ledger', {
item_id: head.id,
item_type: 'journal_entry',
side: 'ledger',
bucket: 'unmatched_ledger',
date: head.entry_date,
description: head.description ?? '',
amount,
currency: 'SEK',
voucher_number: head.voucher_number,
voucher_series: head.voucher_series,
entry_status: head.status,
awaiting_external: head.entry_date >= awaitingFrom,
actions: ['review', 'match'],
})
}
let upcomingTotal = 0
for (const row of upcoming) {
const amount = roundOre(Number(row.belopp_skatteverket))
upcomingTotal = roundOre(upcomingTotal + amount)
pushCapped('upcoming', {
item_id: row.id,
item_type: 'skattekonto_transaction',
side: 'external',
bucket: 'upcoming',
date: row.forfallodatum ?? row.transaktionsdatum,
description: row.transaktionstext,
amount,
currency: 'SEK',
actions: [],
})
}
// Totals and the identity.
const allBookedTotal = booked.reduce((s, r) => roundOre(s + Number(r.belopp_skatteverket)), 0)
const saldo = snapshot?.saldo ?? null
const saldoAtStart = saldo === null ? null : roundOre(saldo - allBookedTotal)
const openingDifference =
saldoAtStart === null || ledgerBefore === null ? null : roundOre(saldoAtStart - ledgerBefore)
const difference = saldo === null || ledgerBalance === null ? null : roundOre(saldo - ledgerBalance)
const unexplained =
difference === null || openingDifference === null
? null
: roundOre(
difference - openingDifference - unlinkedExternalTotal - ignoredTotal + unlinkedLedgerTotal,
)
const isReconciled =
!!snapshot &&
!ledgerReadFailed &&
counts.unmatched_external === 0 &&
counts.proposed === 0 &&
counts.unmatched_ledger === 0 &&
Math.abs(openingDifference ?? 0) < 0.01 &&
Math.abs(unexplained ?? 0) < 0.01
const bridge: BridgeLine[] = [
{
key: 'external_balance',
label_sv: 'Saldo hos Skatteverket',
label_en: 'Balance at Skatteverket',
amount: saldo ?? 0,
count: null,
items_bucket: null,
},
{
key: 'unmatched_external',
label_sv: 'Händelser som saknas i bokföringen',
label_en: 'Events missing from the ledger',
amount: roundOre(-unlinkedExternalTotal),
count: counts.unmatched_external + counts.proposed,
items_bucket: 'unmatched_external',
},
{
key: 'unmatched_ledger',
label_sv: 'Rader på 1630 utan händelse hos Skatteverket',
label_en: '1630 lines without a Skatteverket event',
amount: unlinkedLedgerTotal,
count: counts.unmatched_ledger,
items_bucket: 'unmatched_ledger',
},
]
if (counts.ignored > 0) {
bridge.push({
key: 'ignored',
label_sv: 'Ignorerade händelser',
label_en: 'Ignored events',
amount: roundOre(-ignoredTotal),
count: counts.ignored,
items_bucket: 'ignored',
})
}
if (openingDifference !== null && Math.abs(openingDifference) >= 0.01) {
bridge.push({
key: 'opening_difference',
label_sv: `Ingående skillnad per ${historyStart ?? cutoffDate}`,
label_en: `Opening difference at ${historyStart ?? cutoffDate}`,
amount: roundOre(-openingDifference),
count: null,
items_bucket: null,
})
}
bridge.push({
key: 'ledger_balance',
label_sv: 'Bokfört på 1630',
label_en: 'Booked on 1630',
amount: ledgerBalance ?? 0,
count: null,
items_bucket: null,
})
const truncated = (Object.keys(items) as Array<keyof SkattekontoReconciliationItems>).filter(
(k) => items[k].length >= MAX_ITEMS_PER_BUCKET,
)
return {
account_key: SKATTEKONTO_ACCOUNT_KEY,
kind: 'skattekonto',
account_number: SKATTEKONTO_ACCOUNT,
currency: 'SEK',
window: { from: options.windowFrom ?? null, to: options.windowTo ?? null },
as_of: asOf,
stale,
external_balance: saldo,
ledger_balance: ledgerBalance,
difference,
unexplained_difference: unexplained,
is_reconciled: isReconciled,
bridge,
counts,
skattekonto: {
saldo_skatteverket: saldo,
fetched_at: snapshot ? snapshot.fetchedAt.toISOString() : null,
history_start: historyStart,
opening_difference: openingDifference,
upcoming_count: upcoming.length,
upcoming_total: upcomingTotal,
ledger_balance_before_start: ledgerBefore,
},
bank: null,
items,
items_truncated: truncated,
older_unmatched_count: olderUnmatched,
ledger_read_failed: ledgerReadFailed,
}
}