Files
accounted/lib/reconciliation/service.ts
T
Jakob Wennberg 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

463 lines
17 KiB
TypeScript

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 {
bankAccountKey,
parseAccountKey,
SKATTEKONTO_ACCOUNT_KEY,
STALE_AFTER_DAYS,
type BridgeLine,
type ReconciliationAccount,
type ReconciliationStatus,
} from './schemas'
import { getLatestSignoff, getLatestSignoffs } from './signoff-store'
import { bankLogoUrl } from './bank-logos'
import { getManualReconciliationStatus, listManualAccounts } from './manual-reconciliation'
const log = createLogger('reconciliation/service')
/**
* The account-keyed reconciliation facade: one engine, three doors.
*
* The dashboard routes, the public v1 API and the MCP tools all call these
* functions; none of them re-implements bank or skattekonto logic. Kind
* adapters (bank via bank-reconciliation.ts, skattekonto via
* skattekonto-reconciliation.ts, every other balance account via
* manual-reconciliation.ts) hang off `account_key`, so adding an account
* type is one adapter, never a new set of endpoints.
*
* Core runs with zero extensions: the skattekonto adapter reads the core
* `skattekonto_transactions` table and the snapshot row the extension leaves
* in `extension_data`, and simply reports "not configured" when neither
* exists.
*/
export interface ListAccountsOptions {
today?: string
/** Compute status per account (N reads). Default true; the rail needs it. */
withStatus?: boolean
/** Window for the bank bridge ("i perioden"). Defaults to the calendar year of `today`. */
windowFrom?: string
windowTo?: string
}
interface CashAccountRow {
id: string
name: string | null
ledger_account: string
currency: string | null
iban: string | null
enabled: boolean | null
is_primary: boolean | null
source: string | null
bank_connection_id: string | null
balance: number | null
available_balance: number | null
balance_updated_at: string | null
updated_at: string | null
}
function defaultWindow(today: string): { from: string; to: string } {
return { from: `${today.slice(0, 4)}-01-01`, to: today }
}
async function latestBankSyncAt(
supabase: SupabaseClient,
companyId: string,
cashAccountId: string,
): Promise<string | null> {
const { data } = await supabase
.from('transactions')
.select('created_at')
.eq('company_id', companyId)
.eq('cash_account_id', cashAccountId)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
return (data?.created_at as string | undefined) ?? null
}
/** Bridge lines for the bank kind, mirroring the #1737 status card. */
function bankBridge(status: Awaited<ReturnType<typeof getBankReconciliationStatus>>, accountNumber: string): BridgeLine[] {
const lines: BridgeLine[] = [
{
key: 'bank_transactions',
label_sv: 'Rörelse på banken i perioden',
label_en: 'Movement on the bank in the period',
amount: status.bank_transaction_total,
count: null,
items_bucket: null,
},
{
key: 'unmatched_external',
label_sv: 'Omatchade banktransaktioner',
label_en: 'Unmatched bank transactions',
amount: roundOre(-status.unmatched_transaction_total),
count: status.unmatched_transaction_count,
items_bucket: 'unmatched_external',
},
]
if (status.unmatched_gl_line_total !== null) {
lines.push({
key: 'unmatched_ledger',
label_sv: `Verifikationer på ${accountNumber} utan banktransaktion`,
label_en: `Vouchers on ${accountNumber} without a bank transaction`,
amount: status.unmatched_gl_line_total,
count: status.unmatched_gl_line_count,
items_bucket: 'unmatched_ledger',
})
}
if (status.ignored_transaction_count > 0) {
lines.push({
key: 'ignored',
label_sv: 'Ignorerade transaktioner',
label_en: 'Ignored transactions',
amount: status.ignored_transaction_total,
count: status.ignored_transaction_count,
items_bucket: 'ignored',
})
}
lines.push({
key: 'ledger_balance',
label_sv: `Bokfört på ${accountNumber} i perioden`,
label_en: `Booked on ${accountNumber} in the period`,
amount: status.gl_1930_period_movement,
count: null,
items_bucket: null,
})
return lines
}
async function bankStatus(
supabase: SupabaseClient,
companyId: string,
account: CashAccountRow,
window: { from: string; to: string },
today: string,
): Promise<ReconciliationStatus> {
const currency = account.currency ?? 'SEK'
const raw = await getBankReconciliationStatus(
supabase,
companyId,
window.from,
window.to,
account.ledger_account,
currency,
account.id,
Boolean(account.is_primary),
)
const syncedAt = await latestBankSyncAt(supabase, companyId, account.id)
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
// below, never in external_balance: sign-off persists external_balance into
// account_reconciliations and bokslutsbilagor computes closing - external
// from that row, so a today-balance stored on a balansdag sign-off would
// print a phantom differens in the year-end appendix (skeptic finding,
// PR #2118). difference/unexplained stay transaction-based for the same
// reason. A balance without its timestamp is unusable (age unknown), so
// both fields are exposed only as a pair.
const reportedBalance =
account.balance == null || account.balance_updated_at == null
? null
: Number(account.balance)
const reportedAvailable =
reportedBalance == null || account.available_balance == null
? null
: Number(account.available_balance)
return {
account_key: bankAccountKey(account.id),
kind: 'bank',
account_number: account.ledger_account,
currency,
window: { from: window.from, to: window.to },
as_of: new Date().toISOString(),
stale,
external_balance: null,
ledger_balance: raw.gl_1930_period_movement,
difference: raw.difference,
unexplained_difference: raw.unexplained_difference,
is_reconciled: raw.is_reconciled,
bridge: bankBridge(raw, account.ledger_account),
counts: {
proposed: 0,
unmatched_external: raw.unmatched_transaction_count,
unmatched_ledger: raw.unmatched_gl_line_count,
matched: raw.matched_count,
ignored: raw.ignored_transaction_count,
},
skattekonto: null,
bank: {
...(raw as unknown as Record<string, unknown>),
// What the bank itself reports for the account (F7): booked +
// available + when it was fetched. Distinct from the movement fields.
bank_reported_balance: reportedBalance,
bank_reported_available_balance: reportedAvailable,
bank_balance_updated_at: reportedBalance == null ? null : account.balance_updated_at,
},
}
}
function stateOf(status: ReconciliationStatus | null): ReconciliationAccount['status'] {
if (!status) return null
const state = status.is_reconciled
? 'reconciled'
: status.stale
? 'stale'
: 'open'
return {
state,
as_of: status.as_of,
unexplained_difference: status.unexplained_difference,
open_counts: {
proposed: status.counts.proposed,
unmatched_external: status.counts.unmatched_external,
unmatched_ledger: status.counts.unmatched_ledger,
},
}
}
/**
* Every account with an outside truth, as the side list shows them: enabled
* cash accounts (deduplicated per IBAN + currency, the reconnect-duplicate
* case measured at 25 rows in 17 companies) plus the skattekonto when the
* company has a saldo snapshot or rows.
*/
export async function listReconciliationAccounts(
supabase: SupabaseClient,
companyId: string,
options: ListAccountsOptions = {},
): Promise<ReconciliationAccount[]> {
const today = options.today ?? toIsoDate(new Date())
const withStatus = options.withStatus ?? true
const window = {
from: options.windowFrom ?? defaultWindow(today).from,
to: options.windowTo ?? defaultWindow(today).to,
}
const { data, error } = await supabase
.from('cash_accounts')
.select('id, name, ledger_account, currency, iban, enabled, is_primary, source, bank_connection_id, balance, available_balance, balance_updated_at, updated_at')
.eq('company_id', companyId)
.eq('enabled', true)
.order('is_primary', { ascending: false })
.order('ledger_account', { ascending: true })
if (error) throw new Error(`Kunde inte hämta kassakonton: ${error.message}`)
const cashAccounts = (data ?? []) as CashAccountRow[]
// Reconnect duplicates: same IBAN and currency twice. Keep the most recently
// updated row as the live one and mark the other as superseded so a rail
// can fold it away; never drop it silently, it may still hold unlinked rows.
const supersededBy = new Map<string, string>()
const byIban = new Map<string, CashAccountRow[]>()
for (const a of cashAccounts) {
if (!a.iban) continue
const k = `${a.iban}|${a.currency ?? 'SEK'}`
byIban.set(k, [...(byIban.get(k) ?? []), a])
}
for (const group of byIban.values()) {
if (group.length < 2) continue
const sorted = [...group].sort((x, y) => (y.updated_at ?? '').localeCompare(x.updated_at ?? ''))
const keep = sorted[0]
for (const other of sorted.slice(1)) supersededBy.set(other.id, bankAccountKey(keep.id))
}
// Latest active sign-off per account, one query; the rail shows "avstämt
// t.o.m." next to the live status. A failed read must not hide the accounts.
let signoffs = new Map<string, Awaited<ReturnType<typeof getLatestSignoff>>>()
try {
signoffs = await getLatestSignoffs(supabase, companyId)
} catch (err) {
log.warn('sign-off read failed', { companyId, error: err instanceof Error ? err.message : String(err) })
}
// Bank logos resolve from the connection's bank_name (the same name the
// connect flow shows). A failed read only costs the logos.
const bankNameByConnection = new Map<string, string>()
const connectionIds = [...new Set(cashAccounts.map((a) => a.bank_connection_id).filter((x): x is string => !!x))]
if (connectionIds.length > 0) {
const { data: connRows, error: connError } = await supabase
.from('bank_connections')
.select('id, bank_name')
.in('id', connectionIds)
if (connError) {
log.warn('bank_name read failed; monograms instead of logos', { companyId, error: connError.message })
}
for (const r of (connRows ?? []) as Array<{ id: string; bank_name: string | null }>) {
if (r.bank_name) bankNameByConnection.set(r.id, r.bank_name)
}
}
const bankAccounts = await Promise.all(
cashAccounts.map(async (a): Promise<ReconciliationAccount> => {
let status: ReconciliationStatus | null = null
let syncedAt: string | null = null
if (withStatus) {
try {
status = await bankStatus(supabase, companyId, a, window, today)
} catch (err) {
log.warn('bank status failed for account', {
companyId,
cashAccountId: a.id,
error: err instanceof Error ? err.message : String(err),
})
}
}
try {
syncedAt = await latestBankSyncAt(supabase, companyId, a.id)
} catch {
syncedAt = null
}
const stale = !syncedAt || daysBetweenIso(syncedAt.slice(0, 10), today) > STALE_AFTER_DAYS
return {
account_key: bankAccountKey(a.id),
kind: 'bank',
account_number: a.ledger_account,
name: a.name ?? `Bankkonto ${a.ledger_account}`,
currency: a.currency ?? 'SEK',
logo_url: bankLogoUrl(a.bank_connection_id ? bankNameByConnection.get(a.bank_connection_id) : null, a.name),
source: {
type: a.bank_connection_id ? 'psd2' : a.source === 'file' ? 'bank_file' : 'manual',
synced_at: syncedAt,
stale,
},
status: stateOf(status),
superseded_by: supersededBy.get(a.id) ?? null,
signed_off_through: signoffs.get(bankAccountKey(a.id))?.through_date ?? null,
}
}),
)
let skattekonto: ReconciliationAccount | null = null
try {
const s = await getSkattekontoReconciliationStatus(supabase, companyId, { today })
if (s) {
skattekonto = {
account_key: SKATTEKONTO_ACCOUNT_KEY,
kind: 'skattekonto',
account_number: s.account_number,
name: 'Skattekonto',
currency: 'SEK',
logo_url: '/logos/skatteverket_color.svg',
source: {
type: 'skatteverket_api',
synced_at: s.skattekonto?.fetched_at ?? null,
stale: s.stale,
},
status: s.skattekonto?.fetched_at ? stateOf(s) : { ...stateOf(s)!, state: 'not_configured' },
superseded_by: null,
signed_off_through: signoffs.get(SKATTEKONTO_ACCOUNT_KEY)?.through_date ?? null,
}
}
} catch (err) {
log.warn('skattekonto status failed', {
companyId,
error: err instanceof Error ? err.message : String(err),
})
}
// The rest of the balance sheet: every account the two feeds above do not
// own, reconciled against a system specification or the signer's underlag.
// A failed read costs only this group, never the bank or skattekonto rows.
let manualAccounts: ReconciliationAccount[] = []
try {
const exclude = new Set<string>(cashAccounts.map((a) => a.ledger_account))
if (skattekonto) exclude.add(skattekonto.account_number)
manualAccounts = await listManualAccounts(supabase, companyId, {
asOf: window.to,
exclude,
signoffs,
withStatus,
})
} catch (err) {
log.warn('manual accounts failed', { companyId, error: err instanceof Error ? err.message : String(err) })
}
return [...bankAccounts, ...(skattekonto ? [skattekonto] : []), ...manualAccounts]
}
export interface GetAccountStatusOptions {
today?: string
windowFrom?: string | null
windowTo?: string | null
}
/**
* The bridge for one account. Returns null when the key does not resolve to
* an account of this company (callers map that to 404).
*/
export async function getAccountStatus(
supabase: SupabaseClient,
companyId: string,
accountKey: string,
options: GetAccountStatusOptions = {},
): Promise<ReconciliationStatus | null> {
const parsed = parseAccountKey(accountKey)
if (!parsed) return null
const today = options.today ?? toIsoDate(new Date())
let status: ReconciliationStatus | null = null
if (parsed.kind === 'skattekonto') {
status = await getSkattekontoReconciliationStatus(supabase, companyId, {
today,
windowFrom: options.windowFrom ?? null,
windowTo: options.windowTo ?? null,
})
}
if (parsed.kind === 'bank') {
const { data, error } = await supabase
.from('cash_accounts')
.select('id, name, ledger_account, currency, iban, enabled, is_primary, source, bank_connection_id, balance, available_balance, balance_updated_at, updated_at')
.eq('company_id', companyId)
.eq('id', parsed.cashAccountId)
.maybeSingle()
if (error) throw new Error(`Kunde inte hämta kassakonto: ${error.message}`)
if (!data) return null
const window = {
from: options.windowFrom ?? defaultWindow(today).from,
to: options.windowTo ?? defaultWindow(today).to,
}
status = await bankStatus(supabase, companyId, data as CashAccountRow, window, today)
}
if (parsed.kind === 'manual') {
status = await getManualReconciliationStatus(supabase, companyId, parsed.accountNumber, {
today,
asOf: options.windowTo ?? today,
})
}
if (!status) return null
// The latest active sign-off rides along on every status read (page, v1,
// MCP) so "avstämt t.o.m." never needs a second call.
try {
status.signoff = await getLatestSignoff(supabase, companyId, accountKey)
} catch (err) {
log.warn('sign-off read failed', { companyId, accountKey, error: err instanceof Error ? err.message : String(err) })
status.signoff = null
}
// A manual account without a system specification has no live outside
// balance; the one the signer stated for this very balansdag is the
// attested truth, so the status shows it instead of "okänt".
if (
status.kind === 'manual' &&
status.external_balance == null &&
status.signoff &&
status.signoff.external_balance != null &&
status.signoff.through_date === status.as_of.slice(0, 10)
) {
const external = status.signoff.external_balance
const difference = status.ledger_balance == null ? null : roundOre(status.ledger_balance - external)
status.external_balance = external
status.difference = difference
status.unexplained_difference = difference
status.is_reconciled = difference != null && Math.abs(difference) < 0.005
}
return status
}