web/pages/transaction/create/
submit.rs1use 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 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_not_empty(&form.splits)?;
105
106 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 let tx_id = Uuid::new_v4();
113
114 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 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}