aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/assets.rs27
-rw-r--r--src/controller/balance.rs71
-rw-r--r--src/controller/categories.rs141
-rw-r--r--src/controller/error.rs31
-rw-r--r--src/controller/incomes.rs221
-rw-r--r--src/controller/login.rs86
-rw-r--r--src/controller/mod.rs9
-rw-r--r--src/controller/payments.rs227
-rw-r--r--src/controller/statistics.rs30
-rw-r--r--src/controller/utils.rs119
-rw-r--r--src/controller/wallet.rs13
-rw-r--r--src/db/categories.rs132
-rw-r--r--src/db/incomes.rs494
-rw-r--r--src/db/jobs.rs56
-rw-r--r--src/db/mod.rs6
-rw-r--r--src/db/payments.rs525
-rw-r--r--src/db/users.rs144
-rw-r--r--src/db/utils.rs3
-rw-r--r--src/jobs/jobs.rs28
-rw-r--r--src/jobs/mod.rs2
-rw-r--r--src/jobs/weekly_report.rs55
-rw-r--r--src/mail.rs59
-rw-r--r--src/main.rs88
-rw-r--r--src/model/action.rs1
-rw-r--r--src/model/category.rs20
-rw-r--r--src/model/config.rs8
-rw-r--r--src/model/frequency.rs31
-rw-r--r--src/model/income.rs40
-rw-r--r--src/model/job.rs5
-rw-r--r--src/model/login.rs5
-rw-r--r--src/model/mod.rs9
-rw-r--r--src/model/payment.rs53
-rw-r--r--src/model/report.rs16
-rw-r--r--src/model/user.rs8
-rw-r--r--src/payer.rs38
-rw-r--r--src/queries.rs62
-rw-r--r--src/routes.rs219
-rw-r--r--src/templates.rs97
-rw-r--r--src/utils/mod.rs1
-rw-r--r--src/utils/text.rs19
-rw-r--r--src/validation/category.rs18
-rw-r--r--src/validation/income.rs22
-rw-r--r--src/validation/login.rs11
-rw-r--r--src/validation/mod.rs5
-rw-r--r--src/validation/payment.rs25
-rw-r--r--src/validation/utils.rs54
46 files changed, 3334 insertions, 0 deletions
diff --git a/src/assets.rs b/src/assets.rs
new file mode 100644
index 0000000..dc46c78
--- /dev/null
+++ b/src/assets.rs
@@ -0,0 +1,27 @@
+use sha2::{Digest, Sha256};
+use std::collections::HashMap;
+use std::fs;
+use std::iter::FromIterator;
+
+pub fn get() -> HashMap<String, String> {
+ let paths = fs::read_dir("assets").unwrap().map(|e| {
+ let path = format!("{}", e.unwrap().path().display());
+ let file = fs::read(&path).unwrap();
+ let mut path_iter = path.split("/");
+ path_iter.next();
+ let name = path_iter.collect::<Vec<&str>>().join("/");
+ let hashed = format!("/assets/{}/{}", sha256(file), name);
+ (name, hashed)
+ });
+ HashMap::from_iter(paths)
+}
+
+fn sha256(input: Vec<u8>) -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(input);
+ hasher
+ .finalize()
+ .iter()
+ .map(|b| format!("{:x}", b))
+ .collect()
+}
diff --git a/src/controller/balance.rs b/src/controller/balance.rs
new file mode 100644
index 0000000..228ff04
--- /dev/null
+++ b/src/controller/balance.rs
@@ -0,0 +1,71 @@
+use hyper::{Body, Response};
+use std::collections::HashMap;
+use tera::Context;
+
+use crate::controller::utils;
+use crate::controller::wallet::Wallet;
+use crate::db;
+use crate::model::user::User;
+use crate::payer;
+use crate::templates;
+
+pub async fn get(wallet: &Wallet) -> Response<Body> {
+ let users = db::users::list(&wallet.pool).await;
+
+ let incomes_from = db::incomes::defined_for_all(&wallet.pool).await;
+ let user_incomes = match incomes_from {
+ Some(from) => db::incomes::cumulative(&wallet.pool, from).await,
+ None => HashMap::new(),
+ };
+ let template_user_incomes =
+ get_template_user_incomes(&users, &user_incomes);
+ let total_income: i64 = user_incomes.values().sum();
+
+ let user_payments = db::payments::repartition(&wallet.pool).await;
+ let template_user_payments =
+ get_template_user_payments(&users, &user_payments);
+ let total_payments: i64 =
+ user_payments.clone().into_iter().map(|p| p.1).sum();
+
+ let exceeding_payers =
+ payer::exceeding(&users, &user_incomes, &user_payments);
+
+ let mut context = Context::new();
+ context.insert("header", &templates::Header::Balance);
+ context.insert("connected_user", &wallet.user);
+ context.insert(
+ "incomes_from",
+ &incomes_from.map(|d| d.format("%d/%m/%Y").to_string()),
+ );
+ context.insert("total_income", &total_income);
+ context.insert("user_incomes", &template_user_incomes);
+ context.insert("total_payments", &total_payments);
+ context.insert("user_payments", &template_user_payments);
+ context.insert("exceeding_payers", &exceeding_payers);
+
+ utils::template(&wallet.assets, &wallet.templates, "balance.html", context)
+}
+
+fn get_template_user_payments(
+ users: &Vec<User>,
+ user_payments: &HashMap<i64, i64>,
+) -> Vec<(String, i64)> {
+ let mut user_payments: Vec<(String, i64)> = users
+ .into_iter()
+ .map(|u| (u.name.clone(), *user_payments.get(&u.id).unwrap_or(&0)))
+ .collect();
+ user_payments.sort_by_key(|i| i.1);
+ user_payments
+}
+
+fn get_template_user_incomes(
+ users: &Vec<User>,
+ user_incomes: &HashMap<i64, i64>,
+) -> Vec<(String, i64)> {
+ let mut user_incomes: Vec<(String, i64)> = users
+ .into_iter()
+ .map(|u| (u.name.clone(), *user_incomes.get(&u.id).unwrap_or(&0)))
+ .collect();
+ user_incomes.sort_by_key(|i| i.1);
+ user_incomes
+}
diff --git a/src/controller/categories.rs b/src/controller/categories.rs
new file mode 100644
index 0000000..b1a3664
--- /dev/null
+++ b/src/controller/categories.rs
@@ -0,0 +1,141 @@
+use hyper::{Body, Response};
+use std::collections::HashMap;
+use tera::Context;
+
+use crate::controller::utils;
+use crate::controller::wallet::Wallet;
+use crate::db;
+use crate::queries;
+use crate::templates;
+use crate::validation;
+
+pub async fn table(
+ wallet: &Wallet,
+ query: queries::Categories,
+) -> Response<Body> {
+ let categories = db::categories::list(&wallet.pool).await;
+
+ let mut context = Context::new();
+ context.insert("header", &templates::Header::Categories);
+ context.insert("connected_user", &wallet.user);
+ context.insert("categories", &categories);
+ context.insert("highlight", &query.highlight);
+
+ utils::template(
+ &wallet.assets,
+ &wallet.templates,
+ "category/table.html",
+ context,
+ )
+}
+
+pub async fn create_form(wallet: &Wallet) -> Response<Body> {
+ create_form_feedback(wallet, HashMap::new(), None).await
+}
+
+async fn create_form_feedback(
+ wallet: &Wallet,
+ form: HashMap<String, String>,
+ error: Option<String>,
+) -> Response<Body> {
+ let mut context = Context::new();
+ context.insert("header", &templates::Header::Categories);
+ context.insert("connected_user", &wallet.user.clone());
+ context.insert("form", &form);
+ context.insert("error", &error);
+
+ utils::template(
+ &wallet.assets,
+ &wallet.templates,
+ "category/create.html",
+ context,
+ )
+}
+
+pub async fn create(
+ wallet: &Wallet,
+ form: HashMap<String, String>,
+) -> Response<Body> {
+ let error = |e: &str| {
+ create_form_feedback(wallet, form.clone(), Some(e.to_string()))
+ };
+
+ match validation::category::create(&form) {
+ Some(category) => {
+ match db::categories::create(&wallet.pool, &category).await {
+ Some(id) => {
+ utils::redirect(&format!("/categories?highlight={}", 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) -> Response<Body> {
+ update_form_feedback(id, wallet, HashMap::new(), None).await
+}
+
+async fn update_form_feedback(
+ id: i64,
+ wallet: &Wallet,
+ form: HashMap<String, String>,
+ error: Option<String>,
+) -> Response<Body> {
+ let category = db::categories::get(&wallet.pool, id).await;
+ let is_category_used =
+ db::payments::is_category_used(&wallet.pool, id).await;
+
+ let mut context = Context::new();
+ context.insert("header", &templates::Header::Categories);
+ context.insert("connected_user", &wallet.user);
+ context.insert("id", &id);
+ context.insert("category", &category);
+ context.insert("is_category_used", &is_category_used);
+ context.insert("form", &form);
+ context.insert("error", &error);
+
+ utils::template(
+ &wallet.assets,
+ &wallet.templates,
+ "category/update.html",
+ context,
+ )
+}
+
+pub async fn update(
+ id: i64,
+ wallet: &Wallet,
+ form: HashMap<String, String>,
+) -> Response<Body> {
+ let error = |e: &str| {
+ update_form_feedback(id, wallet, form.clone(), Some(e.to_string()))
+ };
+
+ match validation::category::update(&form) {
+ Some(update_category) => {
+ if db::categories::update(&wallet.pool, id, &update_category).await
+ {
+ utils::redirect(&format!("/categories?highlight={}", id))
+ } else {
+ error("Erreur serveur").await
+ }
+ }
+ None => error("Erreur lors de la validation du formulaire.").await,
+ }
+}
+
+pub async fn delete(id: i64, wallet: &Wallet) -> Response<Body> {
+ if db::categories::delete(&wallet.pool, id).await {
+ utils::redirect("/categories")
+ } else {
+ update_form_feedback(
+ id,
+ wallet,
+ HashMap::new(),
+ Some("Erreur serveur".to_string()),
+ )
+ .await
+ }
+}
diff --git a/src/controller/error.rs b/src/controller/error.rs
new file mode 100644
index 0000000..8dad16b
--- /dev/null
+++ b/src/controller/error.rs
@@ -0,0 +1,31 @@
+use hyper::header::CACHE_CONTROL;
+use hyper::{Body, Response};
+use std::collections::HashMap;
+use tera::{Context, Tera};
+
+use crate::controller::utils;
+use crate::controller::wallet::Wallet;
+
+pub fn error(wallet: &Wallet, title: &str, message: &str) -> Response<Body> {
+ utils::with_header(
+ Response::new(
+ template(&wallet.assets, &wallet.templates, title, message).into(),
+ ),
+ CACHE_CONTROL,
+ "no-cache",
+ )
+}
+
+pub fn template(
+ assets: &HashMap<String, String>,
+ templates: &Tera,
+ title: &str,
+ message: &str,
+) -> String {
+ let mut context = Context::new();
+ context.insert("title", title);
+ context.insert("message", message);
+ context.insert("assets", assets);
+
+ templates.render("error.html", &context).unwrap()
+}
diff --git a/src/controller/incomes.rs b/src/controller/incomes.rs
new file mode 100644
index 0000000..ea7f1cf
--- /dev/null
+++ b/src/controller/incomes.rs
@@ -0,0 +1,221 @@
+use chrono::Datelike;
+use chrono::Utc;
+use hyper::{Body, Response};
+use std::collections::HashMap;
+use tera::Context;
+
+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::Incomes) -> Response<Body> {
+ let page = query.page.unwrap_or(1);
+ let count = db::incomes::count(&wallet.pool).await;
+ let incomes = db::incomes::list(&wallet.pool, page, PER_PAGE).await;
+ let max_page = (count as f32 / PER_PAGE as f32).ceil() as i64;
+
+ let mut context = Context::new();
+ context.insert("header", &templates::Header::Incomes);
+ context.insert("connected_user", &wallet.user);
+ context.insert("incomes", &incomes);
+ context.insert("page", &page);
+ context.insert("max_page", &max_page);
+ context.insert("highlight", &query.highlight);
+
+ utils::template(
+ &wallet.assets,
+ &wallet.templates,
+ "income/table.html",
+ context,
+ )
+}
+
+static MONTHS: [&str; 12] = [
+ "Janvier",
+ "Février",
+ "Mars",
+ "Avril",
+ "Mai",
+ "Juin",
+ "Juillet",
+ "Août",
+ "Septembre",
+ "Octobre",
+ "Novembre",
+ "Décembre",
+];
+
+pub async fn create_form(
+ wallet: &Wallet,
+ query: queries::Incomes,
+) -> Response<Body> {
+ create_form_feedback(wallet, query, HashMap::new(), None).await
+}
+
+async fn create_form_feedback(
+ wallet: &Wallet,
+ query: queries::Incomes,
+ form: HashMap<String, String>,
+ error: Option<String>,
+) -> Response<Body> {
+ let users = db::users::list(&wallet.pool).await;
+
+ let mut context = Context::new();
+ context.insert("header", &templates::Header::Incomes);
+ context.insert("connected_user", &wallet.user);
+ context.insert("users", &users);
+ context.insert("query", &query);
+ context.insert("current_month", &Utc::today().naive_utc().month());
+ context.insert("months", &MONTHS);
+ context.insert("form", &form);
+ context.insert("error", &error);
+
+ utils::template(
+ &wallet.assets,
+ &wallet.templates,
+ "income/create.html",
+ context,
+ )
+}
+
+pub async fn create(
+ wallet: &Wallet,
+ query: queries::Incomes,
+ form: HashMap<String, String>,
+) -> Response<Body> {
+ let error = |e: &str| {
+ create_form_feedback(wallet, query, form.clone(), Some(e.to_string()))
+ };
+
+ match validation::income::create(&form) {
+ Some(income) => {
+ if !db::incomes::defined_at(
+ &wallet.pool,
+ income.user_id,
+ income.month,
+ income.year,
+ )
+ .await
+ .is_empty()
+ {
+ error("Un revenu est déjà défini à cette date.").await
+ } else {
+ match db::incomes::create(&wallet.pool, &income).await {
+ Some(id) => {
+ let row = db::incomes::get_row(&wallet.pool, id).await;
+ let page = (row - 1) / PER_PAGE + 1;
+ utils::redirect(&format!(
+ "/incomes?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::Incomes,
+) -> Response<Body> {
+ update_form_feedback(id, wallet, query, HashMap::new(), None).await
+}
+
+async fn update_form_feedback(
+ id: i64,
+ wallet: &Wallet,
+ query: queries::Incomes,
+ form: HashMap<String, String>,
+ error: Option<String>,
+) -> Response<Body> {
+ let users = db::users::list(&wallet.pool).await;
+ let income = db::incomes::get(&wallet.pool, id).await;
+
+ let mut context = Context::new();
+ context.insert("header", &templates::Header::Incomes);
+ context.insert("connected_user", &wallet.user);
+ context.insert("users", &users);
+ context.insert("id", &id);
+ context.insert("income", &income);
+ context.insert("query", &query);
+ context.insert("months", &MONTHS);
+ context.insert("form", &form);
+ context.insert("error", &error);
+
+ utils::template(
+ &wallet.assets,
+ &wallet.templates,
+ "income/update.html",
+ context,
+ )
+}
+
+pub async fn update(
+ id: i64,
+ wallet: &Wallet,
+ query: queries::Incomes,
+ form: HashMap<String, String>,
+) -> Response<Body> {
+ let error = |e: &str| {
+ update_form_feedback(
+ id,
+ wallet,
+ query,
+ form.clone(),
+ Some(e.to_string()),
+ )
+ };
+
+ match validation::income::update(&form) {
+ Some(income) => {
+ let existing_incomes = db::incomes::defined_at(
+ &wallet.pool,
+ income.user_id,
+ income.month,
+ income.year,
+ )
+ .await;
+ if existing_incomes.into_iter().any(|eid| eid != id) {
+ error("Un revenu est déjà défini à cette date.").await
+ } else if db::incomes::update(&wallet.pool, id, &income).await {
+ let row = db::incomes::get_row(&wallet.pool, id).await;
+ let page = (row - 1) / PER_PAGE + 1;
+ utils::redirect(&format!(
+ "/incomes?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::Incomes,
+) -> Response<Body> {
+ if db::incomes::delete(&wallet.pool, id).await {
+ utils::redirect(&format!("/incomes?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/login.rs b/src/controller/login.rs
new file mode 100644
index 0000000..ea9db57
--- /dev/null
+++ b/src/controller/login.rs
@@ -0,0 +1,86 @@
+use bcrypt;
+use hyper::{Body, Response};
+use sqlx::sqlite::SqlitePool;
+use std::collections::HashMap;
+use tera::{Context, Tera};
+use uuid::Uuid;
+
+use crate::controller::wallet::Wallet;
+use crate::controller::{error, utils};
+use crate::db;
+use crate::model::config::Config;
+use crate::model::user::User;
+use crate::validation;
+
+pub async fn page(
+ assets: &HashMap<String, String>,
+ templates: &Tera,
+ error: Option<String>,
+) -> Response<Body> {
+ let connected_user: Option<User> = None;
+
+ let mut context = Context::new();
+ context.insert("connected_user", &connected_user);
+ context.insert("error", &error);
+
+ utils::template(assets, templates, "login.html", context)
+}
+
+pub async fn login(
+ config: Config,
+ assets: &HashMap<String, String>,
+ templates: &Tera,
+ form: HashMap<String, String>,
+ pool: SqlitePool,
+) -> Response<Body> {
+ let not_authorized = page(
+ assets,
+ templates,
+ Some("Vous n’êtes pas autorisé à vous connecter.".to_string()),
+ )
+ .await;
+ let server_error =
+ page(assets, templates, Some("Erreur serveur.".to_string())).await;
+ match validation::login::login(&form) {
+ Some(login) => {
+ match db::users::get_password_hash(&pool, login.email.clone()).await
+ {
+ Some(hash) => match bcrypt::verify(login.password, &hash) {
+ Ok(true) => {
+ let login_token = Uuid::new_v4();
+ if db::users::set_login_token(
+ &pool,
+ login.email,
+ login_token.clone().to_string(),
+ )
+ .await
+ {
+ utils::with_login_cookie(
+ config,
+ login_token,
+ utils::redirect("/"),
+ )
+ } else {
+ server_error
+ }
+ }
+ Ok(false) => not_authorized,
+ Err(err) => {
+ error!("Error verifying bcrypt password: {:?}", err);
+ server_error
+ }
+ },
+ None => not_authorized,
+ }
+ }
+ None => not_authorized,
+ }
+}
+
+pub async fn logout(config: Config, wallet: &Wallet) -> Response<Body> {
+ if db::users::remove_login_token(&wallet.pool, wallet.user.id).await {
+ utils::with_logout_cookie(config, utils::redirect("/"))
+ } else {
+ error::error(&wallet, "Erreur serveur", "Erreur serveur")
+ }
+}
diff --git a/src/controller/mod.rs b/src/controller/mod.rs
new file mode 100644
index 0000000..e2ef561
--- /dev/null
+++ b/src/controller/mod.rs
@@ -0,0 +1,9 @@
+pub mod balance;
+pub mod categories;
+pub mod error;
+pub mod incomes;
+pub mod login;
+pub mod payments;
+pub mod statistics;
+pub mod utils;
+pub mod wallet;
diff --git a/src/controller/payments.rs b/src/controller/payments.rs
new file mode 100644
index 0000000..ab4bd92
--- /dev/null
+++ b/src/controller/payments.rs
@@ -0,0 +1,227 @@
+use hyper::header::CONTENT_TYPE;
+use hyper::{Body, Response};
+use std::collections::HashMap;
+use tera::Context;
+
+use crate::controller::utils;
+use crate::controller::wallet::Wallet;
+use crate::db;
+use crate::model::frequency::Frequency;
+use crate::queries;
+use crate::templates;
+use crate::validation;
+
+static PER_PAGE: i64 = 10;
+
+pub async fn table(
+ wallet: &Wallet,
+ query: queries::Payments,
+) -> Response<Body> {
+ let page = query.page.unwrap_or(1);
+ let count = db::payments::count(&wallet.pool, &query).await;
+ let payments =
+ db::payments::list_for_table(&wallet.pool, &query, PER_PAGE).await;
+ let max_page = (count.count as f32 / PER_PAGE as f32).ceil() as i64;
+
+ let mut context = Context::new();
+ context.insert("header", &templates::Header::Payments);
+ context.insert("connected_user", &wallet.user);
+ context.insert("payments", &payments);
+ context.insert("page", &page);
+ context.insert("max_page", &max_page);
+ context.insert("query", &query);
+ context.insert("count", &count.count);
+ context.insert("total_cost", &count.total_cost);
+
+ utils::template(
+ &wallet.assets,
+ &wallet.templates,
+ "payment/table.html",
+ context,
+ )
+}
+
+pub async fn create_form(
+ wallet: &Wallet,
+ query: queries::Payments,
+) -> Response<Body> {
+ create_form_feedback(wallet, query, HashMap::new(), None).await
+}
+
+async fn create_form_feedback(
+ wallet: &Wallet,
+ query: queries::Payments,
+ form: HashMap<String, String>,
+ error: Option<String>,
+) -> Response<Body> {
+ let users = db::users::list(&wallet.pool).await;
+ let categories = db::categories::list(&wallet.pool).await;
+
+ let mut context = Context::new();
+ context.insert("header", &templates::Header::Payments);
+ context.insert("connected_user", &wallet.user);
+ context.insert("users", &users);
+ context.insert("categories", &categories);
+ context.insert("query", &query);
+ context.insert("form", &form);
+ context.insert("error", &error);
+
+ utils::template(
+ &wallet.assets,
+ &wallet.templates,
+ "payment/create.html",
+ context,
+ )
+}
+
+pub async fn create(
+ wallet: &Wallet,
+ query: queries::Payments,
+ form: HashMap<String, String>,
+) -> Response<Body> {
+ let error = |e: &str| {
+ create_form_feedback(wallet, query, form.clone(), Some(e.to_string()))
+ };
+
+ match validation::payment::create(&form) {
+ Some(create_payment) => {
+ match db::payments::create(&wallet.pool, &create_payment).await {
+ Some(id) => {
+ let row = db::payments::get_row(
+ &wallet.pool,
+ id,
+ create_payment.frequency,
+ )
+ .await;
+ let page = (row - 1) / PER_PAGE + 1;
+ let query = queries::Payments {
+ page: Some(page),
+ search: None,
+ frequency: Some(create_payment.frequency),
+ highlight: Some(id),
+ };
+ utils::redirect(&format!(
+ "/{}",
+ queries::payments_url(query)
+ ))
+ }
+ 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::Payments,
+) -> Response<Body> {
+ update_form_feedback(id, wallet, query, HashMap::new(), None).await
+}
+
+async fn update_form_feedback(
+ id: i64,
+ wallet: &Wallet,
+ query: queries::Payments,
+ form: HashMap<String, String>,
+ error: Option<String>,
+) -> Response<Body> {
+ let payment = db::payments::get_for_form(&wallet.pool, id).await;
+ let users = db::users::list(&wallet.pool).await;
+ let categories = db::categories::list(&wallet.pool).await;
+
+ let mut context = Context::new();
+ context.insert("header", &templates::Header::Payments);
+ context.insert("connected_user", &wallet.user);
+ context.insert("id", &id);
+ context.insert("payment", &payment);
+ context.insert("users", &users);
+ context.insert("categories", &categories);
+ context.insert("query", &query);
+ context.insert("form", &form);
+ context.insert("error", &error);
+
+ utils::template(
+ &wallet.assets,
+ &wallet.templates,
+ "payment/update.html",
+ context,
+ )
+}
+
+pub async fn update(
+ id: i64,
+ wallet: &Wallet,
+ query: queries::Payments,
+ form: HashMap<String, String>,
+) -> Response<Body> {
+ let error = |e: &str| {
+ update_form_feedback(
+ id,
+ wallet,
+ query.clone(),
+ form.clone(),
+ Some(e.to_string()),
+ )
+ };
+
+ match validation::payment::update(&form) {
+ Some(update_payment) => {
+ if db::payments::update(&wallet.pool, id, &update_payment).await {
+ let frequency = query.frequency.unwrap_or(Frequency::Punctual);
+ let row =
+ db::payments::get_row(&wallet.pool, id, frequency).await;
+ let page = (row - 1) / PER_PAGE + 1;
+ let query = queries::Payments {
+ page: Some(page),
+ search: None,
+ frequency: Some(frequency),
+ highlight: Some(id),
+ };
+ utils::redirect(&format!("/{}", queries::payments_url(query)))
+ } 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::Payments,
+) -> Response<Body> {
+ if db::payments::delete(&wallet.pool, id).await {
+ let query = queries::Payments {
+ highlight: None,
+ ..query
+ };
+ utils::redirect(&format!("/{}", queries::payments_url(query)))
+ } else {
+ update_form_feedback(
+ id,
+ wallet,
+ query,
+ HashMap::new(),
+ Some("Erreur serveur".to_string()),
+ )
+ .await
+ }
+}
+
+pub async fn search_category(
+ wallet: &Wallet,
+ query: queries::PaymentCategory,
+) -> Response<Body> {
+ match db::payments::search_category(&wallet.pool, query.payment_name).await
+ {
+ Some(category_id) => utils::with_header(
+ Response::new(format!("{}", category_id).into()),
+ CONTENT_TYPE,
+ "application/json",
+ ),
+ None => utils::not_found(),
+ }
+}
diff --git a/src/controller/statistics.rs b/src/controller/statistics.rs
new file mode 100644
index 0000000..38a5787
--- /dev/null
+++ b/src/controller/statistics.rs
@@ -0,0 +1,30 @@
+use hyper::{Body, Response};
+use tera::Context;
+
+use crate::controller::utils;
+use crate::controller::wallet::Wallet;
+use crate::db;
+use crate::templates;
+
+pub async fn get(wallet: &Wallet) -> Response<Body> {
+ let categories = db::categories::list(&wallet.pool).await;
+ let payments = db::payments::list_for_stats(&wallet.pool).await;
+ let incomes = db::incomes::total_each_month(&wallet.pool).await;
+
+ let mut context = Context::new();
+ context.insert("header", &templates::Header::Statistics);
+ context.insert("connected_user", &wallet.user);
+ context.insert(
+ "json_categories",
+ &serde_json::to_string(&categories).unwrap(),
+ );
+ context.insert("json_payments", &serde_json::to_string(&payments).unwrap());
+ context.insert("json_incomes", &serde_json::to_string(&incomes).unwrap());
+
+ utils::template(
+ &wallet.assets,
+ &wallet.templates,
+ "statistics.html",
+ context,
+ )
+}
diff --git a/src/controller/utils.rs b/src/controller/utils.rs
new file mode 100644
index 0000000..225f8a4
--- /dev/null
+++ b/src/controller/utils.rs
@@ -0,0 +1,119 @@
+use hyper::header::{
+ HeaderName, HeaderValue, CACHE_CONTROL, CONTENT_TYPE, LOCATION, SET_COOKIE,
+};
+use hyper::{Body, Response, StatusCode};
+use std::collections::HashMap;
+use tera::{Context, Tera};
+use tokio::fs::File;
+use tokio_util::codec::{BytesCodec, FramedRead};
+use uuid::Uuid;
+
+use crate::controller::error;
+use crate::model::config::Config;
+
+pub fn with_header(
+ response: Response<Body>,
+ name: HeaderName,
+ value: &str,
+) -> Response<Body> {
+ with_headers(response, vec![(name, value)])
+}
+
+pub fn with_headers(
+ response: Response<Body>,
+ headers: Vec<(HeaderName, &str)>,
+) -> Response<Body> {
+ let mut response = response;
+ let response_headers = response.headers_mut();
+ for (name, value) in headers {
+ response_headers.insert(name, HeaderValue::from_str(value).unwrap());
+ }
+ response
+}
+
+pub fn with_login_cookie(
+ config: Config,
+ login_token: Uuid,
+ response: Response<Body>,
+) -> Response<Body> {
+ let cookie = format!(
+ "TOKEN={}; SameSite=Strict; HttpOnly; Max-Age=86400{}",
+ login_token,
+ if config.secure_cookies {
+ "; Secure"
+ } else {
+ ""
+ }
+ );
+
+ with_header(response, SET_COOKIE, &cookie)
+}
+
+pub fn with_logout_cookie(
+ config: Config,
+ response: Response<Body>,
+) -> Response<Body> {
+ let cookie = format!(
+ "TOKEN=; SameSite=Strict; HttpOnly; Max-Age=0{}",
+ if config.secure_cookies {
+ "; Secure"
+ } else {
+ ""
+ }
+ );
+
+ with_header(response, SET_COOKIE, &cookie)
+}
+
+pub fn template(
+ assets: &HashMap<String, String>,
+ templates: &Tera,
+ path: &str,
+ context: Context,
+) -> Response<Body> {
+ let mut context = context.clone();
+ context.insert("assets", assets);
+
+ let response = match templates.render(path, &context) {
+ Ok(template) => Response::new(template.into()),
+ Err(err) => Response::new(
+ error::template(
+ assets,
+ templates,
+ "Erreur serveur",
+ &format!(
+ "Erreur lors de la préparation de la page : {:?}",
+ err
+ ),
+ )
+ .into(),
+ ),
+ };
+
+ with_headers(
+ response,
+ vec![(CONTENT_TYPE, "text/html"), (CACHE_CONTROL, "no-cache")],
+ )
+}
+
+pub fn redirect(uri: &str) -> Response<Body> {
+ let mut response = Response::default();
+ *response.status_mut() = StatusCode::MOVED_PERMANENTLY;
+ with_headers(response, vec![(LOCATION, uri), (CACHE_CONTROL, "no-cache")])
+}
+
+pub fn not_found() -> Response<Body> {
+ let mut response = Response::default();
+ *response.status_mut() = StatusCode::NOT_FOUND;
+ response
+}
+
+pub async fn file(filename: &str) -> Response<Body> {
+ if let Ok(file) = File::open(filename).await {
+ let stream = FramedRead::new(file, BytesCodec::new());
+ let body = Body::wrap_stream(stream);
+ with_header(Response::new(body), CACHE_CONTROL, "max-age=3153600000")
+ } else {
+ not_found()
+ }
+}
diff --git a/src/controller/wallet.rs b/src/controller/wallet.rs
new file mode 100644
index 0000000..2a4a593
--- /dev/null
+++ b/src/controller/wallet.rs
@@ -0,0 +1,13 @@
+use sqlx::sqlite::SqlitePool;
+use std::collections::HashMap;
+use tera::Tera;
+
+use crate::model::user::User;
+
+#[derive(Clone)]
+pub struct Wallet {
+ pub pool: SqlitePool,
+ pub assets: HashMap<String, String>,
+ pub templates: Tera,
+ pub user: User,
+}
diff --git a/src/db/categories.rs b/src/db/categories.rs
new file mode 100644
index 0000000..05b1323
--- /dev/null
+++ b/src/db/categories.rs
@@ -0,0 +1,132 @@
+use sqlx::sqlite::SqlitePool;
+
+use crate::model::category::{Category, Create, Update};
+
+pub async fn list(pool: &SqlitePool) -> Vec<Category> {
+ let res = sqlx::query_as::<_, Category>(
+ r#"
+SELECT
+ id,
+ name,
+ color
+FROM
+ categories
+WHERE
+ deleted_at IS NULL
+ORDER BY
+ name
+ "#,
+ )
+ .fetch_all(pool)
+ .await;
+
+ match res {
+ Ok(categories) => categories,
+ Err(err) => {
+ error!("Error listing categories: {:?}", err);
+ vec![]
+ }
+ }
+}
+
+pub async fn get(pool: &SqlitePool, id: i64) -> Option<Category> {
+ let query = r#"
+SELECT
+ id,
+ name,
+ color
+FROM
+ categories
+WHERE
+ id = ?
+ AND deleted_at IS NULL
+ "#;
+
+ let res = sqlx::query_as::<_, Category>(query)
+ .bind(id)
+ .fetch_one(pool)
+ .await;
+
+ match res {
+ Ok(p) => Some(p),
+ Err(err) => {
+ error!("Error looking for category {}: {:?}", id, err);
+ None
+ }
+ }
+}
+
+pub async fn create(pool: &SqlitePool, c: &Create) -> Option<i64> {
+ let res = sqlx::query(
+ r#"
+INSERT INTO
+ categories(name, color, created_at)
+VALUES
+ (?, ?, datetime())
+ "#,
+ )
+ .bind(c.name.clone())
+ .bind(c.color.clone())
+ .execute(pool)
+ .await;
+
+ match res {
+ Ok(x) => Some(x.last_insert_rowid()),
+ Err(err) => {
+ error!("Error creating category: {:?}", err);
+ None
+ }
+ }
+}
+
+pub async fn update(pool: &SqlitePool, id: i64, c: &Update) -> bool {
+ let res = sqlx::query(
+ r#"
+UPDATE
+ categories
+SET
+ name = ?,
+ color = ?,
+ updated_at = datetime()
+WHERE
+ id = ?
+ "#,
+ )
+ .bind(c.name.clone())
+ .bind(c.color.clone())
+ .bind(id)
+ .execute(pool)
+ .await;
+
+ match res {
+ Ok(_) => true,
+ Err(err) => {
+ error!("Error updating category {}: {:?}", id, err);
+ false
+ }
+ }
+}
+
+pub async fn delete(pool: &SqlitePool, id: i64) -> bool {
+ let res = sqlx::query(
+ r#"
+UPDATE
+ categories
+SET
+ deleted_at = datetime()
+WHERE
+ id = ?
+ "#,
+ )
+ .bind(id)
+ .execute(pool)
+ .await;
+
+ match res {
+ Ok(_) => true,
+ Err(err) => {
+ error!("Error deleting category {}: {:?}", id, err);
+ false
+ }
+ }
+}
diff --git a/src/db/incomes.rs b/src/db/incomes.rs
new file mode 100644
index 0000000..cbbfce7
--- /dev/null
+++ b/src/db/incomes.rs
@@ -0,0 +1,494 @@
+use chrono::NaiveDate;
+use sqlx::error::Error;
+use sqlx::sqlite::{SqlitePool, SqliteRow};
+use sqlx_core::row::Row;
+use std::collections::HashMap;
+use std::iter::FromIterator;
+
+use crate::model::income::{Create, Form, Stat, Table, Update};
+use crate::model::report::Report;
+
+pub async fn count(pool: &SqlitePool) -> i64 {
+ let query = r#"
+SELECT
+ COUNT(*) AS count
+FROM
+ incomes
+WHERE
+ incomes.deleted_at IS NULL
+ "#;
+
+ let res = sqlx::query(&query)
+ .map(|row: SqliteRow| row.get("count"))
+ .fetch_one(pool)
+ .await;
+
+ match res {
+ Ok(count) => count,
+ Err(err) => {
+ error!("Error counting incomes: {:?}", err);
+ 0
+ }
+ }
+}
+
+pub async fn list(pool: &SqlitePool, page: i64, per_page: i64) -> Vec<Table> {
+ let query = r#"
+SELECT
+ incomes.id,
+ users.name AS user,
+ strftime('%m/%Y', incomes.date) AS date,
+ incomes.amount
+FROM
+ incomes
+INNER JOIN
+ users
+ON
+ incomes.user_id = users.id
+WHERE
+ incomes.deleted_at IS NULL
+ORDER BY
+ incomes.date DESC
+LIMIT ?
+OFFSET ?
+ "#;
+
+ let res = sqlx::query_as::<_, Table>(query)
+ .bind(per_page)
+ .bind((page - 1) * per_page)
+ .fetch_all(pool)
+ .await;
+
+ match res {
+ Ok(incomes) => incomes,
+ Err(err) => {
+ error!("Error listing incomes: {:?}", err);
+ vec![]
+ }
+ }
+}
+
+pub async fn get_row(pool: &SqlitePool, id: i64) -> i64 {
+ let query = r#"
+SELECT
+ row
+FROM (
+ SELECT
+ ROW_NUMBER () OVER (ORDER BY date DESC) AS row,
+ id
+ FROM
+ incomes
+ WHERE
+ deleted_at IS NULL
+)
+WHERE
+ id = ?
+ "#;
+
+ let res = sqlx::query(query)
+ .bind(id)
+ .map(|row: SqliteRow| row.get("row"))
+ .fetch_one(pool)
+ .await;
+
+ match res {
+ Ok(count) => count,
+ Err(err) => {
+ error!("Error getting income row: {:?}", err);
+ 1
+ }
+ }
+}
+
+pub async fn get(pool: &SqlitePool, id: i64) -> Option<Form> {
+ let query = r#"
+SELECT
+ id,
+ amount,
+ user_id,
+ CAST(strftime('%m', date) AS INTEGER) as month,
+ CAST(strftime('%Y', date) AS INTEGER) as year
+FROM
+ incomes
+WHERE
+ id = ?
+ AND deleted_at IS NULL
+ "#;
+
+ let res = sqlx::query_as::<_, Form>(query)
+ .bind(id)
+ .fetch_one(pool)
+ .await;
+
+ match res {
+ Ok(p) => Some(p),
+ Err(err) => {
+ error!("Error looking for income {}: {:?}", id, err);
+ None
+ }
+ }
+}
+
+pub async fn create(pool: &SqlitePool, i: &Create) -> Option<i64> {
+ let res = sqlx::query(
+ r#"
+INSERT INTO
+ incomes(user_id, date, amount, created_at)
+VALUES
+ (?, ?, ?, datetime())
+ "#,
+ )
+ .bind(i.user_id)
+ .bind(NaiveDate::from_ymd(i.year, i.month, 1))
+ .bind(i.amount)
+ .execute(pool)
+ .await;
+
+ match res {
+ Ok(x) => Some(x.last_insert_rowid()),
+ Err(err) => {
+ error!("Error creating income: {:?}", err);
+ None
+ }
+ }
+}
+
+pub async fn defined_at(
+ pool: &SqlitePool,
+ user_id: i64,
+ month: u32,
+ year: i32,
+) -> Vec<i64> {
+ let query = r#"
+SELECT
+ id
+FROM
+ incomes
+WHERE
+ user_id = ?
+ AND date = ?
+ AND deleted_at IS NULL
+ "#;
+
+ let res = sqlx::query(&query)
+ .bind(user_id)
+ .bind(NaiveDate::from_ymd(year, month, 1))
+ .map(|row: SqliteRow| row.get("id"))
+ .fetch_all(pool)
+ .await;
+
+ match res {
+ Ok(ids) => ids,
+ Err(Error::RowNotFound) => vec![],
+ Err(err) => {
+ error!("Error looking if income is defined: {:?}", err);
+ vec![]
+ }
+ }
+}
+
+pub async fn update(pool: &SqlitePool, id: i64, i: &Update) -> bool {
+ let res = sqlx::query(
+ r#"
+UPDATE
+ incomes
+SET
+ user_id = ?,
+ date = ?,
+ amount = ?,
+ updated_at = datetime()
+WHERE
+ id = ?
+ "#,
+ )
+ .bind(i.user_id)
+ .bind(NaiveDate::from_ymd(i.year, i.month, 1))
+ .bind(i.amount)
+ .bind(id)
+ .execute(pool)
+ .await;
+
+ match res {
+ Ok(_) => true,
+ Err(err) => {
+ error!("Error updating income {}: {:?}", id, err);
+ false
+ }
+ }
+}
+
+pub async fn delete(pool: &SqlitePool, id: i64) -> bool {
+ let res = sqlx::query(
+ r#"
+UPDATE
+ incomes
+SET
+ deleted_at = datetime()
+WHERE
+ id = ?
+ "#,
+ )
+ .bind(id)
+ .execute(pool)
+ .await;
+
+ match res {
+ Ok(_) => true,
+ Err(err) => {
+ error!("Error deleting income {}: {:?}", id, err);
+ false
+ }
+ }
+}
+
+pub async fn defined_for_all(pool: &SqlitePool) -> Option<NaiveDate> {
+ let res = sqlx::query(
+ r#"
+SELECT
+ (CASE COUNT(users.id) == COUNT(min_income.date)
+ WHEN 1 THEN MIN(min_income.date)
+ ELSE NULL
+ END) AS date
+FROM
+ users
+LEFT OUTER JOIN (
+ SELECT
+ user_id,
+ MIN(date) AS date
+ FROM
+ incomes
+ WHERE
+ deleted_at IS NULL
+ GROUP BY
+ user_id
+) min_income
+ON
+ users.id = min_income.user_id;
+ "#,
+ )
+ .map(|row: SqliteRow| row.get("date"))
+ .fetch_one(pool)
+ .await;
+
+ match res {
+ Ok(d) => d,
+ Err(err) => {
+ error!("Error looking for incomes defined for all: {:?}", err);
+ None
+ }
+ }
+}
+
+pub async fn cumulative(
+ pool: &SqlitePool,
+ from: NaiveDate,
+) -> HashMap<i64, i64> {
+ let res = sqlx::query(&cumulative_query(from))
+ .map(|row: SqliteRow| (row.get("user_id"), row.get("income")))
+ .fetch_all(pool)
+ .await;
+
+ match res {
+ Ok(incomes) => HashMap::from_iter(incomes),
+ Err(err) => {
+ error!("Error computing cumulative income: {:?}", err);
+ HashMap::new()
+ }
+ }
+}
+
+/// Select cumulative income of users from the given date and until now.
+///
+/// Associate each month income to its start and end bounds,
+/// then compute the total income of each period,
+/// sum it to get the final result.
+///
+/// Considering each month to be 365 / 12 days long.
+fn cumulative_query(from: NaiveDate) -> String {
+ format!(
+ r#"
+SELECT
+ users.id AS user_id,
+ COALESCE(incomes.income, 0) AS income
+FROM
+ users
+LEFT OUTER JOIN (
+ SELECT
+ user_id,
+ CAST(ROUND(SUM(count)) AS INTEGER) AS income
+ FROM (
+ SELECT
+ I1.user_id,
+ ((JULIANDAY(MIN(I2.date)) - JULIANDAY(I1.date)) * I1.amount * 12 / 365) AS count
+ FROM
+ ({}) AS I1
+ INNER JOIN
+ ({}) AS I2
+ ON
+ I2.date > I1.date
+ AND I2.user_id == I1.user_id
+ GROUP BY
+ I1.date, I1.user_id
+ )
+ GROUP BY
+ user_id
+) incomes
+ON
+ users.id = incomes.user_id
+ "#,
+ bounded_query(">".to_string(), from.format("%Y-%m-%d").to_string()),
+ bounded_query("<".to_string(), "date()".to_string())
+ )
+}
+
+/// Select bounded incomes to the operator and date.
+///
+/// It filters incomes according to the operator and date,
+/// and adds the income at this date.
+fn bounded_query(op: String, date: String) -> String {
+ format!(
+ r#"
+SELECT
+ user_id,
+ date,
+ amount
+FROM (
+ SELECT
+ user_id,
+ {} AS date,
+ amount,
+ MAX(date) AS max_date
+ FROM
+ incomes
+ WHERE
+ date <= {}
+ AND deleted_at IS NULL
+ GROUP BY
+ user_id
+) UNION
+SELECT
+ user_id,
+ date,
+ amount
+FROM
+ incomes
+WHERE
+ date {} {}
+ AND deleted_at IS NULL
+ "#,
+ date, date, op, date
+ )
+}
+
+/// Select total income each month.
+///
+/// For each month, from the first defined income and until now,
+/// compute the total income of the users.
+pub async fn total_each_month(pool: &SqlitePool) -> Vec<Stat> {
+ let query = r#"
+WITH RECURSIVE dates(date) AS (
+ VALUES((
+ SELECT
+ strftime('%Y-%m-01', MIN(date))
+ FROM
+ incomes
+ WHERE
+ deleted_at IS NULL
+ ))
+ UNION ALL
+ SELECT
+ date(date, '+1 month')
+ FROM
+ dates
+ WHERE
+ date < date(date(), '-1 month')
+)
+SELECT
+ strftime('%Y-%m-01', dates.date) AS date,
+ (
+ SELECT
+ SUM(amount) AS amount
+ FROM (
+ SELECT (
+ SELECT
+ amount
+ FROM
+ incomes
+ WHERE
+ user_id = users.id
+ AND date < date(dates.date, '+1 month')
+ AND deleted_at IS NULL
+ ORDER BY
+ date DESC
+ LIMIT
+ 1
+ ) AS amount
+ FROM
+ users
+ )
+ ) AS amount
+FROM
+ dates;
+ "#;
+
+ let res = sqlx::query_as::<_, Stat>(query).fetch_all(pool).await;
+
+ match res {
+ Ok(xs) => xs,
+ Err(err) => {
+ error!("Error listing incomes for statistics: {:?}", err);
+ vec![]
+ }
+ }
+}
+
+pub async fn last_week(pool: &SqlitePool) -> Vec<Report> {
+ let query = r#"
+SELECT
+ strftime('%m/%Y', incomes.date) AS date,
+ users.name AS name,
+ incomes.amount AS amount,
+ (CASE
+ WHEN
+ incomes.deleted_at IS NOT NULL
+ THEN
+ 'Deleted'
+ WHEN
+ incomes.updated_at IS NOT NULL
+ AND incomes.created_at < date('now', 'weekday 0', '-13 days')
+ THEN
+ 'Updated'
+ ELSE
+ 'Created'
+ END) AS action
+FROM
+ incomes
+INNER JOIN
+ users
+ON
+ incomes.user_id = users.id
+WHERE
+ (
+ incomes.created_at >= date('now', 'weekday 0', '-13 days')
+ AND incomes.created_at < date('now', 'weekday 0', '-6 days')
+ ) OR (
+ incomes.updated_at >= date('now', 'weekday 0', '-13 days')
+ AND incomes.updated_at < date('now', 'weekday 0', '-6 days')
+ ) OR (
+ incomes.deleted_at >= date('now', 'weekday 0', '-13 days')
+ AND incomes.deleted_at < date('now', 'weekday 0', '-6 days')
+ )
+ORDER BY
+ incomes.date
+ "#;
+
+ let res = sqlx::query_as::<_, Report>(query).fetch_all(pool).await;
+
+ match res {
+ Ok(payments) => payments,
+ Err(err) => {
+ error!("Error listing payments for report: {:?}", err);
+ vec![]
+ }
+ }
+}
diff --git a/src/db/jobs.rs b/src/db/jobs.rs
new file mode 100644
index 0000000..88c2005
--- /dev/null
+++ b/src/db/jobs.rs
@@ -0,0 +1,56 @@
+use sqlx::error::Error;
+use sqlx::sqlite::SqlitePool;
+
+use crate::model::job::Job;
+
+pub async fn should_run(pool: &SqlitePool, job: Job) -> bool {
+ let run_from = match job {
+ Job::WeeklyReport => "date('now', 'weekday 0', '-6 days')",
+ Job::MonthlyPayment => "date('now', 'start of month')",
+ };
+
+ let query = format!(
+ r#"
+SELECT
+ 1
+FROM
+ jobs
+WHERE
+ name = ?
+ AND last_execution < {}
+ "#,
+ run_from
+ );
+
+ let res = sqlx::query(&query).bind(job).fetch_one(pool).await;
+
+ match res {
+ Ok(_) => true,
+ Err(Error::RowNotFound) => false,
+ Err(err) => {
+ error!("Error looking if job should run: {:?}", err);
+ false
+ }
+ }
+}
+
+pub async fn actualize_last_execution(pool: &SqlitePool, job: Job) -> () {
+ let query = r#"
+UPDATE
+ jobs
+SET
+ last_execution = datetime()
+WHERE
+ name = ?
+ "#;
+
+ let res = sqlx::query(query).bind(job).execute(pool).await;
+
+ match res {
+ Ok(_) => (),
+ Err(err) => {
+ error!("Error actualizing job last execution: {:?}", err);
+ ()
+ }
+ }
+}
diff --git a/src/db/mod.rs b/src/db/mod.rs
new file mode 100644
index 0000000..a0aa3dc
--- /dev/null
+++ b/src/db/mod.rs
@@ -0,0 +1,6 @@
+pub mod categories;
+pub mod incomes;
+pub mod jobs;
+pub mod payments;
+pub mod users;
+mod utils;
diff --git a/src/db/payments.rs b/src/db/payments.rs
new file mode 100644
index 0000000..0197375
--- /dev/null
+++ b/src/db/payments.rs
@@ -0,0 +1,525 @@
+use sqlx::error::Error;
+use sqlx::sqlite::{Sqlite, SqliteArguments};
+use sqlx::sqlite::{SqlitePool, SqliteRow};
+use sqlx::FromRow;
+use sqlx_core::row::Row;
+use std::collections::HashMap;
+use std::iter::FromIterator;
+
+use crate::db::utils;
+use crate::model::frequency::Frequency;
+use crate::model::payment;
+use crate::model::report::Report;
+use crate::queries;
+use crate::utils::text;
+
+#[derive(FromRow)]
+pub struct Count {
+ pub count: i64,
+ pub total_cost: i64,
+}
+
+pub async fn count(
+ pool: &SqlitePool,
+ payment_query: &queries::Payments,
+) -> Count {
+ let search = payment_query.search.clone().unwrap_or("".to_string());
+
+ let query = format!(
+ r#"
+SELECT
+ COUNT(*) AS count,
+ SUM(payments.cost) AS total_cost
+FROM
+ payments
+INNER JOIN
+ users ON users.id = payments.user_id
+INNER JOIN
+ categories ON categories.id = payments.category_id
+WHERE
+ payments.deleted_at IS NULL
+ AND payments.frequency = ?
+ {}
+ "#,
+ search_query(search.clone())
+ );
+
+ let res = bind_search(
+ sqlx::query_as::<_, Count>(&query)
+ .bind(payment_query.frequency.unwrap_or(Frequency::Punctual)),
+ search,
+ )
+ .fetch_one(pool)
+ .await;
+
+ match res {
+ Ok(count) => count,
+ Err(err) => {
+ error!("Error counting payments: {:?}", err);
+ Count {
+ count: 0,
+ total_cost: 0,
+ }
+ }
+ }
+}
+
+pub async fn list_for_table(
+ pool: &SqlitePool,
+ payment_query: &queries::Payments,
+ per_page: i64,
+) -> Vec<payment::Table> {
+ let offset = (payment_query.page.unwrap_or(1) - 1) * per_page;
+ let search = payment_query.search.clone().unwrap_or("".to_string());
+
+ let query = format!(
+ r#"
+SELECT
+ payments.id,
+ payments.name,
+ payments.cost,
+ users.name AS user,
+ categories.name AS category_name,
+ categories.color AS category_color,
+ strftime('%d/%m/%Y', date) AS date,
+ payments.frequency AS frequency
+FROM
+ payments
+INNER JOIN
+ users ON users.id = payments.user_id
+INNER JOIN
+ categories ON categories.id = payments.category_id
+WHERE
+ payments.deleted_at IS NULL
+ AND payments.frequency = ?
+ {}
+ORDER BY
+ payments.date DESC
+LIMIT ?
+OFFSET ?
+ "#,
+ search_query(search.clone())
+ );
+
+ let res = bind_search(
+ sqlx::query_as::<_, payment::Table>(&query)
+ .bind(payment_query.frequency.unwrap_or(Frequency::Punctual)),
+ search,
+ )
+ .bind(per_page)
+ .bind(offset)
+ .fetch_all(pool)
+ .await;
+
+ match res {
+ Ok(payments) => payments,
+ Err(err) => {
+ error!("Error listing payments: {:?}", err);
+ vec![]
+ }
+ }
+}
+
+fn search_query(search: String) -> String {
+ let payments_name = utils::format_key_for_search("payments.name");
+ let users_name = utils::format_key_for_search("users.name");
+ let categories_name = utils::format_key_for_search("categories.name");
+
+ search
+ .split_ascii_whitespace()
+ .map(|_| {
+ format!(
+ r#"
+AND (
+ {} LIKE ?
+ OR payments.cost LIKE ?
+ OR {} LIKE ?
+ OR {} LIKE ?
+ OR strftime('%d/%m/%Y', date) LIKE ?
+)
+ "#,
+ payments_name, users_name, categories_name
+ )
+ })
+ .collect::<Vec<String>>()
+ .join(" ")
+}
+
+fn bind_search<'a, Row: FromRow<'a, SqliteRow>>(
+ query: sqlx::query::QueryAs<'a, Sqlite, Row, SqliteArguments<'a>>,
+ search: String,
+) -> sqlx::query::QueryAs<'a, Sqlite, Row, SqliteArguments<'a>> {
+ search.split_ascii_whitespace().fold(query, |q, word| {
+ let s = format!("%{}%", text::format_search(&word.to_string()));
+ q.bind(s.clone())
+ .bind(s.clone())
+ .bind(s.clone())
+ .bind(s.clone())
+ .bind(s.clone())
+ })
+}
+
+pub async fn list_for_stats(pool: &SqlitePool) -> Vec<payment::Stat> {
+ let query = r#"
+SELECT
+ strftime('%Y-%m-01', payments.date) AS start_date,
+ SUM(payments.cost) AS cost,
+ payments.category_id AS category_id
+FROM
+ payments
+WHERE
+ payments.deleted_at IS NULL
+ AND payments.frequency = 'Punctual'
+GROUP BY
+ start_date,
+ payments.category_id;
+ "#;
+
+ let result = sqlx::query_as::<_, payment::Stat>(query)
+ .fetch_all(pool)
+ .await;
+
+ match result {
+ Ok(payments) => payments,
+ Err(err) => {
+ error!("Error listing payments for statistics: {:?}", err);
+ vec![]
+ }
+ }
+}
+
+pub async fn get_row(pool: &SqlitePool, id: i64, frequency: Frequency) -> i64 {
+ let query = r#"
+SELECT
+ row
+FROM (
+ SELECT
+ ROW_NUMBER () OVER (ORDER BY date DESC) AS row,
+ id
+ FROM
+ payments
+ WHERE
+ deleted_at IS NULL
+ AND frequency = ?
+)
+WHERE
+ id = ?
+ "#;
+
+ let res = sqlx::query(query)
+ .bind(frequency)
+ .bind(id)
+ .map(|row: SqliteRow| row.get("row"))
+ .fetch_one(pool)
+ .await;
+
+ match res {
+ Ok(count) => count,
+ Err(err) => {
+ error!("Error getting payment row: {:?}", err);
+ 1
+ }
+ }
+}
+
+pub async fn get_for_form(pool: &SqlitePool, id: i64) -> Option<payment::Form> {
+ let query = r#"
+SELECT
+ id,
+ name,
+ cost,
+ user_id,
+ category_id,
+ strftime('%Y-%m-%d', date) AS date,
+ frequency AS frequency
+FROM
+ payments
+WHERE
+ id = ?
+ AND deleted_at IS NULL
+ "#;
+
+ let res = sqlx::query_as::<_, payment::Form>(query)
+ .bind(id)
+ .fetch_one(pool)
+ .await;
+
+ match res {
+ Ok(p) => Some(p),
+ Err(err) => {
+ error!("Error looking for payment {}: {:?}", id, err);
+ None
+ }
+ }
+}
+
+pub async fn create(pool: &SqlitePool, p: &payment::Create) -> Option<i64> {
+ let res = sqlx::query(
+ r#"
+INSERT INTO
+ payments(name, cost, user_id, category_id, date, frequency, created_at)
+VALUES
+ (?, ?, ?, ?, ?, ?, datetime())
+ "#,
+ )
+ .bind(p.name.clone())
+ .bind(p.cost)
+ .bind(p.user_id)
+ .bind(p.category_id)
+ .bind(p.date)
+ .bind(p.frequency)
+ .execute(pool)
+ .await;
+
+ match res {
+ Ok(x) => Some(x.last_insert_rowid()),
+ Err(err) => {
+ error!("Error creating payment: {:?}", err);
+ None
+ }
+ }
+}
+
+pub async fn update(pool: &SqlitePool, id: i64, p: &payment::Update) -> bool {
+ let res = sqlx::query(
+ r#"
+UPDATE
+ payments
+SET
+ name = ?,
+ cost = ?,
+ user_id = ?,
+ category_id = ?,
+ date = ?,
+ updated_at = datetime()
+WHERE
+ id = ?
+ "#,
+ )
+ .bind(p.name.clone())
+ .bind(p.cost)
+ .bind(p.user_id)
+ .bind(p.category_id)
+ .bind(p.date)
+ .bind(id)
+ .execute(pool)
+ .await;
+
+ match res {
+ Ok(_) => true,
+ Err(err) => {
+ error!("Error updating payment {}: {:?}", id, err);
+ false
+ }
+ }
+}
+
+pub async fn delete(pool: &SqlitePool, id: i64) -> bool {
+ let res = sqlx::query(
+ r#"
+UPDATE
+ payments
+SET
+ deleted_at = datetime()
+WHERE
+ id = ?
+ "#,
+ )
+ .bind(id)
+ .execute(pool)
+ .await;
+
+ match res {
+ Ok(_) => true,
+ Err(err) => {
+ error!("Error deleting payment {}: {:?}", id, err);
+ false
+ }
+ }
+}
+
+pub async fn search_category(
+ pool: &SqlitePool,
+ payment_name: String,
+) -> Option<i64> {
+ let query = format!(
+ r#"
+SELECT
+ category_id
+FROM
+ payments
+WHERE
+ deleted_at IS NULL
+ AND {} LIKE ?
+ORDER BY
+ updated_at, created_at
+ "#,
+ utils::format_key_for_search("name")
+ );
+
+ let res = sqlx::query(&query)
+ .bind(text::format_search(&format!("%{}%", payment_name)))
+ .map(|row: SqliteRow| row.get("category_id"))
+ .fetch_one(pool)
+ .await;
+
+ match res {
+ Ok(category) => Some(category),
+ Err(Error::RowNotFound) => None,
+ Err(err) => {
+ error!(
+ "Error looking for the category of {}: {:?}",
+ payment_name, err
+ );
+ None
+ }
+ }
+}
+
+pub async fn is_category_used(pool: &SqlitePool, category_id: i64) -> bool {
+ let query = r#"
+SELECT
+ 1
+FROM
+ payments
+WHERE
+ category_id = ?
+ AND deleted_at IS NULL
+LIMIT
+ 1
+ "#;
+
+ let res = sqlx::query(&query).bind(category_id).fetch_one(pool).await;
+
+ match res {
+ Ok(_) => true,
+ Err(Error::RowNotFound) => false,
+ Err(err) => {
+ error!(
+ "Error looking if category {} is used: {:?}",
+ category_id, err
+ );
+ false
+ }
+ }
+}
+
+pub async fn repartition(pool: &SqlitePool) -> HashMap<i64, i64> {
+ let query = r#"
+SELECT
+ users.id AS user_id,
+ COALESCE(payments.sum, 0) AS sum
+FROM
+ users
+LEFT OUTER JOIN (
+ SELECT
+ user_id,
+ SUM(cost) AS sum
+ FROM
+ payments
+ WHERE
+ deleted_at IS NULL
+ AND frequency = 'Punctual'
+ GROUP BY
+ user_id
+) payments
+ON
+ users.id = payments.user_id"#;
+
+ let res = sqlx::query(&query)
+ .map(|row: SqliteRow| (row.get("user_id"), row.get("sum")))
+ .fetch_all(pool)
+ .await;
+
+ match res {
+ Ok(costs) => HashMap::from_iter(costs),
+ Err(err) => {
+ error!("Error getting payments repartition: {:?}", err);
+ HashMap::new()
+ }
+ }
+}
+
+pub async fn create_monthly_payments(pool: &SqlitePool) -> () {
+ let query = r#"
+INSERT INTO
+ payments(name, cost, user_id, category_id, date, frequency, created_at)
+SELECT
+ name,
+ cost,
+ user_id,
+ category_id,
+ date() AS date,
+ 'Punctual' AS frequency,
+ datetime() AS created_at
+FROM
+ payments
+WHERE
+ frequency = 'Monthly'
+ AND deleted_at IS NULL
+ "#;
+
+ let res = sqlx::query(query).execute(pool).await;
+
+ match res {
+ Ok(_) => (),
+ Err(err) => {
+ error!("Error creating monthly payments: {:?}", err);
+ ()
+ }
+ }
+}
+
+pub async fn last_week(pool: &SqlitePool) -> Vec<Report> {
+ let query = r#"
+SELECT
+ strftime('%d/%m/%Y', payments.date) AS date,
+ (payments.name || ' (' || users.name || ')') AS name,
+ payments.cost AS amount,
+ (CASE
+ WHEN
+ payments.deleted_at IS NOT NULL
+ THEN
+ 'Deleted'
+ WHEN
+ payments.updated_at IS NOT NULL
+ AND payments.created_at < date('now', 'weekday 0', '-13 days')
+ THEN
+ 'Updated'
+ ELSE
+ 'Created'
+ END) AS action
+FROM
+ payments
+INNER JOIN
+ users
+ON
+ payments.user_id = users.id
+WHERE
+ payments.frequency = 'Punctual'
+ AND (
+ (
+ payments.created_at >= date('now', 'weekday 0', '-13 days')
+ AND payments.created_at < date('now', 'weekday 0', '-6 days')
+ ) OR (
+ payments.updated_at >= date('now', 'weekday 0', '-13 days')
+ AND payments.updated_at < date('now', 'weekday 0', '-6 days')
+ ) OR (
+ payments.deleted_at >= date('now', 'weekday 0', '-13 days')
+ AND payments.deleted_at < date('now', 'weekday 0', '-6 days')
+ )
+ )
+ORDER BY
+ payments.date
+ "#;
+
+ let res = sqlx::query_as::<_, Report>(query).fetch_all(pool).await;
+
+ match res {
+ Ok(payments) => payments,
+ Err(err) => {
+ error!("Error listing payments for report: {:?}", err);
+ vec![]
+ }
+ }
+}
diff --git a/src/db/users.rs b/src/db/users.rs
new file mode 100644
index 0000000..82326a9
--- /dev/null
+++ b/src/db/users.rs
@@ -0,0 +1,144 @@
+use sqlx::error::Error;
+use sqlx::sqlite::{SqlitePool, SqliteRow};
+use sqlx_core::row::Row;
+
+use crate::model::user::User;
+
+pub async fn list(pool: &SqlitePool) -> Vec<User> {
+ let res = sqlx::query_as::<_, User>(
+ r#"
+SELECT
+ id,
+ name,
+ email
+FROM
+ users
+ORDER BY
+ name
+ "#,
+ )
+ .fetch_all(pool)
+ .await;
+
+ match res {
+ Ok(users) => users,
+ Err(err) => {
+ error!("Error listing users: {:?}", err);
+ vec![]
+ }
+ }
+}
+
+pub async fn set_login_token(
+ pool: &SqlitePool,
+ email: String,
+ login_token: String,
+) -> bool {
+ let res = sqlx::query(
+ r#"
+UPDATE
+ users
+SET
+ login_token = ?,
+ updated_at = datetime()
+WHERE
+ email = ?
+ "#,
+ )
+ .bind(login_token)
+ .bind(email)
+ .execute(pool)
+ .await;
+
+ match res {
+ Ok(_) => true,
+ Err(err) => {
+ error!("Error updating login token: {:?}", err);
+ false
+ }
+ }
+}
+
+pub async fn remove_login_token(pool: &SqlitePool, id: i64) -> bool {
+ let res = sqlx::query(
+ r#"
+UPDATE
+ users
+SET
+ login_token = NULL,
+ updated_at = datetime()
+WHERE
+ id = ?
+ "#,
+ )
+ .bind(id)
+ .execute(pool)
+ .await;
+
+ match res {
+ Ok(_) => true,
+ Err(err) => {
+ error!("Error removing login token: {:?}", err);
+ false
+ }
+ }
+}
+
+pub async fn get_by_login_token(
+ pool: &SqlitePool,
+ login_token: String,
+) -> Option<User> {
+ let res = sqlx::query_as::<_, User>(
+ r#"
+SELECT
+ id,
+ name,
+ email
+FROM
+ users
+WHERE
+ login_token = ?
+ "#,
+ )
+ .bind(login_token)
+ .fetch_one(pool)
+ .await;
+
+ match res {
+ Ok(user) => Some(user),
+ Err(Error::RowNotFound) => None,
+ Err(err) => {
+ error!("Error getting user from login token: {:?}", err);
+ None
+ }
+ }
+}
+
+pub async fn get_password_hash(
+ pool: &SqlitePool,
+ email: String,
+) -> Option<String> {
+ let res = sqlx::query(
+ r#"
+SELECT
+ password
+FROM
+ users
+WHERE
+ email = ?
+ "#,
+ )
+ .bind(email)
+ .map(|row: SqliteRow| row.get("password"))
+ .fetch_one(pool)
+ .await;
+
+ match res {
+ Ok(hash) => Some(hash),
+ Err(Error::RowNotFound) => None,
+ Err(err) => {
+ error!("Error getting password hash: {:?}", err);
+ None
+ }
+ }
+}
diff --git a/src/db/utils.rs b/src/db/utils.rs
new file mode 100644
index 0000000..621a69c
--- /dev/null
+++ b/src/db/utils.rs
@@ -0,0 +1,3 @@
+pub fn format_key_for_search(value: &str) -> String {
+ format!("replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(replace(lower({}), 'à', 'a'), 'â', 'a'), 'ç', 'c'), 'è', 'e'), 'é', 'e'), 'ê', 'e'), 'ë', 'e'), 'î', 'i'), 'ï', 'i'), 'ô', 'o'), 'ù', 'u'), 'û', 'u'), 'ü', 'u')", value)
+}
diff --git a/src/jobs/jobs.rs b/src/jobs/jobs.rs
new file mode 100644
index 0000000..3e54624
--- /dev/null
+++ b/src/jobs/jobs.rs
@@ -0,0 +1,28 @@
+use sqlx::sqlite::SqlitePool;
+use tera::Tera;
+use tokio::time::{delay_for, Duration};
+
+use crate::db;
+use crate::jobs::weekly_report;
+use crate::model::config::Config;
+use crate::model::job::Job;
+
+pub async fn start(config: Config, pool: SqlitePool, templates: Tera) -> () {
+ loop {
+ if db::jobs::should_run(&pool, Job::WeeklyReport).await {
+ info!("Starting weekly report job");
+ if weekly_report::send(&config, &pool, &templates).await {
+ db::jobs::actualize_last_execution(&pool, Job::WeeklyReport)
+ .await;
+ }
+ }
+ if db::jobs::should_run(&pool, Job::MonthlyPayment).await {
+ info!("Starting monthly payment job");
+ db::payments::create_monthly_payments(&pool).await;
+ db::jobs::actualize_last_execution(&pool, Job::MonthlyPayment)
+ .await;
+ }
+ // Sleeping 8 hours
+ delay_for(Duration::from_secs(8 * 60 * 60)).await;
+ }
+}
diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs
new file mode 100644
index 0000000..be2ddac
--- /dev/null
+++ b/src/jobs/mod.rs
@@ -0,0 +1,2 @@
+pub mod jobs;
+mod weekly_report;
diff --git a/src/jobs/weekly_report.rs b/src/jobs/weekly_report.rs
new file mode 100644
index 0000000..819d30b
--- /dev/null
+++ b/src/jobs/weekly_report.rs
@@ -0,0 +1,55 @@
+use sqlx::sqlite::SqlitePool;
+use std::collections::HashMap;
+use tera::{Context, Tera};
+
+use crate::db;
+use crate::mail;
+use crate::model::config::Config;
+use crate::payer;
+
+pub async fn send(
+ config: &Config,
+ pool: &SqlitePool,
+ templates: &Tera,
+) -> bool {
+ match get_weekly_report(pool, templates).await {
+ Ok(report) => {
+ let users = db::users::list(pool).await;
+ mail::send(
+ config,
+ users.into_iter().map(|u| (u.email, u.name)).collect(),
+ "Budget — rapport hebdomadaire".to_string(),
+ report,
+ )
+ }
+ Err(err) => {
+ error!("Error preparing weekly report from template: {:?}", err);
+ false
+ }
+ }
+}
+
+async fn get_weekly_report(
+ pool: &SqlitePool,
+ templates: &Tera,
+) -> Result<String, tera::Error> {
+ let users = db::users::list(pool).await;
+ let incomes_from = db::incomes::defined_for_all(pool).await;
+ let user_incomes = match incomes_from {
+ Some(from) => db::incomes::cumulative(pool, from).await,
+ None => HashMap::new(),
+ };
+ let user_payments = db::payments::repartition(pool).await;
+ let exceeding_payers =
+ payer::exceeding(&users, &user_incomes, &user_payments);
+
+ let last_week_payments = db::payments::last_week(pool).await;
+ let last_week_incomes = db::incomes::last_week(pool).await;
+
+ let mut context = Context::new();
+ context.insert("exceeding_payers", &exceeding_payers);
+ context.insert("payments", &last_week_payments);
+ context.insert("incomes", &last_week_incomes);
+
+ templates.render("report/report.j2", &context)
+}
diff --git a/src/mail.rs b/src/mail.rs
new file mode 100644
index 0000000..d86cff3
--- /dev/null
+++ b/src/mail.rs
@@ -0,0 +1,59 @@
+use lettre::sendmail::SendmailTransport;
+use lettre::{SendableEmail, Transport};
+use lettre_email::Email;
+
+use crate::model::config::Config;
+
+static FROM: &str = "contact@guyonvarch.me";
+
+pub fn send(
+ config: &Config,
+ to: Vec<(String, String)>,
+ subject: String,
+ message: String,
+) -> bool {
+ match prepare_email(to.clone(), subject.clone(), message.clone()) {
+ Ok(email) => {
+ if config.mock_mails {
+ let formatted_to = to
+ .into_iter()
+ .map(|t| t.0)
+ .collect::<Vec<String>>()
+ .join(", ");
+ info!(
+ "MOCK MAIL\nto: {}\nsubject: {}\n\n{}",
+ formatted_to, subject, message
+ );
+ true
+ } else {
+ let mut sender =
+ SendmailTransport::new_with_command(&config.sendmail_path);
+ match sender.send(email) {
+ Ok(_) => true,
+ Err(err) => {
+ error!("Error sending email: {:?}", err);
+ false
+ }
+ }
+ }
+ }
+ Err(err) => {
+ error!("Error preparing email: {:?}", err);
+ false
+ }
+ }
+}
+
+fn prepare_email(
+ to: Vec<(String, String)>,
+ subject: String,
+ message: String,
+) -> Result<SendableEmail, lettre_email::error::Error> {
+ let mut email = Email::builder().from(FROM).subject(subject).text(message);
+
+ for (address, name) in to.iter() {
+ email = email.to((address, name));
+ }
+
+ email.build().map(|e| e.into())
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..502f608
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,88 @@
+use hyper::service::{make_service_fn, service_fn};
+use hyper::Server;
+use sqlx::sqlite::SqlitePool;
+use std::convert::Infallible;
+use std::net::SocketAddr;
+use structopt::StructOpt;
+
+#[macro_use]
+extern crate log;
+
+mod assets;
+mod controller;
+mod db;
+mod jobs;
+mod mail;
+mod model;
+mod payer;
+mod queries;
+mod routes;
+mod templates;
+mod utils;
+mod validation;
+
+use model::config::Config;
+
+#[derive(StructOpt)]
+#[structopt()]
+struct Opt {
+ #[structopt(short, long, default_value = "127.0.0.1:3000")]
+ address: SocketAddr,
+
+ #[structopt(short, long, default_value = "config.json")]
+ config: String,
+
+ #[structopt(short, long, default_value = "database.db")]
+ database: String,
+}
+
+#[tokio::main]
+async fn main() {
+ env_logger::Builder::from_env(
+ env_logger::Env::default().default_filter_or("warn"),
+ )
+ .init();
+
+ let opt = Opt::from_args();
+
+ let config_str = std::fs::read_to_string(&opt.config)
+ .expect(&format!("Missing {}", opt.config));
+ let config: Config = serde_json::from_str(&config_str).unwrap();
+
+ let pool = SqlitePool::connect(&format!("sqlite:{}", opt.database))
+ .await
+ .unwrap();
+
+ let assets = assets::get();
+
+ let templates = templates::get();
+
+ tokio::spawn(jobs::jobs::start(
+ config.clone(),
+ pool.clone(),
+ templates.clone(),
+ ));
+
+ let make_svc = make_service_fn(|_conn| {
+ let config = config.clone();
+ let pool = pool.clone();
+ let assets = assets.clone();
+ let templates = templates.clone();
+ async move {
+ Ok::<_, Infallible>(service_fn(move |req| {
+ routes::routes(
+ config.clone(),
+ pool.clone(),
+ assets.clone(),
+ templates.clone(),
+ req,
+ )
+ }))
+ }
+ });
+
+ info!("Starting server at {}", opt.address);
+ if let Err(e) = Server::bind(&opt.address).serve(make_svc).await {
+ error!("server error: {}", e);
+ }
+}
diff --git a/src/model/action.rs b/src/model/action.rs
new file mode 100644
index 0000000..a77543a
--- /dev/null
+++ b/src/model/action.rs
@@ -0,0 +1 @@
+use serde::Serialize;
diff --git a/src/model/category.rs b/src/model/category.rs
new file mode 100644
index 0000000..de08dea
--- /dev/null
+++ b/src/model/category.rs
@@ -0,0 +1,20 @@
+use serde::Serialize;
+
+#[derive(sqlx::FromRow, Serialize)]
+pub struct Category {
+ pub id: i64,
+ pub name: String,
+ pub color: String,
+}
+
+#[derive(Debug)]
+pub struct Create {
+ pub name: String,
+ pub color: String,
+}
+
+#[derive(Debug)]
+pub struct Update {
+ pub name: String,
+ pub color: String,
+}
diff --git a/src/model/config.rs b/src/model/config.rs
new file mode 100644
index 0000000..8d304e5
--- /dev/null
+++ b/src/model/config.rs
@@ -0,0 +1,8 @@
+use serde::Deserialize;
+
+#[derive(Clone, Deserialize)]
+pub struct Config {
+ pub secure_cookies: bool,
+ pub mock_mails: bool,
+ pub sendmail_path: String,
+}
diff --git a/src/model/frequency.rs b/src/model/frequency.rs
new file mode 100644
index 0000000..bb83e27
--- /dev/null
+++ b/src/model/frequency.rs
@@ -0,0 +1,31 @@
+use serde::{Deserialize, Serialize};
+use std::{fmt, str};
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, sqlx::Type)]
+pub enum Frequency {
+ Punctual,
+ Monthly,
+}
+
+impl fmt::Display for Frequency {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Frequency::Punctual => write!(f, "Punctual"),
+ Frequency::Monthly => write!(f, "Monthly"),
+ }
+ }
+}
+
+pub struct ParseFrequencyError;
+
+impl str::FromStr for Frequency {
+ type Err = ParseFrequencyError;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s {
+ "Punctual" => Ok(Frequency::Punctual),
+ "Monthly" => Ok(Frequency::Monthly),
+ _ => Err(ParseFrequencyError {}),
+ }
+ }
+}
diff --git a/src/model/income.rs b/src/model/income.rs
new file mode 100644
index 0000000..7bc888f
--- /dev/null
+++ b/src/model/income.rs
@@ -0,0 +1,40 @@
+use serde::Serialize;
+
+#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
+pub struct Stat {
+ pub date: String,
+ pub amount: i64,
+}
+
+#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
+pub struct Table {
+ pub id: i64,
+ pub date: String,
+ pub user: String,
+ pub amount: i64,
+}
+
+#[derive(Debug, Clone, sqlx::FromRow, Serialize)]
+pub struct Form {
+ pub id: i64,
+ pub amount: i64,
+ pub user_id: i64,
+ pub month: i64,
+ pub year: i64,
+}
+
+#[derive(Debug)]
+pub struct Create {
+ pub user_id: i64,
+ pub amount: i64,
+ pub month: u32,
+ pub year: i32,
+}
+
+#[derive(Debug)]
+pub struct Update {
+ pub user_id: i64,
+ pub amount: i64,
+ pub month: u32,
+ pub year: i32,
+}
diff --git a/src/model/job.rs b/src/model/job.rs
new file mode 100644
index 0000000..74151ae
--- /dev/null
+++ b/src/model/job.rs
@@ -0,0 +1,5 @@
+#[derive(Debug, sqlx::Type)]
+pub enum Job {
+ MonthlyPayment,
+ WeeklyReport,
+}
diff --git a/src/model/login.rs b/src/model/login.rs
new file mode 100644
index 0000000..c7a10ba
--- /dev/null
+++ b/src/model/login.rs
@@ -0,0 +1,5 @@
+#[derive(Debug)]
+pub struct Login {
+ pub email: String,
+ pub password: String,
+}
diff --git a/src/model/mod.rs b/src/model/mod.rs
new file mode 100644
index 0000000..fb07721
--- /dev/null
+++ b/src/model/mod.rs
@@ -0,0 +1,9 @@
+pub mod category;
+pub mod config;
+pub mod frequency;
+pub mod income;
+pub mod job;
+pub mod login;
+pub mod payment;
+pub mod report;
+pub mod user;
diff --git a/src/model/payment.rs b/src/model/payment.rs
new file mode 100644
index 0000000..5ce6bb9
--- /dev/null
+++ b/src/model/payment.rs
@@ -0,0 +1,53 @@
+use chrono::NaiveDate;
+use serde::Serialize;
+
+use crate::model::frequency::Frequency;
+
+#[derive(Debug, sqlx::FromRow, Serialize)]
+pub struct Table {
+ pub id: i64,
+ pub name: String,
+ pub cost: i64,
+ pub user: String,
+ pub category_name: String,
+ pub category_color: String,
+ pub date: String,
+ pub frequency: Frequency,
+}
+
+#[derive(Debug, sqlx::FromRow, Serialize)]
+pub struct Form {
+ pub id: i64,
+ pub name: String,
+ pub cost: i64,
+ pub user_id: i64,
+ pub category_id: i64,
+ pub date: String,
+ pub frequency: Frequency,
+}
+
+#[derive(Debug, sqlx::FromRow, Serialize)]
+pub struct Stat {
+ pub start_date: String,
+ pub cost: i64,
+ pub category_id: i64,
+}
+
+#[derive(Debug)]
+pub struct Create {
+ pub name: String,
+ pub cost: i64,
+ pub user_id: i64,
+ pub category_id: i64,
+ pub date: NaiveDate,
+ pub frequency: Frequency,
+}
+
+#[derive(Debug)]
+pub struct Update {
+ pub name: String,
+ pub cost: i64,
+ pub user_id: i64,
+ pub category_id: i64,
+ pub date: NaiveDate,
+}
diff --git a/src/model/report.rs b/src/model/report.rs
new file mode 100644
index 0000000..4858402
--- /dev/null
+++ b/src/model/report.rs
@@ -0,0 +1,16 @@
+use serde::Serialize;
+
+#[derive(Debug, sqlx::FromRow, Serialize)]
+pub struct Report {
+ pub date: String,
+ pub name: String,
+ pub amount: i64,
+ pub action: Action,
+}
+
+#[derive(Debug, PartialEq, Serialize, sqlx::Type)]
+pub enum Action {
+ Created,
+ Updated,
+ Deleted,
+}
diff --git a/src/model/user.rs b/src/model/user.rs
new file mode 100644
index 0000000..e8a61bf
--- /dev/null
+++ b/src/model/user.rs
@@ -0,0 +1,8 @@
+use serde::Serialize;
+
+#[derive(Debug, sqlx::FromRow, Clone, Serialize)]
+pub struct User {
+ pub id: i64,
+ pub name: String,
+ pub email: String,
+}
diff --git a/src/payer.rs b/src/payer.rs
new file mode 100644
index 0000000..48cee52
--- /dev/null
+++ b/src/payer.rs
@@ -0,0 +1,38 @@
+use std::collections::HashMap;
+
+use crate::model::user::User;
+
+pub fn exceeding(
+ users: &Vec<User>,
+ user_incomes: &HashMap<i64, i64>,
+ user_payments: &HashMap<i64, i64>,
+) -> Vec<(String, i64)> {
+ let ratios = users.into_iter().map(|u| {
+ let income = *user_incomes.get(&u.id).unwrap_or(&0);
+ if income == 0 {
+ (u.name.clone(), 0, 0.0)
+ } else {
+ let payments = *user_payments.get(&u.id).unwrap_or(&0);
+ let ratio = payments as f64 / income as f64;
+ (u.name.clone(), income, ratio)
+ }
+ });
+ let min_ratio = ratios
+ .clone()
+ .map(|r| r.2)
+ .min_by(|r1, r2| {
+ ((r1 * 100_000_000.0).round() as i64)
+ .cmp(&((r2 * 100_000_000.0).round() as i64))
+ })
+ .unwrap_or(0.0);
+ ratios
+ .filter_map(|r| {
+ let exceeding = ((r.2 - min_ratio) * r.1 as f64).round() as i64;
+ if exceeding == 0 {
+ None
+ } else {
+ Some((r.0, exceeding))
+ }
+ })
+ .collect()
+}
diff --git a/src/queries.rs b/src/queries.rs
new file mode 100644
index 0000000..a7ba28c
--- /dev/null
+++ b/src/queries.rs
@@ -0,0 +1,62 @@
+use crate::model::frequency::Frequency;
+use serde::{Deserialize, Serialize};
+
+#[derive(Deserialize, Serialize, Clone)]
+pub struct Payments {
+ pub page: Option<i64>,
+ pub search: Option<String>,
+ pub frequency: Option<Frequency>,
+ pub highlight: Option<i64>,
+}
+
+pub fn payments_url(q: Payments) -> String {
+ let mut params = Vec::new();
+
+ match q.page {
+ None | Some(1) => (),
+ Some(p) => params.push(format!("page={}", p)),
+ };
+
+ match q.search {
+ Some(s) => {
+ if !s.is_empty() {
+ params.push(format!("search={}", s));
+ }
+ }
+ _ => (),
+ };
+
+ match q.frequency {
+ Some(Frequency::Monthly) => {
+ params.push("frequency=Monthly".to_string())
+ }
+ _ => (),
+ };
+
+ match q.highlight {
+ Some(id) => params.push(format!("highlight={}", id)),
+ _ => (),
+ };
+
+ if params.is_empty() {
+ "".to_string()
+ } else {
+ format!("?{}", params.join("&"))
+ }
+}
+
+#[derive(Deserialize, Serialize, Clone)]
+pub struct Incomes {
+ pub page: Option<i64>,
+ pub highlight: Option<i64>,
+}
+
+#[derive(Deserialize, Serialize, Clone)]
+pub struct Categories {
+ pub highlight: Option<i64>,
+}
+
+#[derive(Deserialize, Serialize)]
+pub struct PaymentCategory {
+ pub payment_name: String,
+}
diff --git a/src/routes.rs b/src/routes.rs
new file mode 100644
index 0000000..3d76ab1
--- /dev/null
+++ b/src/routes.rs
@@ -0,0 +1,219 @@
+use hyper::{Body, Method, Request, Response};
+use serde::Deserialize;
+use serde_urlencoded;
+use sqlx::sqlite::SqlitePool;
+use std::collections::HashMap;
+use std::convert::Infallible;
+use tera::Tera;
+use url::form_urlencoded;
+
+use crate::controller;
+use crate::controller::wallet::Wallet;
+use crate::db;
+use crate::model::config::Config;
+use crate::model::user::User;
+
+pub async fn routes(
+ config: Config,
+ pool: SqlitePool,
+ assets: HashMap<String, String>,
+ templates: Tera,
+ request: Request<Body>,
+) -> Result<Response<Body>, Infallible> {
+ let method = request.method();
+ let uri = request.uri();
+ let path = &uri.path().split('/').collect::<Vec<&str>>()[1..];
+
+ let response = match (method, path) {
+ (&Method::GET, ["login"]) => {
+ controller::login::page(&assets, &templates, None).await
+ }
+ (&Method::POST, ["login"]) => {
+ controller::login::login(
+ config,
+ &assets,
+ &templates,
+ body_form(request).await,
+ pool,
+ )
+ .await
+ }
+ (&Method::GET, ["assets", _, file]) => {
+ controller::utils::file(&format!("assets/{}", file)).await
+ }
+ _ => match connected_user(&pool, &request).await {
+ Some(user) => {
+ let wallet = Wallet {
+ pool,
+ assets,
+ templates,
+ user,
+ };
+ authenticated_routes(config, wallet, request).await
+ }
+ None => controller::utils::redirect("/login"),
+ },
+ };
+
+ Ok(response)
+}
+
+async fn connected_user(
+ pool: &SqlitePool,
+ request: &Request<Body>,
+) -> Option<User> {
+ let cookie = request.headers().get("COOKIE")?.to_str().ok()?;
+ let mut xs = cookie.split('=');
+ xs.next();
+ let login_token = xs.next()?;
+ db::users::get_by_login_token(&pool, login_token.to_string()).await
+}
+
+async fn authenticated_routes(
+ config: Config,
+ wallet: Wallet,
+ request: Request<Body>,
+) -> Response<Body> {
+ let method = request.method();
+ let uri = request.uri();
+ let path = &uri.path().split('/').collect::<Vec<&str>>()[1..];
+ let query = uri.query();
+
+ match (method, path) {
+ (&Method::GET, [""]) => {
+ controller::payments::table(&wallet, parse_query(query)).await
+ }
+ (&Method::GET, ["payment"]) => {
+ controller::payments::create_form(&wallet, parse_query(query)).await
+ }
+ (&Method::POST, ["payment", "create"]) => {
+ controller::payments::create(
+ &wallet,
+ parse_query(query),
+ body_form(request).await,
+ )
+ .await
+ }
+ (&Method::GET, ["payment", "category"]) => {
+ controller::payments::search_category(&wallet, parse_query(query))
+ .await
+ }
+ (&Method::GET, ["payment", id]) => {
+ controller::payments::update_form(
+ parse_id(id),
+ &wallet,
+ parse_query(query),
+ )
+ .await
+ }
+ (&Method::POST, ["payment", id, "update"]) => {
+ controller::payments::update(
+ parse_id(id),
+ &wallet,
+ parse_query(query),
+ body_form(request).await,
+ )
+ .await
+ }
+ (&Method::POST, ["payment", id, "delete"]) => {
+ controller::payments::delete(
+ parse_id(id),
+ &wallet,
+ parse_query(query),
+ )
+ .await
+ }
+ (&Method::GET, ["incomes"]) => {
+ controller::incomes::table(&wallet, parse_query(query)).await
+ }
+ (&Method::GET, ["income"]) => {
+ controller::incomes::create_form(&wallet, parse_query(query)).await
+ }
+ (&Method::POST, ["income", "create"]) => {
+ controller::incomes::create(
+ &wallet,
+ parse_query(query),
+ body_form(request).await,
+ )
+ .await
+ }
+ (&Method::GET, ["income", id]) => {
+ controller::incomes::update_form(
+ parse_id(id),
+ &wallet,
+ parse_query(query),
+ )
+ .await
+ }
+ (&Method::POST, ["income", id, "update"]) => {
+ controller::incomes::update(
+ parse_id(id),
+ &wallet,
+ parse_query(query),
+ body_form(request).await,
+ )
+ .await
+ }
+ (&Method::POST, ["income", id, "delete"]) => {
+ controller::incomes::delete(
+ parse_id(id),
+ &wallet,
+ parse_query(query),
+ )
+ .await
+ }
+ (&Method::GET, ["categories"]) => {
+ controller::categories::table(&wallet, parse_query(query)).await
+ }
+ (&Method::GET, ["category"]) => {
+ controller::categories::create_form(&wallet).await
+ }
+ (&Method::POST, ["category", "create"]) => {
+ controller::categories::create(&wallet, body_form(request).await)
+ .await
+ }
+ (&Method::GET, ["category", id]) => {
+ controller::categories::update_form(parse_id(id), &wallet).await
+ }
+ (&Method::POST, ["category", id, "update"]) => {
+ controller::categories::update(
+ parse_id(id),
+ &wallet,
+ body_form(request).await,
+ )
+ .await
+ }
+ (&Method::POST, ["category", id, "delete"]) => {
+ controller::categories::delete(parse_id(id), &wallet).await
+ }
+ (&Method::GET, ["balance"]) => controller::balance::get(&wallet).await,
+ (&Method::GET, ["statistics"]) => {
+ controller::statistics::get(&wallet).await
+ }
+ (&Method::POST, ["logout"]) => {
+ controller::login::logout(config, &wallet).await
+ }
+ _ => controller::error::error(
+ &wallet,
+ "Page introuvable",
+ "La page que recherchez n’existe pas.",
+ ),
+ }
+}
+
+fn parse_query<'a, T: Deserialize<'a>>(query: Option<&'a str>) -> T {
+ serde_urlencoded::from_str(query.unwrap_or("")).unwrap()
+}
+
+async fn body_form(request: Request<Body>) -> HashMap<String, String> {
+ match hyper::body::to_bytes(request).await {
+ Ok(bytes) => form_urlencoded::parse(bytes.as_ref())
+ .into_owned()
+ .collect::<HashMap<String, String>>(),
+ Err(_) => HashMap::new(),
+ }
+}
+
+fn parse_id(str: &str) -> i64 {
+ str.parse::<i64>().unwrap()
+}
diff --git a/src/templates.rs b/src/templates.rs
new file mode 100644
index 0000000..7e3753a
--- /dev/null
+++ b/src/templates.rs
@@ -0,0 +1,97 @@
+use serde::Serialize;
+use serde_json::json;
+use serde_json::value::Value;
+use std::collections::HashMap;
+use tera::Tera;
+use tera::{Error, Result};
+
+use crate::queries;
+
+#[derive(Debug, Serialize)]
+pub enum Header {
+ Payments,
+ Categories,
+ Incomes,
+ Balance,
+ Statistics,
+}
+
+pub fn get() -> Tera {
+ let mut tera = match Tera::new("templates/**/*") {
+ Ok(t) => t,
+ Err(e) => {
+ error!("Parsing error(s): {}", e);
+ ::std::process::exit(1);
+ }
+ };
+ tera.register_function("payments_params", payments_params);
+ tera.register_filter("numeric", numeric);
+ tera.register_filter("euros", euros);
+ tera
+}
+
+fn payments_params(args: &HashMap<String, Value>) -> Result<Value> {
+ let q = json!({
+ "page": args.get("page"),
+ "search": args.get("search"),
+ "frequency": args.get("frequency"),
+ "highlight": args.get("highlight"),
+ });
+
+ match serde_json::from_value(q) {
+ Ok(q) => Ok(json!(queries::payments_url(q))),
+ Err(msg) => Err(Error::msg(msg)),
+ }
+}
+
+fn euros(value: &Value, _: &HashMap<String, Value>) -> Result<Value> {
+ match value {
+ Value::Number(n) => {
+ if let Some(n) = n.as_i64() {
+ let str = rgrouped(n.abs().to_string(), 3).join(" ");
+ let sign = if n < 0 { "-" } else { "" };
+ Ok(json!(format!("{}{} €", sign, str)))
+ } else if let Some(n) = n.as_f64() {
+ Ok(json!(format!("{} €", n.to_string())))
+ } else {
+ Err(Error::msg("Error parsing number"))
+ }
+ }
+ _ => Err(Error::msg(format!("{:?} should be a number", value))),
+ }
+}
+
+fn numeric(value: &Value, _: &HashMap<String, Value>) -> Result<Value> {
+ match value {
+ Value::Number(n) => {
+ if let Some(n) = n.as_i64() {
+ let str = rgrouped(n.abs().to_string(), 3).join(" ");
+ let sign = if n < 0 { "-" } else { "" };
+ Ok(json!(format!("{}{}", sign, str)))
+ } else if let Some(n) = n.as_f64() {
+ Ok(json!(format!("{}", n.to_string())))
+ } else {
+ Err(Error::msg("Error parsing number"))
+ }
+ }
+ _ => Err(Error::msg(format!("{:?} should be a number", value))),
+ }
+}
+
+fn rgrouped(str: String, n: usize) -> Vec<String> {
+ let mut str = str.clone();
+ let mut l = str.len();
+ let mut res = vec![];
+ while l > n {
+ let str2 = str.clone();
+ let (start, end) = str2.split_at(l - n);
+ l -= n;
+ str = start.to_string();
+ res.push(end.to_string());
+ }
+ if !str.is_empty() {
+ res.push(str);
+ }
+ res.reverse();
+ res
+}
diff --git a/src/utils/mod.rs b/src/utils/mod.rs
new file mode 100644
index 0000000..481c63a
--- /dev/null
+++ b/src/utils/mod.rs
@@ -0,0 +1 @@
+pub mod text;
diff --git a/src/utils/text.rs b/src/utils/text.rs
new file mode 100644
index 0000000..c07ccee
--- /dev/null
+++ b/src/utils/text.rs
@@ -0,0 +1,19 @@
+pub fn format_search(str: &String) -> String {
+ unaccent(&str.to_lowercase())
+}
+
+pub fn unaccent(str: &String) -> String {
+ str.chars().map(unaccent_char).collect()
+}
+
+pub fn unaccent_char(c: char) -> char {
+ match c {
+ 'à' | 'â' => 'a',
+ 'ç' => 'c',
+ 'è' | 'é' | 'ê' | 'ë' => 'e',
+ 'î' | 'ï' => 'i',
+ 'ô' => 'o',
+ 'ù' | 'û' | 'ü' => 'u',
+ _ => c,
+ }
+}
diff --git a/src/validation/category.rs b/src/validation/category.rs
new file mode 100644
index 0000000..7b1b5d5
--- /dev/null
+++ b/src/validation/category.rs
@@ -0,0 +1,18 @@
+use std::collections::HashMap;
+
+use crate::model::category::{Create, Update};
+use crate::validation::utils::*;
+
+pub fn create(form: &HashMap<String, String>) -> Option<Create> {
+ Some(Create {
+ name: non_empty(form, "name")?,
+ color: color(form, "color")?,
+ })
+}
+
+pub fn update(form: &HashMap<String, String>) -> Option<Update> {
+ Some(Update {
+ name: non_empty(form, "name")?,
+ color: color(form, "color")?,
+ })
+}
diff --git a/src/validation/income.rs b/src/validation/income.rs
new file mode 100644
index 0000000..972e42a
--- /dev/null
+++ b/src/validation/income.rs
@@ -0,0 +1,22 @@
+use std::collections::HashMap;
+
+use crate::model::income::{Create, Update};
+use crate::validation::utils::*;
+
+pub fn create(form: &HashMap<String, String>) -> Option<Create> {
+ Some(Create {
+ user_id: parse::<i64>(form, "user_id")?,
+ amount: parse::<i64>(form, "amount")?,
+ month: parse::<u32>(form, "month")?,
+ year: parse::<i32>(form, "year")?,
+ })
+}
+
+pub fn update(form: &HashMap<String, String>) -> Option<Update> {
+ Some(Update {
+ user_id: parse::<i64>(form, "user_id")?,
+ amount: parse::<i64>(form, "amount")?,
+ month: parse::<u32>(form, "month")?,
+ year: parse::<i32>(form, "year")?,
+ })
+}
diff --git a/src/validation/login.rs b/src/validation/login.rs
new file mode 100644
index 0000000..e40bb23
--- /dev/null
+++ b/src/validation/login.rs
@@ -0,0 +1,11 @@
+use std::collections::HashMap;
+
+use crate::model::login::Login;
+use crate::validation::utils::*;
+
+pub fn login(form: &HashMap<String, String>) -> Option<Login> {
+ Some(Login {
+ email: non_empty(form, "email")?,
+ password: non_empty(form, "password")?,
+ })
+}
diff --git a/src/validation/mod.rs b/src/validation/mod.rs
new file mode 100644
index 0000000..181abc7
--- /dev/null
+++ b/src/validation/mod.rs
@@ -0,0 +1,5 @@
+pub mod category;
+pub mod income;
+pub mod login;
+pub mod payment;
+pub mod utils;
diff --git a/src/validation/payment.rs b/src/validation/payment.rs
new file mode 100644
index 0000000..36aa852
--- /dev/null
+++ b/src/validation/payment.rs
@@ -0,0 +1,25 @@
+use std::collections::HashMap;
+
+use crate::model::payment::{Create, Update};
+use crate::validation::utils::*;
+
+pub fn create(form: &HashMap<String, String>) -> Option<Create> {
+ Some(Create {
+ name: non_empty(form, "name")?,
+ cost: parse::<i64>(form, "cost")?,
+ user_id: parse::<i64>(form, "user_id")?,
+ category_id: parse::<i64>(form, "category_id")?,
+ date: date(form, "date")?,
+ frequency: frequency(form, "frequency")?,
+ })
+}
+
+pub fn update(form: &HashMap<String, String>) -> Option<Update> {
+ Some(Update {
+ name: non_empty(form, "name")?,
+ cost: parse::<i64>(form, "cost")?,
+ user_id: parse::<i64>(form, "user_id")?,
+ category_id: parse::<i64>(form, "category_id")?,
+ date: date(form, "date")?,
+ })
+}
diff --git a/src/validation/utils.rs b/src/validation/utils.rs
new file mode 100644
index 0000000..4bff40a
--- /dev/null
+++ b/src/validation/utils.rs
@@ -0,0 +1,54 @@
+use chrono::NaiveDate;
+use std::collections::HashMap;
+use std::str::FromStr;
+
+use crate::model::frequency::Frequency;
+
+pub fn non_empty(
+ form: &HashMap<String, String>,
+ field: &str,
+) -> Option<String> {
+ let s = form.get(field)?.trim();
+ if s.is_empty() {
+ None
+ } else {
+ Some(s.to_string())
+ }
+}
+
+pub fn parse<T: FromStr>(
+ form: &HashMap<String, String>,
+ field: &str,
+) -> Option<T> {
+ let s = form.get(field)?;
+ s.parse::<T>().ok()
+}
+
+pub fn date(form: &HashMap<String, String>, field: &str) -> Option<NaiveDate> {
+ let s = form.get(field)?;
+ NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()
+}
+
+pub fn frequency(
+ form: &HashMap<String, String>,
+ field: &str,
+) -> Option<Frequency> {
+ let s = form.get(field)?;
+ Frequency::from_str(s).ok()
+}
+
+pub fn color(form: &HashMap<String, String>, field: &str) -> Option<String> {
+ let s = form.get(field)?;
+ if s.len() == 7
+ && &s[0..1] == "#"
+ && s[1..]
+ .to_string()
+ .into_bytes()
+ .into_iter()
+ .all(|c| c.is_ascii_hexdigit())
+ {
+ Some(s.to_string())
+ } else {
+ None
+ }
+}