Lines
92.52 %
Functions
11.11 %
Branches
100 %
use chrono::{DateTime, Utc};
use num_rational::Rational64;
use sqlx::types::Uuid;
use std::collections::HashMap;
use supp_macro::command;
use crate::{config::ConfigError, user::User};
use super::super::{
BreakdownData, BreakdownPeriod, BreakdownRow, BreakdownSort, CmdError, CmdResult,
CommodityAmount, FilterEntity, PeriodGrouping, ReportFilter, ReportMeta, UNCATEGORIZED_KEY,
};
use super::fetch::{
BreakdownSplit, fetch_date_range_breakdown_filtered_no_conversion,
fetch_date_range_breakdown_filtered_with_conversion, fetch_target_symbol,
use super::period::{
generate_month_boundaries, generate_quarter_boundaries, generate_year_boundaries,
const DEFAULT_TAG_NAME: &str = "category";
/// Default scope filter: only count splits that live on income or expense
/// accounts. Without this, the balancing asset/liability leg of every
/// transaction lands in `Uncategorized` and every balanced transaction
/// sums to zero across the breakdown.
///
// TODO(script-classifier): mirror ActivityReport's future script hook —
// swap this fixed TagIn for a user-supplied classifier expression.
fn default_scope_filter() -> ReportFilter {
ReportFilter::TagIn {
entity: FilterEntity::Account,
name: "type".to_owned(),
values: vec!["income".to_owned(), "expense".to_owned()],
}
fn compose_scope(user_filter: Option<&ReportFilter>) -> ReportFilter {
match user_filter {
Some(f) => ReportFilter::And(vec![default_scope_filter(), f.clone()]),
None => default_scope_filter(),
fn boundaries_for(
grouping: PeriodGrouping,
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> Vec<(String, DateTime<Utc>, DateTime<Utc>)> {
match grouping {
PeriodGrouping::Month => generate_month_boundaries(from, to),
PeriodGrouping::Quarter => generate_quarter_boundaries(from, to),
PeriodGrouping::Year => generate_year_boundaries(from, to),
async fn fetch_splits(
conn: &mut sqlx::PgConnection,
target_commodity_id: Option<Uuid>,
target_symbol: Option<&str>,
tag_name: &str,
filter: &ReportFilter,
) -> Result<Vec<BreakdownSplit>, CmdError> {
match (target_commodity_id, target_symbol) {
(Some(tid), Some(sym)) => {
fetch_date_range_breakdown_filtered_with_conversion(
conn, tid, sym, from, to, tag_name, filter,
)
.await
_ => {
fetch_date_range_breakdown_filtered_no_conversion(conn, from, to, tag_name, filter)
type AmountByCommodity = HashMap<Uuid, (Rational64, String)>;
fn aggregate(splits: Vec<BreakdownSplit>, include_uncategorized: bool) -> Vec<BreakdownRow> {
let mut by_tag: HashMap<String, AmountByCommodity> = HashMap::new();
for s in splits {
let key = s.category.unwrap_or_else(|| UNCATEGORIZED_KEY.to_owned());
by_tag
.entry(key)
.or_default()
.entry(s.commodity_id)
.and_modify(|(sum, _)| *sum += s.value)
.or_insert_with(|| (s.value, s.commodity_symbol));
.into_iter()
.filter(|(k, _)| include_uncategorized || k != UNCATEGORIZED_KEY)
.map(|(tag_value, amounts)| {
let is_uncategorized = tag_value == UNCATEGORIZED_KEY;
let mut amounts: Vec<CommodityAmount> = amounts
.map(
|(commodity_id, (amount, commodity_symbol))| CommodityAmount {
commodity_id,
commodity_symbol,
amount,
},
.collect();
amounts.sort_by_key(|a| a.commodity_id);
BreakdownRow {
tag_value,
is_uncategorized,
amounts,
})
.collect()
fn row_sort_key(row: &BreakdownRow, target_commodity_id: Option<Uuid>) -> Rational64 {
match target_commodity_id {
Some(tid) => row
.amounts
.iter()
.find(|a| a.commodity_id == tid)
.map_or(Rational64::new(0, 1), |a| a.amount),
None => row
.map(|a| {
if a.amount < Rational64::new(0, 1) {
-a.amount
} else {
a.amount
.fold(Rational64::new(0, 1), |acc, v| acc + v),
fn sort_rows(rows: &mut [BreakdownRow], order: BreakdownSort, target_commodity_id: Option<Uuid>) {
match order {
BreakdownSort::AmountDesc => rows.sort_by(|a, b| {
row_sort_key(b, target_commodity_id).cmp(&row_sort_key(a, target_commodity_id))
}),
BreakdownSort::AmountAsc => rows.sort_by(|a, b| {
row_sort_key(a, target_commodity_id).cmp(&row_sort_key(b, target_commodity_id))
BreakdownSort::NameAsc => rows.sort_by(|a, b| a.tag_value.cmp(&b.tag_value)),
BreakdownSort::NameDesc => rows.sort_by(|a, b| b.tag_value.cmp(&a.tag_value)),
command! {
CategoryBreakdown {
#[required]
user_id: Uuid,
date_from: DateTime<Utc>,
date_to: DateTime<Utc>,
#[optional]
tag_name: String,
target_commodity_id: Uuid,
period_grouping: PeriodGrouping,
report_filter: ReportFilter,
sort_order: BreakdownSort,
include_uncategorized: bool,
// TODO(result-script): accept a nomiscript expression to filter/reshape
// BreakdownRow entries before returning. The format is not yet
// designed; see doc/reporting.org "Future work" for the intent.
result_script: String,
} => {
let user = User { id: user_id };
let mut conn = user.get_connection().await.map_err(|err| {
log::error!("{}", t!("Database error: %{err}", err = err : {:?}));
ConfigError::DB
})?;
let tag_name = tag_name.unwrap_or_else(|| DEFAULT_TAG_NAME.to_owned());
let include_uncategorized = include_uncategorized.unwrap_or(true);
let sort_order = sort_order.unwrap_or_default();
let _ = result_script;
let scoped_filter = compose_scope(report_filter.as_ref());
let target_symbol = match target_commodity_id {
Some(tid) => Some(fetch_target_symbol(&mut conn, tid).await?),
None => None,
let period_boundaries = period_grouping
.map(|g| boundaries_for(g, date_from, date_to))
.unwrap_or_default();
let mut periods = if period_boundaries.is_empty() {
let splits = fetch_splits(
&mut conn,
target_commodity_id,
target_symbol.as_deref(),
date_from,
date_to,
&tag_name,
&scoped_filter,
.await?;
let rows = aggregate(splits, include_uncategorized);
vec![BreakdownPeriod { label: None, rows }]
let mut out = Vec::with_capacity(period_boundaries.len());
for (label, pfrom, pto) in &period_boundaries {
*pfrom,
*pto,
out.push(BreakdownPeriod {
label: Some(label.clone()),
rows,
});
out
for period in &mut periods {
sort_rows(&mut period.rows, sort_order, target_commodity_id);
Ok(Some(CmdResult::Breakdown(BreakdownData {
meta: ReportMeta {
date_from: Some(date_from),
date_to: Some(date_to),
tag_name,
periods,
})))