aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorJoris Guyonvarch2026-04-18 11:04:47 +0200
committerJoris Guyonvarch2026-04-18 11:05:17 +0200
commit6d1300640051baa23360846197b54e1e69ae32e3 (patch)
tree46219dcf5b5c9e5da0920ffd966d49ba80947a9b /src
parentb35589eb90f2e5ee5521964e64eb578e9eb99032 (diff)
Add balancing capabilities
If payment are too unbalanced, it’s easier to make a transfer.
Diffstat (limited to 'src')
-rw-r--r--src/controller/balance.rs20
-rw-r--r--src/controller/balancing.rs189
-rw-r--r--src/controller/mod.rs1
-rw-r--r--src/db/balancing.rs260
-rw-r--r--src/db/migrations/07-create-balancing-table.sql9
-rw-r--r--src/db/mod.rs2
-rw-r--r--src/model/balancing.rs29
-rw-r--r--src/model/mod.rs1
-rw-r--r--src/queries.rs6
-rw-r--r--src/routes.rs47
-rw-r--r--src/templates.rs1
-rw-r--r--src/validation/balancing.rs34
-rw-r--r--src/validation/mod.rs1
13 files changed, 599 insertions, 1 deletions
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<Full<Bytes>> {
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<i64, i64>,
+ balancings: Vec<Balancing>
+) -> HashMap<i64, i64> {
+ 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<Full<Bytes>> {
+ 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<Full<Bytes>> {
+ create_form_feedback(wallet, query, HashMap::new(), None).await
+}
+
+async fn create_form_feedback(
+ wallet: &Wallet,
+ query: queries::Balancing,
+ form: HashMap<String, String>,
+ error: Option<String>,
+) -> Response<Full<Bytes>> {
+ 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<String, String>,
+) -> Response<Full<Bytes>> {
+ 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<Full<Bytes>> {
+ update_form_feedback(id, wallet, query, HashMap::new(), None).await
+}
+
+async fn update_form_feedback(
+ id: i64,
+ wallet: &Wallet,
+ query: queries::Balancing,
+ form: HashMap<String, String>,
+ error: Option<String>,
+) -> Response<Full<Bytes>> {
+ 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<String, String>,
+) -> Response<Full<Bytes>> {
+ 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<Full<Bytes>> {
+ 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<Balancing, rusqlite::Error> {
+ 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<TableRow, rusqlite::Error> {
+ 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::<i64, _>(&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<TableRow> {
+ 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::<Result<Vec<TableRow>, _>>()
+ })
+ .await;
+
+ match res {
+ Ok(xs) => xs,
+ Err(err) => {
+ log::error!("Error listing balancing: {:?}", err);
+ vec![]
+ }
+ }
+}
+pub async fn list(conn: &Connection) -> Vec<Balancing> {
+ 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::<Result<Vec<Balancing>, _>>()
+ })
+ .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::<i64, _>(&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<Balancing> {
+ 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<i64> {
+ let query = r#"INSERT INTO balancing(source, destination, amount) VALUES (:source, :destination, :amount)"#;
+
+ let res: Result<_, tokio_rusqlite::Error<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<i64>,
}
+#[derive(serde::Serialize, serde::Deserialize, Clone)]
+pub struct Balancing {
+ pub page: Option<i64>,
+ pub highlight: Option<i64>,
+}
+
#[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<String, String>) -> Option<Create> {
+ let source = parse::<i64>(form, "source")?;
+ let destination = parse::<i64>(form, "destination")?;
+
+ if source == destination {
+ None
+ } else {
+ Some(Create {
+ source,
+ destination,
+ amount: parse::<i64>(form, "amount")?
+ })
+ }
+}
+
+pub fn update(form: &HashMap<String, String>) -> Option<Update> {
+ let source = parse::<i64>(form, "source")?;
+ let destination = parse::<i64>(form, "destination")?;
+
+ if source == destination {
+ None
+ } else {
+ Some(Update {
+ source,
+ destination,
+ amount: parse::<i64>(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;