1
//web/src/pages/transaction/create/submit.rs
2

            
3
use askama::Template;
4
use axum::{
5
    Extension, Json,
6
    extract::{Query, State},
7
    http::StatusCode,
8
    response::IntoResponse,
9
};
10
use chrono::Local;
11
use finance::tag::Tag;
12
use serde::Deserialize;
13
use server::command::{CmdResult, FinanceEntity};
14
use sqlx::types::Uuid;
15
use std::sync::Arc;
16

            
17
use crate::pages::transaction::util::{
18
    SplitData, TagData, parse_transaction_date, process_split_data, validate_splits_not_empty,
19
};
20
use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
21

            
22
#[derive(Deserialize)]
23
pub struct TransactionCreateParams {
24
    from_account: Option<Uuid>,
25
}
26

            
27
#[derive(Template)]
28
#[template(path = "pages/transaction/create.html")]
29
struct TransactionCreatePage {
30
    from_account: Option<Uuid>,
31
}
32

            
33
pub 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")]
44
struct TransactionFormTemplate {}
45

            
46
pub async fn transaction_form() -> impl IntoResponse {
47
    let template = TransactionFormTemplate {};
48
    HtmlTemplate(template)
49
}
50

            
51
#[derive(Deserialize, Debug)]
52
pub struct TransactionForm {
53
    splits: Vec<SplitData>,
54
    note: Option<String>,
55
    date: Option<String>,
56
    tags: Option<Vec<TagData>>,
57
}
58

            
59
8
pub async fn transaction_submit(
60
8
    State(_data): State<Arc<AppState>>,
61
8
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
62
8
    Json(form): Json<TransactionForm>,
63
8
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
64
8
    let user = &jwt_auth.user;
65

            
66
    // Validate splits
67
8
    validate_splits_not_empty(&form.splits)?;
68

            
69
    // Parse date
70
7
    let post_date = parse_transaction_date(form.date.as_deref());
71
7
    let post_date_utc = post_date.and_utc();
72
7
    let enter_date_utc = Local::now().naive_utc().and_utc();
73

            
74
    // Create transaction ID
75
7
    let tx_id = Uuid::new_v4();
76

            
77
    // Process splits using shared utility
78
7
    let mut split_entities = Vec::new();
79
7
    let mut prices = Vec::new();
80
7
    let mut split_tags_to_create = Vec::new();
81

            
82
7
    for split_data in form.splits {
83
7
        let processed = process_split_data(tx_id, split_data).await?;
84

            
85
3
        let from_split_id = processed.from_split.id;
86
3
        let to_split_id = processed.to_split.id;
87

            
88
3
        split_entities.push(FinanceEntity::Split(processed.from_split));
89
3
        split_entities.push(FinanceEntity::Split(processed.to_split));
90

            
91
3
        if let Some(price) = processed.price {
92
            prices.push(FinanceEntity::Price(price));
93
3
        }
94

            
95
3
        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
3
        }
108

            
109
3
        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
3
        }
122
    }
123

            
124
    // Execute command
125
3
    let mut cmd = server::command::transaction::CreateTransaction::new()
126
3
        .user_id(user.id)
127
3
        .splits(split_entities)
128
3
        .id(tx_id)
129
3
        .post_date(post_date_utc)
130
3
        .enter_date(enter_date_utc);
131

            
132
3
    if !prices.is_empty() {
133
        cmd = cmd.prices(prices);
134
3
    }
135

            
136
3
    if let Some(note) = form.note.as_deref()
137
3
        && !note.trim().is_empty()
138
3
    {
139
3
        cmd = cmd.note(note.to_string());
140
3
    }
141

            
142
3
    if !split_tags_to_create.is_empty() {
143
        cmd = cmd.split_tags(split_tags_to_create);
144
3
    }
145

            
146
3
    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
3
        Err(e) => {
180
3
            let error_response = serde_json::json!({
181
3
                "status": "fail",
182
3
                "message": format!("Failed to create transaction: {:?}", e),
183
            });
184

            
185
3
            log::error!("Failed to create transaction: {e:?}");
186
3
            Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)))
187
        }
188
    }
189
8
}
190

            
191
#[cfg(test)]
192
mod tests {
193
    use super::*;
194
    use askama::Template;
195

            
196
18
    fn render_create_form() -> String {
197
18
        TransactionFormTemplate {}
198
18
            .render()
199
18
            .expect("create form template should render")
200
18
    }
201

            
202
    #[test]
203
2
    fn create_form_has_splits_container() {
204
2
        let html = render_create_form();
205
2
        assert!(
206
2
            html.contains(r#"id="splits-container"#),
207
            "create form must have splits container"
208
        );
209
2
    }
210

            
211
    #[test]
212
2
    fn create_form_has_add_split_button() {
213
2
        let html = render_create_form();
214
2
        assert!(
215
2
            html.contains(r#"id="add-split-btn"#),
216
            "create form must have add-split button"
217
        );
218
2
        assert!(
219
2
            html.contains(r#"hx-get="/api/transaction/split/create"#),
220
            "add-split button must fetch new split via htmx"
221
        );
222
2
        assert!(
223
2
            html.contains(r##"hx-target="#splits-container"##),
224
            "add-split button must target splits container"
225
        );
226
2
        assert!(
227
2
            html.contains(r#"hx-swap="beforeend"#),
228
            "add-split button must append to container"
229
        );
230
2
    }
231

            
232
    #[test]
233
2
    fn create_form_has_note_input() {
234
2
        let html = render_create_form();
235
2
        assert!(
236
2
            html.contains(r#"name="note"#),
237
            "create form must have note input"
238
        );
239
2
    }
240

            
241
    #[test]
242
2
    fn create_form_has_date_input() {
243
2
        let html = render_create_form();
244
2
        assert!(
245
2
            html.contains(r#"id="date"#),
246
            "create form must have date input"
247
        );
248
2
        assert!(
249
2
            html.contains(r#"type="datetime-local"#),
250
            "date input must be datetime-local type"
251
        );
252
2
    }
253

            
254
    #[test]
255
2
    fn create_form_has_entity_tags_editor() {
256
2
        let html = render_create_form();
257
2
        assert!(
258
2
            html.contains("entity-tags-container"),
259
            "create form must have entity tags container"
260
        );
261
2
        assert!(
262
2
            html.contains("entity-tag-template"),
263
            "create form must have entity tag template"
264
        );
265
2
    }
266

            
267
    #[test]
268
2
    fn create_form_uses_json_enc_extension() {
269
2
        let html = render_create_form();
270
2
        assert!(
271
2
            html.contains(r#"hx-ext="json-enc"#),
272
            "create form must use json-enc htmx extension"
273
        );
274
2
    }
275

            
276
    #[test]
277
2
    fn create_form_has_submit_button() {
278
2
        let html = render_create_form();
279
2
        assert!(
280
2
            html.contains(r#"type="submit"#),
281
            "create form must have submit button"
282
        );
283
2
    }
284

            
285
    #[test]
286
2
    fn create_form_posts_to_correct_endpoint() {
287
2
        let html = render_create_form();
288
2
        assert!(
289
2
            html.contains(r#"hx-post="/api/transaction/create/submit"#),
290
            "form must post to transaction create submit endpoint"
291
        );
292
2
    }
293

            
294
    #[test]
295
2
    fn create_form_has_prerendered_split_entry() {
296
2
        let html = render_create_form();
297
2
        assert!(
298
2
            html.contains(r#"class="split-entry""#),
299
            "create form must have a pre-rendered split entry"
300
        );
301
2
        assert!(
302
2
            html.contains(r#"data-split-index="0""#),
303
            "pre-rendered split must have index 0"
304
        );
305
2
        assert!(
306
2
            html.contains(r#"name="splits[0][amount]"#),
307
            "pre-rendered split must have amount input"
308
        );
309
2
    }
310
}