Skip to main content

web/pages/transaction/create/
submit.rs

1//web/src/pages/transaction/create/submit.rs
2
3use askama::Template;
4use axum::{
5    Extension, Json,
6    extract::{Query, State},
7    http::StatusCode,
8    response::IntoResponse,
9};
10use chrono::Local;
11use finance::tag::Tag;
12use serde::Deserialize;
13use server::command::{CmdResult, FinanceEntity};
14use sqlx::types::Uuid;
15use std::sync::Arc;
16
17use crate::pages::transaction::util::{
18    SplitData, TagData, parse_transaction_date, process_split_data, validate_splits_not_empty,
19};
20use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
21
22#[derive(Deserialize)]
23pub struct TransactionCreateParams {
24    from_account: Option<Uuid>,
25}
26
27#[derive(Template)]
28#[template(path = "pages/transaction/create.html")]
29struct TransactionCreatePage {
30    from_account: Option<Uuid>,
31}
32
33pub async fn transaction_create_page(
34    Query(params): Query<TransactionCreateParams>,
35) -> impl IntoResponse {
36    let template = TransactionCreatePage {
37        from_account: params.from_account,
38    };
39    HtmlTemplate(template)
40}
41
42#[derive(Template)]
43#[template(path = "components/transaction/create.html")]
44struct TransactionFormTemplate {}
45
46pub async fn transaction_form() -> impl IntoResponse {
47    let template = TransactionFormTemplate {};
48    HtmlTemplate(template)
49}
50
51#[derive(Deserialize, Debug)]
52pub struct TransactionForm {
53    splits: Vec<SplitData>,
54    note: Option<String>,
55    date: Option<String>,
56    tags: Option<Vec<TagData>>,
57}
58
59pub async fn transaction_submit(
60    State(_data): State<Arc<AppState>>,
61    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
62    Json(form): Json<TransactionForm>,
63) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
64    let user = &jwt_auth.user;
65
66    // Validate splits
67    validate_splits_not_empty(&form.splits)?;
68
69    // Parse date
70    let post_date = parse_transaction_date(form.date.as_deref());
71    let post_date_utc = post_date.and_utc();
72    let enter_date_utc = Local::now().naive_utc().and_utc();
73
74    // Create transaction ID
75    let tx_id = Uuid::new_v4();
76
77    // Process splits using shared utility
78    let mut split_entities = Vec::new();
79    let mut prices = Vec::new();
80    let mut split_tags_to_create = Vec::new();
81
82    for split_data in form.splits {
83        let processed = process_split_data(tx_id, split_data).await?;
84
85        let from_split_id = processed.from_split.id;
86        let to_split_id = processed.to_split.id;
87
88        split_entities.push(FinanceEntity::Split(processed.from_split));
89        split_entities.push(FinanceEntity::Split(processed.to_split));
90
91        if let Some(price) = processed.price {
92            prices.push(FinanceEntity::Price(price));
93        }
94
95        if let Some(tags) = processed.from_split_tags {
96            for tag in tags {
97                split_tags_to_create.push((
98                    from_split_id,
99                    Tag {
100                        id: Uuid::new_v4(),
101                        tag_name: tag.name,
102                        tag_value: tag.value,
103                        description: tag.description,
104                    },
105                ));
106            }
107        }
108
109        if let Some(tags) = processed.to_split_tags {
110            for tag in tags {
111                split_tags_to_create.push((
112                    to_split_id,
113                    Tag {
114                        id: Uuid::new_v4(),
115                        tag_name: tag.name,
116                        tag_value: tag.value,
117                        description: tag.description,
118                    },
119                ));
120            }
121        }
122    }
123
124    // Execute command
125    let mut cmd = server::command::transaction::CreateTransaction::new()
126        .user_id(user.id)
127        .splits(split_entities)
128        .id(tx_id)
129        .post_date(post_date_utc)
130        .enter_date(enter_date_utc);
131
132    if !prices.is_empty() {
133        cmd = cmd.prices(prices);
134    }
135
136    if let Some(note) = form.note.as_deref()
137        && !note.trim().is_empty()
138    {
139        cmd = cmd.note(note.to_string());
140    }
141
142    if !split_tags_to_create.is_empty() {
143        cmd = cmd.split_tags(split_tags_to_create);
144    }
145
146    match cmd.run().await {
147        Ok(result) => {
148            if let Some(tags) = form.tags {
149                let server_user = server::user::User { id: user.id };
150                for tag_data in tags {
151                    server_user
152                        .create_transaction_tag(
153                            tx_id,
154                            tag_data.name,
155                            tag_data.value,
156                            tag_data.description,
157                        )
158                        .await
159                        .map_err(|e| {
160                            let error_response = serde_json::json!({
161                                "status": "fail",
162                                "message": format!("Failed to create transaction tag: {:?}", e),
163                            });
164                            log::error!("Failed to create transaction tag: {e:?}");
165                            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
166                        })?;
167                }
168            }
169
170            match result {
171                Some(CmdResult::Entity(FinanceEntity::Transaction(tx))) => Ok(format!(
172                    "{}: {}",
173                    t!("New transaction created with ID"),
174                    tx.id
175                )),
176                _ => Ok(t!("New transaction created successfully").to_string()),
177            }
178        }
179        Err(e) => {
180            let error_response = serde_json::json!({
181                "status": "fail",
182                "message": format!("Failed to create transaction: {:?}", e),
183            });
184
185            log::error!("Failed to create transaction: {e:?}");
186            Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)))
187        }
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use askama::Template;
195
196    fn render_create_form() -> String {
197        TransactionFormTemplate {}
198            .render()
199            .expect("create form template should render")
200    }
201
202    #[test]
203    fn create_form_has_splits_container() {
204        let html = render_create_form();
205        assert!(
206            html.contains(r#"id="splits-container"#),
207            "create form must have splits container"
208        );
209    }
210
211    #[test]
212    fn create_form_has_add_split_button() {
213        let html = render_create_form();
214        assert!(
215            html.contains(r#"id="add-split-btn"#),
216            "create form must have add-split button"
217        );
218        assert!(
219            html.contains(r#"hx-get="/api/transaction/split/create"#),
220            "add-split button must fetch new split via htmx"
221        );
222        assert!(
223            html.contains(r##"hx-target="#splits-container"##),
224            "add-split button must target splits container"
225        );
226        assert!(
227            html.contains(r#"hx-swap="beforeend"#),
228            "add-split button must append to container"
229        );
230    }
231
232    #[test]
233    fn create_form_has_note_input() {
234        let html = render_create_form();
235        assert!(
236            html.contains(r#"name="note"#),
237            "create form must have note input"
238        );
239    }
240
241    #[test]
242    fn create_form_has_date_input() {
243        let html = render_create_form();
244        assert!(
245            html.contains(r#"id="date"#),
246            "create form must have date input"
247        );
248        assert!(
249            html.contains(r#"type="datetime-local"#),
250            "date input must be datetime-local type"
251        );
252    }
253
254    #[test]
255    fn create_form_has_entity_tags_editor() {
256        let html = render_create_form();
257        assert!(
258            html.contains("entity-tags-container"),
259            "create form must have entity tags container"
260        );
261        assert!(
262            html.contains("entity-tag-template"),
263            "create form must have entity tag template"
264        );
265    }
266
267    #[test]
268    fn create_form_uses_json_enc_extension() {
269        let html = render_create_form();
270        assert!(
271            html.contains(r#"hx-ext="json-enc"#),
272            "create form must use json-enc htmx extension"
273        );
274    }
275
276    #[test]
277    fn create_form_has_submit_button() {
278        let html = render_create_form();
279        assert!(
280            html.contains(r#"type="submit"#),
281            "create form must have submit button"
282        );
283    }
284
285    #[test]
286    fn create_form_posts_to_correct_endpoint() {
287        let html = render_create_form();
288        assert!(
289            html.contains(r#"hx-post="/api/transaction/create/submit"#),
290            "form must post to transaction create submit endpoint"
291        );
292    }
293
294    #[test]
295    fn create_form_has_prerendered_split_entry() {
296        let html = render_create_form();
297        assert!(
298            html.contains(r#"class="split-entry""#),
299            "create form must have a pre-rendered split entry"
300        );
301        assert!(
302            html.contains(r#"data-split-index="0""#),
303            "pre-rendered split must have index 0"
304        );
305        assert!(
306            html.contains(r#"name="splits[0][amount]"#),
307            "pre-rendered split must have amount input"
308        );
309    }
310}