1
use std::sync::Arc;
2

            
3
use askama::Template;
4
use axum::extract::Query;
5
use axum::{Extension, extract::State, http::StatusCode, response::IntoResponse};
6
use num_rational::Rational64;
7
use serde::Deserialize;
8
use server::command::{
9
    CmdResult, FinanceEntity, PaginationInfo, commodity::GetCommodity,
10
    transaction::ListTransactions,
11
};
12
use sqlx::types::Uuid;
13
use sqlx::types::chrono::{DateTime, NaiveDate, Utc};
14

            
15
use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
16

            
17
#[derive(Template)]
18
#[template(path = "pages/transaction/list.html")]
19
struct TransactionListPage {
20
    account: Option<Uuid>,
21
}
22

            
23
1
pub async fn transaction_list_page(Query(params): Query<TransactionParam>) -> impl IntoResponse {
24
1
    let template = TransactionListPage {
25
1
        account: params.account,
26
1
    };
27
1
    HtmlTemplate(template)
28
1
}
29

            
30
struct 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

            
39
impl 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

            
54
struct SplitView {
55
    account_id: Uuid,
56
    account_name: String,
57
    amount: String,
58
    currency: String,
59
    tags: Vec<finance::tag::Tag>,
60
}
61

            
62
struct 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")]
73
struct 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)]
82
pub 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

            
90
11
pub async fn transaction_table(
91
11
    Query(transactionparam): Query<TransactionParam>,
92
11
    State(_data): State<Arc<AppState>>,
93
11
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
94
11
) -> Result<impl IntoResponse, StatusCode> {
95
11
    let mut cmd = ListTransactions::new().user_id(jwt_auth.user.id);
96

            
97
11
    if let Some(id) = transactionparam.account {
98
3
        cmd = cmd.account(id);
99
8
    }
100

            
101
11
    let limit = transactionparam.limit.unwrap_or(20);
102
11
    cmd = cmd.limit(limit);
103

            
104
11
    if let Some(page) = transactionparam.page {
105
3
        let offset = (page - 1) * limit;
106
3
        cmd = cmd.offset(offset);
107
8
    }
108

            
109
11
    if let Some(ref date_from_str) = transactionparam.date_from
110
3
        && let Ok(date) = NaiveDate::parse_from_str(date_from_str, "%Y-%m-%d")
111
3
    {
112
3
        let datetime = date.and_hms_opt(0, 0, 0).unwrap().and_utc();
113
3
        cmd = cmd.date_from(datetime);
114
8
    }
115

            
116
11
    if let Some(ref date_to_str) = transactionparam.date_to
117
3
        && let Ok(date) = NaiveDate::parse_from_str(date_to_str, "%Y-%m-%d")
118
3
    {
119
3
        let datetime = date.and_hms_opt(23, 59, 59).unwrap().and_utc();
120
3
        cmd = cmd.date_to(datetime);
121
8
    }
122

            
123
11
    let result = cmd
124
11
        .run()
125
11
        .await
126
11
        .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
11
}