Skip to main content

web/pages/transaction/
list.rs

1use std::sync::Arc;
2
3use askama::Template;
4use axum::extract::Query;
5use axum::{Extension, extract::State, http::StatusCode, response::IntoResponse};
6use num_rational::Rational64;
7use serde::Deserialize;
8use server::command::{
9    CmdResult, FinanceEntity, PaginationInfo, commodity::GetCommodity,
10    transaction::ListTransactions,
11};
12use sqlx::types::Uuid;
13use sqlx::types::chrono::{DateTime, NaiveDate, Utc};
14
15use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
16
17#[derive(Template)]
18#[template(path = "pages/transaction/list.html")]
19struct TransactionListPage {
20    account: Option<Uuid>,
21}
22
23pub async fn transaction_list_page(Query(params): Query<TransactionParam>) -> impl IntoResponse {
24    let template = TransactionListPage {
25        account: params.account,
26    };
27    HtmlTemplate(template)
28}
29
30struct PaginationView {
31    current_page: i64,
32    total_pages: i64,
33    has_prev: bool,
34    has_next: bool,
35    total_count: i64,
36    limit: i64,
37}
38
39impl PaginationView {
40    fn from_info(info: &PaginationInfo) -> Self {
41        let total_pages = (info.total_count + info.limit - 1) / info.limit;
42        let current_page = info.offset / info.limit + 1;
43        Self {
44            current_page,
45            total_pages,
46            has_prev: current_page > 1,
47            has_next: info.has_more,
48            total_count: info.total_count,
49            limit: info.limit,
50        }
51    }
52}
53
54struct SplitView {
55    account_id: Uuid,
56    account_name: String,
57    amount: String,
58    currency: String,
59    tags: Vec<finance::tag::Tag>,
60}
61
62struct TransactionView {
63    id: Uuid,
64    date: DateTime<Utc>,
65    description: String,
66    amount: String,
67    currency: String,
68    splits: Vec<SplitView>,
69}
70
71#[derive(Template)]
72#[template(path = "components/transaction/table.html")]
73struct TransactionTableTemplate {
74    transactions: Vec<TransactionView>,
75    pagination: Option<PaginationView>,
76    account: Option<Uuid>,
77    date_from: Option<String>,
78    date_to: Option<String>,
79}
80
81#[derive(Deserialize)]
82pub struct TransactionParam {
83    account: Option<Uuid>,
84    limit: Option<i64>,
85    page: Option<i64>,
86    date_from: Option<String>,
87    date_to: Option<String>,
88}
89
90pub async fn transaction_table(
91    Query(transactionparam): Query<TransactionParam>,
92    State(_data): State<Arc<AppState>>,
93    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
94) -> Result<impl IntoResponse, StatusCode> {
95    let mut cmd = ListTransactions::new().user_id(jwt_auth.user.id);
96
97    if let Some(id) = transactionparam.account {
98        cmd = cmd.account(id);
99    }
100
101    let limit = transactionparam.limit.unwrap_or(20);
102    cmd = cmd.limit(limit);
103
104    if let Some(page) = transactionparam.page {
105        let offset = (page - 1) * limit;
106        cmd = cmd.offset(offset);
107    }
108
109    if let Some(ref date_from_str) = transactionparam.date_from
110        && let Ok(date) = NaiveDate::parse_from_str(date_from_str, "%Y-%m-%d")
111    {
112        let datetime = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
113        cmd = cmd.date_from(datetime);
114    }
115
116    if let Some(ref date_to_str) = transactionparam.date_to
117        && let Ok(date) = NaiveDate::parse_from_str(date_to_str, "%Y-%m-%d")
118    {
119        let datetime = date.and_hms_opt(23, 59, 59).unwrap().and_utc();
120        cmd = cmd.date_to(datetime);
121    }
122
123    let result = cmd
124        .run()
125        .await
126        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
127
128    let mut transactions = Vec::new();
129    let mut pagination_view = None;
130
131    if let Some(CmdResult::TaggedEntities {
132        entities,
133        pagination,
134    }) = result
135    {
136        if let Some(ref pag_info) = pagination {
137            pagination_view = Some(PaginationView::from_info(pag_info));
138        }
139        for (entity, tags) in entities {
140            if let FinanceEntity::Transaction(tx) = entity {
141                // Get description from note tag or use default
142                let description = if let Some(FinanceEntity::Tag(note)) = tags.get("note") {
143                    note.tag_value.clone()
144                } else {
145                    format!("Transaction {}", tx.id)
146                };
147
148                // Get splits for this transaction
149                let split_result = server::command::split::ListSplits::new()
150                    .user_id(jwt_auth.user.id)
151                    .transaction(tx.id)
152                    .run()
153                    .await
154                    .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
155
156                // Calculate total amount and get currency
157                let mut total = Rational64::new(0, 1);
158                let mut currency = String::new();
159                let mut splits = Vec::new();
160
161                if let Some(CmdResult::TaggedEntities {
162                    entities: split_entities,
163                    ..
164                }) = split_result
165                {
166                    for (split_entity, split_tags) in split_entities {
167                        if let FinanceEntity::Split(split) = split_entity {
168                            let split_amount = Rational64::new(split.value_num, split.value_denom);
169                            let mut account_name = format!("Account {}", split.account_id);
170                            let mut split_currency = String::new();
171
172                            // For filtered view, show amount from the filtered account's split
173                            // For unfiltered view, sum positive values to get transaction total
174                            let is_filtered_account =
175                                transactionparam.account == Some(split.account_id);
176                            let should_count = if transactionparam.account.is_some() {
177                                is_filtered_account
178                            } else {
179                                split.value_num > 0
180                            };
181
182                            if should_count {
183                                total += split_amount;
184                            }
185
186                            // Get account name and currency for this split
187                            if let Ok(Some(CmdResult::TaggedEntities {
188                                entities: account_entities,
189                                ..
190                            })) = server::command::account::GetAccount::new()
191                                .user_id(jwt_auth.user.id)
192                                .account_id(split.account_id)
193                                .run()
194                                .await
195                                && let Some((FinanceEntity::Account(_account), account_tags)) =
196                                    account_entities.first()
197                            {
198                                if let Some(FinanceEntity::Tag(name)) = account_tags.get("name") {
199                                    account_name = name.tag_value.clone();
200                                }
201
202                                // Get commodity for this split
203                                if let Ok(Some(CmdResult::TaggedEntities {
204                                    entities: commodity_entities,
205                                    ..
206                                })) = GetCommodity::new()
207                                    .user_id(jwt_auth.user.id)
208                                    .commodity_id(split.commodity_id)
209                                    .run()
210                                    .await
211                                    && let Some((_, commodity_tags)) = commodity_entities.first()
212                                    && let Some(FinanceEntity::Tag(symbol)) =
213                                        commodity_tags.get("symbol")
214                                {
215                                    split_currency = symbol.tag_value.clone();
216
217                                    // Set transaction currency based on what we're counting
218                                    if currency.is_empty() && should_count {
219                                        currency = split_currency.clone();
220                                    }
221                                }
222                            }
223
224                            // Format the split amount - preserve the sign for template logic
225                            let split_amount_str = if *split_amount.denom() == 1 {
226                                split_amount.numer().to_string()
227                            } else {
228                                let value =
229                                    *split_amount.numer() as f64 / *split_amount.denom() as f64;
230                                format!("{value:.2}")
231                            };
232
233                            // Extract tags from the split_tags HashMap by taking ownership
234                            let mut tags: Vec<finance::tag::Tag> = split_tags
235                                .into_values()
236                                .filter_map(|entity| {
237                                    if let FinanceEntity::Tag(tag) = entity {
238                                        Some(tag)
239                                    } else {
240                                        None
241                                    }
242                                })
243                                .collect();
244
245                            // Sort tags by name, then by value for stable display
246                            tags.sort_by(|a, b| {
247                                a.tag_name
248                                    .cmp(&b.tag_name)
249                                    .then_with(|| a.tag_value.cmp(&b.tag_value))
250                            });
251
252                            // Add the split with its account name
253                            splits.push(SplitView {
254                                account_id: split.account_id,
255                                account_name,
256                                amount: split_amount_str,
257                                currency: split_currency,
258                                tags,
259                            });
260                        }
261                    }
262                }
263
264                // Format the amount with currency
265                let amount_str = if *total.denom() == 1 {
266                    total.numer().to_string()
267                } else {
268                    format!("{:.2}", *total.numer() as f64 / *total.denom() as f64)
269                };
270
271                transactions.push(TransactionView {
272                    id: tx.id,
273                    date: tx.post_date,
274                    description,
275                    amount: amount_str,
276                    currency,
277                    splits,
278                });
279            }
280        }
281    }
282
283    Ok(HtmlTemplate(TransactionTableTemplate {
284        transactions,
285        pagination: pagination_view,
286        account: transactionparam.account,
287        date_from: transactionparam.date_from,
288        date_to: transactionparam.date_to,
289    }))
290}