Skip to content

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 health flag (GOOD / WARNING / BAD / NEUTRAL) per KPI using standard SMB thresholds, plus a machine-readable format (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 existing Path.DASHBOARD permission (same as the current getNetIncomeKpi), so no PathPermission backfill 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 KpiFormatCURRENCY | PERCENTAGE | RATIO | DAYS | NUMBER.
  • enum KpiTrendDirectionUP | DOWN | FLAT.
  • enum KpiHealthGOOD | WARNING | BAD | NEUTRAL.
  • type FinancialKpikey, label, value, format, previousValue, changePct, trend, health, description.
  • type FinancialKpiGroupkey, label, kpis.
  • type FinancialKpisResponsestartDate, 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

New query — app/graphql/reports/queries/dashboards/financial_kpi_queries.py

  • FinancialKpiQueries.financial_kpis(start_date, end_date) guarded by PermissionExtension([PathPermissionAccess(Path.DASHBOARD)]). Auto-discovered into the root Query by class_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 on getNetIncomeKpi). No new Path / Resource member, 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 (use group.label), one tile per kpi.
  • Render value according to kpi.format: CURRENCY → money, PERCENTAGEvalue%, RATIOvalue× / value:1, DAYSvalue days, NUMBER → plain.
  • value == null means "not enough data" — render a muted dash, not 0.
  • 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 from health, not from trend direction.
  • Colour the tile from health (GOOD/WARNING/BAD/NEUTRAL).
  • kpi.description is 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_thresholds settings 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-free PeriodMetrics dataclass + 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_rate to 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 (reused Path.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.