Files
accounted/app/api/reports/kpi/route.ts
T
Jakob WennbergandClaude Fable 5 d0fb72dc63 feat(kpi): Nyckeltal as Instrumentbrädan — instrument panes, monthly bars, cost list (#1148)
* feat(kpi): Nyckeltal as Berattelsen (serif month hero + metric rail + quiet cost rows)

The founder-picked concept variant: the month's result as a serif hero
with a +/- delta sentence against the previous month, a single sage net
area chart (income/expenses ride in the hover tooltip), and a hairline
metric rail on the right still driven by the user's KPI preferences
(Anpassa, formula tooltips, all seven definitions supported). The cost
story renders as quiet bar rows: expense classes 4xxx-7xxx and top five
suppliers. Replaces the four-tile + three-Recharts-card layout;
KPIHeroCards, KPITrendChart, KPIExpenseMixChart and KPITopSuppliersChart
are deleted. FyPicker replaces FiscalYearSelector; help behind ?.
No API changes: everything derives from the existing KPIReport.

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

* feat(kpi): switch Nyckeltal to Instrumentbradan (founder pick v2)

Berattelsen replaced by the instrument-pane grid on founder review:
monthly result bars as plain SVG (muted months, latest in sage or
terracotta when negative, compact endpoint label, per-bar tooltips)
plus one bordered pane per visible preference KPI, with the
receivables pane carrying a two-segment not-due/overdue strip. The
cost story rows below are unchanged. Recharts leaves this page
entirely (KPIResultChart deleted). Anpassa, formula tooltips and all
seven KPI definitions still supported.

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

* feat(kpi): concept-true cost list and cash runway note

Founder review against the concept: the report now carries
topExpenseAccounts (top five BAS 4-7 accounts for the period, computed
from the trial-balance rows the route already holds) and the page
renders them as the full-width Storsta kostnaderna rows with account
numbers, exactly like the concept. The Kassa pane derives its 'Tacker
cirka N dagars utgifter' note from the period's daily burn so far.
Class-composition and supplier columns leave the UI (data stays on the
API). Route test extended for the new field.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 16:43:42 +02:00

316 lines
12 KiB
TypeScript

import { withRouteContext } from '@/lib/api/with-route-context'
import { NextResponse } from 'next/server'
import {
generateIncomeStatement,
buildIncomeStatementFromRows,
} from '@/lib/reports/income-statement'
import { generateTrialBalance } from '@/lib/reports/trial-balance'
import { generateARLedger, type ARLedgerReport } from '@/lib/reports/ar-ledger'
import {
generateMonthlyBreakdown,
assembleMonthlyBreakdown,
type MonthlyBreakdown,
} from '@/lib/reports/monthly-breakdown'
import {
fetchKpiAggregates,
buildOpeningBalances,
buildTrialBalanceRows,
} from '@/lib/reports/kpi-aggregates'
import { fetchAllRows } from '@/lib/supabase/fetch-all'
import {
calculateCashPosition,
calculateGrossMargin,
calculateExpenseRatio,
calculateAvgPaymentDays,
calculateVatLiability,
} from '@/lib/reports/kpi'
import { mergeWithDefaults } from '@/lib/reports/kpi-definitions'
import { parseDimensionFilterParams } from '@/lib/reports/dimension-filter'
import type {
KPIReport,
KPIPreferences,
IncomeStatementReport,
TrialBalanceRow,
} from '@/types'
export const GET = withRouteContext('report.kpi', async (request, { supabase, companyId }) => {
const { searchParams } = new URL(request.url)
const periodId = searchParams.get('period_id')
if (!periodId) {
return NextResponse.json({ error: 'period_id is required' }, { status: 400 })
}
const { data: period, error: periodError } = await supabase
.from('fiscal_periods')
.select('*')
.eq('id', periodId)
.eq('company_id', companyId)
.single()
if (periodError || !period) {
return NextResponse.json({ error: 'Fiscal period not found' }, { status: 404 })
}
// Dimension filter applies to the P&L-side KPIs only (net result, revenue/
// expenses, months, expense composition). Balance-side KPIs (cash, VAT,
// receivables) and supplier/invoice aggregates stay company-wide: a
// dimension-scoped "cash position" would be silently wrong, not filtered.
// The KPI view hides those tiles when a filter is active.
const dimFilter = parseDimensionFilterParams(searchParams)
if (!dimFilter.ok) {
return NextResponse.json({ error: dimFilter.error }, { status: 400 })
}
const dimensions = dimFilter.dimensions
// The company-wide queries both paths share. Factories, not promises, so
// each Promise.all issues them inside its own single round-trip wave.
const prefsQuery = () =>
supabase
.from('extension_data')
.select('value')
.eq('company_id', companyId)
.eq('extension_id', 'core/kpi')
.eq('key', 'preferences')
.single()
const paidInvoicesQuery = () =>
supabase
.from('invoices')
.select('invoice_date, paid_at')
.eq('company_id', companyId)
.eq('status', 'paid')
.not('paid_at', 'is', null)
const topSuppliersQuery = () =>
supabase
.from('supplier_invoices')
.select('supplier_id, total_sek, total, supplier:suppliers(id, name)')
.eq('company_id', companyId)
.gte('invoice_date', period.period_start)
.lte('invoice_date', period.period_end)
.neq('status', 'credited')
let prefsValue: unknown
let incomeStatement: IncomeStatementReport
let trialBalanceResult: { rows: TrialBalanceRow[] }
let arLedger: ARLedgerReport
let monthlyBreakdown: MonthlyBreakdown
let paidInvoicesResult: { data: Array<{ invoice_date: string; paid_at: string }> | null }
let topSuppliersResult: { data: unknown[] | null; error: unknown }
let filteredTrialBalance: { rows: TrialBalanceRow[] } | null
if (dimensions) {
// Dimension-filtered path: the legacy generators, unchanged. The second,
// dimension-scoped TB feeds the expense composition (classes 4-7, P&L)
// without touching the unfiltered TB the balance-side KPIs read.
const [prefsRes, is, tb, ar, mb, paid, sup, filteredTb] = await Promise.all([
prefsQuery(),
generateIncomeStatement(supabase, companyId, periodId, { dimensions }),
generateTrialBalance(supabase, companyId, periodId),
generateARLedger(supabase, companyId),
generateMonthlyBreakdown(supabase, companyId, periodId, { dimensions }),
paidInvoicesQuery(),
topSuppliersQuery(),
generateTrialBalance(supabase, companyId, periodId, { dimensions }),
])
prefsValue = prefsRes.data?.value
incomeStatement = is
trialBalanceResult = tb
arLedger = ar
monthlyBreakdown = mb
paidInvoicesResult = paid
topSuppliersResult = sup
filteredTrialBalance = filteredTb
} else {
// Hot path (no dimension filter): one Promise.all round trip. The
// get_kpi_report_aggregates RPC replaces three full journal-line scans
// (unfiltered TB, income-statement TB, monthly breakdown) with a single
// SQL pass; the pure builders below reproduce the legacy merge/rounding.
const obEntryId: string | null = period.opening_balance_entry_id ?? null
const [agg, priorResult, accounts, prefsRes, ar, paid, sup] = await Promise.all([
fetchKpiAggregates(supabase, companyId, periodId, obEntryId),
// Opening balances without an OB entry fall back to the server-side
// prior-period aggregate, exactly like getOpeningBalances.
obEntryId
? Promise.resolve(null)
: supabase.rpc('compute_prior_opening_balances', {
p_company_id: companyId,
p_period_start: period.period_start,
}),
fetchAllRows<{
account_number: string
account_name: string
account_class: number
}>(({ from, to }) =>
supabase
.from('chart_of_accounts')
.select('account_number, account_name, account_class')
.eq('company_id', companyId)
.order('account_number', { ascending: true })
.range(from, to)
),
prefsQuery(),
generateARLedger(supabase, companyId),
paidInvoicesQuery(),
topSuppliersQuery(),
])
if (priorResult?.error) {
// Mirrors the fallback branch of lib/reports/opening-balances.ts.
throw new Error(priorResult.error.message)
}
const accountMap = new Map<string, { name: string; class: number }>()
for (const acc of accounts) {
accountMap.set(acc.account_number, {
name: acc.account_name,
class: acc.account_class,
})
}
const openingBalances = buildOpeningBalances(
agg,
obEntryId ? null : (priorResult?.data ?? [])
)
trialBalanceResult = { rows: buildTrialBalanceRows(openingBalances, agg.tb, accountMap) }
const rowsExYearEnd = buildTrialBalanceRows(openingBalances, agg.tb_ex_year_end, accountMap)
incomeStatement = buildIncomeStatementFromRows(rowsExYearEnd)
monthlyBreakdown = assembleMonthlyBreakdown(
period.period_start,
period.period_end,
agg.monthly.map((m) => ({
year: m.year,
month0: m.month - 1,
income: m.income,
expenses: m.expenses,
}))
)
prefsValue = prefsRes.data?.value
arLedger = ar
paidInvoicesResult = paid
topSuppliersResult = sup
filteredTrialBalance = null
}
const preferences = mergeWithDefaults(
(prefsValue as Partial<KPIPreferences>) ?? {}
)
// Cash position: use account overrides if set
const cashOverrides = preferences.accountOverrides['cashPosition']
let cashPosition: number
if (cashOverrides && cashOverrides.length > 0) {
const cashRows = trialBalanceResult.rows.filter((r) =>
cashOverrides.includes(r.account_number)
)
cashPosition = Math.round(
cashRows.reduce((sum, r) => sum + (r.closing_debit - r.closing_credit), 0) * 100
) / 100
} else {
cashPosition = calculateCashPosition(trialBalanceResult.rows)
}
// VAT liability: use account overrides if set
const vatLiability = calculateVatLiability(
trialBalanceResult.rows,
preferences.accountOverrides['vatLiability']
)
// Avg payment days from paid invoices
const paidInvoices = (paidInvoicesResult.data ?? []).map((inv) => ({
invoice_date: inv.invoice_date as string,
paid_at: inv.paid_at as string,
}))
// Expense composition by BAS class (4-7). Expense accounts have a debit
// normal balance, so amount = closing_debit - closing_credit. Negative
// values (rare reclassifications) are clamped to 0 so the donut renders
// sensibly.
const expenseComposition = (filteredTrialBalance ?? trialBalanceResult).rows.reduce(
(acc, r) => {
if (r.account_class < 4 || r.account_class > 7) return acc
const amount = r.closing_debit - r.closing_credit
if (amount <= 0) return acc
if (r.account_class === 4) acc.class4 += amount
else if (r.account_class === 5) acc.class5 += amount
else if (r.account_class === 6) acc.class6 += amount
else if (r.account_class === 7) acc.class7 += amount
return acc
},
{ class4: 0, class5: 0, class6: 0, class7: 0 }
)
// Top expense accounts (classes 4-7) for the period: the concept's
// "Största kostnaderna" list. Same debit-normal reading as the class
// composition above.
const topExpenseAccounts = (filteredTrialBalance ?? trialBalanceResult).rows
.filter((r) => r.account_class >= 4 && r.account_class <= 7)
.map((r) => ({
account_number: r.account_number,
account_name: r.account_name,
total: Math.round((r.closing_debit - r.closing_credit) * 100) / 100,
}))
.filter((r) => r.total > 0)
.sort((a, b) => b.total - a.total)
.slice(0, 5)
// Top suppliers by spend within the fiscal period. Sum total_sek to avoid
// mixing currencies. Drop FX invoices without a SEK conversion (total_sek
// null): they would otherwise inflate a supplier's total with raw
// foreign-currency amounts.
type SupplierInvoiceRow = {
supplier_id: string | null
total_sek: number | null
total: number | null
supplier: { id: string; name: string } | { id: string; name: string }[] | null
}
if (topSuppliersResult.error) {
// Surface the failure rather than silently rendering an empty chart that
// matches the legitimate "no supplier invoices" empty state.
console.error('[kpi] topSuppliersResult error:', topSuppliersResult.error)
}
const supplierTotals = new Map<string, { name: string; total: number }>()
for (const row of (topSuppliersResult.data ?? []) as SupplierInvoiceRow[]) {
if (!row.supplier_id) continue
const supplier = Array.isArray(row.supplier) ? row.supplier[0] : row.supplier
if (!supplier?.name) continue
const amount = row.total_sek ?? null
if (amount == null) continue
const existing = supplierTotals.get(row.supplier_id)
if (existing) existing.total += amount
else supplierTotals.set(row.supplier_id, { name: supplier.name, total: amount })
}
const topSuppliers = Array.from(supplierTotals.entries())
.map(([supplier_id, v]) => ({
supplier_id,
supplier_name: v.name,
total: Math.round(v.total * 100) / 100,
}))
.sort((a, b) => b.total - a.total)
.slice(0, 7)
const report: KPIReport = {
netResult: incomeStatement.net_result,
cashPosition,
outstandingReceivables: arLedger.total_outstanding,
overdueReceivables: arLedger.total_overdue,
vatLiability,
totalRevenue: incomeStatement.total_revenue,
totalExpenses: incomeStatement.total_expenses,
grossMargin: calculateGrossMargin(incomeStatement),
expenseRatio: calculateExpenseRatio(incomeStatement),
avgPaymentDays: calculateAvgPaymentDays(paidInvoices),
periodComplete: period.is_closed,
months: monthlyBreakdown.months,
period: { start: period.period_start, end: period.period_end },
expenseComposition: {
class4: Math.round(expenseComposition.class4 * 100) / 100,
class5: Math.round(expenseComposition.class5 * 100) / 100,
class6: Math.round(expenseComposition.class6 * 100) / 100,
class7: Math.round(expenseComposition.class7 * 100) / 100,
},
topExpenseAccounts,
topSuppliers,
}
return NextResponse.json({ data: report })
})