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