This commit is contained in:
acx
2024-08-11 09:25:09 +00:00
parent e25c1b5ceb
commit 21a6b91139
13 changed files with 466 additions and 7 deletions

1
Cargo.lock generated
View File

@@ -553,6 +553,7 @@ dependencies = [
"once_cell", "once_cell",
"pbkdf2", "pbkdf2",
"rand_core", "rand_core",
"regex",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",

View File

@@ -25,3 +25,4 @@ once_cell = "1.19.0"
axum-macros = "0.4.1" axum-macros = "0.4.1"
pbkdf2 = { version = "0.12", features = ["simple"] } pbkdf2 = { version = "0.12", features = ["simple"] }
rand_core ={version = "0.6", features = ["std"]} rand_core ={version = "0.6", features = ["std"]}
regex = {version = "1.10"}

View File

@@ -3,6 +3,7 @@
CREATE TABLE "categories" ( CREATE TABLE "categories" (
"id" BIGSERIAL PRIMARY KEY, "id" BIGSERIAL PRIMARY KEY,
"uid" BIGINT NOT NULL, "uid" BIGINT NOT NULL,
"book_id" BIGINT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"level" INT NOT NULL DEFAULT 0, "level" INT NOT NULL DEFAULT 0,
"parent_category_id" BIGINT NOT NULL DEFAULT 0, "parent_category_id" BIGINT NOT NULL DEFAULT 0,
@@ -14,6 +15,7 @@ CREATE TABLE "categories" (
CREATE TABLE "tags" ( CREATE TABLE "tags" (
"id" BIGSERIAL PRIMARY KEY, "id" BIGSERIAL PRIMARY KEY,
"uid" BIGINT NOT NULL, "uid" BIGINT NOT NULL,
"book_id" BIGINT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"level" INT NOT NULL DEFAULT 0, "level" INT NOT NULL DEFAULT 0,
"parent_tag_id" BIGINT NOT NULL DEFAULT 0, "parent_tag_id" BIGINT NOT NULL DEFAULT 0,
@@ -66,9 +68,11 @@ CREATE TABLE "accounts" (
CREATE TABLE "amounts" ( CREATE TABLE "amounts" (
"id" BIGSERIAL PRIMARY KEY, "id" BIGSERIAL PRIMARY KEY,
"uid" BIGINT NOT NULL, "uid" BIGINT NOT NULL,
"account_id" BIGINT NOT NULL,
"transaction_id" BIGINT NOT NULL, "transaction_id" BIGINT NOT NULL,
"value" BIGINT NOT NULL DEFAULT 0, "value" BIGINT NOT NULL DEFAULT 0,
"expo" BIGINT NOT NULL DEFAULT 5, "expo" BIGINT NOT NULL DEFAULT 5,
"currency" TEXT NOT NULL DEFAULT '',
"is_delete" BOOLEAN NOT NULL DEFAULT FALSE, "is_delete" BOOLEAN NOT NULL DEFAULT FALSE,
"create_at" TIMESTAMP NOT NULL DEFAULT current_timestamp, "create_at" TIMESTAMP NOT NULL DEFAULT current_timestamp,
"update_at" TIMESTAMP NOT NULL DEFAULT current_timestamp "update_at" TIMESTAMP NOT NULL DEFAULT current_timestamp

View File

@@ -2,3 +2,4 @@ pub mod category;
pub mod tag; pub mod tag;
pub mod book; pub mod book;
pub mod account; pub mod account;
pub mod transaction;

342
src/ledger/transaction.rs Normal file
View File

@@ -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<i64>,
time: String,
amounts: Vec<SubmitTransactionAmountRequest>,
}
#[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<Utc>,
pub tag_ids: Vec<i64>,
pub amount_ids: Vec<i64>,
}
pub fn get_nest_handlers() -> Router<crate::AppState> {
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<crate::AppState>,
claims: Claims,
Json(payload): Json<SubmitTransactionRequest>,
) -> Result<String, (StatusCode, String)> {
// ) -> Result<Json<db_model::Transaction>, (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::<bool>(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::<bool>(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<db_model::AmountForm> = 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<i64> = 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<db_model::Transaction>;
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<db_model::Amount> = 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<i64>,
State(app_state): State<crate::AppState>,
claims: Claims,
Json(payload): Json<SubmitTransactionRequest>,
) -> Result<Json<CommonResp>, (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<i64>,
State(app_state): State<crate::AppState>,
claims: Claims,
) -> Result<Json<db_model::Transaction>, (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<crate::AppState>,
claims: Claims,
) -> Result<Json<Vec<db_model::Transaction>>, (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<crate::AppState>,
claims: Claims,
Query(params): Query<Params>,
) -> Result<Json<Vec<db_model::Amount>>, (StatusCode, String)> {
}

View File

@@ -95,6 +95,7 @@ async fn main() {
.nest("/api/v1/tag", ledger::tag::get_nest_handlers()) .nest("/api/v1/tag", ledger::tag::get_nest_handlers())
.nest("/api/v1/book", ledger::book::get_nest_handlers()) .nest("/api/v1/book", ledger::book::get_nest_handlers())
.nest("/api/v1/account", ledger::account::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()) .nest("/api/v1/user", user::handler::get_nest_handlers())
.with_state(shared_state) .with_state(shared_state)
.layer(global_layer); .layer(global_layer);

View File

@@ -1,5 +1,6 @@
use crate::model::schema; use crate::model::schema;
use diesel::prelude::*; use diesel::prelude::*;
use chrono::{DateTime, Utc};
#[derive(Queryable, Selectable, serde::Serialize, serde::Deserialize)] #[derive(Queryable, Selectable, serde::Serialize, serde::Deserialize)]
#[diesel(table_name = schema::categories)] #[diesel(table_name = schema::categories)]
@@ -66,7 +67,6 @@ pub struct BookForm {
pub name: String, pub name: String,
} }
#[derive(Queryable, Selectable, serde::Serialize, serde::Deserialize)] #[derive(Queryable, Selectable, serde::Serialize, serde::Deserialize)]
#[diesel(table_name = schema::accounts)] #[diesel(table_name = schema::accounts)]
#[diesel(check_for_backend(diesel::pg::Pg))] #[diesel(check_for_backend(diesel::pg::Pg))]
@@ -88,6 +88,57 @@ pub struct AccountForm {
pub account_type: i64, 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<Utc>,
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<i64>,
pub uid: i64,
pub book_id: i64,
pub description: String,
pub category_id: i64,
pub time: chrono::DateTime<Utc>,
}
#[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)] #[derive(Queryable, Selectable, serde::Serialize)]
#[diesel(table_name = schema::users)] #[diesel(table_name = schema::users)]
pub struct User { pub struct User {

View File

@@ -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<i64>,
}

View File

@@ -19,6 +19,7 @@ diesel::table! {
transaction_id -> Int8, transaction_id -> Int8,
value -> Int8, value -> Int8,
expo -> Int8, expo -> Int8,
currency -> Text,
is_delete -> Bool, is_delete -> Bool,
create_at -> Timestamp, create_at -> Timestamp,
update_at -> Timestamp, update_at -> Timestamp,

View File

@@ -16,9 +16,11 @@ diesel::table! {
amounts (id) { amounts (id) {
id -> Int8, id -> Int8,
uid -> Int8, uid -> Int8,
account_id -> Int8,
transaction_id -> Int8, transaction_id -> Int8,
value -> Int8, value -> Int8,
expo -> Int8, expo -> Int8,
currency -> Text,
is_delete -> Bool, is_delete -> Bool,
create_at -> Timestamp, create_at -> Timestamp,
update_at -> Timestamp, update_at -> Timestamp,
@@ -40,6 +42,7 @@ diesel::table! {
categories (id) { categories (id) {
id -> Int8, id -> Int8,
uid -> Int8, uid -> Int8,
book_id -> Int8,
name -> Text, name -> Text,
level -> Int4, level -> Int4,
parent_category_id -> Int8, parent_category_id -> Int8,
@@ -53,6 +56,7 @@ diesel::table! {
tags (id) { tags (id) {
id -> Int8, id -> Int8,
uid -> Int8, uid -> Int8,
book_id -> Int8,
name -> Text, name -> Text,
level -> Int4, level -> Int4,
parent_tag_id -> Int8, parent_tag_id -> Int8,

View File

@@ -25,10 +25,10 @@ pub async fn add_user(app_state: crate::AppState, username: String, password: St
.get_result::<i64>(conn) .get_result::<i64>(conn)
}) })
.await .await
.map_err(|res| { .map_err(|_res| {
() ()
})? })?
.map_err(|res| { .map_err(|_res| {
() ()
})?; })?;
println!("ret {}", res); println!("ret {}", res);
@@ -50,10 +50,10 @@ pub async fn add_user(app_state: crate::AppState, username: String, password: St
.get_result(conn) .get_result(conn)
}) })
.await .await
.map_err(|e| { .map_err(|_e| {
() ()
})? })?
.map_err(|e| { .map_err(|_e| {
() ()
})?; })?;
let out = json!(add_res); 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 { let conn = match conn_res {
Ok(res) => res, Ok(res) => res,
Err(err) => { return false; } Err(_err) => { return false; }
}; };
// 1. get psw hash // 1. get psw hash
let query_username = username.clone(); let query_username = username.clone();

45
src/util/math.rs Normal file
View File

@@ -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<width$}", decimal_part, width=target_expo as usize);
if dec_part_padding.len() > 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::<i64>().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(()));
}
}

View File

@@ -1,2 +1,3 @@
pub mod req; pub mod req;
pub mod pass; pub mod pass;
pub mod math;