1
use std::sync::Arc;
2

            
3
use askama::Template;
4
use axum::extract::{Query, State};
5
use axum::http::{HeaderMap, header};
6
use axum::{Extension, Json};
7
use axum::{http::StatusCode, response::IntoResponse};
8
use num_rational::Rational64;
9
use serde::{Deserialize, Serialize};
10
use server::command::{
11
    CmdResult, FinanceEntity, account::GetAccount, account::GetAccountCommodities,
12
    account::GetBalance, account::ListAccounts,
13
};
14
use sqlx::types::Uuid;
15

            
16
use crate::pages::HtmlTemplate;
17
use crate::{AppState, jwt_auth::JWTAuthMiddleware};
18

            
19
#[derive(Template)]
20
#[template(path = "pages/account/list.html")]
21
struct AccountListPage;
22

            
23
pub async fn account_list_page() -> impl IntoResponse {
24
    let template = AccountListPage {};
25
    HtmlTemplate(template)
26
}
27

            
28
#[derive(Serialize, Clone)]
29
pub struct BalanceView {
30
    balance: Rational64,
31
    commodity: String,
32
}
33

            
34
#[derive(Serialize)]
35
struct AccountView {
36
    id: Uuid,
37
    name: String,
38
    balance: Option<Vec<BalanceView>>,
39
}
40

            
41
#[derive(Template)]
42
#[template(path = "components/account/table.html")]
43
struct AccountTableTemplate {
44
    accounts: Vec<AccountView>,
45
}
46

            
47
#[derive(Serialize)]
48
struct AccountJson {
49
    id: String,
50
    name: String,
51
    balance: Option<Vec<BalanceView>>,
52
}
53

            
54
async fn get_balance_display(
55
    account_id: Uuid,
56
    user_id: Uuid,
57
    commodities: &[server::command::CommodityInfo],
58
) -> Result<Option<Vec<BalanceView>>, server::command::CmdError> {
59
    let balance_result = GetBalance::new()
60
        .user_id(user_id)
61
        .account_id(account_id)
62
        .run()
63
        .await?;
64

            
65
    match balance_result {
66
        Some(CmdResult::MultiCurrencyBalance(balances)) => {
67
            if balances.is_empty() {
68
                Ok(None)
69
            } else if balances.len() == 1 {
70
                // Single currency
71
                let (commodity, balance) = &balances[0];
72
                let commodity_info = commodities
73
                    .iter()
74
                    .find(|c| c.commodity_id == commodity.id)
75
                    .map(|c| c.symbol.clone())
76
                    .unwrap_or_else(|| "?".to_string());
77
                Ok(Some(vec![BalanceView {
78
                    balance: *balance,
79
                    commodity: commodity_info,
80
                }]))
81
            } else {
82
                // Multiple currencies - show all balances comma-separated
83
                // TODO: rework for arrays?
84
                let balance_strings: Vec<BalanceView> = balances
85
                    .iter()
86
                    .map(|(commodity, balance)| {
87
                        let symbol = commodities
88
                            .iter()
89
                            .find(|c| c.commodity_id == commodity.id)
90
                            .map(|c| c.symbol.clone())
91
                            .unwrap_or_else(|| "?".to_string());
92
                        BalanceView {
93
                            balance: *balance,
94
                            commodity: symbol,
95
                        }
96
                    })
97
                    .collect();
98
                Ok(Some(balance_strings))
99
            }
100
        }
101
        Some(CmdResult::Rational(balance)) => {
102
            // Single currency result (when commodity_id was specified)
103
            if balance == Rational64::new(0, 1) {
104
                Ok(None)
105
            } else if commodities.is_empty() {
106
                Ok(Some(vec![BalanceView {
107
                    balance,
108
                    commodity: "?".to_string(),
109
                }]))
110
            } else {
111
                let symbol = &commodities[0].symbol;
112
                Ok(Some(vec![BalanceView {
113
                    balance,
114
                    commodity: symbol.to_string(),
115
                }]))
116
            }
117
        }
118
        None => Ok(None),
119
        _ => Err(server::command::CmdError::Args(
120
            "Unexpected result type from GetBalance".to_string(),
121
        )),
122
    }
123
}
124

            
125
16
pub async fn account_table(
126
16
    State(_data): State<Arc<AppState>>,
127
16
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
128
16
    headers: HeaderMap,
129
24
) -> Result<impl IntoResponse, StatusCode> {
130
16
    let result = ListAccounts::new()
131
16
        .user_id(jwt_auth.user.id)
132
16
        .run()
133
16
        .await
134
16
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
135

            
136
    let mut accounts = Vec::new();
137
    if let Some(CmdResult::TaggedEntities(entities)) = result {
138
        for (entity, tags) in entities {
139
            if let FinanceEntity::Account(account) = entity {
140
                // Find name tag
141
                let name = if let FinanceEntity::Tag(n) = &tags["name"] {
142
                    n.tag_value.clone()
143
                } else {
144
                    return Err(StatusCode::INTERNAL_SERVER_ERROR);
145
                };
146

            
147
                // Get all commodities for this account
148
                let commodities_result = GetAccountCommodities::new()
149
                    .user_id(jwt_auth.user.id)
150
                    .account_id(account.id)
151
                    .run()
152
                    .await
153
                    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
154

            
155
                let commodities =
156
                    if let Some(CmdResult::CommodityInfoList(commodities)) = commodities_result {
157
                        commodities
158
                    } else {
159
                        Vec::new()
160
                    };
161

            
162
                let balance_display =
163
                    get_balance_display(account.id, jwt_auth.user.id, &commodities)
164
                        .await
165
                        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
166

            
167
                accounts.push(AccountView {
168
                    id: account.id,
169
                    name,
170
                    balance: balance_display,
171
                });
172
            }
173
        }
174
    }
175

            
176
    // Check if the client is requesting JSON by examining the Accept header
177
    let wants_json = headers
178
        .get(header::ACCEPT)
179
        .and_then(|value| value.to_str().ok())
180
        .is_some_and(|value| value.contains("application/json"));
181

            
182
    if wants_json {
183
        // Return JSON response
184
        let accounts_json: Vec<AccountJson> = accounts
185
            .iter()
186
            .map(|a| AccountJson {
187
                id: a.id.to_string(),
188
                name: a.name.clone(),
189
                balance: a.balance.clone(),
190
            })
191
            .collect();
192

            
193
        return Ok(Json(accounts_json).into_response());
194
    }
195

            
196
    // Default to HTML response
197
    Ok(HtmlTemplate(AccountTableTemplate { accounts }).into_response())
198
16
}
199

            
200
#[derive(Deserialize)]
201
pub struct AccountInfoParam {
202
    account: Uuid,
203
}
204

            
205
#[derive(Template)]
206
#[template(path = "components/account/info.html")]
207
struct AccountInfoTemplate {
208
    account_id: Uuid,
209
    name: String,
210
    balance: Option<Vec<BalanceView>>,
211
}
212

            
213
pub async fn account_info(
214
    Query(params): Query<AccountInfoParam>,
215
    State(_data): State<Arc<AppState>>,
216
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
217
) -> Result<impl IntoResponse, StatusCode> {
218
    let account_result = GetAccount::new()
219
        .user_id(jwt_auth.user.id)
220
        .account_id(params.account)
221
        .run()
222
        .await
223
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
224

            
225
    let name = if let Some(CmdResult::TaggedEntities(entities)) = account_result
226
        && let Some((FinanceEntity::Account(_account), tags)) = entities.first()
227
        && let Some(FinanceEntity::Tag(name_tag)) = tags.get("name")
228
    {
229
        name_tag.tag_value.clone()
230
    } else {
231
        return Err(StatusCode::NOT_FOUND);
232
    };
233

            
234
    let commodities_result = GetAccountCommodities::new()
235
        .user_id(jwt_auth.user.id)
236
        .account_id(params.account)
237
        .run()
238
        .await
239
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
240

            
241
    let commodities = if let Some(CmdResult::CommodityInfoList(commodities)) = commodities_result {
242
        commodities
243
    } else {
244
        Vec::new()
245
    };
246

            
247
    let balance = get_balance_display(params.account, jwt_auth.user.id, &commodities)
248
        .await
249
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
250

            
251
    Ok(HtmlTemplate(AccountInfoTemplate {
252
        account_id: params.account,
253
        name,
254
        balance,
255
    }))
256
}