From 917aa6625892aec8f504c76d0d6c8a1a91c4e005 Mon Sep 17 00:00:00 2001 From: Vaibhav Date: Wed, 15 Jan 2025 20:47:21 +0530 Subject: [PATCH] feat: overdraft prevention (#1253) * feat: overdraft prevention * test: overdraft prevention * fix: entry type names in initiate withdrawal * fix: create idempotent deposit velocity control * chore: idempotent limit creation --- Cargo.lock | 27 +++++---- core/deposit/Cargo.toml | 1 + core/deposit/src/ledger/error.rs | 2 + core/deposit/src/ledger/mod.rs | 59 +++++++++++++++++++ .../src/ledger/templates/cancel_withdraw.rs | 1 - .../src/ledger/templates/initiate_withdraw.rs | 4 +- core/deposit/src/ledger/velocity/mod.rs | 3 + .../ledger/velocity/overdraft_prevention.rs | 41 +++++++++++++ core/deposit/src/lib.rs | 5 +- core/deposit/tests/withdraw.rs | 12 +++- 10 files changed, 137 insertions(+), 18 deletions(-) create mode 100644 core/deposit/src/ledger/velocity/mod.rs create mode 100644 core/deposit/src/ledger/velocity/overdraft_prevention.rs diff --git a/Cargo.lock b/Cargo.lock index fa1c6e314..412ff8b44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -690,7 +690,7 @@ checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" [[package]] name = "cala-cel-interpreter" version = "0.3.3-dev" -source = "git+https://github.com/galoymoney/cala.git?branch=main#d5783528fa1711883111af334c3ad199ed0d6107" +source = "git+https://github.com/galoymoney/cala.git?branch=main#0a16445d5aece3fe7357cd4dfcf16801740defbd" dependencies = [ "cala-cel-parser", "chrono", @@ -705,7 +705,7 @@ dependencies = [ [[package]] name = "cala-cel-parser" version = "0.3.3-dev" -source = "git+https://github.com/galoymoney/cala.git?branch=main#d5783528fa1711883111af334c3ad199ed0d6107" +source = "git+https://github.com/galoymoney/cala.git?branch=main#0a16445d5aece3fe7357cd4dfcf16801740defbd" dependencies = [ "lalrpop", "lalrpop-util", @@ -714,7 +714,7 @@ dependencies = [ [[package]] name = "cala-ledger" version = "0.3.3-dev" -source = "git+https://github.com/galoymoney/cala.git?branch=main#d5783528fa1711883111af334c3ad199ed0d6107" +source = "git+https://github.com/galoymoney/cala.git?branch=main#0a16445d5aece3fe7357cd4dfcf16801740defbd" dependencies = [ "cached 0.51.4", "cala-cel-interpreter", @@ -746,7 +746,7 @@ dependencies = [ [[package]] name = "cala-ledger-core-types" version = "0.3.3-dev" -source = "git+https://github.com/galoymoney/cala.git?branch=main#d5783528fa1711883111af334c3ad199ed0d6107" +source = "git+https://github.com/galoymoney/cala.git?branch=main#0a16445d5aece3fe7357cd4dfcf16801740defbd" dependencies = [ "cala-cel-interpreter", "chrono", @@ -763,7 +763,7 @@ dependencies = [ [[package]] name = "cala-tracing" version = "0.3.3-dev" -source = "git+https://github.com/galoymoney/cala.git?branch=main#d5783528fa1711883111af334c3ad199ed0d6107" +source = "git+https://github.com/galoymoney/cala.git?branch=main#0a16445d5aece3fe7357cd4dfcf16801740defbd" dependencies = [ "anyhow", "axum-extra", @@ -1197,6 +1197,7 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tracing", + "uuid", ] [[package]] @@ -1369,7 +1370,7 @@ dependencies = [ [[package]] name = "es-entity" version = "0.3.3-dev" -source = "git+https://github.com/galoymoney/cala.git?branch=main#d5783528fa1711883111af334c3ad199ed0d6107" +source = "git+https://github.com/galoymoney/cala.git?branch=main#0a16445d5aece3fe7357cd4dfcf16801740defbd" dependencies = [ "async-graphql", "async-trait", @@ -1388,7 +1389,7 @@ dependencies = [ [[package]] name = "es-entity-macros" version = "0.3.3-dev" -source = "git+https://github.com/galoymoney/cala.git?branch=main#d5783528fa1711883111af334c3ad199ed0d6107" +source = "git+https://github.com/galoymoney/cala.git?branch=main#0a16445d5aece3fe7357cd4dfcf16801740defbd" dependencies = [ "convert_case", "darling", @@ -3223,7 +3224,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0f3e5beed80eb580c68e2c600937ac2c4eedabdfd5ef1e5b7ea4f3fba84497b" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.13.0", "log", "multimap", @@ -3282,7 +3283,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a669d5acbe719010c6f62a64e6d7d88fdedc1fe46e419747949ecb6312e9b14" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "prost", "prost-build", "prost-types", @@ -3406,7 +3407,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4154,7 +4155,7 @@ dependencies = [ [[package]] name = "sim-time" version = "0.3.3-dev" -source = "git+https://github.com/galoymoney/cala.git?branch=main#d5783528fa1711883111af334c3ad199ed0d6107" +source = "git+https://github.com/galoymoney/cala.git?branch=main#0a16445d5aece3fe7357cd4dfcf16801740defbd" dependencies = [ "chrono", "serde", @@ -4653,7 +4654,7 @@ dependencies = [ "fastrand", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5520,7 +5521,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/core/deposit/Cargo.toml b/core/deposit/Cargo.toml index f38f7ecfb..eba6a7b09 100644 --- a/core/deposit/Cargo.toml +++ b/core/deposit/Cargo.toml @@ -28,6 +28,7 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +uuid = { workspace = true } derive_builder = { workspace = true } rust_decimal = { workspace = true } async-trait = { workspace = true } diff --git a/core/deposit/src/ledger/error.rs b/core/deposit/src/ledger/error.rs index fc411d070..d4d336bb8 100644 --- a/core/deposit/src/ledger/error.rs +++ b/core/deposit/src/ledger/error.rs @@ -16,6 +16,8 @@ pub enum DepositLedgerError { CalaBalance(#[from] cala_ledger::balance::error::BalanceError), #[error("DepositLedgerError - CalaTransactionError: {0}")] CalaTransaction(#[from] cala_ledger::transaction::error::TransactionError), + #[error("DepositLedgerError - CalaVelocityError: {0}")] + CalaVelocity(#[from] cala_ledger::velocity::error::VelocityError), #[error("DepositLedgerError - ConversionError: {0}")] ConversionError(#[from] core_money::ConversionError), #[error("DepositLedgerError - MissingTxMetadata")] diff --git a/core/deposit/src/ledger/mod.rs b/core/deposit/src/ledger/mod.rs index 5405ea664..039efe550 100644 --- a/core/deposit/src/ledger/mod.rs +++ b/core/deposit/src/ledger/mod.rs @@ -1,8 +1,11 @@ pub mod error; mod templates; +mod velocity; use cala_ledger::{ account::{error::AccountError, *}, + tx_template::Params, + velocity::{NewVelocityControl, VelocityControlId}, CalaLedger, Currency, DebitOrCredit, JournalId, TransactionId, }; @@ -10,12 +13,16 @@ use crate::{primitives::UsdCents, DepositAccountBalance}; use error::*; +pub const DEPOSITS_VELOCITY_CONTROL_ID: uuid::Uuid = + uuid::uuid!("00000000-0000-0000-0000-000000000001"); + #[derive(Clone)] pub struct DepositLedger { cala: CalaLedger, journal_id: JournalId, deposit_omnibus_account_id: AccountId, usd: Currency, + deposit_control_id: VelocityControlId, } impl DepositLedger { @@ -32,10 +39,25 @@ impl DepositLedger { templates::CancelWithdraw::init(cala).await?; templates::ConfirmWithdraw::init(cala).await?; + let overdraft_prevention_id = velocity::OverdraftPrevention::init(cala).await?; + + let deposit_control_id = Self::create_deposit_control(cala).await?; + + match cala + .velocities() + .add_limit_to_control(deposit_control_id, overdraft_prevention_id) + .await + { + Ok(_) + | Err(cala_ledger::velocity::error::VelocityError::LimitAlreadyAddedToControl) => {} + Err(e) => return Err(e.into()), + } + Ok(Self { cala: cala.clone(), journal_id, deposit_omnibus_account_id, + deposit_control_id, usd: "USD".parse().expect("Could not parse 'USD'"), }) } @@ -187,4 +209,41 @@ impl DepositLedger { Err(e) => Err(e.into()), } } + + pub async fn create_deposit_control( + cala: &CalaLedger, + ) -> Result { + let control = NewVelocityControl::builder() + .id(DEPOSITS_VELOCITY_CONTROL_ID) + .name("Deposit Control") + .description("Velocity Control for Deposits") + .build() + .expect("build control"); + + match cala.velocities().create_control(control).await { + Err(cala_ledger::velocity::error::VelocityError::ControlIdAlreadyExists) => { + Ok(DEPOSITS_VELOCITY_CONTROL_ID.into()) + } + Err(e) => Err(e.into()), + Ok(control) => Ok(control.id()), + } + } + + pub async fn add_deposit_control_to_account( + &self, + op: &mut cala_ledger::LedgerOperation<'_>, + account_id: impl Into, + ) -> Result<(), DepositLedgerError> { + self.cala + .velocities() + .attach_control_to_account_in_op( + op, + self.deposit_control_id, + account_id.into(), + Params::default(), + ) + .await?; + + Ok(()) + } } diff --git a/core/deposit/src/ledger/templates/cancel_withdraw.rs b/core/deposit/src/ledger/templates/cancel_withdraw.rs index 84fb477ea..bc8e8f433 100644 --- a/core/deposit/src/ledger/templates/cancel_withdraw.rs +++ b/core/deposit/src/ledger/templates/cancel_withdraw.rs @@ -90,7 +90,6 @@ impl CancelWithdraw { .build() .expect("Couldn't build TxInput"); let entries = vec![ - // check in graphql/cancel-withdraw the entry type NewTxTemplateEntry::builder() .entry_type("'CANCEL_WITHDRAW_PENDING_CR'") .currency("params.currency") diff --git a/core/deposit/src/ledger/templates/initiate_withdraw.rs b/core/deposit/src/ledger/templates/initiate_withdraw.rs index 9e2b24e2d..9edf0bfd6 100644 --- a/core/deposit/src/ledger/templates/initiate_withdraw.rs +++ b/core/deposit/src/ledger/templates/initiate_withdraw.rs @@ -92,7 +92,7 @@ impl InitiateWithdraw { .expect("Couldn't build TxInput"); let entries = vec![ NewTxTemplateEntry::builder() - .entry_type("'INITIATE_WITHDRAW_SETTLED_DR'") + .entry_type("'INITIATE_WITHDRAW_SETTLED_CR'") .currency("params.currency") .account_id("params.deposit_omnibus_account_id") .direction("CREDIT") @@ -101,7 +101,7 @@ impl InitiateWithdraw { .build() .expect("Couldn't build entry"), NewTxTemplateEntry::builder() - .entry_type("'INITIATE_WITHDRAW_SETTLED_CR'") + .entry_type("'INITIATE_WITHDRAW_SETTLED_DR'") .currency("params.currency") .account_id("params.credit_account_id") .direction("DEBIT") diff --git a/core/deposit/src/ledger/velocity/mod.rs b/core/deposit/src/ledger/velocity/mod.rs new file mode 100644 index 000000000..2ccf18d7e --- /dev/null +++ b/core/deposit/src/ledger/velocity/mod.rs @@ -0,0 +1,3 @@ +mod overdraft_prevention; + +pub use overdraft_prevention::*; diff --git a/core/deposit/src/ledger/velocity/overdraft_prevention.rs b/core/deposit/src/ledger/velocity/overdraft_prevention.rs new file mode 100644 index 000000000..eeb85c004 --- /dev/null +++ b/core/deposit/src/ledger/velocity/overdraft_prevention.rs @@ -0,0 +1,41 @@ +use tracing::instrument; + +use cala_ledger::{velocity::*, *}; + +use crate::ledger::error::*; + +pub struct OverdraftPrevention; + +const OVERDRAFT_PREVENTION_ID: uuid::Uuid = uuid::uuid!("00000000-0000-0000-0000-000000000001"); + +impl OverdraftPrevention { + #[instrument(name = "ledger.overdraft_prevention.init", skip_all)] + pub async fn init(ledger: &CalaLedger) -> Result { + let limit = NewVelocityLimit::builder() + .id(OVERDRAFT_PREVENTION_ID) + .name("Overdraft Prevention") + .description("Prevent overdraft on withdrawals") + .window(vec![]) + .limit( + NewLimit::builder() + .balance(vec![NewBalanceLimit::builder() + .layer("SETTLED") + .amount("decimal('0.0')") + .enforcement_direction("DEBIT") + .build() + .expect("balance limit")]) + .build() + .expect("limit"), + ) + .build() + .expect("velocity limit"); + + match ledger.velocities().create_limit(limit).await { + Err(cala_ledger::velocity::error::VelocityError::LimitIdAlreadyExists) => { + Ok(OVERDRAFT_PREVENTION_ID.into()) + } + Err(e) => Err(e.into()), + Ok(limit) => Ok(limit.id()), + } + } +} diff --git a/core/deposit/src/lib.rs b/core/deposit/src/lib.rs index 2df8f1948..11a5dfddd 100644 --- a/core/deposit/src/lib.rs +++ b/core/deposit/src/lib.rs @@ -173,6 +173,10 @@ where ) .await?; + self.ledger + .add_deposit_control_to_account(&mut op, account_id) + .await?; + op.commit().await?; Ok(account) @@ -255,7 +259,6 @@ where .create_in_op(&mut op, new_withdrawal) .await?; - // TODO: add approval process and check for balance self.ledger .initiate_withdrawal(op, withdrawal_id, amount, deposit_account_id) .await?; diff --git a/core/deposit/tests/withdraw.rs b/core/deposit/tests/withdraw.rs index 76b9ed854..c7955887d 100644 --- a/core/deposit/tests/withdraw.rs +++ b/core/deposit/tests/withdraw.rs @@ -10,7 +10,7 @@ use deposit::*; use helpers::{action, event, object}; #[tokio::test] -async fn cancel_withdrawal() -> anyhow::Result<()> { +async fn overdraw_and_cancel_withdrawal() -> anyhow::Result<()> { use rand::Rng; let pool = helpers::init_pool().await?; @@ -86,6 +86,16 @@ async fn cancel_withdrawal() -> anyhow::Result<()> { .record_deposit(&DummySubject, account.id, deposit_amount, None) .await?; + // overdraw + let withdrawal_amount = UsdCents::try_from_usd(dec!(5000000)).unwrap(); + let withdrawal = deposit + .initiate_withdrawal(&DummySubject, account.id, withdrawal_amount, None) + .await; + assert!(matches!( + withdrawal, + Err(deposit::error::CoreDepositError::DepositLedgerError(_)) + )); + let withdrawal_amount = UsdCents::try_from_usd(dec!(500000)).unwrap(); let withdrawal = deposit