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    template_id: Option<Uuid>,
26}
27
28#[derive(Template)]
29#[template(path = "pages/transaction/create.html")]
30struct 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
37async 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
55pub 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")]
73struct TransactionFormTemplate {}
74
75#[derive(Deserialize, Debug)]
76pub struct TransactionForm {
77    splits: Vec<SplitData>,
78    note: Option<String>,
79    date: Option<String>,
80    tags: Option<Vec<TagData>>,
81}
82
83pub async fn transaction_submit(
84    State(_data): State<Arc<AppState>>,
85    Extension(jwt_auth): Extension<JWTAuthMiddleware>,
86    Json(form): Json<TransactionForm>,
87) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
88    let user = &jwt_auth.user;
89
90    // Validate splits
91    validate_splits_not_empty(&form.splits)?;
92
93    // Parse date
94    let post_date = parse_transaction_date(form.date.as_deref());
95    let post_date_utc = post_date.and_utc();
96    let enter_date_utc = Local::now().naive_utc().and_utc();
97
98    // Create transaction ID
99    let tx_id = Uuid::new_v4();
100
101    // Process splits using shared utility
102    let mut split_entities = Vec::new();
103    let mut prices = Vec::new();
104    let mut split_tags_to_create = Vec::new();
105
106    for split_data in form.splits {
107        let processed = process_split_data(tx_id, split_data).await?;
108
109        let from_split_id = processed.from_split.id;
110        let to_split_id = processed.to_split.id;
111
112        split_entities.push(FinanceEntity::Split(processed.from_split));
113        split_entities.push(FinanceEntity::Split(processed.to_split));
114
115        if let Some(price) = processed.price {
116            prices.push(FinanceEntity::Price(price));
117        }
118
119        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        }
132
133        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        }
146    }
147
148    // Execute command
149    let mut cmd = server::command::transaction::CreateTransaction::new()
150        .user_id(user.id)
151        .splits(split_entities)
152        .id(tx_id)
153        .post_date(post_date_utc)
154        .enter_date(enter_date_utc);
155
156    if !prices.is_empty() {
157        cmd = cmd.prices(prices);
158    }
159
160    if let Some(note) = form.note.as_deref()
161        && !note.trim().is_empty()
162    {
163        cmd = cmd.note(note.to_string());
164    }
165
166    if !split_tags_to_create.is_empty() {
167        cmd = cmd.split_tags(split_tags_to_create);
168    }
169
170    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        Err(e) => {
204            let error_response = serde_json::json!({
205                "status": "fail",
206                "message": format!("Failed to create transaction: {:?}", e),
207            });
208
209            log::error!("Failed to create transaction: {e:?}");
210            Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)))
211        }
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use askama::Template;
219
220    fn render_create_form() -> String {
221        TransactionFormTemplate {}
222            .render()
223            .expect("create form template should render")
224    }
225
226    #[test]
227    fn create_form_has_splits_container() {
228        let html = render_create_form();
229        assert!(
230            html.contains(r#"id="splits-container"#),
231            "create form must have splits container"
232        );
233    }
234
235    #[test]
236    fn create_form_has_add_split_button() {
237        let html = render_create_form();
238        assert!(
239            html.contains(r#"id="add-split-btn"#),
240            "create form must have add-split button"
241        );
242        assert!(
243            html.contains(r#"hx-get="/api/transaction/split/create"#),
244            "add-split button must fetch new split via htmx"
245        );
246        assert!(
247            html.contains(r##"hx-target="#splits-container"##),
248            "add-split button must target splits container"
249        );
250        assert!(
251            html.contains(r#"hx-swap="beforeend"#),
252            "add-split button must append to container"
253        );
254    }
255
256    #[test]
257    fn create_form_has_note_input() {
258        let html = render_create_form();
259        assert!(
260            html.contains(r#"name="note"#),
261            "create form must have note input"
262        );
263    }
264
265    #[test]
266    fn create_form_has_date_input() {
267        let html = render_create_form();
268        assert!(
269            html.contains(r#"id="date"#),
270            "create form must have date input"
271        );
272        assert!(
273            html.contains(r#"type="datetime-local"#),
274            "date input must be datetime-local type"
275        );
276    }
277
278    #[test]
279    fn create_form_has_entity_tags_editor() {
280        let html = render_create_form();
281        assert!(
282            html.contains("entity-tags-container"),
283            "create form must have entity tags container"
284        );
285        assert!(
286            html.contains("entity-tag-template"),
287            "create form must have entity tag template"
288        );
289    }
290
291    #[test]
292    fn create_form_uses_json_enc_extension() {
293        let html = render_create_form();
294        assert!(
295            html.contains(r#"hx-ext="json-enc"#),
296            "create form must use json-enc htmx extension"
297        );
298    }
299
300    #[test]
301    fn create_form_has_submit_button() {
302        let html = render_create_form();
303        assert!(
304            html.contains(r#"type="submit"#),
305            "create form must have submit button"
306        );
307    }
308
309    #[test]
310    fn create_form_posts_to_correct_endpoint() {
311        let html = render_create_form();
312        assert!(
313            html.contains(r#"hx-post="/api/transaction/create/submit"#),
314            "form must post to transaction create submit endpoint"
315        );
316    }
317
318    #[test]
319    fn create_form_has_prerendered_split_entry() {
320        let html = render_create_form();
321        assert!(
322            html.contains(r#"class="split-entry""#),
323            "create form must have a pre-rendered split entry"
324        );
325        assert!(
326            html.contains(r#"data-split-index="0""#),
327            "pre-rendered split must have index 0"
328        );
329        assert!(
330            html.contains(r#"name="splits[0][amount]"#),
331            "pre-rendered split must have amount input"
332        );
333    }
334}