Lines
45.3 %
Functions
17.39 %
Branches
100 %
use axum::http::HeaderMap;
use chrono::{DateTime, Datelike, Utc};
use num_rational::Rational64;
use serde::{Deserialize, Deserializer};
use server::command::{
CmdResult, FilterEntity, FinanceEntity, ReportFilter, commodity::ListCommodities,
};
use sqlx::types::{Uuid, chrono::NaiveDate};
pub mod activity;
pub mod balance;
pub mod category_breakdown;
use server::command::report::view;
use view::AmountView;
pub struct CommodityOption {
pub id: Uuid,
pub symbol: String,
pub name: String,
}
/// A single card in the top-of-report summary strip. `is_net` flags cards
/// that show a signed net (e.g. Income − Expense) so templates can
/// style them distinctly; `highlight` is a generic accent slot.
pub struct SummaryCard {
pub label: String,
pub amounts: Vec<AmountView>,
pub is_net: bool,
pub highlight: bool,
/// Query-string fields shared by every report page to drive client-side
/// table controls. The server only needs to read `commodity_columns` (which
/// drives a template pivot) and forward `collapsed_depth` into the template
/// for initial fold state; `sort_by` is consumed purely by the frontend
/// WASM reorder logic, so the Rust handler does not parse it.
#[derive(Default, Deserialize)]
pub struct TableControlParams {
#[serde(default, deserialize_with = "empty_string_as_none")]
pub collapsed_depth: Option<String>,
pub commodity_columns: Option<String>,
impl TableControlParams {
#[must_use]
pub fn commodity_columns_enabled(&self) -> bool {
self.commodity_columns
.as_deref()
.map(str::trim)
.is_some_and(|v| v.eq_ignore_ascii_case("on") || v.eq_ignore_ascii_case("true"))
/// Chart-specific query parameters shared across report chart handlers.
/// `chart_kind` maps to `plotting::ChartKind`; `chart_series` is a
/// report-specific toggle (e.g. Activity: show Net or not) parsed in
/// each handler. `renderer` picks between server-side SVG (default)
/// and client-side canvas.
pub struct ChartParams {
pub chart_kind: Option<String>,
pub chart_series: Option<String>,
pub renderer: Option<String>,
impl ChartParams {
/// Parse `chart_kind` (`bar`, `line`, `stacked`) into a
/// `plotting::ChartKind`. Unknown / missing values fall back to
/// `Bar` so a stale URL still renders something.
pub fn chart_kind_or_default(&self) -> plotting::ChartKind {
match self.chart_kind.as_deref().map(str::to_ascii_lowercase) {
Some(ref s) if s == "line" => plotting::ChartKind::Line,
Some(ref s) if s == "stacked" || s == "stackedbar" => plotting::ChartKind::StackedBar,
_ => plotting::ChartKind::Bar,
/// Normalise the chart kind to its canonical lowercase name so
/// the template can round-trip it into URLs and `<select>`
/// values.
pub fn chart_kind_str(&self) -> String {
match self.chart_kind_or_default() {
plotting::ChartKind::Bar => "bar".to_string(),
plotting::ChartKind::StackedBar => "stacked".to_string(),
plotting::ChartKind::Line => "line".to_string(),
/// Canonicalise the renderer choice. Accepts `svg` (default) and
/// `canvas`; anything else normalises to `svg`.
pub fn renderer_str(&self) -> String {
match self.renderer.as_deref().map(str::to_ascii_lowercase) {
Some(ref s) if s == "canvas" => "canvas".to_string(),
_ => "svg".to_string(),
/// Minimal URL-query encoder for the (key, value) pairs the chart
/// endpoints take. Avoids pulling in `serde_urlencoded` just to
/// stringify a handful of params — keeps the handler simple.
pub fn encode_query(pairs: &[(&str, &str)]) -> String {
let mut out = String::new();
for (k, v) in pairs {
if v.is_empty() {
continue;
if !out.is_empty() {
out.push('&');
out.push_str(&percent_encode(k));
out.push('=');
out.push_str(&percent_encode(v));
if out.is_empty() {
out
} else {
format!("?{out}")
fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
let keep = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~');
if keep {
out.push(b as char);
out.push_str(&format!("%{b:02X}"));
pub async fn load_commodities(user_id: Uuid) -> Vec<CommodityOption> {
let Ok(Some(CmdResult::TaggedEntities { entities, .. })) =
ListCommodities::new().user_id(user_id).run().await
else {
return Vec::new();
let mut commodities = Vec::new();
for (entity, tags) in entities {
if let FinanceEntity::Commodity(commodity) = entity
&& let (FinanceEntity::Tag(s), FinanceEntity::Tag(n)) = (&tags["symbol"], &tags["name"])
{
commodities.push(CommodityOption {
id: commodity.id,
symbol: s.tag_value.clone(),
name: n.tag_value.clone(),
});
commodities
/// Parse a `YYYY-MM-DD` string into UTC. `end_of_day` controls whether the
/// resulting instant sits at 00:00 (range start) or 23:59:59 (range end /
/// as-of cutoff).
pub fn parse_date_bound(raw: &str, end_of_day: bool) -> Option<DateTime<Utc>> {
let date = NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()?;
let time = if end_of_day {
date.and_hms_opt(23, 59, 59)
date.and_hms_opt(0, 0, 0)
}?;
Some(time.and_utc())
pub fn wants_json(headers: &HeaderMap) -> bool {
headers
.get("accept")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("application/json"))
/// Sum top-level rows' amounts per commodity. Used by Balance to build its
/// grand-total card(s).
pub fn sum_top_level_amounts(rows: &[view::ReportRowView]) -> Vec<AmountView> {
let mut out: Vec<AmountView> = Vec::new();
for row in rows.iter().filter(|r| r.depth == 0) {
for a in &row.amounts {
match out
.iter_mut()
.find(|x| x.commodity_symbol == a.commodity_symbol)
Some(existing) => existing.amount += a.amount,
None => out.push(AmountView {
commodity_symbol: a.commodity_symbol.clone(),
amount: a.amount,
}),
/// Collect the set of commodity symbols present across all rows, in the
/// order the row-data first mentions them. Drives the "Commodities as
/// columns" pivot: a stable column ordering without depending on the
/// user's commodity list.
pub fn commodity_symbols_in_rows(rows: &[view::ReportRowView]) -> Vec<String> {
let mut seen = Vec::new();
for row in rows {
if !seen.iter().any(|s: &String| s == &a.commodity_symbol) {
seen.push(a.commodity_symbol.clone());
seen
/// Look up a row's amount for the given commodity symbol. Called from the
/// commodity-columns branch of the tree-table template.
pub fn row_amount_by_symbol(row: &view::ReportRowView, symbol: &str) -> Option<Rational64> {
row.amounts
.iter()
.find(|a| a.commodity_symbol == symbol)
.map(|a| a.amount)
/// Parse the shared `sort_order` query param. Balance and Breakdown
/// accept the same names; this keeps the URLs consistent between
/// them. Unknown values fall back to `AmountDesc` so a stale URL
/// still renders.
pub fn parse_sort_order_shared(raw: Option<&str>) -> SharedSort {
match raw.map(str::to_ascii_lowercase).as_deref() {
Some("amount_asc") => SharedSort::AmountAsc,
Some("name_asc") => SharedSort::NameAsc,
Some("name_desc") => SharedSort::NameDesc,
_ => SharedSort::AmountDesc,
#[derive(Debug, Clone, Copy, Default)]
pub enum SharedSort {
#[default]
AmountDesc,
AmountAsc,
NameAsc,
NameDesc,
impl SharedSort {
pub fn to_str(self) -> &'static str {
match self {
Self::AmountDesc => "amount_desc",
Self::AmountAsc => "amount_asc",
Self::NameAsc => "name_asc",
Self::NameDesc => "name_desc",
/// Map to the plotting crate's balance-chart sort enum.
pub fn into_plotting_balance(self) -> plotting::adapters::SortOrder {
Self::AmountDesc => plotting::adapters::SortOrder::MagnitudeDesc,
Self::AmountAsc => plotting::adapters::SortOrder::MagnitudeAsc,
Self::NameAsc => plotting::adapters::SortOrder::NameAsc,
Self::NameDesc => plotting::adapters::SortOrder::NameDesc,
fn abs_rational(r: Rational64) -> Rational64 {
if r < Rational64::new(0, 1) { -r } else { r }
/// Sort top-level rows in place according to `order`. Children ride
/// with their parent — they're adjacent in the flattened view and we
/// preserve that adjacency by sorting on a key derived from each
/// top-level row alone.
pub fn sort_top_level_rows(rows: &mut Vec<view::ReportRowView>, order: SharedSort) {
// Group rows by their top-level ancestor. Flat projection is DFS
// order: every depth-0 row is followed by its subtree before the
// next depth-0 row, so a single pass builds the groups.
let mut groups: Vec<Vec<view::ReportRowView>> = Vec::new();
for row in std::mem::take(rows) {
if row.depth == 0 || groups.is_empty() {
groups.push(vec![row]);
} else if let Some(last) = groups.last_mut() {
last.push(row);
groups.sort_by(|a, b| {
let (a_root, b_root) = (&a[0], &b[0]);
match order {
SharedSort::AmountDesc => {
let ka = a_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
let kb = b_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
kb.cmp(&ka)
SharedSort::AmountAsc => {
ka.cmp(&kb)
SharedSort::NameAsc => a_root.account_name.cmp(&b_root.account_name),
SharedSort::NameDesc => b_root.account_name.cmp(&a_root.account_name),
for group in groups {
rows.extend(group);
#[cfg(test)]
mod sort_tests {
use super::*;
use view::{AmountView, ReportRowView};
fn mk_row(name: &str, depth: usize, amount: i64, parent: Option<Uuid>) -> ReportRowView {
let id = Uuid::new_v4();
ReportRowView {
account_id: id,
parent_id: parent,
account_name: name.to_string(),
depth,
has_children: false,
amounts: vec![AmountView {
commodity_symbol: "USD".to_string(),
amount: Rational64::new(amount, 1),
}],
#[test]
fn sort_keeps_children_with_parents() {
let parent_b = mk_row("Bank", 0, 500, None);
let parent_b_id = parent_b.account_id;
let parent_a = mk_row("Assets", 0, 1000, None);
let parent_c = mk_row("Cash", 0, 200, None);
let mut rows = vec![
parent_b,
mk_row("Checking", 1, 300, Some(parent_b_id)),
mk_row("Savings", 1, 200, Some(parent_b_id)),
parent_a,
parent_c,
];
sort_top_level_rows(&mut rows, SharedSort::AmountDesc);
let names: Vec<&str> = rows.iter().map(|r| r.account_name.as_str()).collect();
assert_eq!(
names,
vec!["Assets", "Bank", "Checking", "Savings", "Cash"],
"Assets first (1000), Bank + children next (500), Cash last (200)"
);
fn sort_by_name_ascending() {
mk_row("Zulu", 0, 1, None),
mk_row("Alpha", 0, 2, None),
mk_row("Mike", 0, 3, None),
sort_top_level_rows(&mut rows, SharedSort::NameAsc);
assert_eq!(names, vec!["Alpha", "Mike", "Zulu"]);
/// Breakdown equivalent of `row_amount_by_symbol`. Category Breakdown uses
/// a flat row type (`BreakdownRowView`) rather than the tree `ReportRowView`.
pub fn breakdown_row_amount_by_symbol(
row: &view::BreakdownRowView,
symbol: &str,
) -> Option<Rational64> {
pub fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
let opt = Option::<String>::deserialize(deserializer)?;
Ok(opt.filter(|s| !s.is_empty()))
pub fn today_string() -> String {
Utc::now().date_naive().format("%Y-%m-%d").to_string()
pub fn month_start_string() -> String {
let now = Utc::now().date_naive();
NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
.unwrap_or(now)
.format("%Y-%m-%d")
.to_string()
#[derive(Deserialize)]
#[serde(tag = "type")]
enum FilterItem {
#[serde(rename = "tag")]
Tag {
entities: Vec<String>,
name: String,
values: Vec<String>,
},
#[serde(rename = "account")]
Account {
account_id: String,
#[serde(default, rename = "display_name")]
_display_name: String,
include_subtree: bool,
#[serde(rename = "group")]
Group {
logic: String,
items: Vec<FilterItem>,
struct FilterGroup {
fn build_tag_entity_filter(entity: FilterEntity, name: &str, values: &[String]) -> ReportFilter {
match values.len() {
1 => ReportFilter::Tag {
entity,
name: name.to_string(),
value: values[0].clone(),
_ => ReportFilter::TagIn {
values: values.to_vec(),
fn build_filter_item(item: &FilterItem) -> Option<ReportFilter> {
match item {
FilterItem::Tag {
entities,
name,
values,
} => {
if name.trim().is_empty() || values.is_empty() {
return None;
let filters: Vec<ReportFilter> = entities
.filter_map(|e| {
let entity = match e.as_str() {
"account" => FilterEntity::Account,
"transaction" => FilterEntity::Transaction,
"split" => FilterEntity::Split,
_ => return None,
Some(build_tag_entity_filter(entity, name, values))
})
.collect();
match filters.len() {
0 => None,
1 => filters.into_iter().next(),
_ => Some(ReportFilter::Or(filters)),
FilterItem::Account {
account_id,
include_subtree,
..
let id = account_id.parse::<Uuid>().ok()?;
if *include_subtree {
Some(ReportFilter::AccountSubtree(id))
Some(ReportFilter::AccountEq(id))
FilterItem::Group { logic, items } => {
let filters: Vec<ReportFilter> = items.iter().filter_map(build_filter_item).collect();
combine_group(logic, filters)
/// Combine a group's built child filters by the group's declared logic.
/// `not` requires exactly one child; anything else is ignored (returns
/// `None`) so the rest of the report still runs. Users who want
/// `Not(Or(...))` or `Not(And(...))` must nest the subgroup explicitly.
fn combine_group(logic: &str, filters: Vec<ReportFilter>) -> Option<ReportFilter> {
match logic {
"not" => match filters.len() {
1 => filters
.into_iter()
.next()
.map(|f| ReportFilter::Not(Box::new(f))),
_ => None,
"or" => match filters.len() {
_ => match filters.len() {
_ => Some(ReportFilter::And(filters)),
fn build_filter_from_group(group: &FilterGroup) -> Option<ReportFilter> {
let filters: Vec<ReportFilter> = group.items.iter().filter_map(build_filter_item).collect();
combine_group(&group.logic, filters)
pub fn build_report_filter(
tag_filters: Option<&str>,
tag_filter_mode: Option<&str>,
) -> Option<ReportFilter> {
let raw = tag_filters.filter(|s| !s.is_empty())?;
if tag_filter_mode == Some("script") {
return build_filter_from_sexpr(raw);
let group: FilterGroup = serde_json::from_str(raw).ok()?;
build_filter_from_group(&group)
#[cfg(feature = "scripting")]
fn build_filter_from_sexpr(raw: &str) -> Option<ReportFilter> {
ReportFilter::from_sexpr(raw).ok()
#[cfg(not(feature = "scripting"))]
fn build_filter_from_sexpr(_raw: &str) -> Option<ReportFilter> {
None
mod tests {
fn tag_item(name: &str, value: &str) -> FilterItem {
entities: vec!["transaction".to_owned()],
name: name.to_owned(),
values: vec![value.to_owned()],
fn not_group_with_single_child_becomes_not() {
let group = FilterGroup {
logic: "not".to_owned(),
items: vec![tag_item("category", "food")],
let result = build_filter_from_group(&group).expect("filter built");
match result {
ReportFilter::Not(inner) => match *inner {
ReportFilter::Tag { name, value, .. } => {
assert_eq!(name, "category");
assert_eq!(value, "food");
other => panic!("expected inner Tag, got {other:?}"),
other => panic!("expected Not(Tag), got {other:?}"),
fn not_group_with_zero_children_is_dropped() {
items: vec![],
assert!(build_filter_from_group(&group).is_none());
fn not_group_with_multiple_children_is_dropped() {
items: vec![tag_item("category", "food"), tag_item("category", "rent")],
assert!(
build_filter_from_group(&group).is_none(),
"multi-child NOT must be invalid; users nest explicitly"
fn explicit_not_of_or_via_nesting() {
// Users who want Not(Or(food, rent)) nest an OR subgroup inside a NOT.
items: vec![FilterItem::Group {
logic: "or".to_owned(),
let ReportFilter::Not(inner) = result else {
panic!("expected Not");
let ReportFilter::Or(children) = *inner else {
panic!("expected Not(Or(..))");
assert_eq!(children.len(), 2);
fn and_group_unchanged() {
logic: "and".to_owned(),
items: vec![tag_item("category", "food"), tag_item("project", "roof")],
assert!(matches!(result, ReportFilter::And(v) if v.len() == 2));