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
    currency: Option<Uuid>,
39
    balance: Option<Vec<BalanceView>>,
40
}
41

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

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

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

            
67
    match balance_result {
68
        Some(CmdResult::MultiCurrencyBalance(balances)) => {
69
            if balances.is_empty() {
70
                Ok(None)
71
            } else if balances.len() == 1 {
72
                // Single currency
73
                let (commodity, balance) = &balances[0];
74
                let commodity_info = commodities
75
                    .iter()
76
                    .find(|c| c.commodity_id == commodity.id)
77
                    .map_or_else(|| "?".to_string(), |c| c.symbol.clone());
78
                Ok(Some(vec![BalanceView {
79
                    balance: *balance,
80
                    commodity: commodity_info,
81
                }]))
82
            } else {
83
                // Multiple currencies - show all balances comma-separated
84
                // TODO: rework for arrays?
85
                let balance_strings: Vec<BalanceView> = balances
86
                    .iter()
87
                    .map(|(commodity, balance)| {
88
                        let symbol = commodities
89
                            .iter()
90
                            .find(|c| c.commodity_id == commodity.id)
91
                            .map_or_else(|| "?".to_string(), |c| c.symbol.clone());
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.clone(),
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
9
pub async fn account_table(
126
9
    State(_data): State<Arc<AppState>>,
127
9
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
128
9
    headers: HeaderMap,
129
9
) -> Result<impl IntoResponse, StatusCode> {
130
9
    let result = ListAccounts::new()
131
9
        .user_id(jwt_auth.user.id)
132
9
        .run()
133
9
        .await
134
9
        .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. A nameless account is listed under its id
141
                // rather than failing the whole page, so it can be fixed.
142
                let name = match tags.get("name") {
143
                    Some(FinanceEntity::Tag(n)) => n.tag_value.clone(),
144
                    Some(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
145
                    None => {
146
                        log::warn!("Account {} has no name tag", account.id);
147
                        account.id.to_string()
148
                    }
149
                };
150

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

            
159
                let commodities =
160
                    if let Some(CmdResult::CommodityInfoList(commodities)) = commodities_result {
161
                        commodities
162
                    } else {
163
                        Vec::new()
164
                    };
165

            
166
                let balance_display =
167
                    get_balance_display(account.id, jwt_auth.user.id, &commodities)
168
                        .await
169
                        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
170

            
171
                let currency = commodities.first().map(|c| c.commodity_id);
172

            
173
                accounts.push(AccountView {
174
                    id: account.id,
175
                    name,
176
                    currency,
177
                    balance: balance_display,
178
                });
179
            }
180
        }
181
    }
182

            
183
    // Check if the client is requesting JSON by examining the Accept header
184
    let wants_json = headers
185
        .get(header::ACCEPT)
186
        .and_then(|value| value.to_str().ok())
187
        .is_some_and(|value| value.contains("application/json"));
188

            
189
    if wants_json {
190
        // Return JSON response
191
        let accounts_json: Vec<AccountJson> = accounts
192
            .iter()
193
            .map(|a| AccountJson {
194
                id: a.id.to_string(),
195
                name: a.name.clone(),
196
                currency: a.currency.map(|c| c.to_string()),
197
                balance: a.balance.clone(),
198
            })
199
            .collect();
200

            
201
        return Ok(Json(accounts_json).into_response());
202
    }
203

            
204
    // Default to HTML response
205
    Ok(HtmlTemplate(AccountTableTemplate { accounts }).into_response())
206
9
}
207

            
208
#[derive(Deserialize)]
209
pub struct AccountInfoParam {
210
    account: Uuid,
211
}
212

            
213
#[derive(Template)]
214
#[template(path = "components/account/info.html")]
215
struct AccountInfoTemplate {
216
    account_id: Uuid,
217
    name: String,
218
    balance: Option<Vec<BalanceView>>,
219
}
220

            
221
pub async fn account_info(
222
    Query(params): Query<AccountInfoParam>,
223
    State(_data): State<Arc<AppState>>,
224
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
225
) -> Result<impl IntoResponse, StatusCode> {
226
    let account_result = GetAccount::new()
227
        .user_id(jwt_auth.user.id)
228
        .account_id(params.account)
229
        .run()
230
        .await
231
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
232

            
233
    let name = if let Some(CmdResult::TaggedEntities { entities, .. }) = account_result
234
        && let Some((FinanceEntity::Account(_account), tags)) = entities.first()
235
        && let Some(FinanceEntity::Tag(name_tag)) = tags.get("name")
236
    {
237
        name_tag.tag_value.clone()
238
    } else {
239
        return Err(StatusCode::NOT_FOUND);
240
    };
241

            
242
    let commodities_result = GetAccountCommodities::new()
243
        .user_id(jwt_auth.user.id)
244
        .account_id(params.account)
245
        .run()
246
        .await
247
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
248

            
249
    let commodities = if let Some(CmdResult::CommodityInfoList(commodities)) = commodities_result {
250
        commodities
251
    } else {
252
        Vec::new()
253
    };
254

            
255
    let balance = get_balance_display(params.account, jwt_auth.user.id, &commodities)
256
        .await
257
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
258

            
259
    Ok(HtmlTemplate(AccountInfoTemplate {
260
        account_id: params.account,
261
        name,
262
        balance,
263
    }))
264
}