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
    #[cfg(feature = "scripting")]
26
    template_id: Option<Uuid>,
27
}
28

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

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

            
57
pub async fn transaction_create_page(
58
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
59
    Query(params): Query<TransactionCreateParams>,
60
) -> impl IntoResponse {
61
    #[cfg(feature = "scripting")]
62
    let prefilled_draft = match params.template_id {
63
        Some(template_id) => render_prefilled_draft(jwt_auth.user.id, template_id).await,
64
        None => None,
65
    };
66
    #[cfg(not(feature = "scripting"))]
67
    let prefilled_draft = {
68
        let _ = &jwt_auth;
69
        None
70
    };
71

            
72
    let template = TransactionCreatePage {
73
        from_account: params.from_account,
74
        prefilled_draft,
75
    };
76
    HtmlTemplate(template)
77
}
78

            
79
#[derive(Template)]
80
#[template(path = "components/transaction/create.html")]
81
struct TransactionFormTemplate {}
82

            
83
pub async fn transaction_form() -> impl IntoResponse {
84
    let template = TransactionFormTemplate {};
85
    HtmlTemplate(template)
86
}
87

            
88
#[derive(Deserialize, Debug)]
89
pub struct TransactionForm {
90
    splits: Vec<SplitData>,
91
    note: Option<String>,
92
    date: Option<String>,
93
    tags: Option<Vec<TagData>>,
94
}
95

            
96
8
pub async fn transaction_submit(
97
8
    State(_data): State<Arc<AppState>>,
98
8
    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
99
8
    Json(form): Json<TransactionForm>,
100
8
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
101
8
    let user = &jwt_auth.user;
102

            
103
    // Validate splits
104
8
    validate_splits_not_empty(&form.splits)?;
105

            
106
    // Parse date
107
7
    let post_date = parse_transaction_date(form.date.as_deref());
108
7
    let post_date_utc = post_date.and_utc();
109
7
    let enter_date_utc = Local::now().naive_utc().and_utc();
110

            
111
    // Create transaction ID
112
7
    let tx_id = Uuid::new_v4();
113

            
114
    // Process splits using shared utility
115
7
    let mut split_entities = Vec::new();
116
7
    let mut prices = Vec::new();
117
7
    let mut split_tags_to_create = Vec::new();
118

            
119
7
    for split_data in form.splits {
120
7
        let processed = process_split_data(tx_id, split_data).await?;
121

            
122
3
        let from_split_id = processed.from_split.id;
123
3
        let to_split_id = processed.to_split.id;
124

            
125
3
        split_entities.push(FinanceEntity::Split(processed.from_split));
126
3
        split_entities.push(FinanceEntity::Split(processed.to_split));
127

            
128
3
        if let Some(price) = processed.price {
129
            prices.push(FinanceEntity::Price(price));
130
3
        }
131

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

            
146
3
        if let Some(tags) = processed.to_split_tags {
147
            for tag in tags {
148
                split_tags_to_create.push((
149
                    to_split_id,
150
                    Tag {
151
                        id: Uuid::new_v4(),
152
                        tag_name: tag.name,
153
                        tag_value: tag.value,
154
                        description: tag.description,
155
                    },
156
                ));
157
            }
158
3
        }
159
    }
160

            
161
    // Execute command
162
3
    let mut cmd = server::command::transaction::CreateTransaction::new()
163
3
        .user_id(user.id)
164
3
        .splits(split_entities)
165
3
        .id(tx_id)
166
3
        .post_date(post_date_utc)
167
3
        .enter_date(enter_date_utc);
168

            
169
3
    if !prices.is_empty() {
170
        cmd = cmd.prices(prices);
171
3
    }
172

            
173
3
    if let Some(note) = form.note.as_deref()
174
3
        && !note.trim().is_empty()
175
3
    {
176
3
        cmd = cmd.note(note.to_string());
177
3
    }
178

            
179
3
    if !split_tags_to_create.is_empty() {
180
        cmd = cmd.split_tags(split_tags_to_create);
181
3
    }
182

            
183
3
    match cmd.run().await {
184
        Ok(result) => {
185
            if let Some(tags) = form.tags {
186
                let server_user = server::user::User { id: user.id };
187
                for tag_data in tags {
188
                    server_user
189
                        .create_transaction_tag(
190
                            tx_id,
191
                            tag_data.name,
192
                            tag_data.value,
193
                            tag_data.description,
194
                        )
195
                        .await
196
                        .map_err(|e| {
197
                            let error_response = serde_json::json!({
198
                                "status": "fail",
199
                                "message": format!("Failed to create transaction tag: {:?}", e),
200
                            });
201
                            log::error!("Failed to create transaction tag: {e:?}");
202
                            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
203
                        })?;
204
                }
205
            }
206

            
207
            match result {
208
                Some(CmdResult::Entity(FinanceEntity::Transaction(tx))) => Ok(format!(
209
                    "{}: {}",
210
                    t!("New transaction created with ID"),
211
                    tx.id
212
                )),
213
                _ => Ok(t!("New transaction created successfully").to_string()),
214
            }
215
        }
216
3
        Err(e) => {
217
3
            let error_response = serde_json::json!({
218
3
                "status": "fail",
219
3
                "message": format!("Failed to create transaction: {:?}", e),
220
            });
221

            
222
3
            log::error!("Failed to create transaction: {e:?}");
223
3
            Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)))
224
        }
225
    }
226
8
}
227

            
228
#[cfg(test)]
229
mod tests {
230
    use super::*;
231
    use askama::Template;
232

            
233
18
    fn render_create_form() -> String {
234
18
        TransactionFormTemplate {}
235
18
            .render()
236
18
            .expect("create form template should render")
237
18
    }
238

            
239
    #[test]
240
2
    fn create_form_has_splits_container() {
241
2
        let html = render_create_form();
242
2
        assert!(
243
2
            html.contains(r#"id="splits-container"#),
244
            "create form must have splits container"
245
        );
246
2
    }
247

            
248
    #[test]
249
2
    fn create_form_has_add_split_button() {
250
2
        let html = render_create_form();
251
2
        assert!(
252
2
            html.contains(r#"id="add-split-btn"#),
253
            "create form must have add-split button"
254
        );
255
2
        assert!(
256
2
            html.contains(r#"hx-get="/api/transaction/split/create"#),
257
            "add-split button must fetch new split via htmx"
258
        );
259
2
        assert!(
260
2
            html.contains(r##"hx-target="#splits-container"##),
261
            "add-split button must target splits container"
262
        );
263
2
        assert!(
264
2
            html.contains(r#"hx-swap="beforeend"#),
265
            "add-split button must append to container"
266
        );
267
2
    }
268

            
269
    #[test]
270
2
    fn create_form_has_note_input() {
271
2
        let html = render_create_form();
272
2
        assert!(
273
2
            html.contains(r#"name="note"#),
274
            "create form must have note input"
275
        );
276
2
    }
277

            
278
    #[test]
279
2
    fn create_form_has_date_input() {
280
2
        let html = render_create_form();
281
2
        assert!(
282
2
            html.contains(r#"id="date"#),
283
            "create form must have date input"
284
        );
285
2
        assert!(
286
2
            html.contains(r#"type="datetime-local"#),
287
            "date input must be datetime-local type"
288
        );
289
2
    }
290

            
291
    #[test]
292
2
    fn create_form_has_entity_tags_editor() {
293
2
        let html = render_create_form();
294
2
        assert!(
295
2
            html.contains("entity-tags-container"),
296
            "create form must have entity tags container"
297
        );
298
2
        assert!(
299
2
            html.contains("entity-tag-template"),
300
            "create form must have entity tag template"
301
        );
302
2
    }
303

            
304
    #[test]
305
2
    fn create_form_uses_json_enc_extension() {
306
2
        let html = render_create_form();
307
2
        assert!(
308
2
            html.contains(r#"hx-ext="json-enc"#),
309
            "create form must use json-enc htmx extension"
310
        );
311
2
    }
312

            
313
    #[test]
314
2
    fn create_form_has_submit_button() {
315
2
        let html = render_create_form();
316
2
        assert!(
317
2
            html.contains(r#"type="submit"#),
318
            "create form must have submit button"
319
        );
320
2
    }
321

            
322
    #[test]
323
2
    fn create_form_posts_to_correct_endpoint() {
324
2
        let html = render_create_form();
325
2
        assert!(
326
2
            html.contains(r#"hx-post="/api/transaction/create/submit"#),
327
            "form must post to transaction create submit endpoint"
328
        );
329
2
    }
330

            
331
    #[test]
332
2
    fn create_form_has_prerendered_split_entry() {
333
2
        let html = render_create_form();
334
2
        assert!(
335
2
            html.contains(r#"class="split-entry""#),
336
            "create form must have a pre-rendered split entry"
337
        );
338
2
        assert!(
339
2
            html.contains(r#"data-split-index="0""#),
340
            "pre-rendered split must have index 0"
341
        );
342
2
        assert!(
343
2
            html.contains(r#"name="splits[0][amount]"#),
344
            "pre-rendered split must have amount input"
345
        );
346
2
    }
347
}