web/pages/transaction/edit/
submit.rs1use 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 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}