Skip to main content

server/command/report/
view.rs

1//! View projections shared across report consumers.
2//!
3//! Flattens `ReportData` / `ActivityData` / `BreakdownData` server
4//! responses into small row-oriented structs. Report table handlers, the
5//! chart adapters in the `plotting` crate, and the CLI all consume these
6//! instead of re-walking the tree.
7
8use num_rational::Rational64;
9use sqlx::types::Uuid;
10
11use super::super::{ActivityData, BreakdownData, ReportData, ReportNode, UNCATEGORIZED_KEY};
12
13#[derive(Debug, Clone)]
14pub struct AmountView {
15    pub commodity_symbol: String,
16    pub amount: Rational64,
17}
18
19#[derive(Debug, Clone)]
20pub struct ReportRowView {
21    pub account_id: Uuid,
22    pub parent_id: Option<Uuid>,
23    pub account_name: String,
24    pub depth: usize,
25    pub has_children: bool,
26    pub amounts: Vec<AmountView>,
27}
28
29#[derive(Debug, Clone)]
30pub struct GroupView {
31    pub label: String,
32    pub flip_sign: bool,
33    pub rows: Vec<ReportRowView>,
34    pub total: Vec<AmountView>,
35}
36
37#[derive(Debug, Clone)]
38pub struct PeriodActivityView {
39    pub label: String,
40    pub groups: Vec<GroupView>,
41    pub net: Vec<AmountView>,
42}
43
44#[derive(Debug, Clone)]
45pub struct BreakdownRowView {
46    pub tag_value: String,
47    pub is_uncategorized: bool,
48    pub amounts: Vec<AmountView>,
49}
50
51#[derive(Debug, Clone)]
52pub struct BreakdownPeriodView {
53    pub label: String,
54    pub rows: Vec<BreakdownRowView>,
55}
56
57fn amount_views(amounts: &[super::super::CommodityAmount], flip: bool) -> Vec<AmountView> {
58    amounts
59        .iter()
60        .map(|a| AmountView {
61            commodity_symbol: a.commodity_symbol.clone(),
62            amount: if flip { -a.amount } else { a.amount },
63        })
64        .collect()
65}
66
67fn flatten_nodes(
68    nodes: &[ReportNode],
69    parent: Option<Uuid>,
70    flip: bool,
71    out: &mut Vec<ReportRowView>,
72) {
73    for node in nodes {
74        out.push(ReportRowView {
75            account_id: node.account_id,
76            parent_id: parent,
77            account_name: node.account_name.clone(),
78            depth: node.depth,
79            has_children: !node.children.is_empty(),
80            amounts: amount_views(&node.amounts, flip),
81        });
82        flatten_nodes(&node.children, Some(node.account_id), flip, out);
83    }
84}
85
86/// Flatten a point-in-time or period-activity `ReportData` into a single row
87/// list. The outer `PeriodData` wrapper collapses because Balance always
88/// returns exactly one period.
89#[must_use]
90pub fn flatten_report_data(data: &ReportData) -> Vec<ReportRowView> {
91    let mut rows = Vec::new();
92    for period in &data.periods {
93        flatten_nodes(&period.roots, None, false, &mut rows);
94    }
95    rows
96}
97
98fn sum_into(dest: &mut Vec<AmountView>, src: &[AmountView], negate: bool) {
99    for a in src {
100        let contribution = if negate { -a.amount } else { a.amount };
101        match dest
102            .iter_mut()
103            .find(|d| d.commodity_symbol == a.commodity_symbol)
104        {
105            Some(existing) => existing.amount += contribution,
106            None => dest.push(AmountView {
107                commodity_symbol: a.commodity_symbol.clone(),
108                amount: contribution,
109            }),
110        }
111    }
112}
113
114fn top_level_totals(rows: &[ReportRowView]) -> Vec<AmountView> {
115    let mut out = Vec::new();
116    for row in rows.iter().filter(|r| r.depth == 0) {
117        sum_into(&mut out, &row.amounts, false);
118    }
119    out
120}
121
122/// Flatten an `ActivityData` into per-period / per-group row lists with
123/// totals. `flip_sign` is applied to amounts and to the per-period net,
124/// following the convention documented in `doc/reporting.org`:
125/// `Net = sum(group_total * (flip_sign ? +1 : -1))`.
126#[must_use]
127pub fn flatten_activity_data(data: &ActivityData) -> Vec<PeriodActivityView> {
128    data.periods
129        .iter()
130        .map(|period| {
131            let groups: Vec<GroupView> = period
132                .groups
133                .iter()
134                .map(|g| {
135                    let mut rows = Vec::new();
136                    flatten_nodes(&g.roots, None, g.flip_sign, &mut rows);
137                    let total = top_level_totals(&rows);
138                    GroupView {
139                        label: g.label.clone(),
140                        flip_sign: g.flip_sign,
141                        rows,
142                        total,
143                    }
144                })
145                .collect();
146            let mut net: Vec<AmountView> = Vec::new();
147            for g in &groups {
148                sum_into(&mut net, &g.total, !g.flip_sign);
149            }
150            PeriodActivityView {
151                label: period.label.clone().unwrap_or_default(),
152                groups,
153                net,
154            }
155        })
156        .collect()
157}
158
159/// Flatten a `BreakdownData` into per-period row lists. The uncategorized
160/// sentinel bucket is translated into `is_uncategorized = true` so consumers
161/// can render it differently without knowing the sentinel value.
162#[must_use]
163pub fn flatten_breakdown_data(data: &BreakdownData) -> Vec<BreakdownPeriodView> {
164    data.periods
165        .iter()
166        .map(|p| BreakdownPeriodView {
167            label: p.label.clone().unwrap_or_default(),
168            rows: p
169                .rows
170                .iter()
171                .map(|r| BreakdownRowView {
172                    tag_value: r.tag_value.clone(),
173                    is_uncategorized: r.is_uncategorized || r.tag_value == UNCATEGORIZED_KEY,
174                    amounts: amount_views(&r.amounts, false),
175                })
176                .collect(),
177        })
178        .collect()
179}
180
181#[cfg(test)]
182mod tests {
183    use super::super::super::{
184        ActivityGroupResult, ActivityPeriod, BreakdownPeriod, BreakdownRow, CommodityAmount,
185        PeriodData, ReportMeta,
186    };
187    use super::*;
188
189    fn commodity(symbol: &str, num: i64) -> CommodityAmount {
190        CommodityAmount {
191            commodity_id: Uuid::new_v4(),
192            commodity_symbol: symbol.to_string(),
193            amount: Rational64::new(num, 1),
194        }
195    }
196
197    fn node(
198        name: &str,
199        depth: usize,
200        amounts: Vec<CommodityAmount>,
201        children: Vec<ReportNode>,
202    ) -> ReportNode {
203        ReportNode {
204            account_id: Uuid::new_v4(),
205            account_name: name.to_string(),
206            account_path: name.to_string(),
207            depth,
208            account_type: None,
209            amounts,
210            children,
211        }
212    }
213
214    #[test]
215    fn flatten_report_data_preserves_parent_links() {
216        let child = node("Checking", 1, vec![commodity("USD", 100)], vec![]);
217        let child_id = child.account_id;
218        let parent = node("Assets", 0, vec![commodity("USD", 100)], vec![child]);
219        let parent_id = parent.account_id;
220        let data = ReportData {
221            meta: ReportMeta {
222                date_from: None,
223                date_to: None,
224                target_commodity_id: None,
225            },
226            periods: vec![PeriodData {
227                label: None,
228                roots: vec![parent],
229            }],
230        };
231
232        let rows = flatten_report_data(&data);
233
234        assert_eq!(rows.len(), 2);
235        assert_eq!(rows[0].account_id, parent_id);
236        assert!(rows[0].parent_id.is_none());
237        assert!(rows[0].has_children);
238        assert_eq!(rows[1].account_id, child_id);
239        assert_eq!(rows[1].parent_id, Some(parent_id));
240        assert!(!rows[1].has_children);
241    }
242
243    #[test]
244    fn flatten_activity_data_flips_income_sign_and_nets() {
245        let income = ActivityGroupResult {
246            label: "Income".to_string(),
247            flip_sign: true,
248            roots: vec![node("Salary", 0, vec![commodity("USD", -500)], vec![])],
249        };
250        let expense = ActivityGroupResult {
251            label: "Expense".to_string(),
252            flip_sign: false,
253            roots: vec![node("Rent", 0, vec![commodity("USD", 200)], vec![])],
254        };
255        let data = ActivityData {
256            meta: ReportMeta {
257                date_from: None,
258                date_to: None,
259                target_commodity_id: None,
260            },
261            periods: vec![ActivityPeriod {
262                label: Some("2026-04".to_string()),
263                groups: vec![income, expense],
264            }],
265        };
266
267        let periods = flatten_activity_data(&data);
268
269        assert_eq!(periods.len(), 1);
270        let p = &periods[0];
271        assert_eq!(p.label, "2026-04");
272        assert_eq!(
273            p.groups[0].rows[0].amounts[0].amount,
274            Rational64::new(500, 1)
275        );
276        assert_eq!(p.groups[0].total[0].amount, Rational64::new(500, 1));
277        assert_eq!(p.groups[1].total[0].amount, Rational64::new(200, 1));
278        // Net: income flipped (+500) minus expense (200) = 300.
279        assert_eq!(p.net.len(), 1);
280        assert_eq!(p.net[0].commodity_symbol, "USD");
281        assert_eq!(p.net[0].amount, Rational64::new(300, 1));
282    }
283
284    #[test]
285    fn flatten_breakdown_data_marks_sentinel_uncategorized() {
286        let data = BreakdownData {
287            meta: ReportMeta {
288                date_from: None,
289                date_to: None,
290                target_commodity_id: None,
291            },
292            tag_name: "category".to_string(),
293            periods: vec![BreakdownPeriod {
294                label: None,
295                rows: vec![
296                    BreakdownRow {
297                        tag_value: "food".to_string(),
298                        is_uncategorized: false,
299                        amounts: vec![commodity("USD", 50)],
300                    },
301                    BreakdownRow {
302                        tag_value: UNCATEGORIZED_KEY.to_string(),
303                        is_uncategorized: true,
304                        amounts: vec![commodity("USD", 10)],
305                    },
306                ],
307            }],
308        };
309
310        let periods = flatten_breakdown_data(&data);
311
312        assert_eq!(periods[0].rows.len(), 2);
313        assert!(!periods[0].rows[0].is_uncategorized);
314        assert!(periods[0].rows[1].is_uncategorized);
315    }
316}