1
use chrono::{DateTime, Utc};
2
use std::sync::Arc;
3

            
4
use askama::Template;
5
use axum::Json;
6
use axum::extract::Query;
7
use axum::http::HeaderMap;
8
use axum::{Extension, extract::State, http::StatusCode, response::IntoResponse};
9
use serde::Deserialize;
10
use server::command::{CmdResult, ReportData, ReportMeta, report::BalanceReport};
11
use sqlx::types::Uuid;
12

            
13
use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
14

            
15
use 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
};
21
use plotting::adapters::{BalanceChartOpts, balance_chart};
22
use server::command::report::view::{AmountView, ReportRowView, flatten_report_data};
23

            
24
#[derive(Template)]
25
#[template(path = "pages/report/balance.html")]
26
struct BalanceReportPage;
27

            
28
pub async fn balance_report_page() -> impl IntoResponse {
29
    HtmlTemplate(BalanceReportPage)
30
}
31

            
32
#[derive(Template)]
33
#[template(path = "components/report/balance_table.html")]
34
struct 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)]
57
pub 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

            
76
fn 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.
92
type 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.
99
8
fn balance_bounds(
100
8
    target_commodity_id: Option<&str>,
101
8
    date_from: Option<&str>,
102
8
    as_of_str: &str,
103
8
) -> Result<BalanceBounds, StatusCode> {
104
8
    let target = target_commodity_id
105
8
        .filter(|s| !s.is_empty())
106
8
        .map(|s| s.parse::<Uuid>())
107
8
        .transpose()
108
8
        .map_err(|_| StatusCode::BAD_REQUEST)?;
109
6
    let from = date_from
110
6
        .filter(|s| !s.is_empty())
111
6
        .map(|s| parse_date_bound(s, false).ok_or(StatusCode::BAD_REQUEST))
112
6
        .transpose()?;
113
4
    let as_of = match as_of_str {
114
4
        "" => None,
115
2
        s => Some(parse_date_bound(s, true).ok_or(StatusCode::BAD_REQUEST)?),
116
    };
117
2
    Ok((target, from, as_of))
118
8
}
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.
123
async 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

            
163
pub 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)]
248
pub 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

            
265
async 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

            
291
pub 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

            
305
pub 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)]
315
mod tests {
316
    use super::balance_bounds;
317

            
318
    #[test]
319
2
    fn an_unparsable_bound_is_refused_not_dropped() {
320
2
        assert!(balance_bounds(Some("not-a-uuid"), None, "2024-01-31").is_err());
321
2
        assert!(balance_bounds(None, Some("31-01-2024"), "2024-01-31").is_err());
322
2
        assert!(balance_bounds(None, None, "31-01-2024").is_err());
323
2
    }
324

            
325
    #[test]
326
2
    fn absent_bounds_are_not_an_error() {
327
2
        let (target, from, as_of) =
328
2
            balance_bounds(Some(""), Some(""), "").expect("blank params are not a refusal");
329
2
        assert!(target.is_none() && from.is_none() && as_of.is_none());
330
2
    }
331
}