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 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 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 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 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 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 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 if currency.is_empty() && should_count {
219 currency = split_currency.clone();
220 }
221 }
222 }
223
224 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 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 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 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 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}