From f99513a8c85958fbcc3e62bbec54a6900ce868b1 Mon Sep 17 00:00:00 2001 From: didier amyot Date: Wed, 25 Jan 2023 12:50:16 -0500 Subject: [PATCH] I need to fucking work. --- Cargo.lock | 57 ++++++++++ Cargo.toml | 3 +- migrations/sql/create_tables_001.sql | 32 ++++-- src/db/_accounts.rs | 157 ++++++++++++++++++++++----- src/db/_test_utils.rs | 5 +- src/db/mod.rs | 1 + src/services/_transactions.rs | 28 +++-- 7 files changed, 230 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 08bbdaa..bc1b98c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -210,6 +210,7 @@ dependencies = [ "apache-avro", "clap", "clap-verbosity-flag", + "fraction", "hex", "home", "lazy_static", @@ -245,6 +246,16 @@ dependencies = [ "instant", ] +[[package]] +name = "fraction" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c37ba08fe22fba12a3ada3e479d09a8f7fac6081aa6f5c27f22b78639025031" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "generic-array" version = "0.14.6" @@ -396,6 +407,20 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +[[package]] +name = "num" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43db66d1170d347f9a065114077f7dccb00c1b9478c89384490a3425279a4606" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.3" @@ -407,6 +432,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e0d21255c828d6f128a1e41534206671e8c3ea0c62f32291e808dc82cff17d" +dependencies = [ + "num-traits", +] + [[package]] name = "num-integer" version = "0.1.45" @@ -417,6 +451,29 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d03e6c028c5dc5cac6e2dec0efda81fc887605bb3d884578bb6d6bf7514e252" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index b4d1610..7611f2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,8 @@ hex = "0.4.3" regex = "1.7.0" lazy_static = "1.4.0" tempfile = "3.3.0" +fraction = "0.13.0" [dependencies.apache-avro] version = "0.14.0" -features = ["zstandard"] \ No newline at end of file +features = ["zstandard"] diff --git a/migrations/sql/create_tables_001.sql b/migrations/sql/create_tables_001.sql index 401e0f3..d95cf2f 100644 --- a/migrations/sql/create_tables_001.sql +++ b/migrations/sql/create_tables_001.sql @@ -37,6 +37,7 @@ VALUES ('checking'), CREATE TABLE IF NOT EXISTS accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, + version INTEGER NOT NULL CHECK ( version >= 0 ), security INTEGER NOT NULL, account_type TEXT NOT NULL, @@ -48,6 +49,16 @@ CREATE TABLE IF NOT EXISTS accounts FOREIGN KEY (account_type) REFERENCES account_types (name) ); +CREATE TABLE IF NOT EXISTS accounts_history +( + account_id INTEGER NOT NULL, + account_version INTEGER NOT NULL, + amount INTEGER NOT NULL, + timestamp DATETIME NOT NULL, + FOREIGN KEY (account_id) REFERENCES accounts (id), + PRIMARY KEY (account_id, account_version) +); + CREATE TABLE IF NOT EXISTS account_user_permissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -60,18 +71,19 @@ CREATE TABLE IF NOT EXISTS account_user_permissions ); - CREATE TABLE IF NOT EXISTS cashed_cheques ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - drawer_user_id INTEGER NOT NULL, - drawer_account_id INTEGER NOT NULL, - cheque_number INTEGER NOT NULL, - amount INTEGER NOT NULL, - underlying_security INTEGER NOT NULL, - payee_user_id INTEGER NOT NULL, - payee_account_id INTEGER NOT NULL, - creation_time DATETIME DEFAULT CURRENT_TIMESTAMP, + id INTEGER PRIMARY KEY AUTOINCREMENT, + drawer_user_id INTEGER NOT NULL, + drawer_account_id INTEGER NOT NULL, + drawer_account_version INTEGER NOT NULL, + cheque_number INTEGER NOT NULL, + amount INTEGER NOT NULL, + underlying_security INTEGER NOT NULL, + payee_user_id INTEGER NOT NULL, + payee_account_id INTEGER NOT NULL, + payee_account_version INTEGER NOT NULL, + creation_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (underlying_security) REFERENCES securities (id), FOREIGN KEY (drawer_user_id) REFERENCES users (id), diff --git a/src/db/_accounts.rs b/src/db/_accounts.rs index f729035..d47166c 100644 --- a/src/db/_accounts.rs +++ b/src/db/_accounts.rs @@ -1,4 +1,5 @@ use rusqlite::{Connection, OptionalExtension, params, Result, Row, Transaction}; +use fraction::{Fraction}; use crate::db::_external_id::{AccountId, Id, UserId}; use crate::db::_securities::Security; @@ -51,7 +52,56 @@ impl Account { } } -pub fn list_accounts<'a>(conn: &Connection) -> Result> { +#[derive(Debug)] +struct AccountSnapshot { + amount: i64, + version: i64, + timestamp: i64, +} + +impl AccountSnapshot { + pub fn from_row(row: &Row) -> Result { + Ok(AccountSnapshot { + amount: row.get("amount")?, + version: row.get("account_version")?, + timestamp: row.get("timestamp")?, + }) + } +} + +struct AccountHistory { + id: i64, + security: Security, + account_type: AccountType, + snapshots: Vec, +} + +impl AccountHistory { + pub fn id(&self) -> AccountId { + AccountId::from_id(self.id) + } + + pub fn len(&self) -> usize { + self.snapshots.len() + } + + pub fn average_amount(&self) -> Fraction { + let num: i64 = self.snapshots.iter().map(|x| x.amount).sum(); + Fraction::from(num) / Fraction::from(self.len()) + } + + pub fn new(account: Account, snapshots: Vec) -> CustomResult { + Ok(AccountHistory { + id: account.id, + security: account.security, + account_type: account.account_type, + snapshots, + }) + } +} + + +pub fn list_accounts<'a>(conn: &Connection) -> CustomResult> { let q = "SELECT acc.id AS acc_id, acc.amount AS acc_amount, UNIXEPOCH(acc.timestamp) AS acc_timestamp, @@ -63,16 +113,17 @@ pub fn list_accounts<'a>(conn: &Connection) -> Result> { FROM accounts acc JOIN securities sec ON acc.security = sec.id ORDER BY acc.id ASC"; - let mut stmt = conn.prepare(q)?; + let mut stmt = conn.prepare(q).map_err(Error::SqlError)?; let ret: Vec = stmt - .query_map([], &Account::from_row)? + .query_map([], &Account::from_row) + .map_err(Error::SqlError)? .filter(Result::is_ok) .map(Result::unwrap) .collect(); return Ok(ret); } -pub fn get_account_by_id(tx: &Connection, id: &AccountId) -> Result { +pub fn get_account_by_id(tx: &Connection, id: &AccountId) -> CustomResult { let q = "SELECT acc.id AS acc_id, acc.amount AS acc_amount, UNIXEPOCH(acc.timestamp) AS acc_timestamp, @@ -84,43 +135,64 @@ pub fn get_account_by_id(tx: &Connection, id: &AccountId) -> Result { FROM accounts acc JOIN securities sec ON acc.security = sec.id WHERE acc.id = ?1"; - return tx.query_row(q, params![id], Account::from_row); + return tx.query_row(q, params![id], Account::from_row).map_err(Error::SqlError); } -fn create_account(tx: &Connection, sec: &SecurityId, user: &UserId, account_type: AccountType) -> Result { - let q = "INSERT INTO accounts(security, amount, timestamp, account_type) - VALUES (?1, 0, CURRENT_TIMESTAMP, ?2)"; +fn create_account(tx: &Connection, sec: &SecurityId, user: &UserId, account_type: AccountType) -> CustomResult { + let q = "INSERT INTO accounts(security, version, amount, timestamp, account_type) + VALUES (?1, 0, 0, CURRENT_TIMESTAMP, ?2)"; let account_type = match account_type { AccountType::Checking => "checking", AccountType::SecurityEmitter => "security_emitter", }; - tx.execute(q, (sec, account_type))?; + tx.execute(q, (sec, account_type)).map_err(Error::SqlError)?; let id = AccountId::from_id(tx.last_insert_rowid() as i64); let q = "INSERT INTO account_user_permissions(user_id, account_id) VALUES(?1, ?2)"; - tx.execute(q, params![user, id])?; + tx.execute(q, params![user, id]).map_err(Error::SqlError)?; + + update_account_history(tx, &id)?; return get_account_by_id(tx, &id); } -pub fn create_checking_account(tx: &Connection, sec: &SecurityId, user: &UserId) -> Result { +fn get_account_history(tx: &Connection, id: &AccountId) -> CustomResult { + let q = " + SELECT account_version, + amount, + UNIXEPOCH(timestamp) AS timestamp + FROM accounts_history + WHERE account_id = ?1 + ORDER BY account_version DESC"; + let mut stmt = tx.prepare(q).map_err(Error::SqlError)?; + let snapshots: Vec = stmt.query_map(params![id], AccountSnapshot::from_row) + .map_err(Error::SqlError)? + .filter(Result::is_ok) + .map(Result::unwrap) + .collect(); + + return AccountHistory::new(get_account_by_id(tx, id)?, snapshots); +} + +pub fn create_checking_account(tx: &Connection, sec: &SecurityId, user: &UserId) -> CustomResult { create_account(tx, sec, user, AccountType::Checking) } -pub fn create_security_emitter_account(tx: &Connection, sec: &SecurityId, user: &UserId) -> Result { +pub fn create_security_emitter_account(tx: &Connection, sec: &SecurityId, user: &UserId) -> CustomResult { create_account(tx, sec, user, AccountType::SecurityEmitter) } -pub fn is_owner(tx: &Connection, account_id: &AccountId, user_id: &UserId) -> Result { +pub fn is_owner(tx: &Connection, account_id: &AccountId, user_id: &UserId) -> CustomResult { let q = "SELECT id FROM account_user_permissions WHERE account_id = ?1 AND user_id = ?2"; let params = params![account_id, user_id]; let ite = |row: &Row| row.get::(0); - let option = tx.query_row(q, params, ite).optional()?; + let option = tx.query_row(q, params, ite).optional().map_err(Error::SqlError)?; Ok(option.is_some()) } pub struct Receipt { pub account_id: AccountId, + pub version: i64, pub timestamp: i64, pub amount: i64, } @@ -129,12 +201,27 @@ impl Receipt { pub fn from_row(row: &Row) -> Result { Ok(Receipt { account_id: AccountId::from_id(row.get("id")?), + version: row.get("version")?, amount: row.get("amount")?, timestamp: row.get("timestamp")?, }) } } +fn update_account_history(tx: &Connection, account_id: &AccountId) -> CustomResult<()> { + let q = " + INSERT INTO accounts_history + SELECT id AS account_id, + version AS account_version, + amount, + timestamp + FROM accounts + WHERE id = ?1 + "; + tx.execute(q, params![account_id]).map_err(Error::SqlError)?; + Ok(()) +} + pub fn add_to_account(tx: &Connection, account_id: &AccountId, amount: i64) -> CustomResult { @@ -143,12 +230,15 @@ pub fn add_to_account(tx: &Connection, WHEN 'security_emitter' THEN amount ELSE amount + ?1 END, + version = version + 1, timestamp = CURRENT_TIMESTAMP WHERE id = ?2 - RETURNING id, amount, UNIXEPOCH(CURRENT_TIMESTAMP) as timestamp + RETURNING id, version, amount, UNIXEPOCH(CURRENT_TIMESTAMP) as timestamp "; let params = params![amount, account_id]; - tx.query_row(q, params, Receipt::from_row).map_err(Error::SqlError) + let ret = tx.query_row(q, params, Receipt::from_row).map_err(Error::SqlError); + update_account_history(tx, account_id)?; + return ret; } #[cfg(test)] @@ -171,7 +261,7 @@ mod test_accounts { return (conn, users, securities); } - fn create_all_accounts(tx: &Transaction, users: &Vec, secs: &Vec) -> Result> { + fn create_all_accounts(tx: &Transaction, users: &Vec, secs: &Vec) -> CustomResult> { let mut ret: Vec = Vec::new(); for sec in secs.iter() { for user in users.iter() { @@ -182,12 +272,12 @@ mod test_accounts { } #[test] - fn test_simplest_checking_account() -> Result<()> { + fn test_simplest_checking_account() -> CustomResult<()> { let (mut conn, users, secs) = setup(); let user = users.get(0).unwrap(); let not_owner = users.get(1).unwrap(); let sec = secs.get(0).unwrap(); - let tx = conn.transaction()?; + let tx = conn.transaction().unwrap(); let acc = create_checking_account(&tx, &sec.id(), &user.id())?; assert_eq!(acc.account_type, AccountType::Checking); @@ -195,16 +285,18 @@ mod test_accounts { assert!(is_owner(&tx, &acc.id(), &user.id())?); assert!(!is_owner(&tx, &acc.id(), ¬_owner.id())?); + + assert_eq!(get_account_history(&tx, &acc.id())?.len(), 1); Ok(()) } #[test] - fn test_simplest_security_emitter_account() -> Result<()> { + fn test_simplest_security_emitter_account() -> CustomResult<()> { let (mut conn, users, secs) = setup(); let user = users.get(0).unwrap(); let not_owner = users.get(1).unwrap(); let sec = secs.get(0).unwrap(); - let tx = conn.transaction()?; + let tx = conn.transaction().unwrap(); let acc = create_security_emitter_account(&tx, &sec.id(), &user.id())?; assert_eq!(acc.account_type, AccountType::SecurityEmitter); @@ -216,30 +308,30 @@ mod test_accounts { } #[test] - fn test_happy_path() -> Result<()> { + fn test_happy_path() -> CustomResult<()> { let (mut conn, users, secs) = setup(); - let tx = conn.transaction()?; + let tx = conn.transaction().unwrap(); create_all_accounts(&tx, &users, &secs)?; - tx.commit()?; - let accs = list_accounts(&conn)?; + tx.commit().unwrap(); + let accs = list_accounts(&conn).unwrap(); assert_eq!(4, accs.len()); assert_eq!(1, accs.get(0).unwrap().id); return Ok(()); } #[test] - fn test_happy_path_no_commit() -> Result<()> { + fn test_happy_path_no_commit() -> CustomResult<()> { let (mut conn, users, secs) = setup(); - create_all_accounts(&conn.transaction()?, &users, &secs)?; + create_all_accounts(&conn.transaction().unwrap(), &users, &secs)?; let accs = list_accounts(&conn)?; assert_eq!(0, accs.len()); return Ok(()); } #[test] - fn test_is_owner() -> Result<()> { + fn test_is_owner() -> CustomResult<()> { let (mut conn, users, secs) = setup(); - let tx = &conn.transaction()?; + let tx = &conn.transaction().unwrap(); let _accounts = create_all_accounts(tx, &users, &secs)?; let user_1 = users.get(0).unwrap(); let user_2 = users.get(1).unwrap(); @@ -269,6 +361,8 @@ mod test_accounts { let acc = get_account_by_id(&tx2, &acc.id()).unwrap(); assert_eq!(acc.timestamp, receipt.timestamp); assert_eq!(acc.amount, 33); + assert_eq!(get_account_history(&tx2, &acc.id())?.average_amount(), + Fraction::from(0 + 33) / Fraction::from(2)); Ok(()) } @@ -279,10 +373,12 @@ mod test_accounts { let tx = conn.transaction().unwrap(); let sec = secs.get(0).unwrap().id(); let user = users.get(0).unwrap().id(); - let acc = create_security_emitter_account(&tx, &sec, &user).unwrap(); + let acc = create_security_emitter_account(&tx, &sec, &user)?; + let receipt = add_to_account(&tx, &acc.id(), 33)?; assert_eq!(receipt.account_id, acc.id()); assert_eq!(receipt.amount, 0); + assert_eq!(receipt.version, 1); tx.commit().unwrap(); let tx2 = conn.transaction().unwrap(); @@ -290,6 +386,7 @@ mod test_accounts { assert_eq!(acc.timestamp, receipt.timestamp); assert_eq!(acc.amount, 0); + assert_eq!(get_account_history(&tx2, &acc.id())?.len(), 2); Ok(()) } } diff --git a/src/db/_test_utils.rs b/src/db/_test_utils.rs index 88133f0..fc94605 100644 --- a/src/db/_test_utils.rs +++ b/src/db/_test_utils.rs @@ -6,6 +6,7 @@ use crate::db::_external_id::UserId; use crate::db::_securities::{NewSecurity, Security, SecurityType}; use crate::db::_users::User; use crate::db::accounts::Account; +use crate::util::CustomResult; pub fn mock_currency_1() -> NewSecurity { return NewSecurity { @@ -41,8 +42,8 @@ pub fn setup_users_and_securities(tx: &Transaction) -> Result<(Vec, Vec Result<(Vec, Vec, Vec)> { - let (users, securities) = setup_users_and_securities(tx)?; +pub fn setup_basic_environment(tx: &Transaction) -> CustomResult<(Vec, Vec, Vec)> { + let (users, securities) = setup_users_and_securities(tx).unwrap(); let acc1 = create_checking_account(tx, &securities.get(0).unwrap().id(), diff --git a/src/db/mod.rs b/src/db/mod.rs index cf98e4c..4481c6c 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -20,6 +20,7 @@ pub mod users { pub mod accounts { pub use super::_accounts::{Account, + Receipt, create_checking_account, create_security_emitter_account, is_owner, diff --git a/src/services/_transactions.rs b/src/services/_transactions.rs index 75b5cf9..5962981 100644 --- a/src/services/_transactions.rs +++ b/src/services/_transactions.rs @@ -8,35 +8,40 @@ use crate::util::*; fn _move_securities(tx: &Savepoint, cheque: &Cheque, - account_id: &AccountId) -> CustomResult<()> { + account_id: &AccountId) -> CustomResult<(accounts::Receipt, accounts::Receipt)> { let drawer_receipt = accounts::add_to_account(tx, &cheque.drawer_account_id, -cheque.amount)?; - let _payee_receipt = accounts::add_to_account(tx, account_id, cheque.amount)?; + let payee_receipt = accounts::add_to_account(tx, account_id, cheque.amount)?; if drawer_receipt.amount < 0 { let m = format!("Drawer of {:?} does not have sufficient amount in account.", cheque); return Err(Error::InvalidAmount(m)); } - Ok(()) + Ok((drawer_receipt, payee_receipt)) } fn _add_cheque_to_cashed_cheques_log(tx: &Connection, cheque: &Cheque, - payee_account_id: &AccountId) -> Result<(), Error> { + drawer_receipt: &accounts::Receipt, + payee_receipt: &accounts::Receipt) -> Result<(), Error> { let q = " INSERT INTO cashed_cheques( drawer_user_id, drawer_account_id, + drawer_account_version, payee_user_id, payee_account_id, + payee_account_version, cheque_number, amount, underlying_security ) - VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7) + VALUES(?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) "; let params = params![cheque.drawer_user_id, cheque.drawer_account_id, + drawer_receipt.version, cheque.payee_user_id, - payee_account_id, + payee_receipt.account_id, + payee_receipt.version, cheque.cheque_number, cheque.amount, cheque.security_id]; @@ -55,16 +60,19 @@ fn cash_cheque_unsafe(savepoint: &Savepoint, if cheque.amount < 0 { return Err(Error::InvalidAmount(format!("The cheque {:?} has a negative amount.", cheque))); } - if !accounts::is_owner(&savepoint, &cheque.drawer_account_id, &cheque.drawer_user_id).map_err(Error::SqlError)? { + if !accounts::is_owner(&savepoint, &cheque.drawer_account_id, &cheque.drawer_user_id)? { return Err(Error::InvalidDrawer(format!("The drawer is the security owner: {:?}", cheque))); } - if !accounts::is_owner(&savepoint, account_id, &cheque.payee_user_id).map_err(Error::SqlError)? { + if !accounts::is_owner(&savepoint, account_id, &cheque.payee_user_id)? { return Err(Error::InvalidPayee(format!("Trying to cash the cheque {:?} in \ an account {:?} that is not owned by the payee.", cheque, account_id))); } - _move_securities(&savepoint, cheque, account_id)?; - _add_cheque_to_cashed_cheques_log(&savepoint, cheque, account_id)?; + let (drawer_receipt, payee_receipt) = _move_securities(&savepoint, cheque, account_id)?; + _add_cheque_to_cashed_cheques_log(&savepoint, + cheque, + &drawer_receipt, + &payee_receipt)?; Ok(()) }