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
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
9
}
203

            
204
#[derive(Deserialize)]
205
pub struct AccountInfoParam {
206
    account: Uuid,
207
}
208

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

            
217
pub 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
}