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}
26
27#[derive(Template)]
28#[template(path = "pages/transaction/create.html")]
29struct TransactionCreatePage {
30 from_account: Option<Uuid>,
31}
32
33pub async fn transaction_create_page(
34 Query(params): Query<TransactionCreateParams>,
35) -> impl IntoResponse {
36 let template = TransactionCreatePage {
37 from_account: params.from_account,
38 };
39 HtmlTemplate(template)
40}
41
42#[derive(Template)]
43#[template(path = "components/transaction/create.html")]
44struct TransactionFormTemplate {}
45
46pub async fn transaction_form() -> impl IntoResponse {
47 let template = TransactionFormTemplate {};
48 HtmlTemplate(template)
49}
50
51#[derive(Deserialize, Debug)]
52pub struct TransactionForm {
53 splits: Vec<SplitData>,
54 note: Option<String>,
55 date: Option<String>,
56 tags: Option<Vec<TagData>>,
57}
58
59pub async fn transaction_submit(
60 State(_data): State<Arc<AppState>>,
61 Extension(jwt_auth): Extension<JWTAuthMiddleware>,
62 Json(form): Json<TransactionForm>,
63) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
64 let user = &jwt_auth.user;
65
66 validate_splits_not_empty(&form.splits)?;
68
69 let post_date = parse_transaction_date(form.date.as_deref());
71 let post_date_utc = post_date.and_utc();
72 let enter_date_utc = Local::now().naive_utc().and_utc();
73
74 let tx_id = Uuid::new_v4();
76
77 let mut split_entities = Vec::new();
79 let mut prices = Vec::new();
80 let mut split_tags_to_create = Vec::new();
81
82 for split_data in form.splits {
83 let processed = process_split_data(tx_id, split_data).await?;
84
85 let from_split_id = processed.from_split.id;
86 let to_split_id = processed.to_split.id;
87
88 split_entities.push(FinanceEntity::Split(processed.from_split));
89 split_entities.push(FinanceEntity::Split(processed.to_split));
90
91 if let Some(price) = processed.price {
92 prices.push(FinanceEntity::Price(price));
93 }
94
95 if let Some(tags) = processed.from_split_tags {
96 for tag in tags {
97 split_tags_to_create.push((
98 from_split_id,
99 Tag {
100 id: Uuid::new_v4(),
101 tag_name: tag.name,
102 tag_value: tag.value,
103 description: tag.description,
104 },
105 ));
106 }
107 }
108
109 if let Some(tags) = processed.to_split_tags {
110 for tag in tags {
111 split_tags_to_create.push((
112 to_split_id,
113 Tag {
114 id: Uuid::new_v4(),
115 tag_name: tag.name,
116 tag_value: tag.value,
117 description: tag.description,
118 },
119 ));
120 }
121 }
122 }
123
124 let mut cmd = server::command::transaction::CreateTransaction::new()
126 .user_id(user.id)
127 .splits(split_entities)
128 .id(tx_id)
129 .post_date(post_date_utc)
130 .enter_date(enter_date_utc);
131
132 if !prices.is_empty() {
133 cmd = cmd.prices(prices);
134 }
135
136 if let Some(note) = form.note.as_deref()
137 && !note.trim().is_empty()
138 {
139 cmd = cmd.note(note.to_string());
140 }
141
142 if !split_tags_to_create.is_empty() {
143 cmd = cmd.split_tags(split_tags_to_create);
144 }
145
146 match cmd.run().await {
147 Ok(result) => {
148 if let Some(tags) = form.tags {
149 let server_user = server::user::User { id: user.id };
150 for tag_data in tags {
151 server_user
152 .create_transaction_tag(
153 tx_id,
154 tag_data.name,
155 tag_data.value,
156 tag_data.description,
157 )
158 .await
159 .map_err(|e| {
160 let error_response = serde_json::json!({
161 "status": "fail",
162 "message": format!("Failed to create transaction tag: {:?}", e),
163 });
164 log::error!("Failed to create transaction tag: {e:?}");
165 (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
166 })?;
167 }
168 }
169
170 match result {
171 Some(CmdResult::Entity(FinanceEntity::Transaction(tx))) => Ok(format!(
172 "{}: {}",
173 t!("New transaction created with ID"),
174 tx.id
175 )),
176 _ => Ok(t!("New transaction created successfully").to_string()),
177 }
178 }
179 Err(e) => {
180 let error_response = serde_json::json!({
181 "status": "fail",
182 "message": format!("Failed to create transaction: {:?}", e),
183 });
184
185 log::error!("Failed to create transaction: {e:?}");
186 Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)))
187 }
188 }
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use askama::Template;
195
196 fn render_create_form() -> String {
197 TransactionFormTemplate {}
198 .render()
199 .expect("create form template should render")
200 }
201
202 #[test]
203 fn create_form_has_splits_container() {
204 let html = render_create_form();
205 assert!(
206 html.contains(r#"id="splits-container"#),
207 "create form must have splits container"
208 );
209 }
210
211 #[test]
212 fn create_form_has_add_split_button() {
213 let html = render_create_form();
214 assert!(
215 html.contains(r#"id="add-split-btn"#),
216 "create form must have add-split button"
217 );
218 assert!(
219 html.contains(r#"hx-get="/api/transaction/split/create"#),
220 "add-split button must fetch new split via htmx"
221 );
222 assert!(
223 html.contains(r##"hx-target="#splits-container"##),
224 "add-split button must target splits container"
225 );
226 assert!(
227 html.contains(r#"hx-swap="beforeend"#),
228 "add-split button must append to container"
229 );
230 }
231
232 #[test]
233 fn create_form_has_note_input() {
234 let html = render_create_form();
235 assert!(
236 html.contains(r#"name="note"#),
237 "create form must have note input"
238 );
239 }
240
241 #[test]
242 fn create_form_has_date_input() {
243 let html = render_create_form();
244 assert!(
245 html.contains(r#"id="date"#),
246 "create form must have date input"
247 );
248 assert!(
249 html.contains(r#"type="datetime-local"#),
250 "date input must be datetime-local type"
251 );
252 }
253
254 #[test]
255 fn create_form_has_entity_tags_editor() {
256 let html = render_create_form();
257 assert!(
258 html.contains("entity-tags-container"),
259 "create form must have entity tags container"
260 );
261 assert!(
262 html.contains("entity-tag-template"),
263 "create form must have entity tag template"
264 );
265 }
266
267 #[test]
268 fn create_form_uses_json_enc_extension() {
269 let html = render_create_form();
270 assert!(
271 html.contains(r#"hx-ext="json-enc"#),
272 "create form must use json-enc htmx extension"
273 );
274 }
275
276 #[test]
277 fn create_form_has_submit_button() {
278 let html = render_create_form();
279 assert!(
280 html.contains(r#"type="submit"#),
281 "create form must have submit button"
282 );
283 }
284
285 #[test]
286 fn create_form_posts_to_correct_endpoint() {
287 let html = render_create_form();
288 assert!(
289 html.contains(r#"hx-post="/api/transaction/create/submit"#),
290 "form must post to transaction create submit endpoint"
291 );
292 }
293
294 #[test]
295 fn create_form_has_prerendered_split_entry() {
296 let html = render_create_form();
297 assert!(
298 html.contains(r#"class="split-entry""#),
299 "create form must have a pre-rendered split entry"
300 );
301 assert!(
302 html.contains(r#"data-split-index="0""#),
303 "pre-rendered split must have index 0"
304 );
305 assert!(
306 html.contains(r#"name="splits[0][amount]"#),
307 "pre-rendered split must have amount input"
308 );
309 }
310}