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>
This commit is contained in:
Jakob Wennberg
2026-09-02 11:51:16 +02:00
committed by GitHub
co-authored by Claude Fable 5.1 Jakob Wennberg
parent 18cbc4c30a
commit f266c386f3
359 changed files with 1796 additions and 11199 deletions
+2 -1
View File
@@ -1887,7 +1887,8 @@ export async function autoReconcileTransactionForLinkedVoucher(
* Fetch unlinked GL lines for a settlement account. `accountNumber` defaults to
* '1930' for back-compat; multi-account customers (Plusgiro 1920, kreditkort
* 1940, EUR-konto 1932, etc.) pass the BAS code of the account they're
* reconciling. The CashAccountSelector populates this from cash_accounts.
* reconciling. The reconciliation UI populates this from cash_accounts.
*/
export async function fetchUnlinkedGLLines(
supabase: SupabaseClient,
+27 -2
View File
@@ -21,10 +21,35 @@ export const ACCOUNT_KEY_REGEX =
/^(bank:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}|skattekonto|manual:\d{4})$/
export const AccountKeySchema = z.string().regex(ACCOUNT_KEY_REGEX, 'Ogiltig account_key')
export type AccountKey = z.infer<typeof AccountKeySchema>
export const ReconciliationKindSchema = z.enum(['bank', 'skattekonto', 'manual'])
export type ReconciliationKind = z.infer<typeof ReconciliationKindSchema>
/** One link request: outside rows against verifikat (N:1), or a bank 1:N with allocations. */
export const ReconciliationPairSchema = z.object({
external_ids: z.array(z.string().uuid()).min(1).max(50),
journal_entry_ids: z.array(z.string().uuid()).min(1).max(50),
// Bank 1:N only: the signed slice per verifikat (transaction sign
// convention). Omitted: each slice defaults to the voucher's bank line.
allocations: z
.array(z.object({ journal_entry_id: z.string().uuid(), amount: z.number() }))
.min(2)
.max(50)
.optional(),
})
/** Body fields shared by the dashboard and v1 POST .../links routes (spread into z.object). */
export const reconciliationLinksBodyFields = {
pairs: z.array(ReconciliationPairSchema).max(200).optional(),
use_proposals: z.boolean().optional(),
confidence_threshold: z.number().min(0).max(1).optional(),
}
/** The links body must carry explicit pairs or opt into the persisted proposals. */
export const reconciliationLinksBodyRefinement = [
(b: { pairs?: unknown[]; use_proposals?: boolean }) =>
(b.pairs && b.pairs.length > 0) || b.use_proposals === true,
{ message: 'Ange pairs eller use_proposals: true.' },
] as const
export type ParsedAccountKey =
| { kind: 'bank'; cashAccountId: string }
+5 -13
View File
@@ -1,6 +1,7 @@
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { daysBetweenIso, toIsoDate } from '@/lib/dates/iso'
import { getReconciliationStatus as getBankReconciliationStatus } from './bank-reconciliation'
import { getSkattekontoReconciliationStatus } from './skattekonto-reconciliation'
import {
@@ -59,15 +60,6 @@ interface CashAccountRow {
updated_at: string | null
}
function isoDate(d: Date): string {
return d.toISOString().slice(0, 10)
}
function daysBetween(a: string, b: string): number {
const ms = new Date(a + 'T00:00:00Z').getTime() - new Date(b + 'T00:00:00Z').getTime()
return Math.round(ms / 86_400_000)
}
function defaultWindow(today: string): { from: string; to: string } {
return { from: `${today.slice(0, 4)}-01-01`, to: today }
}
@@ -158,7 +150,7 @@ async function bankStatus(
Boolean(account.is_primary),
)
const syncedAt = await latestBankSyncAt(supabase, companyId, account.id)
const stale = !syncedAt || daysBetween(today, syncedAt.slice(0, 10)) > STALE_AFTER_DAYS
const stale = !syncedAt || daysBetweenIso(syncedAt.slice(0, 10), today) > STALE_AFTER_DAYS
// The bank-reported (booked) balance, mirrored from the last PSD2 balance
// refresh. Point-in-time and dated by balance_updated_at, NOT by any
// through-date a caller asks for. It therefore lives ONLY in the bank block
@@ -240,7 +232,7 @@ export async function listReconciliationAccounts(
companyId: string,
options: ListAccountsOptions = {},
): Promise<ReconciliationAccount[]> {
const today = options.today ?? isoDate(new Date())
const today = options.today ?? toIsoDate(new Date())
const withStatus = options.withStatus ?? true
const window = {
from: options.windowFrom ?? defaultWindow(today).from,
@@ -320,7 +312,7 @@ export async function listReconciliationAccounts(
} catch {
syncedAt = null
}
const stale = !syncedAt || daysBetween(today, syncedAt.slice(0, 10)) > STALE_AFTER_DAYS
const stale = !syncedAt || daysBetweenIso(syncedAt.slice(0, 10), today) > STALE_AFTER_DAYS
return {
account_key: bankAccountKey(a.id),
kind: 'bank',
@@ -406,7 +398,7 @@ export async function getAccountStatus(
): Promise<ReconciliationStatus | null> {
const parsed = parseAccountKey(accountKey)
if (!parsed) return null
const today = options.today ?? isoDate(new Date())
const today = options.today ?? toIsoDate(new Date())
let status: ReconciliationStatus | null = null
if (parsed.kind === 'skattekonto') {
+2 -5
View File
@@ -3,6 +3,7 @@ import { ISO_DATE_RE } from '@/lib/invariants'
import { eventBus } from '@/lib/events/bus'
import { createLogger } from '@/lib/logger'
import { roundOre } from '@/lib/money'
import { todayIsoUtc } from '@/lib/dates/iso'
import { parseAccountKey, type ReconciliationSignoff, type ReconciliationStatus } from './schemas'
import { getAccountStatus } from './service'
import { getLatestSignoff, getSignoffById, insertSignoff, stampReopen } from './signoff-store'
@@ -81,10 +82,6 @@ export type SignoffResult =
| { dry_run: true; would_sign: SignoffPreview }
| { dry_run: false; signoff: ReconciliationSignoff }
function isoToday(): string {
return new Date().toISOString().slice(0, 10)
}
/**
* Sign one account off through a date. Returns null when the account key does
* not resolve for this company (callers map that to 404); throws
@@ -100,7 +97,7 @@ export async function signOffAccount(
): Promise<SignoffResult | null> {
const parsed = parseAccountKey(accountKey)
if (!parsed) return null
const today = options.today ?? isoToday()
const today = options.today ?? todayIsoUtc()
const throughDate = input.through_date
if (!ISO_DATE_RE.test(throughDate) || Number.isNaN(Date.parse(throughDate))) {
throw new ReconciliationSignoffError('Ogiltigt datum. Ange ÅÅÅÅ-MM-DD.', 'INVALID_DATE')
@@ -1,9 +1,11 @@
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,
@@ -95,20 +97,6 @@ export interface SkattekontoReconciliationResult extends ReconciliationStatus {
ledger_read_failed: boolean
}
function round2(n: number): number {
return roundOre(n)
}
function addDays(iso: string, days: number): string {
const d = new Date(iso + 'T00:00:00Z')
d.setUTCDate(d.getUTCDate() + days)
return d.toISOString().slice(0, 10)
}
function isoDate(d: Date): string {
return d.toISOString().slice(0, 10)
}
function parseFetchedAt(value: unknown): Date | null {
if (typeof value === 'number' && Number.isFinite(value)) return new Date(value)
if (typeof value === 'string') {
@@ -119,12 +107,6 @@ function parseFetchedAt(value: unknown): Date | null {
return null
}
function chunk<T>(xs: T[], size: number): T[][] {
const out: T[][] = []
for (let i = 0; i < xs.length; i += size) out.push(xs.slice(i, i + size))
return out
}
async function readSnapshot(
supabase: SupabaseClient,
companyId: string,
@@ -141,7 +123,7 @@ async function readSnapshot(
const fetchedAt = parseFetchedAt(value.fetchedAt)
const saldo = Number(value.saldo?.saldoSkatteverket)
if (!fetchedAt || !Number.isFinite(saldo)) return null
return { saldo: round2(saldo), fetchedAt }
return { saldo: roundOre(saldo), fetchedAt }
}
async function fetchRows(supabase: SupabaseClient, companyId: string): Promise<SkattekontoRow[]> {
@@ -209,8 +191,8 @@ async function fetchLedgerEntries(
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 = round2(existing.amount + amount)
else byEntry.set(head.id, { head, amount: round2(amount) })
if (existing) existing.amount = roundOre(existing.amount + amount)
else byEntry.set(head.id, { head, amount: roundOre(amount) })
}
return byEntry
}
@@ -226,16 +208,11 @@ function proposalFrom(head: EntryHead, row: SkattekontoRow): ReconciliationPropo
confidence: head.status === 'posted' ? 0.95 : 0.8,
reasons: [
'exakt belopp på 1630',
`${Math.abs(daysBetween(head.entry_date, row.transaktionsdatum))} dagars avstånd`,
`${Math.abs(daysBetweenIso(row.transaktionsdatum, head.entry_date))} dagars avstånd`,
],
}
}
function daysBetween(a: string, b: string): number {
const ms = new Date(a + 'T00:00:00Z').getTime() - new Date(b + 'T00:00:00Z').getTime()
return Math.round(ms / 86_400_000)
}
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
@@ -275,16 +252,16 @@ export async function getSkattekontoReconciliationStatus(
companyId: string,
options: SkattekontoReconciliationOptions = {},
): Promise<SkattekontoReconciliationResult | null> {
const today = options.today ?? isoDate(new Date())
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 ? isoDate(snapshot.fetchedAt) : today
const cutoffDate = snapshot ? toIsoDate(snapshot.fetchedAt) : today
const asOf = snapshot ? snapshot.fetchedAt.toISOString() : new Date(today + 'T00:00:00Z').toISOString()
const stale = !snapshot || daysBetween(today, cutoffDate) > STALE_AFTER_DAYS
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)
@@ -340,7 +317,7 @@ export async function getSkattekontoReconciliationStatus(
const olderThanWindow = (date: string) => !!options.windowFrom && date < options.windowFrom
for (const row of booked) {
const amount = round2(Number(row.belopp_skatteverket))
const amount = roundOre(Number(row.belopp_skatteverket))
const base = {
item_id: row.id,
item_type: 'skattekonto_transaction' as const,
@@ -352,7 +329,7 @@ export async function getSkattekontoReconciliationStatus(
}
if (row.is_ignored) {
ignoredTotal = round2(ignoredTotal + amount)
ignoredTotal = roundOre(ignoredTotal + amount)
counts.ignored++
if (visible(row.transaktionsdatum)) {
pushCapped('ignored', { ...base, bucket: 'ignored', actions: ['unignore'] })
@@ -389,7 +366,7 @@ export async function getSkattekontoReconciliationStatus(
}
// Unlinked (or dead link): counts toward the bridge either way.
unlinkedExternalTotal = round2(unlinkedExternalTotal + amount)
unlinkedExternalTotal = roundOre(unlinkedExternalTotal + amount)
if (olderThanWindow(row.transaktionsdatum)) olderUnmatched++
const suggestedHead = row.suggested_journal_entry_id
@@ -429,14 +406,14 @@ export async function getSkattekontoReconciliationStatus(
// Ledger entries in the comparable history that no live link settles.
let unlinkedLedgerTotal = 0
const awaitingFrom = addDays(cutoffDate, -AWAITING_EXTERNAL_DAYS)
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 = round2(unlinkedLedgerTotal + amount)
unlinkedLedgerTotal = roundOre(unlinkedLedgerTotal + amount)
counts.unmatched_ledger++
if (olderThanWindow(head.entry_date)) olderUnmatched++
if (!visible(head.entry_date)) continue
@@ -459,8 +436,8 @@ export async function getSkattekontoReconciliationStatus(
let upcomingTotal = 0
for (const row of upcoming) {
const amount = round2(Number(row.belopp_skatteverket))
upcomingTotal = round2(upcomingTotal + amount)
const amount = roundOre(Number(row.belopp_skatteverket))
upcomingTotal = roundOre(upcomingTotal + amount)
pushCapped('upcoming', {
item_id: row.id,
item_type: 'skattekonto_transaction',
@@ -475,16 +452,16 @@ export async function getSkattekontoReconciliationStatus(
}
// Totals and the identity.
const allBookedTotal = booked.reduce((s, r) => round2(s + Number(r.belopp_skatteverket)), 0)
const allBookedTotal = booked.reduce((s, r) => roundOre(s + Number(r.belopp_skatteverket)), 0)
const saldo = snapshot?.saldo ?? null
const saldoAtStart = saldo === null ? null : round2(saldo - allBookedTotal)
const saldoAtStart = saldo === null ? null : roundOre(saldo - allBookedTotal)
const openingDifference =
saldoAtStart === null || ledgerBefore === null ? null : round2(saldoAtStart - ledgerBefore)
const difference = saldo === null || ledgerBalance === null ? null : round2(saldo - ledgerBalance)
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
: round2(
: roundOre(
difference - openingDifference - unlinkedExternalTotal - ignoredTotal + unlinkedLedgerTotal,
)
@@ -510,7 +487,7 @@ export async function getSkattekontoReconciliationStatus(
key: 'unmatched_external',
label_sv: 'Händelser som saknas i bokföringen',
label_en: 'Events missing from the ledger',
amount: round2(-unlinkedExternalTotal),
amount: roundOre(-unlinkedExternalTotal),
count: counts.unmatched_external + counts.proposed,
items_bucket: 'unmatched_external',
},
@@ -528,7 +505,7 @@ export async function getSkattekontoReconciliationStatus(
key: 'ignored',
label_sv: 'Ignorerade händelser',
label_en: 'Ignored events',
amount: round2(-ignoredTotal),
amount: roundOre(-ignoredTotal),
count: counts.ignored,
items_bucket: 'ignored',
})
@@ -538,7 +515,7 @@ export async function getSkattekontoReconciliationStatus(
key: 'opening_difference',
label_sv: `Ingående skillnad per ${historyStart ?? cutoffDate}`,
label_en: `Opening difference at ${historyStart ?? cutoffDate}`,
amount: round2(-openingDifference),
amount: roundOre(-openingDifference),
count: null,
items_bucket: null,
})