1use axum::http::HeaderMap;
2use chrono::{DateTime, Datelike, Utc};
3use num_rational::Rational64;
4use serde::{Deserialize, Deserializer};
5use server::command::{
6 CmdResult, FilterEntity, FinanceEntity, ReportFilter, commodity::ListCommodities,
7};
8use sqlx::types::{Uuid, chrono::NaiveDate};
9
10pub mod activity;
11pub mod balance;
12pub mod category_breakdown;
13
14use server::command::report::view;
15use view::AmountView;
16
17pub struct CommodityOption {
18 pub id: Uuid,
19 pub symbol: String,
20 pub name: String,
21}
22
23pub struct SummaryCard {
27 pub label: String,
28 pub amounts: Vec<AmountView>,
29 pub is_net: bool,
30 pub highlight: bool,
31}
32
33#[derive(Default, Deserialize)]
39pub struct TableControlParams {
40 #[serde(default, deserialize_with = "empty_string_as_none")]
41 pub collapsed_depth: Option<String>,
42 #[serde(default, deserialize_with = "empty_string_as_none")]
43 pub commodity_columns: Option<String>,
44}
45
46impl TableControlParams {
47 #[must_use]
48 pub fn commodity_columns_enabled(&self) -> bool {
49 self.commodity_columns
50 .as_deref()
51 .map(str::trim)
52 .is_some_and(|v| v.eq_ignore_ascii_case("on") || v.eq_ignore_ascii_case("true"))
53 }
54}
55
56#[derive(Default, Deserialize)]
62pub struct ChartParams {
63 #[serde(default, deserialize_with = "empty_string_as_none")]
64 pub chart_kind: Option<String>,
65 #[serde(default, deserialize_with = "empty_string_as_none")]
66 pub chart_series: Option<String>,
67 #[serde(default, deserialize_with = "empty_string_as_none")]
68 pub renderer: Option<String>,
69}
70
71impl ChartParams {
72 #[must_use]
76 pub fn chart_kind_or_default(&self) -> plotting::ChartKind {
77 match self.chart_kind.as_deref().map(str::to_ascii_lowercase) {
78 Some(ref s) if s == "line" => plotting::ChartKind::Line,
79 Some(ref s) if s == "stacked" || s == "stackedbar" => plotting::ChartKind::StackedBar,
80 _ => plotting::ChartKind::Bar,
81 }
82 }
83
84 #[must_use]
88 pub fn chart_kind_str(&self) -> String {
89 match self.chart_kind_or_default() {
90 plotting::ChartKind::Bar => "bar".to_string(),
91 plotting::ChartKind::StackedBar => "stacked".to_string(),
92 plotting::ChartKind::Line => "line".to_string(),
93 }
94 }
95
96 #[must_use]
99 pub fn renderer_str(&self) -> String {
100 match self.renderer.as_deref().map(str::to_ascii_lowercase) {
101 Some(ref s) if s == "canvas" => "canvas".to_string(),
102 _ => "svg".to_string(),
103 }
104 }
105}
106
107#[must_use]
111pub fn encode_query(pairs: &[(&str, &str)]) -> String {
112 let mut out = String::new();
113 for (k, v) in pairs {
114 if v.is_empty() {
115 continue;
116 }
117 if !out.is_empty() {
118 out.push('&');
119 }
120 out.push_str(&percent_encode(k));
121 out.push('=');
122 out.push_str(&percent_encode(v));
123 }
124 if out.is_empty() {
125 out
126 } else {
127 format!("?{out}")
128 }
129}
130
131fn percent_encode(s: &str) -> String {
132 let mut out = String::with_capacity(s.len());
133 for b in s.bytes() {
134 let keep = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~');
135 if keep {
136 out.push(b as char);
137 } else {
138 out.push_str(&format!("%{b:02X}"));
139 }
140 }
141 out
142}
143
144pub async fn load_commodities(user_id: Uuid) -> Vec<CommodityOption> {
145 let Ok(Some(CmdResult::TaggedEntities { entities, .. })) =
146 ListCommodities::new().user_id(user_id).run().await
147 else {
148 return Vec::new();
149 };
150
151 let mut commodities = Vec::new();
152 for (entity, tags) in entities {
153 if let FinanceEntity::Commodity(commodity) = entity
154 && let (FinanceEntity::Tag(s), FinanceEntity::Tag(n)) = (&tags["symbol"], &tags["name"])
155 {
156 commodities.push(CommodityOption {
157 id: commodity.id,
158 symbol: s.tag_value.clone(),
159 name: n.tag_value.clone(),
160 });
161 }
162 }
163 commodities
164}
165
166#[must_use]
170pub fn parse_date_bound(raw: &str, end_of_day: bool) -> Option<DateTime<Utc>> {
171 let date = NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()?;
172 let time = if end_of_day {
173 date.and_hms_opt(23, 59, 59)
174 } else {
175 date.and_hms_opt(0, 0, 0)
176 }?;
177 Some(time.and_utc())
178}
179
180#[must_use]
181pub fn wants_json(headers: &HeaderMap) -> bool {
182 headers
183 .get("accept")
184 .and_then(|v| v.to_str().ok())
185 .is_some_and(|v| v.contains("application/json"))
186}
187
188#[must_use]
191pub fn sum_top_level_amounts(rows: &[view::ReportRowView]) -> Vec<AmountView> {
192 let mut out: Vec<AmountView> = Vec::new();
193 for row in rows.iter().filter(|r| r.depth == 0) {
194 for a in &row.amounts {
195 match out
196 .iter_mut()
197 .find(|x| x.commodity_symbol == a.commodity_symbol)
198 {
199 Some(existing) => existing.amount += a.amount,
200 None => out.push(AmountView {
201 commodity_symbol: a.commodity_symbol.clone(),
202 amount: a.amount,
203 }),
204 }
205 }
206 }
207 out
208}
209
210#[must_use]
215pub fn commodity_symbols_in_rows(rows: &[view::ReportRowView]) -> Vec<String> {
216 let mut seen = Vec::new();
217 for row in rows {
218 for a in &row.amounts {
219 if !seen.iter().any(|s: &String| s == &a.commodity_symbol) {
220 seen.push(a.commodity_symbol.clone());
221 }
222 }
223 }
224 seen
225}
226
227#[must_use]
230pub fn row_amount_by_symbol(row: &view::ReportRowView, symbol: &str) -> Option<Rational64> {
231 row.amounts
232 .iter()
233 .find(|a| a.commodity_symbol == symbol)
234 .map(|a| a.amount)
235}
236
237#[must_use]
242pub fn parse_sort_order_shared(raw: Option<&str>) -> SharedSort {
243 match raw.map(str::to_ascii_lowercase).as_deref() {
244 Some("amount_asc") => SharedSort::AmountAsc,
245 Some("name_asc") => SharedSort::NameAsc,
246 Some("name_desc") => SharedSort::NameDesc,
247 _ => SharedSort::AmountDesc,
248 }
249}
250
251#[derive(Debug, Clone, Copy, Default)]
252pub enum SharedSort {
253 #[default]
254 AmountDesc,
255 AmountAsc,
256 NameAsc,
257 NameDesc,
258}
259
260impl SharedSort {
261 #[must_use]
262 pub fn to_str(self) -> &'static str {
263 match self {
264 Self::AmountDesc => "amount_desc",
265 Self::AmountAsc => "amount_asc",
266 Self::NameAsc => "name_asc",
267 Self::NameDesc => "name_desc",
268 }
269 }
270
271 #[must_use]
273 pub fn into_plotting_balance(self) -> plotting::adapters::SortOrder {
274 match self {
275 Self::AmountDesc => plotting::adapters::SortOrder::MagnitudeDesc,
276 Self::AmountAsc => plotting::adapters::SortOrder::MagnitudeAsc,
277 Self::NameAsc => plotting::adapters::SortOrder::NameAsc,
278 Self::NameDesc => plotting::adapters::SortOrder::NameDesc,
279 }
280 }
281}
282
283fn abs_rational(r: Rational64) -> Rational64 {
284 if r < Rational64::new(0, 1) { -r } else { r }
285}
286
287pub fn sort_top_level_rows(rows: &mut Vec<view::ReportRowView>, order: SharedSort) {
292 let mut groups: Vec<Vec<view::ReportRowView>> = Vec::new();
296 for row in std::mem::take(rows) {
297 if row.depth == 0 || groups.is_empty() {
298 groups.push(vec![row]);
299 } else if let Some(last) = groups.last_mut() {
300 last.push(row);
301 }
302 }
303
304 groups.sort_by(|a, b| {
305 let (a_root, b_root) = (&a[0], &b[0]);
306 match order {
307 SharedSort::AmountDesc => {
308 let ka = a_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
309 let kb = b_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
310 kb.cmp(&ka)
311 }
312 SharedSort::AmountAsc => {
313 let ka = a_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
314 let kb = b_root.amounts.iter().map(|x| abs_rational(x.amount)).max();
315 ka.cmp(&kb)
316 }
317 SharedSort::NameAsc => a_root.account_name.cmp(&b_root.account_name),
318 SharedSort::NameDesc => b_root.account_name.cmp(&a_root.account_name),
319 }
320 });
321
322 for group in groups {
323 rows.extend(group);
324 }
325}
326
327#[cfg(test)]
328mod sort_tests {
329 use super::*;
330 use num_rational::Rational64;
331 use view::{AmountView, ReportRowView};
332
333 fn mk_row(name: &str, depth: usize, amount: i64, parent: Option<Uuid>) -> ReportRowView {
334 let id = Uuid::new_v4();
335 ReportRowView {
336 account_id: id,
337 parent_id: parent,
338 account_name: name.to_string(),
339 depth,
340 has_children: false,
341 amounts: vec![AmountView {
342 commodity_symbol: "USD".to_string(),
343 amount: Rational64::new(amount, 1),
344 }],
345 }
346 }
347
348 #[test]
349 fn sort_keeps_children_with_parents() {
350 let parent_b = mk_row("Bank", 0, 500, None);
351 let parent_b_id = parent_b.account_id;
352 let parent_a = mk_row("Assets", 0, 1000, None);
353 let parent_c = mk_row("Cash", 0, 200, None);
354 let mut rows = vec![
355 parent_b,
356 mk_row("Checking", 1, 300, Some(parent_b_id)),
357 mk_row("Savings", 1, 200, Some(parent_b_id)),
358 parent_a,
359 parent_c,
360 ];
361
362 sort_top_level_rows(&mut rows, SharedSort::AmountDesc);
363 let names: Vec<&str> = rows.iter().map(|r| r.account_name.as_str()).collect();
364 assert_eq!(
365 names,
366 vec!["Assets", "Bank", "Checking", "Savings", "Cash"],
367 "Assets first (1000), Bank + children next (500), Cash last (200)"
368 );
369 }
370
371 #[test]
372 fn sort_by_name_ascending() {
373 let mut rows = vec![
374 mk_row("Zulu", 0, 1, None),
375 mk_row("Alpha", 0, 2, None),
376 mk_row("Mike", 0, 3, None),
377 ];
378 sort_top_level_rows(&mut rows, SharedSort::NameAsc);
379 let names: Vec<&str> = rows.iter().map(|r| r.account_name.as_str()).collect();
380 assert_eq!(names, vec!["Alpha", "Mike", "Zulu"]);
381 }
382}
383
384#[must_use]
387pub fn breakdown_row_amount_by_symbol(
388 row: &view::BreakdownRowView,
389 symbol: &str,
390) -> Option<Rational64> {
391 row.amounts
392 .iter()
393 .find(|a| a.commodity_symbol == symbol)
394 .map(|a| a.amount)
395}
396
397pub fn empty_string_as_none<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
398where
399 D: Deserializer<'de>,
400{
401 let opt = Option::<String>::deserialize(deserializer)?;
402 Ok(opt.filter(|s| !s.is_empty()))
403}
404
405#[must_use]
406pub fn today_string() -> String {
407 Utc::now().date_naive().format("%Y-%m-%d").to_string()
408}
409
410#[must_use]
411pub fn month_start_string() -> String {
412 let now = Utc::now().date_naive();
413 NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
414 .unwrap_or(now)
415 .format("%Y-%m-%d")
416 .to_string()
417}
418
419#[derive(Deserialize)]
420#[serde(tag = "type")]
421enum FilterItem {
422 #[serde(rename = "tag")]
423 Tag {
424 entities: Vec<String>,
425 name: String,
426 values: Vec<String>,
427 },
428 #[serde(rename = "account")]
429 Account {
430 account_id: String,
431 #[serde(default, rename = "display_name")]
432 _display_name: String,
433 include_subtree: bool,
434 },
435 #[serde(rename = "group")]
436 Group {
437 logic: String,
438 items: Vec<FilterItem>,
439 },
440}
441
442#[derive(Deserialize)]
443struct FilterGroup {
444 logic: String,
445 items: Vec<FilterItem>,
446}
447
448fn build_tag_entity_filter(entity: FilterEntity, name: &str, values: &[String]) -> ReportFilter {
449 match values.len() {
450 1 => ReportFilter::Tag {
451 entity,
452 name: name.to_string(),
453 value: values[0].clone(),
454 },
455 _ => ReportFilter::TagIn {
456 entity,
457 name: name.to_string(),
458 values: values.to_vec(),
459 },
460 }
461}
462
463fn build_filter_item(item: &FilterItem) -> Option<ReportFilter> {
464 match item {
465 FilterItem::Tag {
466 entities,
467 name,
468 values,
469 } => {
470 if name.trim().is_empty() || values.is_empty() {
471 return None;
472 }
473 let filters: Vec<ReportFilter> = entities
474 .iter()
475 .filter_map(|e| {
476 let entity = match e.as_str() {
477 "account" => FilterEntity::Account,
478 "transaction" => FilterEntity::Transaction,
479 "split" => FilterEntity::Split,
480 _ => return None,
481 };
482 Some(build_tag_entity_filter(entity, name, values))
483 })
484 .collect();
485 match filters.len() {
486 0 => None,
487 1 => filters.into_iter().next(),
488 _ => Some(ReportFilter::Or(filters)),
489 }
490 }
491 FilterItem::Account {
492 account_id,
493 include_subtree,
494 ..
495 } => {
496 let id = account_id.parse::<Uuid>().ok()?;
497 if *include_subtree {
498 Some(ReportFilter::AccountSubtree(id))
499 } else {
500 Some(ReportFilter::AccountEq(id))
501 }
502 }
503 FilterItem::Group { logic, items } => {
504 let filters: Vec<ReportFilter> = items.iter().filter_map(build_filter_item).collect();
505 combine_group(logic, filters)
506 }
507 }
508}
509
510fn combine_group(logic: &str, filters: Vec<ReportFilter>) -> Option<ReportFilter> {
515 match logic {
516 "not" => match filters.len() {
517 1 => filters
518 .into_iter()
519 .next()
520 .map(|f| ReportFilter::Not(Box::new(f))),
521 _ => None,
522 },
523 "or" => match filters.len() {
524 0 => None,
525 1 => filters.into_iter().next(),
526 _ => Some(ReportFilter::Or(filters)),
527 },
528 _ => match filters.len() {
529 0 => None,
530 1 => filters.into_iter().next(),
531 _ => Some(ReportFilter::And(filters)),
532 },
533 }
534}
535
536fn build_filter_from_group(group: &FilterGroup) -> Option<ReportFilter> {
537 let filters: Vec<ReportFilter> = group.items.iter().filter_map(build_filter_item).collect();
538 combine_group(&group.logic, filters)
539}
540
541#[must_use]
542pub fn build_report_filter(
543 tag_filters: Option<&str>,
544 tag_filter_mode: Option<&str>,
545) -> Option<ReportFilter> {
546 let raw = tag_filters.filter(|s| !s.is_empty())?;
547
548 if tag_filter_mode == Some("script") {
549 return build_filter_from_sexpr(raw);
550 }
551
552 let group: FilterGroup = serde_json::from_str(raw).ok()?;
553 build_filter_from_group(&group)
554}
555
556#[cfg(feature = "scripting")]
557fn build_filter_from_sexpr(raw: &str) -> Option<ReportFilter> {
558 ReportFilter::from_sexpr(raw).ok()
559}
560
561#[cfg(not(feature = "scripting"))]
562fn build_filter_from_sexpr(_raw: &str) -> Option<ReportFilter> {
563 None
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 fn tag_item(name: &str, value: &str) -> FilterItem {
571 FilterItem::Tag {
572 entities: vec!["transaction".to_owned()],
573 name: name.to_owned(),
574 values: vec![value.to_owned()],
575 }
576 }
577
578 #[test]
579 fn not_group_with_single_child_becomes_not() {
580 let group = FilterGroup {
581 logic: "not".to_owned(),
582 items: vec![tag_item("category", "food")],
583 };
584 let result = build_filter_from_group(&group).expect("filter built");
585 match result {
586 ReportFilter::Not(inner) => match *inner {
587 ReportFilter::Tag { name, value, .. } => {
588 assert_eq!(name, "category");
589 assert_eq!(value, "food");
590 }
591 other => panic!("expected inner Tag, got {other:?}"),
592 },
593 other => panic!("expected Not(Tag), got {other:?}"),
594 }
595 }
596
597 #[test]
598 fn not_group_with_zero_children_is_dropped() {
599 let group = FilterGroup {
600 logic: "not".to_owned(),
601 items: vec![],
602 };
603 assert!(build_filter_from_group(&group).is_none());
604 }
605
606 #[test]
607 fn not_group_with_multiple_children_is_dropped() {
608 let group = FilterGroup {
609 logic: "not".to_owned(),
610 items: vec![tag_item("category", "food"), tag_item("category", "rent")],
611 };
612 assert!(
613 build_filter_from_group(&group).is_none(),
614 "multi-child NOT must be invalid; users nest explicitly"
615 );
616 }
617
618 #[test]
619 fn explicit_not_of_or_via_nesting() {
620 let group = FilterGroup {
622 logic: "not".to_owned(),
623 items: vec![FilterItem::Group {
624 logic: "or".to_owned(),
625 items: vec![tag_item("category", "food"), tag_item("category", "rent")],
626 }],
627 };
628 let result = build_filter_from_group(&group).expect("filter built");
629 let ReportFilter::Not(inner) = result else {
630 panic!("expected Not");
631 };
632 let ReportFilter::Or(children) = *inner else {
633 panic!("expected Not(Or(..))");
634 };
635 assert_eq!(children.len(), 2);
636 }
637
638 #[test]
639 fn and_group_unchanged() {
640 let group = FilterGroup {
641 logic: "and".to_owned(),
642 items: vec![tag_item("category", "food"), tag_item("project", "roof")],
643 };
644 let result = build_filter_from_group(&group).expect("filter built");
645 assert!(matches!(result, ReportFilter::And(v) if v.len() == 2));
646 }
647}