diff --git a/Cargo.lock b/Cargo.lock index 11023bc..ae1e667 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,7 @@ version = "0.1.0" dependencies = [ "chrono", "futures", + "md-5", "regex", "serde", "sqlx", diff --git a/Cargo.toml b/Cargo.toml index e84a74d..5d8f5b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ chrono = { version = "0.4", features = ["serde"] } sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "chrono"] } futures = "0.3" regex = "1" +md-5 = "0.10" [dev-dependencies] tokio = { version = "1", features = ["full"] } diff --git a/build.rs b/build.rs index 491bd99..b75cdaa 100644 --- a/build.rs +++ b/build.rs @@ -10,5 +10,5 @@ fn main() { )) .status() .unwrap(); - println!("cargo:rerun-if-changed=templates/*") + println!("cargo:rerun-if-changed=templates/") } diff --git a/migrations/20231110161954_base.sql b/migrations/20231110161954_base.sql index 6bc1c3f..d3c55d3 100644 --- a/migrations/20231110161954_base.sql +++ b/migrations/20231110161954_base.sql @@ -44,8 +44,10 @@ CREATE TABLE IF NOT EXISTS transactions ( transaction_timestamp DATETIME, category INTEGER, amount INTEGER, + hash TEXT, FOREIGN KEY (account) REFERENCES accounts(account_id), FOREIGN KEY (category) REFERENCES categories(category_id) ); CREATE INDEX idx_transactions_ts ON transactions(account, transaction_timestamp); +CREATE INDEX idx_transactions_hash ON transactions(hash); diff --git a/src/models/transaction.rs b/src/models/transaction.rs index e159ec1..c80394c 100644 --- a/src/models/transaction.rs +++ b/src/models/transaction.rs @@ -2,8 +2,16 @@ use chrono::prelude::*; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, Result, Sqlite, SqlitePool}; +use md5::{Digest, Md5}; + use crate::models::rules::Rule; +pub enum TxConflictResolutionMode { + Nothing, + Error, + Duplicate, +} + #[derive(FromRow, Serialize, Deserialize, Debug)] pub struct Transaction { transaction_id: i32, @@ -12,6 +20,8 @@ pub struct Transaction { transaction_timestamp: DateTime, category: Option, amount: i32, + #[serde(default, skip_serializing)] + hash: Option, } impl Transaction { @@ -22,21 +32,47 @@ impl Transaction { ts: &DateTime, category: Option, amount: i32, + on_conflict: TxConflictResolutionMode, ) -> Result { - let res = sqlx::query(concat!( + let hash = Transaction::get_tx_hash(account, &desc, &ts, amount); + let tx_db = match sqlx::query("SELECT * FROM transactions WHERE hash=? LIMIT 1") + .bind(&hash) + .fetch_one(pool) + .await + { + Ok(row) => Some(Transaction::from_row(&row)?), + Err(sqlx::Error::RowNotFound) => None, + Err(e) => { + return Err(e); + } + }; + + if let Some(tx) = tx_db { + match on_conflict { + TxConflictResolutionMode::Nothing => { + return Ok(tx); + } + TxConflictResolutionMode::Error => { + return Err(sqlx::Error::RowNotFound); + } + _ => {} + } + } + + sqlx::query(concat!( "INSERT INTO transactions(", - "account, description, transaction_timestamp, category, amount", - ") VALUES (?,?,?,?,?) RETURNING *" + "account, description, transaction_timestamp, category, amount, hash", + ") VALUES (?,?,?,?,?,?) RETURNING *" )) .bind(account) .bind(desc) .bind(ts) .bind(category) .bind(amount) + .bind(hash) .fetch_one(pool) - .await?; - - Transaction::from_row(&res) + .await + .map(|x| Transaction::from_row(&x).unwrap()) } pub async fn list( @@ -165,19 +201,42 @@ impl Transaction { } pub async fn set_description(&mut self, pool: &SqlitePool, desc: &str) -> Result<()> { - sqlx::query("UPDATE transactions SET description=? WHERE transaction_id=?") + sqlx::query("UPDATE transactions SET description=?, hash=? WHERE transaction_id=?") .bind(desc) + .bind(Transaction::get_tx_hash( + self.account, + desc, + &self.transaction_timestamp, + self.amount, + )) .bind(self.transaction_id) .execute(pool) .await?; self.description = desc.to_string(); Ok(()) } + + pub fn get_tx_hash(account: i32, description: &str, ts: &DateTime, amount: i32) -> String { + let mut hasher = Md5::new(); + hasher.update(format!( + "{}/{}/{}/{}", + account, + description, + ts.to_rfc3339(), + amount + )); + let mut out = String::new(); + out.reserve(32); + for byte in hasher.finalize().iter() { + out.push_str(&format!("{:02x?}", byte)); + } + out + } } #[cfg(test)] mod tests { - use super::Transaction; + use super::{Transaction, TxConflictResolutionMode}; use crate::models::{account::Account, users::User}; use sqlx::SqlitePool; @@ -206,6 +265,7 @@ mod tests { &chrono::Utc::now(), None, 100, + TxConflictResolutionMode::Nothing, ) .await .unwrap(); diff --git a/static/csv.js b/static/csv.js new file mode 100644 index 0000000..161db03 --- /dev/null +++ b/static/csv.js @@ -0,0 +1,47 @@ +function parse(text) { + let state = 0; + let idx = 0; + let current = ''; + let curr_row = []; + let rows = []; + + while(idx < text.length) { + switch (text[idx]) { + case '\\': + current += text[idx++]; + break; + + case '"': + if(current.length == 0) { + while(text.length > idx && text[++idx] != '"') + current += text[idx]; + } + break; + + case ',': + if (/^\d+(\.\d+)?$/.test(current)) { + let asnum = parseFloat(current); + curr_row.push(asnum); + } else { + curr_row.push(current); + } + current = ''; + break; + + case '\n': + curr_row.push(current); + current = ''; + rows.push(curr_row); + curr_row = []; + break; + + default: + current += text[idx]; + break; + } + idx++; + } + return rows; +} + +export default parse; diff --git a/static/styles.css b/static/styles.css index 6da84ff..24e1af5 100644 --- a/static/styles.css +++ b/static/styles.css @@ -544,6 +544,40 @@ video { --tw-backdrop-sepia: ; } +.container { + width: 100%; +} + +@media (min-width: 640px) { + .container { + max-width: 640px; + } +} + +@media (min-width: 768px) { + .container { + max-width: 768px; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 1024px; + } +} + +@media (min-width: 1280px) { + .container { + max-width: 1280px; + } +} + +@media (min-width: 1536px) { + .container { + max-width: 1536px; + } +} + .block { display: block; } @@ -560,10 +594,6 @@ video { height: 100%; } -.flex-grow { - flex-grow: 1; -} - .grow { flex-grow: 1; } @@ -572,6 +602,10 @@ video { flex-direction: column; } +.overflow-auto { + overflow: auto; +} + .border { border-width: 1px; } @@ -581,10 +615,24 @@ video { background-color: rgb(214 211 209 / var(--tw-bg-opacity)); } +.p-2 { + padding: 0.5rem; +} + .p-4 { padding: 1rem; } +.text-lg { + font-size: 1.125rem; + line-height: 1.75rem; +} + +.hover\:bg-stone-200:hover { + --tw-bg-opacity: 1; + background-color: rgb(231 229 228 / var(--tw-bg-opacity)); +} + .hover\:bg-stone-400:hover { --tw-bg-opacity: 1; background-color: rgb(168 162 158 / var(--tw-bg-opacity)); diff --git a/templates/accounts.html b/templates/accounts.html index 421de76..7e429ae 100644 --- a/templates/accounts.html +++ b/templates/accounts.html @@ -1,14 +1,19 @@ {% extends "base.html" %} {% block title %}Account {{account.account_name}}{% endblock title %} {% block body %} -
{{account.account_name}}
+
+ {{account.account_name}} +
+ + +
+
- - - - + + + + {% for tx in transactions %} @@ -19,7 +24,7 @@ {% endfor %}
DescripciónFechaCantidadCategoríaDescriptionDateAmountCategory
-

Cargados {{n_txs}} movimientos

+

Loaded {{n_txs}} transactions

{% endblock body %} diff --git a/templates/accounts_add_txs.html b/templates/accounts_add_txs.html new file mode 100644 index 0000000..805610d --- /dev/null +++ b/templates/accounts_add_txs.html @@ -0,0 +1,176 @@ +{% extends "base.html" %} +{% block title %}Account {{account.account_name}}{% endblock title %} +{% block body %} +
+
+ Add transactions to {{account.account_name}} +
+
+
+
+
+
+
+
+
+
+ + + +{% endblock body %} + diff --git a/templates/base.html b/templates/base.html index b00b79d..07c127a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -13,8 +13,7 @@ } .sidebar { - max-width: 12rem; - flex-grow: 1; + width: 12rem; } .sidebar a { @@ -26,10 +25,11 @@
-
+
{% block body %} {% endblock body %}
diff --git a/templates/categories_list.html b/templates/categories_list.html new file mode 100644 index 0000000..b603fa7 --- /dev/null +++ b/templates/categories_list.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block title %}Categories{% endblock title %} +{% block body %} +
+ New +
+ + + + + + + + + + {% for category in categories %} + + + + + + {% endfor %} + +
IdNameDescription
{{category.category_id}}{{category.name}}{{category.description}}
+{% endblock body %} diff --git a/templates/categories_new.html b/templates/categories_new.html new file mode 100644 index 0000000..5903d33 --- /dev/null +++ b/templates/categories_new.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block title %}Create category{% endblock title %} +{% block body %} +
+ + + +
+ +{% endblock body %} diff --git a/templates/index.html b/templates/index.html index a84ca5e..89ed83f 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,7 +1,10 @@ {% extends "base.html" %} {% block title %}Index{% endblock title %} {% block body %} -{% for account in accounts %} - {{account.account_name}}({{account.account_id}}) -{% endfor %} +

Accounts

+
+ {% for account in accounts %} + {{account.account_name}} + {% endfor %} +
{% endblock body %} diff --git a/webserver/src/main.rs b/webserver/src/main.rs index 993eb65..3cb05e4 100644 --- a/webserver/src/main.rs +++ b/webserver/src/main.rs @@ -33,13 +33,26 @@ async fn main() { "/", Router::new() .route("/", get(routes::ui::index)) - .route("/accounts/id/:id", get(routes::ui::account)) + .route("/accounts/id/:id", get(routes::ui::account::list)) + .route( + "/accounts/id/:id/transactions/add", + get(routes::ui::account::add_transactions_view), + ) + .route( + "/accounts/id/:id/transactions/add", + post(routes::ui::account::add_transactions_action), + ) .route("/rules", get(routes::ui::rules::list)) .route("/rules/new", get(routes::ui::rules::new_view)) .route("/rules/new", post(routes::ui::rules::new_action)) + .route("/categories", get(routes::ui::categories::list)) + .route("/categories/new", get(routes::ui::categories::new_view)) + .route("/categories/new", post(routes::ui::categories::new_action)) .nest( "/static", - Router::new().route("/styles.css", get(routes::static_routes::styles)), + Router::new() + .route("/styles.css", get(routes::static_routes::styles)) + .route("/csv.js", get(routes::static_routes::csv)), ), ) .nest( diff --git a/webserver/src/routes/api/transactions.rs b/webserver/src/routes/api/transactions.rs index 133f594..fb60857 100644 --- a/webserver/src/routes/api/transactions.rs +++ b/webserver/src/routes/api/transactions.rs @@ -6,7 +6,7 @@ use hyper::StatusCode; use serde::Deserialize; use sqlx::SqlitePool; -use accounters::models::Transaction; +use accounters::models::{transaction::TxConflictResolutionMode, Transaction}; #[derive(Deserialize)] pub struct TransactionContent { @@ -14,6 +14,8 @@ pub struct TransactionContent { timestamp: DateTime, category: Option, amount: i32, + #[serde(default)] + error_on_conflict: Option, } pub async fn create( @@ -21,6 +23,11 @@ pub async fn create( Path(account): Path, Json(txcnt): Json, ) -> (StatusCode, String) { + let error_on_conflict = if txcnt.error_on_conflict.is_some_and(|x| x) { + TxConflictResolutionMode::Error + } else { + TxConflictResolutionMode::Nothing + }; match Transaction::new( db.as_ref(), account, @@ -28,6 +35,7 @@ pub async fn create( &txcnt.timestamp, None, txcnt.amount, + error_on_conflict, ) .await { diff --git a/webserver/src/routes/static_routes.rs b/webserver/src/routes/static_routes.rs index df51d72..091c990 100644 --- a/webserver/src/routes/static_routes.rs +++ b/webserver/src/routes/static_routes.rs @@ -10,3 +10,11 @@ pub async fn styles() -> impl IntoResponse { fs::read_to_string("static/styles.css").unwrap(), ) } + +pub async fn csv() -> impl IntoResponse { + ( + StatusCode::OK, + [(CONTENT_TYPE, "application/javascript")], + fs::read_to_string("static/csv.js").unwrap(), + ) +} diff --git a/webserver/src/routes/ui.rs b/webserver/src/routes/ui.rs index f32912c..1771bac 100644 --- a/webserver/src/routes/ui.rs +++ b/webserver/src/routes/ui.rs @@ -1,17 +1,15 @@ use std::sync::Arc; -use axum::{ - extract::{Path, Query, State}, - response::IntoResponse, -}; +use axum::{extract::State, response::IntoResponse}; use hyper::{header::CONTENT_TYPE, StatusCode}; -use serde::Deserialize; use sqlx::SqlitePool; use tera::{Context, Tera}; use crate::users::UserToken; -use accounters::models::{Account, Transaction}; +use accounters::models::Account; +pub mod account; +pub mod categories; pub mod rules; pub async fn index( @@ -37,66 +35,3 @@ pub async fn index( ), } } - -#[derive(Deserialize)] -pub struct AccountViewParams { - movements: Option, -} - -pub async fn account( - State(db): State>, - State(tmpls): State>, - uid: UserToken, - Path(account_id): Path, - Query(AccountViewParams { movements }): Query, -) -> impl IntoResponse { - let mut ctx = Context::new(); - - let account = match Account::get_by_id(db.as_ref(), account_id).await { - Ok(a) => a, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - [(CONTENT_TYPE, "text/plain")], - format!("{e}"), - ); - } - }; - - if account.get_user() != uid.user_id { - return ( - StatusCode::UNAUTHORIZED, - [(CONTENT_TYPE, "text/plain")], - String::from("You cannot access this resource"), - ); - } - - let txs = match Transaction::list( - db.as_ref(), - account.get_id(), - movements.unwrap_or(10), - 0, - false, - ) - .await - { - Ok(t) => t, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - [(CONTENT_TYPE, "text/plain")], - format!("Error at loading transactions: {e}"), - ); - } - }; - - ctx.insert("account", &account); - ctx.insert("transactions", &txs); - ctx.insert("n_txs", &txs.len()); - - ( - StatusCode::OK, - [(CONTENT_TYPE, "text/html;charset=utf-8")], - tmpls.render("accounts.html", &ctx).unwrap(), - ) -} diff --git a/webserver/src/routes/ui/account.rs b/webserver/src/routes/ui/account.rs new file mode 100644 index 0000000..2194f59 --- /dev/null +++ b/webserver/src/routes/ui/account.rs @@ -0,0 +1,147 @@ +use std::sync::Arc; + +use axum::{ + extract::{Path, Query, State}, + response::IntoResponse, + Json, +}; +use chrono::{DateTime, Utc}; +use hyper::{header::CONTENT_TYPE, StatusCode}; +use serde::Deserialize; +use sqlx::SqlitePool; +use tera::{Context, Tera}; + +use crate::users::UserToken; +use accounters::models::{transaction::TxConflictResolutionMode, Account, Transaction}; + +#[derive(Deserialize)] +pub struct AccountViewParams { + movements: Option, +} + +pub async fn list( + State(db): State>, + State(tmpls): State>, + uid: UserToken, + Path(account_id): Path, + Query(AccountViewParams { movements }): Query, +) -> impl IntoResponse { + let mut ctx = Context::new(); + + let account = match Account::get_by_id(db.as_ref(), account_id).await { + Ok(a) => a, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + [(CONTENT_TYPE, "text/plain")], + format!("{e}"), + ); + } + }; + + if account.get_user() != uid.user_id { + return ( + StatusCode::UNAUTHORIZED, + [(CONTENT_TYPE, "text/plain")], + String::from("You cannot access this resource"), + ); + } + + let txs = match Transaction::list( + db.as_ref(), + account.get_id(), + movements.unwrap_or(10), + 0, + false, + ) + .await + { + Ok(t) => t, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + [(CONTENT_TYPE, "text/plain")], + format!("Error at loading transactions: {e}"), + ); + } + }; + + ctx.insert("account", &account); + ctx.insert("transactions", &txs); + ctx.insert("n_txs", &txs.len()); + + ( + StatusCode::OK, + [(CONTENT_TYPE, "text/html;charset=utf-8")], + tmpls.render("accounts.html", &ctx).unwrap(), + ) +} + +pub async fn add_transactions_view( + State(db): State>, + State(tmpls): State>, + uid: UserToken, + Path(account_id): Path, +) -> impl IntoResponse { + let mut ctxt = Context::new(); + ctxt.insert("account_id", &account_id); + + let account = match Account::get_by_id(db.as_ref(), account_id).await { + Ok(a) => a, + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + [(CONTENT_TYPE, "text/plain")], + format!("{e:?}"), + ); + } + }; + + if account.get_user() != uid.user_id { + return ( + StatusCode::UNAUTHORIZED, + [(CONTENT_TYPE, "text/plain")], + String::from("You cannot access this resource"), + ); + } + + ctxt.insert("account", &account); + + ( + StatusCode::OK, + [(CONTENT_TYPE, "text/html;charset=utf-8")], + tmpls.render("accounts_add_txs.html", &ctxt).unwrap(), + ) +} + +#[derive(Deserialize, Debug)] +pub struct CreateTransactionRequest { + date: DateTime, + description: String, + amount: f32, +} + +pub async fn add_transactions_action( + State(db): State>, + uid: UserToken, + Path(account_id): Path, + Json(body): Json>, +) -> impl IntoResponse { + // TODO missing user id check + for tx in body.iter() { + if let Err(e) = Transaction::new( + db.as_ref(), + account_id, + &tx.description, + &tx.date, + None, + (tx.amount * 100.0).round() as i32, + TxConflictResolutionMode::Nothing, + ) + .await + { + return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:?}")); + } + } + (StatusCode::OK, String::new()) +} diff --git a/webserver/src/routes/ui/categories.rs b/webserver/src/routes/ui/categories.rs new file mode 100644 index 0000000..7d6eb30 --- /dev/null +++ b/webserver/src/routes/ui/categories.rs @@ -0,0 +1,72 @@ +use std::sync::Arc; + +use accounters::models::categories::Category; +use axum::{ + extract::{Form, State}, + response::IntoResponse, +}; +use hyper::{header::CONTENT_TYPE, StatusCode}; +use serde::Deserialize; +use sqlx::SqlitePool; +use tera::{Context, Tera}; + +use crate::users::UserToken; + +pub async fn list( + State(db): State>, + State(tmpl): State>, + uid: UserToken, +) -> impl IntoResponse { + match Category::list(db.as_ref()).await { + Ok(categories) => { + let mut ctx = Context::new(); + ctx.insert("categories", &categories); + ( + StatusCode::OK, + [(CONTENT_TYPE, "text/html;charset=utf-8")], + tmpl.render("categories_list.html", &ctx).unwrap(), + ) + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + [(CONTENT_TYPE, "text/plain;charset=utf-8")], + format!("{e}"), + ), + } +} + +pub async fn new_view(State(tmpl): State>, uid: UserToken) -> impl IntoResponse { + ( + StatusCode::OK, + [(CONTENT_TYPE, "text/html;charset=utf-8")], + tmpl.render("categories_new.html", &Context::new()).unwrap(), + ) +} + +#[derive(Deserialize)] +pub struct NewRuleParams { + pub name: String, + pub description: String, +} + +pub async fn new_action( + State(db): State>, + State(tmpls): State>, + uid: UserToken, + Form(params): Form, +) -> impl IntoResponse { + match Category::new(db.as_ref(), ¶ms.name, ¶ms.description).await { + Ok(_) => ( + StatusCode::OK, + [(CONTENT_TYPE, "text/html;charset=utf-8")], + tmpls + .render("rules_new_success.html", &Context::new()) + .unwrap(), + ), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + [(CONTENT_TYPE, "text/plain;charset=utf-8")], + format!("{e}"), + ), + } +}