Skip to main content

web/pages/transaction/edit/
submit.rs

1//web/src/pages/transaction/edit/submit.rs
2
3use askama::Template;
4use axum::{
5    Extension, Json,
6    extract::{Path, State},
7    http::StatusCode,
8    response::IntoResponse,
9};
10use chrono::Local;
11use serde::Deserialize;
12use server::command::{CmdResult, FinanceEntity, transaction::GetTransaction};
13use sqlx::types::Uuid;
14use std::sync::Arc;
15
16use crate::pages::transaction::util::{
17    SplitData, TagData, get_account_name, get_commodity_name, parse_transaction_date,
18    process_split_data, validate_splits_not_empty,
19};
20use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
21
22#[derive(Template)]
23#[template(path = "pages/transaction/edit.html")]
24struct TransactionEditPage {
25    transaction_id: String,
26    splits: Vec<SplitDisplayData>,
27    note: String,
28    date: String,
29    transaction_tags: Vec<finance::tag::Tag>,
30}
31
32#[derive(Debug)]
33struct SplitDisplayData {
34    id: String,
35    amount: String,
36    amount_converted: String,
37    from_account: String,
38    from_account_name: String,
39    to_account: String,
40    to_account_name: String,
41    from_commodity: String,
42    from_commodity_name: String,
43    to_commodity: String,
44    to_commodity_name: String,
45    tags: Vec<finance::tag::Tag>,
46}
47
48pub async fn transaction_edit_page(
49    Path(transaction_id): Path<String>,
50    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
51) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
52    let user = &jwt_auth.user;
53
54    let tx_uuid = Uuid::parse_str(&transaction_id).map_err(|_| {
55        let error_response = serde_json::json!({
56            "status": "fail",
57            "message": "Invalid transaction ID format",
58        });
59        (StatusCode::BAD_REQUEST, Json(error_response))
60    })?;
61
62    let transaction_result = GetTransaction::new()
63        .user_id(user.id)
64        .transaction_id(tx_uuid)
65        .run()
66        .await
67        .map_err(|e| {
68            let error_response = serde_json::json!({
69                "status": "fail",
70                "message": format!("Failed to get transaction: {:?}", e),
71            });
72            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
73        })?;
74
75    let (transaction, tags) =
76        if let Some(CmdResult::TaggedEntities { mut entities, .. }) = transaction_result {
77            if let Some((FinanceEntity::Transaction(tx), tags)) = entities.pop() {
78                (tx, tags)
79            } else {
80                let error_response = serde_json::json!({
81                    "status": "fail",
82                    "message": "Transaction not found",
83                });
84                return Err((StatusCode::NOT_FOUND, Json(error_response)));
85            }
86        } else {
87            let error_response = serde_json::json!({
88                "status": "fail",
89                "message": "Transaction not found",
90            });
91            return Err((StatusCode::NOT_FOUND, Json(error_response)));
92        };
93
94    let splits_result = server::command::split::ListSplits::new()
95        .user_id(user.id)
96        .transaction(tx_uuid)
97        .run()
98        .await
99        .map_err(|e| {
100            let error_response = serde_json::json!({
101                "status": "fail",
102                "message": format!("Failed to get splits: {:?}", e),
103            });
104            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
105        })?;
106
107    let mut splits_display = Vec::new();
108
109    if let Some(CmdResult::TaggedEntities {
110        entities: split_entities,
111        ..
112    }) = splits_result
113    {
114        let mut logical_splits = Vec::new();
115        let mut processed_splits = std::collections::HashSet::new();
116
117        for (entity, tags) in &split_entities {
118            if let FinanceEntity::Split(split) = entity {
119                if processed_splits.contains(&split.id) {
120                    continue;
121                }
122
123                let mut pair_split = None;
124                let mut pair_tags = None;
125
126                for (other_entity, other_tags) in &split_entities {
127                    if let FinanceEntity::Split(other_split) = other_entity
128                        && other_split.id != split.id
129                        && !processed_splits.contains(&other_split.id)
130                        && (split.value_num > 0) != (other_split.value_num > 0)
131                    {
132                        pair_split = Some(other_split);
133                        pair_tags = Some(other_tags);
134                        break;
135                    }
136                }
137
138                if let Some(pair) = pair_split {
139                    processed_splits.insert(split.id);
140                    processed_splits.insert(pair.id);
141
142                    let (from_split, to_split, from_tags) = if split.value_num < 0 {
143                        (split, pair, tags)
144                    } else {
145                        (pair, split, pair_tags.unwrap_or(tags))
146                    };
147
148                    let split_tags: Vec<finance::tag::Tag> = from_tags
149                        .values()
150                        .filter_map(|entity| {
151                            if let FinanceEntity::Tag(tag) = entity {
152                                Some(finance::tag::Tag {
153                                    id: tag.id,
154                                    tag_name: tag.tag_name.clone(),
155                                    tag_value: tag.tag_value.clone(),
156                                    description: tag.description.clone(),
157                                })
158                            } else {
159                                None
160                            }
161                        })
162                        .collect();
163
164                    logical_splits.push((from_split, to_split, split_tags));
165                }
166            }
167        }
168
169        for (from_split, to_split, split_tags) in logical_splits {
170            let from_account_name = get_account_name(user.id, from_split.account_id)
171                .await
172                .unwrap_or("Unknown Account".to_string());
173            let to_account_name = get_account_name(user.id, to_split.account_id)
174                .await
175                .unwrap_or("Unknown Account".to_string());
176            let from_commodity_name = get_commodity_name(user.id, from_split.commodity_id)
177                .await
178                .unwrap_or("Unknown Currency".to_string());
179            let to_commodity_name = get_commodity_name(user.id, to_split.commodity_id)
180                .await
181                .unwrap_or("Unknown Currency".to_string());
182
183            let amount = (-from_split.value_num as f64) / (from_split.value_denom as f64);
184            let amount_converted = if from_split.commodity_id == to_split.commodity_id {
185                0.0
186            } else {
187                (to_split.value_num as f64) / (to_split.value_denom as f64)
188            };
189
190            splits_display.push(SplitDisplayData {
191                id: from_split.id.to_string(),
192                amount: format!("{amount:.2}"),
193                amount_converted: if amount_converted > 0.0 {
194                    format!("{amount_converted:.2}")
195                } else {
196                    String::new()
197                },
198                from_account: from_split.account_id.to_string(),
199                from_account_name,
200                to_account: to_split.account_id.to_string(),
201                to_account_name,
202                from_commodity: from_split.commodity_id.to_string(),
203                from_commodity_name,
204                to_commodity: to_split.commodity_id.to_string(),
205                to_commodity_name,
206                tags: split_tags,
207            });
208        }
209    }
210
211    let note = tags
212        .get("note")
213        .and_then(|entity| {
214            if let FinanceEntity::Tag(tag) = entity {
215                Some(tag.tag_value.clone())
216            } else {
217                None
218            }
219        })
220        .unwrap_or_default();
221
222    let date = transaction.post_date.format("%Y-%m-%dT%H:%M").to_string();
223
224    let server_user = server::user::User { id: user.id };
225    let transaction_tags: Vec<finance::tag::Tag> = server_user
226        .get_transaction_tags(tx_uuid)
227        .await
228        .unwrap_or_default()
229        .into_iter()
230        .filter(|t| t.tag_name != "note")
231        .collect();
232
233    let template = TransactionEditPage {
234        transaction_id: transaction_id.clone(),
235        splits: splits_display,
236        note,
237        date,
238        transaction_tags,
239    };
240
241    Ok(HtmlTemplate(template))
242}
243
244#[derive(Deserialize, Debug)]
245pub struct TransactionEditForm {
246    transaction_id: String,
247    splits: Vec<SplitData>,
248    note: Option<String>,
249    date: Option<String>,
250    tags: Option<Vec<TagData>>,
251}
252
253pub async fn transaction_edit_submit(
254    State(_data): State<Arc<AppState>>,
255    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
256    Json(form): Json<TransactionEditForm>,
257) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
258    let user = &jwt_auth.user;
259
260    let tx_uuid = Uuid::parse_str(&form.transaction_id).map_err(|_| {
261        let error_response = serde_json::json!({
262            "status": "fail",
263            "message": "Invalid transaction ID format",
264        });
265        (StatusCode::BAD_REQUEST, Json(error_response))
266    })?;
267
268    validate_splits_not_empty(&form.splits)?;
269
270    let post_date = parse_transaction_date(form.date.as_deref());
271    let post_date_utc = post_date.and_utc();
272    let enter_date_utc = Local::now().naive_utc().and_utc();
273
274    let mut split_entities = Vec::new();
275    let mut prices = Vec::new();
276    let mut split_tags_to_create = Vec::new();
277
278    for split_data in form.splits {
279        let processed = process_split_data(tx_uuid, split_data).await?;
280
281        let from_split_id = processed.from_split.id;
282        let to_split_id = processed.to_split.id;
283
284        split_entities.push(FinanceEntity::Split(processed.from_split));
285        split_entities.push(FinanceEntity::Split(processed.to_split));
286
287        if let Some(price) = processed.price {
288            prices.push(FinanceEntity::Price(price));
289        }
290
291        if let Some(tags) = processed.from_split_tags {
292            for tag in tags {
293                split_tags_to_create.push((from_split_id, tag));
294            }
295        }
296
297        if let Some(tags) = processed.to_split_tags {
298            for tag in tags {
299                split_tags_to_create.push((to_split_id, tag));
300            }
301        }
302    }
303
304    let mut cmd = server::command::transaction::UpdateTransaction::new()
305        .user_id(user.id)
306        .transaction_id(tx_uuid)
307        .splits(split_entities)
308        .post_date(post_date_utc)
309        .enter_date(enter_date_utc);
310
311    if !prices.is_empty() {
312        cmd = cmd.prices(prices);
313    }
314
315    if let Some(note) = form.note.as_deref() {
316        cmd = cmd.note(note.to_string());
317    }
318
319    match cmd.run().await {
320        Ok(result) => {
321            let server_user = server::user::User { id: user.id };
322
323            for (split_id, tag_data) in split_tags_to_create {
324                server_user
325                    .create_split_tag(
326                        split_id,
327                        tag_data.name,
328                        tag_data.value,
329                        tag_data.description,
330                    )
331                    .await
332                    .map_err(|e| {
333                        let error_response = serde_json::json!({
334                            "status": "fail",
335                            "message": format!("Failed to create split tag: {:?}", e),
336                        });
337                        log::error!("Failed to create split tag for split {split_id}: {e:?}");
338                        (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
339                    })?;
340            }
341
342            // Delete existing non-note transaction tags and create new ones
343            let existing_tags = server_user
344                .get_transaction_tags(tx_uuid)
345                .await
346                .unwrap_or_default();
347            for tag in &existing_tags {
348                if tag.tag_name == "note" {
349                    continue;
350                }
351                let _ = server_user.detach_transaction_tag(tx_uuid, tag.id).await;
352                let _ = server_user.cleanup_orphan_tag(tag.id).await;
353            }
354
355            if let Some(tags) = form.tags {
356                for tag_data in tags {
357                    if tag_data.name == "note" {
358                        continue;
359                    }
360                    server_user
361                        .create_transaction_tag(
362                            tx_uuid,
363                            tag_data.name,
364                            tag_data.value,
365                            tag_data.description,
366                        )
367                        .await
368                        .map_err(|e| {
369                            let error_response = serde_json::json!({
370                                "status": "fail",
371                                "message": format!("Failed to create transaction tag: {:?}", e),
372                            });
373                            log::error!("Failed to create transaction tag: {e:?}");
374                            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
375                        })?;
376                }
377            }
378
379            match result {
380                Some(CmdResult::Entity(FinanceEntity::Transaction(tx))) => {
381                    Ok(format!("{}: {}", t!("Transaction updated with ID"), tx.id))
382                }
383                _ => Ok(t!("Transaction updated successfully").to_string()),
384            }
385        }
386        Err(e) => {
387            let error_response = serde_json::json!({
388                "status": "fail",
389                "message": format!("Failed to update transaction: {:?}", e),
390            });
391
392            log::error!("Failed to update transaction: {e:?}");
393            Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)))
394        }
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use askama::Template;
402
403    #[test]
404    fn edit_page_add_split_button_has_htmx_attributes() {
405        let template = TransactionEditPage {
406            transaction_id: "00000000-0000-0000-0000-000000000000".to_string(),
407            splits: vec![SplitDisplayData {
408                id: "00000000-0000-0000-0000-000000000001".to_string(),
409                amount: "100.00".to_string(),
410                amount_converted: String::new(),
411                from_account: "00000000-0000-0000-0000-000000000002".to_string(),
412                from_account_name: "Cash".to_string(),
413                to_account: "00000000-0000-0000-0000-000000000003".to_string(),
414                to_account_name: "Groceries".to_string(),
415                from_commodity: "00000000-0000-0000-0000-000000000004".to_string(),
416                from_commodity_name: "USD".to_string(),
417                to_commodity: "00000000-0000-0000-0000-000000000004".to_string(),
418                to_commodity_name: "USD".to_string(),
419                tags: vec![],
420            }],
421            note: String::new(),
422            date: "2026-01-01T00:00".to_string(),
423            transaction_tags: vec![],
424        };
425
426        let html = template.render().expect("template should render");
427        assert!(
428            html.contains(r#"hx-get="/api/transaction/split/create""#),
429            "add-split button must have hx-get attribute"
430        );
431        assert!(
432            html.contains(r##"hx-target="#splits-container""##),
433            "add-split button must have hx-target attribute"
434        );
435        assert!(
436            html.contains(r#"hx-swap="beforeend""#),
437            "add-split button must have hx-swap attribute"
438        );
439    }
440}