Skip to main content

web/pages/account/
list.rs

1use std::sync::Arc;
2
3use askama::Template;
4use axum::extract::{Query, State};
5use axum::http::{HeaderMap, header};
6use axum::{Extension, Json};
7use axum::{http::StatusCode, response::IntoResponse};
8use num_rational::Rational64;
9use serde::{Deserialize, Serialize};
10use server::command::{
11    CmdResult, FinanceEntity, account::GetAccount, account::GetAccountCommodities,
12    account::GetBalance, account::ListAccounts,
13};
14use sqlx::types::Uuid;
15
16use crate::pages::HtmlTemplate;
17use crate::{AppState, jwt_auth::JWTAuthMiddleware};
18
19#[derive(Template)]
20#[template(path = "pages/account/list.html")]
21struct AccountListPage;
22
23pub async fn account_list_page() -> impl IntoResponse {
24    let template = AccountListPage {};
25    HtmlTemplate(template)
26}
27
28#[derive(Serialize, Clone)]
29pub struct BalanceView {
30    balance: Rational64,
31    commodity: String,
32}
33
34#[derive(Serialize)]
35struct 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")]
44struct AccountTableTemplate {
45    accounts: Vec<AccountView>,
46}
47
48#[derive(Serialize)]
49struct AccountJson {
50    id: String,
51    name: String,
52    currency: Option<String>,
53    balance: Option<Vec<BalanceView>>,
54}
55
56async 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
125pub async fn account_table(
126    State(_data): State<Arc<AppState>>,
127    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
128    headers: HeaderMap,
129) -> Result<impl IntoResponse, StatusCode> {
130    let result = ListAccounts::new()
131        .user_id(jwt_auth.user.id)
132        .run()
133        .await
134        .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                let currency = commodities.first().map(|c| c.commodity_id);
168
169                accounts.push(AccountView {
170                    id: account.id,
171                    name,
172                    currency,
173                    balance: balance_display,
174                });
175            }
176        }
177    }
178
179    // Check if the client is requesting JSON by examining the Accept header
180    let wants_json = headers
181        .get(header::ACCEPT)
182        .and_then(|value| value.to_str().ok())
183        .is_some_and(|value| value.contains("application/json"));
184
185    if wants_json {
186        // Return JSON response
187        let accounts_json: Vec<AccountJson> = accounts
188            .iter()
189            .map(|a| AccountJson {
190                id: a.id.to_string(),
191                name: a.name.clone(),
192                currency: a.currency.map(|c| c.to_string()),
193                balance: a.balance.clone(),
194            })
195            .collect();
196
197        return Ok(Json(accounts_json).into_response());
198    }
199
200    // Default to HTML response
201    Ok(HtmlTemplate(AccountTableTemplate { accounts }).into_response())
202}
203
204#[derive(Deserialize)]
205pub struct AccountInfoParam {
206    account: Uuid,
207}
208
209#[derive(Template)]
210#[template(path = "components/account/info.html")]
211struct AccountInfoTemplate {
212    account_id: Uuid,
213    name: String,
214    balance: Option<Vec<BalanceView>>,
215}
216
217pub async fn account_info(
218    Query(params): Query<AccountInfoParam>,
219    State(_data): State<Arc<AppState>>,
220    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
221) -> Result<impl IntoResponse, StatusCode> {
222    let account_result = GetAccount::new()
223        .user_id(jwt_auth.user.id)
224        .account_id(params.account)
225        .run()
226        .await
227        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
228
229    let name = if let Some(CmdResult::TaggedEntities { entities, .. }) = account_result
230        && let Some((FinanceEntity::Account(_account), tags)) = entities.first()
231        && let Some(FinanceEntity::Tag(name_tag)) = tags.get("name")
232    {
233        name_tag.tag_value.clone()
234    } else {
235        return Err(StatusCode::NOT_FOUND);
236    };
237
238    let commodities_result = GetAccountCommodities::new()
239        .user_id(jwt_auth.user.id)
240        .account_id(params.account)
241        .run()
242        .await
243        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
244
245    let commodities = if let Some(CmdResult::CommodityInfoList(commodities)) = commodities_result {
246        commodities
247    } else {
248        Vec::new()
249    };
250
251    let balance = get_balance_display(params.account, jwt_auth.user.id, &commodities)
252        .await
253        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
254
255    Ok(HtmlTemplate(AccountInfoTemplate {
256        account_id: params.account,
257        name,
258        balance,
259    }))
260}