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

            
8
use num_rational::Rational64;
9
use sqlx::types::Uuid;
10

            
11
use super::super::{ActivityData, BreakdownData, ReportData, ReportNode, UNCATEGORIZED_KEY};
12

            
13
#[derive(Debug, Clone)]
14
pub struct AmountView {
15
    pub commodity_symbol: String,
16
    pub amount: Rational64,
17
}
18

            
19
#[derive(Debug, Clone)]
20
pub 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)]
30
pub 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)]
38
pub struct PeriodActivityView {
39
    pub label: String,
40
    pub groups: Vec<GroupView>,
41
    pub net: Vec<AmountView>,
42
}
43

            
44
#[derive(Debug, Clone)]
45
pub struct BreakdownRowView {
46
    pub tag_value: String,
47
    pub is_uncategorized: bool,
48
    pub amounts: Vec<AmountView>,
49
}
50

            
51
#[derive(Debug, Clone)]
52
pub struct BreakdownPeriodView {
53
    pub label: String,
54
    pub rows: Vec<BreakdownRowView>,
55
}
56

            
57
6
fn amount_views(amounts: &[super::super::CommodityAmount], flip: bool) -> Vec<AmountView> {
58
6
    amounts
59
6
        .iter()
60
6
        .map(|a| AmountView {
61
6
            commodity_symbol: a.commodity_symbol.clone(),
62
6
            amount: if flip { -a.amount } else { a.amount },
63
6
        })
64
6
        .collect()
65
6
}
66

            
67
7
fn flatten_nodes(
68
7
    nodes: &[ReportNode],
69
7
    parent: Option<Uuid>,
70
7
    flip: bool,
71
7
    out: &mut Vec<ReportRowView>,
72
7
) {
73
7
    for node in nodes {
74
4
        out.push(ReportRowView {
75
4
            account_id: node.account_id,
76
4
            parent_id: parent,
77
4
            account_name: node.account_name.clone(),
78
4
            depth: node.depth,
79
4
            has_children: !node.children.is_empty(),
80
4
            amounts: amount_views(&node.amounts, flip),
81
4
        });
82
4
        flatten_nodes(&node.children, Some(node.account_id), flip, out);
83
4
    }
84
7
}
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]
90
1
pub fn flatten_report_data(data: &ReportData) -> Vec<ReportRowView> {
91
1
    let mut rows = Vec::new();
92
1
    for period in &data.periods {
93
1
        flatten_nodes(&period.roots, None, false, &mut rows);
94
1
    }
95
1
    rows
96
1
}
97

            
98
4
fn sum_into(dest: &mut Vec<AmountView>, src: &[AmountView], negate: bool) {
99
4
    for a in src {
100
4
        let contribution = if negate { -a.amount } else { a.amount };
101
4
        match dest
102
4
            .iter_mut()
103
4
            .find(|d| d.commodity_symbol == a.commodity_symbol)
104
        {
105
1
            Some(existing) => existing.amount += contribution,
106
3
            None => dest.push(AmountView {
107
3
                commodity_symbol: a.commodity_symbol.clone(),
108
3
                amount: contribution,
109
3
            }),
110
        }
111
    }
112
4
}
113

            
114
2
fn top_level_totals(rows: &[ReportRowView]) -> Vec<AmountView> {
115
2
    let mut out = Vec::new();
116
2
    for row in rows.iter().filter(|r| r.depth == 0) {
117
2
        sum_into(&mut out, &row.amounts, false);
118
2
    }
119
2
    out
120
2
}
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]
127
1
pub fn flatten_activity_data(data: &ActivityData) -> Vec<PeriodActivityView> {
128
1
    data.periods
129
1
        .iter()
130
1
        .map(|period| {
131
1
            let groups: Vec<GroupView> = period
132
1
                .groups
133
1
                .iter()
134
2
                .map(|g| {
135
2
                    let mut rows = Vec::new();
136
2
                    flatten_nodes(&g.roots, None, g.flip_sign, &mut rows);
137
2
                    let total = top_level_totals(&rows);
138
2
                    GroupView {
139
2
                        label: g.label.clone(),
140
2
                        flip_sign: g.flip_sign,
141
2
                        rows,
142
2
                        total,
143
2
                    }
144
2
                })
145
1
                .collect();
146
1
            let mut net: Vec<AmountView> = Vec::new();
147
2
            for g in &groups {
148
2
                sum_into(&mut net, &g.total, !g.flip_sign);
149
2
            }
150
1
            PeriodActivityView {
151
1
                label: period.label.clone().unwrap_or_default(),
152
1
                groups,
153
1
                net,
154
1
            }
155
1
        })
156
1
        .collect()
157
1
}
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]
163
1
pub fn flatten_breakdown_data(data: &BreakdownData) -> Vec<BreakdownPeriodView> {
164
1
    data.periods
165
1
        .iter()
166
1
        .map(|p| BreakdownPeriodView {
167
1
            label: p.label.clone().unwrap_or_default(),
168
1
            rows: p
169
1
                .rows
170
1
                .iter()
171
1
                .map(|r| BreakdownRowView {
172
2
                    tag_value: r.tag_value.clone(),
173
2
                    is_uncategorized: r.is_uncategorized || r.tag_value == UNCATEGORIZED_KEY,
174
2
                    amounts: amount_views(&r.amounts, false),
175
2
                })
176
1
                .collect(),
177
1
        })
178
1
        .collect()
179
1
}
180

            
181
#[cfg(test)]
182
mod tests {
183
    use super::super::super::{
184
        ActivityGroupResult, ActivityPeriod, BreakdownPeriod, BreakdownRow, CommodityAmount,
185
        PeriodData, ReportMeta,
186
    };
187
    use super::*;
188

            
189
6
    fn commodity(symbol: &str, num: i64) -> CommodityAmount {
190
6
        CommodityAmount {
191
6
            commodity_id: Uuid::new_v4(),
192
6
            commodity_symbol: symbol.to_string(),
193
6
            amount: Rational64::new(num, 1),
194
6
        }
195
6
    }
196

            
197
4
    fn node(
198
4
        name: &str,
199
4
        depth: usize,
200
4
        amounts: Vec<CommodityAmount>,
201
4
        children: Vec<ReportNode>,
202
4
    ) -> ReportNode {
203
4
        ReportNode {
204
4
            account_id: Uuid::new_v4(),
205
4
            account_name: name.to_string(),
206
4
            account_path: name.to_string(),
207
4
            depth,
208
4
            account_type: None,
209
4
            amounts,
210
4
            children,
211
4
        }
212
4
    }
213

            
214
    #[test]
215
1
    fn flatten_report_data_preserves_parent_links() {
216
1
        let child = node("Checking", 1, vec![commodity("USD", 100)], vec![]);
217
1
        let child_id = child.account_id;
218
1
        let parent = node("Assets", 0, vec![commodity("USD", 100)], vec![child]);
219
1
        let parent_id = parent.account_id;
220
1
        let data = ReportData {
221
1
            meta: ReportMeta {
222
1
                date_from: None,
223
1
                date_to: None,
224
1
                target_commodity_id: None,
225
1
            },
226
1
            periods: vec![PeriodData {
227
1
                label: None,
228
1
                roots: vec![parent],
229
1
            }],
230
1
        };
231

            
232
1
        let rows = flatten_report_data(&data);
233

            
234
1
        assert_eq!(rows.len(), 2);
235
1
        assert_eq!(rows[0].account_id, parent_id);
236
1
        assert!(rows[0].parent_id.is_none());
237
1
        assert!(rows[0].has_children);
238
1
        assert_eq!(rows[1].account_id, child_id);
239
1
        assert_eq!(rows[1].parent_id, Some(parent_id));
240
1
        assert!(!rows[1].has_children);
241
1
    }
242

            
243
    #[test]
244
1
    fn flatten_activity_data_flips_income_sign_and_nets() {
245
1
        let income = ActivityGroupResult {
246
1
            label: "Income".to_string(),
247
1
            flip_sign: true,
248
1
            roots: vec![node("Salary", 0, vec![commodity("USD", -500)], vec![])],
249
1
        };
250
1
        let expense = ActivityGroupResult {
251
1
            label: "Expense".to_string(),
252
1
            flip_sign: false,
253
1
            roots: vec![node("Rent", 0, vec![commodity("USD", 200)], vec![])],
254
1
        };
255
1
        let data = ActivityData {
256
1
            meta: ReportMeta {
257
1
                date_from: None,
258
1
                date_to: None,
259
1
                target_commodity_id: None,
260
1
            },
261
1
            periods: vec![ActivityPeriod {
262
1
                label: Some("2026-04".to_string()),
263
1
                groups: vec![income, expense],
264
1
            }],
265
1
        };
266

            
267
1
        let periods = flatten_activity_data(&data);
268

            
269
1
        assert_eq!(periods.len(), 1);
270
1
        let p = &periods[0];
271
1
        assert_eq!(p.label, "2026-04");
272
1
        assert_eq!(
273
1
            p.groups[0].rows[0].amounts[0].amount,
274
1
            Rational64::new(500, 1)
275
        );
276
1
        assert_eq!(p.groups[0].total[0].amount, Rational64::new(500, 1));
277
1
        assert_eq!(p.groups[1].total[0].amount, Rational64::new(200, 1));
278
        // Net: income flipped (+500) minus expense (200) = 300.
279
1
        assert_eq!(p.net.len(), 1);
280
1
        assert_eq!(p.net[0].commodity_symbol, "USD");
281
1
        assert_eq!(p.net[0].amount, Rational64::new(300, 1));
282
1
    }
283

            
284
    #[test]
285
1
    fn flatten_breakdown_data_marks_sentinel_uncategorized() {
286
1
        let data = BreakdownData {
287
1
            meta: ReportMeta {
288
1
                date_from: None,
289
1
                date_to: None,
290
1
                target_commodity_id: None,
291
1
            },
292
1
            tag_name: "category".to_string(),
293
1
            periods: vec![BreakdownPeriod {
294
1
                label: None,
295
1
                rows: vec![
296
1
                    BreakdownRow {
297
1
                        tag_value: "food".to_string(),
298
1
                        is_uncategorized: false,
299
1
                        amounts: vec![commodity("USD", 50)],
300
1
                    },
301
1
                    BreakdownRow {
302
1
                        tag_value: UNCATEGORIZED_KEY.to_string(),
303
1
                        is_uncategorized: true,
304
1
                        amounts: vec![commodity("USD", 10)],
305
1
                    },
306
1
                ],
307
1
            }],
308
1
        };
309

            
310
1
        let periods = flatten_breakdown_data(&data);
311

            
312
1
        assert_eq!(periods[0].rows.len(), 2);
313
1
        assert!(!periods[0].rows[0].is_uncategorized);
314
1
        assert!(periods[0].rows[1].is_uncategorized);
315
1
    }
316
}