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