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 template_id: Option<Uuid>,
26}
27
28#[derive(Template)]
29#[template(path = "pages/transaction/create.html")]
30struct TransactionCreatePage {
31 from_account: Option<Uuid>,
32 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_not_empty(&form.splits)?;
92
93 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 let tx_id = Uuid::new_v4();
100
101 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 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}