From 21a6b91139e810e0a01a1368807262513cf44bcc Mon Sep 17 00:00:00 2001 From: acx Date: Sun, 11 Aug 2024 09:25:09 +0000 Subject: [PATCH] WIP --- Cargo.lock | 1 + Cargo.toml | 1 + .../2024-07-07-151037_base_schema/up.sql | 4 + src/ledger/mod.rs | 3 +- src/ledger/transaction.rs | 342 ++++++++++++++++++ src/main.rs | 1 + src/model/db_model.rs | 53 ++- src/model/req.rs | 7 + src/model/schema.rs | 1 + src/schema.rs | 4 + src/user/dal.rs | 10 +- src/util/math.rs | 45 +++ src/util/mod.rs | 1 + 13 files changed, 466 insertions(+), 7 deletions(-) create mode 100644 src/ledger/transaction.rs create mode 100644 src/util/math.rs diff --git a/Cargo.lock b/Cargo.lock index 0a6dbb9..22f5251 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -553,6 +553,7 @@ dependencies = [ "once_cell", "pbkdf2", "rand_core", + "regex", "serde", "serde_json", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 468ea90..5a4360a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,3 +25,4 @@ once_cell = "1.19.0" axum-macros = "0.4.1" pbkdf2 = { version = "0.12", features = ["simple"] } rand_core ={version = "0.6", features = ["std"]} +regex = {version = "1.10"} diff --git a/migrations/2024-07-07-151037_base_schema/up.sql b/migrations/2024-07-07-151037_base_schema/up.sql index 58907dd..a51dbd3 100644 --- a/migrations/2024-07-07-151037_base_schema/up.sql +++ b/migrations/2024-07-07-151037_base_schema/up.sql @@ -3,6 +3,7 @@ CREATE TABLE "categories" ( "id" BIGSERIAL PRIMARY KEY, "uid" BIGINT NOT NULL, + "book_id" BIGINT NOT NULL, "name" TEXT NOT NULL, "level" INT NOT NULL DEFAULT 0, "parent_category_id" BIGINT NOT NULL DEFAULT 0, @@ -14,6 +15,7 @@ CREATE TABLE "categories" ( CREATE TABLE "tags" ( "id" BIGSERIAL PRIMARY KEY, "uid" BIGINT NOT NULL, + "book_id" BIGINT NOT NULL, "name" TEXT NOT NULL, "level" INT NOT NULL DEFAULT 0, "parent_tag_id" BIGINT NOT NULL DEFAULT 0, @@ -66,9 +68,11 @@ CREATE TABLE "accounts" ( CREATE TABLE "amounts" ( "id" BIGSERIAL PRIMARY KEY, "uid" BIGINT NOT NULL, + "account_id" BIGINT NOT NULL, "transaction_id" BIGINT NOT NULL, "value" BIGINT NOT NULL DEFAULT 0, "expo" BIGINT NOT NULL DEFAULT 5, + "currency" TEXT NOT NULL DEFAULT '', "is_delete" BOOLEAN NOT NULL DEFAULT FALSE, "create_at" TIMESTAMP NOT NULL DEFAULT current_timestamp, "update_at" TIMESTAMP NOT NULL DEFAULT current_timestamp diff --git a/src/ledger/mod.rs b/src/ledger/mod.rs index 7a0d918..a563d1f 100644 --- a/src/ledger/mod.rs +++ b/src/ledger/mod.rs @@ -1,4 +1,5 @@ pub mod category; pub mod tag; pub mod book; -pub mod account; \ No newline at end of file +pub mod account; +pub mod transaction; diff --git a/src/ledger/transaction.rs b/src/ledger/transaction.rs new file mode 100644 index 0000000..e8fbf25 --- /dev/null +++ b/src/ledger/transaction.rs @@ -0,0 +1,342 @@ +use axum::extract::Query; +use axum::routing::{get, post}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, Router, +}; +use axum_macros::debug_handler; +use diesel::dsl::exists; +use diesel::prelude::*; +use std::fmt; +// use diesel::update; +use serde::{Deserialize, Serialize}; +// use serde_json::to_string; +use crate::middleware::auth; +use crate::middleware::auth::Claims; +use crate::model::{db_model,schema,req}; +use crate::util; +use crate::util::req::CommonResp; +use chrono::prelude::*; +use tracing::info; + +const PAYMENT_STORE_EXPO: i64 = 5; + +#[derive(Deserialize)] +pub struct SubmitTransactionRequest { + description: String, + book_id: i64, + category_id: i64, + tag_ids: Vec, + time: String, + amounts: Vec, +} + +#[derive(Deserialize)] +pub struct SubmitTransactionAmountRequest { + account_id: i64, + payment: String, + expo: i32, + currency: String, +} + +#[derive(Serialize)] +pub struct CreateTransactionResponse { + pub id: i64, + pub book_id: i64, + pub description: String, + pub category_id: i64, + pub time: chrono::DateTime, + pub tag_ids: Vec, + pub amount_ids: Vec, +} + +pub fn get_nest_handlers() -> Router { + Router::new() + .route( + "/entry", + post(create_transaction) // create new transaction entry with amount + .get(get_all_transactions),// get all transactions with entry + ) + .route("/entry/:id", get(get_transaction)) // get transaction entry + .route("/amount", get(get_amounts_by_tid)) + // .route("/entry/amount/:id", post(update_amount).get(get_amount)) // require query param tid=transaction_id +} +// implementation, or do something in between. +#[derive(Debug, Clone)] +struct TransactionError; + +impl fmt::Display for TransactionError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "invalid transaction insert result") + } +} + +#[debug_handler] +pub async fn create_transaction( + State(app_state): State, + claims: Claims, + Json(payload): Json, +) -> Result { + // ) -> Result, (StatusCode, String)> { + let uid: i64 = claims.uid.clone(); + let conn = app_state + .db + .get() + .await + .map_err(util::req::internal_error)?; + + // 1. check related ids + // 1.1 check book id + if payload.book_id <= 0 { + return Err((StatusCode::BAD_REQUEST, "invalid book id".to_string())); + } + let check_book = conn + .interact(move |conn| { + diesel::select(exists( + schema::books::table + .filter(schema::books::uid.eq(uid)) + .filter(schema::books::id.eq(payload.book_id)), + )) + .get_result::(conn) + }) + .await + .map_err(util::req::internal_error)? + .map_err(util::req::internal_error)?; + + println!("book valid: {}", check_book); + if !check_book { + return Err((StatusCode::BAD_REQUEST, "invalid book id".to_string())); + } + // 1.2 check category id + if payload.category_id <= 0 { + return Err((StatusCode::BAD_REQUEST, "invalid category id".to_string())); + } + let check_category = conn + .interact(move |conn| { + diesel::select(exists( + schema::categories::table + .filter(schema::categories::uid.eq(uid)) + .filter(schema::categories::id.eq(payload.category_id)), + )) + .get_result::(conn) + }) + .await + .map_err(util::req::internal_error)? + .map_err(util::req::internal_error)?; + + println!("category valid: {}", check_category); + if !check_category { + return Err((StatusCode::BAD_REQUEST, "invalid category id".to_string())); + } + // 1.3 check tag ids + let payload_tag_size = payload.tag_ids.len() as i64; + let mut check_tag = payload_tag_size == 0; + if !check_tag { + let check_tag_count = conn + .interact(move |conn| { + schema::tags::table + .filter(schema::tags::uid.eq(uid)) + .filter(schema::tags::id.eq_any(payload.tag_ids)) + .select(diesel::dsl::count(schema::tags::id)) + .first(conn) + .map(|x: i64| x as i64) + }) + .await + .map_err(util::req::internal_error)? + .map_err(util::req::internal_error)?; + println!("check tag: {}", check_tag_count); + check_tag = check_tag_count == payload_tag_size; + } + + println!("tag valid: {}", check_tag); + + if !check_tag { + return Err((StatusCode::BAD_REQUEST, "invalid tag ids".to_string())); + } + + // 1.4 check account + let mut check_amount = true; + let mut amounts: Vec = Vec::new(); + for amount_req in payload.amounts { + // Parse and check payment + let parse_payment_result = + util::math::parse_payment_to_value_expo(amount_req.payment.clone(), PAYMENT_STORE_EXPO); + let value: i64; + let expo: i64; + match parse_payment_result { + Ok((val, expon)) => { + value = val; + expo = expon; + } + Err(_) => { + break; + } + } + + let amount = db_model::AmountForm { + uid: uid, + transaction_id: 0, + value: value, + expo: expo, + currency: amount_req.currency.clone(), + }; + check_amount = check_amount && true; + amounts.push(amount); + } + + if !check_amount || amounts.len() == 0 { + return Err((StatusCode::BAD_REQUEST, "invalid amount".to_string())); + } + + // 2. build and insert into db + + let mut transaction_resp: CreateTransactionResponse; + let mut amount_ids: Vec = Vec::new(); + + let transaction = conn + .interact(move |conn| { + conn.transaction(|conn| { + let new_transaction = db_model::TransactionForm { + id: None, + uid: uid, + book_id: payload.book_id, + description: payload.description, + category_id: payload.category_id, + // time: payload + time: Utc::now(), + }; + let inserted_transactions = diesel::insert_into(schema::transactions::table) + .values(&new_transaction) + .returning(db_model::Transaction::as_returning()) + .get_results(conn); + + let mut new_tr_vec: Vec; + match inserted_transactions { + Ok(tr) => new_tr_vec = tr, + Err(e) => { + return diesel::result::QueryResult::Err(e); + } + } + let mut new_tid = 0 as i64; + let new_tr = new_tr_vec.get(0); + match new_tr { + Some(tr) =>new_tid = tr.id, + None => new_tid = 0, + } + if new_tid <= 0 { + return diesel::result::QueryResult::Err(diesel::result::Error::NotFound); + } + for amount in amounts.iter_mut() { + amount.transaction_id = new_tid; + } + let inserted_amounts = diesel::insert_into(schema::amounts::table) + .values(&amounts) + .returning(db_model::Amount::as_returning()) + .get_results(conn); + let new_amounts: Vec = match inserted_amounts { + Ok(ams) => ams, + Err(_) => Vec::new(), + }; + for am in new_amounts { + amount_ids.push(am.id) + }; + diesel::result::QueryResult::Ok(()) + }) + }) + .await + .map_err(util::req::internal_error)?; + + // 3. build response data. + + // Ok(Json(res)) + Ok("finish".to_string()) +} + +pub async fn update_transaction( + Path(id): Path, + State(app_state): State, + claims: Claims, + Json(payload): Json, +) -> Result, (StatusCode, String)> { + let uid: i64 = claims.uid.clone(); + let conn = app_state + .db + .get() + .await + .map_err(util::req::internal_error)?; + let now = Utc::now().naive_utc(); + let res = conn + .interact(move |conn| { + diesel::update(schema::transactions::table) + .filter(schema::transactions::id.eq(id)) + .filter(schema::transactions::uid.eq(uid)) + .set(( + schema::transactions::description.eq(payload.description), + schema::transactions::update_at.eq(now), + )) + .execute(conn) + }) + .await + .map_err(util::req::internal_error)? + .map_err(util::req::internal_error)?; + let resp = util::req::CommonResp { code: 0 }; + Ok(Json(resp)) +} + +pub async fn get_transaction( + Path(id): Path, + State(app_state): State, + claims: Claims, +) -> Result, (StatusCode, String)> { + let uid: i64 = claims.uid.clone(); + let conn = app_state + .db + .get() + .await + .map_err(util::req::internal_error)?; + let res = conn + .interact(move |conn| { + schema::transactions::table + .filter(schema::transactions::id.eq(id)) + .filter(schema::transactions::uid.eq(uid)) + .select(db_model::Transaction::as_select()) + .limit(1) + .get_result(conn) + }) + .await + .map_err(util::req::internal_error)? + .map_err(util::req::internal_error)?; + Ok(Json(res)) +} + +pub async fn get_all_transactions( + State(app_state): State, + claims: Claims, +) -> Result>, (StatusCode, String)> { + let uid: i64 = claims.uid.clone(); + let conn = app_state + .db + .get() + .await + .map_err(util::req::internal_error)?; + let res = conn + .interact(move |conn| { + schema::transactions::table + .filter(schema::transactions::uid.eq(uid)) + .select(db_model::Transaction::as_select()) + .load(conn) + }) + .await + .map_err(util::req::internal_error)? + .map_err(util::req::internal_error)?; + Ok(Json(res)) +} + +pub async fn get_amounts_by_tid( + State(app_state): State, + claims: Claims, + Query(params): Query, +) -> Result>, (StatusCode, String)> { + +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 21e4cd5..1bdb989 100644 --- a/src/main.rs +++ b/src/main.rs @@ -95,6 +95,7 @@ async fn main() { .nest("/api/v1/tag", ledger::tag::get_nest_handlers()) .nest("/api/v1/book", ledger::book::get_nest_handlers()) .nest("/api/v1/account", ledger::account::get_nest_handlers()) + .nest("/api/v1/transaction", ledger::transaction::get_nest_handlers()) .nest("/api/v1/user", user::handler::get_nest_handlers()) .with_state(shared_state) .layer(global_layer); diff --git a/src/model/db_model.rs b/src/model/db_model.rs index 15db6c6..13f12bf 100644 --- a/src/model/db_model.rs +++ b/src/model/db_model.rs @@ -1,5 +1,6 @@ use crate::model::schema; use diesel::prelude::*; +use chrono::{DateTime, Utc}; #[derive(Queryable, Selectable, serde::Serialize, serde::Deserialize)] #[diesel(table_name = schema::categories)] @@ -66,7 +67,6 @@ pub struct BookForm { pub name: String, } - #[derive(Queryable, Selectable, serde::Serialize, serde::Deserialize)] #[diesel(table_name = schema::accounts)] #[diesel(check_for_backend(diesel::pg::Pg))] @@ -88,6 +88,57 @@ pub struct AccountForm { pub account_type: i64, } +#[derive(Queryable, Selectable, serde::Serialize, serde::Deserialize)] +#[diesel(table_name = schema::transactions)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Transaction { + pub id: i64, + uid: i64, + pub book_id: i64, + pub description: String, + pub category_id: i64, + pub time: chrono::DateTime, + is_delete: bool, + create_at: chrono::NaiveDateTime, + update_at: chrono::NaiveDateTime, +} + +#[derive(serde::Deserialize, Insertable)] +#[diesel(table_name = schema::transactions)] +pub struct TransactionForm { + pub id: Option, + pub uid: i64, + pub book_id: i64, + pub description: String, + pub category_id: i64, + pub time: chrono::DateTime, +} + +#[derive(Queryable, Selectable, serde::Serialize, serde::Deserialize)] +#[diesel(table_name = schema::amounts)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Amount { + pub id: i64, + uid: i64, + transaction_id: i64, + value: i64, + expo: i64, + currency: String, + is_delete: bool, + create_at: chrono::NaiveDateTime, + update_at: chrono::NaiveDateTime, +} + +#[derive(serde::Deserialize, Insertable)] +#[diesel(table_name = schema::amounts)] +pub struct AmountForm { + pub uid: i64, + pub transaction_id: i64, + pub value: i64, + pub expo: i64, + pub currency: String, +} + #[derive(Queryable, Selectable, serde::Serialize)] #[diesel(table_name = schema::users)] pub struct User { diff --git a/src/model/req.rs b/src/model/req.rs index e69de29..c9b3162 100644 --- a/src/model/req.rs +++ b/src/model/req.rs @@ -0,0 +1,7 @@ +use serde::{de, Deserialize, Deserializer}; + +#[derive(Debug, Deserialize)] +pub struct Params { + #[serde(default, deserialize_with="empty_string_as_none")] + transaction_id: Option, +} \ No newline at end of file diff --git a/src/model/schema.rs b/src/model/schema.rs index 4e7ce31..2ef148d 100644 --- a/src/model/schema.rs +++ b/src/model/schema.rs @@ -19,6 +19,7 @@ diesel::table! { transaction_id -> Int8, value -> Int8, expo -> Int8, + currency -> Text, is_delete -> Bool, create_at -> Timestamp, update_at -> Timestamp, diff --git a/src/schema.rs b/src/schema.rs index 4e7ce31..9ee4a19 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -16,9 +16,11 @@ diesel::table! { amounts (id) { id -> Int8, uid -> Int8, + account_id -> Int8, transaction_id -> Int8, value -> Int8, expo -> Int8, + currency -> Text, is_delete -> Bool, create_at -> Timestamp, update_at -> Timestamp, @@ -40,6 +42,7 @@ diesel::table! { categories (id) { id -> Int8, uid -> Int8, + book_id -> Int8, name -> Text, level -> Int4, parent_category_id -> Int8, @@ -53,6 +56,7 @@ diesel::table! { tags (id) { id -> Int8, uid -> Int8, + book_id -> Int8, name -> Text, level -> Int4, parent_tag_id -> Int8, diff --git a/src/user/dal.rs b/src/user/dal.rs index 228d6a5..e5d8e2d 100644 --- a/src/user/dal.rs +++ b/src/user/dal.rs @@ -25,10 +25,10 @@ pub async fn add_user(app_state: crate::AppState, username: String, password: St .get_result::(conn) }) .await - .map_err(|res| { + .map_err(|_res| { () })? - .map_err(|res| { + .map_err(|_res| { () })?; println!("ret {}", res); @@ -50,10 +50,10 @@ pub async fn add_user(app_state: crate::AppState, username: String, password: St .get_result(conn) }) .await - .map_err(|e| { + .map_err(|_e| { () })? - .map_err(|e| { + .map_err(|_e| { () })?; let out = json!(add_res); @@ -72,7 +72,7 @@ pub async fn check_user_psw(app_state: crate::AppState, username: String, passwo }); let conn = match conn_res { Ok(res) => res, - Err(err) => { return false; } + Err(_err) => { return false; } }; // 1. get psw hash let query_username = username.clone(); diff --git a/src/util/math.rs b/src/util/math.rs new file mode 100644 index 0000000..2ee64f0 --- /dev/null +++ b/src/util/math.rs @@ -0,0 +1,45 @@ +use regex::Regex; + +pub fn parse_payment_to_value_expo(payment_str: String, target_expo: i64) -> Result<(i64, i64), ()> { + // 1. check format + let re = Regex::new(r"[1-9]{0,9}[0-9]\.[0-9]{2,6}$").unwrap(); + let res_format = re.is_match(payment_str.as_str()); + if !res_format { + return Err(()) + } + let mut value: i64 = 0; + let mut expo : i64 = 0; + + let dot_index = payment_str.find('.'); + let (int_part, decimal_part) = match dot_index { + Some(pos) => (&payment_str[..pos], &payment_str[pos+1..]), + None => (payment_str.as_str(), ""), + }; + + let mut dec_part_padding = format!("{:0 target_expo as usize { + let pd = &dec_part_padding[..target_expo as usize]; + dec_part_padding = pd.to_string(); + } + let num_str = format!("{}{}", int_part, dec_part_padding); + println!("parsed num string \"{}\"", num_str); + let num = num_str.parse::().unwrap(); + let value = num; + let expo = target_expo; + + Ok((value, expo)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_parse_payment(){ + let r1 = parse_payment_to_value_expo("1.345".to_string(), 6); + assert_eq!(r1, Ok((1345000, 6))); + let r2 = parse_payment_to_value_expo("0.01".to_string(), 6); + assert_eq!(r2, Ok((10000, 6))); + let r3 = parse_payment_to_value_expo("0.10000001".to_string(), 6); + assert_eq!(r3, Err(())); + } +} \ No newline at end of file diff --git a/src/util/mod.rs b/src/util/mod.rs index 13815ca..04a0187 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,2 +1,3 @@ pub mod req; pub mod pass; +pub mod math; \ No newline at end of file