web/pages/account/
list.rs1use 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 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 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 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 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 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 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 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 Ok(HtmlTemplate(AccountTableTemplate { accounts }).into_response())
206}
207
208#[derive(Deserialize)]
209pub struct AccountInfoParam {
210 account: Uuid,
211}
212
213#[derive(Template)]
214#[template(path = "components/account/info.html")]
215struct AccountInfoTemplate {
216 account_id: Uuid,
217 name: String,
218 balance: Option<Vec<BalanceView>>,
219}
220
221pub 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}