From 6d1300640051baa23360846197b54e1e69ae32e3 Mon Sep 17 00:00:00 2001 From: Joris Guyonvarch Date: Sat, 18 Apr 2026 11:04:47 +0200 Subject: Add balancing capabilities If payment are too unbalanced, it’s easier to make a transfer. --- src/controller/balance.rs | 20 +- src/controller/balancing.rs | 189 +++++++++++++++++ src/controller/mod.rs | 1 + src/db/balancing.rs | 260 ++++++++++++++++++++++++ src/db/migrations/07-create-balancing-table.sql | 9 + src/db/mod.rs | 2 + src/model/balancing.rs | 29 +++ src/model/mod.rs | 1 + src/queries.rs | 6 + src/routes.rs | 47 +++++ src/templates.rs | 1 + src/validation/balancing.rs | 34 ++++ src/validation/mod.rs | 1 + 13 files changed, 599 insertions(+), 1 deletion(-) create mode 100644 src/controller/balancing.rs create mode 100644 src/db/balancing.rs create mode 100644 src/db/migrations/07-create-balancing-table.sql create mode 100644 src/model/balancing.rs create mode 100644 src/validation/balancing.rs (limited to 'src') diff --git a/src/controller/balance.rs b/src/controller/balance.rs index 309f15c..6cb937b 100644 --- a/src/controller/balance.rs +++ b/src/controller/balance.rs @@ -7,6 +7,7 @@ use crate::controller::utils; use crate::controller::wallet::Wallet; use crate::db; use crate::model::user::User; +use crate::model::balancing::Balancing; use crate::payer; use crate::templates; @@ -22,7 +23,10 @@ pub async fn get(wallet: &Wallet) -> Response> { get_template_user_incomes(&users, &user_incomes); let total_income: i64 = user_incomes.values().sum(); - let user_payments = db::payments::repartition(&wallet.db_conn).await; + let user_payments = with_balancing( + db::payments::repartition(&wallet.db_conn).await, + db::balancing::list(&wallet.db_conn).await + ); let template_user_payments = get_template_user_payments(&users, &user_payments); let total_payments: i64 = user_payments.iter().map(|p| p.1).sum(); @@ -67,3 +71,17 @@ fn get_template_user_incomes( user_incomes.sort_by_key(|i| i.1); user_incomes } + +fn with_balancing( + user_payments: HashMap, + balancings: Vec +) -> HashMap { + let mut user_payments = user_payments; + for balancing in balancings { + let src = user_payments.entry(balancing.source).or_insert(0); + *src += balancing.amount; + let dest = user_payments.entry(balancing.destination).or_insert(0); + *dest -= balancing.amount; + } + user_payments +} diff --git a/src/controller/balancing.rs b/src/controller/balancing.rs new file mode 100644 index 0000000..718358c --- /dev/null +++ b/src/controller/balancing.rs @@ -0,0 +1,189 @@ +use http_body_util::Full; +use hyper::Response; +use hyper::body::Bytes; +use std::collections::HashMap; + +use crate::controller::utils; +use crate::controller::wallet::Wallet; +use crate::db; +use crate::queries; +use crate::templates; +use crate::validation; + +static PER_PAGE: i64 = 10; + +pub async fn table( + wallet: &Wallet, + query: queries::Balancing, +) -> Response> { + let page = query.page.unwrap_or(1); + let count = db::balancing::count(&wallet.db_conn).await; + let balancings = db::balancing::list_for_table(&wallet.db_conn, page, PER_PAGE).await; + let max_page = (count as f32 / PER_PAGE as f32).ceil() as i64; + + let context = minijinja::context!( + header => templates::Header::Balancing, + connected_user => wallet.user, + balancings => balancings, + page => page, + max_page => max_page, + highlight => query.highlight + ); + + utils::template( + &wallet.assets, + &wallet.templates, + "balancing/table.html", + context, + ) +} + +pub async fn create_form( + wallet: &Wallet, + query: queries::Balancing, +) -> Response> { + create_form_feedback(wallet, query, HashMap::new(), None).await +} + +async fn create_form_feedback( + wallet: &Wallet, + query: queries::Balancing, + form: HashMap, + error: Option, +) -> Response> { + let users = db::users::list(&wallet.db_conn).await; + + let context = minijinja::context!( + header => templates::Header::Balancing, + connected_user => wallet.user, + users => users, + query => query, + form => form, + error => error, + ); + + utils::template( + &wallet.assets, + &wallet.templates, + "balancing/create.html", + context, + ) +} + + +pub async fn create( + wallet: &Wallet, + query: queries::Balancing, + form: HashMap, +) -> Response> { + let error = |e: &str| { + create_form_feedback(wallet, query, form.clone(), Some(e.to_string())) + }; + + match validation::balancing::create(&form) { + Some(balancing) => { + match db::balancing::create(&wallet.db_conn, balancing).await { + Some(id) => { + let row = + db::balancing::get_row(&wallet.db_conn, id).await; + let page = (row - 1) / PER_PAGE + 1; + utils::redirect(&format!( + "/balancings?page={}&highlight={}", + page, id + )) + } + None => error("Erreur serveur").await, + } + } + None => error("Erreur lors de la validation du formulaire.").await, + } +} + +pub async fn update_form( + id: i64, + wallet: &Wallet, + query: queries::Balancing, +) -> Response> { + update_form_feedback(id, wallet, query, HashMap::new(), None).await +} + +async fn update_form_feedback( + id: i64, + wallet: &Wallet, + query: queries::Balancing, + form: HashMap, + error: Option, +) -> Response> { + let users = db::users::list(&wallet.db_conn).await; + let balancing = db::balancing::get(&wallet.db_conn, id).await; + + let context = minijinja::context!( + header => &templates::Header::Balancing, + connected_user => &wallet.user, + users => &users, + id => &id, + balancing => &balancing, + query => &query, + form => &form, + error => &error + ); + + utils::template( + &wallet.assets, + &wallet.templates, + "balancing/update.html", + context, + ) +} + +pub async fn update( + id: i64, + wallet: &Wallet, + query: queries::Balancing, + form: HashMap, +) -> Response> { + let error = |e: &str| { + update_form_feedback( + id, + wallet, + query, + form.clone(), + Some(e.to_string()), + ) + }; + + match validation::balancing::update(&form) { + Some(balancing) => { + if db::balancing::update(&wallet.db_conn, id, balancing).await { + let row = db::balancing::get_row(&wallet.db_conn, id).await; + let page = (row - 1) / PER_PAGE + 1; + utils::redirect(&format!( + "/balancings?page={}&highlight={}", + page, id + )) + } else { + error("Erreur serveur").await + } + } + None => error("Erreur lors de la validation du formulaire.").await, + } +} + +pub async fn delete( + id: i64, + wallet: &Wallet, + query: queries::Balancing, +) -> Response> { + if db::balancing::delete(&wallet.db_conn, id).await { + utils::redirect(&format!("/balancings?page={}", query.page.unwrap_or(1))) + } else { + update_form_feedback( + id, + wallet, + query, + HashMap::new(), + Some("Erreur serveur".to_string()), + ) + .await + } +} diff --git a/src/controller/mod.rs b/src/controller/mod.rs index e2ef561..bbead68 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -1,4 +1,5 @@ pub mod balance; +pub mod balancing; pub mod categories; pub mod error; pub mod incomes; diff --git a/src/db/balancing.rs b/src/db/balancing.rs new file mode 100644 index 0000000..1641b97 --- /dev/null +++ b/src/db/balancing.rs @@ -0,0 +1,260 @@ +use tokio_rusqlite::{Connection, Row, named_params}; + +use crate::db::utils; +use crate::model::balancing::{Balancing, Create, Update, TableRow}; + +fn row_to_balancing(row: &Row) -> Result { + Ok(Balancing { + id: row.get(0)?, + source: row.get(1)?, + destination: row.get(2)?, + amount: row.get(3)?, + }) +} + +fn row_to_table_row(row: &Row) -> Result { + Ok(TableRow { + id: row.get(0)?, + source: row.get(1)?, + destination: row.get(2)?, + amount: row.get(3)?, + }) +} + +pub async fn count(conn: &Connection) -> i64 { + let query = r#" + SELECT COUNT(*) + FROM balancing + WHERE balancing.deleted_at IS NULL + "#; + + let res = conn + .call(move |conn| { + let mut stmt = conn.prepare(query)?; + let mut iter = stmt.query_map([], |row| row.get(0))?; + utils::one::(&mut iter) + }) + .await; + + match res { + Ok(count) => count, + Err(err) => { + log::error!("Error counting balancing: {:?}", err); + 0 + } + } +} + +pub async fn list_for_table(conn: &Connection, page: i64, per_page: i64) -> Vec { + let query = r#" + SELECT + balancing.id, + users_src.name, + users_dest.name, + balancing.amount + FROM balancing + INNER JOIN users AS users_src ON users_src.id = balancing.source + INNER JOIN users AS users_dest ON users_dest.id = balancing.destination + WHERE balancing.deleted_at IS NULL + ORDER BY balancing.created_at DESC + LIMIT :limit + OFFSET :offset + "#; + + let res = conn + .call(move |conn| { + let mut stmt = conn.prepare(query)?; + + stmt.query_map( + named_params![":limit": per_page, ":offset": (page - 1) * per_page], + row_to_table_row + )? + .collect::, _>>() + }) + .await; + + match res { + Ok(xs) => xs, + Err(err) => { + log::error!("Error listing balancing: {:?}", err); + vec![] + } + } +} +pub async fn list(conn: &Connection) -> Vec { + let query = r#" + SELECT + id, + source, + destination, + amount + FROM balancing + WHERE deleted_at IS NULL + ORDER BY created_at DESC + "#; + + let res = conn + .call(move |conn| { + let mut stmt = conn.prepare(query)?; + + stmt.query_map([], row_to_balancing)? + .collect::, _>>() + }) + .await; + + match res { + Ok(xs) => xs, + Err(err) => { + log::error!("Error listing balancing: {:?}", err); + vec![] + } + } +} + +pub async fn get_row(conn: &Connection, id: i64) -> i64 { + let query = r#" + SELECT row + FROM ( + SELECT + ROW_NUMBER () OVER (ORDER BY created_at DESC) AS row, + id + FROM balancing + WHERE deleted_at IS NULL + ) + WHERE id = :id + "#; + + let res = conn + .call(move |conn| { + let mut stmt = conn.prepare(query)?; + let mut iter = + stmt.query_map(named_params![":id": id], |row| row.get(0))?; + utils::one::(&mut iter) + }) + .await; + + match res { + Ok(row) => row, + Err(err) => { + log::error!("Error getting balancing row: {:?}", err); + 1 + } + } +} + +pub async fn get(conn: &Connection, id: i64) -> Option { + let query = r#" + SELECT + id, + source, + destination, + amount + FROM balancing + WHERE + id = :id + AND deleted_at IS NULL + "#; + + let res = conn + .call(move |conn| { + let mut stmt = conn.prepare(query)?; + let mut iter = + stmt.query_map(named_params![":id": id], row_to_balancing)?; + utils::one(&mut iter) + }) + .await; + + match res { + Ok(balancing) => Some(balancing), + Err(err) => { + log::error!("Error looking for balancing {}: {:?}", id, err); + None + } + } +} + +pub async fn create(conn: &Connection, c: Create) -> Option { + let query = r#"INSERT INTO balancing(source, destination, amount) VALUES (:source, :destination, :amount)"#; + + let res: Result<_, tokio_rusqlite::Error> = conn + .call(move |conn| { + conn.execute( + query, + named_params![ + ":source": c.source, + ":destination": c.destination, + ":amount": c.amount, + ], + )?; + Ok(conn.last_insert_rowid()) + }) + .await; + + match res { + Ok(balancing_id) => Some(balancing_id), + Err(err) => { + log::error!("Error creating balancing: {:?}", err); + None + } + } +} + +pub async fn update(conn: &Connection, id: i64, c: Update) -> bool { + let query = r#" + UPDATE balancing + SET + source = :source, + destination = :destination, + amount = :amount, + updated_at = datetime() + WHERE id = :id + "#; + + let res = conn + .call(move |conn| { + conn.execute( + query, + named_params![ + ":source": c.source, + ":destination": c.destination, + ":amount": c.amount, + ":id": id + ], + ) + }) + .await; + + match res { + Ok(_) => true, + Err(err) => { + log::error!("Error updating balancing {}: {:?}", id, err); + false + } + } +} + +pub async fn delete(conn: &Connection, id: i64) -> bool { + let res = conn + .call(move |conn| { + conn.execute( + r#" + UPDATE + balancing + SET + deleted_at = datetime() + WHERE + id = :id + "#, + named_params![":id": id], + ) + }) + .await; + + match res { + Ok(_) => true, + Err(err) => { + log::error!("Error deleting balancing {}: {:?}", id, err); + false + } + } +} diff --git a/src/db/migrations/07-create-balancing-table.sql b/src/db/migrations/07-create-balancing-table.sql new file mode 100644 index 0000000..148657e --- /dev/null +++ b/src/db/migrations/07-create-balancing-table.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS "balancing"( + "id" INTEGER PRIMARY KEY, + "source" INTEGER NOT NULL REFERENCES "users", + "destination" INTEGER NOT NULL REFERENCES "users", + "amount" INTEGER NOT NULL, + "created_at" TEXT NULL DEFAULT (datetime('now')), + "updated_at" TEXT NULL, + "deleted_at" TEXT NULL +) STRICT; diff --git a/src/db/mod.rs b/src/db/mod.rs index c444995..d257bf1 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -2,6 +2,7 @@ use anyhow::{Error, Result}; use rusqlite_migration::{M, Migrations}; use tokio_rusqlite::Connection; +pub mod balancing; pub mod categories; pub mod incomes; pub mod jobs; @@ -28,6 +29,7 @@ async fn apply_migrations(conn: &Connection) -> Result<()> { M::up(include_str!("migrations/04-plural-naming.sql")), M::up(include_str!("migrations/05-strict-tables.sql")), M::up(include_str!("migrations/06-remove-weekly-report-job.sql")), + M::up(include_str!("migrations/07-create-balancing-table.sql")), ]); Ok(conn.call(move |conn| migrations.to_latest(conn)).await?) diff --git a/src/model/balancing.rs b/src/model/balancing.rs new file mode 100644 index 0000000..fe480f2 --- /dev/null +++ b/src/model/balancing.rs @@ -0,0 +1,29 @@ +#[derive(Debug, serde::Serialize)] +pub struct TableRow { + pub id: i64, + pub source: String, + pub destination: String, + pub amount: i64, +} + +#[derive(serde::Serialize, Clone)] +pub struct Balancing { + pub id: i64, + pub source: i64, + pub destination: i64, + pub amount: i64, +} + +#[derive(Debug)] +pub struct Create { + pub source: i64, + pub destination: i64, + pub amount: i64, +} + +#[derive(Debug)] +pub struct Update { + pub source: i64, + pub destination: i64, + pub amount: i64, +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 55adadd..9381103 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,3 +1,4 @@ +pub mod balancing; pub mod category; pub mod config; pub mod frequency; diff --git a/src/queries.rs b/src/queries.rs index 86a8520..519b00e 100644 --- a/src/queries.rs +++ b/src/queries.rs @@ -79,6 +79,12 @@ pub struct Categories { pub highlight: Option, } +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub struct Balancing { + pub page: Option, + pub highlight: Option, +} + #[derive(serde::Serialize, serde::Deserialize)] pub struct PaymentCategory { pub payment_name: String, diff --git a/src/routes.rs b/src/routes.rs index 8abe1b4..3afc822 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -86,6 +86,7 @@ async fn authenticated_routes( let query = uri.query(); match (method, path) { + // PAYMENTS (&Method::GET, [""]) => { controller::payments::table(&wallet, parse_query(query)).await } @@ -129,6 +130,7 @@ async fn authenticated_routes( ) .await } + // INCOMES (&Method::GET, ["incomes"]) => { controller::incomes::table(&wallet, parse_query(query)).await } @@ -168,6 +170,7 @@ async fn authenticated_routes( ) .await } + // CATEGORIES (&Method::GET, ["categories"]) => { controller::categories::table(&wallet, parse_query(query)).await } @@ -192,13 +195,57 @@ async fn authenticated_routes( (&Method::POST, ["category", id, "delete"]) => { controller::categories::delete(parse_id(id), &wallet).await } + // BALANCING + (&Method::GET, ["balancings"]) => { + controller::balancing::table(&wallet, parse_query(query)).await + } + (&Method::GET, ["balancing"]) => { + controller::balancing::create_form(&wallet, parse_query(query)).await + } + (&Method::POST, ["balancing", "create"]) => { + controller::balancing::create( + &wallet, + parse_query(query), + body_form(request).await, + ) + .await + } + (&Method::GET, ["balancing", id]) => { + controller::balancing::update_form( + parse_id(id), + &wallet, + parse_query(query), + ) + .await + } + (&Method::POST, ["balancing", id, "update"]) => { + controller::balancing::update( + parse_id(id), + &wallet, + parse_query(query), + body_form(request).await, + ) + .await + } + (&Method::POST, ["balancing", id, "delete"]) => { + controller::balancing::delete( + parse_id(id), + &wallet, + parse_query(query), + ) + .await + } + // BALANCE (&Method::GET, ["balance"]) => controller::balance::get(&wallet).await, + // STATS (&Method::GET, ["statistics"]) => { controller::statistics::get(&wallet).await } + // LOGOUT (&Method::POST, ["logout"]) => { controller::login::logout(config, &wallet).await } + // ERROR _ => controller::error::error( &wallet, "Page introuvable", diff --git a/src/templates.rs b/src/templates.rs index 5ea91b4..2c65ddb 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -8,6 +8,7 @@ pub enum Header { Payments, Categories, Incomes, + Balancing, Balance, Statistics, } diff --git a/src/validation/balancing.rs b/src/validation/balancing.rs new file mode 100644 index 0000000..8892bca --- /dev/null +++ b/src/validation/balancing.rs @@ -0,0 +1,34 @@ +use std::collections::HashMap; + +use crate::model::balancing::{Create, Update}; +use crate::validation::utils::*; + +pub fn create(form: &HashMap) -> Option { + let source = parse::(form, "source")?; + let destination = parse::(form, "destination")?; + + if source == destination { + None + } else { + Some(Create { + source, + destination, + amount: parse::(form, "amount")? + }) + } +} + +pub fn update(form: &HashMap) -> Option { + let source = parse::(form, "source")?; + let destination = parse::(form, "destination")?; + + if source == destination { + None + } else { + Some(Update { + source, + destination, + amount: parse::(form, "amount")? + }) + } +} diff --git a/src/validation/mod.rs b/src/validation/mod.rs index 181abc7..291eb4f 100644 --- a/src/validation/mod.rs +++ b/src/validation/mod.rs @@ -1,3 +1,4 @@ +pub mod balancing; pub mod category; pub mod income; pub mod login; -- cgit v1.2.3