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
    template_id: Option<Uuid>,
26
}
27

            
28
#[derive(Template)]
29
#[template(path = "pages/transaction/create.html")]
30
struct TransactionCreatePage {
31
    from_account: Option<Uuid>,
32
    /// JSON for `window.prefilledDraft` when arrived at via `?template_id=`.
33
    /// `None` (the common case) renders no prefill script.
34
    prefilled_draft: Option<String>,
35
}
36

            
37
async fn render_prefilled_draft(user_id: Uuid, template_id: Uuid) -> Option<String> {
38
    let server_user = server::user::User { id: user_id };
39
    let detail = server_user.get_template(template_id).await.ok()?;
40
    let draft = rpc::render_template(&rpc::ScriptCtx::new(user_id), &detail.source)
41
        .await
42
        .map_err(|e| log::error!("template {template_id} render failed: {e}"))
43
        .ok()?;
44
    let prefilled = super::prefill::draft_to_prefilled(draft).or_else(|| {
45
        log::warn!(
46
            "template {template_id} draft is not exactly representable as a form; skipping prefill"
47
        );
48
        None
49
    })?;
50
    super::prefill::to_script_safe_json(&prefilled)
51
        .map_err(|e| log::error!("prefilled draft serialize failed: {e}"))
52
        .ok()
53
}
54

            
55
pub async fn transaction_create_page(
56
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
57
    Query(params): Query<TransactionCreateParams>,
58
) -> impl IntoResponse {
59
    let prefilled_draft = match params.template_id {
60
        Some(template_id) => render_prefilled_draft(jwt_auth.user.id, template_id).await,
61
        None => None,
62
    };
63

            
64
    let template = TransactionCreatePage {
65
        from_account: params.from_account,
66
        prefilled_draft,
67
    };
68
    HtmlTemplate(template)
69
}
70

            
71
#[derive(Template)]
72
#[template(path = "components/transaction/create.html")]
73
struct TransactionFormTemplate {}
74

            
75
#[derive(Deserialize, Debug)]
76
pub struct TransactionForm {
77
    splits: Vec<SplitData>,
78
    note: Option<String>,
79
    date: Option<String>,
80
    tags: Option<Vec<TagData>>,
81
}
82

            
83
8
pub async fn transaction_submit(
84
8
    State(_data): State<Arc<AppState>>,
85
8
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
86
8
    Json(form): Json<TransactionForm>,
87
8
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
88
8
    let user = &jwt_auth.user;
89

            
90
    // Validate splits
91
8
    validate_splits_not_empty(&form.splits)?;
92

            
93
    // Parse date
94
7
    let post_date = parse_transaction_date(form.date.as_deref());
95
7
    let post_date_utc = post_date.and_utc();
96
7
    let enter_date_utc = Local::now().naive_utc().and_utc();
97

            
98
    // Create transaction ID
99
7
    let tx_id = Uuid::new_v4();
100

            
101
    // Process splits using shared utility
102
7
    let mut split_entities = Vec::new();
103
7
    let mut prices = Vec::new();
104
7
    let mut split_tags_to_create = Vec::new();
105

            
106
7
    for split_data in form.splits {
107
7
        let processed = process_split_data(tx_id, split_data).await?;
108

            
109
3
        let from_split_id = processed.from_split.id;
110
3
        let to_split_id = processed.to_split.id;
111

            
112
3
        split_entities.push(FinanceEntity::Split(processed.from_split));
113
3
        split_entities.push(FinanceEntity::Split(processed.to_split));
114

            
115
3
        if let Some(price) = processed.price {
116
            prices.push(FinanceEntity::Price(price));
117
3
        }
118

            
119
3
        if let Some(tags) = processed.from_split_tags {
120
            for tag in tags {
121
                split_tags_to_create.push((
122
                    from_split_id,
123
                    Tag {
124
                        id: Uuid::new_v4(),
125
                        tag_name: tag.name,
126
                        tag_value: tag.value,
127
                        description: tag.description,
128
                    },
129
                ));
130
            }
131
3
        }
132

            
133
3
        if let Some(tags) = processed.to_split_tags {
134
            for tag in tags {
135
                split_tags_to_create.push((
136
                    to_split_id,
137
                    Tag {
138
                        id: Uuid::new_v4(),
139
                        tag_name: tag.name,
140
                        tag_value: tag.value,
141
                        description: tag.description,
142
                    },
143
                ));
144
            }
145
3
        }
146
    }
147

            
148
    // Execute command
149
3
    let mut cmd = server::command::transaction::CreateTransaction::new()
150
3
        .user_id(user.id)
151
3
        .splits(split_entities)
152
3
        .id(tx_id)
153
3
        .post_date(post_date_utc)
154
3
        .enter_date(enter_date_utc);
155

            
156
3
    if !prices.is_empty() {
157
        cmd = cmd.prices(prices);
158
3
    }
159

            
160
3
    if let Some(note) = form.note.as_deref()
161
3
        && !note.trim().is_empty()
162
3
    {
163
3
        cmd = cmd.note(note.to_string());
164
3
    }
165

            
166
3
    if !split_tags_to_create.is_empty() {
167
        cmd = cmd.split_tags(split_tags_to_create);
168
3
    }
169

            
170
3
    match cmd.run().await {
171
        Ok(result) => {
172
            if let Some(tags) = form.tags {
173
                let server_user = server::user::User { id: user.id };
174
                for tag_data in tags {
175
                    server_user
176
                        .create_transaction_tag(
177
                            tx_id,
178
                            tag_data.name,
179
                            tag_data.value,
180
                            tag_data.description,
181
                        )
182
                        .await
183
                        .map_err(|e| {
184
                            let error_response = serde_json::json!({
185
                                "status": "fail",
186
                                "message": format!("Failed to create transaction tag: {:?}", e),
187
                            });
188
                            log::error!("Failed to create transaction tag: {e:?}");
189
                            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
190
                        })?;
191
                }
192
            }
193

            
194
            match result {
195
                Some(CmdResult::Entity(FinanceEntity::Transaction(tx))) => Ok(format!(
196
                    "{}: {}",
197
                    t!("New transaction created with ID"),
198
                    tx.id
199
                )),
200
                _ => Ok(t!("New transaction created successfully").to_string()),
201
            }
202
        }
203
3
        Err(e) => {
204
3
            let error_response = serde_json::json!({
205
3
                "status": "fail",
206
3
                "message": format!("Failed to create transaction: {:?}", e),
207
            });
208

            
209
3
            log::error!("Failed to create transaction: {e:?}");
210
3
            Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)))
211
        }
212
    }
213
8
}
214

            
215
#[cfg(test)]
216
mod tests {
217
    use super::*;
218
    use askama::Template;
219

            
220
18
    fn render_create_form() -> String {
221
18
        TransactionFormTemplate {}
222
18
            .render()
223
18
            .expect("create form template should render")
224
18
    }
225

            
226
    #[test]
227
2
    fn create_form_has_splits_container() {
228
2
        let html = render_create_form();
229
2
        assert!(
230
2
            html.contains(r#"id="splits-container"#),
231
            "create form must have splits container"
232
        );
233
2
    }
234

            
235
    #[test]
236
2
    fn create_form_has_add_split_button() {
237
2
        let html = render_create_form();
238
2
        assert!(
239
2
            html.contains(r#"id="add-split-btn"#),
240
            "create form must have add-split button"
241
        );
242
2
        assert!(
243
2
            html.contains(r#"hx-get="/api/transaction/split/create"#),
244
            "add-split button must fetch new split via htmx"
245
        );
246
2
        assert!(
247
2
            html.contains(r##"hx-target="#splits-container"##),
248
            "add-split button must target splits container"
249
        );
250
2
        assert!(
251
2
            html.contains(r#"hx-swap="beforeend"#),
252
            "add-split button must append to container"
253
        );
254
2
    }
255

            
256
    #[test]
257
2
    fn create_form_has_note_input() {
258
2
        let html = render_create_form();
259
2
        assert!(
260
2
            html.contains(r#"name="note"#),
261
            "create form must have note input"
262
        );
263
2
    }
264

            
265
    #[test]
266
2
    fn create_form_has_date_input() {
267
2
        let html = render_create_form();
268
2
        assert!(
269
2
            html.contains(r#"id="date"#),
270
            "create form must have date input"
271
        );
272
2
        assert!(
273
2
            html.contains(r#"type="datetime-local"#),
274
            "date input must be datetime-local type"
275
        );
276
2
    }
277

            
278
    #[test]
279
2
    fn create_form_has_entity_tags_editor() {
280
2
        let html = render_create_form();
281
2
        assert!(
282
2
            html.contains("entity-tags-container"),
283
            "create form must have entity tags container"
284
        );
285
2
        assert!(
286
2
            html.contains("entity-tag-template"),
287
            "create form must have entity tag template"
288
        );
289
2
    }
290

            
291
    #[test]
292
2
    fn create_form_uses_json_enc_extension() {
293
2
        let html = render_create_form();
294
2
        assert!(
295
2
            html.contains(r#"hx-ext="json-enc"#),
296
            "create form must use json-enc htmx extension"
297
        );
298
2
    }
299

            
300
    #[test]
301
2
    fn create_form_has_submit_button() {
302
2
        let html = render_create_form();
303
2
        assert!(
304
2
            html.contains(r#"type="submit"#),
305
            "create form must have submit button"
306
        );
307
2
    }
308

            
309
    #[test]
310
2
    fn create_form_posts_to_correct_endpoint() {
311
2
        let html = render_create_form();
312
2
        assert!(
313
2
            html.contains(r#"hx-post="/api/transaction/create/submit"#),
314
            "form must post to transaction create submit endpoint"
315
        );
316
2
    }
317

            
318
    #[test]
319
2
    fn create_form_has_prerendered_split_entry() {
320
2
        let html = render_create_form();
321
2
        assert!(
322
2
            html.contains(r#"class="split-entry""#),
323
            "create form must have a pre-rendered split entry"
324
        );
325
2
        assert!(
326
2
            html.contains(r#"data-split-index="0""#),
327
            "pre-rendered split must have index 0"
328
        );
329
2
        assert!(
330
2
            html.contains(r#"name="splits[0][amount]"#),
331
            "pre-rendered split must have amount input"
332
        );
333
2
    }
334
}