1use chrono::{DateTime, Utc};
2use std::sync::Arc;
3
4use askama::Template;
5use axum::Json;
6use axum::extract::Query;
7use axum::http::HeaderMap;
8use axum::{Extension, extract::State, http::StatusCode, response::IntoResponse};
9use serde::Deserialize;
10use server::command::{CmdResult, ReportData, ReportMeta, report::BalanceReport};
11use sqlx::types::Uuid;
12
13use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
14
15use super::{
16 ChartParams, CommodityOption, SummaryCard, TableControlParams, build_report_filter,
17 commodity_symbols_in_rows, empty_string_as_none, load_commodities, parse_date_bound,
18 parse_sort_order_shared, report_status, sort_top_level_rows, sum_top_level_amounts,
19 today_string, wants_json,
20};
21use plotting::adapters::{BalanceChartOpts, balance_chart};
22use server::command::report::view::{AmountView, ReportRowView, flatten_report_data};
23
24#[derive(Template)]
25#[template(path = "pages/report/balance.html")]
26struct BalanceReportPage;
27
28pub async fn balance_report_page() -> impl IntoResponse {
29 HtmlTemplate(BalanceReportPage)
30}
31
32#[derive(Template)]
33#[template(path = "components/report/balance_table.html")]
34struct BalanceTableTemplate {
35 commodities: Vec<CommodityOption>,
36 summary: Vec<SummaryCard>,
37 rows: Vec<ReportRowView>,
38 commodity_symbols: Vec<String>,
39 commodity_columns: bool,
40 sortable: bool,
41 collapsible: bool,
42 period_mode: bool,
43 date_from: Option<String>,
44 as_of: Option<String>,
45 target_commodity_id: Option<String>,
46 collapsed_depth: Option<String>,
47 sort_order: String,
48 tag_filters: String,
49 tag_filter_mode: String,
50 scripting_enabled: bool,
51 chart_kind: String,
52 chart_query: String,
53 renderer: String,
54}
55
56#[derive(Deserialize)]
57pub struct BalanceParams {
58 #[serde(default, deserialize_with = "empty_string_as_none")]
59 target_commodity_id: Option<String>,
60 #[serde(default, deserialize_with = "empty_string_as_none")]
61 date_from: Option<String>,
62 #[serde(default, deserialize_with = "empty_string_as_none")]
63 as_of: Option<String>,
64 #[serde(default, deserialize_with = "empty_string_as_none")]
65 tag_filters: Option<String>,
66 #[serde(default, deserialize_with = "empty_string_as_none")]
67 tag_filter_mode: Option<String>,
68 #[serde(default, deserialize_with = "empty_string_as_none")]
69 sort_order: Option<String>,
70 #[serde(flatten)]
71 table: TableControlParams,
72 #[serde(flatten)]
73 chart: ChartParams,
74}
75
76fn balance_summary(rows: &[ReportRowView]) -> Vec<SummaryCard> {
77 sum_top_level_amounts(rows)
78 .into_iter()
79 .map(|a| SummaryCard {
80 label: format!("Total ({})", a.commodity_symbol),
81 amounts: vec![AmountView {
82 commodity_symbol: a.commodity_symbol,
83 amount: a.amount,
84 }],
85 is_net: false,
86 highlight: false,
87 })
88 .collect()
89}
90
91type BalanceBounds = (Option<Uuid>, Option<DateTime<Utc>>, Option<DateTime<Utc>>);
93
94fn balance_bounds(
100 target_commodity_id: Option<&str>,
101 date_from: Option<&str>,
102 as_of_str: &str,
103) -> Result<BalanceBounds, StatusCode> {
104 let target = target_commodity_id
105 .filter(|s| !s.is_empty())
106 .map(|s| s.parse::<Uuid>())
107 .transpose()
108 .map_err(|_| StatusCode::BAD_REQUEST)?;
109 let from = date_from
110 .filter(|s| !s.is_empty())
111 .map(|s| parse_date_bound(s, false).ok_or(StatusCode::BAD_REQUEST))
112 .transpose()?;
113 let as_of = match as_of_str {
114 "" => None,
115 s => Some(parse_date_bound(s, true).ok_or(StatusCode::BAD_REQUEST)?),
116 };
117 Ok((target, from, as_of))
118}
119
120async fn run_balance(
124 user_id: Uuid,
125 target_commodity_id: Option<&str>,
126 date_from: Option<&str>,
127 as_of_str: &str,
128 tag_filters: Option<&str>,
129 tag_filter_mode: Option<&str>,
130) -> Result<ReportData, StatusCode> {
131 let mut cmd = BalanceReport::new().user_id(user_id);
132
133 let (target, from, as_of) = balance_bounds(target_commodity_id, date_from, as_of_str)?;
134 if let Some(tid) = target {
135 cmd = cmd.target_commodity_id(tid);
136 }
137 if let Some(df) = from {
138 cmd = cmd.date_from(df);
139 }
140 if let Some(as_of) = as_of {
141 cmd = cmd.as_of(as_of);
142 }
143
144 if let Some(filter) = build_report_filter(tag_filters, tag_filter_mode)? {
145 cmd = cmd.report_filter(filter);
146 }
147
148 let result = cmd.run().await.map_err(report_status)?;
149
150 Ok(match result {
151 Some(CmdResult::Report(data)) => data,
152 _ => ReportData {
153 meta: ReportMeta {
154 date_from: None,
155 date_to: None,
156 target_commodity_id: None,
157 },
158 periods: vec![],
159 },
160 })
161}
162
163pub async fn balance_report_table(
164 Query(params): Query<BalanceParams>,
165 State(_data): State<Arc<AppState>>,
166 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
167 headers: HeaderMap,
168) -> Result<impl IntoResponse, StatusCode> {
169 let commodities = load_commodities(jwt_auth.user.id).await;
170
171 let as_of_str = params.as_of.clone().unwrap_or_else(today_string);
172 let period_mode = params.date_from.is_some();
173
174 let report_data = run_balance(
175 jwt_auth.user.id,
176 params.target_commodity_id.as_deref(),
177 params.date_from.as_deref(),
178 &as_of_str,
179 params.tag_filters.as_deref(),
180 params.tag_filter_mode.as_deref(),
181 )
182 .await?;
183
184 if wants_json(&headers) {
185 return Ok(Json(report_data).into_response());
186 }
187
188 let mut rows = flatten_report_data(&report_data);
189 let sort_order = parse_sort_order_shared(params.sort_order.as_deref());
190 sort_top_level_rows(&mut rows, sort_order);
191 let summary = balance_summary(&rows);
192 let commodity_symbols = commodity_symbols_in_rows(&rows);
193 let commodity_columns = params.table.commodity_columns_enabled();
194
195 let tag_filter_mode = params
196 .tag_filter_mode
197 .clone()
198 .unwrap_or_else(|| "visual".to_string());
199
200 let chart_kind = params.chart.chart_kind_str();
201 let renderer = params.chart.renderer_str();
202 let sort_order_str = sort_order.to_str().to_string();
203 let chart_query = super::encode_query(&[
204 (
205 "target_commodity_id",
206 params.target_commodity_id.as_deref().unwrap_or_default(),
207 ),
208 ("date_from", params.date_from.as_deref().unwrap_or_default()),
209 ("as_of", as_of_str.as_str()),
210 (
211 "tag_filters",
212 params.tag_filters.as_deref().unwrap_or_default(),
213 ),
214 ("tag_filter_mode", tag_filter_mode.as_str()),
215 ("chart_kind", chart_kind.as_str()),
216 ("renderer", renderer.as_str()),
217 ("sort_order", sort_order_str.as_str()),
218 ]);
219
220 Ok(HtmlTemplate(BalanceTableTemplate {
221 commodities,
222 summary,
223 rows,
224 commodity_symbols,
225 commodity_columns,
226 sortable: true,
227 collapsible: true,
228 period_mode,
229 date_from: params.date_from,
230 as_of: Some(as_of_str),
231 target_commodity_id: params.target_commodity_id,
232 collapsed_depth: params.table.collapsed_depth,
233 sort_order: sort_order_str,
234 tag_filters: params.tag_filters.unwrap_or_default(),
235 tag_filter_mode,
236 scripting_enabled: true,
237 chart_kind,
238 chart_query,
239 renderer,
240 })
241 .into_response())
242}
243
244#[derive(Deserialize)]
248pub struct BalanceChartQuery {
249 #[serde(default, deserialize_with = "empty_string_as_none")]
250 target_commodity_id: Option<String>,
251 #[serde(default, deserialize_with = "empty_string_as_none")]
252 date_from: Option<String>,
253 #[serde(default, deserialize_with = "empty_string_as_none")]
254 as_of: Option<String>,
255 #[serde(default, deserialize_with = "empty_string_as_none")]
256 tag_filters: Option<String>,
257 #[serde(default, deserialize_with = "empty_string_as_none")]
258 tag_filter_mode: Option<String>,
259 #[serde(default, deserialize_with = "empty_string_as_none")]
260 sort_order: Option<String>,
261 #[serde(flatten)]
262 chart: ChartParams,
263}
264
265async fn balance_chart_spec(
266 user_id: Uuid,
267 params: &BalanceChartQuery,
268) -> Result<plotting::ChartSpec, StatusCode> {
269 let as_of_str = params.as_of.clone().unwrap_or_else(today_string);
270 let report_data = run_balance(
271 user_id,
272 params.target_commodity_id.as_deref(),
273 params.date_from.as_deref(),
274 &as_of_str,
275 params.tag_filters.as_deref(),
276 params.tag_filter_mode.as_deref(),
277 )
278 .await?;
279 let rows = flatten_report_data(&report_data);
280 let sort_order = parse_sort_order_shared(params.sort_order.as_deref());
281 Ok(balance_chart(
282 &rows,
283 BalanceChartOpts {
284 kind: params.chart.chart_kind_or_default(),
285 top_n: 10,
286 sort_order: sort_order.into_plotting_balance(),
287 },
288 ))
289}
290
291pub async fn balance_report_chart_svg(
292 Query(params): Query<BalanceChartQuery>,
293 State(_data): State<Arc<AppState>>,
294 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
295) -> Result<impl IntoResponse, StatusCode> {
296 let spec = balance_chart_spec(jwt_auth.user.id, ¶ms).await?;
297 let svg = plotting::svg::render_svg(&spec, 720, 360);
298 Ok((
299 StatusCode::OK,
300 [(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
301 svg,
302 ))
303}
304
305pub async fn balance_report_chart_json(
306 Query(params): Query<BalanceChartQuery>,
307 State(_data): State<Arc<AppState>>,
308 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
309) -> Result<impl IntoResponse, StatusCode> {
310 let spec = balance_chart_spec(jwt_auth.user.id, ¶ms).await?;
311 Ok(Json(spec))
312}
313
314#[cfg(test)]
315mod tests {
316 use super::balance_bounds;
317
318 #[test]
319 fn an_unparsable_bound_is_refused_not_dropped() {
320 assert!(balance_bounds(Some("not-a-uuid"), None, "2024-01-31").is_err());
321 assert!(balance_bounds(None, Some("31-01-2024"), "2024-01-31").is_err());
322 assert!(balance_bounds(None, None, "31-01-2024").is_err());
323 }
324
325 #[test]
326 fn absent_bounds_are_not_an_error() {
327 let (target, from, as_of) =
328 balance_bounds(Some(""), Some(""), "").expect("blank params are not a refusal");
329 assert!(target.is_none() && from.is_none() && as_of.is_none());
330 }
331}