Skip to main content

server/command/report/
category_breakdown.rs

1use chrono::{DateTime, Utc};
2use num_rational::Rational64;
3use sqlx::types::Uuid;
4use std::collections::HashMap;
5use supp_macro::command;
6
7use crate::{config::ConfigError, user::User};
8
9use super::super::{
10    BreakdownData, BreakdownPeriod, BreakdownRow, BreakdownSort, CmdError, CmdResult,
11    CommodityAmount, FilterEntity, PeriodGrouping, ReportFilter, ReportMeta, UNCATEGORIZED_KEY,
12};
13use super::fetch::{
14    BreakdownSplit, fetch_date_range_breakdown_filtered_no_conversion,
15    fetch_date_range_breakdown_filtered_with_conversion, fetch_target_symbol,
16};
17use super::period::boundaries;
18
19const DEFAULT_TAG_NAME: &str = "category";
20
21/// Default scope filter: only count splits that live on income or expense
22/// accounts. Without this, the balancing asset/liability leg of every
23/// transaction lands in `Uncategorized` and every balanced transaction
24/// sums to zero across the breakdown.
25///
26// TODO(script-classifier): mirror ActivityReport's future script hook —
27// swap this fixed TagIn for a user-supplied classifier expression.
28fn default_scope_filter() -> ReportFilter {
29    ReportFilter::TagIn {
30        entity: FilterEntity::Account,
31        name: "type".to_owned(),
32        values: vec!["income".to_owned(), "expense".to_owned()],
33    }
34}
35
36fn compose_scope(user_filter: Option<&ReportFilter>) -> ReportFilter {
37    match user_filter {
38        Some(f) => ReportFilter::And(vec![default_scope_filter(), f.clone()]),
39        None => default_scope_filter(),
40    }
41}
42
43async fn fetch_splits(
44    conn: &mut sqlx::PgConnection,
45    target_commodity_id: Option<Uuid>,
46    target_symbol: Option<&str>,
47    from: DateTime<Utc>,
48    to: DateTime<Utc>,
49    tag_name: &str,
50    filter: &ReportFilter,
51) -> Result<Vec<BreakdownSplit>, CmdError> {
52    match (target_commodity_id, target_symbol) {
53        (Some(tid), Some(sym)) => {
54            fetch_date_range_breakdown_filtered_with_conversion(
55                conn, tid, sym, from, to, tag_name, filter,
56            )
57            .await
58        }
59        _ => {
60            fetch_date_range_breakdown_filtered_no_conversion(conn, from, to, tag_name, filter)
61                .await
62        }
63    }
64}
65
66type AmountByCommodity = HashMap<Uuid, (Rational64, String)>;
67
68fn aggregate(splits: Vec<BreakdownSplit>, include_uncategorized: bool) -> Vec<BreakdownRow> {
69    let mut by_tag: HashMap<String, AmountByCommodity> = HashMap::new();
70
71    for s in splits {
72        let key = s.category.unwrap_or_else(|| UNCATEGORIZED_KEY.to_owned());
73        by_tag
74            .entry(key)
75            .or_default()
76            .entry(s.commodity_id)
77            .and_modify(|(sum, _)| *sum += s.value)
78            .or_insert_with(|| (s.value, s.commodity_symbol));
79    }
80
81    by_tag
82        .into_iter()
83        .filter(|(k, _)| include_uncategorized || k != UNCATEGORIZED_KEY)
84        .map(|(tag_value, amounts)| {
85            let is_uncategorized = tag_value == UNCATEGORIZED_KEY;
86            let mut amounts: Vec<CommodityAmount> = amounts
87                .into_iter()
88                .map(
89                    |(commodity_id, (amount, commodity_symbol))| CommodityAmount {
90                        commodity_id,
91                        commodity_symbol,
92                        amount,
93                    },
94                )
95                .collect();
96            amounts.sort_by_key(|a| a.commodity_id);
97            BreakdownRow {
98                tag_value,
99                is_uncategorized,
100                amounts,
101            }
102        })
103        .collect()
104}
105
106fn row_sort_key(row: &BreakdownRow, target_commodity_id: Option<Uuid>) -> Rational64 {
107    match target_commodity_id {
108        Some(tid) => row
109            .amounts
110            .iter()
111            .find(|a| a.commodity_id == tid)
112            .map_or(Rational64::new(0, 1), |a| a.amount),
113        None => row
114            .amounts
115            .iter()
116            .map(|a| {
117                if a.amount < Rational64::new(0, 1) {
118                    -a.amount
119                } else {
120                    a.amount
121                }
122            })
123            .fold(Rational64::new(0, 1), |acc, v| acc + v),
124    }
125}
126
127fn sort_rows(rows: &mut [BreakdownRow], order: BreakdownSort, target_commodity_id: Option<Uuid>) {
128    match order {
129        BreakdownSort::AmountDesc => rows.sort_by(|a, b| {
130            row_sort_key(b, target_commodity_id).cmp(&row_sort_key(a, target_commodity_id))
131        }),
132        BreakdownSort::AmountAsc => rows.sort_by(|a, b| {
133            row_sort_key(a, target_commodity_id).cmp(&row_sort_key(b, target_commodity_id))
134        }),
135        BreakdownSort::NameAsc => rows.sort_by(|a, b| a.tag_value.cmp(&b.tag_value)),
136        BreakdownSort::NameDesc => rows.sort_by(|a, b| b.tag_value.cmp(&a.tag_value)),
137    }
138}
139
140command! {
141    CategoryBreakdown {
142        #[required]
143        user_id: Uuid,
144        #[required]
145        date_from: DateTime<Utc>,
146        #[required]
147        date_to: DateTime<Utc>,
148        #[optional]
149        tag_name: String,
150        #[optional]
151        target_commodity_id: Uuid,
152        #[optional]
153        period_grouping: PeriodGrouping,
154        #[optional]
155        report_filter: ReportFilter,
156        #[optional]
157        sort_order: BreakdownSort,
158        #[optional]
159        include_uncategorized: bool,
160        // TODO(result-script): accept a nomiscript expression to filter/reshape
161        // BreakdownRow entries before returning. The format is not yet
162        // designed; see doc/reporting.org "Future work" for the intent.
163        #[optional]
164        result_script: String,
165    } => {
166        let user = User { id: user_id };
167        let mut conn = user.get_connection().await.map_err(|err| {
168            log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
169            ConfigError::DB
170        })?;
171
172        let tag_name = tag_name.unwrap_or_else(|| DEFAULT_TAG_NAME.to_owned());
173        let include_uncategorized = include_uncategorized.unwrap_or(true);
174        let sort_order = sort_order.unwrap_or_default();
175        let _ = result_script;
176        // Measured after scoping, because that is the filter the query runs:
177        // checking the user's half alone let a 64-leaf filter execute as 66.
178        let scoped_filter = compose_scope(report_filter.as_ref());
179        super::filter::check_filter_size(scoped_filter.leaves())?;
180
181        let target_symbol = match target_commodity_id {
182            Some(tid) => Some(fetch_target_symbol(&mut conn, tid).await?),
183            None => None,
184        };
185
186        super::period::check_range(date_from, date_to)?;
187        let period_boundaries = period_grouping
188            .map(|g| boundaries(g, date_from, date_to))
189            .transpose()?
190            .unwrap_or_default();
191
192        let mut periods = if period_boundaries.is_empty() {
193            let splits = fetch_splits(
194                &mut conn,
195                target_commodity_id,
196                target_symbol.as_deref(),
197                date_from,
198                date_to,
199                &tag_name,
200                &scoped_filter,
201            )
202            .await?;
203            let rows = aggregate(splits, include_uncategorized);
204            vec![BreakdownPeriod { label: None, rows }]
205        } else {
206            let mut out = Vec::with_capacity(period_boundaries.len());
207            for (label, pfrom, pto) in &period_boundaries {
208                let splits = fetch_splits(
209                    &mut conn,
210                    target_commodity_id,
211                    target_symbol.as_deref(),
212                    *pfrom,
213                    *pto,
214                    &tag_name,
215                    &scoped_filter,
216                )
217                .await?;
218                let rows = aggregate(splits, include_uncategorized);
219                out.push(BreakdownPeriod {
220                    label: Some(label.clone()),
221                    rows,
222                });
223            }
224            out
225        };
226
227        for period in &mut periods {
228            sort_rows(&mut period.rows, sort_order, target_commodity_id);
229        }
230
231        Ok(Some(CmdResult::Breakdown(BreakdownData {
232            meta: ReportMeta {
233                date_from: Some(date_from),
234                date_to: Some(date_to),
235                target_commodity_id,
236            },
237            tag_name,
238            periods,
239        })))
240    }
241}