Skip to main content

web/pages/report/
balance.rs

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
89/// Build the `BalanceReport` command from shared filter params, run it,
90/// and flatten to `ReportRowView`. Both the table handler and the chart
91/// handlers reuse this so the chart is always consistent with the table.
92async 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/// The balance chart shows top-level accounts by magnitude. Both the
222/// SVG and JSON chart endpoints take the same params as the table
223/// endpoint plus `ChartParams`.
224#[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, &params).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, &params).await?;
288    Ok(Json(spec))
289}