Financial KPI Dashboard¶
Date: 2026-06-21
Problem / motivation¶
Cifras already ships a deep catalogue of operational and financial reports (income statement, balance sheet, aged receivables/payables, registers, top-entity rankings). What it lacks is a single, cheap-to-render KPI surface that answers the three questions an owner or accountant actually asks: "Will I run out of cash?", "How efficiently am I running working capital?", and "Are sales converting and growing?".
Today a user has to open four or five reports and do the arithmetic by hand to get DSO, the current ratio, or gross margin. This feature exposes those numbers directly as dashboard tiles — each with a value, a period-over-period trend, and an opinionated health flag — computed entirely from data that already exists in the ledger and the sales pipeline. No new tables, no backfill.
This is Feature 1 of a two-part analytics push. The heavier standalone reports (cash-flow forecast, customer profitability, project P&L, AR collections effectiveness, inventory turnover / dead stock, ITBMS liability) are a documented fast-follow (see Future additions).
In scope¶
- A single GraphQL query
financialKpis(startDate, endDate)returning grouped KPI tiles. - Six KPI groups: Profitability, Liquidity, Working-capital efficiency, Receivables & Payables, Growth, Sales conversion.
- ~22 individual KPIs (see GraphQL surface).
- Period-over-period trend (current vs. the immediately-preceding equal-length window) for every flow-based KPI, and as-of-date comparison for balance-based KPIs.
- An opinionated
healthflag (GOOD / WARNING / BAD / NEUTRAL) per KPI using standard SMB thresholds, plus a machine-readableformat(CURRENCY / PERCENTAGE / RATIO / DAYS / NUMBER) so the frontend can render each tile without hard-coding units.
Out of scope¶
- No new persisted tables, models, or alembic migrations — every number is computed from existing ledger entries, invoices, quotes, orders, and the aged AR/AP summaries.
- No new RBAC
Path— the query reuses the existingPath.DASHBOARDpermission (same as the currentgetNetIncomeKpi), so noPathPermissionbackfill is required. - No PDF/CSV export (these are screen tiles, not a printable report).
- The standalone analytical reports listed under Future additions.
Data model changes¶
None. No new tables, columns, FKs, or indexes. No alembic revision.
What's being implemented¶
New GraphQL types — app/graphql/reports/strawberry/financial_kpi_response.py¶
enum KpiFormat—CURRENCY | PERCENTAGE | RATIO | DAYS | NUMBER.enum KpiTrendDirection—UP | DOWN | FLAT.enum KpiHealth—GOOD | WARNING | BAD | NEUTRAL.type FinancialKpi—key, label, value, format, previousValue, changePct, trend, health, description.type FinancialKpiGroup—key, label, kpis.type FinancialKpisResponse—startDate, endDate, previousStartDate, previousEndDate, groups.
New pure builder — app/graphql/reports/services/dashboards/financial_kpi_builder.py¶
@dataclass PeriodMetrics— the raw numbers for one window (revenue, cogs, opex, gross_profit, net_income, cash, ar, inventory, current_assets, current_liabilities, ap, quote/order counts, avg cycle days).- Pure helpers:
safe_div,pct,build_groups(current, previous, ar_aging, ap_aging, days_in_period)→list[FinancialKpiGroup]. No DB access, no injection — trivially unit-testable.
New service — app/graphql/reports/services/dashboards/financial_kpi_service.py¶
FinancialKpiService(BaseDashboardReportService)injectingFinancialReportRepository,QuoteReportRepository,OrderReportRepository,AgedReceivableReportRepository, andAgedPayableReportRepository.get_financial_kpis(start_date, end_date)(@service_cache(ttl=600)): buildsPeriodMetricsfor the current and previous windows from posted ledger balances + pipeline counts, reads the aged AR/AP summaries, and returnsbuild_groups(...).- Private
_period_metrics(start, end)and_balance_metrics(as_of)helpers reuse the same debit/credit sign conventions asFinancialReportService.
New query — app/graphql/reports/queries/dashboards/financial_kpi_queries.py¶
FinancialKpiQueries.financial_kpis(start_date, end_date)guarded byPermissionExtension([PathPermissionAccess(Path.DASHBOARD)]). Auto-discovered into the rootQuerybyclass_discovery(no manual mount).
Tests — tests/graphql/reports/test_financial_kpi_service.py¶
- Builder unit tests (ratios, divide-by-zero →
None, trend direction, health thresholds). - One service-level happy-path test: seed invoices/expenses against a tenant, call
get_financial_kpis, assert the expected groups/keys exist and the profitability numbers match.
Background tasks / cron¶
None.
GraphQL surface¶
type Query {
financialKpis(startDate: Date!, endDate: Date!): FinancialKpisResponse!
}
type FinancialKpisResponse {
startDate: Date!
endDate: Date!
previousStartDate: Date!
previousEndDate: Date!
groups: [FinancialKpiGroup!]!
}
type FinancialKpiGroup {
key: String!
label: String!
kpis: [FinancialKpi!]!
}
type FinancialKpi {
key: String!
label: String!
value: Decimal # null when not computable (e.g. divide-by-zero)
format: KpiFormat!
previousValue: Decimal
changePct: Decimal
trend: KpiTrendDirection!
health: KpiHealth!
description: String!
}
enum KpiFormat { CURRENCY PERCENTAGE RATIO DAYS NUMBER }
enum KpiTrendDirection { UP DOWN FLAT }
enum KpiHealth { GOOD WARNING BAD NEUTRAL }
KPI catalogue (keys returned)¶
| Group | KPI keys |
|---|---|
profitability |
revenue, gross_profit, net_income, gross_margin_pct, net_margin_pct, opex_ratio_pct |
liquidity |
cash_balance, working_capital, current_ratio, quick_ratio |
efficiency |
dso, dpo, dio, cash_conversion_cycle, ar_turnover, inventory_turnover |
receivables_payables |
ar_outstanding, ar_overdue_pct, ap_outstanding |
growth |
revenue_growth_pct, net_income_growth_pct |
sales_conversion |
quote_win_rate, order_fulfillment_rate, avg_quote_to_order_days |
RBAC¶
- Reuses
Path.DASHBOARD(already enforced ongetNetIncomeKpi). No newPath/Resourcemember, no menu wiring, no backfill script.
Frontend contract¶
- Call
financialKpis(startDate, endDate)once per dashboard load. Default the range to the current month (or whatever the dashboard date-picker holds). - Render one section per
group(usegroup.label), one tile perkpi. - Render
valueaccording tokpi.format:CURRENCY→ money,PERCENTAGE→value%,RATIO→value×/value:1,DAYS→value days,NUMBER→ plain. value == nullmeans "not enough data" — render a muted dash, not0.- Show a trend chip from
trend+changePct(UP/DOWN/FLAT). Note that for "lower-is-better" KPIs (DSO, DPO, DIO, CCC, opex ratio) a downward trend is good — colour fromhealth, not fromtrenddirection. - Colour the tile from
health(GOOD/WARNING/BAD/NEUTRAL). kpi.descriptionis a one-line tooltip explaining the metric.
Open questions¶
- Health thresholds are opinionated SMB defaults baked into the builder. If tenants want
configurable thresholds, that becomes a follow-up (a
kpi_thresholdssettings table) — out of scope here.
What shipped¶
Everything in In scope landed as planned, plus one extra KPI:
financialKpis(startDate, endDate)query —app/graphql/reports/queries/dashboards/financial_kpi_queries.py.FinancialKpiService(computes the metrics from the ledger + pipeline + aged summaries) —app/graphql/reports/services/dashboards/financial_kpi_service.py.financial_kpi_builder.py— the pure, DB-freePeriodMetricsdataclass + tile assembly (build_groups), where all ratio/health/trend logic lives.financial_kpi_response.py— the strawberry types/enums.- 6 KPI groups, 23 KPIs total — added
order_fulfillment_rateto the sales-conversion group (orders → invoices) since the order-report repository was already wired in. - Tests —
tests/graphql/reports/test_financial_kpi_service.py: 12 pure builder tests + 1 DB-backed service test that seeds posted ledger entries and checks the revenue/COGS derivation. All green. - No migration, no backfill, no new RBAC
Path(reusedPath.DASHBOARD).
Reused (not rebuilt): FinancialReportRepository.get_account_balances_as_of
(ledger P&L + balance figures, same debit/credit sign conventions as the income
statement / balance sheet), the quote/order report repositories (pipeline counts),
and the aged receivable/payable summaries (open-balance snapshot).
Future additions¶
These are the heavier standalone reports from the original analysis, deferred to a fast-follow feature so this KPI surface can ship cleanly:
- Cash-flow forecast (13-week) — forward projection from open AR/AP due dates + recurring invoices/expenses. Deferred: needs its own response shape + recurring-schedule expansion.
- Customer profitability scorecard — gross margin + payment behaviour per client. Deferred: heavier per-entity aggregation, belongs with the report (paginated) family.
- Project / job profitability — per-project P&L vs budget. Deferred: needs project-scoped ledger attribution.
- AR collections effectiveness — DSO trend, CEI, at-risk bucket. Deferred: extends the aging report, not a tile.
- Inventory turnover & dead-stock report — per-item turnover, days-on-hand, no-sale-in-N-days. Deferred: per-item report, not a tile.
- ITBMS (VAT) liability summary — output vs input tax per period. Deferred: compliance report with its own fiscal-period semantics.