1
use std::sync::Arc;
2

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

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

            
16
use super::{
17
    ChartParams, CommodityOption, SummaryCard, TableControlParams, build_report_filter,
18
    empty_string_as_none, load_commodities, month_start_string, parse_date_bound, report_status,
19
    today_string, wants_json,
20
};
21
use plotting::adapters::{BreakdownChartOpts, breakdown_chart};
22
use server::command::report::view::{AmountView, BreakdownPeriodView, flatten_breakdown_data};
23

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

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

            
32
fn parse_period_grouping(s: &str) -> Option<PeriodGrouping> {
33
    match s {
34
        "month" => Some(PeriodGrouping::Month),
35
        "quarter" => Some(PeriodGrouping::Quarter),
36
        "year" => Some(PeriodGrouping::Year),
37
        _ => None,
38
    }
39
}
40

            
41
fn parse_sort_order(s: &str) -> Option<BreakdownSort> {
42
    match s {
43
        "amount_desc" => Some(BreakdownSort::AmountDesc),
44
        "amount_asc" => Some(BreakdownSort::AmountAsc),
45
        "name_asc" => Some(BreakdownSort::NameAsc),
46
        "name_desc" => Some(BreakdownSort::NameDesc),
47
        _ => None,
48
    }
49
}
50

            
51
#[derive(Template)]
52
#[template(path = "components/report/category_breakdown_table.html")]
53
struct CategoryBreakdownTableTemplate {
54
    commodities: Vec<CommodityOption>,
55
    summary: Vec<SummaryCard>,
56
    periods: Vec<BreakdownPeriodView>,
57
    commodity_symbols: Vec<String>,
58
    commodity_columns: bool,
59
    tag_name: String,
60
    date_from: Option<String>,
61
    date_to: Option<String>,
62
    target_commodity_id: Option<String>,
63
    period_grouping: Option<String>,
64
    sort_order: String,
65
    include_uncategorized: bool,
66
    tag_filters: String,
67
    tag_filter_mode: String,
68
    scripting_enabled: bool,
69
    chart_kind: String,
70
    chart_query: String,
71
    renderer: String,
72
}
73

            
74
#[derive(Deserialize)]
75
pub struct CategoryBreakdownParams {
76
    #[serde(default, deserialize_with = "empty_string_as_none")]
77
    date_from: Option<String>,
78
    #[serde(default, deserialize_with = "empty_string_as_none")]
79
    date_to: Option<String>,
80
    #[serde(default, deserialize_with = "empty_string_as_none")]
81
    target_commodity_id: Option<String>,
82
    #[serde(default, deserialize_with = "empty_string_as_none")]
83
    period_grouping: Option<String>,
84
    #[serde(default, deserialize_with = "empty_string_as_none")]
85
    tag_name: Option<String>,
86
    #[serde(default, deserialize_with = "empty_string_as_none")]
87
    sort_order: Option<String>,
88
    #[serde(default)]
89
    exclude_uncategorized: Option<String>,
90
    #[serde(default, deserialize_with = "empty_string_as_none")]
91
    tag_filters: Option<String>,
92
    #[serde(default, deserialize_with = "empty_string_as_none")]
93
    tag_filter_mode: Option<String>,
94
    #[serde(flatten)]
95
    table: TableControlParams,
96
    #[serde(flatten)]
97
    chart: ChartParams,
98
}
99

            
100
/// HTML checkboxes only submit a value when checked. `exclude_uncategorized`
101
/// defaults to false so an absent parameter naturally reads as "include the
102
/// bucket" and a checked box as "exclude it".
103
fn params_include_uncategorized(exclude_raw: Option<&str>) -> bool {
104
    let exclude = exclude_raw.is_some_and(|v| {
105
        let v = v.trim();
106
        v.eq_ignore_ascii_case("on") || v.eq_ignore_ascii_case("true")
107
    });
108
    !exclude
109
}
110

            
111
fn push_amount(dest: &mut Vec<AmountView>, a: &AmountView) {
112
    match dest
113
        .iter_mut()
114
        .find(|d| d.commodity_symbol == a.commodity_symbol)
115
    {
116
        Some(existing) => existing.amount += a.amount,
117
        None => dest.push(a.clone()),
118
    }
119
}
120

            
121
/// Build summary cards for Category Breakdown: one grand-total card per
122
/// commodity plus up to `top_n` cards for the largest categories by
123
/// absolute amount.
124
fn breakdown_summary(periods: &[BreakdownPeriodView], top_n: usize) -> Vec<SummaryCard> {
125
    let mut totals: Vec<AmountView> = Vec::new();
126
    let mut per_category: Vec<(String, bool, Vec<AmountView>)> = Vec::new();
127

            
128
    for period in periods {
129
        for row in &period.rows {
130
            for a in &row.amounts {
131
                push_amount(&mut totals, a);
132
            }
133
            match per_category
134
                .iter_mut()
135
                .find(|(name, _, _)| name == &row.tag_value)
136
            {
137
                Some((_, _, amts)) => {
138
                    for a in &row.amounts {
139
                        push_amount(amts, a);
140
                    }
141
                }
142
                None => per_category.push((
143
                    row.tag_value.clone(),
144
                    row.is_uncategorized,
145
                    row.amounts.clone(),
146
                )),
147
            }
148
        }
149
    }
150

            
151
    let mut cards: Vec<SummaryCard> = totals
152
        .into_iter()
153
        .map(|a| SummaryCard {
154
            label: format!("Total ({})", a.commodity_symbol),
155
            amounts: vec![a],
156
            is_net: false,
157
            highlight: true,
158
        })
159
        .collect();
160

            
161
    per_category.sort_by(|a, b| {
162
        let key = |row: &(String, bool, Vec<AmountView>)| -> num_rational::Rational64 {
163
            row.2
164
                .iter()
165
                .map(|x| {
166
                    if x.amount < num_rational::Rational64::new(0, 1) {
167
                        -x.amount
168
                    } else {
169
                        x.amount
170
                    }
171
                })
172
                .sum()
173
        };
174
        key(b).cmp(&key(a))
175
    });
176

            
177
    for (name, is_uncat, amounts) in per_category.into_iter().take(top_n) {
178
        let label = if is_uncat {
179
            "(uncategorized)".to_string()
180
        } else {
181
            name
182
        };
183
        cards.push(SummaryCard {
184
            label,
185
            amounts,
186
            is_net: false,
187
            highlight: false,
188
        });
189
    }
190
    cards
191
}
192

            
193
fn collect_commodity_symbols(periods: &[BreakdownPeriodView]) -> Vec<String> {
194
    let mut seen: Vec<String> = Vec::new();
195
    for period in periods {
196
        for row in &period.rows {
197
            for a in &row.amounts {
198
                if !seen.iter().any(|s| s == &a.commodity_symbol) {
199
                    seen.push(a.commodity_symbol.clone());
200
                }
201
            }
202
        }
203
    }
204
    seen
205
}
206

            
207
pub async fn category_breakdown_report_table(
208
    Query(params): Query<CategoryBreakdownParams>,
209
    State(_data): State<Arc<AppState>>,
210
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
211
    headers: HeaderMap,
212
) -> Result<impl IntoResponse, StatusCode> {
213
    let commodities = load_commodities(jwt_auth.user.id).await;
214

            
215
    let date_from_str = params.date_from.clone().unwrap_or_else(month_start_string);
216
    let date_to_str = params.date_to.clone().unwrap_or_else(today_string);
217

            
218
    let date_from = parse_date_bound(&date_from_str, false);
219
    let date_to = parse_date_bound(&date_to_str, true);
220

            
221
    let tag_filter_mode = params
222
        .tag_filter_mode
223
        .clone()
224
        .unwrap_or_else(|| "visual".to_string());
225

            
226
    let tag_name_display = params
227
        .tag_name
228
        .clone()
229
        .unwrap_or_else(|| "category".to_string());
230

            
231
    let sort_order_raw = params
232
        .sort_order
233
        .clone()
234
        .unwrap_or_else(|| "amount_desc".to_string());
235

            
236
    let include_uncategorized =
237
        params_include_uncategorized(params.exclude_uncategorized.as_deref());
238

            
239
    let commodity_columns = params.table.commodity_columns_enabled();
240
    let chart_kind = params.chart.chart_kind_str();
241
    let renderer = params.chart.renderer_str();
242
    let chart_query = super::encode_query(&[
243
        ("date_from", date_from_str.as_str()),
244
        ("date_to", date_to_str.as_str()),
245
        (
246
            "target_commodity_id",
247
            params.target_commodity_id.as_deref().unwrap_or_default(),
248
        ),
249
        (
250
            "period_grouping",
251
            params.period_grouping.as_deref().unwrap_or_default(),
252
        ),
253
        ("tag_name", params.tag_name.as_deref().unwrap_or_default()),
254
        ("sort_order", sort_order_raw.as_str()),
255
        (
256
            "exclude_uncategorized",
257
            if include_uncategorized { "" } else { "on" },
258
        ),
259
        (
260
            "tag_filters",
261
            params.tag_filters.as_deref().unwrap_or_default(),
262
        ),
263
        ("tag_filter_mode", tag_filter_mode.as_str()),
264
        ("chart_kind", chart_kind.as_str()),
265
        ("renderer", renderer.as_str()),
266
    ]);
267

            
268
    // An unparsable date is a client error: an empty table under a 200 reads
269
    // as "no transactions in that range", which is a different answer.
270
    let (Some(df), Some(dt)) = (date_from, date_to) else {
271
        return Err(StatusCode::BAD_REQUEST);
272
    };
273

            
274
    let mut cmd = CategoryBreakdown::new()
275
        .user_id(jwt_auth.user.id)
276
        .date_from(df)
277
        .date_to(dt)
278
        .include_uncategorized(include_uncategorized);
279

            
280
    if let Some(ref name) = params.tag_name {
281
        cmd = cmd.tag_name(name.clone());
282
    }
283

            
284
    // A conversion target that will not parse must not be dropped: the report
285
    // would come back in original currencies under a 200, which is a different
286
    // answer to the one asked for.
287
    if let Some(tid) = params
288
        .target_commodity_id
289
        .as_deref()
290
        .filter(|s| !s.is_empty())
291
        .map(|s| s.parse::<Uuid>())
292
        .transpose()
293
        .map_err(|_| StatusCode::BAD_REQUEST)?
294
    {
295
        cmd = cmd.target_commodity_id(tid);
296
    }
297

            
298
    // Likewise an unknown grouping: silently returning an UNGROUPED report is
299
    // not the report that was requested.
300
    if let Some(pg) = params
301
        .period_grouping
302
        .as_deref()
303
        .filter(|s| !s.is_empty())
304
        .map(|s| parse_period_grouping(s).ok_or(StatusCode::BAD_REQUEST))
305
        .transpose()?
306
    {
307
        cmd = cmd.period_grouping(pg);
308
    }
309

            
310
    if let Some(order) = parse_sort_order(&sort_order_raw) {
311
        cmd = cmd.sort_order(order);
312
    }
313

            
314
    if let Some(filter) = build_report_filter(
315
        params.tag_filters.as_deref(),
316
        Some(tag_filter_mode.as_str()),
317
    )? {
318
        cmd = cmd.report_filter(filter);
319
    }
320

            
321
    let result = cmd.run().await.map_err(report_status)?;
322

            
323
    let breakdown_data = match result {
324
        Some(CmdResult::Breakdown(data)) => data,
325
        _ => BreakdownData {
326
            meta: ReportMeta {
327
                date_from: None,
328
                date_to: None,
329
                target_commodity_id: None,
330
            },
331
            tag_name: tag_name_display.clone(),
332
            periods: vec![],
333
        },
334
    };
335

            
336
    if wants_json(&headers) {
337
        return Ok(Json(breakdown_data).into_response());
338
    }
339

            
340
    let periods = flatten_breakdown_data(&breakdown_data);
341
    let summary = breakdown_summary(&periods, 5);
342
    let commodity_symbols = collect_commodity_symbols(&periods);
343

            
344
    Ok(HtmlTemplate(CategoryBreakdownTableTemplate {
345
        commodities,
346
        summary,
347
        periods,
348
        commodity_symbols,
349
        commodity_columns,
350
        tag_name: breakdown_data.tag_name,
351
        date_from: Some(date_from_str),
352
        date_to: Some(date_to_str),
353
        target_commodity_id: params.target_commodity_id,
354
        period_grouping: params.period_grouping,
355
        sort_order: sort_order_raw,
356
        include_uncategorized,
357
        tag_filters: params.tag_filters.unwrap_or_default(),
358
        tag_filter_mode,
359
        scripting_enabled: true,
360
        chart_kind,
361
        chart_query,
362
        renderer,
363
    })
364
    .into_response())
365
}
366

            
367
#[derive(Deserialize)]
368
pub struct CategoryBreakdownChartQuery {
369
    #[serde(default, deserialize_with = "empty_string_as_none")]
370
    date_from: Option<String>,
371
    #[serde(default, deserialize_with = "empty_string_as_none")]
372
    date_to: Option<String>,
373
    #[serde(default, deserialize_with = "empty_string_as_none")]
374
    target_commodity_id: Option<String>,
375
    #[serde(default, deserialize_with = "empty_string_as_none")]
376
    period_grouping: Option<String>,
377
    #[serde(default, deserialize_with = "empty_string_as_none")]
378
    tag_name: Option<String>,
379
    #[serde(default, deserialize_with = "empty_string_as_none")]
380
    sort_order: Option<String>,
381
    #[serde(default)]
382
    exclude_uncategorized: Option<String>,
383
    #[serde(default, deserialize_with = "empty_string_as_none")]
384
    tag_filters: Option<String>,
385
    #[serde(default, deserialize_with = "empty_string_as_none")]
386
    tag_filter_mode: Option<String>,
387
    #[serde(flatten)]
388
    chart: ChartParams,
389
}
390

            
391
async fn breakdown_periods_for_chart(
392
    user_id: Uuid,
393
    params: &CategoryBreakdownChartQuery,
394
) -> Result<Vec<BreakdownPeriodView>, StatusCode> {
395
    let date_from_str = params.date_from.clone().unwrap_or_else(month_start_string);
396
    let date_to_str = params.date_to.clone().unwrap_or_else(today_string);
397
    let (Some(df), Some(dt)) = (
398
        parse_date_bound(&date_from_str, false),
399
        parse_date_bound(&date_to_str, true),
400
    ) else {
401
        return Err(StatusCode::BAD_REQUEST);
402
    };
403

            
404
    let include_uncategorized =
405
        params_include_uncategorized(params.exclude_uncategorized.as_deref());
406

            
407
    let mut cmd = CategoryBreakdown::new()
408
        .user_id(user_id)
409
        .date_from(df)
410
        .date_to(dt)
411
        .include_uncategorized(include_uncategorized);
412

            
413
    if let Some(ref name) = params.tag_name {
414
        cmd = cmd.tag_name(name.clone());
415
    }
416

            
417
    // A conversion target that will not parse must not be dropped: the report
418
    // would come back in original currencies under a 200, which is a different
419
    // answer to the one asked for.
420
    if let Some(tid) = params
421
        .target_commodity_id
422
        .as_deref()
423
        .filter(|s| !s.is_empty())
424
        .map(|s| s.parse::<Uuid>())
425
        .transpose()
426
        .map_err(|_| StatusCode::BAD_REQUEST)?
427
    {
428
        cmd = cmd.target_commodity_id(tid);
429
    }
430

            
431
    // Likewise an unknown grouping: silently returning an UNGROUPED report is
432
    // not the report that was requested.
433
    if let Some(pg) = params
434
        .period_grouping
435
        .as_deref()
436
        .filter(|s| !s.is_empty())
437
        .map(|s| parse_period_grouping(s).ok_or(StatusCode::BAD_REQUEST))
438
        .transpose()?
439
    {
440
        cmd = cmd.period_grouping(pg);
441
    }
442

            
443
    if let Some(order) = params.sort_order.as_deref().and_then(parse_sort_order) {
444
        cmd = cmd.sort_order(order);
445
    }
446

            
447
    if let Some(filter) = build_report_filter(
448
        params.tag_filters.as_deref(),
449
        params.tag_filter_mode.as_deref(),
450
    )? {
451
        cmd = cmd.report_filter(filter);
452
    }
453

            
454
    let result = cmd.run().await.map_err(report_status)?;
455

            
456
    let breakdown_data = match result {
457
        Some(CmdResult::Breakdown(data)) => data,
458
        _ => BreakdownData {
459
            meta: ReportMeta {
460
                date_from: None,
461
                date_to: None,
462
                target_commodity_id: None,
463
            },
464
            tag_name: params
465
                .tag_name
466
                .clone()
467
                .unwrap_or_else(|| "category".to_string()),
468
            periods: vec![],
469
        },
470
    };
471

            
472
    Ok(flatten_breakdown_data(&breakdown_data))
473
}
474

            
475
async fn breakdown_chart_spec(
476
    user_id: Uuid,
477
    params: &CategoryBreakdownChartQuery,
478
) -> Result<plotting::ChartSpec, StatusCode> {
479
    let periods = breakdown_periods_for_chart(user_id, params).await?;
480
    Ok(breakdown_chart(
481
        &periods,
482
        BreakdownChartOpts {
483
            kind: params.chart.chart_kind_or_default(),
484
            top_n: 10,
485
        },
486
    ))
487
}
488

            
489
pub async fn category_breakdown_report_chart_svg(
490
    Query(params): Query<CategoryBreakdownChartQuery>,
491
    State(_data): State<Arc<AppState>>,
492
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
493
) -> Result<impl IntoResponse, StatusCode> {
494
    let spec = breakdown_chart_spec(jwt_auth.user.id, &params).await?;
495
    let svg = plotting::svg::render_svg(&spec, 720, 360);
496
    Ok((
497
        StatusCode::OK,
498
        [(axum::http::header::CONTENT_TYPE, "image/svg+xml")],
499
        svg,
500
    ))
501
}
502

            
503
pub async fn category_breakdown_report_chart_json(
504
    Query(params): Query<CategoryBreakdownChartQuery>,
505
    State(_data): State<Arc<AppState>>,
506
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
507
) -> Result<impl IntoResponse, StatusCode> {
508
    let spec = breakdown_chart_spec(jwt_auth.user.id, &params).await?;
509
    Ok(Json(spec))
510
}