Files
accounted/lib/byra/kpi-overview.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

295 lines
8.9 KiB
TypeScript

/**
* Byrå cockpit Nyckeltal fetch layer (WL-16): cross-client KPI numbers.
*
* Read-first like the rest of the cockpit (WL-09): every query is a
* cross-membership READ that RLS already allows via user_company_ids().
* Per client company the numbers come from the get_kpi_report_aggregates
* RPC (SECURITY INVOKER, one SQL pass per fiscal period; the same fast
* path the in-company KPI route uses), so nothing here scans journal
* lines through PostgREST.
*
* One RPC call per (company, fiscal period): with the byrå scale this is
* designed for (~10 clients, 1-2 periods per range) that is a couple of
* dozen parallel round trips. Past ~15 clients the right move is a new
* RPC taking uuid[] (see DECISIONS.md).
*/
import { chunk as chunked } from '@/lib/utils'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createLogger } from '@/lib/logger'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
fetchClientOverview,
type ClientOverview,
} from '@/lib/clients/fetch-client-overview'
import type { ClientDeadline } from '@/lib/clients/aggregate'
import {
fetchKpiAggregates,
buildOpeningBalances,
buildTrialBalanceRows,
} from '@/lib/reports/kpi-aggregates'
import { calculateCashPosition, calculateVatLiability } from '@/lib/reports/kpi'
import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart'
import {
filterBucketsToRange,
mergeMonthlySeries,
periodsInRange,
pickCurrentPeriod,
resolvePeriodPreset,
resultMargin,
sumBuckets,
type FiscalPeriodLite,
type KpiPeriodPreset,
type KpiRange,
type MonthBucket,
} from '@/lib/byra/kpi-aggregate'
const log = createLogger('byra:kpi-overview')
/** Max ids per PostgREST .in() filter (mirrors lib/clients/fetch-client-overview.ts). */
const IN_CLAUSE_CHUNK = 150
export interface ByraKpiClientRow {
companyId: string
name: string
orgNumber: string | null
revenue: number
expenses: number
result: number
/** Net margin in percent, null when revenue is 0. */
margin: number | null
/** Closing balance of 19xx accounts for the current fiscal period. */
cash: number
/** Net 26xx position, positive = att betala (mirrors ruta 49). */
vatLiability: number
unbookedCount: number
inboxCount: number
nextDeadline: ClientDeadline | null
/** True when this client's numbers could not be fetched; render dashes, not zeros. */
failed: boolean
}
export interface ByraKpiOverview {
team: { id: string; name: string }
role: ClientOverview['role']
preset: KpiPeriodPreset
range: KpiRange
/** Full roster for the company filter chips (unfiltered). */
allClients: Array<{ companyId: string; name: string }>
/** Ids the current filter selects (equals the full roster when unfiltered). */
selectedIds: string[]
/** One row per selected client, revenue descending. */
rows: ByraKpiClientRow[]
/** Merged monthly income/expense series across the selected clients. */
months: MonthlyDataPoint[]
}
interface CompanyKpiNumbers {
revenue: number
expenses: number
result: number
margin: number | null
cash: number
vatLiability: number
failed: boolean
buckets: MonthBucket[]
}
const FAILED_NUMBERS: CompanyKpiNumbers = {
revenue: 0,
expenses: 0,
result: 0,
margin: null,
cash: 0,
vatLiability: 0,
failed: true,
buckets: [],
}
/**
* Fetch one company's numbers: monthly P&L buckets clipped to the range,
* plus cash and VAT from the current fiscal period's closing balances.
* Account names are irrelevant here (nothing displays them), so the trial
* balance rows are built with an empty account map.
*/
async function fetchCompanyNumbers(
supabase: SupabaseClient,
companyId: string,
periods: FiscalPeriodLite[],
range: KpiRange,
todayIso: string,
): Promise<CompanyKpiNumbers> {
const current = pickCurrentPeriod(periods, todayIso)
const inRange = periodsInRange(periods, range)
const needed = new Map<string, FiscalPeriodLite>()
for (const p of inRange) needed.set(p.id, p)
if (current) needed.set(current.id, current)
if (needed.size === 0) {
return { ...FAILED_NUMBERS, failed: false }
}
const [aggEntries, priorResult] = await Promise.all([
Promise.all(
[...needed.values()].map(async (period) => ({
period,
agg: await fetchKpiAggregates(
supabase,
companyId,
period.id,
period.opening_balance_entry_id,
),
})),
),
// Opening balances without an OB entry fall back to the server-side
// prior-period aggregate, exactly like the in-company KPI route.
current && !current.opening_balance_entry_id
? supabase.rpc('compute_prior_opening_balances', {
p_company_id: companyId,
p_period_start: current.period_start,
})
: Promise.resolve(null),
])
if (priorResult?.error) {
throw new Error(priorResult.error.message)
}
const inRangeIds = new Set(inRange.map((p) => p.id))
const buckets: MonthBucket[] = []
for (const { period, agg } of aggEntries) {
if (!inRangeIds.has(period.id)) continue
for (const m of agg.monthly) {
buckets.push({ year: m.year, month0: m.month - 1, income: m.income, expenses: m.expenses })
}
}
const rangeBuckets = filterBucketsToRange(buckets, range)
const sums = sumBuckets(rangeBuckets)
let cash = 0
let vatLiability = 0
if (current) {
const currentAgg = aggEntries.find((e) => e.period.id === current.id)!.agg
const openingBalances = buildOpeningBalances(
currentAgg,
current.opening_balance_entry_id ? null : (priorResult?.data ?? []),
)
const tbRows = buildTrialBalanceRows(openingBalances, currentAgg.tb, new Map())
cash = calculateCashPosition(tbRows)
vatLiability = calculateVatLiability(tbRows)
}
return {
...sums,
margin: resultMargin(sums.revenue, sums.result),
cash,
vatLiability,
failed: false,
buckets: rangeBuckets,
}
}
/**
* Aggregate the Nyckeltal overview for the caller's byrå team. Returns null
* when the user is not a byrå team member (the page redirects). A failing
* client is marked `failed` and zero-weighted rather than failing the page.
*/
export async function fetchByraKpiOverview(
supabase: SupabaseClient,
userId: string,
opts: { preset?: string; companyIds?: string[]; today?: Date } = {},
): Promise<ByraKpiOverview | null> {
const overview = await fetchClientOverview(supabase, userId)
if (!overview) return null
const today = opts.today ?? new Date()
const { preset, range } = resolvePeriodPreset(opts.preset, today)
const todayIso = range.toDate
const roster = overview.clients
const requested = new Set(opts.companyIds ?? [])
const validRequested = roster.filter((c) => requested.has(c.companyId))
const selected = validRequested.length > 0 ? validRequested : roster
const base: ByraKpiOverview = {
team: overview.team,
role: overview.role,
preset,
range,
allClients: roster.map((c) => ({ companyId: c.companyId, name: c.name })),
selectedIds: selected.map((c) => c.companyId),
rows: [],
months: [],
}
if (selected.length === 0) return base
const selectedIds = base.selectedIds
const periodRows: FiscalPeriodLite[] = []
for (const chunk of chunked(selectedIds, IN_CLAUSE_CHUNK)) {
const rows = await fetchAllRows<FiscalPeriodLite>(({ from, to }) =>
supabase
.from('fiscal_periods')
.select('id, company_id, period_start, period_end, opening_balance_entry_id')
.in('company_id', chunk)
.order('id', { ascending: true })
.range(from, to),
)
periodRows.push(...rows)
}
const periodsByCompany = new Map<string, FiscalPeriodLite[]>()
for (const p of periodRows) {
const list = periodsByCompany.get(p.company_id)
if (list) list.push(p)
else periodsByCompany.set(p.company_id, [p])
}
const numbers = await Promise.all(
selected.map(async (client) => {
try {
return await fetchCompanyNumbers(
supabase,
client.companyId,
periodsByCompany.get(client.companyId) ?? [],
range,
todayIso,
)
} catch (err) {
log.error('byra-kpi client fetch failed', err, {
companyId: client.companyId,
operation: 'byra.kpi_overview',
})
return FAILED_NUMBERS
}
}),
)
const rows: ByraKpiClientRow[] = selected.map((client, i) => {
const n = numbers[i]
return {
companyId: client.companyId,
name: client.name,
orgNumber: client.orgNumber,
revenue: n.revenue,
expenses: n.expenses,
result: n.result,
margin: n.margin,
cash: n.cash,
vatLiability: n.vatLiability,
unbookedCount: client.unbookedCount,
inboxCount: client.inboxCount,
nextDeadline: client.nextDeadline,
failed: n.failed,
}
})
rows.sort((a, b) => b.revenue - a.revenue || a.name.localeCompare(b.name, 'sv'))
return {
...base,
rows,
months: mergeMonthlySeries(
numbers.flatMap((n) => n.buckets),
range,
),
}
}