From 6df9c9d7a902c643757c49e3a87aaeb2f3f38f20 Mon Sep 17 00:00:00 2001 From: Bruno BELANYI Date: Tue, 23 Aug 2022 13:49:07 +0200 Subject: [PATCH] ledger: process deposits and withdrawals --- Cargo.lock | 17 +++++++ Cargo.toml | 3 ++ src/error.rs | 7 +++ src/ledger.rs | 126 +++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 152 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 92ce977..1c0d17c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,6 +123,22 @@ dependencies = [ "syn", ] +[[package]] +name = "dissimilar" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c97b9233581d84b8e1e689cdd3a47b6f69770084fc246e86a7f78b0d9c1d4a5" + +[[package]] +name = "expect-test" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d4661aca38d826eb7c72fe128e4238220616de4c0cc00db7bfc38e2e1364dd3" +dependencies = [ + "dissimilar", + "once_cell", +] + [[package]] name = "fnv" version = "1.0.7" @@ -293,6 +309,7 @@ name = "processor" version = "0.1.0" dependencies = [ "csv", + "expect-test", "fpdec", "serde", "serde_with", diff --git a/Cargo.toml b/Cargo.toml index 2653096..d655c30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,3 +11,6 @@ fpdec = "0.5" serde = { version = "1.0", features = ["derive"] } serde_with = "2.0" thiserror = "1.0" + +[dev-dependencies] +expect-test = "1.4" diff --git a/src/error.rs b/src/error.rs index 59594be..717177d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,6 +1,13 @@ //! Error types for this crate. use thiserror::Error; +/// Any kind of error that can happen when processing a [crate::Transaction] in a [crate::Ledger]. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Error)] +pub enum LedgerError { + #[error("not enough funds available to run transaction")] + NotEnoughFunds, +} + /// Any kind of error that can happen when deserializing a [crate::Transaction] value. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Error)] pub enum ParseError { diff --git a/src/ledger.rs b/src/ledger.rs index b7d0d2c..3cfc8b2 100644 --- a/src/ledger.rs +++ b/src/ledger.rs @@ -1,6 +1,6 @@ //! A ledger implementation to track all transactions. -use crate::{ClientId, TxAmount, TxId}; +use crate::{ClientId, Deposit, LedgerError, Transaction, TxAmount, TxId, Withdrawal}; /// A ledger of accounts, which processes transactions one at a time. #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -16,6 +16,8 @@ pub struct AccountInfo { locked: bool, } +type LedgerResult = Result; + impl Ledger { pub fn new() -> Self { Default::default() @@ -37,6 +39,27 @@ impl Ledger { } Ok(()) } + + pub fn process(&mut self, tx: Transaction) -> LedgerResult<()> { + match tx { + Transaction::Deposit(Deposit { client, tx, amount }) => self.delta(client, tx, amount), + Transaction::Withdrawal(Withdrawal { client, tx, amount }) => { + self.delta(client, tx, -amount) + } + _ => todo!(), + } + } + + fn delta(&mut self, client: ClientId, tx: TxId, delta: TxAmount) -> LedgerResult<()> { + let account = self.accounts.entry(client).or_default(); + let new_balance = account.available_funds() + delta; + if new_balance < TxAmount::ZERO { + return Err(LedgerError::NotEnoughFunds); + } + account.available_funds = new_balance; + self.reversible_transactions.insert(tx, (client, delta)); + Ok(()) + } } impl AccountInfo { @@ -60,3 +83,104 @@ impl AccountInfo { self.available_funds + self.held_funds } } + +#[cfg(test)] +mod test { + use super::*; + use expect_test::{expect, Expect}; + + macro_rules! inline_csv { + ($line:literal) => { + $line + }; + ($line:literal, $($lines:literal),+ $(,)?) => { + concat!($line, "\n", inline_csv!($($lines),+)) + }; + } + + fn process_transactions(input: &str) -> Result { + let mut ledger = Ledger::new(); + for tx in Transaction::configured_csv_reader_builder() + .from_reader(input.as_bytes()) + .into_deserialize() + { + ledger.process(tx.unwrap())? + } + Ok(ledger) + } + + fn check_ledger(ledger: &Ledger, expect: Expect) { + let mut writer = csv::Writer::from_writer(vec![]); + ledger.dump_csv(&mut writer).unwrap(); + let actual = String::from_utf8(writer.into_inner().unwrap()).unwrap(); + expect.assert_eq(&actual); + } + + #[test] + fn deposit_single_account() { + let ledger = process_transactions(inline_csv!( + "type, client, tx, amount", + "deposit, 1, 1, 1.0", + "deposit, 1, 2, 2.0", + )) + .unwrap(); + check_ledger( + &ledger, + expect![[r#" + client,available,held,total,locked + 1,3.0,0,3.0,false + "#]], + ); + } + + #[test] + fn deposit_multiple_accounts() { + let ledger = process_transactions(inline_csv!( + "type, client, tx, amount", + "deposit, 1, 1, 1.0", + "deposit, 2, 2, 1.0", + "deposit, 1, 3, 2.0", + )) + .unwrap(); + check_ledger( + &ledger, + expect![[r#" + client,available,held,total,locked + 1,3.0,0,3.0,false + 2,1.0,0,1.0,false + "#]], + ); + } + + #[test] + fn deposit_and_withdrawal() { + let ledger = process_transactions(inline_csv!( + "type, client, tx, amount", + "deposit, 1, 1, 1.0", + "deposit, 2, 2, 1.0", + "deposit, 1, 3, 2.0", + "withdrawal, 1, 4, 1.5", + "withdrawal, 2, 5, 1.0", + )) + .unwrap(); + check_ledger( + &ledger, + expect![[r#" + client,available,held,total,locked + 1,1.5,0,1.5,false + 2,0.0,0,0.0,false + "#]], + ); + } + + #[test] + fn deposit_and_withdrawal_not_enough_funds() { + let error = process_transactions(inline_csv!( + "type, client, tx, amount", + "deposit, 2, 2, 1.0", + "withdrawal, 2, 5, 3.0", + )) + .unwrap_err(); + assert_eq!(error, LedgerError::NotEnoughFunds); + } +}