Lines
64.94 %
Functions
40 %
Branches
100 %
//web/src/pages/transaction/create/submit.rs
use askama::Template;
use axum::{
Extension, Json,
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
};
use chrono::Local;
use finance::tag::Tag;
use serde::Deserialize;
use server::command::{CmdResult, FinanceEntity};
use sqlx::types::Uuid;
use std::sync::Arc;
use crate::pages::transaction::util::{
SplitData, TagData, parse_transaction_date, process_split_data, validate_splits_not_empty,
use crate::{AppState, jwt_auth::JWTAuthMiddleware, pages::HtmlTemplate};
#[derive(Deserialize)]
pub struct TransactionCreateParams {
from_account: Option<Uuid>,
}
#[derive(Template)]
#[template(path = "pages/transaction/create.html")]
struct TransactionCreatePage {
pub async fn transaction_create_page(
Query(params): Query<TransactionCreateParams>,
) -> impl IntoResponse {
let template = TransactionCreatePage {
from_account: params.from_account,
HtmlTemplate(template)
#[template(path = "components/transaction/create.html")]
struct TransactionFormTemplate {}
pub async fn transaction_form() -> impl IntoResponse {
let template = TransactionFormTemplate {};
#[derive(Deserialize, Debug)]
pub struct TransactionForm {
splits: Vec<SplitData>,
note: Option<String>,
date: Option<String>,
tags: Option<Vec<TagData>>,
pub async fn transaction_submit(
State(_data): State<Arc<AppState>>,
Extension(jwt_auth): Extension<JWTAuthMiddleware>,
Json(form): Json<TransactionForm>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let user = &jwt_auth.user;
// Validate splits
validate_splits_not_empty(&form.splits)?;
// Parse date
let post_date = parse_transaction_date(form.date.as_deref());
let post_date_utc = post_date.and_utc();
let enter_date_utc = Local::now().naive_utc().and_utc();
// Create transaction ID
let tx_id = Uuid::new_v4();
// Process splits using shared utility
let mut split_entities = Vec::new();
let mut prices = Vec::new();
let mut split_tags_to_create = Vec::new();
for split_data in form.splits {
let processed = process_split_data(tx_id, split_data).await?;
let from_split_id = processed.from_split.id;
let to_split_id = processed.to_split.id;
split_entities.push(FinanceEntity::Split(processed.from_split));
split_entities.push(FinanceEntity::Split(processed.to_split));
if let Some(price) = processed.price {
prices.push(FinanceEntity::Price(price));
if let Some(tags) = processed.from_split_tags {
for tag in tags {
split_tags_to_create.push((
from_split_id,
Tag {
id: Uuid::new_v4(),
tag_name: tag.name,
tag_value: tag.value,
description: tag.description,
},
));
if let Some(tags) = processed.to_split_tags {
to_split_id,
// Execute command
let mut cmd = server::command::transaction::CreateTransaction::new()
.user_id(user.id)
.splits(split_entities)
.id(tx_id)
.post_date(post_date_utc)
.enter_date(enter_date_utc);
if !prices.is_empty() {
cmd = cmd.prices(prices);
if let Some(note) = form.note.as_deref()
&& !note.trim().is_empty()
{
cmd = cmd.note(note.to_string());
if !split_tags_to_create.is_empty() {
cmd = cmd.split_tags(split_tags_to_create);
match cmd.run().await {
Ok(result) => {
if let Some(tags) = form.tags {
let server_user = server::user::User { id: user.id };
for tag_data in tags {
server_user
.create_transaction_tag(
tx_id,
tag_data.name,
tag_data.value,
tag_data.description,
)
.await
.map_err(|e| {
let error_response = serde_json::json!({
"status": "fail",
"message": format!("Failed to create transaction tag: {:?}", e),
});
log::error!("Failed to create transaction tag: {e:?}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(error_response))
})?;
match result {
Some(CmdResult::Entity(FinanceEntity::Transaction(tx))) => Ok(format!(
"{}: {}",
t!("New transaction created with ID"),
tx.id
)),
_ => Ok(t!("New transaction created successfully").to_string()),
Err(e) => {
"message": format!("Failed to create transaction: {:?}", e),
log::error!("Failed to create transaction: {e:?}");
Err((StatusCode::INTERNAL_SERVER_ERROR, Json(error_response)))
#[cfg(test)]
mod tests {
use super::*;
fn render_create_form() -> String {
TransactionFormTemplate {}
.render()
.expect("create form template should render")
#[test]
fn create_form_has_splits_container() {
let html = render_create_form();
assert!(
html.contains(r#"id="splits-container"#),
"create form must have splits container"
);
fn create_form_has_add_split_button() {
html.contains(r#"id="add-split-btn"#),
"create form must have add-split button"
html.contains(r#"hx-get="/api/transaction/split/create"#),
"add-split button must fetch new split via htmx"
html.contains(r##"hx-target="#splits-container"##),
"add-split button must target splits container"
html.contains(r#"hx-swap="beforeend"#),
"add-split button must append to container"
fn create_form_has_note_input() {
html.contains(r#"name="note"#),
"create form must have note input"
fn create_form_has_date_input() {
html.contains(r#"id="date"#),
"create form must have date input"
html.contains(r#"type="datetime-local"#),
"date input must be datetime-local type"
fn create_form_has_entity_tags_editor() {
html.contains("entity-tags-container"),
"create form must have entity tags container"
html.contains("entity-tag-template"),
"create form must have entity tag template"
fn create_form_uses_json_enc_extension() {
html.contains(r#"hx-ext="json-enc"#),
"create form must use json-enc htmx extension"
fn create_form_has_submit_button() {
html.contains(r#"type="submit"#),
"create form must have submit button"
fn create_form_posts_to_correct_endpoint() {
html.contains(r#"hx-post="/api/transaction/create/submit"#),
"form must post to transaction create submit endpoint"
fn create_form_has_prerendered_split_entry() {
html.contains(r#"class="split-entry""#),
"create form must have a pre-rendered split entry"
html.contains(r#"data-split-index="0""#),
"pre-rendered split must have index 0"
html.contains(r#"name="splits[0][amount]"#),
"pre-rendered split must have amount input"