1use 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#[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#[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#[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 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}