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:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
18cbc4c30a
commit
f266c386f3
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user