Skip to main content

web/pages/report/
balance.rs

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
91/// A conversion target and the two range bounds a balance report accepts.
92type BalanceBounds = (Option<Uuid>, Option<DateTime<Utc>>, Option<DateTime<Utc>>);
93
94/// The conversion target and range bounds, or `BAD_REQUEST`.
95///
96/// Dropping any of these silently answers a different question under a 200:
97/// an unparsable target leaves the report in original currencies, and an
98/// unparsable `date_from` turns a range into a snapshot.
99fn 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
120/// Build the `BalanceReport` command from shared filter params, run it,
121/// and flatten to `ReportRowView`. Both the table handler and the chart
122/// handlers reuse this so the chart is always consistent with the table.
123async 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/// The balance chart shows top-level accounts by magnitude. Both the
245/// SVG and JSON chart endpoints take the same params as the table
246/// endpoint plus `ChartParams`.
247#[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, &params).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, &params).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}